Merge remote-tracking branch 'forgejo/qa/date-422-live' into qa/frontend-qa-batch

This commit is contained in:
Emi Griffith 2026-07-24 16:09:44 -07:00
commit 831f10d657
2 changed files with 28 additions and 2 deletions

View file

@ -71,6 +71,18 @@ def test_api_version_reports_backend_contract(client):
assert body["backend_version"] == "2" assert body["backend_version"] == "2"
def test_malformed_date_is_rejected(client):
# A malformed or non-calendar date must be a 422, not a 500: fromisoformat
# raises ValueError on these and the handler used to leak it as a crash.
for bad in ("notadate", "2026-13-40", "2026-02-30"):
for route in ("grade", "day"):
r = client.get(f"/thermograph/api/v2/{route}", params={**Q, "date": bad})
assert r.status_code == 422, (route, bad, r.status_code)
# A valid date still works.
assert client.get("/thermograph/api/v2/grade",
params={**Q, "date": "2026-07-11"}).status_code == 200
def test_day_detail_and_ladders(client, history): def test_day_detail_and_ladders(client, history):
r = client.get("/thermograph/api/v2/day", params=Q) r = client.get("/thermograph/api/v2/day", params=Q)
assert r.status_code == 200 assert r.status_code == 200

View file

@ -79,6 +79,20 @@ def _weather_fetch_error(e) -> HTTPException:
return HTTPException(status_code=502, detail=f"weather data fetch failed: {e}") return HTTPException(status_code=502, detail=f"weather data fetch failed: {e}")
def _parse_target_date(date: str | None, default: datetime.date) -> datetime.date:
"""Parse the ``date`` query param (YYYY-MM-DD) into a date, falling back to
``default`` when it's absent. A malformed or non-calendar date (e.g. 2026-13-40)
is a client error, not a crash: surface it as a 422 rather than let
fromisoformat's ValueError become a 500."""
if not date:
return default
try:
return datetime.date.fromisoformat(date[:10])
except ValueError:
raise HTTPException(status_code=422,
detail=f"invalid date: {date!r} (expected YYYY-MM-DD)")
# --- background neighbor warming --------------------------------------------- # --- background neighbor warming ---------------------------------------------
# /cell?neighbors=1 asks the server to warm the 8 grid cells around the request # /cell?neighbors=1 asks the server to warm the 8 grid cells around the request
# (the frontend used to fire 8 staggered prefetch requests, mirroring grid.py's # (the frontend used to fire 8 staggered prefetch requests, mirroring grid.py's
@ -492,7 +506,7 @@ def api_grade(
description="days to grade after the target (observed, or forecast when future; " description="days to grade after the target (observed, or forecast when future; "
"the forecast reaches ~7 days out so future days cap there)"), "the forecast reaches ~7 days out so future days cap there)"),
): ):
target = datetime.date.fromisoformat(date[:10]) if date else datetime.date.today() target = _parse_target_date(date, datetime.date.today())
cell = grid.snap(lat, lon) cell = grid.snap(lat, lon)
with audit.RunAudit( with audit.RunAudit(
@ -580,7 +594,7 @@ def api_day(
) as run: ) as run:
history, cache_meta, _ = _fetch_history(run, cell) history, cache_meta, _ = _fetch_history(run, cell)
last = history["date"].max() last = history["date"].max()
target = datetime.date.fromisoformat(date[:10]) if date else last target = _parse_target_date(date, last)
run.set(target_date=target.isoformat()) run.set(target_date=target.isoformat())
def build(place): def build(place):