● Zion Boggan
repos/Docket/services/ingest/tests/test_meetings.py
zionboggan.com ↗
553 lines · python
History for this file →
1
"""
2
Tests for ingest/pollers/meetings.py
3
 
4
Coverage:
5
  • _parse_senate_xml()           -  full XML, empty identifier skipped
6
  • _parse_house_day_page()       -  normal row, subcommittee row, rescheduled row
7
  • _parse_house_event_type()     -  hearing / markup / business_meeting / unknown
8
  • _parse_floor_xml()            -  typical floor XML structure
9
  • _build_datetime_iso()         -  12h and 24h formats, missing inputs
10
  • _norm_senate_type()           -  all known types + unknown fallback
11
  • _norm_house_type()            -  prefix matching
12
  • _detect_status()              -  rescheduled / canceled / normal
13
  • _ensure_tables()              -  idempotent schema creation
14
  • _upsert_meeting() idempotency  -  double-upsert does not duplicate
15
  • poll_senate() with mocked HTTP
16
"""
17
 
18
import os
19
import sqlite3
20
import tempfile
21
from datetime import date
22
from unittest.mock import patch, MagicMock
23
 
24
import pytest
25
 
26
from ingest.db import get_connection, init_schema
27
from ingest.pollers.meetings import (
28
    _parse_senate_xml,
29
    _parse_house_day_page,
30
    _parse_house_event_type,
31
    _parse_floor_xml,
32
    _build_datetime_iso,
33
    _norm_senate_type,
34
    _norm_house_type,
35
    _detect_status,
36
    _ensure_tables,
37
    _upsert_meeting,
38
    _most_recent_monday,
39
    SOURCE_SENATE,
40
    SOURCE_HOUSE_CMT,
41
    SOURCE_HOUSE_FLR,
42
    SENATE_PAGE_URL,
43
)
44
 
45
 
46
 
47
def _make_db() -> tuple:
48
    """Return (conn, path) for a fresh temp DB."""
49
    path = tempfile.mktemp(suffix=".db")
50
    conn = get_connection(path)
51
    init_schema(conn)
52
    _ensure_tables(conn)
53
    return conn, path
54
 
55
 
56
 
57
@pytest.mark.parametrize("date_str,time_str,expected", [
58
    ("2026-06-03", "10:00 AM", "2026-06-03T10:00:00"),
59
    ("2026-07-15", "02:30 PM", "2026-07-15T14:30:00"),
60
    ("2026-07-15", "09:45 AM", "2026-07-15T09:45:00"),
61
    ("2026-07-15", "14:30:00", "2026-07-15T14:30:00"),
62
    ("2026-06-03", None, None),
63
    (None, "10:00 AM", None),
64
    ("", "10:00 AM", None),
65
    ("2026-06-03", "", None),
66
    ("2026-06-03", "bad time", None),
67
])
68
def test_build_datetime_iso(date_str, time_str, expected):
69
    assert _build_datetime_iso(date_str, time_str) == expected
70
 
71
 
72
 
73
@pytest.mark.parametrize("raw,expected", [
74
    ("Open Hearing", "hearing"),
75
    ("Closed Hearing", "hearing"),
76
    ("Field Hearing", "hearing"),
77
    ("Open Markup", "markup"),
78
    ("Closed Markup", "markup"),
79
    ("Open Business Meeting", "business_meeting"),
80
    ("Closed Business Meeting", "business_meeting"),
81
    ("Executive Session", "business_meeting"),
82
    ("Unknown Type", "other"),
83
    ("", "other"),
84
])
85
def test_norm_senate_type(raw, expected):
86
    assert _norm_senate_type(raw) == expected
87
 
88
 
89
 
90
@pytest.mark.parametrize("h1,expected", [
91
    ('Hearing: "The Budget Request"', "hearing"),
92
    ("Markup: FY 2027 NDAA", "markup"),
93
    ("Business Meeting to Consider Pending Business", "business_meeting"),
94
    ("Full Committee Hearing on Defense", "hearing"),
95
    ("Unknown prefix: Something", "other"),
96
    ("", "other"),
97
])
98
def test_norm_house_type(h1, expected):
99
    assert _norm_house_type(h1) == expected
100
 
101
 
102
 
103
@pytest.mark.parametrize("text,expected", [
104
    ("Rescheduled", "postponed"),
105
    ("Postponed", "postponed"),
106
    ("Canceled", "canceled"),
107
    ("Cancelled", "canceled"),
108
    ("", "scheduled"),
109
    ("Some other note", "scheduled"),
110
])
111
def test_detect_status(text, expected):
112
    assert _detect_status(text) == expected
