main
py 632 lines 23.1 KB
Raw
1 """Tests for RSS matrix fan-in: per-source artifacts and deterministic merge.
2
3 Covers:
4 - Per-source artifact emission and validation
5 - Run context building and validation
6 - Deterministic merge producing canonical output
7 - Fan-in validation: schema mismatch, window mismatch, missing sources, duplicates
8 - Partial optional-source failures with warnings
9 - Deduplication across sources
10 - Fixture-based determinism proof
11 """
12
13 from __future__ import annotations
14
15 import json
16 from datetime import UTC, datetime, timedelta
17 from pathlib import Path
18 from typing import Any
19
20 import pytest
21
22 from scripts.rss_fan_in import (
23 SOURCE_ARTIFACT_SCHEMA_VERSION,
24 FanInValidationError,
25 build_run_context,
26 build_source_artifact,
27 merge_source_artifacts,
28 validate_fan_in_compatibility,
29 validate_run_context,
30 validate_source_artifact,
31 )
32 from scripts.techcrunch_crawler import (
33 CANONICAL_SCHEMA_VERSION,
34 iso_timestamp,
35 schema_checksum,
36 )
37
38 # --- Fixtures ---
39
40 NOW = datetime(2026, 6, 13, 12, 0, 0, tzinfo=UTC)
41 SINCE = datetime(2026, 6, 6, 0, 0, 0, tzinfo=UTC)
42 UNTIL = datetime(2026, 6, 13, 0, 0, 0, tzinfo=UTC)
43 WEEK = "2026-W24"
44 RUN_ID = "test-run-12345"
45 CONFIG_CHECKSUM = "abc123def456"
46 SCHEMA_CHECKSUM_VALUE = schema_checksum()
47
48
49 def _make_run_context(**overrides: Any) -> dict[str, Any]:
50 ctx = {
51 "schema_version": SOURCE_ARTIFACT_SCHEMA_VERSION,
52 "run_id": RUN_ID,
53 "week": WEEK,
54 "crawl_window": {"since": iso_timestamp(SINCE), "until": iso_timestamp(UNTIL)},
55 "source_config_checksum": CONFIG_CHECKSUM,
56 "schema_checksum": SCHEMA_CHECKSUM_VALUE,
57 "sources_requested": ["techcrunch", "github_blog", "nvidia_blog"],
58 "required_sources": ["techcrunch", "github_blog"],
59 "optional_sources": ["nvidia_blog"],
60 "started_at": iso_timestamp(NOW),
61 "crawler_code_sha": "sha256-test",
62 }
63 ctx.update(overrides)
64 return ctx
65
66
67 def _make_article(source: str, title: str = "Test Article", url: str = "") -> dict[str, Any]:
68 return {
69 "source": source,
70 "title": title,
71 "url": url or f"https://example.com/{source}/{title.lower().replace(' ', '-')}",
72 "published_at": iso_timestamp(NOW - timedelta(hours=2)),
73 "categories": ["AI", "Open Source"],
74 "summary": "A test article about AI and machine learning frameworks.",
75 "github_links": ["https://github.com/org/repo"],
76 "entities": ["TestCo"],
77 "relevance_score": 0.8,
78 }
79
80
81 def _make_status(source: str, success: bool = True) -> dict[str, Any]:
82 status: dict[str, Any] = {
83 "source": source,
84 "host": f"{source}.example.com",
85 "started_at": iso_timestamp(NOW),
86 "ended_at": iso_timestamp(NOW + timedelta(seconds=1)),
87 "duration_seconds": 1.0,
88 "timeout_seconds": 15,
89 "attempts": 1,
90 "total_articles": 3 if success else 0,
91 "relevant_articles": 2 if success else 0,
92 "github_links_found": 1 if success else 0,
93 "success": success,
94 "error_class": "" if success else "ConnectionError",
95 "error_message": "" if success else "Connection refused",
96 }
97 return status
98
99
100 def _make_source_artifact(
101 source_id: str,
102 run_context: dict[str, Any] | None = None,
103 articles: list[dict[str, Any]] | None = None,
104 success: bool = True,
105 ) -> dict[str, Any]:
106 ctx = run_context or _make_run_context()
107 arts = (
108 articles
109 if articles is not None
110 else [_make_article(source_id, f"Article {i}") for i in range(3)]
111 )
112 status = _make_status(source_id, success=success)
113 return build_source_artifact(
114 source_id=source_id,
115 articles=arts,
116 status=status,
117 run_context=ctx,
118 crawled_at=NOW,
119 )
120
121
122 # --- Run Context Tests ---
123
124
125 class TestRunContext:
126 def test_build_run_context(self) -> None:
127 ctx = build_run_context(
128 run_id=RUN_ID,
129 week=WEEK,
130 crawl_window={"since": iso_timestamp(SINCE), "until": iso_timestamp(UNTIL)},
131 source_config_checksum_value=CONFIG_CHECKSUM,
132 schema_checksum_value=SCHEMA_CHECKSUM_VALUE,
133 sources_requested=["techcrunch", "github_blog"],
134 required_sources=["techcrunch"],
135 optional_sources=["github_blog"],
136 started_at=iso_timestamp(NOW),
137 crawler_code_sha="sha-test",
138 )
139 assert ctx["run_id"] == RUN_ID
140 assert ctx["week"] == WEEK
141 assert ctx["sources_requested"] == ["github_blog", "techcrunch"]
142 assert ctx["required_sources"] == ["techcrunch"]
143 assert ctx["optional_sources"] == ["github_blog"]
144
145 def test_validate_run_context_valid(self) -> None:
146 ctx = _make_run_context()
147 validate_run_context(ctx) # Should not raise
148
149 def test_validate_run_context_missing_keys(self) -> None:
150 ctx = _make_run_context()
151 del ctx["run_id"]
152 with pytest.raises(FanInValidationError, match="missing keys"):
153 validate_run_context(ctx)
154
155 def test_validate_run_context_bad_schema_version(self) -> None:
156 ctx = _make_run_context(schema_version=99)
157 with pytest.raises(FanInValidationError, match="schema_version mismatch"):
158 validate_run_context(ctx)
159
160 def test_validate_run_context_bad_crawl_window(self) -> None:
161 ctx = _make_run_context(crawl_window={"only_since": "x"})
162 with pytest.raises(FanInValidationError, match="crawl_window"):
163 validate_run_context(ctx)
164
165
166 # --- Per-Source Artifact Tests ---
167
168
169 class TestSourceArtifact:
170 def test_build_source_artifact_structure(self) -> None:
171 ctx = _make_run_context()
172 articles = [_make_article("techcrunch", f"Art {i}") for i in range(3)]
173 status = _make_status("techcrunch")
174
175 artifact = build_source_artifact(
176 source_id="techcrunch",
177 articles=articles,
178 status=status,
179 run_context=ctx,
180 crawled_at=NOW,
181 )
182
183 assert artifact["source_artifact_schema_version"] == SOURCE_ARTIFACT_SCHEMA_VERSION
184 assert artifact["source_id"] == "techcrunch"
185 assert artifact["crawled_at"] == iso_timestamp(NOW)
186 assert artifact["run_context"]["run_id"] == RUN_ID
187 assert artifact["run_context"]["week"] == WEEK
188 assert artifact["status"]["success"] is True
189 assert artifact["metrics"]["total_articles"] == 3
190 assert artifact["metrics"]["relevant_articles"] == 3
191 assert "artifact_checksum" in artifact
192 assert len(artifact["artifact_checksum"]) == 64 # SHA-256 hex
193
194 def test_source_artifact_deterministic(self) -> None:
195 ctx = _make_run_context()
196 articles = [_make_article("techcrunch", f"Art {i}") for i in range(3)]
197 status = _make_status("techcrunch")
198
199 a1 = build_source_artifact(
200 source_id="techcrunch",
201 articles=articles,
202 status=status,
203 run_context=ctx,
204 crawled_at=NOW,
205 )
206 a2 = build_source_artifact(
207 source_id="techcrunch",
208 articles=articles,
209 status=status,
210 run_context=ctx,
211 crawled_at=NOW,
212 )
213 assert a1["artifact_checksum"] == a2["artifact_checksum"]
214 assert a1["articles"] == a2["articles"]
215
216 def test_source_artifact_different_order_same_checksum(self) -> None:
217 """Articles in different order produce same checksum (sorted internally)."""
218 ctx = _make_run_context()
219 articles = [_make_article("techcrunch", f"Art {i}") for i in range(3)]
220 status = _make_status("techcrunch")
221
222 a1 = build_source_artifact(
223 source_id="techcrunch",
224 articles=articles,
225 status=status,
226 run_context=ctx,
227 crawled_at=NOW,
228 )
229 a2 = build_source_artifact(
230 source_id="techcrunch",
231 articles=list(reversed(articles)),
232 status=status,
233 run_context=ctx,
234 crawled_at=NOW,
235 )
236 assert a1["artifact_checksum"] == a2["artifact_checksum"]
237
238 def test_validate_source_artifact_valid(self) -> None:
239 artifact = _make_source_artifact("techcrunch")
240 validate_source_artifact(artifact) # Should not raise
241
242 def test_validate_source_artifact_bad_schema(self) -> None:
243 artifact = _make_source_artifact("techcrunch")
244 artifact["source_artifact_schema_version"] = 99
245 with pytest.raises(FanInValidationError, match="schema version mismatch"):
246 validate_source_artifact(artifact)
247
248 def test_validate_source_artifact_tampered_checksum(self) -> None:
249 artifact = _make_source_artifact("techcrunch")
250 artifact["artifact_checksum"] = "tampered"
251 with pytest.raises(FanInValidationError, match="checksum mismatch"):
252 validate_source_artifact(artifact)
253
254 def test_validate_source_artifact_missing_keys(self) -> None:
255 artifact = _make_source_artifact("techcrunch")
256 del artifact["metrics"]
257 with pytest.raises(FanInValidationError, match="missing keys"):
258 validate_source_artifact(artifact)
259
260
261 # --- Fan-In Merge Tests ---
262
263
264 class TestMerge:
265 def test_merge_basic(self) -> None:
266 ctx = _make_run_context()
267 artifacts = [
268 _make_source_artifact("techcrunch", ctx),
269 _make_source_artifact("github_blog", ctx),
270 ]
271
272 output, warnings = merge_source_artifacts(artifacts, ctx, merged_at=NOW)
273
274 assert output["schema_version"] == CANONICAL_SCHEMA_VERSION
275 assert output["source"] == "external_news"
276 assert output["week"] == WEEK
277 assert output["crawl_window"] == ctx["crawl_window"]
278 assert output["metadata"]["sources_requested"] == [
279 "github_blog",
280 "nvidia_blog",
281 "techcrunch",
282 ]
283 assert "techcrunch" in output["metadata"]["sources_succeeded"]
284 assert "github_blog" in output["metadata"]["sources_succeeded"]
285 assert output["metadata"]["fan_in_mode"] == "matrix"
286 assert output["metadata"]["total_articles"] >= 0
287 assert output["metadata"]["artifact_checksum"]
288 # nvidia_blog is optional and missing → warning
289 assert any(w.source_id == "nvidia_blog" for w in warnings)
290
291 def test_merge_deterministic(self) -> None:
292 """Same inputs always produce same output (excluding crawled_at)."""
293 ctx = _make_run_context()
294 artifacts = [
295 _make_source_artifact("techcrunch", ctx),
296 _make_source_artifact("github_blog", ctx),
297 ]
298
299 out1, _ = merge_source_artifacts(artifacts, ctx, merged_at=NOW)
300 out2, _ = merge_source_artifacts(artifacts, ctx, merged_at=NOW)
301
302 assert out1["metadata"]["artifact_checksum"] == out2["metadata"]["artifact_checksum"]
303 assert out1["articles"] == out2["articles"]
304 assert (
305 out1["metadata"]["source_artifact_provenance"]
306 == out2["metadata"]["source_artifact_provenance"]
307 )
308
309 def test_merge_different_artifact_order_same_result(self) -> None:
310 """Order of input artifacts doesn't affect output."""
311 ctx = _make_run_context()
312 a1 = _make_source_artifact("techcrunch", ctx)
313 a2 = _make_source_artifact("github_blog", ctx)
314
315 out_ab, _ = merge_source_artifacts([a1, a2], ctx, merged_at=NOW)
316 out_ba, _ = merge_source_artifacts([a2, a1], ctx, merged_at=NOW)
317
318 assert out_ab["metadata"]["artifact_checksum"] == out_ba["metadata"]["artifact_checksum"]
319
320 def test_merge_deduplicates_across_sources(self) -> None:
321 """Articles with same URL from different sources are deduped."""
322 ctx = _make_run_context()
323 shared_url = "https://example.com/shared-article"
324 art_tc = _make_article("techcrunch", "Shared Article", shared_url)
325 art_gh = _make_article("github_blog", "Shared Article", shared_url)
326
327 a1 = build_source_artifact(
328 source_id="techcrunch",
329 articles=[art_tc],
330 status=_make_status("techcrunch"),
331 run_context=ctx,
332 crawled_at=NOW,
333 )
334 a2 = build_source_artifact(
335 source_id="github_blog",
336 articles=[art_gh],
337 status=_make_status("github_blog"),
338 run_context=ctx,
339 crawled_at=NOW,
340 )
341
342 output, _ = merge_source_artifacts([a1, a2], ctx, merged_at=NOW)
343 assert output["metadata"]["dedupe_count"] == 1
344 # The merged article preserves both sources
345 merged_article = output["articles"][0]
346 assert "techcrunch" in merged_article["sources"]
347 assert "github_blog" in merged_article["sources"]
348
349 def test_merge_with_failed_optional_source(self) -> None:
350 """Optional source failure produces warning but valid output."""
351 ctx = _make_run_context(
352 required_sources=["techcrunch"],
353 optional_sources=["github_blog"],
354 )
355 a1 = _make_source_artifact("techcrunch", ctx)
356 a2 = _make_source_artifact("github_blog", ctx, articles=[], success=False)
357
358 output, warnings = merge_source_artifacts([a1, a2], ctx, merged_at=NOW)
359
360 assert "github_blog" in output["metadata"]["sources_failed"]
361 assert any(
362 w.category == "source_failure" and w.source_id == "github_blog" for w in warnings
363 )
364 # Output is still valid
365 assert output["metadata"]["artifact_checksum"]
366
367
368 # --- Fan-In Validation Tests ---
369
370
371 class TestValidation:
372 def test_rejects_empty_artifacts(self) -> None:
373 ctx = _make_run_context()
374 with pytest.raises(FanInValidationError, match="No source artifacts"):
375 validate_fan_in_compatibility([], ctx)
376
377 def test_rejects_schema_mismatch(self) -> None:
378 ctx = _make_run_context()
379 artifact = _make_source_artifact("techcrunch", ctx)
380 artifact["run_context"]["schema_checksum"] = "wrong"
381 # Recompute checksum after tampering
382 from scripts.rss_fan_in import _source_artifact_checksum
383
384 artifact["artifact_checksum"] = _source_artifact_checksum(artifact)
385
386 with pytest.raises(FanInValidationError, match="Schema checksum mismatch"):
387 validate_fan_in_compatibility([artifact], ctx)
388
389 def test_rejects_window_mismatch(self) -> None:
390 ctx = _make_run_context()
391 artifact = _make_source_artifact("techcrunch", ctx)
392 artifact["run_context"]["crawl_window"] = {"since": "wrong", "until": "wrong"}
393 from scripts.rss_fan_in import _source_artifact_checksum
394
395 artifact["artifact_checksum"] = _source_artifact_checksum(artifact)
396
397 with pytest.raises(FanInValidationError, match="Crawl window mismatch"):
398 validate_fan_in_compatibility([artifact], ctx)
399
400 def test_rejects_config_checksum_mismatch(self) -> None:
401 ctx = _make_run_context()
402 artifact = _make_source_artifact("techcrunch", ctx)
403 artifact["run_context"]["source_config_checksum"] = "wrong"
404 from scripts.rss_fan_in import _source_artifact_checksum
405
406 artifact["artifact_checksum"] = _source_artifact_checksum(artifact)
407
408 with pytest.raises(FanInValidationError, match="Source config checksum mismatch"):
409 validate_fan_in_compatibility([artifact], ctx)
410
411 def test_rejects_run_id_mismatch(self) -> None:
412 ctx = _make_run_context()
413 artifact = _make_source_artifact("techcrunch", ctx)
414 artifact["run_context"]["run_id"] = "different-run"
415 from scripts.rss_fan_in import _source_artifact_checksum
416
417 artifact["artifact_checksum"] = _source_artifact_checksum(artifact)
418
419 with pytest.raises(FanInValidationError, match="Run ID mismatch"):
420 validate_fan_in_compatibility([artifact], ctx)
421
422 def test_rejects_missing_required_sources(self) -> None:
423 ctx = _make_run_context(required_sources=["techcrunch", "github_blog"])
424 # Only provide techcrunch
425 artifact = _make_source_artifact("techcrunch", ctx)
426
427 with pytest.raises(FanInValidationError, match="Missing required source"):
428 validate_fan_in_compatibility([artifact], ctx)
429
430 def test_rejects_duplicate_sources(self) -> None:
431 ctx = _make_run_context(required_sources=["techcrunch"])
432 a1 = _make_source_artifact("techcrunch", ctx)
433 a2 = _make_source_artifact("techcrunch", ctx)
434
435 with pytest.raises(FanInValidationError, match="Duplicate source"):
436 validate_fan_in_compatibility([a1, a2], ctx)
437
438 def test_warns_missing_optional_source(self) -> None:
439 ctx = _make_run_context(
440 required_sources=["techcrunch"],
441 optional_sources=["nvidia_blog"],
442 )
443 artifact = _make_source_artifact("techcrunch", ctx)
444
445 warnings = validate_fan_in_compatibility([artifact], ctx)
446 assert len(warnings) == 1
447 assert warnings[0].source_id == "nvidia_blog"
448 assert warnings[0].category == "missing_optional_source"
449
450
451 # --- CLI Integration Tests ---
452
453
454 class TestCLI:
455 def test_emit_and_merge_roundtrip(self, tmp_path: Path) -> None:
456 """Full roundtrip: emit per-source artifacts then merge them."""
457 from scripts.rss_fan_in import main as fan_in_main
458
459 ctx = _make_run_context(
460 required_sources=["techcrunch", "github_blog"],
461 optional_sources=[],
462 )
463 ctx_path = tmp_path / "run-context.json"
464 ctx_path.write_text(json.dumps(ctx), encoding="utf-8")
465
466 artifacts_dir = tmp_path / "artifacts"
467 artifacts_dir.mkdir()
468
469 # Emit two source artifacts
470 for source_id in ["techcrunch", "github_blog"]:
471 articles = [_make_article(source_id, f"Art {i}") for i in range(2)]
472 articles_path = tmp_path / f"{source_id}-articles.json"
473 articles_path.write_text(json.dumps(articles), encoding="utf-8")
474
475 status = _make_status(source_id)
476 status_path = tmp_path / f"{source_id}-status.json"
477 status_path.write_text(json.dumps(status), encoding="utf-8")
478
479 result = fan_in_main(
480 [
481 "emit",
482 "--source",
483 source_id,
484 "--articles",
485 str(articles_path),
486 "--status",
487 str(status_path),
488 "--run-context",
489 str(ctx_path),
490 "--output",
491 str(artifacts_dir / f"{source_id}.json"),
492 ]
493 )
494 assert result == 0
495
496 # Verify artifacts were created
497 assert (artifacts_dir / "techcrunch.json").exists()
498 assert (artifacts_dir / "github_blog.json").exists()
499
500 # Merge
501 merged_path = tmp_path / "merged.json"
502 result = fan_in_main(
503 [
504 "merge",
505 "--artifacts-dir",
506 str(artifacts_dir),
507 "--run-context",
508 str(ctx_path),
509 "--output",
510 str(merged_path),
511 ]
512 )
513 assert result == 0
514 assert merged_path.exists()
515
516 # Validate merged output
517 merged = json.loads(merged_path.read_text(encoding="utf-8"))
518 assert merged["schema_version"] == CANONICAL_SCHEMA_VERSION
519 assert merged["source"] == "external_news"
520 assert merged["metadata"]["fan_in_mode"] == "matrix"
521 assert merged["metadata"]["total_articles"] == 4
522 assert "techcrunch" in merged["metadata"]["sources_succeeded"]
523 assert "github_blog" in merged["metadata"]["sources_succeeded"]
524
525 def test_validate_command(self, tmp_path: Path) -> None:
526 """Validate subcommand checks artifacts without merging."""
527 from scripts.rss_fan_in import main as fan_in_main
528
529 ctx = _make_run_context(required_sources=["techcrunch"], optional_sources=[])
530 ctx_path = tmp_path / "run-context.json"
531 ctx_path.write_text(json.dumps(ctx), encoding="utf-8")
532
533 artifacts_dir = tmp_path / "artifacts"
534 artifacts_dir.mkdir()
535
536 artifact = _make_source_artifact("techcrunch", ctx)
537 (artifacts_dir / "techcrunch.json").write_text(json.dumps(artifact), encoding="utf-8")
538
539 result = fan_in_main(
540 [
541 "validate",
542 "--artifacts-dir",
543 str(artifacts_dir),
544 "--run-context",
545 str(ctx_path),
546 ]
547 )
548 assert result == 0
549
550 def test_merge_fails_on_missing_required(self, tmp_path: Path) -> None:
551 """Merge fails when required source is missing."""
552 from scripts.rss_fan_in import main as fan_in_main
553
554 ctx = _make_run_context(
555 required_sources=["techcrunch", "github_blog"],
556 optional_sources=[],
557 )
558 ctx_path = tmp_path / "run-context.json"
559 ctx_path.write_text(json.dumps(ctx), encoding="utf-8")
560
561 artifacts_dir = tmp_path / "artifacts"
562 artifacts_dir.mkdir()
563
564 # Only provide techcrunch
565 artifact = _make_source_artifact("techcrunch", ctx)
566 (artifacts_dir / "techcrunch.json").write_text(json.dumps(artifact), encoding="utf-8")
567
568 result = fan_in_main(
569 [
570 "merge",
571 "--artifacts-dir",
572 str(artifacts_dir),
573 "--run-context",
574 str(ctx_path),
575 "--output",
576 str(tmp_path / "merged.json"),
577 ]
578 )
579 assert result == 1 # Fails due to missing required source
580
581
582 # --- Determinism Proof ---
583
584
585 class TestDeterminism:
586 """Fixture-based proof that same inputs → same merged output."""
587
588 def test_deterministic_merge_with_fixed_fixtures(self, tmp_path: Path) -> None:
589 """Given fixed input artifacts, merge always produces identical output."""
590 ctx = _make_run_context(
591 required_sources=["techcrunch", "github_blog", "nvidia_blog"],
592 optional_sources=[],
593 )
594
595 # Create deterministic articles
596 articles_by_source = {}
597 for source_id in ["techcrunch", "github_blog", "nvidia_blog"]:
598 articles_by_source[source_id] = [
599 {
600 "source": source_id,
601 "title": f"{source_id} Article {i}",
602 "url": f"https://{source_id}.example.com/article-{i}",
603 "published_at": "2026-06-12T10:00:00Z",
604 "categories": ["AI"],
605 "summary": f"Summary for {source_id} article {i}",
606 "github_links": [],
607 "entities": [],
608 "relevance_score": 0.8,
609 }
610 for i in range(3)
611 ]
612
613 artifacts = [
614 build_source_artifact(
615 source_id=sid,
616 articles=articles_by_source[sid],
617 status=_make_status(sid),
618 run_context=ctx,
619 crawled_at=NOW,
620 )
621 for sid in ["techcrunch", "github_blog", "nvidia_blog"]
622 ]
623
624 # Merge 10 times and verify all produce identical output
625 checksums = set()
626 for _ in range(10):
627 output, _ = merge_source_artifacts(artifacts, ctx, merged_at=NOW)
628 checksums.add(output["metadata"]["artifact_checksum"])
629
630 assert len(checksums) == 1, (
631 f"Non-deterministic merge: got {len(checksums)} distinct checksums"
632 )