main
py 1,200 lines 52.5 KB
Raw
1 import hashlib
2 import io
3 import json
4 import sys
5 import tempfile
6 import unittest
7 from html.parser import HTMLParser
8 from pathlib import Path
9 from unittest import mock
10 from urllib import error
11
12 import scripts.podcaster_handoff as podcaster_handoff
13
14
15 class _FakeHTTPResponse(io.BytesIO):
16 status = 202
17
18 def __enter__(self):
19 return self
20
21 def __exit__(self, exc_type, exc, tb):
22 self.close()
23 return False
24
25 def getcode(self):
26 return self.status
27
28
29 class _BalancedHtmlParser(HTMLParser):
30 _void_tags = {
31 "area",
32 "base",
33 "br",
34 "col",
35 "embed",
36 "hr",
37 "img",
38 "input",
39 "link",
40 "meta",
41 "param",
42 "source",
43 "track",
44 "wbr",
45 }
46
47 def __init__(self) -> None:
48 super().__init__(convert_charrefs=False)
49 self.stack: list[str] = []
50 self.errors: list[str] = []
51
52 def handle_starttag(self, tag: str, attrs) -> None: # type: ignore[override]
53 if tag not in self._void_tags:
54 self.stack.append(tag)
55
56 def handle_endtag(self, tag: str) -> None: # type: ignore[override]
57 if not self.stack or self.stack[-1] != tag:
58 self.errors.append(tag)
59 return
60 self.stack.pop()
61
62
63 class PodcasterHandoffTests(unittest.TestCase):
64 def _write_manifest(
65 self,
66 base: Path,
67 *,
68 run_mode: str = "normal",
69 ai_status: str = "ai",
70 policy: str | None = None,
71 audit: dict | None = None,
72 ) -> Path:
73 manifest = base / "publish-manifest.json"
74 promotion: dict[str, object] = {"eligible": True, "decision": "promote"}
75 if policy is not None:
76 promotion["policy"] = policy
77 payload = {
78 "week": "2026-W23",
79 "run_id": "123456789",
80 "run_mode": run_mode,
81 "candidate": {"summary_sha256": "a" * 64, "content_sha256": "c" * 64},
82 "analysis": {"ai_status": ai_status},
83 "promotion": promotion,
84 "source_artifacts": [
85 {
86 "role": "raw",
87 "path": "data/raw/2026-W23.json",
88 "sha256": "b" * 64,
89 "generated_at": "2026-06-08T10:15:00Z",
90 "freshness": {"status": "fresh", "reasons": []},
91 "provenance": {
92 "path": "data/raw/2026-W23.json",
93 "sha256": "b" * 64,
94 },
95 },
96 {
97 "role": "blob",
98 "artifact_url": "https://example.blob.core.windows.net/artifacts/source.json",
99 "exists": True,
100 "size_bytes": 1024,
101 },
102 ],
103 }
104 if audit is not None:
105 payload["audit"] = audit
106 manifest.write_text(
107 json.dumps(payload),
108 encoding="utf-8",
109 )
110 return manifest
111
112 def _write_historical_context(
113 self,
114 base: Path,
115 *,
116 month_synthesis: str | None = None,
117 yearly_narrative: str | None = None,
118 ) -> None:
119 if month_synthesis is not None:
120 month_path = base / "data" / "analyzed"
121 month_path.mkdir(parents=True, exist_ok=True)
122 (month_path / "2026-06-month-synthesis.md").write_text(
123 month_synthesis, encoding="utf-8"
124 )
125 if yearly_narrative is not None:
126 yearly_path = base / "content" / "yearly"
127 yearly_path.mkdir(parents=True, exist_ok=True)
128 (yearly_path / "2026.md").write_text(yearly_narrative, encoding="utf-8")
129
130 def test_escape_gha_data_escapes_workflow_command_data(self) -> None:
131 self.assertEqual(podcaster_handoff._escape_gha_data("a\r\nb%c"), "a%0D%0Ab%25c")
132
133 def test_build_payload_uses_required_fields_and_real_optional_values(self) -> None:
134 tests_root = Path(__file__).resolve().parent
135 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
136 manifest = self._write_manifest(Path(tmpdir))
137 # Create article file so article_content is included
138 article_dir = Path(tmpdir) / "content" / "weekly" / "2026"
139 article_dir.mkdir(parents=True)
140 article_file = article_dir / "W23.md"
141 article_file.write_text(
142 "---\ntitle: Week 23 Report\nsummary: Week 23 summary.\n---\n# Heading\nBody content here.\n",
143 encoding="utf-8",
144 )
145 self._write_historical_context(
146 Path(tmpdir),
147 month_synthesis=(
148 "---\n"
149 "title: June 2026 Month Synthesis\n"
150 "---\n\n"
151 "## Month Synthesis\n\n"
152 "June continued the agent-skills story.\n\n"
153 "## Trend Arc\n\n"
154 "- Agent skills kept accelerating.\n\n"
155 "## Prediction Review\n\n"
156 "Ignored.\n"
157 ),
158 yearly_narrative=(
159 "---\n"
160 "title: 2026 Yearly Narrative\n"
161 "---\n\n"
162 "## Year in Review\n\n"
163 "The year kept compounding around agent packaging and trust gaps.\n"
164 ),
165 )
166
167 payload = podcaster_handoff.build_payload(
168 week="2026-W23",
169 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
170 article_path="content/weekly/2026/W23.md",
171 publish_run_id="123456789",
172 publish_mode="normal",
173 manifest_path=manifest,
174 repo_root=Path(tmpdir),
175 )
176
177 self.assertEqual(payload["week"], "2026-W23")
178 self.assertEqual(
179 payload["article_url"], "https://jmservera.github.io/SquadScope/weekly/2026/w23/"
180 )
181 self.assertEqual(payload["article_path"], "content/weekly/2026/W23.md")
182 self.assertEqual(payload["publish_run_id"], "123456789")
183 self.assertEqual(payload["publish_mode"], "normal")
184 self.assertEqual(payload["article_sha256"], "c" * 64)
185 self.assertEqual(
186 payload["source_artifacts"],
187 [
188 {
189 "role": "raw",
190 "path": "data/raw/2026-W23.json",
191 "sha256": "b" * 64,
192 "generated_at": "2026-06-08T10:15:00Z",
193 "freshness": {"status": "fresh", "reasons": []},
194 "provenance": {
195 "path": "data/raw/2026-W23.json",
196 "sha256": "b" * 64,
197 },
198 },
199 {
200 "role": "blob",
201 "url": "https://example.blob.core.windows.net/artifacts/source.json",
202 "exists": True,
203 "size_bytes": 1024,
204 },
205 ],
206 )
207 self.assertNotIn("artifact_url", payload["source_artifacts"][1])
208 self.assertNotIn("freshness_status", payload["source_artifacts"][0])
209 self.assertNotIn("force", payload)
210 self.assertNotIn("dry_run", payload)
211 # article_content and article_title from the article file
212 self.assertIn("article_content", payload)
213 self.assertIn("Week 23 Report", payload["article_title"])
214 self.assertEqual(payload["article_summary"], "Week 23 summary.")
215 self.assertIn("Body content here.", payload["article_content"])
216 self.assertEqual(
217 payload["script_directions"]["historical_context"]["month_synthesis"],
218 "## Month Synthesis June continued the agent-skills story. ## Trend Arc - Agent skills kept accelerating.",
219 )
220 self.assertEqual(
221 payload["script_directions"]["historical_context"]["yearly_narrative"],
222 "## Year in Review The year kept compounding around agent packaging and trust gaps.",
223 )
224 self.assertLess(len(json.dumps(payload)), 100_000)
225
226 def test_smoke_payload_matches_real_weekly_handoff_shape(self) -> None:
227 podcaster_root = Path(__file__).resolve().parents[2] / "SquadScope-Podcaster"
228 if not podcaster_root.exists():
229 self.skipTest("SquadScope-Podcaster checkout is not available for contract validation")
230
231 sys.path.insert(0, str(podcaster_root))
232 try:
233 from podcaster.validation import validate_payload
234 finally:
235 sys.path.pop(0)
236
237 tests_root = Path(__file__).resolve().parent
238 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
239 manifest = self._write_manifest(Path(tmpdir))
240 article_dir = Path(tmpdir) / "content" / "weekly" / "2026"
241 article_dir.mkdir(parents=True)
242 # The Podcaster contract rejects article_content shorter than 150 chars
243 # (the W30 press-less "91-char baseline" defect) and requires
244 # article_sha256 to match the exact UTF-8 article_content bytes, so this
245 # smoke fixture must be a realistic full-length article whose sha256 the
246 # manifest records — mirroring a real weekly handoff.
247 article_text = (
248 "---\ntitle: Week 23 Report\nsummary: Week 23 summary.\n---\n"
249 "# Week 23 Report\n"
250 "This week the agent-skills ecosystem kept compounding: new packaging "
251 "conventions, tighter trust boundaries, and a steady stream of GitHub "
252 "signal worth reading in full.\n"
253 )
254 (article_dir / "W23.md").write_text(article_text, encoding="utf-8")
255 # Point candidate.content_sha256 at the real article bytes so the emitted
256 # payload.article_sha256 matches what the Podcaster recomputes and checks.
257 manifest_data = json.loads(manifest.read_text(encoding="utf-8"))
258 manifest_data["candidate"]["content_sha256"] = hashlib.sha256(
259 article_text.encode("utf-8")
260 ).hexdigest()
261 manifest.write_text(json.dumps(manifest_data), encoding="utf-8")
262
263 payload = podcaster_handoff.build_payload(
264 week="2026-W23",
265 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
266 article_path="content/weekly/2026/W23.md",
267 publish_run_id="123456789",
268 publish_mode="normal",
269 manifest_path=manifest,
270 podcast_config_path=Path(__file__).resolve().parents[1] / "config" / "podcast.json",
271 podcaster_dry_run=True,
272 repo_root=Path(tmpdir),
273 )
274
275 self.assertEqual(validate_payload(payload), [])
276 self.assertTrue(payload["dry_run"])
277 self.assertIn("source_artifacts", payload)
278 self.assertTrue(payload["source_artifacts"])
279 self.assertIn("podcast_config", payload)
280 self.assertIn("script_directions", payload)
281 # Production config enables backchannels for every render (operator directive,
282 # SquadScope-Podcaster#555). It rides through under script_directions.
283 self.assertTrue(payload["script_directions"]["backchannels"]["enabled"])
284 self.assertIn("spotify_publish", payload)
285 self.assertEqual(payload["article_title"], "Week 23 Report")
286 self.assertEqual(payload["article_summary"], "Week 23 summary.")
287
288 def test_build_payload_filters_non_string_source_artifact_lists(self) -> None:
289 tests_root = Path(__file__).resolve().parent
290 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
291 manifest = self._write_manifest(Path(tmpdir))
292 payload = json.loads(manifest.read_text(encoding="utf-8"))
293 payload["source_artifacts"][0]["sources_requested"] = ["github", "", None, 3]
294 payload["source_artifacts"][0]["sources_succeeded"] = ["github", False]
295 payload["source_artifacts"][0]["sources_failed"] = ["rss", "", {"bad": "entry"}]
296 payload["source_artifacts"][1]["sources_requested"] = [None, 0, ""]
297 manifest.write_text(json.dumps(payload), encoding="utf-8")
298
299 built = podcaster_handoff.build_payload(
300 week="2026-W23",
301 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
302 article_path="content/weekly/2026/W23.md",
303 publish_run_id="123456789",
304 publish_mode="normal",
305 manifest_path=manifest,
306 repo_root=Path(tmpdir),
307 )
308
309 self.assertEqual(built["source_artifacts"][0]["sources_requested"], ["github"])
310 self.assertEqual(built["source_artifacts"][0]["sources_succeeded"], ["github"])
311 self.assertEqual(built["source_artifacts"][0]["sources_failed"], ["rss"])
312 self.assertNotIn("sources_requested", built["source_artifacts"][1])
313
314 def test_build_payload_omits_historical_context_when_both_files_are_missing(self) -> None:
315 tests_root = Path(__file__).resolve().parent
316 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
317 manifest = self._write_manifest(Path(tmpdir))
318 article_dir = Path(tmpdir) / "content" / "weekly" / "2026"
319 article_dir.mkdir(parents=True)
320 (article_dir / "W23.md").write_text(
321 "---\ntitle: Week 23 Report\nsummary: Week 23 summary.\n---\n# Heading\nBody content here.\n",
322 encoding="utf-8",
323 )
324
325 payload = podcaster_handoff.build_payload(
326 week="2026-W23",
327 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
328 article_path="content/weekly/2026/W23.md",
329 publish_run_id="123456789",
330 publish_mode="normal",
331 manifest_path=manifest,
332 repo_root=Path(tmpdir),
333 )
334
335 self.assertNotIn("historical_context", payload.get("script_directions", {}))
336
337 def test_read_historical_context_includes_available_file_when_other_is_missing(self) -> None:
338 tests_root = Path(__file__).resolve().parent
339 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
340 base = Path(tmpdir)
341 self._write_historical_context(
342 base,
343 yearly_narrative=(
344 "---\n"
345 "title: 2026 Yearly Narrative\n"
346 "---\n\n"
347 "## Year in Review\n\n"
348 "Only the yearly narrative is available.\n"
349 ),
350 )
351
352 context = podcaster_handoff._read_historical_context("2026-W23", base)
353
354 self.assertEqual(
355 context,
356 {"yearly_narrative": "## Year in Review Only the yearly narrative is available."},
357 )
358
359 def test_read_historical_context_truncates_to_word_budget(self) -> None:
360 tests_root = Path(__file__).resolve().parent
361 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
362 base = Path(tmpdir)
363 month_words = " ".join(f"month{i}" for i in range(1, 351))
364 trend_words = " ".join(f"trend{i}" for i in range(1, 101))
365 yearly_words = " ".join(f"year{i}" for i in range(1, 651))
366 self._write_historical_context(
367 base,
368 month_synthesis=(
369 "---\n"
370 "title: June 2026 Month Synthesis\n"
371 "---\n\n"
372 "## Month Synthesis\n\n"
373 f"{month_words}\n\n"
374 "## Trend Arc\n\n"
375 f"{trend_words}\n"
376 ),
377 yearly_narrative=(
378 "---\n"
379 "title: 2026 Yearly Narrative\n"
380 "---\n\n"
381 "## Year in Review\n\n"
382 f"{yearly_words}\n"
383 ),
384 )
385
386 context = podcaster_handoff._read_historical_context("2026-W23", base)
387
388 self.assertIsNotNone(context)
389 assert context is not None
390 self.assertLessEqual(
391 len(context["month_synthesis"].split()),
392 podcaster_handoff.MAX_MONTH_SYNTHESIS_WORDS,
393 )
394 self.assertLessEqual(
395 len(context["yearly_narrative"].split()),
396 podcaster_handoff.MAX_YEARLY_NARRATIVE_WORDS,
397 )
398 self.assertLessEqual(
399 len(context["month_synthesis"].split()) + len(context["yearly_narrative"].split()),
400 podcaster_handoff.MAX_MONTH_SYNTHESIS_WORDS
401 + podcaster_handoff.MAX_YEARLY_NARRATIVE_WORDS,
402 )
403
404 def test_read_historical_context_extracts_only_year_in_review_section(self) -> None:
405 tests_root = Path(__file__).resolve().parent
406 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
407 base = Path(tmpdir)
408 self._write_historical_context(
409 base,
410 yearly_narrative=(
411 "---\n"
412 "title: 2026 Yearly Narrative\n"
413 "---\n\n"
414 "## Year in Review\n\n"
415 "The year kept compounding around agent packaging.\n\n"
416 "## Methodology\n\n"
417 "This section should NOT appear in the handoff.\n\n"
418 "## Contributors\n\n"
419 "Also should NOT appear.\n"
420 ),
421 )
422
423 context = podcaster_handoff._read_historical_context("2026-W23", base)
424
425 self.assertIsNotNone(context)
426 assert context is not None
427 self.assertIn("yearly_narrative", context)
428 self.assertIn("agent packaging", context["yearly_narrative"])
429 self.assertNotIn("Methodology", context["yearly_narrative"])
430 self.assertNotIn("Contributors", context["yearly_narrative"])
431
432 def test_podcaster_dry_run_sets_payload_flag(self) -> None:
433 payload = podcaster_handoff.build_payload(
434 week="2026-W23",
435 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
436 article_path="content/weekly/2026/W23.md",
437 publish_run_id="123456789",
438 publish_mode="normal",
439 podcaster_dry_run=True,
440 )
441
442 self.assertTrue(payload["dry_run"])
443 self.assertEqual(payload["publish_mode"], "normal")
444
445 def test_build_payload_normalizes_absolute_article_path(self) -> None:
446 payload = podcaster_handoff.build_payload(
447 week="2026-W23",
448 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
449 article_path="/home/runner/work/SquadScope/SquadScope/content/weekly/2026/W23.md",
450 publish_run_id="123456789",
451 publish_mode="normal",
452 )
453
454 self.assertEqual(payload["article_path"], "content/weekly/2026/W23.md")
455
456 def test_missing_config_skips_without_calling_podcaster(self) -> None:
457 with (
458 mock.patch.object(podcaster_handoff.request, "urlopen") as urlopen_mock,
459 mock.patch.dict(podcaster_handoff.os.environ, {"PODCASTER_API_KEY": ""}),
460 mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
461 ):
462 exit_code = podcaster_handoff.main(
463 [
464 "--week",
465 "2026-W23",
466 "--article-url",
467 "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
468 "--article-path",
469 "content/weekly/2026/W23.md",
470 "--publish-run-id",
471 "123456789",
472 "--endpoint",
473 "",
474 ]
475 )
476
477 self.assertEqual(exit_code, 0)
478 urlopen_mock.assert_not_called()
479 self.assertIn("Podcaster handoff skipped", stdout.getvalue())
480
481 def test_main_prints_podcaster_job_id_and_status_notice(self) -> None:
482 response = {
483 "job_id": "podcast-2026-W30-abc12345",
484 "status": "accepted",
485 "errors": [],
486 }
487 payload = {"week": "2026-W30"}
488 with (
489 mock.patch.object(podcaster_handoff, "build_payload", return_value=payload),
490 mock.patch.object(
491 podcaster_handoff, "post_handoff", return_value=response
492 ) as post_handoff_mock,
493 mock.patch.dict(
494 podcaster_handoff.os.environ, {"PODCASTER_API_KEY": "super-secret-value"}
495 ),
496 mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
497 ):
498 exit_code = podcaster_handoff.main(
499 [
500 "--week",
501 "2026-W30",
502 "--article-url",
503 "https://jmservera.github.io/SquadScope/weekly/2026/w30/",
504 "--article-path",
505 "content/weekly/2026/W30.md",
506 "--publish-run-id",
507 "123456789",
508 "--endpoint",
509 "http://localhost:7071/api/generate",
510 ]
511 )
512
513 self.assertEqual(exit_code, 0)
514 post_handoff_mock.assert_called_once_with(
515 "http://localhost:7071/api/generate",
516 "super-secret-value",
517 payload,
518 timeout=podcaster_handoff.DEFAULT_TIMEOUT_SECONDS,
519 )
520 notice = stdout.getvalue()
521 self.assertIn("job_id=podcast-2026-W30-abc12345", notice)
522 self.assertIn("status=accepted", notice)
523
524 def test_post_handoff_sends_auth_header_without_logging_value(self) -> None:
525 response = _FakeHTTPResponse(
526 json.dumps(
527 {"job_id": "podcast-2026-W23-abc12345", "status": "accepted", "errors": []}
528 ).encode()
529 )
530 with (
531 mock.patch.object(
532 podcaster_handoff.request, "urlopen", return_value=response
533 ) as urlopen_mock,
534 mock.patch.dict(
535 podcaster_handoff.os.environ, {"PODCASTER_API_KEY": "super-secret-value"}
536 ),
537 mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
538 ):
539 exit_code = podcaster_handoff.main(
540 [
541 "--week",
542 "2026-W23",
543 "--article-url",
544 "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
545 "--article-path",
546 "content/weekly/2026/W23.md",
547 "--publish-run-id",
548 "123456789",
549 "--endpoint",
550 "http://localhost:7071/api/generate",
551 ]
552 )
553
554 self.assertEqual(exit_code, 0)
555 req = urlopen_mock.call_args.args[0]
556 self.assertEqual(req.get_header("X-podcaster-api-key"), "super-secret-value")
557 self.assertEqual(req.get_header("Content-type"), "application/json")
558 sent_payload = json.loads(req.data.decode("utf-8"))
559 self.assertEqual(sent_payload["week"], "2026-W23")
560 self.assertEqual(
561 sent_payload["article_url"], "https://jmservera.github.io/SquadScope/weekly/2026/w23/"
562 )
563 self.assertEqual(sent_payload["article_path"], "content/weekly/2026/W23.md")
564 self.assertEqual(sent_payload["publish_run_id"], "123456789")
565 self.assertEqual(sent_payload["publish_mode"], "normal")
566 self.assertIn("podcast_config", sent_payload)
567 self.assertEqual(sent_payload["podcast_config"]["name"], "Claracle")
568 self.assertIn("script_directions", sent_payload)
569 self.assertIn("music_mix", sent_payload["script_directions"])
570 self.assertIn("spotify_publish", sent_payload)
571 self.assertEqual(sent_payload["spotify_publish"]["publish_mode"], "draft")
572 self.assertEqual(sent_payload["spotify_publish"]["upload_format"], "wav")
573 self.assertIsInstance(sent_payload["spotify_publish"]["season_number"], int)
574 self.assertIsInstance(sent_payload["spotify_publish"]["episode_number"], int)
575 self.assertNotIn("super-secret-value", stdout.getvalue())
576
577 def test_non_normal_publish_mode_skips_without_calling_podcaster(self) -> None:
578 with (
579 mock.patch.object(podcaster_handoff.request, "urlopen") as urlopen_mock,
580 mock.patch.dict(
581 podcaster_handoff.os.environ, {"PODCASTER_API_KEY": "super-secret-value"}
582 ),
583 mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
584 ):
585 exit_code = podcaster_handoff.main(
586 [
587 "--week",
588 "2026-W23",
589 "--article-url",
590 "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
591 "--article-path",
592 "content/weekly/2026/W23.md",
593 "--publish-run-id",
594 "123456789",
595 "--publish-mode",
596 "restore",
597 "--endpoint",
598 "http://localhost:7071/api/generate",
599 ]
600 )
601
602 self.assertEqual(exit_code, 0)
603 urlopen_mock.assert_not_called()
604 self.assertIn("skipped for publish mode restore", stdout.getvalue())
605 self.assertNotIn("super-secret-value", stdout.getvalue())
606
607 def test_manifest_blocks_restore_and_no_ai_handoffs(self) -> None:
608 tests_root = Path(__file__).resolve().parent
609 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
610 base = Path(tmpdir)
611 restore_manifest = self._write_manifest(base, run_mode="restore")
612 with self.assertRaisesRegex(podcaster_handoff.PodcasterHandoffError, "not eligible"):
613 podcaster_handoff.build_payload(
614 week="2026-W23",
615 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
616 article_path="content/weekly/2026/W23.md",
617 publish_run_id="123456789",
618 publish_mode="normal",
619 manifest_path=restore_manifest,
620 )
621 no_ai_manifest = self._write_manifest(base, ai_status="no-ai")
622 with self.assertRaisesRegex(podcaster_handoff.PodcasterHandoffError, "not eligible"):
623 podcaster_handoff.build_payload(
624 week="2026-W23",
625 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
626 article_path="content/weekly/2026/W23.md",
627 publish_run_id="123456789",
628 publish_mode="normal",
629 manifest_path=no_ai_manifest,
630 )
631
632 def test_plain_restore_replay_skips_without_calling_podcaster(self) -> None:
633 tests_root = Path(__file__).resolve().parent
634 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
635 manifest = self._write_manifest(Path(tmpdir), run_mode="restore")
636 with (
637 mock.patch.object(podcaster_handoff.request, "urlopen") as urlopen_mock,
638 mock.patch.dict(
639 podcaster_handoff.os.environ, {"PODCASTER_API_KEY": "super-secret-value"}
640 ),
641 mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
642 ):
643 exit_code = podcaster_handoff.main(
644 [
645 "--week",
646 "2026-W23",
647 "--article-url",
648 "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
649 "--article-path",
650 "content/weekly/2026/W23.md",
651 "--publish-run-id",
652 "123456789",
653 "--publish-mode",
654 "normal",
655 "--manifest",
656 str(manifest),
657 "--endpoint",
658 "http://localhost:7071/api/generate",
659 ]
660 )
661
662 self.assertEqual(exit_code, 0)
663 urlopen_mock.assert_not_called()
664 self.assertIn("skipped", stdout.getvalue())
665 self.assertIn("non-audited replay", stdout.getvalue())
666
667 def test_audited_force_replace_restore_is_handoff_eligible(self) -> None:
668 tests_root = Path(__file__).resolve().parent
669 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
670 manifest = self._write_manifest(
671 Path(tmpdir),
672 run_mode="restore",
673 policy="force-replace",
674 audit={"actor": "jmservera", "reason": "W30 press-inclusion correction"},
675 )
676
677 payload = podcaster_handoff.build_payload(
678 week="2026-W23",
679 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
680 article_path="content/weekly/2026/W23.md",
681 publish_run_id="123456789",
682 publish_mode="normal",
683 manifest_path=manifest,
684 )
685 loaded_manifest = json.loads(manifest.read_text(encoding="utf-8"))
686
687 self.assertEqual(payload["week"], "2026-W23")
688 self.assertFalse(podcaster_handoff._is_gated_replay(loaded_manifest, week="2026-W23"))
689
690 def test_normal_publish_manifest_is_handoff_eligible(self) -> None:
691 tests_root = Path(__file__).resolve().parent
692 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
693 manifest = self._write_manifest(Path(tmpdir), run_mode="normal")
694
695 payload = podcaster_handoff.build_payload(
696 week="2026-W23",
697 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
698 article_path="content/weekly/2026/W23.md",
699 publish_run_id="123456789",
700 publish_mode="normal",
701 manifest_path=manifest,
702 )
703 loaded_manifest = json.loads(manifest.read_text(encoding="utf-8"))
704
705 self.assertEqual(payload["week"], "2026-W23")
706 self.assertFalse(podcaster_handoff._is_gated_replay(loaded_manifest, week="2026-W23"))
707
708 def test_manifest_allows_audited_force_replace_but_not_plain_restore(self) -> None:
709 tests_root = Path(__file__).resolve().parent
710 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
711 base = Path(tmpdir)
712 audited_manifest = self._write_manifest(
713 base,
714 run_mode="restore",
715 policy="force-replace",
716 audit={"actor": "jmservera", "reason": "W30 press-inclusion correction"},
717 )
718 audited = json.loads(audited_manifest.read_text(encoding="utf-8"))
719 plain_restore_manifest = self._write_manifest(base, run_mode="restore")
720 plain_restore = json.loads(plain_restore_manifest.read_text(encoding="utf-8"))
721
722 self.assertTrue(
723 podcaster_handoff._manifest_allows_handoff(
724 audited, week="2026-W23", publish_mode="normal"
725 )
726 )
727 self.assertFalse(
728 podcaster_handoff._manifest_allows_handoff(
729 plain_restore, week="2026-W23", publish_mode="normal"
730 )
731 )
732
733 def test_non_restore_modes_are_not_gated_replays(self) -> None:
734 # A gated replay is specifically a plain (non-audited) restore. Any other
735 # non-normal or missing run_mode must NOT be treated as a clean skip -- it
736 # stays fail-closed via build_payload -- so a broken manifest is never
737 # silently skipped (regression for jmservera/SquadScope#587 review).
738 tests_root = Path(__file__).resolve().parent
739 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
740 base = Path(tmpdir)
741 missing_mode = self._write_manifest(base, run_mode="normal")
742 missing = json.loads(missing_mode.read_text(encoding="utf-8"))
743 del missing["run_mode"]
744 candidate_only = self._write_manifest(base, run_mode="candidate-only")
745 candidate = json.loads(candidate_only.read_text(encoding="utf-8"))
746
747 self.assertFalse(podcaster_handoff._is_gated_replay(missing, week="2026-W23"))
748 self.assertFalse(podcaster_handoff._is_gated_replay(candidate, week="2026-W23"))
749
750 def test_build_payload_uses_preloaded_manifest_without_reloading(self) -> None:
751 # main() loads the manifest once for _is_gated_replay and passes it into
752 # build_payload, which must reuse it rather than re-reading the file.
753 tests_root = Path(__file__).resolve().parent
754 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
755 manifest_path = self._write_manifest(Path(tmpdir), run_mode="normal")
756 preloaded = json.loads(manifest_path.read_text(encoding="utf-8"))
757 with mock.patch.object(
758 podcaster_handoff, "_load_manifest", side_effect=AssertionError("reloaded")
759 ):
760 payload = podcaster_handoff.build_payload(
761 week="2026-W23",
762 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
763 article_path="content/weekly/2026/W23.md",
764 publish_run_id="123456789",
765 publish_mode="normal",
766 manifest=preloaded,
767 )
768 self.assertEqual(payload["week"], "2026-W23")
769
770 def test_missing_manifest_path_raises_fail_closed(self) -> None:
771 tests_root = Path(__file__).resolve().parent
772 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
773 missing = Path(tmpdir) / "does-not-exist.json"
774 with self.assertRaisesRegex(podcaster_handoff.PodcasterHandoffError, "does not exist"):
775 podcaster_handoff.build_payload(
776 week="2026-W23",
777 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
778 article_path="content/weekly/2026/W23.md",
779 publish_run_id="123456789",
780 publish_mode="normal",
781 manifest_path=missing,
782 )
783
784 def test_validate_response_rejects_failed_status_or_errors(self) -> None:
785 with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
786 podcaster_handoff.validate_response(
787 {"job_id": "podcast-1", "status": "failed", "errors": []}
788 )
789 with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
790 podcaster_handoff.validate_response(
791 {"job_id": "podcast-1", "status": "accepted", "errors": ["bad"]}
792 )
793
794 def test_validate_response_rejects_unexpected_or_missing_status(self) -> None:
795 for status in ("rejected", "queued", "pending", None):
796 with self.assertRaisesRegex(
797 podcaster_handoff.PodcasterHandoffError, "known success status"
798 ):
799 podcaster_handoff.validate_response(
800 {"job_id": "podcast-1", "status": status, "errors": []}
801 )
802 with self.assertRaisesRegex(
803 podcaster_handoff.PodcasterHandoffError, "known success status"
804 ):
805 podcaster_handoff.validate_response({"job_id": "podcast-1", "errors": []})
806
807 def test_validate_response_accepts_known_success_status(self) -> None:
808 result = podcaster_handoff.validate_response(
809 {"job_id": "podcast-1", "status": "accepted", "errors": []}
810 )
811 self.assertEqual(result["status"], "accepted")
812
813 def test_validate_response_accepts_dry_run_status(self) -> None:
814 result = podcaster_handoff.validate_response(
815 {"job_id": "podcast-1", "status": "dry_run", "errors": []}
816 )
817 self.assertEqual(result["status"], "dry_run")
818
819 def test_non_2xx_response_fails_handoff(self) -> None:
820 http_err = error.HTTPError(
821 url="http://localhost:7071/api/generate",
822 code=500,
823 msg="Internal Server Error",
824 hdrs={},
825 fp=io.BytesIO(b'{"errors":["boom"]}'),
826 )
827 with mock.patch.object(podcaster_handoff.request, "urlopen", side_effect=http_err):
828 with self.assertRaisesRegex(podcaster_handoff.PodcasterHandoffError, "HTTP 500"):
829 podcaster_handoff.post_handoff(
830 "http://localhost:7071/api/generate",
831 "super-secret-value",
832 {
833 "week": "2026-W23",
834 "article_url": "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
835 "article_path": "content/weekly/2026/W23.md",
836 "publish_run_id": "123456789",
837 "publish_mode": "normal",
838 },
839 )
840
841 def test_error_body_included_and_sanitized_in_exception(self) -> None:
842 """Error body is truncated to 1024 bytes and sanitized (no newlines, no ::)."""
843 # Body with newlines, workflow-command injection, and > 1024 bytes
844 dangerous_body = b"line1\n::warning::injected\r\n" + b"A" * 1100
845 http_err = error.HTTPError(
846 url="http://localhost:7071/api/generate",
847 code=502,
848 msg="Bad Gateway",
849 hdrs={},
850 fp=io.BytesIO(dangerous_body),
851 )
852 with mock.patch.object(podcaster_handoff.request, "urlopen", side_effect=http_err):
853 with self.assertRaises(podcaster_handoff.PodcasterHandoffError) as ctx:
854 podcaster_handoff.post_handoff(
855 "http://localhost:7071/api/generate",
856 "super-secret-value",
857 {
858 "week": "2026-W23",
859 "article_url": "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
860 "article_path": "content/weekly/2026/W23.md",
861 "publish_run_id": "123456789",
862 "publish_mode": "normal",
863 },
864 )
865 msg = str(ctx.exception)
866 # Body IS included
867 self.assertIn("Response body:", msg)
868 self.assertIn("line1", msg)
869 # Truncated: 1024 bytes read max, so not all 1100 'A's appear
870 self.assertLessEqual(len(msg), 1200)
871 # Sanitized: no newlines or :: sequences
872 body_part = msg.split("Response body: ", 1)[1]
873 self.assertNotIn("\n", body_part)
874 self.assertNotIn("\r", body_part)
875 self.assertNotIn("::", body_part)
876
877 def test_article_url_from_page_path_matches_hugo_weekly_permalink(self) -> None:
878 self.assertEqual(
879 podcaster_handoff.article_url_from_page_path(
880 "https://jmservera.github.io/SquadScope/",
881 "content/weekly/2026/W23.md",
882 ),
883 "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
884 )
885
886 def test_article_url_from_page_path_normalizes_absolute_runner_path(self) -> None:
887 self.assertEqual(
888 podcaster_handoff.article_url_from_page_path(
889 "https://jmservera.github.io/SquadScope/",
890 "/home/runner/work/SquadScope/SquadScope/content/weekly/2026/W23.md",
891 ),
892 "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
893 )
894
895 def test_build_payload_includes_article_content_from_file(self) -> None:
896 with tempfile.TemporaryDirectory() as tmpdir:
897 base = Path(tmpdir)
898 article_dir = base / "content" / "weekly" / "2026"
899 article_dir.mkdir(parents=True)
900 article = article_dir / "W24.md"
901 article.write_text(
902 "---\ntitle: My Title\nsummary: My summary.\n---\n# Heading\nHello world.\n",
903 encoding="utf-8",
904 )
905
906 payload = podcaster_handoff.build_payload(
907 week="2026-W24",
908 article_url="https://example.com/weekly/2026/w24/",
909 article_path="content/weekly/2026/W24.md",
910 publish_run_id="999",
911 publish_mode="normal",
912 podcaster_dry_run=True,
913 repo_root=base,
914 )
915
916 self.assertEqual(payload["article_title"], "My Title")
917 self.assertEqual(payload["article_summary"], "My summary.")
918 self.assertIn("Hello world.", payload["article_content"])
919 self.assertIn("---\ntitle: My Title\nsummary: My summary.\n---", payload["article_content"])
920
921 def test_build_payload_resolves_spotify_publish_templates(self) -> None:
922 with tempfile.TemporaryDirectory() as tmpdir:
923 base = Path(tmpdir)
924 article_dir = base / "content" / "weekly" / "2026"
925 article_dir.mkdir(parents=True)
926 article = article_dir / "W24.md"
927 article.write_text(
928 "---\ntitle: Skills Go Vertical\nsummary: This week we explore agent skills.\n---\n# Heading\nHello world.\n",
929 encoding="utf-8",
930 )
931
932 payload = podcaster_handoff.build_payload(
933 week="2026-W24",
934 article_url="https://example.com/weekly/2026/w24/",
935 article_path="content/weekly/2026/W24.md",
936 publish_run_id="999",
937 publish_mode="normal",
938 podcaster_dry_run=True,
939 repo_root=base,
940 )
941
942 self.assertEqual(
943 payload["spotify_publish"]["title"],
944 "Why Skills Go Vertical Matters for AI, GitHub & Developer Trends | W24",
945 )
946 self.assertIn(
947 "This week we explore agent skills.", payload["spotify_publish"]["description"]
948 )
949 self.assertEqual(payload["spotify_publish"]["season_number"], 2026)
950 self.assertEqual(payload["spotify_publish"]["episode_number"], 24)
951
952 def test_build_payload_truncates_resolved_spotify_publish_fields(self) -> None:
953 with tempfile.TemporaryDirectory() as tmpdir:
954 base = Path(tmpdir)
955 article_dir = base / "content" / "weekly" / "2026"
956 article_dir.mkdir(parents=True)
957 article = article_dir / "W24.md"
958 article.write_text(
959 f"---\ntitle: {'T' * 250}\nsummary: {'S' * 5000}\n---\n# Heading\nHello world.\n",
960 encoding="utf-8",
961 )
962
963 payload = podcaster_handoff.build_payload(
964 week="2026-W24",
965 article_url="https://example.com/weekly/2026/w24/",
966 article_path="content/weekly/2026/W24.md",
967 publish_run_id="999",
968 publish_mode="normal",
969 podcaster_dry_run=True,
970 repo_root=base,
971 )
972
973 self.assertLessEqual(len(payload["spotify_publish"]["title"]), 200)
974 self.assertTrue(payload["spotify_publish"]["title"].startswith("Why "))
975 desc = payload["spotify_publish"]["description"]
976 self.assertLessEqual(len(desc), 4000)
977 self.assertTrue(desc.startswith("<p>"))
978 parser = _BalancedHtmlParser()
979 parser.feed(desc)
980 parser.close()
981 self.assertEqual(parser.errors, [])
982 self.assertEqual(parser.stack, [])
983
984 def test_truncate_html_drops_partial_trailing_tag(self) -> None:
985 html = "<p>" + ("x" * 3988) + '<a href="https://example.com/really/long/link">link</a></p>'
986 truncated = podcaster_handoff.truncate_html(
987 html, podcaster_handoff.MAX_SPOTIFY_DESCRIPTION_CHARS
988 )
989
990 parser = _BalancedHtmlParser()
991 parser.feed(truncated)
992 parser.close()
993
994 self.assertLessEqual(len(truncated), podcaster_handoff.MAX_SPOTIFY_DESCRIPTION_CHARS)
995 self.assertTrue(truncated.endswith("</p>"))
996 self.assertNotIn("<a href", truncated)
997 self.assertEqual(parser.errors, [])
998 self.assertEqual(parser.stack, [])
999
1000 def test_truncate_html_entity_ref_atomic(self) -> None:
1001 self.assertEqual(podcaster_handoff.truncate_html("<p>&amp;z</p>", 11), "<p></p>")
1002 self.assertEqual(podcaster_handoff.truncate_html("<p>&amp;z</p>", 12), "<p>&amp;</p>")
1003
1004 def test_truncate_html_charref_atomic(self) -> None:
1005 self.assertEqual(podcaster_handoff.truncate_html("<p>&#169;z</p>", 12), "<p></p>")
1006 self.assertEqual(podcaster_handoff.truncate_html("<p>&#169;z</p>", 13), "<p>&#169;</p>")
1007
1008 def test_truncate_html_comment_atomic(self) -> None:
1009 self.assertEqual(podcaster_handoff.truncate_html("<p><!--ok-->z</p>", 15), "<p></p>")
1010 self.assertEqual(
1011 podcaster_handoff.truncate_html("<p><!--ok-->z</p>", 16), "<p><!--ok--></p>"
1012 )
1013
1014 def test_render_template_value_raises_on_malformed_format_string(self) -> None:
1015 context = {"year": 2026, "week": 24}
1016 with self.assertRaises(podcaster_handoff.PodcasterHandoffError) as cm:
1017 podcaster_handoff._render_template_value("{year}-W{week}: {unclosed", context)
1018 self.assertIn("invalid format syntax", str(cm.exception))
1019
1020 def test_build_payload_extracts_title_from_heading_when_no_frontmatter(self) -> None:
1021 with tempfile.TemporaryDirectory() as tmpdir:
1022 base = Path(tmpdir)
1023 article_dir = base / "content" / "weekly" / "2026"
1024 article_dir.mkdir(parents=True)
1025 article = article_dir / "W24.md"
1026 article.write_text("# My Heading Title\nSome content.\n", encoding="utf-8")
1027
1028 payload = podcaster_handoff.build_payload(
1029 week="2026-W24",
1030 article_url="https://example.com/weekly/2026/w24/",
1031 article_path="content/weekly/2026/W24.md",
1032 publish_run_id="999",
1033 publish_mode="normal",
1034 podcaster_dry_run=True,
1035 repo_root=base,
1036 )
1037
1038 self.assertEqual(payload["article_title"], "My Heading Title")
1039
1040 def test_build_payload_truncates_large_article_content(self) -> None:
1041 with tempfile.TemporaryDirectory() as tmpdir:
1042 base = Path(tmpdir)
1043 article_dir = base / "content" / "weekly" / "2026"
1044 article_dir.mkdir(parents=True)
1045 article = article_dir / "W24.md"
1046 large_content = "# Title\n" + "x" * 60_000
1047 article.write_text(large_content, encoding="utf-8")
1048
1049 payload = podcaster_handoff.build_payload(
1050 week="2026-W24",
1051 article_url="https://example.com/weekly/2026/w24/",
1052 article_path="content/weekly/2026/W24.md",
1053 publish_run_id="999",
1054 publish_mode="normal",
1055 podcaster_dry_run=True,
1056 repo_root=base,
1057 )
1058
1059 self.assertEqual(len(payload["article_content"]), 50_000)
1060 self.assertEqual(payload["article_title"], "Title")
1061
1062 def test_build_payload_missing_article_file_omits_content(self) -> None:
1063 with tempfile.TemporaryDirectory() as tmpdir:
1064 base = Path(tmpdir)
1065 payload = podcaster_handoff.build_payload(
1066 week="2026-W24",
1067 article_url="https://example.com/weekly/2026/w24/",
1068 article_path="content/weekly/2026/W24.md",
1069 publish_run_id="999",
1070 publish_mode="normal",
1071 podcaster_dry_run=True,
1072 repo_root=base,
1073 )
1074
1075 self.assertNotIn("article_content", payload)
1076 self.assertNotIn("article_title", payload)
1077
1078 def test_read_article_content_path_traversal_raises(self) -> None:
1079 """Path traversal attempts must raise PodcasterHandoffError."""
1080 with tempfile.TemporaryDirectory() as tmpdir:
1081 base = Path(tmpdir)
1082 # Create a file outside repo_root
1083 outside = base.parent / "secret.txt"
1084 outside.write_text("secret data", encoding="utf-8")
1085 try:
1086 with self.assertRaises(podcaster_handoff.PodcasterHandoffError) as ctx:
1087 podcaster_handoff._read_article_content("../secret.txt", repo_root=base)
1088 self.assertIn("outside the repository root", str(ctx.exception))
1089 finally:
1090 outside.unlink(missing_ok=True)
1091
1092 def test_read_article_content_unreadable_file_raises(self) -> None:
1093 """An existing but unreadable file must raise, not silently omit content."""
1094 with tempfile.TemporaryDirectory() as tmpdir:
1095 base = Path(tmpdir)
1096 article_dir = base / "content" / "weekly"
1097 article_dir.mkdir(parents=True)
1098 article_file = article_dir / "W24.md"
1099 article_file.write_text("# Test", encoding="utf-8")
1100 # Make file unreadable
1101 article_file.chmod(0o000)
1102 try:
1103 with self.assertRaises(podcaster_handoff.PodcasterHandoffError) as ctx:
1104 podcaster_handoff._read_article_content("content/weekly/W24.md", repo_root=base)
1105 self.assertIn("could not be read", str(ctx.exception))
1106 finally:
1107 article_file.chmod(0o644)
1108
1109 def test_build_payload_omits_breaking_news_by_default(self) -> None:
1110 """breaking_news must not appear in the payload when not provided."""
1111 payload = podcaster_handoff.build_payload(
1112 week="2026-W23",
1113 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
1114 article_path="content/weekly/2026/W23.md",
1115 publish_run_id="123456789",
1116 publish_mode="normal",
1117 )
1118 self.assertNotIn("breaking_news", payload)
1119
1120 def test_build_payload_includes_breaking_news_when_provided(self) -> None:
1121 """breaking_news must be included in the payload when a value is given."""
1122 payload = podcaster_handoff.build_payload(
1123 week="2026-W23",
1124 article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
1125 article_path="content/weekly/2026/W23.md",
1126 publish_run_id="123456789",
1127 publish_mode="normal",
1128 breaking_news="Major outage at GitHub Actions today",
1129 )
1130 self.assertEqual(payload["breaking_news"], "Major outage at GitHub Actions today")
1131
1132 def test_verify_article_merged_passes_on_match(self) -> None:
1133 import hashlib
1134
1135 with tempfile.TemporaryDirectory() as tmp:
1136 root = Path(tmp)
1137 article = root / "content" / "weekly" / "2026" / "W27.md"
1138 article.parent.mkdir(parents=True, exist_ok=True)
1139 article.write_text("# W27\nbody", encoding="utf-8")
1140 sha = hashlib.sha256(article.read_bytes()).hexdigest()
1141 manifest = {"candidate": {"content_sha256": sha}}
1142 self.assertEqual(
1143 podcaster_handoff.verify_article_merged(
1144 "content/weekly/2026/W27.md", manifest, repo_root=root
1145 ),
1146 sha,
1147 )
1148
1149 def test_verify_article_merged_raises_when_missing(self) -> None:
1150 with tempfile.TemporaryDirectory() as tmp:
1151 manifest = {"candidate": {"content_sha256": "a" * 64}}
1152 with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
1153 podcaster_handoff.verify_article_merged(
1154 "content/weekly/2026/W27.md", manifest, repo_root=Path(tmp)
1155 )
1156
1157 def test_verify_article_merged_raises_on_sha_mismatch(self) -> None:
1158 with tempfile.TemporaryDirectory() as tmp:
1159 root = Path(tmp)
1160 article = root / "content" / "weekly" / "2026" / "W27.md"
1161 article.parent.mkdir(parents=True, exist_ok=True)
1162 article.write_text("# W27\nbody", encoding="utf-8")
1163 manifest = {"candidate": {"content_sha256": "f" * 64}}
1164 with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
1165 podcaster_handoff.verify_article_merged(
1166 "content/weekly/2026/W27.md", manifest, repo_root=root
1167 )
1168
1169 def test_verify_article_merged_rejects_non_hex_sha256(self) -> None:
1170 with tempfile.TemporaryDirectory() as tmp:
1171 root = Path(tmp)
1172 article = root / "content" / "weekly" / "2026" / "W27.md"
1173 article.parent.mkdir(parents=True, exist_ok=True)
1174 article.write_text("# W27\nbody", encoding="utf-8")
1175 manifest = {"candidate": {"content_sha256": "g" * 64}}
1176 with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
1177 podcaster_handoff.verify_article_merged(
1178 "content/weekly/2026/W27.md", manifest, repo_root=root
1179 )
1180
1181 def test_require_merged_fails_closed_for_unmerged_article(self) -> None:
1182 with tempfile.TemporaryDirectory() as tmp:
1183 base = Path(tmp)
1184 self._write_manifest(base)
1185 manifest = base / "publish-manifest.json"
1186 with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
1187 podcaster_handoff.build_payload(
1188 week="2026-W23",
1189 article_url="https://claracle.com/weekly/2026/w23/",
1190 article_path="content/weekly/2026/W23.md",
1191 publish_run_id="123456789",
1192 publish_mode="normal",
1193 manifest_path=manifest,
1194 repo_root=base,
1195 require_merged=True,
1196 )
1197
1198
1199 if __name__ == "__main__":
1200 unittest.main()