| 1 | import socket |
| 2 | import urllib.error |
| 3 | from unittest.mock import MagicMock, patch |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | from lib import ( |
| 8 | bird_x, |
| 9 | health, |
| 10 | http, |
| 11 | jobs, |
| 12 | pipeline, |
| 13 | reddit, |
| 14 | reddit_listing, |
| 15 | reddit_rss, |
| 16 | render, |
| 17 | schema, |
| 18 | youtube_yt, |
| 19 | ) |
| 20 | |
| 21 | |
| 22 | def _report(*, source_status=None, items_by_source=None, errors_by_source=None): |
| 23 | return schema.Report( |
| 24 | topic="test topic", |
| 25 | range_from="2026-06-10", |
| 26 | range_to="2026-07-10", |
| 27 | generated_at="2026-07-10T18:22:03Z", |
| 28 | provider_runtime=schema.ProviderRuntime( |
| 29 | reasoning_provider="gemini", |
| 30 | planner_model="test-planner", |
| 31 | rerank_model="test-reranker", |
| 32 | ), |
| 33 | query_plan=schema.QueryPlan( |
| 34 | intent="general", |
| 35 | freshness_mode="balanced_recent", |
| 36 | cluster_mode="story", |
| 37 | raw_topic="test topic", |
| 38 | subqueries=[ |
| 39 | schema.SubQuery( |
| 40 | label="primary", |
| 41 | search_query="test topic", |
| 42 | ranking_query="test topic", |
| 43 | sources=["x"], |
| 44 | ) |
| 45 | ], |
| 46 | source_weights={"x": 1.0}, |
| 47 | ), |
| 48 | clusters=[], |
| 49 | ranked_candidates=[], |
| 50 | items_by_source=items_by_source or {}, |
| 51 | errors_by_source=errors_by_source or {}, |
| 52 | source_status=source_status or {}, |
| 53 | ) |
| 54 | |
| 55 | |
| 56 | @pytest.mark.parametrize( |
| 57 | ("error", "expected"), |
| 58 | [ |
| 59 | (http.HTTPError("HTTP 429", status_code=429), schema.RATE_LIMITED), |
| 60 | (http.HTTPError("HTTP 401", status_code=401), schema.AUTH_FAILED), |
| 61 | (http.HTTPError("HTTP 402", status_code=402), schema.AUTH_FAILED), |
| 62 | (http.HTTPError("HTTP 403", status_code=403), schema.AUTH_FAILED), |
| 63 | (http.HTTPError("Invalid JSON response"), schema.SCHEMA_DRIFT), |
| 64 | (http.HTTPError("Connection error: reset"), schema.UNREACHABLE), |
| 65 | (http.HTTPError("Request timed out"), health.TIMEOUT), |
| 66 | ], |
| 67 | ) |
| 68 | def test_http_error_exposes_run_outcome_state(error, expected): |
| 69 | assert error.outcome_state == expected |
| 70 | |
| 71 | |
| 72 | @patch("lib.http.time.sleep") |
| 73 | @patch("lib.http.urllib.request.urlopen") |
| 74 | def test_http_wrapper_classifies_dns_failure(mock_urlopen, _mock_sleep): |
| 75 | mock_urlopen.side_effect = urllib.error.URLError( |
| 76 | socket.gaierror(-2, "Name or service not known") |
| 77 | ) |
| 78 | |
| 79 | with pytest.raises(http.HTTPError) as caught: |
| 80 | http.get("https://unreachable.example", retries=1) |
| 81 | |
| 82 | assert caught.value.outcome_state == schema.UNREACHABLE |
| 83 | |
| 84 | |
| 85 | def test_source_specific_text_failures_are_mapped(): |
| 86 | assert bird_x.classify_run_failure("likely Twitter anti-bot interstitial") == schema.SCHEMA_DRIFT |
| 87 | assert reddit.classify_run_failure("blocked by Reddit interstitial") == schema.RATE_LIMITED |
| 88 | assert youtube_yt.classify_run_failure("Sign in to confirm you're not a bot") == schema.RATE_LIMITED |
| 89 | assert youtube_yt.classify_run_failure("Search timed out after 1s") == health.TIMEOUT |
| 90 | |
| 91 | |
| 92 | def test_bundle_distinguishes_clean_no_results_from_failure(): |
| 93 | clean = schema.RetrievalBundle() |
| 94 | clean.mark_attempted("x") |
| 95 | |
| 96 | failed = schema.RetrievalBundle() |
| 97 | failed.mark_attempted("x") |
| 98 | failed.record_failure("x", schema.RATE_LIMITED, "HTTP 429") |
| 99 | |
| 100 | assert clean.source_status["x"].state == schema.NO_RESULTS |
| 101 | assert failed.source_status["x"].state == schema.RATE_LIMITED |
| 102 | assert failed.source_status["x"].fix_hint == "doctor" |
| 103 | |
| 104 | |
| 105 | @patch("lib.http.urllib.request.urlopen") |
| 106 | def test_stream_adapter_recovers_http_failure_laundered_as_empty(mock_urlopen): |
| 107 | mock_urlopen.side_effect = urllib.error.HTTPError( |
| 108 | "https://api.example.com", |
| 109 | 401, |
| 110 | "Unauthorized", |
| 111 | {}, |
| 112 | None, |
| 113 | ) |
| 114 | |
| 115 | def source_that_launders_failure(*_args, **_kwargs): |
| 116 | try: |
| 117 | http.get("https://api.example.com", retries=1) |
| 118 | except http.HTTPError: |
| 119 | return [], {} |
| 120 | raise AssertionError("request should have failed") |
| 121 | |
| 122 | with patch("lib.pipeline._retrieve_stream_impl", side_effect=source_that_launders_failure): |
| 123 | items, artifact = pipeline._retrieve_stream() |
| 124 | |
| 125 | assert items == [] |
| 126 | assert artifact["_source_outcome"]["state"] == schema.AUTH_FAILED |
| 127 | |
| 128 | |
| 129 | @patch("lib.http.time.sleep") |
| 130 | @patch("lib.http.urllib.request.urlopen") |
| 131 | def test_reddit_nested_worker_propagates_failure_capture(mock_urlopen, _mock_sleep): |
| 132 | mock_urlopen.side_effect = urllib.error.HTTPError( |
| 133 | "https://api.scrapecreators.com/v1/reddit/search", |
| 134 | 429, |
| 135 | "Too Many Requests", |
| 136 | {}, |
| 137 | None, |
| 138 | ) |
| 139 | |
| 140 | with http.capture_failures() as failures: |
| 141 | result = reddit.search_reddit( |
| 142 | "test topic", |
| 143 | "2026-06-10", |
| 144 | "2026-07-10", |
| 145 | depth="quick", |
| 146 | token="dummy-token", |
| 147 | ) |
| 148 | |
| 149 | assert result["items"] == [] |
| 150 | assert failures[-1].outcome_state == schema.RATE_LIMITED |
| 151 | |
| 152 | |
| 153 | def _reddit_429(url="https://www.reddit.com/search.rss"): |
| 154 | return urllib.error.HTTPError(url, 429, "Too Many Requests", {}, None) |
| 155 | |
| 156 | |
| 157 | @patch("lib.http.time.sleep") |
| 158 | @patch("lib.http.urllib.request.urlopen") |
| 159 | def test_reddit_rss_fanout_propagates_failure_capture(mock_urlopen, _mock_sleep): |
| 160 | # get_text launders the 429 into None; the sink is what must survive the |
| 161 | # ThreadPoolExecutor hop into the feed workers (issue #899). |
| 162 | mock_urlopen.side_effect = _reddit_429() |
| 163 | |
| 164 | with http.capture_failures() as failures: |
| 165 | posts = reddit_rss.search_rss("test topic", depth="quick") |
| 166 | |
| 167 | assert posts == [] |
| 168 | assert failures[-1].outcome_state == schema.RATE_LIMITED |
| 169 | |
| 170 | |
| 171 | @patch("lib.http.time.sleep") |
| 172 | @patch("lib.http.urllib.request.urlopen") |
| 173 | def test_reddit_listing_fanout_propagates_failure_capture(mock_urlopen, _mock_sleep): |
| 174 | mock_urlopen.side_effect = _reddit_429( |
| 175 | "https://www.reddit.com/svc/shreddit/community-more-posts/hot/" |
| 176 | ) |
| 177 | |
| 178 | with http.capture_failures() as failures: |
| 179 | posts = reddit_listing.fetch_listings(["example"], depth="quick", query="test topic") |
| 180 | |
| 181 | assert posts == [] |
| 182 | assert failures[-1].outcome_state == schema.RATE_LIMITED |
| 183 | |
| 184 | |
| 185 | @patch("lib.http.time.sleep") |
| 186 | @patch("lib.http.urllib.request.urlopen") |
| 187 | def test_reddit_discovery_listing_fanout_propagates_failure_capture(mock_urlopen, _mock_sleep): |
| 188 | mock_urlopen.side_effect = _reddit_429( |
| 189 | "https://www.reddit.com/svc/shreddit/community-more-posts/rising/" |
| 190 | ) |
| 191 | |
| 192 | with http.capture_failures() as failures: |
| 193 | result = reddit_listing.fetch_discovery_listings(["example"], query="test topic") |
| 194 | |
| 195 | assert result["items"] == [] |
| 196 | # The discovery path reads this list, not the sink — a blocked feed must not |
| 197 | # look like an empty one there either. |
| 198 | assert result["errors"] |
| 199 | assert any("429" in error for error in result["errors"]) |
| 200 | assert failures[-1].outcome_state == schema.RATE_LIMITED |
| 201 | |
| 202 | |
| 203 | @patch("lib.http.urllib.request.urlopen") |
| 204 | def test_tee_failures_does_not_hide_from_parent_sink(mock_urlopen): |
| 205 | # tee_failures must never become capture_failures: the latter replaces the |
| 206 | # sink, which would silently re-break the Reddit lanes above. |
| 207 | mock_urlopen.side_effect = urllib.error.HTTPError( |
| 208 | "https://api.example.com/missing", 404, "Not Found", {}, None |
| 209 | ) |
| 210 | |
| 211 | with http.capture_failures() as parent: |
| 212 | with http.tee_failures() as local: |
| 213 | with pytest.raises(http.HTTPError): |
| 214 | http.get("https://api.example.com/missing", retries=1) |
| 215 | |
| 216 | assert len(local) == 1 |
| 217 | assert local == parent |
| 218 | |
| 219 | |
| 220 | @patch("lib.http.urllib.request.urlopen") |
| 221 | def test_jobs_expected_probe_misses_do_not_degrade_final_result(mock_urlopen): |
| 222 | miss = urllib.error.HTTPError( |
| 223 | "https://boards-api.greenhouse.io/v1/boards/example/jobs", |
| 224 | 404, |
| 225 | "Not Found", |
| 226 | {}, |
| 227 | None, |
| 228 | ) |
| 229 | success = MagicMock() |
| 230 | success.status = 200 |
| 231 | success.read.return_value = ( |
| 232 | b'{"jobs":[{"id":"1","title":"Engineer",' |
| 233 | b'"jobUrl":"https://jobs.ashbyhq.com/example/1"}]}' |
| 234 | ) |
| 235 | success.__enter__.return_value = success |
| 236 | success.__exit__.return_value = False |
| 237 | mock_urlopen.side_effect = [miss, success] |
| 238 | |
| 239 | with patch("lib.jobs._candidate_slugs", return_value=["example"]): |
| 240 | with http.capture_failures() as failures: |
| 241 | provider, slug, _ = jobs._probe_ats("Example") |
| 242 | |
| 243 | assert provider == jobs.ATS_PROVIDER_ASHBY |
| 244 | assert slug == "example" |
| 245 | assert failures == [] |
| 246 | |
| 247 | |
| 248 | @pytest.mark.parametrize( |
| 249 | ("source", "artifact", "expected"), |
| 250 | [ |
| 251 | ("perplexity", {"error": "timeout"}, health.TIMEOUT), |
| 252 | ( |
| 253 | "grounding", |
| 254 | {"reason": "keyless-search-unavailable"}, |
| 255 | schema.UNREACHABLE, |
| 256 | ), |
| 257 | ], |
| 258 | ) |
| 259 | def test_stream_adapter_converts_legacy_error_artifacts(source, artifact, expected): |
| 260 | with patch("lib.pipeline._retrieve_stream_impl", return_value=([], artifact)): |
| 261 | _, converted = pipeline._retrieve_stream(source=source) |
| 262 | |
| 263 | assert converted["_source_outcome"]["state"] == expected |
| 264 | |
| 265 | |
| 266 | @pytest.mark.parametrize( |
| 267 | ("source", "detail", "expected"), |
| 268 | [ |
| 269 | ("truthsocial", "Truth Social token expired", schema.AUTH_FAILED), |
| 270 | ( |
| 271 | "bluesky", |
| 272 | "Cloudflare blocked the request (403 Forbidden). This is a network-level block, not an auth issue.", |
| 273 | schema.UNREACHABLE, |
| 274 | ), |
| 275 | ], |
| 276 | ) |
| 277 | def test_legacy_result_uses_source_specific_outcome(source, detail, expected): |
| 278 | artifact = pipeline._result_outcome_artifact(source, {"error": detail}) |
| 279 | |
| 280 | assert artifact["_source_outcome"]["state"] == expected |
| 281 | |
| 282 | |
| 283 | def test_captured_http_failure_overrides_generic_artifact_error(): |
| 284 | failure = http.HTTPError("HTTP 429: Too Many Requests", status_code=429) |
| 285 | outcome = pipeline._resolve_stream_outcome( |
| 286 | "tiktok", |
| 287 | pipeline._outcome_artifact(health.ERROR, "request failed"), |
| 288 | [failure], |
| 289 | ) |
| 290 | |
| 291 | assert outcome["state"] == schema.RATE_LIMITED |
| 292 | |
| 293 | |
| 294 | def test_bundle_records_items_then_429_as_partial(): |
| 295 | item = schema.SourceItem( |
| 296 | item_id="x1", |
| 297 | source="x", |
| 298 | title="A post", |
| 299 | body="body", |
| 300 | url="https://x.com/example/status/1", |
| 301 | ) |
| 302 | bundle = schema.RetrievalBundle() |
| 303 | bundle.mark_attempted("x") |
| 304 | bundle.add_items("primary", "x", [item]) |
| 305 | bundle.record_failure("x", schema.RATE_LIMITED, "429 after first page") |
| 306 | |
| 307 | outcome = bundle.source_status["x"] |
| 308 | assert outcome.state == schema.PARTIAL |
| 309 | assert outcome.items_returned == 1 |
| 310 | assert outcome.detail == "429 after first page" |
| 311 | |
| 312 | |
| 313 | def test_pipeline_records_clean_empty_source_as_no_results(): |
| 314 | plan = { |
| 315 | "intent": "general", |
| 316 | "freshness_mode": "balanced_recent", |
| 317 | "cluster_mode": "story", |
| 318 | "subqueries": [ |
| 319 | { |
| 320 | "label": "primary", |
| 321 | "search_query": "test topic", |
| 322 | "ranking_query": "test topic", |
| 323 | "sources": ["x"], |
| 324 | } |
| 325 | ], |
| 326 | "source_weights": {"x": 1.0}, |
| 327 | } |
| 328 | with patch("lib.pipeline._retrieve_stream", return_value=([], {})): |
| 329 | report = pipeline.run( |
| 330 | topic="test topic", |
| 331 | config={"LAST30DAYS_REASONING_PROVIDER": "gemini"}, |
| 332 | depth="quick", |
| 333 | requested_sources=["x"], |
| 334 | mock=True, |
| 335 | external_plan=plan, |
| 336 | ) |
| 337 | |
| 338 | assert report.source_status["x"].state == schema.NO_RESULTS |
| 339 | assert "x" not in report.errors_by_source |
| 340 | |
| 341 | |
| 342 | def test_pipeline_preserves_typed_http_failure(): |
| 343 | plan = { |
| 344 | "intent": "general", |
| 345 | "freshness_mode": "balanced_recent", |
| 346 | "cluster_mode": "story", |
| 347 | "subqueries": [ |
| 348 | { |
| 349 | "label": "primary", |
| 350 | "search_query": "test topic", |
| 351 | "ranking_query": "test topic", |
| 352 | "sources": ["x"], |
| 353 | } |
| 354 | ], |
| 355 | "source_weights": {"x": 1.0}, |
| 356 | } |
| 357 | failure = http.HTTPError("HTTP 429: Too Many Requests", status_code=429) |
| 358 | with patch("lib.pipeline._retrieve_stream", side_effect=failure): |
| 359 | report = pipeline.run( |
| 360 | topic="test topic", |
| 361 | config={"LAST30DAYS_REASONING_PROVIDER": "gemini"}, |
| 362 | depth="quick", |
| 363 | requested_sources=["x"], |
| 364 | mock=True, |
| 365 | external_plan=plan, |
| 366 | ) |
| 367 | |
| 368 | assert report.source_status["x"].state == schema.RATE_LIMITED |
| 369 | assert report.source_status["x"].items_returned == 0 |
| 370 | assert "x" in report.errors_by_source |
| 371 | |
| 372 | |
| 373 | def test_footer_and_synthesis_note_surface_failed_source(): |
| 374 | report = _report( |
| 375 | source_status={ |
| 376 | "x": schema.SourceOutcome( |
| 377 | source="x", |
| 378 | state=schema.RATE_LIMITED, |
| 379 | detail="HTTP 429 after retry budget", |
| 380 | fix_hint="doctor", |
| 381 | ) |
| 382 | }, |
| 383 | errors_by_source={"x": "HTTP 429 after retry budget"}, |
| 384 | ) |
| 385 | |
| 386 | text = render.render_compact(report) |
| 387 | |
| 388 | # A failed source that returned zero items is surfaced to synthesis via the |
| 389 | # evidence blocks (## Partial Coverage), NOT as a user-facing footer line - |
| 390 | # zero-item sources are dropped from the emoji tree (see test_render_footer). |
| 391 | assert "## Partial Coverage" in text |
| 392 | assert "Do not interpret a failed source as no discussion" in text |
| 393 | assert "🔵 X: rate-limited: HTTP 429 after retry budget (run doctor for fixes)" not in text |
| 394 | |
| 395 | |
| 396 | def test_report_source_status_round_trips_through_schema_serialization(): |
| 397 | report = _report( |
| 398 | source_status={ |
| 399 | "x": schema.SourceOutcome( |
| 400 | source="x", |
| 401 | state=schema.PARTIAL, |
| 402 | items_returned=12, |
| 403 | detail="429 after 12 items", |
| 404 | at="2026-07-10T18:22:03Z", |
| 405 | fix_hint="doctor", |
| 406 | ) |
| 407 | } |
| 408 | ) |
| 409 | |
| 410 | payload = schema.to_dict(report) |
| 411 | restored = schema.report_from_dict(payload) |
| 412 | |
| 413 | assert payload["source_status"]["x"]["state"] == schema.PARTIAL |
| 414 | assert restored.source_status["x"] == report.source_status["x"] |
| 415 | |
| 416 | |
| 417 | # --- strict exit (LAST30DAYS_STRICT_EXIT, issue #384) --- |
| 418 | |
| 419 | import last30days as cli |
| 420 | |
| 421 | |
| 422 | def _outcome(source, state, **kwargs): |
| 423 | return schema.SourceOutcome(source=source, state=state, **kwargs) |
| 424 | |
| 425 | |
| 426 | def test_strict_exit_disabled_by_default_even_when_degraded(): |
| 427 | report = _report( |
| 428 | source_status={"x": _outcome("x", schema.RATE_LIMITED, detail="429")} |
| 429 | ) |
| 430 | assert cli._strict_exit_code(report, None, {}) == 0 |
| 431 | |
| 432 | |
| 433 | def test_strict_exit_returns_3_for_degraded_run(capsys): |
| 434 | report = _report( |
| 435 | source_status={"x": _outcome("x", schema.AUTH_FAILED, detail="401")} |
| 436 | ) |
| 437 | rc = cli._strict_exit_code(report, None, {"LAST30DAYS_STRICT_EXIT": "1"}) |
| 438 | assert rc == 3 |
| 439 | assert "strict-exit: degraded sources: x" in capsys.readouterr().err |
| 440 | |
| 441 | |
| 442 | def test_strict_exit_clean_states_return_0(): |
| 443 | report = _report( |
| 444 | source_status={ |
| 445 | "reddit": _outcome("reddit", health.OK, items_returned=12), |
| 446 | "hn": _outcome("hn", schema.NO_RESULTS), |
| 447 | "tiktok": _outcome("tiktok", schema.SKIPPED_UNCONFIGURED, attempted=False), |
| 448 | } |
| 449 | ) |
| 450 | assert cli._strict_exit_code(report, None, {"LAST30DAYS_STRICT_EXIT": "true"}) == 0 |
| 451 | |
| 452 | |
| 453 | def test_strict_exit_checks_entity_reports_in_comparison_runs(): |
| 454 | lead = _report(source_status={"reddit": _outcome("reddit", health.OK)}) |
| 455 | entity = _report( |
| 456 | source_status={"x": _outcome("x", schema.UNREACHABLE, detail="dns")} |
| 457 | ) |
| 458 | rc = cli._strict_exit_code(lead, [("other", entity)], {"LAST30DAYS_STRICT_EXIT": "on"}) |
| 459 | assert rc == 3 |
| 460 | |
| 461 | |
| 462 | def test_strict_exit_env_key_is_registered(): |
| 463 | # Unregistered keys are silently dropped by env config loading (#707 class). |
| 464 | from lib import env as env_module |
| 465 | import inspect |
| 466 | |
| 467 | assert "LAST30DAYS_STRICT_EXIT" in inspect.getsource(env_module) |
| 468 | |
| 469 | |
| 470 | def test_captured_failure_selection_prefers_most_specific(): |
| 471 | auth = http.HTTPError("HTTP 401: Unauthorized", status_code=401) |
| 472 | rate = http.HTTPError("HTTP 429: Too Many Requests", status_code=429) |
| 473 | # Order must not matter: auth-failed wins over rate-limited either way. |
| 474 | for failures in ([auth, rate], [rate, auth]): |
| 475 | outcome = pipeline._resolve_stream_outcome("x", None, failures) |
| 476 | assert outcome["state"] == schema.AUTH_FAILED |
| 477 |