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 == []
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user