● Zion Boggan
repos/Docket/services/ingest/tests/test_voteview.py
zionboggan.com ↗
515 lines · python
History for this file →
1
"""
2
Tests for voteview.py  -  cast_code mapping, crosswalk building, schema setup,
3
source URL construction, and year-index helpers.
4
 
5
All network access is mocked; no live HTTP calls are made.
6
"""
7
 
8
from __future__ import annotations
9
 
10
import os
11
import sqlite3
12
import tempfile
13
 
14
import pytest
15
 
16
from ingest.db import get_connection, init_schema
17
from ingest.pollers.members import ensure_votes_schema
18
from ingest.pollers.voteview import (
19
    CAST_CODE_MAP,
20
    _derive_session,
21
    _house_source_url,
22
    _senate_source_url,
23
    build_crosswalk,
24
    cast_code_to_position,
25
    completeness_report,
26
    ensure_voteview_schema,
27
    votes_by_member_year,
28
    votes_in_year,
29
)
30
 
31
 
32
 
33
def _make_db() -> tuple[sqlite3.Connection, str]:
34
    """Create a fresh temp-file DB with all schemas applied."""
35
    tmp = tempfile.mktemp(suffix=".db")
36
    conn = get_connection(tmp)
37
    init_schema(conn)
38
    ensure_votes_schema(conn)
39
    ensure_voteview_schema(conn)
40
    return conn, tmp
41
 
42
 
43
 
44
class TestCastCodeToPosition:
45
    """Verify the full Voteview cast-code โ†’ canonical position mapping."""
46
 
47
    def test_code_1_yea(self):
48
        assert cast_code_to_position(1) == "Yea"
49
 
50
    def test_code_2_paired_yea(self):
51
        assert cast_code_to_position(2) == "Yea"
52
 
53
    def test_code_3_announced_yea(self):
54
        assert cast_code_to_position(3) == "Yea"
55
 
56
    def test_code_4_announced_nay(self):
57
        assert cast_code_to_position(4) == "Nay"
58
 
59
    def test_code_5_paired_nay(self):
60
        assert cast_code_to_position(5) == "Nay"
61
 
62
    def test_code_6_nay(self):
63
        assert cast_code_to_position(6) == "Nay"
64
 
65
    def test_code_7_present_v1(self):
66
        assert cast_code_to_position(7) == "Present"
67
 
68
    def test_code_8_present_v2(self):
69
        assert cast_code_to_position(8) == "Present"
70
 
71
    def test_code_9_not_voting(self):
72
        assert cast_code_to_position(9) == "Not Voting"
73
 
74
    def test_code_0_not_a_member(self):
75
        """Code 0 = not a member / absent no record โ†’ Not Voting."""
76
        assert cast_code_to_position(0) == "Not Voting"
77
 
78
    def test_all_codes_covered(self):
79
        """Every code in the map must return a valid canonical position."""
80
        valid = {"Yea", "Nay", "Present", "Not Voting"}
81
        for code in range(0, 10):
82
            pos = cast_code_to_position(code)
83
            assert pos in valid, f"cast_code {code} โ†’ {pos!r} is not in {valid}"
84
 
85
    def test_string_codes_accepted(self):
86
        """CSV columns are strings; the function must coerce them."""
87
        assert cast_code_to_position("1") == "Yea"
88
        assert cast_code_to_position("6") == "Nay"
89
        assert cast_code_to_position("9") == "Not Voting"
90
 
91
    def test_float_string_accepted(self):
92
        """Voteview CSVs sometimes encode integers as floats (e.g. '1.0')."""
93
        assert cast_code_to_position("1.0") == "Yea"
94
        assert cast_code_to_position("6.0") == "Nay"
95
 
96
    def test_unknown_code_returns_none(self):
97
        assert cast_code_to_position(99)  is None
98
        assert cast_code_to_position(-1)  is None
99
        assert cast_code_to_position(10)  is None
100
 
101
    def test_bad_input_returns_none(self):
102
        assert cast_code_to_position("") is None
103
        assert cast_code_to_position(None) is None
104
        assert cast_code_to_position("x") is None
105
 
106
    def test_map_has_exactly_ten_entries(self):
