8065e6effa
Replace the single comma-separated text box with an add/remove list of native HH:MM pickers for the Immich scheduled-assets, periodic-summary, and memory slots. The backend already stored comma-separated *_times and scheduled one cron job per time; this makes entering several times per day discoverable and hardens the read/write path. Backend: - services/time_list.py: normalize_time_list (validate / dedup / sort / cap at 24, raising TimeListError) + lenient parse_hhmm_list; the scheduler now uses the shared parser (drops its private copy). - tracking_configs API normalizes *_times on every write (422 on bad input) and rejects enabling a slot whose times list is empty. - scheduler warns when an enabled slot has zero or dropped fire times, restoring the observability lost with the old per-call warning. Frontend: - TimeListEditor.svelte: add/remove native time rows, dedupe + sort on emit, per-day cap, collapses on-screen duplicates, aria-labelled rows; syncs from the value prop only on external changes (untrack guard) so keyboard entry isn't clobbered mid-edit. - Descriptor-driven save guard: an enabled feature section must have at least one time. - i18n (en/ru) keys; refreshed help text; removed dead invalidTimeList. Tests: time_list normalization/parsing (incl. non-ASCII/odd shapes) and the enabled-implies-times validation.
115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
"""Tests for the tracking-config write-time guards.
|
|
|
|
Covers the cross-field rule that an *enabled* Immich dispatch slot
|
|
(periodic / scheduled / memory) must carry at least one fire time — otherwise
|
|
the config saves but the scheduler creates zero cron jobs and the slot is
|
|
silently dead.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from notify_bridge_server.api.tracking_configs import (
|
|
_normalize_time_fields,
|
|
_validate_enabled_have_times,
|
|
)
|
|
from notify_bridge_server.database.models import TrackingConfig
|
|
|
|
|
|
class TestNormalizeTimeFields:
|
|
def test_normalizes_each_field_in_place(self):
|
|
values = {
|
|
"periodic_times": "18:30, 09:00",
|
|
"scheduled_times": "9:0",
|
|
"memory_times": "",
|
|
}
|
|
_normalize_time_fields(values)
|
|
assert values["periodic_times"] == "09:00,18:30"
|
|
assert values["scheduled_times"] == "09:00"
|
|
assert values["memory_times"] == ""
|
|
|
|
def test_ignores_absent_fields(self):
|
|
values = {"name": "x"}
|
|
_normalize_time_fields(values) # no KeyError, no-op
|
|
assert values == {"name": "x"}
|
|
|
|
def test_malformed_raises_422(self):
|
|
with pytest.raises(HTTPException) as exc:
|
|
_normalize_time_fields({"scheduled_times": "25:00"})
|
|
assert exc.value.status_code == 422
|
|
assert "scheduled_times" in str(exc.value.detail)
|
|
|
|
|
|
class TestValidateEnabledHaveTimes:
|
|
# --- create path (existing=None) ---
|
|
def test_create_enabled_with_times_ok(self):
|
|
_validate_enabled_have_times(
|
|
{"scheduled_enabled": True, "scheduled_times": "09:00"}, None
|
|
)
|
|
|
|
def test_create_enabled_without_times_raises(self):
|
|
with pytest.raises(HTTPException) as exc:
|
|
_validate_enabled_have_times(
|
|
{"scheduled_enabled": True, "scheduled_times": ""}, None
|
|
)
|
|
assert exc.value.status_code == 422
|
|
assert "scheduled_times" in str(exc.value.detail)
|
|
|
|
def test_create_disabled_without_times_ok(self):
|
|
_validate_enabled_have_times(
|
|
{"memory_enabled": False, "memory_times": ""}, None
|
|
)
|
|
|
|
def test_all_three_kinds_checked(self):
|
|
for kind in ("periodic", "scheduled", "memory"):
|
|
with pytest.raises(HTTPException):
|
|
_validate_enabled_have_times(
|
|
{f"{kind}_enabled": True, f"{kind}_times": ""}, None
|
|
)
|
|
|
|
# --- update path (effective state = values merged over existing) ---
|
|
def test_update_enable_without_providing_times_uses_existing_empty(self):
|
|
existing = TrackingConfig(provider_type="immich", name="t", scheduled_times="")
|
|
with pytest.raises(HTTPException):
|
|
_validate_enabled_have_times({"scheduled_enabled": True}, existing)
|
|
|
|
def test_update_enable_with_existing_times_ok(self):
|
|
existing = TrackingConfig(
|
|
provider_type="immich", name="t", scheduled_times="09:00"
|
|
)
|
|
_validate_enabled_have_times({"scheduled_enabled": True}, existing)
|
|
|
|
def test_update_clear_times_while_enabled_in_existing_raises(self):
|
|
existing = TrackingConfig(
|
|
provider_type="immich",
|
|
name="t",
|
|
scheduled_enabled=True,
|
|
scheduled_times="09:00",
|
|
)
|
|
with pytest.raises(HTTPException):
|
|
_validate_enabled_have_times({"scheduled_times": ""}, existing)
|
|
|
|
def test_update_unrelated_field_does_not_trigger_check(self):
|
|
# A pre-existing enabled-but-empty config must not block edits that don't
|
|
# touch the slot's enabled flag or times (only touched kinds are checked).
|
|
existing = TrackingConfig(
|
|
provider_type="immich",
|
|
name="t",
|
|
scheduled_enabled=True,
|
|
scheduled_times="",
|
|
)
|
|
_validate_enabled_have_times({"name": "renamed"}, existing)
|
|
|
|
def test_update_disabling_slot_clears_requirement(self):
|
|
existing = TrackingConfig(
|
|
provider_type="immich",
|
|
name="t",
|
|
scheduled_enabled=True,
|
|
scheduled_times="09:00",
|
|
)
|
|
_validate_enabled_have_times(
|
|
{"scheduled_enabled": False, "scheduled_times": ""}, existing
|
|
)
|