Files
llm-wiki/harness/tests/test_vault_migrate.py
T

268 lines
12 KiB
Python

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()