| 1 | """Reddit transport failures must not be reported as a clean no-results. |
| 2 | |
| 3 | Regression coverage for issue #899: on a datacenter egress Reddit answers |
| 4 | 429/403 on the keyless lanes, the adapters swallow that into an empty list, and |
| 5 | the run used to export ``"reddit": "no-results"`` with ``doctor --postmortem`` |
| 6 | printing "No failures on the last run." These tests drive the real pipeline and |
| 7 | the real postmortem renderer over a mocked socket, so the whole chain -- |
| 8 | capture sink -> source outcome -> postmortem bucket -- stays honest. |
| 9 | """ |
| 10 | |
| 11 | import urllib.error |
| 12 | from unittest import mock |
| 13 | |
| 14 | from lib import doctor, pipeline, schema |
| 15 | |
| 16 | |
| 17 | def _plan(source): |
| 18 | return { |
| 19 | "intent": "general", |
| 20 | "freshness_mode": "balanced_recent", |
| 21 | "cluster_mode": "none", |
| 22 | "source_weights": {source: 1.0}, |
| 23 | "subqueries": [{ |
| 24 | "label": "primary", |
| 25 | "search_query": "claude code user feedback", |
| 26 | "ranking_query": "claude code user feedback", |
| 27 | "sources": [source], |
| 28 | }], |
| 29 | } |
| 30 | |
| 31 | |
| 32 | def _run_reddit_against(error): |
| 33 | runtime = schema.ProviderRuntime("local", "test-planner", "test-reranker") |
| 34 | with mock.patch.object( |
| 35 | pipeline.providers, "resolve_runtime", return_value=(runtime, mock.Mock()) |
| 36 | ), mock.patch.object( |
| 37 | pipeline, "available_sources", return_value=["reddit"] |
| 38 | ), mock.patch("lib.http.time.sleep"), mock.patch( |
| 39 | "lib.http.urllib.request.urlopen", side_effect=error |
| 40 | ): |
| 41 | return pipeline.run( |
| 42 | topic="claude code user feedback", |
| 43 | config={"EXCLUDE_SOURCES": ""}, |
| 44 | depth="quick", |
| 45 | requested_sources=["reddit"], |
| 46 | mock=False, |
| 47 | as_of_date="2026-07-10", |
| 48 | external_plan=_plan("reddit"), |
| 49 | ) |
| 50 | |
| 51 | |
| 52 | def _postmortem_from(report): |
| 53 | return { |
| 54 | "engine_version": "test", |
| 55 | "mode": "postmortem", |
| 56 | "present": True, |
| 57 | "topic": report.topic, |
| 58 | "at": report.generated_at, |
| 59 | "outcomes": { |
| 60 | source: schema.to_dict(outcome) |
| 61 | for source, outcome in report.source_status.items() |
| 62 | }, |
| 63 | } |
| 64 | |
| 65 | |
| 66 | def test_reddit_rate_limit_is_not_reported_as_clean_no_results(): |
| 67 | report = _run_reddit_against( |
| 68 | urllib.error.HTTPError( |
| 69 | "https://www.reddit.com/search.rss", 429, "Too Many Requests", {}, None |
| 70 | ) |
| 71 | ) |
| 72 | |
| 73 | outcome = report.source_status["reddit"] |
| 74 | assert outcome.state == schema.RATE_LIMITED |
| 75 | assert "429" in (outcome.detail or "") |
| 76 | |
| 77 | |
| 78 | def test_reddit_block_is_not_reported_as_clean_no_results(): |
| 79 | report = _run_reddit_against( |
| 80 | urllib.error.HTTPError( |
| 81 | "https://www.reddit.com/search.rss", 403, "Blocked", {}, None |
| 82 | ) |
| 83 | ) |
| 84 | |
| 85 | # 403 lands on auth-failed via http.classify_failure. The point of the test |
| 86 | # is that a blocked host is a failure state at all, not which noun it gets. |
| 87 | assert report.source_status["reddit"].state != schema.NO_RESULTS |
| 88 | |
| 89 | |
| 90 | def test_postmortem_does_not_claim_success_after_a_reddit_block(): |
| 91 | report = _run_reddit_against( |
| 92 | urllib.error.HTTPError( |
| 93 | "https://www.reddit.com/search.rss", 429, "Too Many Requests", {}, None |
| 94 | ) |
| 95 | ) |
| 96 | |
| 97 | text = doctor.render_postmortem_text(_postmortem_from(report)) |
| 98 | |
| 99 | assert "No failures on the last run." not in text |
| 100 | assert "Succeeded: reddit" not in text |
| 101 | assert "Failed:" in text |
| 102 | assert schema.RATE_LIMITED in text |
| 103 |