"""Publication fault injection without inference or audio processing.""" import json import multiprocessing import os import tempfile import unittest from pathlib import Path from unittest.mock import patch from src.meeting_lab.models.meeting_context import create_meeting_context, load_meeting_context from src.meeting_lab.protocol import history from src.meeting_lab.orchestration import mvp from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult from tests import test_mvp_api as fixtures def result(text): return DirectProtocolResult( protocol_text=text, exact_prompt="prompt " + text, transcript_input="Lumini original", model_metadata={"name": "test"}, runtime_metadata={ "glossary_aliases_configured": {"Lumini": "Luminy"}, "glossary_replacements": [], "num_thread": 10, }, raw_response={"response": text}, ) def interrupt_publication(root): original = history._replace_link def interrupted(path, target): if path.name == "current": os._exit(77) original(path, target) history._replace_link = interrupted history.persist_protocol_generation( Path(root), result("new"), context=create_meeting_context(fixtures.context_data()) ) class HistoryTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.root = Path(self.temporary.name) self.context = create_meeting_context(fixtures.context_data()) def persist(self, text): return history.persist_protocol_generation(self.root, result(text), context=self.context) def snapshot(self): names = [ "protocol.md", "context/meeting_context.yaml", *["protocol/" + n for n in history._DIAGNOSTICS], ] return {name: (self.root / name).read_bytes() for name in names} def test_success_numbering_provenance_and_immutable_context(self): self.persist("first") first = self.snapshot() self.persist("second") self.assertEqual((self.root / "protocol.md").read_text(), "second") old = self.root / "protocol/generations/001" self.assertEqual((old / "protocol.md").read_bytes(), first["protocol.md"]) self.assertEqual( (old / "meeting_context.yaml").read_bytes(), first["context/meeting_context.yaml"] ) current = (self.root / "protocol/current").resolve() self.assertEqual(current.name, "002") metadata = json.loads((current / "runtime_metadata.json").read_text()) self.assertEqual(metadata["generation_index"], 2) self.assertEqual(metadata["speaker_mapping"], self.context.speaker_mappings) self.assertEqual(metadata["glossary_aliases_configured"], {"Lumini": "Luminy"}) self.assertEqual(metadata["glossary_replacements"], []) self.assertEqual(metadata["num_thread"], 10) self.assertEqual(metadata["source_run_id"], self.root.name) self.assertEqual( json.loads((current / "model_metadata.json").read_text()), {"name": "test"} ) self.assertIn("sha", metadata["git"]["meeting_lab"]) self.assertEqual( load_meeting_context(current / "meeting_context.yaml").data, self.context.data ) for name in history._DIAGNOSTICS: self.assertEqual((self.root / "protocol" / name).resolve(), current / name) def test_failure_writing_record_preserves_previous(self): self.persist("first") before = self.snapshot() with patch.object(history, "_write_json", side_effect=OSError("disk full")): with self.assertRaises(OSError): self.persist("second") self.assertEqual(self.snapshot(), before) self.persist("third") self.assertEqual((self.root / "protocol/current").resolve().name, "002") def test_failure_at_atomic_publication_preserves_all_stable_paths(self): self.persist("first") before = self.snapshot() original = history.os.replace def fail_current(source, destination): if Path(destination).name == "current": raise OSError("publication stopped") original(source, destination) with patch.object(history.os, "replace", side_effect=fail_current): with self.assertRaises(OSError): self.persist("second") self.assertEqual(self.snapshot(), before) self.assertFalse((self.root / "protocol/generations/002").exists()) def test_process_interruption_before_pointer_swap_preserves_current(self): self.persist("first") before = self.snapshot() worker = multiprocessing.get_context("fork").Process( target=interrupt_publication, args=(str(self.root),) ) worker.start() worker.join(timeout=10) self.assertFalse(worker.is_alive()) self.assertEqual(worker.exitcode, 77) self.assertEqual(self.snapshot(), before) self.persist("third") self.assertEqual((self.root / "protocol.md").read_text(), "third") def test_legacy_migration_and_failure_during_link_conversion(self): (self.root / "protocol").mkdir() (self.root / "context").mkdir() (self.root / "protocol.md").write_text("legacy") for name in history._DIAGNOSTICS: (self.root / "protocol" / name).write_text("legacy " + name) (self.root / "context/meeting_context.yaml").write_text("legacy context") before = self.snapshot() original = history._replace_link def fail_conversion(path, target): if path.name == "raw_response.json": raise OSError("link conversion stopped") original(path, target) with patch.object(history, "_replace_link", side_effect=fail_conversion): with self.assertRaises(OSError): self.persist("new") self.assertEqual(self.snapshot(), before) self.persist("new") self.assertEqual((self.root / "protocol/generations/001/protocol.md").read_text(), "legacy") self.assertEqual((self.root / "protocol/current").resolve().name, "002") def test_failed_model_does_not_change_context_or_latest_generation(self): self.persist("first") before = self.snapshot() (self.root / "transcript").mkdir() (self.root / "transcript/transcript.json").write_text('{"text":"original"}') different = fixtures.context_data() different["speaker_mappings"] = {} with patch.object( mvp, "generate_direct_protocol", side_effect=RuntimeError("model stopped") ): with self.assertRaises(RuntimeError): mvp.regenerate_mvp_protocol(self.root, meeting_context=different) self.assertEqual(self.snapshot(), before)