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