107
        """The codebook defines codes 0-9; no other codes should exist."""
108
        assert set(CAST_CODE_MAP.keys()) == set(range(0, 10))
109
 
110
    def test_yea_group_codes_all_map_to_yea(self):
111
        for code in (1, 2, 3):
112
            assert CAST_CODE_MAP[code] == "Yea", f"code {code} should be Yea"
113
 
114
    def test_nay_group_codes_all_map_to_nay(self):
115
        for code in (4, 5, 6):
116
            assert CAST_CODE_MAP[code] == "Nay", f"code {code} should be Nay"
117
 
118
    def test_present_group_codes_map_to_present(self):
119
        for code in (7, 8):
120
            assert CAST_CODE_MAP[code] == "Present", f"code {code} should be Present"
121
 
122
    def test_not_voting_group_codes_map_to_not_voting(self):
123
        for code in (0, 9):
124
            assert CAST_CODE_MAP[code] == "Not Voting", f"code {code} should be Not Voting"
125
 
126
 
127
 
128
class TestSourceUrls:
129
    def test_house_url_format(self):
130
        url = _house_source_url(2023, 42)
131
        assert url == "https://clerk.house.gov/Votes/2023/roll042"
132
        assert url.startswith("https://clerk.house.gov")
133
 
134
    def test_house_url_zero_pads_to_3_digits(self):
135
        assert _house_source_url(2024, 1).endswith("roll001")
136
        assert _house_source_url(2024, 99).endswith("roll099")
137
        assert _house_source_url(2024, 100).endswith("roll100")
138
 
139
    def test_senate_url_format(self):
140
        url = _senate_source_url(118, 1, 42)
141
        assert url == (
142
            "https://www.senate.gov/legislative/LIS/roll_call_votes/"
143
            "vote1181/vote_118_1_00042.xml"
144
        )
145
        assert "senate.gov" in url
146
 
147
    def test_senate_url_zero_pads_roll_to_5_digits(self):
148
        url = _senate_source_url(119, 2, 7)
149
        assert "vote_119_2_00007.xml" in url
150
 
151
    def test_house_url_host_is_gov(self):
152
        from ingest.source_validator import is_allowlisted
153
        assert is_allowlisted(_house_source_url(2025, 1))
154
 
155
    def test_senate_url_host_is_gov(self):
156
        from ingest.source_validator import is_allowlisted
157
        assert is_allowlisted(_senate_source_url(119, 1, 1))
158
 
159
 
160
 
161
class TestDeriveSession:
162
    def test_csv_session_preferred(self):
163
        assert _derive_session("2023-01-15", "1") == 1
164
        assert _derive_session("2024-06-01", "2") == 2
165
 
166
    def test_odd_year_fallback_to_session_1(self):
167
        assert _derive_session("2023-03-01", "") == 1
168
        assert _derive_session("2021-07-04", "") == 1
169
 
170
    def test_even_year_fallback_to_session_2(self):
171
        assert _derive_session("2024-11-01", "") == 2
172
        assert _derive_session("2022-01-01", "") == 2
173
 
174
    def test_bad_date_defaults_to_1(self):
175
        assert _derive_session("", "") == 1
176
        assert _derive_session("bad-date", "x") == 1
177
 
178
 
179
 
180
class TestEnsureVoteviewSchema:
181
    def test_crosswalk_table_created(self):
182
        conn, tmp = _make_db()
183
        tables = {
184
            row[0]
185
            for row in conn.execute(
186
                "SELECT name FROM sqlite_master WHERE type='table'"
187
            )
188
        }
189
        assert "voteview_crosswalk" in tables
190
        conn.close()
191
        os.unlink(tmp)
192
 
193
    def test_year_column_added_to_votes(self):
194
        conn, tmp = _make_db()
195
        cols = {
196
            row[1]
197
            for row in conn.execute("PRAGMA table_info(votes)")
198
        }
199
        assert "year" in cols, "year column must be added to votes table"
200
        assert "description" in cols
201
        assert "voteview_rollnumber" in cols
202
        conn.close()
203
        os.unlink(tmp)
204
 
205
    def test_year_index_exists(self):
206
        conn, tmp = _make_db()
