61
62
class PodcasterHandoffTests(unittest.TestCase):
63
def _write_manifest(
64
- self, base: Path, *, run_mode: str = "normal", ai_status: str = "ai"
64
+ self,
65
+ base: Path,
66
+ *,
67
+ run_mode: str = "normal",
68
+ ai_status: str = "ai",
69
+ policy: str | None = None,
70
+ audit: dict | None = None,
71
) -> Path:
72
manifest = base / "publish-manifest.json"
67
- manifest.write_text(
68
- json.dumps(
73
+ promotion: dict[str, object] = {"eligible": True, "decision": "promote"}
74
+ if policy is not None:
75
+ promotion["policy"] = policy
76
+ payload = {
77
+ "week": "2026-W23",
78
+ "run_id": "123456789",
79
+ "run_mode": run_mode,
80
+ "candidate": {"summary_sha256": "a" * 64, "content_sha256": "c" * 64},
81
+ "analysis": {"ai_status": ai_status},
82
+ "promotion": promotion,
83
+ "source_artifacts": [
84
{
70
- "week": "2026-W23",
71
- "run_id": "123456789",
72
- "run_mode": run_mode,
73
- "candidate": {"summary_sha256": "a" * 64, "content_sha256": "c" * 64},
74
- "analysis": {"ai_status": ai_status},
75
- "promotion": {"eligible": True, "decision": "promote"},
76
- "source_artifacts": [
77
- {
78
- "role": "raw",
79
- "path": "data/raw/2026-W23.json",
80
- "sha256": "b" * 64,
81
- "generated_at": "2026-06-08T10:15:00Z",
82
- "freshness": {"status": "fresh", "reasons": []},
83
- "provenance": {
84
- "path": "data/raw/2026-W23.json",
85
- "sha256": "b" * 64,
86
- },
87
- },
88
- {
89
- "role": "blob",
90
- "artifact_url": "https://example.blob.core.windows.net/artifacts/source.json",
91
- "exists": True,
92
- "size_bytes": 1024,
93
- },
94
- ],
95
- }
96
- ),
85
+ "role": "raw",
86
+ "path": "data/raw/2026-W23.json",
87
+ "sha256": "b" * 64,
88
+ "generated_at": "2026-06-08T10:15:00Z",
89
+ "freshness": {"status": "fresh", "reasons": []},
90
+ "provenance": {
91
+ "path": "data/raw/2026-W23.json",
92
+ "sha256": "b" * 64,
93
+ },
94
+ },
95
+ {
96
+ "role": "blob",
97
+ "artifact_url": "https://example.blob.core.windows.net/artifacts/source.json",
98
+ "exists": True,
99
+ "size_bytes": 1024,
100
+ },
101
+ ],
102
+ }
103
+ if audit is not None:
104
+ payload["audit"] = audit
105
+ manifest.write_text(
106
+ json.dumps(payload),
107
encoding="utf-8",
108
)
109
return manifest
566
manifest_path=no_ai_manifest,
567
)
568
569
+ def test_plain_restore_replay_skips_without_calling_podcaster(self) -> None:
570
+ tests_root = Path(__file__).resolve().parent
571
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
572
+ manifest = self._write_manifest(Path(tmpdir), run_mode="restore")
573
+ with (
574
+ mock.patch.object(podcaster_handoff.request, "urlopen") as urlopen_mock,
575
+ mock.patch.dict(
576
+ podcaster_handoff.os.environ, {"PODCASTER_API_KEY": "super-secret-value"}
577
+ ),
578
+ mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
579
+ ):
580
+ exit_code = podcaster_handoff.main(
581
+ [
582
+ "--week",
583
+ "2026-W23",
584
+ "--article-url",
585
+ "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
586
+ "--article-path",
587
+ "content/weekly/2026/W23.md",
588
+ "--publish-run-id",
589
+ "123456789",
590
+ "--publish-mode",
591
+ "normal",
592
+ "--manifest",
593
+ str(manifest),
594
+ "--endpoint",
595
+ "http://localhost:7071/api/generate",
596
+ ]
597
+ )
598
+
599
+ self.assertEqual(exit_code, 0)
600
+ urlopen_mock.assert_not_called()
601
+ self.assertIn("skipped", stdout.getvalue())
602
+ self.assertIn("non-audited replay", stdout.getvalue())
603
+
604
+ def test_audited_force_replace_restore_is_handoff_eligible(self) -> None:
605
+ tests_root = Path(__file__).resolve().parent
606
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
607
+ manifest = self._write_manifest(
608
+ Path(tmpdir),
609
+ run_mode="restore",
610
+ policy="force-replace",
611
+ audit={"actor": "jmservera", "reason": "W30 press-inclusion correction"},
612
+ )
613
+
614
+ payload = podcaster_handoff.build_payload(
615
+ week="2026-W23",
616
+ article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
617
+ article_path="content/weekly/2026/W23.md",
618
+ publish_run_id="123456789",
619
+ publish_mode="normal",
620
+ manifest_path=manifest,
621
+ )
622
+ loaded_manifest = json.loads(manifest.read_text(encoding="utf-8"))
623
+
624
+ self.assertEqual(payload["week"], "2026-W23")
625
+ self.assertFalse(podcaster_handoff._is_gated_replay(loaded_manifest, week="2026-W23"))
626
+
627
+ def test_normal_publish_manifest_is_handoff_eligible(self) -> None:
628
+ tests_root = Path(__file__).resolve().parent
629
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
630
+ manifest = self._write_manifest(Path(tmpdir), run_mode="normal")
631
+
632
+ payload = podcaster_handoff.build_payload(
633
+ week="2026-W23",
634
+ article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
635
+ article_path="content/weekly/2026/W23.md",
636
+ publish_run_id="123456789",
637
+ publish_mode="normal",
638
+ manifest_path=manifest,
639
+ )
640
+ loaded_manifest = json.loads(manifest.read_text(encoding="utf-8"))
641
+
642
+ self.assertEqual(payload["week"], "2026-W23")
643
+ self.assertFalse(podcaster_handoff._is_gated_replay(loaded_manifest, week="2026-W23"))
644
+
645
+ def test_manifest_allows_audited_force_replace_but_not_plain_restore(self) -> None:
646
+ tests_root = Path(__file__).resolve().parent
647
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
648
+ base = Path(tmpdir)
649
+ audited_manifest = self._write_manifest(
650
+ base,
651
+ run_mode="restore",
652
+ policy="force-replace",
653
+ audit={"actor": "jmservera", "reason": "W30 press-inclusion correction"},
654
+ )
655
+ audited = json.loads(audited_manifest.read_text(encoding="utf-8"))
656
+ plain_restore_manifest = self._write_manifest(base, run_mode="restore")
657
+ plain_restore = json.loads(plain_restore_manifest.read_text(encoding="utf-8"))
658
+
659
+ self.assertTrue(
660
+ podcaster_handoff._manifest_allows_handoff(
661
+ audited, week="2026-W23", publish_mode="normal"
662
+ )
663
+ )
664
+ self.assertFalse(
665
+ podcaster_handoff._manifest_allows_handoff(
666
+ plain_restore, week="2026-W23", publish_mode="normal"
667
+ )
668
+ )
669
+
670
+ def test_non_restore_modes_are_not_gated_replays(self) -> None:
671
+ # A gated replay is specifically a plain (non-audited) restore. Any other
672
+ # non-normal or missing run_mode must NOT be treated as a clean skip -- it
673
+ # stays fail-closed via build_payload -- so a broken manifest is never
674
+ # silently skipped (regression for jmservera/SquadScope#587 review).
675
+ tests_root = Path(__file__).resolve().parent
676
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
677
+ base = Path(tmpdir)
678
+ missing_mode = self._write_manifest(base, run_mode="normal")
679
+ missing = json.loads(missing_mode.read_text(encoding="utf-8"))
680
+ del missing["run_mode"]
681
+ candidate_only = self._write_manifest(base, run_mode="candidate-only")
682
+ candidate = json.loads(candidate_only.read_text(encoding="utf-8"))
683
+
684
+ self.assertFalse(podcaster_handoff._is_gated_replay(missing, week="2026-W23"))
685
+ self.assertFalse(podcaster_handoff._is_gated_replay(candidate, week="2026-W23"))
686
+
687
+ def test_build_payload_uses_preloaded_manifest_without_reloading(self) -> None:
688
+ # main() loads the manifest once for _is_gated_replay and passes it into
689
+ # build_payload, which must reuse it rather than re-reading the file.
690
+ tests_root = Path(__file__).resolve().parent
691
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
692
+ manifest_path = self._write_manifest(Path(tmpdir), run_mode="normal")
693
+ preloaded = json.loads(manifest_path.read_text(encoding="utf-8"))
694
+ with mock.patch.object(
695
+ podcaster_handoff, "_load_manifest", side_effect=AssertionError("reloaded")
696
+ ):
697
+ payload = podcaster_handoff.build_payload(
698
+ week="2026-W23",
699
+ article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
700
+ article_path="content/weekly/2026/W23.md",
701
+ publish_run_id="123456789",
702
+ publish_mode="normal",
703
+ manifest=preloaded,
704
+ )
705
+ self.assertEqual(payload["week"], "2026-W23")
706
+
707
def test_missing_manifest_path_raises_fail_closed(self) -> None:
708
tests_root = Path(__file__).resolve().parent
709
with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: