● Zion Boggan
repos/Docket/services/ingest/tests/test_votes_house_parser.py
zionboggan.com ↗
526 lines · python
History for this file →
1
"""
2
Tests for votes_house XML parser and votes_enrich Senate parser.
3
 
4
Coverage:
5
  • parse_house_vote_xml()  -  position enum, bill linkage, aggregate counts
6
  • _normalize_position()   -  canonical mapping including edge cases
7
  • _parse_legis_num()      -  bill-type normalization
8
  • parse_senate_vote_positions()  -  lis_member_id extraction, position mapping
9
  • Integration: schema setup + position insertion via temp DB
10
"""
11
 
12
import os
13
import sqlite3
14
import tempfile
15
 
16
import pytest
17
 
18
from ingest.db import get_connection, init_schema
19
from ingest.pollers.members import ensure_votes_schema
20
from ingest.pollers.votes_house import (
21
    _normalize_position,
22
    _parse_legis_num,
23
    parse_house_vote_xml,
24
)
25
from ingest.pollers.votes_enrich import parse_senate_vote_positions
26
 
27
 
28
 
29
SAMPLE_HOUSE_XML = """\
30
<?xml version="1.0" encoding="UTF-8"?>
31
<rollcall-vote>
32
  <vote-metadata>
33
    <majority>Republican</majority>
34
    <congress>119</congress>
35
    <session>1</session>
36
    <chamber>U.S. House of Representatives</chamber>
37
    <rollcall-num>42</rollcall-num>
38
    <legis-num>H R 1000</legis-num>
39
    <vote-question>On Passage</vote-question>
40
    <vote-type>YEA-AND-NAY</vote-type>
41
    <vote-result>Passed</vote-result>
42
    <action-date>15-Mar-2025</action-date>
43
    <action-time time-etz="2:30 PM">2:30 PM</action-time>
44
    <vote-desc>Some bill</vote-desc>
45
    <vote-totals>
46
      <totals-by-party>
47
        <party>Republican</party>
48
        <yea-total>210</yea-total>
49
        <nay-total>5</nay-total>
50
        <present-total>0</present-total>
51
        <not-voting-total>2</not-voting-total>
52
      </totals-by-party>
53
      <totals-by-party>
54
        <party>Democratic</party>
55
        <yea-total>3</yea-total>
56
        <nay-total>195</nay-total>
57
        <present-total>1</present-total>
58
        <not-voting-total>1</not-voting-total>
59
      </totals-by-party>
60
      <totals-by-vote>
61
        <yea-total>213</yea-total>
62
        <nay-total>200</nay-total>
63
        <present-total>1</present-total>
64
        <not-voting-total>3</not-voting-total>
65
      </totals-by-vote>
66
    </vote-totals>
67
  </vote-metadata>
68
  <vote-data>
69
    <recorded-vote>
70
      <legislator name-id="A000055" sort-field="Aderholt" party="R" state="AL" role="Member">Aderholt</legislator>
71
      <vote>Yea</vote>
72
    </recorded-vote>
73
    <recorded-vote>
74
      <legislator name-id="B000574" sort-field="Blumenauer" party="D" state="OR" role="Member">Blumenauer</legislator>
75
      <vote>Nay</vote>
76
    </recorded-vote>
77
    <recorded-vote>
78
      <legislator name-id="C000537" sort-field="Clyburn" party="D" state="SC" role="Member">Clyburn</legislator>
79
      <vote>Present</vote>
80
    </recorded-vote>
81
    <recorded-vote>
82
      <legislator name-id="D000617" sort-field="DelBene" party="D" state="WA" role="Member">DelBene</legislator>
83
      <vote>Not Voting</vote>
84
    </recorded-vote>
85
  </vote-data>
86
</rollcall-vote>
87
"""
88
 
89
VOICE_VOTE_XML = """\
90
<?xml version="1.0" encoding="UTF-8"?>
91
<rollcall-vote>
92
  <vote-metadata>
93
    <congress>119</congress>
94
    <rollcall-num>10</rollcall-num>
95
    <vote-type>VOICE VOTE</vote-type>
96
    <vote-result>Passed</vote-result>
97
    <action-date>10-Jan-2025</action-date>
98
    <vote-totals>
99
      <totals-by-vote>
100
        <yea-total>0</yea-total>
101
        <nay-total>0</nay-total>
102
        <present-total>0</present-total>
103
        <not-voting-total>0</not-voting-total>
104
      </totals-by-vote>
105
    </vote-totals>
106
  </vote-metadata>
107
</rollcall-vote>
108
"""
109
 
110
SAMPLE_SENATE_XML = """\
111
<?xml version="1.0" encoding="UTF-8"?>
112
<roll_call_vote>
113
  <congress>119</congress>
114
  <session>1</session>
115
  <congress_year>2025</congress_year>
116
  <vote_number>100</vote_number>
117
  <vote_date>March 15, 2025, 02:00 PM</vote_date>
118
  <vote_question_text>On Passage of the Bill</vote_question_text>
119
  <vote_result_text>Passed</vote_result_text>
120
  <count>
121
    <yeas>51</yeas>
122
    <nays>49</nays>
123
    <abstains>0</abstains>
124
    <present>0</present>
125
  </count>
126
  <members>
127
    <member>
128
      <lis_member_id>S000148</lis_member_id>
129
      <member_full>Schumer, Charles E.</member_full>
130
      <last_name>Schumer</last_name>
131
      <first_name>Charles</first_name>
132
      <party>D</party>
133
      <state>NY</state>
134
      <vote_cast>Yea</vote_cast>
135
    </member>
136
    <member>
137
      <lis_member_id>S001217</lis_member_id>
138
      <member_full>Scott, Rick</member_full>
139
      <last_name>Scott</last_name>
140
      <first_name>Rick</first_name>
141
      <party>R</party>
142
      <state>FL</state>
143
      <vote_cast>Nay</vote_cast>
144
    </member>
145
    <member>
146
      <lis_member_id>S001181</lis_member_id>
147
      <member_full>Shaheen, Jeanne</member_full>
148
      <last_name>Shaheen</last_name>
149
      <first_name>Jeanne</first_name>
150
      <party>D</party>
151
      <state>NH</state>
152
      <vote_cast>Not Voting</vote_cast>
153
    </member>
154
    <member>
155
      <lis_member_id>S001184</lis_member_id>
156
      <member_full>Scott, Tim</member_full>
157
      <last_name>Scott</last_name>
158
      <first_name>Tim</first_name>
159
      <party>R</party>
160
      <state>SC</state>
161
      <vote_cast>Present</vote_cast>
162
    </member>
163
  </members>
164
</roll_call_vote>
165
"""
166
 
167
 
168
 
169
class TestNormalizePosition:
170
    def test_yea_variants(self):
171
        assert _normalize_position("Yea") == "Yea"
172
        assert _normalize_position("YEA") == "Yea"
173
        assert _normalize_position("yea") == "Yea"
174
        assert _normalize_position("Aye") == "Yea"
175
        assert _normalize_position("aye") == "Yea"
176
 
177
    def test_nay_variants(self):
178
        assert _normalize_position("Nay") == "Nay"
179
        assert _normalize_position("NAY") == "Nay"
180
        assert _normalize_position("No") == "Nay"
181
        assert _normalize_position("no") == "Nay"
182
 
183
    def test_present(self):
184
        assert _normalize_position("Present") == "Present"
185
        assert _normalize_position("present") == "Present"
186
        assert _normalize_position("PRESENT") == "Present"
187
 
188
    def test_not_voting(self):
189
        assert _normalize_position("Not Voting") == "Not Voting"
190
        assert _normalize_position("not voting") == "Not Voting"
191
        assert _normalize_position("not-voting") == "Not Voting"
192
 
193
    def test_unknown_returns_none(self):
194
        assert _normalize_position("Abstain") is None
195
        assert _normalize_position("") is None
196
        assert _normalize_position("Pass") is None
197
 
198
    def test_positions_are_in_valid_set(self):
199
        valid = {"Yea", "Nay", "Present", "Not Voting"}
200
        for raw in ["Yea", "Nay", "Present", "Not Voting", "Aye", "No"]:
201
            result = _normalize_position(raw)
202
            assert result in valid, f"Got unexpected position {result!r} for {raw!r}"
203
 
204
 
205
 
206
class TestParseLegisNum:
207
    def test_house_bill(self):
208
        bt, num, ext = _parse_legis_num("H R 1234", 119)
209
        assert bt == "hr"
210
        assert num == "1234"
211
        assert ext == "119-hr-1234"
212
 
213
    def test_house_resolution(self):
214
        bt, num, ext = _parse_legis_num("H RES 50", 119)
215
        assert bt == "hres"
216
        assert num == "50"
217
        assert ext == "119-hres-50"
218
 
219
    def test_house_joint_resolution(self):
220
        bt, num, ext = _parse_legis_num("H J RES 88", 119)
221
        assert bt == "hjres"
222
        assert num == "88"
223
        assert ext == "119-hjres-88"
224
 
225
    def test_house_concurrent_resolution(self):
226
        bt, num, ext = _parse_legis_num("H CON RES 3", 119)
227
        assert bt == "hconres"
228
        assert num == "3"
229
        assert ext == "119-hconres-3"
230
 
231
    def test_senate_bill(self):
232
        bt, num, ext = _parse_legis_num("S 500", 119)
233
        assert bt == "s"
234
        assert num == "500"
235
        assert ext == "119-s-500"
236
 
237
    def test_senate_resolution(self):
238
        bt, num, ext = _parse_legis_num("S RES 22", 119)
239
        assert bt == "sres"
240
        assert num == "22"
241
        assert ext == "119-sres-22"
242
 
243
    def test_unparseable_returns_none_triple(self):
244
        bt, num, ext = _parse_legis_num("QUORUM CALL", 119)
245
        assert bt is None and num is None and ext is None
246
 
247
    def test_empty_returns_none_triple(self):
248
        bt, num, ext = _parse_legis_num("", 119)
249
        assert bt is None and num is None and ext is None
250
 
251
    def test_case_insensitive_prefix(self):
252
        bt, num, ext = _parse_legis_num("h r 999", 119)
253
        assert bt == "hr"
254
        assert num == "999"
255
 
256
 
257
 
258
class TestParseHouseVoteXml:
259
    SOURCE_URL = "https://clerk.house.gov/evs/2025/roll042.xml"
260
 
261
    def test_basic_fields(self):
262
        result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL)
263
        assert result is not None
264
        assert result["congress"]     == 119
265
        assert result["session"]      == 1
266
        assert result["roll_number"]  == 42
267
        assert result["action_date"]  == "2025-03-15"
268
        assert result["question"]     == "On Passage"
269
        assert result["result"]       == "Passed"
270
        assert result["source_url"]   == self.SOURCE_URL
271
 
272
    def test_aggregate_counts(self):
273
        result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL)
274
        assert result is not None
275
        assert result["yea"]        == 213
276
        assert result["nay"]        == 200
277
        assert result["present"]    == 1
278
        assert result["not_voting"] == 3
279
 
280
    def test_bill_linkage_parsed(self):
281
        result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL)
282
        assert result is not None
283
        assert result["bill_text_ref"]     == "H R 1000"
284
        assert result["legis_external_id"] == "119-hr-1000"
285
 
286
    def test_positions_extracted(self):
287
        result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL)
288
        assert result is not None
289
        positions = result["positions"]
290
        assert len(positions) == 4
291
 
292
    def test_position_bioguides(self):
293
        result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL)
294
        assert result is not None
295
        pos_by_bio = {p["bioguide"]: p["position"] for p in result["positions"]}
296
        assert pos_by_bio["A000055"] == "Yea"
297
        assert pos_by_bio["B000574"] == "Nay"
298
        assert pos_by_bio["C000537"] == "Present"
299
        assert pos_by_bio["D000617"] == "Not Voting"
300
 
301
    def test_all_positions_in_valid_enum(self):
302
        valid = {"Yea", "Nay", "Present", "Not Voting"}
303
        result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL)
304
        assert result is not None
305
        for p in result["positions"]:
306
            assert p["position"] in valid, f"Invalid position: {p['position']!r}"
307
 
308
    def test_voice_vote_returns_none(self):
309
        """Voice votes must be skipped  -  no individual record exists."""
310
        result = parse_house_vote_xml(VOICE_VOTE_XML, self.SOURCE_URL)
311
        assert result is None, "Voice vote should not produce position data"
312
 
313
    def test_malformed_xml_returns_none(self):
314
        result = parse_house_vote_xml("<broken>", self.SOURCE_URL)
315
        assert result is None
316
 
317
    def test_empty_xml_returns_none(self):
318
        result = parse_house_vote_xml("", self.SOURCE_URL)
319
        assert result is None
320
 
321
 
322
 
323
class TestParseSenateVotePositions:
324
    def test_member_count(self):
325
        positions = parse_senate_vote_positions(SAMPLE_SENATE_XML)
326
        assert len(positions) == 4
327
 
328
    def test_all_positions_valid(self):
329
        valid = {"Yea", "Nay", "Present", "Not Voting"}
330
        for p in parse_senate_vote_positions(SAMPLE_SENATE_XML):
331
            assert p["position"] in valid
332
 
333
    def test_yea_member(self):
334
        positions = parse_senate_vote_positions(SAMPLE_SENATE_XML)
335
        schumer = next(p for p in positions if p["lis_member_id"] == "S000148")
336
        assert schumer["position"] == "Yea"
337
        assert schumer["state"] == "NY"
338
 
339
    def test_nay_member(self):
340
        positions = parse_senate_vote_positions(SAMPLE_SENATE_XML)
341
        scott = next(p for p in positions if p["lis_member_id"] == "S001217")
342
        assert scott["position"] == "Nay"
343
 
344
    def test_not_voting_member(self):
345
        positions = parse_senate_vote_positions(SAMPLE_SENATE_XML)
346
        shaheen = next(p for p in positions if p["lis_member_id"] == "S001181")
347
        assert shaheen["position"] == "Not Voting"
348
 
349
    def test_present_member(self):
350
        positions = parse_senate_vote_positions(SAMPLE_SENATE_XML)
351
        tim_scott = next(p for p in positions if p["lis_member_id"] == "S001184")
352
        assert tim_scott["position"] == "Present"
353
 
354
    def test_malformed_xml_returns_empty(self):
355
        assert parse_senate_vote_positions("<broken>") == []
356
 
357
    def test_empty_xml_returns_empty(self):
358
        assert parse_senate_vote_positions("") == []
359
 
360
    def test_no_members_element_returns_empty(self):
361
        xml = "<roll_call_vote><congress>119</congress></roll_call_vote>"
362
        assert parse_senate_vote_positions(xml) == []
363
 
364
 
365
 
366
class TestSchemaAndInsertion:
367
    """Verify that the schema setup and actual DB writes work end-to-end."""
368
 
369
    def _make_db(self):
370
        tmp = tempfile.mktemp(suffix=".db")
371
        conn = get_connection(tmp)
372
        init_schema(conn)
373
        ensure_votes_schema(conn)
374
        return conn, tmp
375
 
376
    def test_members_table_created(self):
377
        conn, tmp = self._make_db()
378
        tables = {
379
            row[0]
380
            for row in conn.execute(
381
                "SELECT name FROM sqlite_master WHERE type='table'"
382
            )
383
        }
384
        assert "members" in tables
385
        assert "vote_positions" in tables
386
        conn.close()
387
        os.unlink(tmp)
388
 
389
    def test_votes_extended_columns_present(self):
390
        conn, tmp = self._make_db()
391
        info = {
392
            row[1]
393
            for row in conn.execute("PRAGMA table_info(votes)")
394
        }
395
        for col in ["congress", "session", "roll_number", "question",
396
                    "not_voting", "recorded", "vote_external_id", "bill_text_ref"]:
397
            assert col in info, f"Expected column {col!r} in votes"
398
        conn.close()
399
        os.unlink(tmp)
400
 
401
    def test_vote_positions_indexes_exist(self):
402
        conn, tmp = self._make_db()
403
        indexes = {
404
            row[0]
405
            for row in conn.execute(
406
                "SELECT name FROM sqlite_master WHERE type='index'"
407
            )
408
        }
409
        assert "idx_vote_positions_vote_id"   in indexes
410
        assert "idx_vote_positions_member_id"  in indexes
411
        conn.close()
412
        os.unlink(tmp)
413
 
414
    def test_insert_position_row(self):
415
        conn, tmp = self._make_db()
416
        conn.execute(
417
            "INSERT INTO votes (chamber, date, yea, nay, present, result, source_url) "
418
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
419
            ("house", "2025-03-15", 213, 200, 1, "Passed",
420
             "https://clerk.house.gov/evs/2025/ROLL_042.xml"),
421
        )
422
        vote_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
423
 
424
        conn.execute(
425
            "INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) "
426
            "VALUES (?, ?, ?, ?)",
427
            (vote_id, "A000055", None, "Yea"),
428
        )
429
        conn.commit()
430
 
431
        row = conn.execute(
432
            "SELECT * FROM vote_positions WHERE vote_id=?", (vote_id,)
433
        ).fetchone()
434
        assert row is not None
435
        assert row["member_bioguide"] == "A000055"
436
        assert row["position"] == "Yea"
437
 
438
        conn.close()
439
        os.unlink(tmp)
440
 
441
    def test_position_enum_enforced(self):
442
        """SQLite CHECK constraint must reject invalid position values."""
443
        conn, tmp = self._make_db()
444
        conn.execute(
445
            "INSERT INTO votes (chamber, date, yea, nay, present, result, source_url) "
446
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
447
            ("house", "2025-03-15", 1, 0, 0, "Passed",
448
             "https://clerk.house.gov/evs/2025/roll001.xml"),
449
        )
450
        vote_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
451
        conn.execute("PRAGMA foreign_keys=ON")
452
 
453
        with pytest.raises(sqlite3.IntegrityError):
454
            conn.execute(
455
                "INSERT INTO vote_positions (vote_id, member_bioguide, position) "
456
                "VALUES (?, ?, ?)",
457
                (vote_id, "A000055", "Abstain"),
458
            )
459
            conn.commit()
460
 
461
        conn.close()
462
        os.unlink(tmp)
463
 
464
    def test_both_pivots_queryable(self):
465
        """
466
        Confirm that both query directions are supported:
467
          - all positions for a vote  (vote_id → vote_positions → members)
468
          - all votes for a member   (bioguide → vote_positions → votes)
469
        """
470
        conn, tmp = self._make_db()
471
 
472
        conn.execute(
473
            "INSERT INTO members (bioguide_id, full_name, party, state, chamber, source_url) "
474
            "VALUES (?, ?, ?, ?, ?, ?)",
475
            ("A000055", "Robert Aderholt", "Republican", "AL", "house",
476
             "https://bioguide.congress.gov/search/bio/A000055"),
477
        )
478
        member_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
479
 
480
        for roll in [42, 43]:
481
            conn.execute(
482
                "INSERT INTO votes (chamber, date, yea, nay, present, result, source_url, roll_number) "
483
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
484
                ("house", "2025-03-15", 213, 200, 1, "Passed",
485
                 f"https://clerk.house.gov/evs/2025/ROLL_{roll:03d}.xml", roll),
486
            )
487
            vid = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
488
            conn.execute(
489
                "INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) "
490
                "VALUES (?, ?, ?, ?)",
491
                (vid, "A000055", member_id, "Yea"),
492
            )
493
 
494
        conn.commit()
495
 
496
        vote_id_42 = conn.execute(
497
            "SELECT id FROM votes WHERE roll_number=42"
498
        ).fetchone()["id"]
499
        pos_rows = conn.execute(
500
            """
501
            SELECT vp.position, m.full_name, m.state
502
            FROM   vote_positions vp
503
            JOIN   members m ON m.id = vp.member_id
504
            WHERE  vp.vote_id = ?
505
            """,
506
            (vote_id_42,),
507
        ).fetchall()
508
        assert len(pos_rows) == 1
509
        assert pos_rows[0]["full_name"] == "Robert Aderholt"
510
        assert pos_rows[0]["position"]  == "Yea"
511
 
512
        member_votes = conn.execute(
513
            """
514
            SELECT v.roll_number, vp.position
515
            FROM   vote_positions vp
516
            JOIN   votes v ON v.id = vp.vote_id
517
            WHERE  vp.member_bioguide = 'A000055'
518
            ORDER  BY v.roll_number
519
            """,
520
        ).fetchall()
521
        assert len(member_votes) == 2
522
        assert member_votes[0]["roll_number"] == 42
523
        assert member_votes[1]["roll_number"] == 43
524
 
525
        conn.close()
526
        os.unlink(tmp)