feat(immich): multi-time-point scheduling for scheduled/periodic/memory
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.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""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
|
||||
)
|
||||
Reference in New Issue
Block a user