from __future__ import annotations import json from pathlib import Path import subprocess import sys import tempfile import unittest from unittest import mock RUNTIME = Path(__file__).resolve().parents[1] / "runtime" sys.path.insert(0, str(RUNTIME)) import fs_transaction # noqa: E402 import layout_check # noqa: E402 import vault_migrate # noqa: E402 class VaultMigrateTest(unittest.TestCase): def setUp(self) -> None: self.tempdir = tempfile.TemporaryDirectory() self.addCleanup(self.tempdir.cleanup) self.root = Path(self.tempdir.name) for path in ( "raw/project-notes", "raw/branch-notes", "harness/source", "harness/adapters", "harness/runtime", "harness/tests", "vault/10-projects", ): (self.root / path).mkdir(parents=True, exist_ok=True) self.project = self.root / "raw/project-notes/sample-project.md" self.branch = self.root / "raw/branch-notes/feature-sample-project-runtime-contract.md" self.project.write_text("---\ntitle: Sample project\n---\n# Sample project\n", encoding="utf-8") self.branch.write_text( "---\ntitle: Sample branch\nproject: sample-project\n---\n# Sample branch\n", encoding="utf-8", ) self.layout = self.root / "harness/source/vault-layout.json" self.layout.write_text( json.dumps({ "schema_version": "vault-layout/v1", "mode": "compatibility", "vault_root": "vault", "canonical_mapping": { "schema_version": "project-first-paths/v1", "project_relation": "branch-to-project", "project_member_pattern": "{vault_root}/{area}/{project}/{category}/{relative_path}", "default_pattern": "{vault_root}/{area}/{category}/{relative_path}", }, "write_roots": { "compatibility": ["raw", "wiki"], "shadow": ["raw", "wiki"], "canonical": ["vault"], }, "migration_manifest": {"schema_version": "vault-migration/v1", "entries": []}, "rollback_mapping": {"schema_version": "vault-rollback/v1", "entries": []}, "areas": {"10-projects": ["raw/project-notes", "raw/branch-notes"]}, }), encoding="utf-8", ) self.relations = self.root / "harness/source/document-relations.json" self.relations.write_text( json.dumps({ "schema_version": "document-relations/v1", "relations": [{ "id": "branch-to-project", "child_roots": ["raw/branch-notes"], "parent_field": "project", "parent_roots": ["raw/project-notes"], "marker": "branches", }], }), encoding="utf-8", ) def _prepare(self, target: str) -> vault_migrate.MigrationPlan: return vault_migrate.prepare( self.root, target, layout_path=Path("harness/source/vault-layout.json"), relations_path=Path("harness/source/document-relations.json"), ) def test_project_first_mapping_is_relation_driven_and_unique(self) -> None: config = json.loads(self.layout.read_text(encoding="utf-8")) relations = json.loads(self.relations.read_text(encoding="utf-8")) mapping = vault_migrate.build_mapping(self.root, config, relations) self.assertEqual( mapping[self.project], self.root / "vault/10-projects/sample-project/project-notes/sample-project.md", ) self.assertEqual( mapping[self.branch], self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md", ) def test_shadow_dry_run_is_read_only_and_atomic_apply_is_converged(self) -> None: original_layout = self.layout.read_bytes() plan = self._prepare("shadow") self.assertRegex(plan.result["plan_sha256"], r"^[0-9a-f]{64}$") self.assertEqual(plan.result["migration_entries"], 2) self.assertEqual(self.layout.read_bytes(), original_layout) self.assertFalse((self.root / "vault/10-projects/sample-project/project-notes/sample-project.md").exists()) vault_migrate.apply(plan) result = layout_check.check_layout(self.root) self.assertEqual(result["status"], "PASS", result["findings"]) self.assertEqual(result["mode"], "shadow") self.assertEqual(result["migration_entries"], 2) def test_shadow_writer_updates_legacy_mirror_and_manifest_hash_together(self) -> None: vault_migrate.apply(self._prepare("shadow")) updated = self.branch.read_bytes() + b"updated\n" changes, authority = vault_migrate.expand_authoritative_changes(self.root, {self.branch: updated}) self.assertEqual(authority["mode"], "shadow") canonical = self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md" self.assertEqual(changes[canonical], updated) fs_transaction.replace_many(changes) self.assertEqual(self.branch.read_bytes(), canonical.read_bytes()) self.assertEqual(layout_check.check_layout(self.root)["status"], "PASS") def test_shadow_refresh_recovers_new_documents_and_mirror_drift(self) -> None: vault_migrate.apply(self._prepare("shadow")) self.branch.write_bytes(self.branch.read_bytes() + b"changed outside harness\n") added = self.root / "raw/branch-notes/feature-new-shadow-document.md" added.write_text( "---\ntitle: New shadow document\nproject: sample-project\n---\n# New\n", encoding="utf-8", ) dirty = layout_check.check_layout(self.root) self.assertEqual(dirty["status"], "FAIL") self.assertEqual( {item["code"] for item in dirty["findings"]}, {"MIGRATION_ENTRY_MISSING", "SHADOW_MIRROR_DRIFT"}, ) refresh = self._prepare("shadow") self.assertEqual(refresh.result["from_mode"], "shadow") self.assertEqual(refresh.result["to_mode"], "shadow") vault_migrate.apply(refresh) result = layout_check.check_layout(self.root) self.assertEqual(result["status"], "PASS", result["findings"]) canonical_added = self.root / "vault/10-projects/sample-project/branch-notes/feature-new-shadow-document.md" self.assertEqual(added.read_bytes(), canonical_added.read_bytes()) def test_canonical_apply_creates_compatibility_symlinks_and_rollback_restores_shadow(self) -> None: vault_migrate.apply(self._prepare("shadow")) canonical_plan = self._prepare("canonical") vault_migrate.apply(canonical_plan) result = layout_check.check_layout(self.root) self.assertEqual(result["status"], "PASS", result["findings"]) self.assertEqual(result["authority"], "vault") self.assertTrue(self.branch.is_symlink()) self.assertEqual( self.branch.resolve(), (self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md").resolve(), ) rollback = self._prepare("shadow") vault_migrate.apply(rollback) self.assertEqual(layout_check.check_layout(self.root)["mode"], "shadow") canonical = self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md" self.assertEqual(self.branch.read_bytes(), canonical.read_bytes()) # 롤백은 legacy 를 독립 *실파일* 미러로 복원해야 한다. 바이트 동일만 확인하면 # 심링크도 통과하므로(그 자체가 SHADOW_MIRROR_SYMLINK 가 잡는 결함), 실파일임을 못박는다. self.assertFalse(self.branch.is_symlink()) def test_canonical_symlink_preserves_escaped_alias_wikilink_without_stale_authority(self) -> None: vault_migrate.apply(self._prepare("shadow")) updated = self.branch.read_text(encoding="utf-8") + ( "[[raw/project-notes/sample-project\\|project]]\n" ) changes, _authority = vault_migrate.expand_authoritative_changes( self.root, {self.branch: updated.encode("utf-8")}, ) fs_transaction.replace_many(changes) vault_migrate.apply(self._prepare("canonical")) canonical = self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md" self.assertIn( "[[raw/project-notes/sample-project\\|project]]", canonical.read_text(encoding="utf-8"), ) self.assertEqual(layout_check.check_layout(self.root)["status"], "PASS") def test_transaction_failure_restores_layout_and_removes_new_mirrors(self) -> None: plan = self._prepare("shadow") original_layout = self.layout.read_bytes() real_replace = fs_transaction.os.replace calls = 0 def fail_second(source, target): nonlocal calls calls += 1 if calls == 2: raise OSError("injected cutover failure") return real_replace(source, target) with mock.patch("fs_transaction.os.replace", side_effect=fail_second): with self.assertRaises(fs_transaction.TransactionError): vault_migrate.apply(plan) self.assertEqual(self.layout.read_bytes(), original_layout) self.assertFalse((self.root / "vault/10-projects/sample-project/project-notes/sample-project.md").exists()) self.assertFalse((self.root / "vault/10-projects/sample-project/branch-notes/feature-sample-project-runtime-contract.md").exists()) def test_plan_hash_and_snapshot_preconditions_block_stale_apply(self) -> None: first = self._prepare("shadow") second = self._prepare("shadow") self.assertEqual(first.result["plan_sha256"], second.result["plan_sha256"]) original_layout = self.layout.read_bytes() self.branch.write_bytes(self.branch.read_bytes() + b"concurrent\n") concurrent = self.branch.read_bytes() with self.assertRaises(vault_migrate.MigrationError) as raised: vault_migrate.apply(first) self.assertEqual(raised.exception.code, "CONCURRENT_MODIFICATION") self.assertEqual(self.branch.read_bytes(), concurrent) self.assertEqual(self.layout.read_bytes(), original_layout) def test_cli_apply_requires_matching_dry_run_plan_hash(self) -> None: command = [ sys.executable, str(RUNTIME / "vault_migrate.py"), "--root", str(self.root), "--to", "shadow", ] rejected = subprocess.run( [*command, "--apply", "--expected-plan-sha256", "0" * 64], check=False, capture_output=True, text=True, ) self.assertEqual(rejected.returncode, 1, rejected.stdout) self.assertEqual(json.loads(rejected.stdout)["errors"][0]["code"], "PLAN_HASH_MISMATCH") self.assertEqual(json.loads(self.layout.read_text(encoding="utf-8"))["mode"], "compatibility") def test_non_markdown_asset_uses_symlink_redirect_and_rolls_back(self) -> None: diagram_root = self.root / "raw/diagrams" diagram_root.mkdir() diagram = diagram_root / "sample.drawio" diagram.write_bytes(b"diagram") config = json.loads(self.layout.read_text(encoding="utf-8")) config["areas"]["10-projects"].append("raw/diagrams") self.layout.write_text(json.dumps(config), encoding="utf-8") vault_migrate.apply(self._prepare("shadow")) vault_migrate.apply(self._prepare("canonical")) canonical = self.root / "vault/10-projects/diagrams/sample.drawio" self.assertTrue(diagram.is_symlink()) self.assertEqual(diagram.resolve(), canonical.resolve()) self.assertEqual(layout_check.check_layout(self.root)["status"], "PASS") authority = layout_check.resolve_authority(self.root) with self.assertRaises(layout_check.LayoutContractError) as raised: layout_check.enforce_write_paths(self.root, [diagram], authority) self.assertEqual(raised.exception.code, "WRITE_ROOT_VIOLATION") vault_migrate.apply(self._prepare("shadow")) self.assertFalse(diagram.is_symlink()) self.assertEqual(diagram.read_bytes(), b"diagram") self.assertEqual(layout_check.check_layout(self.root)["mode"], "shadow") if __name__ == "__main__": unittest.main()