"""Unit-Tests Content-Manifest (PLAN.md §6.4, §10.1, §29.1). Manifest-Chunks/-Hashes, Diff für Sync, Verifikation gegen Fälschung und Rundreise – Grundlage für Preflight und resumierbare Übertragung. """ from __future__ import annotations import json from pathlib import Path import pytest from hms_content_sync import CHUNK_SIZE, ContentManifest, hash_file @pytest.fixture() def content_tree(tmp_path: Path) -> Path: root = tmp_path / "content" (root / "clips").mkdir(parents=True) (root / "clips" / "intro.mp4").write_bytes(b"A" * 1000) (root / "clips" / "loop.mp4").write_bytes(b"B" * 500) (root / "presets").mkdir() (root / "presets" / "look.json").write_text("{}", encoding="utf-8") return root def test_hash_file_chunks_match_content(tmp_path: Path) -> None: f = tmp_path / "f.bin" f.write_bytes(b"x" * (CHUNK_SIZE + 100)) # > 1 Chunk file_hash, chunks = hash_file(f) assert len(chunks) == 2 # voller + Rest-Chunk assert len(file_hash) == 64 # SHA-256 Hex def test_hash_file_empty_file_yields_empty_chunk(tmp_path: Path) -> None: f = tmp_path / "empty.bin" f.write_bytes(b"") file_hash, chunks = hash_file(f) assert len(chunks) == 1 assert file_hash # auch leere Dateien haben einen definierten Hash def test_manifest_build_covers_all_files(content_tree: Path) -> None: manifest = ContentManifest.build(content_tree, revision=7) assert len(manifest) == 3 assert manifest.revision == 7 assert "clips/intro.mp4" in manifest.entries assert "presets/look.json" in manifest.entries entry = manifest.entries["clips/intro.mp4"] assert entry.size_bytes == 1000 assert len(entry.sha256) == 64 assert entry.kind == "media" # Default-Art def test_manifest_kind_classification(content_tree: Path) -> None: manifest = ContentManifest.build( content_tree, kind_of=lambda rel: "plugin" if rel.endswith(".json") else "media", ) assert manifest.entries["presets/look.json"].kind == "plugin" assert manifest.entries["clips/intro.mp4"].kind == "media" def test_manifest_roundtrip_preserves_entries(content_tree: Path) -> None: manifest = ContentManifest.build(content_tree, revision=3) restored = ContentManifest.from_dict(json.loads(json.dumps(manifest.to_dict()))) assert restored.revision == 3 assert set(restored.entries) == set(manifest.entries) orig = manifest.entries["clips/intro.mp4"] copy = restored.entries["clips/intro.mp4"] assert copy.sha256 == orig.sha256 assert copy.chunk_hashes == orig.chunk_hashes def test_manifest_is_deterministic_across_builds(content_tree: Path) -> None: """Gleicher Inhalt → gleiche Einträge (nur manifest_id ist neu).""" a = ContentManifest.build(content_tree) b = ContentManifest.build(content_tree) assert a.to_dict()["entries"] == b.to_dict()["entries"] assert a.manifest_id != b.manifest_id # Instanz-ID ist zeitlich unique def test_verify_detects_tampered_file(content_tree: Path) -> None: manifest = ContentManifest.build(content_tree) assert manifest.verify_file(content_tree, "clips/intro.mp4") (content_tree / "clips" / "intro.mp4").write_bytes(b"TAMPERED" * 50) assert not manifest.verify_file(content_tree, "clips/intro.mp4") # §6.4 Hashprüfung def test_verify_detects_missing_and_unknown(content_tree: Path) -> None: manifest = ContentManifest.build(content_tree) (content_tree / "clips" / "loop.mp4").unlink() assert not manifest.verify_file(content_tree, "clips/loop.mp4") assert not manifest.verify_file(content_tree, "gibts/nicht.mp4") def test_diff_drives_sync_plan(content_tree: Path) -> None: """Diff liefert exakt die Übertragungsliste (§6.4: added/removed/changed).""" old = ContentManifest.build(content_tree, revision=1) # Ändern + Hinzufügen + Entfernen (content_tree / "clips" / "intro.mp4").write_bytes(b"GEAENDERT") (content_tree / "clips" / "neu.mp4").write_bytes(b"N") (content_tree / "clips" / "loop.mp4").unlink() new = ContentManifest.build(content_tree, revision=2) diff = old.diff(new) assert diff.added == frozenset({"clips/neu.mp4"}) assert diff.removed == frozenset({"clips/loop.mp4"}) assert diff.changed == frozenset({"clips/intro.mp4"}) assert not diff.empty # identische Manifeste: leerer Diff same = old.diff(ContentManifest.from_dict(old.to_dict())) assert same.empty def test_build_rejects_missing_root(tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): ContentManifest.build(tmp_path / "gibts-nicht")