Files
notify-bridge/packages/server/tests/test_deferred_dispatch.py
T
alexei.dolgolyov 66f152ef2c
Release / test-backend (push) Failing after 8s
Release / release (push) Has been skipped
fix(tests): green pytest gate for v0.8.1
Four root causes blocked the CI test gate; all fixed minimally:

1. test_release_provider._allow_private_urls used setenv +
   importlib.reload(ssrf_mod). The reload permanently rebound
   _ALLOW_PRIVATE=True in the module; monkeypatch.setenv undid the
   env var on teardown but the module attribute stayed True for the
   rest of the session, masking every test_ssrf*/test_ssrf_hardening
   case (16 failures). Switched to monkeypatch.setattr on the module
   attribute directly — restored cleanly on teardown.

2. _FakeResponse in test_release_provider lacked the content_length
   attribute and a top-level read() method that the new size-cap
   guards in gitea.py consult before parsing (5 failures).

3. test_gate_quiet_hours_wins_over_event_type_flag was asserting the
   pre-refactor gate order. evaluate_event_gate now intentionally
   reports EVENT_TYPE_DISABLED before QUIET_HOURS so deferrable
   events with the event-type flag off get dropped immediately
   instead of being deferred and then silently discarded at drain
   time. Renamed the test and inverted the expectation.

4. resolve_version() returned 0.0.0+unknown in CI because
   pip-wheel-built hatchling distributions ended up with METADATA
   missing the Version field — importlib.metadata returned None.
   Added __version__ = "0.8.1" to notify_bridge_server/__init__.py
   as a third (always-available) candidate; resolve_version() now
   picks the max of (installed, package, source).
2026-05-16 18:25:51 +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_event_type_disabled_wins_over_quiet_hours(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",
# When BOTH gates close, event_type_disabled wins — otherwise the
# event would defer through quiet hours and be silently dropped at
# drain time. The user already said "don't tell me about this kind
# of event", so honour that immediately rather than deferring.
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
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 == []