Files
parentcalendar/tests/test_phase1.py
T

188 lines
11 KiB
Python

"""End-to-end check of the Phase 1 API with the model replaced by canned results. No GPU or network needed.
Run: python tests/test_phase1.py
"""
import io
import os
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
TMP = tempfile.mkdtemp(prefix="parentcalendar-test-")
os.environ.update(DATA_DIR=TMP, PUBLIC_URL="https://calendar.example.com", EXTRACT_IGNORE="")
from fastapi.testclient import TestClient # noqa: E402
from PIL import Image # noqa: E402
from parentcalendar import app as app_module, db, extraction, scans # noqa: E402
app_module.START_WORKER = False
def png(color):
buf = io.BytesIO()
Image.new("RGB", (400, 300), color).save(buf, "PNG")
return buf.getvalue()
def ev(date, title, **kw):
return {"date": date, "end_date": None, "all_day": True, "start_time": None, "end_time": None, "title": title,
"notes": None, "location": None, "kind": "event", "applies": True, "source_text": title,
"confidence": 0.9, "cell_bbox": [0, 0.2, 1, 0.2], "flags": [], **kw}
CANNED = [] # the next fake extraction results, one per page
def fake_extract(img, hint, double_read=None, ignore=None):
result = CANNED.pop(0)
return {"model": "fake", "method": "page", "seconds": 0.1, "usage": {"input_tokens": 1, "output_tokens": 1},
"result": {"date_range": {"start": "2026-10-01", "end": "2026-10-31"}, "menu_items": [], **result},
"image": img}
extraction.extract_image = fake_extract
def upload(c, source_id, image, result, month="2026-10"):
r = c.post("/api/scans", data={"source_id": source_id, "month": month}, files=[("files", ("p.png", image, "image/png"))])
assert r.status_code == 200, r.text
CANNED.append(result)
with db.connect() as conn:
scans.process_scan(conn, r.json()["id"])
return c.get(f"/api/scans/{r.json()['id']}").json()
def test():
with TestClient(app_module.app) as c:
# Household setup
s = c.post("/api/children", json={"display_name": "Sam", "color": "#ff8800"}).json()
sam = s["children"][0]["id"]
s = c.post("/api/calendars", json={"name": "Sam's school", "child_ids": [sam], "alarm_minutes": 60}).json()
cal = s["calendars"][0]["id"]
assert len(s["calendars"][0]["feed_token"]) >= 32 # 192 random bits
s = c.post("/api/sources", json={"name": "District", "child_ids": [sam]}).json()
district = s["sources"][0]["id"]
s = c.post("/api/sources", json={"name": "Lunch", "layout_type": "menu", "child_ids": [sam]}).json()
menu_src = s["sources"][1]["id"]
assert c.put("/api/user", json={"timezone": "America/Chicago"}).json()["timezone"] == "America/Chicago"
assert c.put("/api/user", json={"timezone": "Mars/Base"}).status_code == 422
# Upload: bad files are rejected; the same image again returns the same scan (no second model run)
bad = c.post("/api/scans", data={"source_id": district, "month": "2026-10"}, files=[("files", ("x.png", b"nope", "image/png"))])
assert bad.status_code == 400
scan = upload(c, district, png("white"), {"events": [
ev("2026-10-02", "Spirit Day"),
ev("2026-10-20", "PTO", all_day=False, start_time="16:00", location="Library"),
ev("2026-10-29", "End of Q.1", flags=["second read differs: date"]),
ev("2026-10-14", "Grade 5 field trip", applies=False),
ev("2026-10-30", "No School", kind="no_school"),
]})
again = c.post("/api/scans", data={"source_id": district, "month": "2026-10"},
files=[("files", ("p.png", png("white"), "image/png"))]).json()
assert again["id"] == scan["id"]
assert scan["status"] == "parsed" and len(scan["events"]) == 5
by = {e["title"]: e for e in scan["events"]}
assert by["PTO"]["child_ids"] == [sam] and by["End of Q.1"]["flags"] == ["second read differs: date"]
assert by["Grade 5 field trip"]["include"] is False # not for this child: unchecked
assert c.get(f"/api/scans/{scan['id']}/pages/1").status_code == 200
# Review: edit, validation, undo, bulk, add
r = c.patch(f"/api/events/{by['End of Q.1']['id']}", json={"date": "2026-10-28", "end_date": "2026-10-20"})
assert r.status_code == 422 # end before start
c.patch(f"/api/events/{by['End of Q.1']['id']}", json={"date": "2026-10-28"})
c.patch(f"/api/events/{by['Spirit Day']['id']}", json={"title": "Spirit Day!"})
scan = c.post(f"/api/scans/{scan['id']}/undo").json() # undoes the title only
titles = {e["title"]: e for e in scan["events"]}
assert "Spirit Day" in titles and titles["End of Q.1"]["date"] == "2026-10-28" and scan["status"] == "reviewed"
c.post("/api/events/bulk", json={"ids": [by["Spirit Day"]["id"], by["PTO"]["id"]], "delete": True})
scan = c.post(f"/api/scans/{scan['id']}/undo").json() # both come back together
assert all(e["status"] == "draft" for e in scan["events"])
added = c.post("/api/events", json={"scan_id": scan["id"], "date": "2026-10-09", "title": "Spirit Day",
"child_ids": [sam]}).json()
assert added["status"] == "draft" and added["all_day"] is True
# Publish: checked drafts are confirmed, the unchecked one is dropped
scan = c.post(f"/api/scans/{scan['id']}/publish").json()
assert scan["status"] == "published"
assert sorted(e["title"] for e in scan["events"]) == ["End of Q.1", "No School", "PTO", "Spirit Day", "Spirit Day"]
assert c.post(f"/api/scans/{scan['id']}/publish").status_code == 409
# Export: first download has everything; the next only what changed
ics1 = c.get(f"/api/calendars/{cal}/export.ics").text
assert ics1.count("BEGIN:VEVENT") == 5 and "TZID=America/Chicago:20261020T160000" in ics1
assert c.get(f"/api/calendars/{cal}/export.ics").text.count("BEGIN:VEVENT") == 0
pto = next(e for e in scan["events"] if e["title"] == "PTO")
assert c.patch(f"/api/events/{pto['id']}", json={"end_time": "17:00"}).json()["sequence"] == 1
ics2 = c.get(f"/api/calendars/{cal}/export.ics").text
assert ics2.count("BEGIN:VEVENT") == 1 and pto["uid"] in ics2.replace("\r\n ", "") and "SEQUENCE:1" in ics2
c.delete(f"/api/events/{pto['id']}")
assert "STATUS:CANCELLED" in c.get(f"/api/calendars/{cal}/export.ics").text
assert c.get(f"/api/calendars/{cal}/export.ics?everything=true").text.count("BEGIN:VEVENT") == 4
# A revised calendar: the matching event keeps its UID and is updated, not duplicated
spirit = next(e for e in scan["events"] if e["title"] == "Spirit Day" and e["date"] == "2026-10-02")
rescan = upload(c, district, png("gray"), {"events": [
ev("2026-10-02", "Spirit Day", notes="Wear purple"), ev("2026-10-23", "Spirit Day")]})
m = {e["date"]: e for e in rescan["events"]}
assert m["2026-10-02"]["match"] == "updated" and m["2026-10-02"]["replaces_id"] == spirit["id"]
assert m["2026-10-23"]["match"] == "new"
c.post(f"/api/scans/{rescan['id']}/publish")
events = c.get("/api/events", params={"calendar_id": cal}).json()
spirits = [e for e in events if e["date"] == "2026-10-02"]
assert len(spirits) == 1 and spirits[0]["uid"] == spirit["uid"] and spirits[0]["notes"] == "Wear purple"
assert spirits[0]["sequence"] == 1
# Lunch: menu days, greyed closures, picks as events
menu = upload(c, menu_src, png("yellow"), {"events": [], "menu_items": [
{"date": "2026-10-05", "items": ["Pork Chopette", "Corn"], "no_lunch": False, "flags": [],
"reads": [{"items": ["Pork Chopette", "Corn"], "no_lunch": False},
{"items": ["Pork Chop", "Corn"], "no_lunch": False}]},
{"date": "2026-10-30", "items": [], "no_lunch": True, "flags": []}]})
assert [r["items"][0] for r in menu["menu_items"][0]["reads"]] == ["Pork Chopette", "Pork Chop"] # both reads kept
assert menu["menu_items"][1]["reads"] is None
c.post(f"/api/scans/{menu['id']}/publish")
# After publishing, a mistaken "no lunch" keeps the food, and unticking it brings the day back
item = menu["menu_items"][0]["id"]
r = c.patch(f"/api/menu_items/{item}", json={"items": ["Pork Chopette", "Corn"], "no_lunch": True}).json()
assert r["no_lunch"] is True and r["items"] == ["Pork Chopette", "Corn"], r
days = {d["date"]: d for d in c.get("/api/lunch", params={"child_id": sam, "month": "2026-10"}).json()}
assert days["2026-10-05"]["greyed"] is True
c.patch(f"/api/menu_items/{item}", json={"items": ["Pork Chopette", "Corn"], "no_lunch": False})
days = {d["date"]: d for d in c.get("/api/lunch", params={"child_id": sam, "month": "2026-10"}).json()}
assert days["2026-10-05"]["greyed"] is False and days["2026-10-30"]["greyed"] is True
assert days["2026-10-30"]["closed"] == "No School"
assert c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-30", "picked": True}).status_code == 409
days = c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True}).json()
assert next(d for d in days if d["date"] == "2026-10-05")["picked"] is True
pack = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is False and pack[0]["start_time"] == "08:00" and pack[0]["notes"] == "Pork Chopette"
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": False})
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True})
pack2 = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
assert pack2[0]["uid"] == pack[0]["uid"] and pack2[0]["sequence"] == 2 # same event, re-picked
# Select all skips the no-school day instead of failing; clear all unpicks
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": False})
days = {d["date"]: d for d in c.post("/api/lunch/bulk", json={
"child_id": sam, "dates": ["2026-10-05", "2026-10-30"], "picked": True}).json()}
assert days["2026-10-05"]["picked"] is True and days["2026-10-30"]["picked"] is False
days = {d["date"]: d for d in c.post("/api/lunch/bulk", json={
"child_id": sam, "dates": ["2026-10-05"], "picked": False}).json()}
assert days["2026-10-05"]["picked"] is False
assert c.post("/api/lunch/bulk", json={"child_id": sam, "dates": [], "picked": True}).status_code == 422
# A failed extraction marks the scan failed and can be retried
r = c.post("/api/scans", data={"source_id": district, "month": "2026-11"},
files=[("files", ("p.png", png("blue"), "image/png"))]).json()
with db.connect() as conn:
conn.execute("UPDATE scan SET status = 'failed', error = 'boom' WHERE id = ?", (r["id"],))
conn.commit()
assert c.post(f"/api/scans/{r['id']}/retry").json()["status"] == "queued"
print("phase 1 api test ok")
if __name__ == "__main__":
test()