207
        indexes = {
208
            row[0]
209
            for row in conn.execute(
210
                "SELECT name FROM sqlite_master WHERE type='index'"
211
            )
212
        }
213
        assert "idx_votes_year" in indexes
214
        conn.close()
215
        os.unlink(tmp)
216
 
217
    def test_idempotent_multiple_calls(self):
218
        conn, tmp = _make_db()
219
        ensure_voteview_schema(conn)
220
        ensure_voteview_schema(conn)
221
        conn.close()
222
        os.unlink(tmp)
223
 
224
 
225
 
226
class TestBuildCrosswalk:
227
    """Build the crosswalk from a mock legislators JSON (no HTTP)."""
228
 
229
    _MOCK_LEGISLATORS = [
230
        {
231
            "id": {"bioguide": "A000001", "icpsr": 12345},
232
            "name": {"official_full": "Alice Anderson"},
233
            "terms": [{"type": "rep", "state": "NY"}],
234
        },
235
        {
236
            "id": {"bioguide": "B000002", "icpsr": 67890},
237
            "name": {"official_full": "Bob Brown"},
238
            "terms": [{"type": "sen", "state": "CA"}],
239
        },
240
        {
241
            "id": {"bioguide": "C000003"},
242
            "name": {"official_full": "Carol Carter"},
243
            "terms": [{"type": "rep", "state": "TX"}],
244
        },
245
        {
246
            "id": {"bioguide": "D000004", "icpsr": 11111, "icpsr_hn": 22222},
247
            "name": {"official_full": "Dave Davis"},
248
            "terms": [{"type": "rep", "state": "FL"}],
249
        },
250
    ]
251
 
252
    def _make_crosswalk_from_mock(self) -> tuple[dict, sqlite3.Connection, str]:
253
        """
254
        Build the crosswalk by directly inserting mock data, bypassing HTTP.
255
        Returns (crosswalk_dict, conn, tmp_path).
256
        """
257
        conn, tmp = _make_db()
258
 
259
        now = "2025-01-01T00:00:00+00:00"
260
        for leg in self._MOCK_LEGISLATORS:
261
            ids      = leg.get("id", {})
262
            bioguide = ids.get("bioguide", "").strip()
263
            full_name = leg.get("name", {}).get("official_full", "")
264
            for field in ("icpsr", "icpsr_hn"):
265
                raw = ids.get(field)
266
                if raw is None:
267
                    continue
268
                try:
269
                    icpsr_int = int(float(raw))
270
                except (ValueError, TypeError):
271
                    continue
272
                conn.execute(
273
                    """
274
                    INSERT INTO voteview_crosswalk
275
                        (icpsr_id, bioguide_id, full_name, fetched_at)
276
                    VALUES (?, ?, ?, ?)
277
                    ON CONFLICT(icpsr_id) DO UPDATE SET
278
                        bioguide_id = excluded.bioguide_id,
279
                        full_name   = excluded.full_name,
280
                        fetched_at  = excluded.fetched_at
281
                    """,
282
                    (icpsr_int, bioguide, full_name, now),
283
                )
284
        conn.commit()
285
 
286
        crosswalk = build_crosswalk(conn, force_refresh=False)
287
        return crosswalk, conn, tmp
288
 
289
    def test_primary_icpsr_mapped(self):
290
        cw, conn, tmp = self._make_crosswalk_from_mock()
291
        assert cw[12345] == "A000001"
292
        assert cw[67890] == "B000002"
293
        conn.close()
294
        os.unlink(tmp)
295
 
296
    def test_icpsr_hn_also_mapped(self):
297
        """Both icpsr and icpsr_hn for D000004 should map to the same bioguide."""
298
        cw, conn, tmp = self._make_crosswalk_from_mock()
299
        assert cw[11111] == "D000004"
300
        assert cw[22222] == "D000004"
301
        conn.close()
302
        os.unlink(tmp)
303
 
304
    def test_member_without_icpsr_not_in_crosswalk(self):
305
        cw, conn, tmp = self._make_crosswalk_from_mock()
306
        bioguides = set(cw.values())
307
        assert "C000003" not in bioguides or all(
308
            v != "C000003" for v in cw.values()
309
        )
