110 lines
4.1 KiB
Python
110 lines
4.1 KiB
Python
|
|
"""/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
|