返回 last30days-skill
test_source_outcomes.py
根目录 / tests / test_source_outcomes.py
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
477 lines PYTHON