348 lines
15 KiB
Python
348 lines
15 KiB
Python
# 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"
|
|
|
|
|
|
# ---- 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"] == []
|
|
|
|
|
|
# ---- /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 "<html" in html.lower()
|
|
finally:
|
|
httpd.shutdown()
|
|
httpd.server_close()
|