113
 
114
 
115
 
116
def test_most_recent_monday_on_monday():
117
    mon = date(2026, 6, 22)
118
    assert _most_recent_monday(mon) == mon
119
 
120
def test_most_recent_monday_on_thursday():
121
    thu = date(2026, 6, 25)
122
    assert _most_recent_monday(thu) == date(2026, 6, 22)
123
 
124
def test_most_recent_monday_on_sunday():
125
    sun = date(2026, 6, 28)
126
    assert _most_recent_monday(sun) == date(2026, 6, 22)
127
 
128
 
129
 
130
SENATE_XML_SAMPLE = b"""<?xml version="1.0" encoding="UTF-8"?>
131
<css_meetings_scheduled>
132
  <meeting>
133
    <cmte_code/>
134
    <committee/>
135
    <type/>
136
    <date>26-JUN-2026</date>
137
    <date_iso_8601>2026-06-26</date_iso_8601>
138
    <day_of_week>Today</day_of_week>
139
    <time>12:00:00</time>
140
    <room/>
141
    <matter>No committee hearings scheduled</matter>
142
    <video_url/>
143
    <senate_cable_channel/>
144
  </meeting>
145
  <meeting>
146
    <identifier>338613</identifier>
147
    <last_update>06-26-2026 11:26:27 AM</last_update>
148
    <last_update_iso_8601>2026-06-26T11:26:27.000000Z-04:00</last_update_iso_8601>
149
    <cmte_code>SSHR00</cmte_code>
150
    <committee>Health, Education, Labor, and Pensions</committee>
151
    <type>Open Business Meeting</type>
152
    <date>15-JUL-2026 09:45 AM</date>
153
    <date_iso_8601>2026-07-15</date_iso_8601>
154
    <day_of_week>Wednesday</day_of_week>
155
    <time>09:45 AM</time>
156
    <time_iso_8601>09:45:00</time_iso_8601>
157
    <room>SD-430</room>
158
    <matter>Business meeting to consider pending calendar business.</matter>
159
  </meeting>
160
  <meeting>
161
    <identifier>338612</identifier>
162
    <last_update>06-24-2026 05:53:16 PM</last_update>
163
    <last_update_iso_8601>2026-06-24T17:53:16.000000Z-04:00</last_update_iso_8601>
164
    <cmte_code>SSAS17</cmte_code>
165
    <committee>Armed Services</committee>
166
    <sub_cmte>Personnel Subcommittee</sub_cmte>
167
    <type>Open Hearing</type>
168
    <date>15-JUL-2026 02:30 PM</date>
169
    <date_iso_8601>2026-07-15</date_iso_8601>
170
    <day_of_week>Wednesday</day_of_week>
171
    <time>02:30 PM</time>
172
    <time_iso_8601>14:30:00</time_iso_8601>
173
    <room>SR-222</room>
174
    <matter>Hearings to examine the TRICARE pharmacy program.</matter>
175
  </meeting>
176
</css_meetings_scheduled>
177
"""
178
 
179
def test_parse_senate_xml_skips_placeholder():
180
    """The first entry (no <identifier>) is a placeholder and must be skipped."""
181
    meetings = _parse_senate_xml(SENATE_XML_SAMPLE)
182
    assert len(meetings) == 2
183
 
184
def test_parse_senate_xml_business_meeting():
185
    meetings = _parse_senate_xml(SENATE_XML_SAMPLE)
186
    bm = next(m for m in meetings if m["external_id"] == "338613")
187
    assert bm["chamber"] == "Senate"
188
    assert bm["committee"] == "Health, Education, Labor, and Pensions"
189
    assert bm["date"] == "2026-07-15"
190
    assert bm["time"] == "09:45 AM"
191
    assert bm["datetime_iso"] == "2026-07-15T09:45:00"
192
    assert bm["type"] == "business_meeting"
193
    assert bm["location"] == "SD-430"
194
    assert bm["status"] == "scheduled"
195
    assert bm["source_url"] == SENATE_PAGE_URL
196
 
197
def test_parse_senate_xml_subcommittee_hearing():
198
    meetings = _parse_senate_xml(SENATE_XML_SAMPLE)
199
    h = next(m for m in meetings if m["external_id"] == "338612")
200
    assert h["type"] == "hearing"
201
    assert h["committee"] == "Personnel Subcommittee (Armed Services)"
202
    assert h["title"] == "Hearings to examine the TRICARE pharmacy program."
203
    assert h["datetime_iso"] == "2026-07-15T14:30:00"
204
 
205
def test_parse_senate_xml_bad_xml():
206
    result = _parse_senate_xml(b"not xml at all <broken>")