310
        conn.close()
311
        os.unlink(tmp)
312
 
313
    def test_cached_load_returns_same_data(self):
314
        """Second call (force_refresh=False) should return the same crosswalk."""
315
        cw1, conn, tmp = self._make_crosswalk_from_mock()
316
        cw2 = build_crosswalk(conn, force_refresh=False)
317
        assert cw1 == cw2
318
        conn.close()
319
        os.unlink(tmp)
320
 
321
    def test_unknown_icpsr_returns_none_from_get(self):
322
        cw, conn, tmp = self._make_crosswalk_from_mock()
323
        assert cw.get(99999) is None
324
        conn.close()
325
        os.unlink(tmp)
326
 
327
 
328
 
329
class TestYearIndexHelpers:
330
    """
331
    Seed a minimal DB with known votes + positions, then verify the
332
    year-index helper queries return the correct rows.
333
    """
334
 
335
    def _seed_db(self) -> tuple[sqlite3.Connection, str]:
336
        conn, tmp = _make_db()
337
 
338
        for bioguide, name, state in [
339
            ("N000002", "Jerry Nadler", "NY"),
340
            ("C000714", "James Clyburn", "SC"),
341
        ]:
342
            conn.execute(
343
                "INSERT INTO members "
344
                "(bioguide_id, full_name, party, state, chamber, source_url) "
345
                "VALUES (?, ?, ?, ?, ?, ?)",
346
                (
347
                    bioguide, name, "D", state, "house",
348
                    f"https://bioguide.congress.gov/search/bio/{bioguide}",
349
                ),
350
            )
351
 
352
        votes_data = [
353
            ("house", "2023-01-19", 2023, 213, 215, "Failed",
354
             "https://clerk.house.gov/Votes/2023/roll010", 118, 10),
355
            ("house", "2023-06-01", 2023, 220, 210, "Passed",
356
             "https://clerk.house.gov/Votes/2023/roll200", 118, 200),
357
            ("house", "2024-03-15", 2024, 230, 200, "Passed",
358
             "https://clerk.house.gov/Votes/2024/roll100", 118, 100),
359
        ]
360
        vote_ids = []
361
        for row in votes_data:
362
            conn.execute(
363
                """
364
                INSERT INTO votes
365
                    (chamber, date, year, yea, nay, result, source_url,
366
                     congress, roll_number, recorded, present, not_voting)
367
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0)
368
                """,
369
                row,
370
            )
371
            vid = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
372
            vote_ids.append(vid)
373
 
374
        for vid, pos in zip(vote_ids, ["Yea", "Nay", "Yea"]):
375
            conn.execute(
376
                "INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) "
377
                "VALUES (?, ?, NULL, ?)",
378
                (vid, "N000002", pos),
379
            )
380
        conn.execute(
381
            "INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) "
382
            "VALUES (?, ?, NULL, ?)",
383
            (vote_ids[0], "C000714", "Yea"),
384
        )
385
        conn.commit()
386
        return conn, tmp
387
 
388
    def test_votes_by_member_year_returns_correct_count(self):
389
        conn, tmp = self._seed_db()
390
        rows = conn.execute(
391
            """
392
            SELECT * FROM vote_positions vp
393
            JOIN votes v ON v.id = vp.vote_id
394
            WHERE vp.member_bioguide = 'N000002' AND v.year = 2023
395
            """
396
        ).fetchall()
397
        assert len(rows) == 2, "N000002 voted on 2 rolls in 2023"
398
        conn.close()
399
        os.unlink(tmp)
400
 
401
    def test_votes_by_member_year_2024(self):
402
        conn, tmp = self._seed_db()
403
        rows = conn.execute(
404
            """
405
            SELECT * FROM vote_positions vp
406
            JOIN votes v ON v.id = vp.vote_id
407
            WHERE vp.member_bioguide = 'N000002' AND v.year = 2024
408
            """
409
        ).fetchall()
410
        assert len(rows) == 1
411
        assert dict(rows[0])["date"] == "2024-03-15"
412
        conn.close()
413
        os.unlink(tmp)
414
 
415
    def test_votes_by_member_year_empty_for_nonexistent_year(self):
