# Tests for the dashboard API layer against the frozen API_CONTRACT.md shapes. # Drives handlers via api.run(Request(...)) — no sockets — plus one in-process # ThreadingHTTPServer test. Builds a small temp DB (flat + relational + # bill_tags) so shapes + error codes + empty states are asserted. from __future__ import annotations import json import threading import urllib.request import pytest from scraper import relational from scraper.schema import BillAction from scraper.io import write_sqlite from scraper.dashboard import api from scraper.dashboard.api import Request, Services from scraper.dashboard.prefs import PrefsStore, LocalAuth # ---- fixture: a small, controlled DB -------------------------------------- def build_small_db(path): recs = [ BillAction(date="2026-09-10", house="assembly", record_type="bill", bill_number="AB 100", bill_id="202520260AB100", session="2025-2026", chamber_origin="assembly", author="Schiavo et al.", short_title="Waste: recycling standards", description="d", source="ASSEMBLY_DAILY_FILE"), BillAction(date="2026-09-10", house="assembly", record_type="bill", bill_number="AB 200", bill_id="202520260AB200", session="2025-2026", chamber_origin="assembly", author="Macedo", short_title="Education: K-12 funding", description="d", source="ASSEMBLY_DAILY_FILE"), BillAction(date="2026-09-05", house="assembly", record_type="bill", bill_number="SB 300", bill_id="202520260SB300", session="2025-2026", chamber_origin="assembly", author="Unrelated", short_title="Old bill, past date", description="d", source="ASSEMBLY_DAILY_FILE"), ] n = write_sqlite(recs, path, table=relational.FLAT_TABLE) assert n == 3 relational.correlate(path, quiet=True) conn = relational.connect(path, foreign_keys=False) try: conn.execute( "CREATE TABLE IF NOT EXISTS bill_tags (" "id INTEGER PRIMARY KEY, bill_id TEXT NOT NULL, " "tag TEXT NOT NULL, source TEXT, generated_by TEXT NOT NULL, " "generated_at TEXT, UNIQUE (bill_id, tag, generated_by))") for bid, tag in ("202520260AB100", "waste"), \ ("202520260AB200", "education"), \ ("202520260AB200", "health"): conn.execute( "INSERT OR IGNORE INTO bill_tags " "(bill_id,tag,generated_by) VALUES (?,?,?)", (bid, tag, "fallback")) conn.commit() finally: conn.close() return path @pytest.fixture def small_db(tmp_path): return build_small_db(str(tmp_path / "dash.sqlite")) @pytest.fixture def services(tmp_path, small_db): prefs = PrefsStore(str(tmp_path / "prefs.json")) auth = LocalAuth(str(tmp_path / "auth.json")) return Services(small_db, prefs, auth) def _req(method, path, services, token=None, query=None, body=None, user_id=None): return Request(method, path, query=query or {}, body=body or {}, token=token, services=services, user_id=user_id) # ---- /bills envelope (§1/§2) ---------------------------------------------- def test_bills_envelope_shape(services): status, body = api.run(_req("GET", "/api/v1/bills", services, query={"upcoming": "1", "as_of": "2026-09-06"})) assert status == 200, body for key in ("session", "as_of", "chamber_availability", "items", "groups"): assert key in body assert isinstance(body["items"], list) assert isinstance(body["groups"], dict) ids = {i["bill_id"] for i in body["items"]} assert "202520260AB100" in ids assert "202520260AB200" in ids assert "202520260SB300" not in ids item = body["items"][0] for key in ("bill_id", "bill_number", "chamber_origin", "author", "short_title", "hearing_sort", "tags"): assert key in item assert isinstance(item["hearing_sort"], int) def test_bills_personal_returns_relevance_and_floats(services): reg = services.auth.register("u@example.com") # user watches "schiavo" and opts into "education" api.run(_req("POST", "/api/v1/prefs/watch", services, token=reg["token"], body={"legislator_id": "schiavo"})) api.run(_req("POST", "/api/v1/prefs/topics", services, token=reg["token"], body={"tag": "education"})) # personal + full + upcoming (as_of=2026-09-04, before all 3 bills' date) status, body = api.run(_req("GET", "/api/v1/bills", services, token=reg["token"], query={"upcoming": "1", "as_of": "2026-09-04", "personal": "1", "full": "1"})) assert status == 200, body by = {i["bill_id"]: i for i in body["items"]} assert "relevance" in by["202520260AB100"] assert by["202520260AB100"]["relevance"]["score"] > 0 assert by["202520260AB200"]["relevance"]["score"] > 0 # SB300 not relevant to the user (no match) — its score is 0 assert "202520260SB300" in by assert by["202520260SB300"]["relevance"]["score"] == 0 # float-to-top: the first item has a positive score (a floated bill) first = by[body["items"][0]["bill_id"]] assert first["relevance"]["score"] > 0 assert by["202520260AB100"]["bill_id"] in \ [i["bill_id"] for i in body["items"][:1]] # SB300 comes after the floated bills in the ordering order = [i["bill_id"] for i in body["items"]] assert order.index("202520260SB300") > \ max(order.index("202520260AB100"), order.index("202520260AB200")), order def test_bills_personal_requires_auth(services): status, body = api.run(_req("GET", "/api/v1/bills", services, query={"personal": "1"})) assert status == 401, body assert body["error"]["code"] == "not_authenticated" # ---- §5 "upcoming" item 2: each item carries status + reason ------------- def test_bills_items_carry_status_reason_and_possibly_closed(services): # Every item must expose the "why is this here?" triad so a closed/past # bill doesn't read as a vague "pending" (API_CONTRACT §5 item 2). status, body = api.run(_req("GET", "/api/v1/bills", services, query={"upcoming": "1"})) assert status == 200, body items = body["items"] assert items, "expected at least one item" for it in items: for key in ("status", "reason", "possibly_closed"): assert key in it, key assert it["status"] in ("open", "closed", "unknown") assert isinstance(it["reason"], str) and it["reason"] assert it["possibly_closed"] in (True, False, None) # ---- six empty states -> 200 items:[] (never 4xx/5xx) --------------------- def test_empty_no_next_hearing(services): status, body = api.run(_req("GET", "/api/v1/bills", services, query={"from": "2030-01-01", "to": "2030-12-31"})) assert status == 200 and body["items"] == [] and body["groups"] == {} def test_empty_filter_returns_nothing(services): status, body = api.run(_req("GET", "/api/v1/bills", services, query={"upcoming": "1", "as_of": "2030-01-01", "tags": "education"})) assert status == 200 and body["items"] == [] def test_empty_prefs_not_set(services): reg = services.auth.register("nobody@example.com") status, body = api.run(_req("GET", "/api/v1/prefs", services, token=reg["token"])) # register seeds an empty-blob, so 200 with empty prefs is valid; # a truly-empty store would 404 with prefs_not_set — accept both, # never 5xx. assert status in (200, 404) if status == 404: assert body["error"]["code"] == "prefs_not_set" def test_empty_no_tagged_bills_and_chamber_pending(tmp_path, services): # a flat DB with no tag rows -> /tags returns 200 tags [] (§6.6) empty = str(tmp_path / "notags.sqlite") write_sqlite([BillAction(bill_number="AB 1", bill_id="202520260AB1", session="2025-2026", chamber_origin="assembly", author="Solo", short_title="x", source="ASSEMBLY_DAILY_FILE")], empty, table=relational.FLAT_TABLE) relational.migrate(empty) svc = Services(empty, PrefsStore(str(tmp_path / "p.json")), LocalAuth(str(tmp_path / "a.json"))) status, body = api.run(_req("GET", "/api/v1/tags", svc)) assert status == 200 and body["tags"] == [] # and a Senate-only-empty window stays 200 (chamber pending, not error) status, body = api.run(_req("GET", "/api/v1/bills", svc, query={"from": "2030-01-01", "chamber": "SENATE"})) assert status == 200 and body["items"] == [] # ---- forward "upcoming" source: scheduled_events + next_legislative_day ---- def _events_db(tmp_path): """A small DB whose flat snapshot is in the PAST but whose chamber scheduled_events table holds future-dated events (the forward source).""" p = str(tmp_path / "fwd.sqlite") write_sqlite([ BillAction(date="2026-08-31", house="assembly", record_type="bill", bill_number="AB 1099", bill_id="202520260AB1099", session="2025-2026", chamber_origin="assembly", author="Bryan", short_title="Developmental services", description="Read third time. Passed. Ordered to the Assembly.", source="ASSEMBLY_DAILY_FILE")], p, table=relational.FLAT_TABLE) relational.migrate(p) relational.store_scheduled_events([ {"date": "2026-09-08", "chamber": "ASSEMBLY", "kind": "FLOOR", "title": "Assembly Floor Session", "source_url": "https://www.assembly.ca.gov/events/upcoming", "source": "assembly_events"}, {"date": "2026-09-09", "chamber": "ASSEMBLY", "kind": "COMMITTEE", "title": "Judiciary Committee", "source": "assembly_events"}, ], path=p, fetched_at="2026-09-06T00:00:00Z") return p def test_upcoming_defaults_to_not_past(tmp_path): """The bug that motivated this work: with upcoming=1 and no as_of, the bill queue must floor at *today* (the past snapshot drops out), not at the most-recent data date (which would re-surface yesterday as "upcoming").""" fwd = _events_db(tmp_path) svc = Services(fwd, PrefsStore(str(tmp_path / "p.json")), LocalAuth(str(tmp_path / "a.json"))) status, body = api.run(_req("GET", "/api/v1/bills", svc, query={"upcoming": "1"})) assert status == 200, body # the 08-31 snapshot is in the past relative to today -> not "upcoming" assert body["items"] == [] # ...but the chamber calendar still answers "next": assert body["next_legislative_day"]["date"] is not None assert body["next_legislative_day"]["recess"] is False assert len(body["scheduled_events"]) >= 1 def test_envelope_carries_forward_fields(tmp_path): """Envelope must carry the forward fields with a 7-day window.""" fwd = _events_db(tmp_path) svc = Services(fwd, PrefsStore(str(tmp_path / "p.json")), LocalAuth(str(tmp_path / "a.json"))) status, body = api.run(_req("GET", "/api/v1/bills", svc, query={"upcoming": "1", "as_of": "2026-09-06"})) assert status == 200, body for key in ("next_legislative_day", "scheduled_events"): assert key in body nd = body["next_legislative_day"] assert nd["date"] == "2026-09-08" assert nd["kind"] == "FLOOR" # 7-day window from 09-06 reaches 09-08 and 09-09, not further: dates = {e["date"] for e in body["scheduled_events"]} assert "2026-09-08" in dates and "2026-09-09" in dates def test_next_day_recess_when_no_forward_events(tmp_path): """A pre-v2-style DB with no scheduled_events degrades to a recess marker (never a 5xx): next_legislative_day is a {date: None, recess: True}.""" p = str(tmp_path / "empty.sqlite") relational.migrate(p) svc = Services(p, PrefsStore(str(tmp_path / "p.json")), LocalAuth(str(tmp_path / "a.json"))) status, body = api.run(_req("GET", "/api/v1/bills", svc, query={"upcoming": "1"})) assert status == 200, body assert body["next_legislative_day"] == {"date": None, "recess": True} assert body["scheduled_events"] == [] def test_all_view_still_returns_history(tmp_path): """Omitting upcoming (the "All"/history view) must still return the past snapshot rows -- the forward fix only changes the *upcoming* view.""" fwd = _events_db(tmp_path) svc = Services(fwd, PrefsStore(str(tmp_path / "p.json")), LocalAuth(str(tmp_path / "a.json"))) status, body = api.run(_req("GET", "/api/v1/bills", svc, query={})) assert status == 200 assert len(body["items"]) >= 1 assert any(i["bill_number"] == "AB 1099" for i in body["items"]) # ---- /tags, /legislators, /districts (§4.1 / §3.2 / §4.2) ----------------- def test_tags_cloud(services): status, body = api.run(_req("GET", "/api/v1/tags", services)) assert status == 200, body assert "tags" in body labels = {t["tag_id"] for t in body["tags"]} assert "tag:waste" in labels assert "tag:education" in labels assert all("count" in t and "label" in t for t in body["tags"]) def test_legislators_browse(services): status, body = api.run(_req("GET", "/api/v1/legislators", services)) assert status == 200 assert "items" in body ids = {x["legislator_id"] for x in body["items"]} assert "schiavo" in ids def test_districts_list(services): status, body = api.run(_req("GET", "/api/v1/districts", services)) assert status == 200 assert "items" in body and body["items"] # ---- /districts/resolve (§3.1) — confidence, proposes (no persist) --------- def test_resolve_single(services): status, body = api.run(_req("POST", "/api/v1/districts/resolve", services, body={"zip": "90210"})) assert status == 200, body assert body["confidence"] == "single" assert body["resolved_from"] == "zip" assert body["suggested_watch"] and len(body["districts"]) == 1 def test_resolve_ambiguous_lists_candidates(services): status, body = api.run(_req("POST", "/api/v1/districts/resolve", services, body={"zip": "90012"})) assert status == 200, body assert body["confidence"] == "ambiguous" assert len(body["districts"]) >= 2 def test_resolve_zero_and_out_of_state(services): s0, b0 = api.run(_req("POST", "/api/v1/districts/resolve", services, body={"zip": "00001"})) assert s0 == 404 and b0["error"]["code"] == "district_not_found" # out-of-state prefix (99xxx / 990xx) -> 400 out_of_state s2, b2 = api.run(_req("POST", "/api/v1/districts/resolve", services, body={"zip": "99001"})) assert s2 == 400 and b2["error"]["code"] == "out_of_state" # manual pick is the fallback and always resolves (single) s3, b3 = api.run(_req("POST", "/api/v1/districts/resolve", services, body={"district": "A-14"})) assert s3 == 200 and b3["confidence"] == "single" def test_resolve_missing_input_is_400(services): status, body = api.run(_req("POST", "/api/v1/districts/resolve", services, body={})) assert status == 400 and "field" in body["error"] # ---- prefs + watch/topic (§3.3/§3.4/§3.5) --------------------------------- def test_prefs_put_and_get_roundtrip(services): reg = services.auth.register("prefs@example.com") token = reg["token"] status, body = api.run(_req("PUT", "/api/v1/prefs", services, token=token, body={"zip": "90210", "watched_legislators": ["schiavo"], "opted_in_tags": ["waste"]})) assert status == 200, body _, got = api.run(_req("GET", "/api/v1/prefs", services, token=token)) assert got["zip"] == "90210" assert "schiavo" in got["watched_legislators"] assert "waste" in got["opted_in_tags"] def test_prefs_put_rejects_unknown_tag(services): reg = services.auth.register("bad@example.com") status, body = api.run(_req("PUT", "/api/v1/prefs", services, token=reg["token"], body={"opted_in_tags": ["nope-not-tag"]})) assert status == 400 and body["error"]["code"] == "tag_unknown" def test_add_remove_watch_is_idempotent(services): reg = services.auth.register("w@example.com") token = reg["token"] api.run(_req("POST", "/api/v1/prefs/watch", services, token=token, body={"legislator_id": "schiavo"})) # add again -> idempotent, still exactly one api.run(_req("POST", "/api/v1/prefs/watch", services, token=token, body={"legislator_id": "schiavo"})) _, got = api.run(_req("GET", "/api/v1/prefs", services, token=token)) assert got["watched_legislators"] == ["schiavo"] # remove -> gone api.run(_req("DELETE", "/api/v1/prefs/watch/schiavo", services, token=token)) _, got2 = api.run(_req("GET", "/api/v1/prefs", services, token=token)) assert "schiavo" not in got2["watched_legislators"] def test_add_unknown_legislator_is_400(services): reg = services.auth.register("l@example.com") status, body = api.run(_req("POST", "/api/v1/prefs/watch", services, token=reg["token"], body={"legislator_id": "phantom-leg"})) assert status == 400 and body["error"]["code"] == "legislator_unknown" # ---- router: unknown endpoint -> 404, not a crash ------------------------- def test_unknown_endpoint_404(services): status, body = api.run(_req("GET", "/api/v1/nope", services)) assert status == 404 and body["error"]["code"] == "not_found" # ---- live server (real socket, in-process, no external port) -------------- def test_live_server_serves_bills_and_static(tmp_path, small_db): from scraper.dashboard import server as srv prefs = PrefsStore(str(tmp_path / "prefs.json")) auth = LocalAuth(str(tmp_path / "auth.json")) services = Services(small_db, prefs, auth) handler = srv.make_handler(services) httpd = srv.ThreadingHTTPServer(("127.0.0.1", 0), handler) port = httpd.server_address[1] t = threading.Thread(target=httpd.serve_forever, daemon=True) t.start() try: url = ("http://127.0.0.1:%d/api/v1/bills?upcoming=1" "&as_of=2026-09-06" % port) with urllib.request.urlopen(url, timeout=5) as resp: assert resp.status == 200 payload = json.loads(resp.read().decode("utf-8")) assert "items" in payload with urllib.request.urlopen( "http://127.0.0.1:%d/index.html" % port, timeout=5) as resp: assert resp.status == 200 html = resp.read().decode("utf-8") assert "