Files
Northern-Thailand-Ping-Rive…/tests/test_collection_disabled.py
T
grabowski 64c5b02e06
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
fix(api): /health never serves a cached result older than 4 TTLs
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.
2026-10-18 13:49:39 +07:00

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