| 1 | """ |
| 2 | Tests for state coverage and the STATE_LEG_HOSTS allowlist. |
| 3 | |
| 4 | Covers: |
| 5 | 1. STATE_LEG_HOSTS - explicit non-.gov state legislature hosts |
| 6 | 2. *.state.<XX>.us pattern matching |
| 7 | 3. state_coverage.compute() populates all 52 jurisdictions as "missing" |
| 8 | when the DB is empty (keys absent scenario) |
| 9 | 4. state_coverage.compute() flips to "ok" after log_scrape() is called |
| 10 | 5. state_coverage.compute() correctly marks "stale" for old timestamps |
| 11 | 6. Helper functions: _confidence(), _infer_state_from_ocd(), |
| 12 | _infer_state_from_legiscan_id() |
| 13 | """ |
| 14 | |
| 15 | import os |
| 16 | import sqlite3 |
| 17 | import tempfile |
| 18 | from datetime import datetime, timedelta, timezone |
| 19 | |
| 20 | import pytest |
| 21 | |
| 22 | from ingest.source_validator import ( |
| 23 | STATE_LEG_HOSTS, |
| 24 | _is_state_leg_pattern, |
| 25 | is_allowlisted, |
| 26 | validate_source_url, |
| 27 | SourceURLError, |
| 28 | ) |
| 29 | from ingest.state_coverage import ( |
| 30 | ALL_STATES, |
| 31 | SOURCES, |
| 32 | DATA_TYPES, |
| 33 | STALE_HOURS, |
| 34 | _confidence, |
| 35 | _infer_state_from_ocd, |
| 36 | _infer_state_from_legiscan_id, |
| 37 | compute, |
| 38 | log_scrape, |
| 39 | ) |
| 40 | from ingest.db import get_connection, init_schema |
| 41 | |
| 42 | |
| 43 | |
| 44 | def _make_db(): |
| 45 | tmp = tempfile.mktemp(suffix=".db") |
| 46 | conn = get_connection(tmp) |
| 47 | init_schema(conn) |
| 48 | return conn, tmp |
| 49 | |
| 50 | |
| 51 | |
| 52 | class TestStateLegHosts: |
| 53 | def test_state_leg_hosts_is_non_empty(self): |
| 54 | assert len(STATE_LEG_HOSTS) > 0 |
| 55 | |
| 56 | def test_explicit_pa_host_is_allowed(self): |
| 57 | assert is_allowlisted("https://www.legis.state.pa.us/CFDOCS/billInfo/billInfo.cfm?sYear=2025&sInd=0&body=H&type=B&bn=1") |
| 58 | |
| 59 | def test_explicit_nj_host_is_allowed(self): |
| 60 | assert is_allowlisted("https://www.njleg.state.nj.us/Bills/2025/A0500/499_I1.HTM") |
| 61 | |
| 62 | def test_ks_org_host_is_allowed(self): |
| 63 | assert is_allowlisted("https://www.kslegislature.org/li/b2025_26/measures/hb2001/") |
| 64 | |
| 65 | def test_tx_capitol_host_is_allowed(self): |
| 66 | assert is_allowlisted("https://capitol.state.tx.us/BillLookup/History.aspx?LegSess=89R&Bill=HB1") |
| 67 | |
| 68 | def test_mn_leg_host_is_allowed(self): |
| 69 | assert is_allowlisted("https://www.leg.state.mn.us/cgi-bin/bi?mode=1&session=91") |
| 70 | |
| 71 | def test_ms_billstatus_host_is_allowed(self): |
| 72 | assert is_allowlisted("https://billstatus.ls.state.ms.us/2025/pdf/history/SB/SB2001.xml") |
| 73 | |
| 74 | def test_nh_gencourt_host_is_allowed(self): |
| 75 | assert is_allowlisted("https://www.gencourt.state.nh.us/legislation/2025/SB0001.html") |
| 76 | |
| 77 | def test_ak_legis_host_is_allowed(self): |
| 78 | assert is_allowlisted("https://www.legis.state.ak.us/basis/bill_status.asp?session=34&bill=SB1") |
| 79 | |
| 80 | def test_ar_arkleg_host_is_allowed(self): |
| 81 | assert is_allowlisted("https://www.arkleg.state.ar.us/Bills/Detail?id=SB1&ddBienniumSession=2025%2F2025R") |
| 82 | |
| 83 | def test_dotgov_still_passes(self): |
| 84 | """Existing .gov URLs must still pass after the STATE_LEG_HOSTS addition.""" |
| 85 | gov_urls = [ |
| 86 | "https://leginfo.legislature.ca.gov/faces/billStatusClient.xhtml?bill_id=202520260AB1", |
| 87 | "https://leg.colorado.gov/bills/hb25-1001", |
| 88 | "https://legislature.mi.gov/documents/2025-2026/billengrossed/House/htm/2025-HEB-0001.htm", |
| 89 | "https://malegislature.gov/Bills/194/H1", |
| 90 | "https://www.congress.gov/bill/119th-congress/house-bill/1", |
| 91 | ] |
| 92 | for url in gov_urls: |
| 93 | assert is_allowlisted(url), f"Expected .gov URL to pass: {url}" |
| 94 | |
| 95 | def test_aggregator_hosts_rejected(self): |
| 96 | """OpenStates and LegiScan URLs must NOT be accepted as source_url.""" |
| 97 | bad_urls = [ |
| 98 | "https://openstates.org/ca/bills/20252026/AB1/", |
| 99 | "https://legiscan.com/CA/bill/AB1/2025", |
| 100 | "https://api.legiscan.com/", |
| 101 | ] |
| 102 | for url in bad_urls: |
| 103 | assert not is_allowlisted(url), f"Aggregator URL should be rejected: {url}" |
| 104 | |
| 105 | |
| 106 | |
| 107 | class TestStateLegPattern: |
| 108 | def test_pattern_matches_pa(self): |
| 109 | assert _is_state_leg_pattern("legis.state.pa.us") |
| 110 | |
| 111 | def test_pattern_matches_pa_with_www(self): |
| 112 | assert _is_state_leg_pattern("www.legis.state.pa.us") |
| 113 | |
| 114 | def test_pattern_matches_nj(self): |
| 115 | assert _is_state_leg_pattern("njleg.state.nj.us") |
| 116 | |
| 117 | def test_pattern_matches_mn_deep(self): |
| 118 | assert _is_state_leg_pattern("www.house.leg.state.mn.us") |
| 119 | |
| 120 | def test_pattern_matches_ms(self): |
| 121 | assert _is_state_leg_pattern("billstatus.ls.state.ms.us") |
| 122 | |
| 123 | def test_pattern_rejects_govtrack(self): |
| 124 | assert not _is_state_leg_pattern("govtrack.us") |
| 125 | |
| 126 | def test_pattern_rejects_short_us(self): |
| 127 | assert not _is_state_leg_pattern("example.us") |
| 128 | |
| 129 | def test_pattern_rejects_no_state_segment(self): |
| 130 | assert not _is_state_leg_pattern("www.legislature.mi.gov") |
| 131 | |
| 132 | def test_pattern_rejects_wrong_tld(self): |
| 133 | assert not _is_state_leg_pattern("legis.state.ca.com") |
| 134 | |
| 135 | def test_full_url_via_is_allowlisted(self): |
| 136 | assert is_allowlisted("https://gencourt.state.nh.us/legislation/2025/SB0001.html") |
| 137 | |
| 138 | |
| 139 | |
| 140 | class TestConfidence: |
| 141 | def test_none_is_missing(self): |
| 142 | assert _confidence(None) == "missing" |
| 143 | |
| 144 | def test_empty_string_is_missing(self): |
| 145 | assert _confidence("") == "missing" |
| 146 | |
| 147 | def test_recent_timestamp_is_ok(self): |
| 148 | recent = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() |
| 149 | assert _confidence(recent) == "ok" |
| 150 | |
| 151 | def test_exactly_stale_threshold_is_stale(self): |
| 152 | old = (datetime.now(timezone.utc) - timedelta(hours=STALE_HOURS + 1)).isoformat() |
| 153 | assert _confidence(old) == "stale" |
| 154 | |
| 155 | def test_very_old_is_stale(self): |
| 156 | ancient = "2020-01-01T00:00:00+00:00" |
| 157 | assert _confidence(ancient) == "stale" |
| 158 | |
| 159 | def test_invalid_timestamp_is_missing(self): |
| 160 | assert _confidence("not-a-timestamp") == "missing" |
| 161 | |
| 162 | def test_utc_z_suffix_parses(self): |
| 163 | recent = (datetime.now(timezone.utc) - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ") |
| 164 | assert _confidence(recent) == "ok" |
| 165 | |
| 166 | |
| 167 | |
| 168 | class TestIdParsing: |
| 169 | def test_ocd_state(self): |
| 170 | assert _infer_state_from_ocd( |
| 171 | "ocd-bill/country:us/state:ca/legislature/2025-2026/HB1" |
| 172 | ) == "CA" |
| 173 | |
| 174 | def test_ocd_district_dc(self): |
| 175 | assert _infer_state_from_ocd( |
| 176 | "ocd-jurisdiction/country:us/district:dc/government" |
| 177 | ) == "DC" |
| 178 | |
| 179 | def test_ocd_territory_pr(self): |
| 180 | assert _infer_state_from_ocd( |
| 181 | "ocd-jurisdiction/country:us/territory:pr/government" |
| 182 | ) == "PR" |
| 183 | |
| 184 | def test_ocd_empty_returns_empty(self): |
| 185 | assert _infer_state_from_ocd("ocd-bill/country:us/") == "" |
| 186 | |
| 187 | def test_legiscan_id_ca(self): |
| 188 | assert _infer_state_from_legiscan_id("legiscan-CA-12345") == "CA" |
| 189 | |
| 190 | def test_legiscan_id_dc(self): |
| 191 | assert _infer_state_from_legiscan_id("legiscan-DC-999") == "DC" |
| 192 | |
| 193 | def test_legiscan_id_wrong_format_returns_empty(self): |
| 194 | assert _infer_state_from_legiscan_id("openstates-CA-1") == "" |
| 195 | |
| 196 | def test_legiscan_id_no_state_returns_empty(self): |
| 197 | assert _infer_state_from_legiscan_id("legiscan-12345") == "" |
| 198 | |
| 199 | |
| 200 | |
| 201 | class TestStateCoverageCompute: |
| 202 | """ |
| 203 | Core honesty layer tests. These run without any API keys - they verify |
| 204 | that compute() works off DB state alone. |
| 205 | """ |
| 206 | |
| 207 | def test_all_states_list_length(self): |
| 208 | """ALL_STATES must contain exactly 52 entries (50 + DC + PR).""" |
| 209 | assert len(ALL_STATES) == 52 |
| 210 | |
| 211 | def test_all_states_has_dc_and_pr(self): |
| 212 | assert "DC" in ALL_STATES |
| 213 | assert "PR" in ALL_STATES |
| 214 | |
| 215 | def test_all_states_are_uppercase_two_char(self): |
| 216 | for s in ALL_STATES: |
| 217 | assert len(s) == 2, f"State code not 2 chars: {s!r}" |
| 218 | assert s.isupper(), f"State code not uppercase: {s!r}" |
| 219 | |
| 220 | def test_empty_db_produces_all_missing(self): |
| 221 | """With no data in DB, every (state, source, data_type) is 'missing'.""" |
| 222 | conn, tmp = _make_db() |
| 223 | conn.close() |
| 224 | try: |
| 225 | rows = compute(tmp) |
| 226 | expected = len(ALL_STATES) * len(SOURCES) * len(DATA_TYPES) |
| 227 | assert len(rows) == expected, ( |
| 228 | f"Expected {expected} rows, got {len(rows)}" |
| 229 | ) |
| 230 | for row in rows: |
| 231 | assert row["confidence"] == "missing", ( |
| 232 | f"Expected missing for {row['state']}/{row['source']}/{row['data_type']}, " |
| 233 | f"got {row['confidence']!r}" |
| 234 | ) |
| 235 | assert row["last_scrape"] is None |
| 236 | finally: |
| 237 | os.unlink(tmp) |
| 238 | |
| 239 | def test_row_count_matches_52_times_sources_times_types(self): |
| 240 | conn, tmp = _make_db() |
| 241 | conn.close() |
| 242 | try: |
| 243 | rows = compute(tmp) |
| 244 | assert len(rows) == 52 * 2 * 3 |
| 245 | finally: |
| 246 | os.unlink(tmp) |
| 247 | |
| 248 | def test_after_log_scrape_confidence_is_ok(self): |
| 249 | """After log_scrape(), the relevant row should be 'ok'.""" |
| 250 | conn, tmp = _make_db() |
| 251 | try: |
| 252 | log_scrape(conn, "openstates", "CA", "bills", 10) |
| 253 | conn.commit() |
| 254 | conn.close() |
| 255 | rows = compute(tmp) |
| 256 | target = [ |
| 257 | r for r in rows |
| 258 | if r["state"] == "CA" |
| 259 | and r["source"] == "openstates" |
| 260 | and r["data_type"] == "bills" |
| 261 | ] |
| 262 | assert len(target) == 1 |
| 263 | assert target[0]["confidence"] == "ok" |
| 264 | missing = [ |
| 265 | r for r in rows |
| 266 | if r["state"] == "CA" |
| 267 | and r["source"] == "openstates" |
| 268 | and r["data_type"] != "bills" |
| 269 | ] |
| 270 | for r in missing: |
| 271 | assert r["confidence"] == "missing" |
| 272 | finally: |
| 273 | os.unlink(tmp) |
| 274 | |
| 275 | def test_stale_timestamp_marks_stale(self): |
| 276 | """A scrape older than STALE_HOURS should show as 'stale'.""" |
| 277 | conn, tmp = _make_db() |
| 278 | try: |
| 279 | old_ts = ( |
| 280 | datetime.now(timezone.utc) - timedelta(hours=STALE_HOURS + 2) |
| 281 | ).isoformat() |
| 282 | log_scrape(conn, "legiscan", "TX", "bills", 5, old_ts) |
| 283 | conn.commit() |
| 284 | conn.close() |
| 285 | rows = compute(tmp) |
| 286 | target = [ |
| 287 | r for r in rows |
| 288 | if r["state"] == "TX" |
| 289 | and r["source"] == "legiscan" |
| 290 | and r["data_type"] == "bills" |
| 291 | ] |
| 292 | assert len(target) == 1 |
| 293 | assert target[0]["confidence"] == "stale" |
| 294 | finally: |
| 295 | os.unlink(tmp) |
| 296 | |
| 297 | def test_compute_is_idempotent(self): |
| 298 | """Calling compute() twice should produce the same rows.""" |
| 299 | conn, tmp = _make_db() |
| 300 | conn.close() |
| 301 | try: |
| 302 | rows1 = compute(tmp) |
| 303 | rows2 = compute(tmp) |
| 304 | assert len(rows1) == len(rows2) |
| 305 | for r1, r2 in zip(rows1, rows2): |
| 306 | assert r1["state"] == r2["state"] |
| 307 | assert r1["confidence"] == r2["confidence"] |
| 308 | finally: |
| 309 | os.unlink(tmp) |
| 310 | |
| 311 | def test_no_duplicate_rows_in_output(self): |
| 312 | conn, tmp = _make_db() |
| 313 | conn.close() |
| 314 | try: |
| 315 | rows = compute(tmp) |
| 316 | seen_keys = set() |
| 317 | for r in rows: |
| 318 | key = (r["state"], r["source"], r["data_type"]) |
| 319 | assert key not in seen_keys, f"Duplicate key: {key}" |
| 320 | seen_keys.add(key) |
| 321 | finally: |
| 322 | os.unlink(tmp) |
| 323 | |
| 324 | def test_all_50_states_present_in_output(self): |
| 325 | conn, tmp = _make_db() |
| 326 | conn.close() |
| 327 | try: |
| 328 | rows = compute(tmp) |
| 329 | states_in_output = {r["state"] for r in rows} |
| 330 | for state in ALL_STATES: |
| 331 | assert state in states_in_output, f"State {state} missing from output" |
| 332 | finally: |
| 333 | os.unlink(tmp) |