"""/api/v2/narrative — the read-only half of the plain-English day summaries. The load-bearing assertion here is the negative one: this route must never reach for a model. ``narrator.generate`` is replaced with a bomb for every test, so any future edit that makes the request path call inference fails loudly instead of quietly coupling a public endpoint to a desktop GPU. """ import datetime import pytest from fastapi.testclient import TestClient from data import grid from data import narrator from data import store from web import app as appmod Q = {"lat": 47.6062, "lon": -122.3321} TODAY = datetime.date.today() @pytest.fixture def client(monkeypatch): def _boom(*a, **kw): # pragma: no cover - failing here is the point raise AssertionError("the request path must never call the LLM") monkeypatch.setattr(narrator, "generate", _boom) return TestClient(appmod.app) @pytest.fixture def seeded(): """Store one narrative for today's cell and hand back its identity.""" cell = grid.snap(Q["lat"], Q["lon"]) key = narrator.narrative_key(TODAY) token = narrator.narrative_token() store.put_narrative(cell["id"], key, token, "A cool night for late July here.", "qwen3:14b") yield {"cell": cell, "key": key, "token": token} def test_missing_narrative_is_pending_not_an_error(client): r = client.get("/thermograph/api/v2/narrative", params={"lat": -33.8688, "lon": 151.2093}) assert r.status_code == 200 body = r.json() assert body["narrative"] is None assert body["status"] == "pending" assert body["model"] is None def test_stored_narrative_is_served_with_provenance(client, seeded): r = client.get("/thermograph/api/v2/narrative", params=Q) assert r.status_code == 200 body = r.json() assert body["narrative"] == "A cool night for late July here." assert body["status"] == "ready" assert body["model"] == "qwen3:14b" assert body["generated_at"] > 0 assert body["date"] == TODAY.isoformat() assert body["cell"]["id"] == seeded["cell"]["id"] def test_conditional_revalidation_returns_304(client, seeded): first = client.get("/thermograph/api/v2/narrative", params=Q) etag = first.headers["ETag"] again = client.get("/thermograph/api/v2/narrative", params=Q, headers={"If-None-Match": etag}) assert again.status_code == 304 assert again.content == b"" def test_the_pending_etag_changes_once_the_row_lands(client, seeded): """A client that cached a pending response has to notice the sentence appearing — so the two states must not share an ETag.""" ready = client.get("/thermograph/api/v2/narrative", params=Q).headers["ETag"] pending = client.get("/thermograph/api/v2/narrative", params={"lat": -33.8688, "lon": 151.2093}).headers["ETag"] assert ready != pending def test_a_row_written_under_another_token_is_not_served(client): """A prompt or model change must orphan old text rather than serve it.""" cell = grid.snap(35.6762, 139.6503) store.put_narrative(cell["id"], narrator.narrative_key(TODAY), "n0:some-old-model", "Written under the old contract.", "some-old-model") r = client.get("/thermograph/api/v2/narrative", params={"lat": 35.6762, "lon": 139.6503}) assert r.status_code == 200 assert r.json()["narrative"] is None assert r.json()["status"] == "pending" def test_an_explicit_past_date_is_its_own_row(client, seeded): r = client.get("/thermograph/api/v2/narrative", params={**Q, "date": "2020-01-01"}) assert r.status_code == 200 assert r.json()["date"] == "2020-01-01" assert r.json()["narrative"] is None def test_an_unparseable_date_is_rejected(client): r = client.get("/thermograph/api/v2/narrative", params={**Q, "date": "last-tuesday"}) assert r.status_code == 400 @pytest.mark.parametrize("bad", [{"lat": 91, "lon": 0}, {"lat": 0, "lon": 181}]) def test_out_of_range_coordinates_are_rejected(client, bad): assert client.get("/thermograph/api/v2/narrative", params=bad).status_code == 422