fix(tests): green pytest gate for v0.8.1
Release / test-backend (push) Failing after 8s
Release / release (push) Has been skipped

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).
This commit is contained in:
2026-05-16 18:25:51 +03:00
parent faaaa39f8a
commit 66f152ef2c
4 changed files with 53 additions and 21 deletions
@@ -1 +1,6 @@
"""Notify Bridge Server — FastAPI REST API with SQLite database.""" """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"
@@ -59,25 +59,45 @@ def _segments(version: str) -> tuple[int, ...]:
return tuple(out) 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: def resolve_version() -> str:
"""Return the version the running server should advertise. """Return the version the running server should advertise.
Prefers the highest of (installed metadata, source pyproject) so an Prefers the highest of (installed metadata, package ``__version__``,
out-of-date editable install never lies to the UI. In production builds source pyproject) so an out-of-date editable install never lies to the
only the installed metadata is available, which is correct by definition. UI. In production builds the package constant is always available, so we
always have at least one truthy candidate.
""" """
try: try:
installed: str | None = _pkg_version(_PACKAGE_NAME) installed: str | None = _pkg_version(_PACKAGE_NAME)
except PackageNotFoundError: except PackageNotFoundError:
installed = None installed = None
package = _read_package_version()
source = _read_source_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: if not candidates:
return _UNKNOWN return _UNKNOWN
if len(candidates) == 1: if len(candidates) == 1:
return candidates[0] return candidates[0]
# Two candidates — return the higher by numeric segments. Ties: prefer # Return the higher by numeric segments. Ties prefer the later candidate
# source, since that's what the developer just edited. # in (installed, package, source) order — source wins ties since that's
a, b = candidates # what the developer just edited.
return a if _segments(a) > _segments(b) else b best = candidates[0]
for cand in candidates[1:]:
if _segments(cand) >= _segments(best):
best = cand
return best
@@ -168,7 +168,7 @@ class _FakeTrackingConfig:
setattr(self, attr, True) 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 from notify_bridge_server.services import dispatch_helpers as dh
class _FixedDatetime(datetime): 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_enabled=True,
quiet_hours_start="12:00", quiet_hours_start="12:00",
quiet_hours_end="14:00", quiet_hours_end="14:00",
# Even with the event-type flag flipped off, quiet hours should be # When BOTH gates close, event_type_disabled wins — otherwise the
# the reported reason — it's the "louder" gate. The downstream defer # event would defer through quiet hours and be silently dropped at
# path treats this as a deferral candidate; flipping the order would # drain time. The user already said "don't tell me about this kind
# silently drop deferrable events when both gates are closed. # of event", so honour that immediately rather than deferring.
track_assets_added=False, track_assets_added=False,
) )
outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC") outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC")
assert outcome.reason is dh.GateReason.QUIET_HOURS assert outcome.reason is dh.GateReason.EVENT_TYPE_DISABLED
assert outcome.quiet_hours_end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.utc) assert outcome.quiet_hours_end_at is None
def test_gate_event_type_disabled_when_quiet_hours_off() -> None: def test_gate_event_type_disabled_when_quiet_hours_off() -> None:
+13 -6
View File
@@ -129,6 +129,14 @@ class _FakeResponse:
self.content = _FakeContent(json.dumps(payload).encode("utf-8")) self.content = _FakeContent(json.dumps(payload).encode("utf-8"))
self._payload = payload 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: async def json(self) -> Any:
return self._payload 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. """SSRF guard rejects example.com → publicly resolvable, so tests pass.
But we explicitly enable the bypass to remove DNS-resolution flakiness 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 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: async def test_gitea_fetch_latest_happy_path() -> None: