● Zion Boggan
repos/Docket/services/ingest/tests/test_member_photos.py
zionboggan.com ↗
313 lines · python
History for this file →
1
"""
2
Tests for member_photos poller.
3
 
4
Coverage:
5
  • build_photo_url()   -  URL format and bioguide substitution
6
  • head_check()        -  200/404/exception handling (mocked network)
7
  • poll()              -  DB writes, idempotency, stats (mocked network)
8
  • Schema              -  photo_url column exists after ensure_votes_schema()
9
"""
10
 
11
import os
12
import tempfile
13
from unittest.mock import MagicMock, patch
14
 
15
import pytest
16
import requests
17
 
18
from ingest.db import get_connection, init_schema
19
from ingest.pollers.members import ensure_votes_schema
20
from ingest.pollers.member_photos import (
21
    PHOTO_BASE,
22
    build_photo_url,
23
    head_check,
24
    poll,
25
)
26
 
27
 
28
 
29
class TestBuildPhotoUrl:
30
    def test_known_bioguide(self):
31
        """A000055 is Robert Aderholt  -  canonical test bioguide."""
32
        url = build_photo_url("A000055")
33
        assert url == f"{PHOTO_BASE}/A000055.jpg"
34
 
35
    def test_url_contains_bioguide(self):
36
        url = build_photo_url("Z999999")
37
        assert "Z999999" in url
38
 
39
    def test_url_ends_with_jpg(self):
40
        url = build_photo_url("B000574")
41
        assert url.endswith(".jpg")
42
 
43
    def test_url_uses_450x550_size(self):
44
        url = build_photo_url("A000055")
45
        assert "congress/450x550" in url
46
 
47
    def test_url_host_is_github_pages(self):
48
        url = build_photo_url("A000055")
49
        assert "unitedstates.github.io" in url
50
 
51
    def test_url_format_is_stable(self):
52
        """Verify the full URL matches the documented pattern exactly."""
53
        bioguide = "S000033"
54
        url = build_photo_url(bioguide)
55
        expected = (
56
            "https://unitedstates.github.io/images/congress/450x550/S000033.jpg"
57
        )
58
        assert url == expected
59
 
60
 
61
 
62
class TestHeadCheck:
63
    def _make_session(self, status_code=200):
64
        session = MagicMock()
65
        resp = MagicMock()
66
        resp.status_code = status_code
67
        session.head.return_value = resp
68
        return session
69
 
70
    def test_200_returns_true(self):
71
        session = self._make_session(200)
72
        assert head_check("https://example.com/photo.jpg", session) is True
73
 
74
    def test_404_returns_false(self):
75
        session = self._make_session(404)
76
        assert head_check("https://example.com/photo.jpg", session) is False
77
 
78
    def test_301_after_redirect_returns_true(self):
79
        session = self._make_session(200)
80
        assert head_check("https://example.com/photo.jpg", session) is True
81
 
82
    def test_500_returns_false(self):
83
        session = self._make_session(500)
84
        assert head_check("https://example.com/photo.jpg", session) is False
85
 
86
    def test_request_exception_returns_false(self):
87
        session = MagicMock()
88
        session.head.side_effect = requests.RequestException("connection timeout")
89
        assert head_check("https://example.com/photo.jpg", session) is False
90
 
91
    def test_connection_error_returns_false(self):
92
        session = MagicMock()
93
        session.head.side_effect = requests.ConnectionError("refused")
94
        assert head_check("https://example.com/photo.jpg", session) is False
95
 
96
    def test_head_called_with_allow_redirects(self):
97
        session = self._make_session(200)
98
        head_check("https://example.com/photo.jpg", session)
99
        call_kwargs = session.head.call_args
100
        assert call_kwargs.kwargs.get("allow_redirects") is True or \
101
               (call_kwargs.args and True in call_kwargs.args)
102
 
103
 
104
 
105
class TestSchema:
106
    def _make_db(self):
107
        tmp = tempfile.mktemp(suffix=".db")
108
        conn = get_connection(tmp)
109
        init_schema(conn)
110
        ensure_votes_schema(conn)
111
        return conn, tmp
112
 
113
    def test_photo_url_column_exists(self):
114
        conn, tmp = self._make_db()
115
        info = {row[1] for row in conn.execute("PRAGMA table_info(members)")}
116
        assert "photo_url" in info, "photo_url column should be in members table"
117
        conn.close()
118
        os.unlink(tmp)
119
 
120
    def test_photo_url_is_nullable(self):
121
        """photo_url must be nullable  -  many historical members have no image."""
122
        conn, tmp = self._make_db()
123
        conn.execute(
124
            "INSERT INTO members (bioguide_id, full_name, source_url) "
125
            "VALUES (?, ?, ?)",
126
            ("T000001", "Test Member",
127
             "https://bioguide.congress.gov/search/bio/T000001"),
128
        )
129
        conn.commit()
130
        row = conn.execute(
131
            "SELECT photo_url FROM members WHERE bioguide_id='T000001'"
132
        ).fetchone()
133
        assert row is not None
134
        assert row["photo_url"] is None
135
        conn.close()
136
        os.unlink(tmp)
137
 
138
    def test_ensure_votes_schema_idempotent_with_photo_url(self):
139
        """Calling ensure_votes_schema twice should not raise."""
140
        conn, tmp = self._make_db()
141
        ensure_votes_schema(conn)
142
        info = {row[1] for row in conn.execute("PRAGMA table_info(members)")}
143
        assert "photo_url" in info
144
        conn.close()
145
        os.unlink(tmp)
146
 
147
 
148
 
149
def _make_db_with_members():
150
    """Helper: create a temp DB seeded with two members."""
151
    tmp = tempfile.mktemp(suffix=".db")
152
    conn = get_connection(tmp)
153
    init_schema(conn)
154
    ensure_votes_schema(conn)
155
    conn.execute(
156
        "INSERT INTO members (bioguide_id, full_name, party, state, chamber, source_url) "
157
        "VALUES (?, ?, ?, ?, ?, ?)",
158
        (
159
            "A000055",
160
            "Robert Aderholt",
161
            "Republican",
162
            "AL",
163
            "house",
164
            "https://bioguide.congress.gov/search/bio/A000055",
165
        ),
166
    )
167
    conn.execute(
168
        "INSERT INTO members (bioguide_id, full_name, party, state, chamber, source_url) "
169
        "VALUES (?, ?, ?, ?, ?, ?)",
170
        (
171
            "Z999999",
172
            "Historical Member (no photo)",
173
            "Democratic",
174
            "NY",
175
            "senate",
176
            "https://bioguide.congress.gov/search/bio/Z999999",
177
        ),
178
    )
179
    conn.commit()
180
    conn.close()
181
    return tmp
182
 
183
 
184
def _mock_session_factory(hits: set):
185
    """
186
    Return a context-manager patch for requests.Session that returns 200 for
187
    bioguide_ids in `hits` and 404 for all others.
188
    """
189
    def fake_head(url, timeout=10, allow_redirects=True):
190
        resp = MagicMock()
191
        resp.status_code = 200 if any(h in url for h in hits) else 404
192
        return resp
193
 
194
    mock_session = MagicMock()
195
    mock_session.head.side_effect = fake_head
196
    return mock_session
197
 
198
 
199
class TestPoll:
200
    def test_photo_url_set_for_200(self):
201
        tmp = _make_db_with_members()
202
 
203
        with patch("ingest.pollers.member_photos.requests.Session") as cls:
204
            cls.return_value = _mock_session_factory({"A000055"})
205
            poll(db_path=tmp, delay=0)
206
 
207
        conn = get_connection(tmp)
208
        row = conn.execute(
209
            "SELECT photo_url FROM members WHERE bioguide_id='A000055'"
210
        ).fetchone()
211
        assert row["photo_url"] is not None
212
        assert "A000055" in row["photo_url"]
213
        assert row["photo_url"].endswith(".jpg")
214
        conn.close()
215
        os.unlink(tmp)
