CI / Format & lint (push) Successful in 27s
CI / Test suite (push) Successful in 56s
Docs / Validate documentation (push) Successful in 26s
Security / Dependency vulnerabilities (push) Successful in 2m2s
Security / Static analysis (push) Successful in 21s
Security / License report (push) Successful in 1m50s
Stale-while-revalidate answered the first /health after a quiet night with the previous evening's state (seen after the collector cutover: 'Latest data is old: 21:00' at 13:47 while data was current). _cached_swr gains max_stale; /health uses 4 x HEALTH_CACHE_TTL_SECONDS.
124 lines
4.1 KiB
Python
124 lines
4.1 KiB
Python
"""COLLECTION_ENABLED=false: the Rust ping-collector collects, this app only
|
|
re-forecasts and notifies when the database's newest RID reading advances
|
|
(and once per clock hour), and /scraping/status reports the collector's file."""
|
|
|
|
import asyncio
|
|
import datetime
|
|
import json
|
|
|
|
from src import web_api
|
|
from src.config import Config
|
|
|
|
|
|
def test_forecasts_follow_new_readings_and_the_clock_hour(monkeypatch):
|
|
clock = {"now": datetime.datetime(2026, 10, 17, 21, 30)}
|
|
|
|
class FixedClock(datetime.datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return clock["now"]
|
|
|
|
# newest reading per poll: unchanged, unchanged, advances, next hour
|
|
latest = iter(
|
|
[
|
|
datetime.datetime(2026, 10, 17, 20),
|
|
datetime.datetime(2026, 10, 17, 20),
|
|
datetime.datetime(2026, 10, 17, 21),
|
|
datetime.datetime(2026, 10, 17, 21),
|
|
]
|
|
)
|
|
calls = {"precompute": 0, "notify": 0}
|
|
sleeps = []
|
|
|
|
async def precompute():
|
|
calls["precompute"] += 1
|
|
|
|
async def notify():
|
|
calls["notify"] += 1
|
|
|
|
async def fake_sleep(seconds):
|
|
sleeps.append(seconds)
|
|
if len(sleeps) == 3: # the last poll happens in the next clock hour
|
|
clock["now"] = datetime.datetime(2026, 10, 17, 22, 1)
|
|
if len(sleeps) == 4:
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(web_api, "datetime", FixedClock)
|
|
monkeypatch.setattr(web_api.asyncio, "sleep", fake_sleep)
|
|
monkeypatch.setattr(web_api, "_latest_rid_reading", lambda: next(latest))
|
|
monkeypatch.setattr(web_api, "_precompute_forecasts", precompute)
|
|
monkeypatch.setattr(web_api, "_notify_transitions", notify)
|
|
monkeypatch.setattr(Config, "FORECAST_POLL_SECONDS", 60)
|
|
|
|
asyncio.run(web_api.forecast_notify_task())
|
|
|
|
# first poll (new + hourly), third (new reading), fourth (new clock hour)
|
|
assert calls == {"precompute": 3, "notify": 3}
|
|
assert sleeps == [60, 60, 60, 60]
|
|
|
|
|
|
def test_status_comes_from_the_collector_file(tmp_path, monkeypatch):
|
|
status = tmp_path / "collector-status.json"
|
|
status.write_text(
|
|
json.dumps(
|
|
{
|
|
"is_running": False,
|
|
"total_runs": 4,
|
|
"successful_runs": 3,
|
|
"failed_runs": 1,
|
|
"last_run": "2026-10-17T22:05:01",
|
|
"next_run": "2026-10-17T23:05:00",
|
|
"feed_current": True,
|
|
"latest_reading": "2026-10-17T22:00:00",
|
|
"readings_saved": 352,
|
|
"collector": "ping-collector 0.1.0",
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr(Config, "COLLECTION_ENABLED", False)
|
|
monkeypatch.setattr(Config, "SCRAPING_STATUS_FILE", str(status))
|
|
monkeypatch.setitem(web_api.app_state, "leader_lock", object())
|
|
|
|
resp = asyncio.run(web_api.get_scraping_status())
|
|
|
|
assert resp.total_runs == 4
|
|
assert resp.feed_current is True
|
|
assert resp.latest_reading == datetime.datetime(2026, 10, 17, 22)
|
|
|
|
|
|
def test_manual_trigger_is_refused_when_collection_runs_elsewhere(monkeypatch):
|
|
import pytest
|
|
from fastapi import BackgroundTasks, HTTPException
|
|
|
|
monkeypatch.setattr(Config, "COLLECTION_ENABLED", False)
|
|
with pytest.raises(HTTPException) as err:
|
|
asyncio.run(web_api.trigger_scraping(BackgroundTasks()))
|
|
assert err.value.status_code == 409
|
|
|
|
|
|
def test_stale_cache_older_than_max_stale_is_recomputed(monkeypatch):
|
|
"""/health must not answer with a result from hours ago."""
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
|
|
from src import web_api
|
|
|
|
cache, lock, compute_lock = {}, threading.Lock(), threading.Lock()
|
|
cache["k"] = (time.monotonic() - 3600, "an hour old")
|
|
|
|
value, stale = asyncio.run(
|
|
web_api._cached_swr(
|
|
cache, lock, compute_lock, "k", 30, lambda: "fresh", max_stale=120
|
|
)
|
|
)
|
|
assert (value, stale) == ("fresh", False)
|
|
|
|
cache["k"] = (time.monotonic() - 60, "a minute old")
|
|
value, stale = asyncio.run(
|
|
web_api._cached_swr(
|
|
cache, lock, compute_lock, "k", 30, lambda: "fresh", max_stale=120
|
|
)
|
|
)
|
|
assert (value, stale) == ("a minute old", True) # SWR within the bound
|