thermograph/backend/tests/api/test_narrative_routes.py

110 lines
4.1 KiB
Python
Raw Normal View History

"""/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