216
 
217
    def test_photo_url_null_for_404(self):
218
        tmp = _make_db_with_members()
219
 
220
        with patch("ingest.pollers.member_photos.requests.Session") as cls:
221
            cls.return_value = _mock_session_factory({"A000055"})
222
            poll(db_path=tmp, delay=0)
223
 
224
        conn = get_connection(tmp)
225
        row = conn.execute(
226
            "SELECT photo_url FROM members WHERE bioguide_id='Z999999'"
227
        ).fetchone()
228
        assert row["photo_url"] is None
229
        conn.close()
230
        os.unlink(tmp)
231
 
232
    def test_stats_returned(self):
233
        tmp = _make_db_with_members()
234
 
235
        with patch("ingest.pollers.member_photos.requests.Session") as cls:
236
            cls.return_value = _mock_session_factory({"A000055"})
237
            result = poll(db_path=tmp, delay=0)
238
 
239
        assert result["checked"] == 2
240
        assert result["updated"] == 1
241
        assert result["skipped"] == 1
242
        os.unlink(tmp)
243
 
244
    def test_idempotent_rerun_preserves_url(self):
245
        """Second poll() with same hits must not clear a previously set URL."""
246
        tmp = _make_db_with_members()
247
 
248
        for _ in range(2):
249
            with patch("ingest.pollers.member_photos.requests.Session") as cls:
250
                cls.return_value = _mock_session_factory({"A000055"})
251
                poll(db_path=tmp, delay=0)
252
 
253
        conn = get_connection(tmp)
254
        row = conn.execute(
255
            "SELECT photo_url FROM members WHERE bioguide_id='A000055'"
256
        ).fetchone()
257
        assert row["photo_url"] is not None
258
        conn.close()
259
        os.unlink(tmp)
260
 
261
    def test_reset_clears_then_repopulates(self):
262
        """reset=True should clear photo_url before re-checking."""
263
        tmp = _make_db_with_members()
264
 
265
        with patch("ingest.pollers.member_photos.requests.Session") as cls:
266
            cls.return_value = _mock_session_factory({"A000055"})
267
            poll(db_path=tmp, delay=0)
268
 
269
        with patch("ingest.pollers.member_photos.requests.Session") as cls:
270
            cls.return_value = _mock_session_factory({"A000055"})
271
            result = poll(db_path=tmp, delay=0, reset=True)
272
 
273
        conn = get_connection(tmp)
274
        row = conn.execute(
275
            "SELECT photo_url FROM members WHERE bioguide_id='A000055'"
276
        ).fetchone()
277
        assert row["photo_url"] is not None
278
        assert result["updated"] == 1
279
        conn.close()
280
        os.unlink(tmp)
281
 
282
    def test_empty_members_table(self):
283
        tmp = tempfile.mktemp(suffix=".db")
284
        conn = get_connection(tmp)
285
        init_schema(conn)
286
        ensure_votes_schema(conn)
287
        conn.close()
288
 
289
        with patch("ingest.pollers.member_photos.requests.Session") as cls:
290
            cls.return_value = _mock_session_factory(set())
291
            result = poll(db_path=tmp, delay=0)
292
 
293
        assert result["checked"] == 0
294
        assert result["updated"] == 0
295
        assert result["skipped"] == 0
296
        os.unlink(tmp)
297
 
298
    def test_photo_url_points_to_correct_base(self):
299
        """Stored URL must use the 450x550 base path."""
300
        tmp = _make_db_with_members()
301
 
302
        with patch("ingest.pollers.member_photos.requests.Session") as cls:
303
            cls.return_value = _mock_session_factory({"A000055"})
304
            poll(db_path=tmp, delay=0)
305
 
306
        conn = get_connection(tmp)
307
        row = conn.execute(
308
            "SELECT photo_url FROM members WHERE bioguide_id='A000055'"
309
        ).fetchone()
310
        assert "congress/450x550" in row["photo_url"]
311
        assert "unitedstates.github.io" in row["photo_url"]
312
        conn.close()
313
        os.unlink(tmp)