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:
2026-05-12 02:58:07 +03:00
parent bb5afcc222
commit ba199f24bd
47 changed files with 5627 additions and 290 deletions
@@ -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 == []
@@ -0,0 +1,235 @@
"""Tests for the release provider abstraction and Gitea probe."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from notify_bridge_core.release import build_release_provider, is_valid_repo
from notify_bridge_core.release.base import (
ReleaseErrorCode,
ReleaseProviderKind,
compare_versions,
is_newer,
normalise_version,
)
from notify_bridge_core.release.gitea import GiteaReleaseProvider
# --- pure utilities ---------------------------------------------------------
def test_normalise_version_strips_v_prefix() -> None:
assert normalise_version("v1.2.3") == "1.2.3"
assert normalise_version("V1.2.3") == "1.2.3"
assert normalise_version("1.2.3") == "1.2.3"
assert normalise_version("") == ""
# Only strip ``v`` when followed by a digit — guard against names like
# ``vendor-1`` being mangled into ``endor-1``.
assert normalise_version("vendor-1") == "vendor-1"
@pytest.mark.parametrize(
("a", "b", "expected"),
[
("0.7.3", "0.7.2", 1),
("0.7.2", "0.7.3", -1),
("0.7.2", "0.7.2", 0),
("v0.7.3", "0.7.2", 1),
("1.0.0", "0.9.99", 1),
# Stable beats prerelease at equal numerics (tie-break).
("0.7.2-rc1", "0.7.2", -1),
("0.7.2", "0.7.2-rc1", 1),
# Implicit prerelease form ``1.0a2`` must NOT extract ``2`` as a
# third numeric segment — equal to ``1.0`` stable, then stable wins.
("1.0a2", "1.0", -1),
("", "0.0.0", 0),
],
)
def test_compare_versions(a: str, b: str, expected: int) -> None:
assert compare_versions(a, b) == expected
def test_is_newer_is_strict() -> None:
assert is_newer("0.7.3", "0.7.2") is True
assert is_newer("0.7.2", "0.7.2") is False
# A pre-release of the next minor should still be flagged as newer when
# explicitly fetched with include_prereleases=True at the provider level.
assert is_newer("0.7.3-rc1", "0.7.2") is True
def test_is_valid_repo() -> None:
assert is_valid_repo("alexei.dolgolyov/notify-bridge") is True
assert is_valid_repo("a/b") is True
assert is_valid_repo("a_b/c.d-e") is True
assert is_valid_repo("") is False
assert is_valid_repo("no-slash") is False
# Path-traversal attempts.
assert is_valid_repo("foo/bar/../admin") is False
assert is_valid_repo("foo/bar/baz") is False
assert is_valid_repo("foo/../bar") is False
# Embedded special chars.
assert is_valid_repo("foo@bar/baz") is False
assert is_valid_repo("foo/bar?x=1") is False
# --- registry ---------------------------------------------------------------
def test_registry_returns_none_for_disabled() -> None:
assert build_release_provider("disabled", session=MagicMock(), url="x", repo="a/b") is None
def test_registry_returns_none_for_unknown_kind() -> None:
assert build_release_provider("svn", session=MagicMock(), url="x", repo="a/b") is None
def test_registry_gitea_requires_url_and_valid_repo() -> None:
sess = MagicMock()
assert build_release_provider("gitea", session=sess, url="", repo="a/b") is None
assert build_release_provider("gitea", session=sess, url="https://x", repo="") is None
# Path traversal blocked by repo validation.
assert build_release_provider("gitea", session=sess, url="https://x", repo="a/b/../c") is None
provider = build_release_provider("gitea", session=sess, url="https://x", repo="a/b")
assert isinstance(provider, GiteaReleaseProvider)
assert provider.kind is ReleaseProviderKind.GITEA
# --- Gitea provider ---------------------------------------------------------
def _gitea_payload(**overrides: Any) -> list[dict[str, Any]]:
base = {
"tag_name": "v0.7.3",
"name": "v0.7.3",
"html_url": "https://git.example.com/owner/repo/releases/tag/v0.7.3",
"body": "Notes",
"published_at": "2026-05-01T00:00:00Z",
"draft": False,
"prerelease": False,
}
base.update(overrides)
return [base]
class _FakeContent:
def __init__(self, raw: bytes) -> None:
self._raw = raw
async def read(self, n: int = -1) -> bytes:
return self._raw if n < 0 else self._raw[:n]
class _FakeResponse:
def __init__(self, status: int, payload: Any) -> None:
self.status = status
import json
self.content = _FakeContent(json.dumps(payload).encode("utf-8"))
self._payload = payload
async def json(self) -> Any:
return self._payload
async def __aenter__(self) -> "_FakeResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
def _session_with(payload: Any, status: int = 200) -> MagicMock:
"""Return a session whose `.get()` yields a fresh response per call.
Using ``side_effect`` rather than ``return_value`` ensures multiple
awaited fetches don't share mutable response state across tests.
"""
sess = MagicMock()
sess.get = MagicMock(side_effect=lambda *a, **kw: _FakeResponse(status, payload))
return sess
@pytest.fixture(autouse=True)
def _allow_private_urls(monkeypatch: pytest.MonkeyPatch) -> None:
"""SSRF guard rejects example.com → publicly resolvable, so tests pass.
But we explicitly enable the bypass to remove DNS-resolution flakiness
from CI runs.
"""
monkeypatch.setenv("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS", "1")
# Reload the ssrf module to pick up the env var (it's read at import).
import importlib
import notify_bridge_core.notifications.ssrf as ssrf_mod
importlib.reload(ssrf_mod)
async def test_gitea_fetch_latest_happy_path() -> None:
sess = _session_with(_gitea_payload())
provider = GiteaReleaseProvider(sess, "https://git.example.com/", "owner/repo")
info = await provider.fetch_latest(include_prereleases=False)
assert info is not None
assert info.tag == "v0.7.3"
assert info.version == "0.7.3"
assert info.url == "https://git.example.com/owner/repo/releases/tag/v0.7.3"
assert info.prerelease is False
async def test_gitea_skips_prereleases_by_default() -> None:
payload = _gitea_payload(prerelease=True)
sess = _session_with(payload)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
assert await provider.fetch_latest(include_prereleases=False) is None
async def test_gitea_includes_prereleases_when_asked() -> None:
payload = _gitea_payload(prerelease=True)
sess = _session_with(payload)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
info = await provider.fetch_latest(include_prereleases=True)
assert info is not None
assert info.prerelease is True
async def test_gitea_skips_drafts() -> None:
payload = _gitea_payload(draft=True)
sess = _session_with(payload)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
assert await provider.fetch_latest(include_prereleases=True) is None
async def test_gitea_returns_none_on_http_error() -> None:
sess = _session_with([], status=500)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
assert await provider.fetch_latest() is None
async def test_gitea_test_returns_structured_status() -> None:
sess = _session_with(_gitea_payload())
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
result = await provider.test()
assert result["ok"] is True
assert result["info"] is not None
assert result["error"] is None
async def test_gitea_test_reports_http_error() -> None:
sess = _session_with([], status=404)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
result = await provider.test()
assert result["ok"] is False
assert result["info"] is None
# Taxonomy code, not a raw exception string.
assert result["error"] in {code.value for code in ReleaseErrorCode}
def test_gitea_constructor_validates_repo_format() -> None:
with pytest.raises(ValueError):
GiteaReleaseProvider(MagicMock(), "https://x.example.com", "no-slash")
with pytest.raises(ValueError):
GiteaReleaseProvider(MagicMock(), "https://x.example.com", "foo/bar/../baz")
with pytest.raises(ValueError):
GiteaReleaseProvider(MagicMock(), "", "owner/repo")
@@ -0,0 +1,144 @@
"""Tests for the release_check service (interval clamping + status endpoints + persistence)."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
def test_parse_interval_hours_clamps_and_defaults() -> None:
from notify_bridge_server.services.release_check import parse_interval_hours
assert parse_interval_hours("12") == 12
assert parse_interval_hours("") == 12 # default
assert parse_interval_hours(None) == 12
assert parse_interval_hours("0") == 1 # clamped to min
assert parse_interval_hours("9999") == 168 # clamped to max
assert parse_interval_hours("not-a-number") == 12 # fallback to default
assert parse_interval_hours("24") == 24
def test_release_endpoint_anonymous_is_rejected(tmp_data_dir) -> None: # noqa: ARG001
"""GET /api/settings/release requires auth — same as other settings."""
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.get("/api/settings/release")
# Either 401 (missing token) or 403 (not authenticated) is acceptable.
assert resp.status_code in (401, 403)
def test_release_force_check_requires_admin(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.post("/api/settings/release/check")
assert resp.status_code in (401, 403)
def test_release_test_requires_admin(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.post(
"/api/settings/release/test",
json={"provider_kind": "gitea", "provider_url": "https://x.example.com", "provider_repo": "a/b"},
)
assert resp.status_code in (401, 403)
# --- Persistence round-trip -------------------------------------------------
@pytest.mark.asyncio
async def test_persist_release_state_round_trip(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""Write a fake ReleaseInfo, read it back via load_status, assert flags."""
from notify_bridge_core.release import ReleaseInfo
from notify_bridge_server.database.engine import init_db
from notify_bridge_server.services.release_check import (
load_status,
persist_release_state,
)
await init_db()
info = ReleaseInfo(
tag="v0.9.0",
version="0.9.0",
name="0.9.0 — Aurora",
body="Release notes",
url="https://example.com/x/y/releases/tag/v0.9.0",
published_at="2026-06-01T00:00:00Z",
prerelease=False,
draft=False,
)
await persist_release_state(
checked_at="2026-06-01T00:01:00+00:00",
error=None,
info=info,
)
# Force the comparator to see an older "current" so update_available
# comes out True regardless of the actual installed package version.
monkeypatch.setattr(
"notify_bridge_server.services.release_check._server_version",
lambda: "0.7.0",
)
status = await load_status()
assert status.latest == "0.9.0"
assert status.latest_tag == "v0.9.0"
assert status.update_available is True
assert status.error is None
assert status.latest_body == "Release notes"
@pytest.mark.asyncio
async def test_persist_release_state_clears_on_none_info(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""A persist call with ``info=None`` must blank all the latest-* fields."""
from notify_bridge_core.release import ReleaseInfo
from notify_bridge_server.database.engine import init_db
from notify_bridge_server.services.release_check import (
load_status,
persist_release_state,
)
await init_db()
# Seed a populated row.
await persist_release_state(
checked_at="2026-06-01T00:00:00+00:00",
error=None,
info=ReleaseInfo(tag="v9.9.9", version="9.9.9"),
)
# Now wipe by passing info=None — mimics the "provider_changed" flow.
await persist_release_state(
checked_at="2026-06-01T00:02:00+00:00",
error="provider_changed",
info=None,
)
monkeypatch.setattr(
"notify_bridge_server.services.release_check._server_version",
lambda: "0.7.0",
)
status = await load_status()
assert status.latest is None
assert status.latest_tag is None
assert status.update_available is False
assert status.error == "provider_changed"
# --- Version resolver -------------------------------------------------------
def test_resolve_version_prefers_source_pyproject() -> None:
"""When pyproject.toml is alongside the source, prefer the higher of (installed, source)."""
from notify_bridge_server.version import resolve_version
v = resolve_version()
assert v != "0.0.0+unknown"
# If the editable install is stale (e.g. 0.3.2) but pyproject says 0.7.2,
# resolve_version must return 0.7.2 (or higher) — the resolver's
# whole purpose. We test the "not stale" half of the contract here.
parts = v.split(".")
assert len(parts) >= 2
assert parts[0].isdigit()