● Zion Boggan
repos/Docket/services/ingest/tests/test_state_coverage.py
zionboggan.com ↗
333 lines · python
History for this file →
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)