Files
notify-bridge/packages/server/tests/test_deferred_dispatch.py
T
alexei.dolgolyov ba199f24bd feat: deferred dispatch, release-check provider, settings polish
- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
2026-05-12 02:58:07 +03:00

432 lines
17 KiB
Python

"""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 == []