diff --git a/packages/server/src/notify_bridge_server/__init__.py b/packages/server/src/notify_bridge_server/__init__.py index d3a535e..eab9098 100644 --- a/packages/server/src/notify_bridge_server/__init__.py +++ b/packages/server/src/notify_bridge_server/__init__.py @@ -1 +1,6 @@ """Notify Bridge Server — FastAPI REST API with SQLite database.""" + +# Source of truth for the running app's reported version. Synced with +# pyproject.toml's [project].version on every release; see version.py for +# the resolution rules and CLAUDE.md for the bump checklist. +__version__ = "0.8.1" diff --git a/packages/server/src/notify_bridge_server/version.py b/packages/server/src/notify_bridge_server/version.py index 12549f5..d97584f 100644 --- a/packages/server/src/notify_bridge_server/version.py +++ b/packages/server/src/notify_bridge_server/version.py @@ -59,25 +59,45 @@ def _segments(version: str) -> tuple[int, ...]: return tuple(out) +def _read_package_version() -> str | None: + """Read ``__version__`` from the package's ``__init__.py``. + + Acts as a robust fallback when wheel-built installs end up with a METADATA + file missing the ``Version`` field — observed in CI when ``pip wheel`` + + hatchling produce dist-info that ``importlib.metadata`` reads as None. + """ + try: + from . import __version__ as pkg_version + except ImportError: # pragma: no cover — only if __init__ misconfigured + return None + return str(pkg_version) if pkg_version else None + + def resolve_version() -> str: """Return the version the running server should advertise. - Prefers the highest of (installed metadata, source pyproject) so an - out-of-date editable install never lies to the UI. In production builds - only the installed metadata is available, which is correct by definition. + Prefers the highest of (installed metadata, package ``__version__``, + source pyproject) so an out-of-date editable install never lies to the + UI. In production builds the package constant is always available, so we + always have at least one truthy candidate. """ try: installed: str | None = _pkg_version(_PACKAGE_NAME) except PackageNotFoundError: installed = None + package = _read_package_version() source = _read_source_version() - candidates = [v for v in (installed, source) if v] + candidates = [v for v in (installed, package, source) if v] if not candidates: return _UNKNOWN if len(candidates) == 1: return candidates[0] - # Two candidates — return the higher by numeric segments. Ties: prefer - # source, since that's what the developer just edited. - a, b = candidates - return a if _segments(a) > _segments(b) else b + # Return the higher by numeric segments. Ties prefer the later candidate + # in (installed, package, source) order — source wins ties since that's + # what the developer just edited. + best = candidates[0] + for cand in candidates[1:]: + if _segments(cand) >= _segments(best): + best = cand + return best diff --git a/packages/server/tests/test_deferred_dispatch.py b/packages/server/tests/test_deferred_dispatch.py index 2a701df..dd942ca 100644 --- a/packages/server/tests/test_deferred_dispatch.py +++ b/packages/server/tests/test_deferred_dispatch.py @@ -168,7 +168,7 @@ class _FakeTrackingConfig: setattr(self, attr, True) -def test_gate_quiet_hours_wins_over_event_type_flag(monkeypatch: pytest.MonkeyPatch) -> None: +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): @@ -181,15 +181,15 @@ def test_gate_quiet_hours_wins_over_event_type_flag(monkeypatch: pytest.MonkeyPa 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. + # 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.QUIET_HOURS - assert outcome.quiet_hours_end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.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: diff --git a/packages/server/tests/test_release_provider.py b/packages/server/tests/test_release_provider.py index 8c2c3f6..65994e1 100644 --- a/packages/server/tests/test_release_provider.py +++ b/packages/server/tests/test_release_provider.py @@ -129,6 +129,14 @@ class _FakeResponse: self.content = _FakeContent(json.dumps(payload).encode("utf-8")) self._payload = payload + # Mimic aiohttp.ClientResponse — readers consult content_length to + # short-circuit oversized bodies before reading. + self.content_length: int | None = None + + async def read(self) -> bytes: + import json + + return json.dumps(self._payload).encode("utf-8") async def json(self) -> Any: return self._payload @@ -156,14 +164,13 @@ 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. + from CI runs. Patch the module attribute directly — env-var-plus-reload + permanently mutates the module and leaks the bypass into unrelated SSRF + test files that run later in the session. """ - 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) + + monkeypatch.setattr(ssrf_mod, "_ALLOW_PRIVATE", True) async def test_gitea_fetch_latest_happy_path() -> None: