| 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) |