"""Tests for the quiet-hours deferred-dispatch pipeline. Covers the four behaviours that distinguish the new feature from the legacy "drop on quiet hours" code path: 1. ``quiet_hours_status`` returns the correct UTC end datetime, including overnight windows that wrap past midnight. 2. ``evaluate_event_gate`` distinguishes ``QUIET_HOURS`` (deferrable) from ``EVENT_TYPE_DISABLED`` (drop forever). 3. ``serialize_event`` / ``deserialize_event`` round-trip without losing asset metadata. 4. ``defer_event`` coalesces ``assets_added`` + ``assets_removed`` of the same IDs for the same link+collection — the cancellation case that motivated the whole feature. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any import pytest from sqlmodel import SQLModel, select from sqlmodel.ext.asyncio.session import AsyncSession from sqlalchemy.ext.asyncio import create_async_engine from notify_bridge_core.models.events import EventType, ServiceEvent from notify_bridge_core.models.media import MediaAsset, MediaType from notify_bridge_core.providers.base import ServiceProviderType # --------------------------------------------------------------------------- # Quiet-hours math # --------------------------------------------------------------------------- def test_quiet_hours_status_inside_normal_window(monkeypatch: pytest.MonkeyPatch) -> None: from notify_bridge_server.services import dispatch_helpers as dh # Pretend it's 13:00 UTC inside a 12:00-14:00 window. class _FixedDatetime(datetime): @classmethod def now(cls, tz=None): return datetime(2026, 5, 12, 13, 0, tzinfo=timezone.utc) monkeypatch.setattr(dh, "datetime", _FixedDatetime) end_at = dh.quiet_hours_status("12:00", "14:00", "UTC") assert end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.utc) def test_quiet_hours_status_start_equals_end_returns_none() -> None: """``00:00-00:00`` is ambiguous (single instant vs always-on); treat as no window. Code-review feedback: without this guard, the overnight-window branch would interpret it as "always quiet" and silently defer every notification all day. The conservative read is that the user misconfigured and we should behave as if quiet hours were off. """ from notify_bridge_server.services import dispatch_helpers as dh assert dh.quiet_hours_status("00:00", "00:00", "UTC") is None assert dh.quiet_hours_status("13:30", "13:30", "UTC") is None def test_quiet_hours_status_outside_window_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: from notify_bridge_server.services import dispatch_helpers as dh class _FixedDatetime(datetime): @classmethod def now(cls, tz=None): return datetime(2026, 5, 12, 15, 0, tzinfo=timezone.utc) monkeypatch.setattr(dh, "datetime", _FixedDatetime) assert dh.quiet_hours_status("12:00", "14:00", "UTC") is None def test_quiet_hours_status_overnight_window_post_midnight(monkeypatch: pytest.MonkeyPatch) -> None: """22:00-06:00 window, current time 03:00 → window ends today at 06:00.""" from notify_bridge_server.services import dispatch_helpers as dh class _FixedDatetime(datetime): @classmethod def now(cls, tz=None): return datetime(2026, 5, 12, 3, 0, tzinfo=timezone.utc) monkeypatch.setattr(dh, "datetime", _FixedDatetime) end_at = dh.quiet_hours_status("22:00", "06:00", "UTC") assert end_at == datetime(2026, 5, 12, 6, 0, tzinfo=timezone.utc) def test_quiet_hours_status_overnight_window_pre_midnight(monkeypatch: pytest.MonkeyPatch) -> None: """22:00-06:00 window, current time 23:30 → window ends tomorrow at 06:00.""" from notify_bridge_server.services import dispatch_helpers as dh class _FixedDatetime(datetime): @classmethod def now(cls, tz=None): return datetime(2026, 5, 12, 23, 30, tzinfo=timezone.utc) monkeypatch.setattr(dh, "datetime", _FixedDatetime) end_at = dh.quiet_hours_status("22:00", "06:00", "UTC") assert end_at == datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc) # --------------------------------------------------------------------------- # Gate enum / outcome # --------------------------------------------------------------------------- def _make_event( event_type: EventType = EventType.ASSETS_ADDED, *, added_assets: list[MediaAsset] | None = None, ) -> ServiceEvent: return ServiceEvent( event_type=event_type, provider_type=ServiceProviderType.IMMICH, provider_name="test-immich", collection_id="col-1", collection_name="Album A", timestamp=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc), added_assets=added_assets or [], added_count=len(added_assets or []), ) def _make_asset(asset_id: str, *, filename: str | None = None) -> MediaAsset: return MediaAsset( id=asset_id, type=MediaType.IMAGE, filename=filename or f"{asset_id}.jpg", created_at=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc), ) class _FakeTrackingConfig: """Minimal stand-in for TrackingConfig — only the fields the gate reads.""" def __init__( self, *, quiet_hours_enabled: bool = False, quiet_hours_start: str | None = None, quiet_hours_end: str | None = None, track_assets_added: bool = True, ) -> None: self.quiet_hours_enabled = quiet_hours_enabled self.quiet_hours_start = quiet_hours_start self.quiet_hours_end = quiet_hours_end self.track_assets_added = track_assets_added # The gate's flag map reads every track_* attribute; set the rest to # True so it doesn't accidentally block on an unrelated event type. for attr in ( "track_assets_removed", "track_collection_renamed", "track_collection_deleted", "track_sharing_changed", "track_push", "track_issue_opened", "track_issue_closed", "track_issue_commented", "track_pr_opened", "track_pr_closed", "track_pr_merged", "track_pr_commented", "track_release_published", "track_card_created", "track_card_updated", "track_card_moved", "track_card_deleted", "track_card_commented", "track_comment_updated", "track_board_created", "track_board_updated", "track_board_deleted", "track_list_created", "track_list_updated", "track_list_deleted", "track_attachment_created", "track_card_label_added", "track_task_completed", "track_scheduled_message", "track_webhook_received", "track_ups_online", "track_ups_on_battery", "track_ups_low_battery", "track_ups_battery_restored", "track_ups_comms_lost", "track_ups_comms_restored", "track_ups_replace_battery", "track_ups_overload", ): setattr(self, attr, True) def test_gate_quiet_hours_wins_over_event_type_flag(monkeypatch: pytest.MonkeyPatch) -> None: from notify_bridge_server.services import dispatch_helpers as dh class _FixedDatetime(datetime): @classmethod def now(cls, tz=None): return datetime(2026, 5, 12, 13, 0, tzinfo=timezone.utc) monkeypatch.setattr(dh, "datetime", _FixedDatetime) tc = _FakeTrackingConfig( quiet_hours_enabled=True, quiet_hours_start="12:00", quiet_hours_end="14:00", # Even with the event-type flag flipped off, quiet hours should be # the reported reason — it's the "louder" gate. The downstream defer # path treats this as a deferral candidate; flipping the order would # silently drop deferrable events when both gates are closed. track_assets_added=False, ) outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC") assert outcome.reason is dh.GateReason.QUIET_HOURS assert outcome.quiet_hours_end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.utc) def test_gate_event_type_disabled_when_quiet_hours_off() -> None: from notify_bridge_server.services import dispatch_helpers as dh tc = _FakeTrackingConfig(quiet_hours_enabled=False, track_assets_added=False) outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC") assert outcome.reason is dh.GateReason.EVENT_TYPE_DISABLED assert outcome.quiet_hours_end_at is None # --------------------------------------------------------------------------- # Event payload round-trip # --------------------------------------------------------------------------- def test_serialize_deserialize_roundtrips_assets_and_extras() -> None: from notify_bridge_server.services import deferred_dispatch as dd asset = _make_asset("a1") asset.extra = {"city": "Minsk", "is_favorite": True, "rating": 5} event = _make_event(added_assets=[asset]) event.extra = {"people": ["Alice"]} payload = dd.serialize_event(event) restored = dd.deserialize_event(payload) assert restored.event_type is EventType.ASSETS_ADDED assert restored.provider_type is ServiceProviderType.IMMICH assert restored.collection_id == "col-1" assert len(restored.added_assets) == 1 assert restored.added_assets[0].id == "a1" assert restored.added_assets[0].extra["city"] == "Minsk" assert restored.extra["people"] == ["Alice"] assert restored.timestamp == event.timestamp # --------------------------------------------------------------------------- # Coalescing — the add-then-remove cancellation that motivated the design # --------------------------------------------------------------------------- @pytest.fixture async def empty_session(): """In-memory SQLite session for coalescing tests — no fixtures, just a clean DB.""" # Importing models here registers them on SQLModel.metadata. We rely on # ``DeferredDispatch`` being declared so create_all picks it up. from notify_bridge_server.database import models # noqa: F401 — side effect engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) async with AsyncSession(engine) as session: yield session await engine.dispose() @pytest.mark.asyncio async def test_add_then_remove_same_assets_cancels_pending(empty_session: AsyncSession) -> None: """User adds {A, B}, then removes {A, B} — both pending rows should disappear. Before this feature this scenario would either spam two late notifications ("added" then "removed") or silently drop both. The cancellation path is the win that justified the coalescing module. """ from notify_bridge_server.services import deferred_dispatch as dd from notify_bridge_server.database.models import DeferredDispatch fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc) add_event = _make_event( EventType.ASSETS_ADDED, added_assets=[_make_asset("A"), _make_asset("B")], ) result = await dd.defer_event( empty_session, event=add_event, user_id=1, tracker_id=1, link_id=1, event_log_id=100, fire_at=fire_at, ) await empty_session.commit() assert result == "inserted" remove_event = ServiceEvent( event_type=EventType.ASSETS_REMOVED, provider_type=ServiceProviderType.IMMICH, provider_name="test-immich", collection_id="col-1", collection_name="Album A", timestamp=datetime(2026, 5, 12, 12, 5, tzinfo=timezone.utc), removed_asset_ids=["A", "B"], removed_count=2, ) result = await dd.defer_event( empty_session, event=remove_event, user_id=1, tracker_id=1, link_id=1, event_log_id=101, fire_at=fire_at, ) await empty_session.commit() pending = (await empty_session.exec( select(DeferredDispatch).where(DeferredDispatch.status == "pending") )).all() assert pending == [], "add-then-remove of same IDs should leave the queue empty" @pytest.mark.asyncio async def test_add_then_partial_remove_keeps_remainder(empty_session: AsyncSession) -> None: """User adds {A, B, C}, then removes {B} — pending row should contain {A, C}.""" from notify_bridge_server.services import deferred_dispatch as dd from notify_bridge_server.database.models import DeferredDispatch fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc) await dd.defer_event( empty_session, event=_make_event(EventType.ASSETS_ADDED, added_assets=[ _make_asset("A"), _make_asset("B"), _make_asset("C"), ]), user_id=1, tracker_id=1, link_id=1, event_log_id=100, fire_at=fire_at, ) await empty_session.commit() remove_event = ServiceEvent( event_type=EventType.ASSETS_REMOVED, provider_type=ServiceProviderType.IMMICH, provider_name="test-immich", collection_id="col-1", collection_name="Album A", timestamp=datetime(2026, 5, 12, 12, 5, tzinfo=timezone.utc), removed_asset_ids=["B"], removed_count=1, ) await dd.defer_event( empty_session, event=remove_event, user_id=1, tracker_id=1, link_id=1, event_log_id=101, fire_at=fire_at, ) await empty_session.commit() rows = (await empty_session.exec( select(DeferredDispatch).where(DeferredDispatch.status == "pending") )).all() # Only the assets_added row survives (B subtracted). No assets_removed # row because B was just added — its removal is a wash. assert len(rows) == 1 assert rows[0].event_type == "assets_added" remaining_ids = sorted(a["id"] for a in rows[0].event_payload["added_assets"]) assert remaining_ids == ["A", "C"] @pytest.mark.asyncio async def test_add_then_add_unions_assets(empty_session: AsyncSession) -> None: """Two consecutive assets_added events should merge into one pending row.""" from notify_bridge_server.services import deferred_dispatch as dd from notify_bridge_server.database.models import DeferredDispatch fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc) await dd.defer_event( empty_session, event=_make_event(EventType.ASSETS_ADDED, added_assets=[_make_asset("A")]), user_id=1, tracker_id=1, link_id=1, event_log_id=100, fire_at=fire_at, ) await empty_session.commit() await dd.defer_event( empty_session, event=_make_event(EventType.ASSETS_ADDED, added_assets=[ _make_asset("B"), _make_asset("C"), ]), user_id=1, tracker_id=1, link_id=1, event_log_id=101, fire_at=fire_at, ) await empty_session.commit() rows = (await empty_session.exec( select(DeferredDispatch).where(DeferredDispatch.status == "pending") )).all() assert len(rows) == 1 merged_ids = sorted(a["id"] for a in rows[0].event_payload["added_assets"]) assert merged_ids == ["A", "B", "C"] @pytest.mark.asyncio async def test_non_asset_event_is_not_coalesced(empty_session: AsyncSession) -> None: """Two push events for the same repo should both be queued — historical facts.""" from notify_bridge_server.services import deferred_dispatch as dd from notify_bridge_server.database.models import DeferredDispatch fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc) for i in range(2): push_event = ServiceEvent( event_type=EventType.PUSH, provider_type=ServiceProviderType.GITEA, provider_name="test-gitea", collection_id="repo-1", collection_name="my/repo", timestamp=datetime(2026, 5, 12, 12, i, tzinfo=timezone.utc), extra={"commit_sha": f"sha{i}"}, ) await dd.defer_event( empty_session, event=push_event, user_id=1, tracker_id=1, link_id=1, event_log_id=100 + i, fire_at=fire_at, ) await empty_session.commit() rows = (await empty_session.exec( select(DeferredDispatch).where(DeferredDispatch.status == "pending") )).all() # Both rows survive — pushes don't cancel one another. assert len(rows) == 2 @pytest.mark.asyncio async def test_scheduled_message_is_non_deferrable(empty_session: AsyncSession) -> None: """``scheduled_message`` is wall-clock — defer_event should refuse to enqueue.""" from notify_bridge_server.services import deferred_dispatch as dd from notify_bridge_server.database.models import DeferredDispatch sched_event = ServiceEvent( event_type=EventType.SCHEDULED_MESSAGE, provider_type=ServiceProviderType.SCHEDULER, provider_name="sched", collection_id="", collection_name="", timestamp=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc), ) result = await dd.defer_event( empty_session, event=sched_event, user_id=1, tracker_id=1, link_id=1, event_log_id=100, fire_at=datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc), ) assert result == "non_deferrable" await empty_session.commit() rows = (await empty_session.exec(select(DeferredDispatch))).all() assert rows == []