207
    assert result == []
208
 
209
 
210
 
211
HOUSE_DAY_HTML = """
212
<html><body>
213
<table id="MainContent_GridViewMeetings">
214
<tr><th>Title</th><th>Time</th><th>Location</th></tr>
215
<tr>
216
  <td>
217
    <a href="ByEvent.aspx?EventID=119243"
218
       title="&quot;A Review of the FY2027 DHS Budget&quot;">
219
       "A Review of the FY2027 DHS Budget"</a>
220
    <br />
221
    <span class="text-tiny" title="Committee on Homeland Security">
222
        Committee on Homeland Security
223
    </span>
224
  </td>
225
  <td><span class="text-small">10:00 AM</span></td>
226
  <td><span class="text-small">310 CHOB</span></td>
227
</tr>
228
<tr>
229
  <td>
230
    <a href="ByEvent.aspx?EventID=119340"
231
       title="Future of United States Maritime">
232
       Future of United States Maritime</a>
233
    <br />
234
    <span class="text-tiny" title="Subcommittee on Coast Guard">
235
        Subcommittee on Coast Guard
236
         (Committee on Transportation and Infrastructure)
237
    </span>
238
  </td>
239
  <td><span class="text-small">10:00 AM</span></td>
240
  <td><span class="text-small">2167 RHOB</span></td>
241
</tr>
242
<tr>
243
  <td>
244
    <a href="ByEvent.aspx?EventID=118474"
245
       title="Crypto Capital of the World">
246
       Crypto Capital of the World</a>
247
    <span class='muted'>(Rescheduled)</span>
248
    <br />
249
    <span class="text-tiny" title="Subcommittee on Oversight">
250
        Subcommittee on Oversight
251
         (Committee on Ways and Means)
252
    </span>
253
  </td>
254
  <td><span class="text-small">9:00 AM</span></td>
255
  <td><span class="text-small">1100 LHOB</span></td>
256
</tr>
257
</table>
258
</body></html>
259
"""
260
 
261
def test_parse_house_day_page_count():
262
    d = date(2026, 6, 3)
263
    meetings = _parse_house_day_page(HOUSE_DAY_HTML, d)
264
    assert len(meetings) == 3
265
 
266
def test_parse_house_day_page_basic():
267
    d = date(2026, 6, 3)
268
    meetings = _parse_house_day_page(HOUSE_DAY_HTML, d)
269
    m = next(m for m in meetings if m["external_id"] == "119243")
270
    assert m["chamber"] == "House"
271
    assert m["date"] == "2026-06-03"
272
    assert m["time"] == "10:00 AM"
273
    assert m["location"] == "310 CHOB"
274
    assert m["status"] == "scheduled"
275
    assert m["type"] is None
276
    assert "docs.house.gov" in m["source_url"]
277
    assert m["datetime_iso"] == "2026-06-03T10:00:00"
278
 
279
def test_parse_house_day_page_subcommittee():
280
    d = date(2026, 6, 3)
281
    meetings = _parse_house_day_page(HOUSE_DAY_HTML, d)
282
    m = next(m for m in meetings if m["external_id"] == "119340")
283
    assert "Subcommittee on Coast Guard" in m["committee"]
284
    assert "Transportation" in m["committee"]
285
 
286
def test_parse_house_day_page_rescheduled():
287
    d = date(2026, 6, 3)
288
    meetings = _parse_house_day_page(HOUSE_DAY_HTML, d)
289
    m = next(m for m in meetings if m["external_id"] == "118474")
290
    assert m["status"] == "postponed"
291
 
292
def test_parse_house_day_page_no_table():
293
    """Page without GridView returns empty list."""
294
    d = date(2026, 6, 3)
295
    meetings = _parse_house_day_page("<html><body>No meetings found.</body></html>", d)
296
    assert meetings == []
297
 
298
 
299
 
300
HOUSE_EVENT_HEARING = """
301
<html><body>
302
<div id="previewPanel">
303
  <div class="well">
304
    <h1>Hearing: "A Review of the FY2027 DHS Budget"
305
      <small><p>Committee on Homeland Security</p></small>
306
    </h1>
307
  </div>
308
</div>
309
</body></html>
310
"""
311
 
312
HOUSE_EVENT_MARKUP = """
313
<html><body>
314
<div id="previewPanel">
315
  <h1>Markup: FY2027 NDAA
316
    <small><p>Committee on Armed Services</p></small>
317
  </h1>
318
</div>
319
</body></html>
320
"""
321
 
322
HOUSE_EVENT_BUSINESS = """
323
<html><body>
324
<h1>Business Meeting to Consider Pending Business</h1>
325
</body></html>
326
"""
327
 
