Initial commit

This commit is contained in:
kfj001 committed 2026-09-06 10:18:26 -07:00
commit 5f2db08b43
100 files changed
+16985

No files matched your search

+347
View File
@@ -0,0 +1,347 @@
# 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()