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).
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user