416
        conn, tmp = self._seed_db()
417
        rows = conn.execute(
418
            """
419
            SELECT * FROM vote_positions vp
420
            JOIN votes v ON v.id = vp.vote_id
421
            WHERE vp.member_bioguide = 'N000002' AND v.year = 2019
422
            """
423
        ).fetchall()
424
        assert rows == []
425
        conn.close()
426
        os.unlink(tmp)
427
 
428
    def test_votes_in_year_2023(self):
429
        conn, tmp = self._seed_db()
430
        rows = conn.execute(
431
            "SELECT * FROM votes WHERE year = 2023 AND recorded = 1"
432
        ).fetchall()
433
        assert len(rows) == 2
434
        dates = sorted(dict(r)["date"] for r in rows)
435
        assert dates == ["2023-01-19", "2023-06-01"]
436
        conn.close()
437
        os.unlink(tmp)
438
 
439
    def test_votes_in_year_2024(self):
440
        conn, tmp = self._seed_db()
441
        rows = conn.execute(
442
            "SELECT * FROM votes WHERE year = 2024 AND recorded = 1"
443
        ).fetchall()
444
        assert len(rows) == 1
445
        assert dict(rows[0])["roll_number"] == 100
446
        conn.close()
447
        os.unlink(tmp)
448
 
449
    def test_year_index_is_used(self):
450
        """EXPLAIN QUERY PLAN should mention the year index (not a full scan)."""
451
        conn, tmp = self._seed_db()
452
        plan = conn.execute(
453
            "EXPLAIN QUERY PLAN SELECT * FROM votes WHERE year = 2023"
454
        ).fetchall()
455
        plan_text = " ".join(
456
            " ".join(str(col) for col in tuple(row))
457
            for row in plan
458
        ).lower()
459
        assert "idx_votes_year" in plan_text, (
460
            f"Expected idx_votes_year in query plan; got: {plan_text!r}"
461
        )
462
        conn.close()
463
        os.unlink(tmp)
464
 
465
    def test_position_enum_enforced_for_voteview_positions(self):
466
        """vote_positions CHECK constraint rejects cast_code-derived invalid positions."""
467
        conn, tmp = _make_db()
468
        conn.execute(
469
            "INSERT INTO votes (chamber, date, year, yea, nay, result, source_url, recorded) "
470
            "VALUES ('house', '2023-01-01', 2023, 1, 0, 'Passed', "
471
            "'https://clerk.house.gov/Votes/2023/roll001', 1)"
472
        )
473
        vid = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
474
        with pytest.raises(sqlite3.IntegrityError):
475
            conn.execute(
476
                "INSERT INTO vote_positions (vote_id, member_bioguide, position) "
477
                "VALUES (?, ?, ?)",
478
                (vid, "Z000001", "Abstain"),
479
            )
480
            conn.commit()
481
        conn.close()
482
        os.unlink(tmp)
483
 
484
 
485
 
486
class TestSchemaIdempotency:
487
    """Running schema setup multiple times must not corrupt the DB."""
488
 
489
    def test_full_schema_setup_multiple_times(self):
490
        conn, tmp = _make_db()
491
        for _ in range(3):
492
            init_schema(conn)
493
            ensure_votes_schema(conn)
494
            ensure_voteview_schema(conn)
495
        tables = {
496
            row[0]
497
            for row in conn.execute(
498
                "SELECT name FROM sqlite_master WHERE type='table'"
499
            )
500
        }
501
        for t in ("votes", "vote_positions", "members", "voteview_crosswalk"):
502
            assert t in tables
503
        conn.close()
504
        os.unlink(tmp)
505
 
506
    def test_cast_code_coverage_against_schema_enum(self):
507
        """
508
        Every position value produced by CAST_CODE_MAP must satisfy the
509
        vote_positions CHECK constraint (Yea, Nay, Present, Not Voting).
510
        """
511
        valid = {"Yea", "Nay", "Present", "Not Voting"}
512
        for code, position in CAST_CODE_MAP.items():
513
            assert position in valid, (
514
                f"CAST_CODE_MAP[{code}] = {position!r} is not in the DB enum {valid}"
515
            )