|
4 | 4 | import json |
5 | 5 | import unittest |
6 | 6 | from contextlib import redirect_stderr, redirect_stdout |
| 7 | +from http.client import BadStatusLine |
7 | 8 | from pathlib import Path |
8 | 9 | from unittest import mock |
9 | 10 | from urllib.error import HTTPError, URLError |
@@ -81,6 +82,33 @@ def test_http_403_raises_actionable_error(self, mock_urlopen: mock.Mock) -> None |
81 | 82 | self.assertIn("--input-html", message) |
82 | 83 | self.assertEqual(mock_urlopen.call_count, 1) |
83 | 84 |
|
| 85 | + @mock.patch("breach_scraper.wa_atg_scraper.time.sleep", return_value=None) |
| 86 | + @mock.patch("breach_scraper.wa_atg_scraper.urlopen") |
| 87 | + def test_retries_on_http_protocol_error( |
| 88 | + self, mock_urlopen: mock.Mock, _sleep: mock.Mock |
| 89 | + ) -> None: |
| 90 | + mock_urlopen.side_effect = [BadStatusLine("oops"), _FakeResponse(b"<html>ok</html>")] |
| 91 | + html = fetch_html("https://example.test", retries=3, backoff=0) |
| 92 | + self.assertEqual(html, "<html>ok</html>") |
| 93 | + self.assertEqual(mock_urlopen.call_count, 2) |
| 94 | + |
| 95 | + @mock.patch("breach_scraper.wa_atg_scraper.urlopen") |
| 96 | + def test_unknown_charset_falls_back_to_utf8(self, mock_urlopen: mock.Mock) -> None: |
| 97 | + mock_urlopen.return_value = _FakeResponse( |
| 98 | + b"<html>caf\xc3\xa9</html>", charset="bogus-charset" |
| 99 | + ) |
| 100 | + html = fetch_html("https://example.test", retries=1, backoff=0) |
| 101 | + self.assertIn("caf", html) |
| 102 | + |
| 103 | + @mock.patch("breach_scraper.wa_atg_scraper.time.sleep") |
| 104 | + @mock.patch("breach_scraper.wa_atg_scraper.urlopen") |
| 105 | + def test_backoff_is_capped(self, mock_urlopen: mock.Mock, mock_sleep: mock.Mock) -> None: |
| 106 | + mock_urlopen.side_effect = URLError("down") |
| 107 | + with self.assertRaises(RuntimeError): |
| 108 | + fetch_html("https://example.test", retries=20, backoff=1.0) |
| 109 | + max_sleep = max(call.args[0] for call in mock_sleep.call_args_list) |
| 110 | + self.assertLessEqual(max_sleep, 30.0) |
| 111 | + |
84 | 112 |
|
85 | 113 | class TestMainCli(unittest.TestCase): |
86 | 114 | def _run(self, argv: list[str]) -> tuple[int, str, str]: |
@@ -119,6 +147,11 @@ def test_missing_input_file_returns_error_code(self) -> None: |
119 | 147 | self.assertEqual(rc, 1) |
120 | 148 | self.assertIn("error:", err) |
121 | 149 |
|
| 150 | + def test_malformed_url_returns_error_code(self) -> None: |
| 151 | + rc, _, err = self._run(["--url", "not a url"]) |
| 152 | + self.assertEqual(rc, 1) |
| 153 | + self.assertIn("error:", err) |
| 154 | + |
122 | 155 |
|
123 | 156 | if __name__ == "__main__": |
124 | 157 | unittest.main() |
0 commit comments