| 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.PAYMENT_REQUIRED), |
| 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 _perplexity_plan(): |
| 343 | return { |
| 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": ["perplexity"], |
| 353 | } |
| 354 | ], |
| 355 | "source_weights": {"perplexity": 1.0}, |
| 356 | } |
| 357 | |
| 358 | |
| 359 | def test_pipeline_records_both_mode_semantic_leg_failure_as_partial(): |
| 360 | raw_item = { |
| 361 | "id": "PXS1", |
| 362 | "title": "Search result", |
| 363 | "url": "https://example.com/result", |
| 364 | "snippet": "Raw search evidence", |
| 365 | # Inside the pinned as_of window below. A wall-clock relative date |
| 366 | # (today-5) falls outside 2026-07-21..2026-08-20 once the calendar |
| 367 | # moves past late August, and the failure then records as ERROR. |
| 368 | "date": "2026-08-15", |
| 369 | "relevance": 0.8, |
| 370 | "why_relevant": "Perplexity Search result", |
| 371 | "engagement": {}, |
| 372 | } |
| 373 | artifact = { |
| 374 | "mode": "both", |
| 375 | "search": {"mode": "search"}, |
| 376 | "agent": { |
| 377 | "error": "failed", |
| 378 | "agentErrorMessage": "Provider rejected synthesis", |
| 379 | }, |
| 380 | "itemCount": 1, |
| 381 | } |
| 382 | with patch("lib.pipeline._retrieve_stream_impl", return_value=([raw_item], artifact)): |
| 383 | report = pipeline.run( |
| 384 | topic="test topic", |
| 385 | config={ |
| 386 | "LAST30DAYS_REASONING_PROVIDER": "gemini", |
| 387 | "PERPLEXITY_API_KEY": "pplx-test", |
| 388 | }, |
| 389 | depth="quick", |
| 390 | lookback_days=30, |
| 391 | as_of_date="2026-08-20", |
| 392 | requested_sources=["perplexity"], |
| 393 | mock=True, |
| 394 | external_plan=_perplexity_plan(), |
| 395 | ) |
| 396 | |
| 397 | assert report.range_from == "2026-07-21" |
| 398 | assert report.range_to == "2026-08-20" |
| 399 | outcome = report.source_status["perplexity"] |
| 400 | assert outcome.state == schema.PARTIAL |
| 401 | assert outcome.items_returned == 1 |
| 402 | assert outcome.detail == "agent leg: Provider rejected synthesis" |
| 403 | |
| 404 | |
| 405 | def test_pipeline_records_both_mode_semantic_failure_without_items(): |
| 406 | artifact = { |
| 407 | "mode": "both", |
| 408 | "search": {"mode": "search"}, |
| 409 | "agent": { |
| 410 | "error": "failed", |
| 411 | "agentErrorMessage": "Provider rejected synthesis", |
| 412 | }, |
| 413 | "itemCount": 0, |
| 414 | } |
| 415 | with patch("lib.pipeline._retrieve_stream_impl", return_value=([], artifact)): |
| 416 | report = pipeline.run( |
| 417 | topic="test topic", |
| 418 | config={ |
| 419 | "LAST30DAYS_REASONING_PROVIDER": "gemini", |
| 420 | "PERPLEXITY_API_KEY": "pplx-test", |
| 421 | }, |
| 422 | depth="quick", |
| 423 | requested_sources=["perplexity"], |
| 424 | mock=True, |
| 425 | external_plan=_perplexity_plan(), |
| 426 | ) |
| 427 | |
| 428 | outcome = report.source_status["perplexity"] |
| 429 | assert outcome.state == health.ERROR |
| 430 | assert outcome.items_returned == 0 |
| 431 | assert outcome.detail == "agent leg: Provider rejected synthesis" |
| 432 | |
| 433 | |
| 434 | def test_pipeline_preserves_typed_http_failure(): |
| 435 | plan = { |
| 436 | "intent": "general", |
| 437 | "freshness_mode": "balanced_recent", |
| 438 | "cluster_mode": "story", |
| 439 | "subqueries": [ |
| 440 | { |
| 441 | "label": "primary", |
| 442 | "search_query": "test topic", |
| 443 | "ranking_query": "test topic", |
| 444 | "sources": ["x"], |
| 445 | } |
| 446 | ], |
| 447 | "source_weights": {"x": 1.0}, |
| 448 | } |
| 449 | failure = http.HTTPError("HTTP 429: Too Many Requests", status_code=429) |
| 450 | with patch("lib.pipeline._retrieve_stream", side_effect=failure): |
| 451 | report = pipeline.run( |
| 452 | topic="test topic", |
| 453 | config={"LAST30DAYS_REASONING_PROVIDER": "gemini"}, |
| 454 | depth="quick", |
| 455 | requested_sources=["x"], |
| 456 | mock=True, |
| 457 | external_plan=plan, |
| 458 | ) |
| 459 | |
| 460 | assert report.source_status["x"].state == schema.RATE_LIMITED |
| 461 | assert report.source_status["x"].items_returned == 0 |
| 462 | assert "x" in report.errors_by_source |
| 463 | |
| 464 | |
| 465 | def test_footer_and_synthesis_note_surface_failed_source(): |
| 466 | report = _report( |
| 467 | source_status={ |
| 468 | "x": schema.SourceOutcome( |
| 469 | source="x", |
| 470 | state=schema.RATE_LIMITED, |
| 471 | detail="HTTP 429 after retry budget", |
| 472 | fix_hint="doctor", |
| 473 | ) |
| 474 | }, |
| 475 | errors_by_source={"x": "HTTP 429 after retry budget"}, |
| 476 | ) |
| 477 | |
| 478 | text = render.render_compact(report) |
| 479 | |
| 480 | # A failed source that returned zero items is surfaced to synthesis via the |
| 481 | # evidence blocks (## Partial Coverage), NOT as a user-facing footer line - |
| 482 | # zero-item sources are dropped from the emoji tree (see test_render_footer). |
| 483 | assert "## Partial Coverage" in text |
| 484 | assert "Do not interpret a failed source as no discussion" in text |
| 485 | assert "🔵 X: rate-limited: HTTP 429 after retry budget (run doctor for fixes)" not in text |
| 486 | |
| 487 | |
| 488 | def test_report_source_status_round_trips_through_schema_serialization(): |
| 489 | report = _report( |
| 490 | source_status={ |
| 491 | "x": schema.SourceOutcome( |
| 492 | source="x", |
| 493 | state=schema.PARTIAL, |
| 494 | items_returned=12, |
| 495 | detail="429 after 12 items", |
| 496 | at="2026-07-10T18:22:03Z", |
| 497 | fix_hint="doctor", |
| 498 | ) |
| 499 | } |
| 500 | ) |
| 501 | |
| 502 | payload = schema.to_dict(report) |
| 503 | restored = schema.report_from_dict(payload) |
| 504 | |
| 505 | assert payload["source_status"]["x"]["state"] == schema.PARTIAL |
| 506 | assert restored.source_status["x"] == report.source_status["x"] |
| 507 | |
| 508 | |
| 509 | # --- strict exit (LAST30DAYS_STRICT_EXIT, issue #384) --- |
| 510 | |
| 511 | import last30days as cli |
| 512 | |
| 513 | |
| 514 | def _outcome(source, state, **kwargs): |
| 515 | return schema.SourceOutcome(source=source, state=state, **kwargs) |
| 516 | |
| 517 | |
| 518 | def test_strict_exit_disabled_by_default_even_when_degraded(): |
| 519 | report = _report( |
| 520 | source_status={"x": _outcome("x", schema.RATE_LIMITED, detail="429")} |
| 521 | ) |
| 522 | assert cli._strict_exit_code(report, None, {}) == 0 |
| 523 | |
| 524 | |
| 525 | def test_strict_exit_returns_3_for_degraded_run(capsys): |
| 526 | report = _report( |
| 527 | source_status={"x": _outcome("x", schema.AUTH_FAILED, detail="401")} |
| 528 | ) |
| 529 | rc = cli._strict_exit_code(report, None, {"LAST30DAYS_STRICT_EXIT": "1"}) |
| 530 | assert rc == 3 |
| 531 | assert "strict-exit: degraded sources: x" in capsys.readouterr().err |
| 532 | |
| 533 | |
| 534 | def test_strict_exit_treats_payment_required_as_degraded(capsys): |
| 535 | report = _report( |
| 536 | source_status={ |
| 537 | "x": _outcome("x", schema.PAYMENT_REQUIRED, detail="xapi: payment required") |
| 538 | } |
| 539 | ) |
| 540 | rc = cli._strict_exit_code(report, None, {"LAST30DAYS_STRICT_EXIT": "1"}) |
| 541 | assert rc == 3 |
| 542 | assert "strict-exit: degraded sources: x" in capsys.readouterr().err |
| 543 | |
| 544 | |
| 545 | def test_strict_exit_clean_states_return_0(): |
| 546 | report = _report( |
| 547 | source_status={ |
| 548 | "reddit": _outcome("reddit", health.OK, items_returned=12), |
| 549 | "hn": _outcome("hn", schema.NO_RESULTS), |
| 550 | "tiktok": _outcome("tiktok", schema.SKIPPED_UNCONFIGURED, attempted=False), |
| 551 | } |
| 552 | ) |
| 553 | assert cli._strict_exit_code(report, None, {"LAST30DAYS_STRICT_EXIT": "true"}) == 0 |
| 554 | |
| 555 | |
| 556 | def test_strict_exit_checks_entity_reports_in_comparison_runs(): |
| 557 | lead = _report(source_status={"reddit": _outcome("reddit", health.OK)}) |
| 558 | entity = _report( |
| 559 | source_status={"x": _outcome("x", schema.UNREACHABLE, detail="dns")} |
| 560 | ) |
| 561 | rc = cli._strict_exit_code(lead, [("other", entity)], {"LAST30DAYS_STRICT_EXIT": "on"}) |
| 562 | assert rc == 3 |
| 563 | |
| 564 | |
| 565 | def test_strict_exit_env_key_is_registered(): |
| 566 | # Unregistered keys are silently dropped by env config loading (#707 class). |
| 567 | from lib import env as env_module |
| 568 | import inspect |
| 569 | |
| 570 | assert "LAST30DAYS_STRICT_EXIT" in inspect.getsource(env_module) |
| 571 | |
| 572 | |
| 573 | def test_captured_failure_selection_prefers_most_specific(): |
| 574 | auth = http.HTTPError("HTTP 401: Unauthorized", status_code=401) |
| 575 | rate = http.HTTPError("HTTP 429: Too Many Requests", status_code=429) |
| 576 | # Order must not matter: auth-failed wins over rate-limited either way. |
| 577 | for failures in ([auth, rate], [rate, auth]): |
| 578 | outcome = pipeline._resolve_stream_outcome("x", None, failures) |
| 579 | assert outcome["state"] == schema.AUTH_FAILED |
| 580 | |
| 581 | |
| 582 | def test_bundle_keeps_ok_with_lane_detail_across_subqueries(): |
| 583 | """A swallowed lane failure on a source that delivered items stays ``ok`` |
| 584 | and carries the loss as detail; a later clean subquery keeps that detail.""" |
| 585 | item = schema.SourceItem( |
| 586 | item_id="r1", |
| 587 | source="reddit", |
| 588 | title="A thread", |
| 589 | body="body", |
| 590 | url="https://www.reddit.com/r/test/comments/abc/", |
| 591 | ) |
| 592 | bundle = schema.RetrievalBundle() |
| 593 | bundle.mark_attempted("reddit") |
| 594 | bundle.record_detail("reddit", "3 sub-requests rate-limited (HTTP 429)") |
| 595 | bundle.add_items("primary", "reddit", [item]) |
| 596 | |
| 597 | outcome = bundle.source_status["reddit"] |
| 598 | assert outcome.state == health.OK |
| 599 | assert outcome.items_returned == 1 |
| 600 | assert outcome.detail == "3 sub-requests rate-limited (HTTP 429)" |
| 601 | assert outcome.fix_hint is None |
| 602 | |
| 603 | bundle.add_items("secondary", "reddit", []) |
| 604 | outcome = bundle.source_status["reddit"] |
| 605 | assert outcome.state == health.OK |
| 606 | assert outcome.detail == "3 sub-requests rate-limited (HTTP 429)" |
| 607 | |
| 608 | |
| 609 | def test_finalize_turns_an_empty_ok_source_with_lane_failures_into_that_failure(): |
| 610 | """Zero items after filtering plus swallowed 429s is not 'completed cleanly |
| 611 | with zero matches'; it is the rate limit, so ## Partial Coverage fires and |
| 612 | doctor does not list the source as succeeded.""" |
| 613 | bundle = schema.RetrievalBundle() |
| 614 | bundle.mark_attempted("polymarket") |
| 615 | bundle.record_detail("polymarket", "5 sub-requests rate-limited (HTTP 429)", state=schema.RATE_LIMITED) |
| 616 | bundle.add_items("primary", "polymarket", []) |
| 617 | finalized = pipeline._finalize_source_status(bundle.source_status, {"polymarket": []}) |
| 618 | outcome = finalized["polymarket"] |
| 619 | assert outcome.state == schema.RATE_LIMITED |
| 620 | assert outcome.detail == "5 sub-requests rate-limited (HTTP 429)" |
| 621 | assert outcome.items_returned == 0 |
| 622 | |
| 623 | |
| 624 | # --- payment-required: credit exhaustion is not an auth failure (KTD6) --- |
| 625 | |
| 626 | |
| 627 | def test_classify_failure_402_status_is_payment_required(): |
| 628 | assert http.classify_failure(status_code=402) == schema.PAYMENT_REQUIRED |
| 629 | # A 402 with a non-JSON / empty body still classifies by status alone. |
| 630 | err = http.HTTPError("HTTP 402: Payment Required", 402, "<html>upgrade</html>") |
| 631 | assert err.outcome_state == schema.PAYMENT_REQUIRED |
| 632 | |
| 633 | |
| 634 | @pytest.mark.parametrize( |
| 635 | "message", |
| 636 | [ |
| 637 | "Your enrolled account does not have any credits", |
| 638 | "insufficient credits", |
| 639 | "ScrapeCreators: Insufficient credits remaining", |
| 640 | "xapi: payment required (X API credits exhausted)", |
| 641 | "Xquik key unpaid: payment required (402)", |
| 642 | "You are out of credits for this billing period", |
| 643 | ], |
| 644 | ) |
| 645 | def test_classify_failure_credit_exhaustion_markers(message): |
| 646 | assert http.classify_failure(message=message) == schema.PAYMENT_REQUIRED |
| 647 | |
| 648 | |
| 649 | def test_classify_failure_bare_word_credits_is_not_a_marker(): |
| 650 | # The onboarding copy mentions "10,000 free credits"; a message that merely |
| 651 | # contains the word must not be branded as credit exhaustion. |
| 652 | state = http.classify_failure(message="Sign up for 10,000 free credits") |
| 653 | assert state != schema.PAYMENT_REQUIRED |
| 654 | |
| 655 | |
| 656 | @pytest.mark.parametrize("status", [401, 403]) |
| 657 | def test_classify_failure_401_403_stay_auth_failed(status): |
| 658 | assert http.classify_failure(status_code=status) == schema.AUTH_FAILED |
| 659 | assert http.classify_failure(message=f"HTTP {status}: nope") == schema.AUTH_FAILED |
| 660 | |
| 661 | |
| 662 | def test_payment_required_wins_over_auth_marker_in_same_message(): |
| 663 | # A credit-exhaustion message that also carries an auth word is about |
| 664 | # money, not identity: the payment branch runs before the 401/403 branch. |
| 665 | state = http.classify_failure( |
| 666 | status_code=403, |
| 667 | message="Forbidden: your enrolled account does not have any credits", |
| 668 | ) |
| 669 | assert state == schema.PAYMENT_REQUIRED |
| 670 | |
| 671 | |
| 672 | def test_cross_source_insufficient_credits_reclassifies_from_error(): |
| 673 | # ScrapeCreators-backed sources surface credit exhaustion as a plain |
| 674 | # legacy error string; the shared classifier now types it. |
| 675 | artifact = pipeline._result_outcome_artifact( |
| 676 | "tiktok", {"items": [], "error": "ScrapeCreators: insufficient credits"} |
| 677 | ) |
| 678 | assert artifact["_source_outcome"]["state"] == schema.PAYMENT_REQUIRED |
| 679 | state, attempted = pipeline._classify_source_failure( |
| 680 | http.HTTPError("HTTP 402: insufficient credits", status_code=402) |
| 681 | ) |
| 682 | assert (state, attempted) == (schema.PAYMENT_REQUIRED, True) |
| 683 | |
| 684 | |
| 685 | def test_captured_failure_selection_ranks_payment_required_above_rate_limit(): |
| 686 | pay = http.HTTPError("HTTP 402: Payment Required", status_code=402) |
| 687 | rate = http.HTTPError("HTTP 429: Too Many Requests", status_code=429) |
| 688 | for failures in ([pay, rate], [rate, pay]): |
| 689 | outcome = pipeline._resolve_stream_outcome("x", None, failures) |
| 690 | assert outcome["state"] == schema.PAYMENT_REQUIRED |
| 691 | |
| 692 | |
| 693 | def test_lane_failure_summary_names_credit_exhaustion(): |
| 694 | text = pipeline._summarize_lane_failures( |
| 695 | [http.HTTPError("HTTP 402: Payment Required", status_code=402)] |
| 696 | ) |
| 697 | assert text == "1 sub-request credits exhausted (HTTP 402)" |
| 698 | |
| 699 | |
| 700 | def test_render_summary_labels_payment_required_per_source(): |
| 701 | x_outcome = schema.SourceOutcome( |
| 702 | source="x", state=schema.PAYMENT_REQUIRED, detail="xapi: payment required" |
| 703 | ) |
| 704 | other = schema.SourceOutcome( |
| 705 | source="tiktok", state=schema.PAYMENT_REQUIRED, detail="insufficient credits" |
| 706 | ) |
| 707 | x_text = render._format_outcome(x_outcome) |
| 708 | assert x_text.startswith("X API credits exhausted") |
| 709 | assert "xapi: payment required" in x_text |
| 710 | assert render._format_outcome(other).startswith("credits exhausted") |
| 711 | # The Partial Coverage note carries the label too. |
| 712 | report = _report(source_status={"x": x_outcome, "tiktok": other}) |
| 713 | note = "\n".join(render._render_source_outcome_note(report)) |
| 714 | assert "X API credits exhausted" in note |
| 715 | assert "credits exhausted" in note |
| 716 | |
| 717 | |
| 718 | def test_postmortem_labels_payment_required(): |
| 719 | from lib import doctor |
| 720 | |
| 721 | pm = { |
| 722 | "engine_version": "test", |
| 723 | "mode": "postmortem", |
| 724 | "present": True, |
| 725 | "topic": "t", |
| 726 | "at": "2026-07-10T18:22:03Z", |
| 727 | "outcomes": { |
| 728 | "x": schema.to_dict( |
| 729 | schema.SourceOutcome( |
| 730 | source="x", |
| 731 | state=schema.PAYMENT_REQUIRED, |
| 732 | detail="xapi: payment required (X API credits exhausted)", |
| 733 | ) |
| 734 | ), |
| 735 | "tiktok": schema.to_dict( |
| 736 | schema.SourceOutcome( |
| 737 | source="tiktok", |
| 738 | state=schema.PAYMENT_REQUIRED, |
| 739 | detail="insufficient credits", |
| 740 | ) |
| 741 | ), |
| 742 | }, |
| 743 | } |
| 744 | text = doctor.render_postmortem_text(pm) |
| 745 | assert "Failed:" in text |
| 746 | assert "x — X API credits exhausted" in text |
| 747 | assert "tiktok — credits exhausted" in text |
| 748 | assert "auth-failed" not in text |
| 749 | |
| 750 | |
| 751 | def test_json_export_carries_payment_required(): |
| 752 | report = _report( |
| 753 | source_status={"x": _outcome("x", schema.PAYMENT_REQUIRED, detail="402")} |
| 754 | ) |
| 755 | assert schema.to_agent_export(report)["source_status"]["x"] == "payment-required" |
| 756 | restored = schema.report_from_dict(schema.to_dict(report)) |
| 757 | assert restored.source_status["x"].state == schema.PAYMENT_REQUIRED |
| 758 |