328
def test_parse_house_event_type_hearing():
329
    assert _parse_house_event_type(HOUSE_EVENT_HEARING) == "hearing"
330
 
331
def test_parse_house_event_type_markup():
332
    assert _parse_house_event_type(HOUSE_EVENT_MARKUP) == "markup"
333
 
334
def test_parse_house_event_type_business_meeting():
335
    assert _parse_house_event_type(HOUSE_EVENT_BUSINESS) == "business_meeting"
336
 
337
def test_parse_house_event_type_fallback():
338
    assert _parse_house_event_type("<html><h1>Unknown Header</h1></html>") == "other"
339
 
340
 
341
 
342
FLOOR_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
343
<floorschedule congress-num="119" week-date="2026-06-22"
344
    create-date="2026-06-16T17:55:16.977">
345
  <category type="Items that may be considered under suspension of the rules"
346
            sort-order="1">
347
    <floor-items>
348
      <floor-item id="409612">
349
        <legis-num>H.R. 7396</legis-num>
350
        <floor-text>Native American Entrepreneurial Opportunity Act</floor-text>
351
      </floor-item>
352
      <floor-item id="409613">
353
        <legis-num>H.R. 7401</legis-num>
354
        <floor-text>Small Business Lending Fraud Prevention Act</floor-text>
355
      </floor-item>
356
    </floor-items>
357
  </category>
358
  <category type="Items considered pursuant to a rule" sort-order="2">
359
    <floor-items>
360
      <floor-item id="409620">
361
        <legis-num>H.R. 1</legis-num>
362
        <floor-text>Big Beautiful Bill Act</floor-text>
363
      </floor-item>
364
    </floor-items>
365
  </category>
366
</floorschedule>
367
"""
368
 
369
def test_parse_floor_xml_count():
370
    monday = date(2026, 6, 22)
371
    source_url = "https://docs.house.gov/floor/Default.aspx?date=2026-06-22"
372
    meetings = _parse_floor_xml(FLOOR_XML, monday, source_url)
373
    assert len(meetings) == 3
374
 
375
def test_parse_floor_xml_structure():
376
    monday = date(2026, 6, 22)
377
    source_url = "https://docs.house.gov/floor/Default.aspx?date=2026-06-22"
378
    meetings = _parse_floor_xml(FLOOR_XML, monday, source_url)
379
 
380
    first = meetings[0]
381
    assert first["chamber"] == "House"
382
    assert first["type"] == "floor"
383
    assert first["date"] == "2026-06-22"
384
    assert first["location"] == "House Floor"
385
    assert first["status"] == "scheduled"
386
    assert "H.R. 7396" in first["title"]
387
    assert first["source_url"] == source_url
388
    assert first["external_id"].startswith("20260622_")
389
 
390
def test_parse_floor_xml_external_id_unique():
391
    monday = date(2026, 6, 22)
392
    source_url = "https://docs.house.gov/floor/Default.aspx?date=2026-06-22"
393
    meetings = _parse_floor_xml(FLOOR_XML, monday, source_url)
394
    ids = [m["external_id"] for m in meetings]
395
    assert len(ids) == len(set(ids)), "External IDs must be unique"
396
 
397
def test_parse_floor_xml_bad_xml():
398
    result = _parse_floor_xml(b"not xml", date(2026, 6, 22), "https://docs.house.gov/floor/")
399
    assert result == []
400
 
401
 
402
 
403
def test_ensure_tables_idempotent():
404
    conn, path = _make_db()
405
    _ensure_tables(conn)
406
    _ensure_tables(conn)
407
    tables = {r[0] for r in conn.execute(
408
        "SELECT name FROM sqlite_master WHERE type='table'"
409
    ).fetchall()}
410
    assert "meetings" in tables
411
    conn.close()
412
    os.unlink(path)
413
 
414
 
415
def _sample_meeting(**kwargs) -> dict:
416
    base = {
417
        "source":       SOURCE_SENATE,
418
        "external_id":  "338613",
419
        "chamber":      "Senate",
420
        "committee":    "Health, Education, Labor, and Pensions",
421
        "date":         "2026-07-15",
422
        "time":         "09:45 AM",
423
        "datetime_iso": "2026-07-15T09:45:00",
424
        "title":        "Business meeting to consider pending calendar business.",
425
        "subject":      "Business meeting to consider pending calendar business.",
426
        "type":         "business_meeting",
427
        "location":     "SD-430",
428
        "status":       "scheduled",
429
        "source_url":   SENATE_PAGE_URL,
430
    }
431
    base.update(kwargs)
432
    return base
433
 
434
 
435
def test_upsert_meeting_inserts_row():
436
    conn, path = _make_db()
437
    m = _sample_meeting()
438
    _upsert_meeting(conn, m)
439
    conn.commit()
440
    row = conn.execute(
441
        "SELECT * FROM meetings WHERE source=? AND external_id=?",
442
        (m["source"], m["external_id"])
443
    ).fetchone()
444
    assert row is not None
445
    assert row["committee"] == "Health, Education, Labor, and Pensions"
446
    assert row["type"] == "business_meeting"
447
    conn.close()
448
    os.unlink(path)
449
 
450
 
451
def test_upsert_meeting_idempotent():
452
    """Double-upsert must not create duplicate rows."""
453
    conn, path = _make_db()
454
    m = _sample_meeting()
455
    _upsert_meeting(conn, m)
456
    _upsert_meeting(conn, m)
457
    conn.commit()
458
    count = conn.execute(
459
        "SELECT COUNT(*) FROM meetings WHERE source=? AND external_id=?",
460
        (m["source"], m["external_id"])
461
    ).fetchone()[0]
462
    assert count == 1
463
    conn.close()
464
    os.unlink(path)
465
 
466
 
467
def test_upsert_meeting_updates_status():
468
    """Upsert with changed status overwrites the row."""
469
    conn, path = _make_db()
470
    m = _sample_meeting(status="scheduled")
471
    _upsert_meeting(conn, m)
472
    conn.commit()
473
 
474
    m2 = _sample_meeting(status="canceled")
475
    _upsert_meeting(conn, m2)
476
    conn.commit()
477
 
478
    row = conn.execute(
479
        "SELECT status FROM meetings WHERE source=? AND external_id=?",
480
        (m["source"], m["external_id"])
481
    ).fetchone()
482
    assert row["status"] == "canceled"
483
    conn.close()
484
    os.unlink(path)
485
 
486
 
487
def test_upsert_meeting_house_chamber():
488
    conn, path = _make_db()
489
    m = _sample_meeting(
490
        source=SOURCE_HOUSE_CMT,
491
        external_id="119243",
492
        chamber="House",
493
        committee="Committee on Homeland Security",
494
        type="hearing",
495
        source_url="https://docs.house.gov/Committee/Calendar/ByEvent.aspx?EventID=119243",
496
    )
497
    _upsert_meeting(conn, m)
498
    conn.commit()
499
    row = conn.execute(
500
        "SELECT chamber, type FROM meetings WHERE source=? AND external_id=?",
501
        (SOURCE_HOUSE_CMT, "119243")
502
    ).fetchone()
503
    assert row["chamber"] == "House"
504
    assert row["type"] == "hearing"
505
    conn.close()
506
    os.unlink(path)
507
 
508
 
509
 
510
def test_poll_senate_mock(tmp_path):
511
    """poll_senate() must store 2 meetings from sample XML and be idempotent."""
512
    from ingest.pollers.meetings import poll_senate
513
 
514
    db_path = str(tmp_path / "test.db")
515
    conn = get_connection(db_path)
516
    init_schema(conn)
517
    _ensure_tables(conn)
518
 
519
    mock_resp = MagicMock()
520
    mock_resp.raise_for_status.return_value = None
521
    mock_resp.content = SENATE_XML_SAMPLE
522
 
523
    with patch("ingest.pollers.meetings.requests.get", return_value=mock_resp):
524
        count1 = poll_senate(conn)
525
 
526
    assert count1 == 2, f"Expected 2 new rows, got {count1}"
527
 
528
    with patch("ingest.pollers.meetings.requests.get", return_value=mock_resp):
529
        count2 = poll_senate(conn)
530
 
531
    assert count2 == 0, f"Expected 0 on re-run, got {count2}"
532
 
533
    rows = conn.execute("SELECT * FROM meetings WHERE source=?",
534
                        (SOURCE_SENATE,)).fetchall()
535
    assert len(rows) == 2
536
    dates = {r["date"] for r in rows}
537
    assert "2026-07-15" in dates
538
 
539
    conn.close()
540
 
541
 
542
 
543
def test_source_urls_are_allowlisted():
544
    """Confirm that all stored source_urls pass the validator."""
545
    from ingest.source_validator import is_allowlisted
546
 
547
    urls = [
548
        SENATE_PAGE_URL,
549
        "https://docs.house.gov/Committee/Calendar/ByEvent.aspx?EventID=119243",
550
        "https://docs.house.gov/floor/Default.aspx?date=2026-06-22",
551
    ]
552
    for url in urls:
553
        assert is_allowlisted(url), f"URL not allowlisted: {url}"