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.
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
"""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 == []
|
||||
Reference in New Issue
Block a user