ba199f24bd
- 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.
432 lines
17 KiB
Python
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 == []
|