"""TDD tests for the snapshot entries refactor. Defines the new contract: - Snapshot file trees are stored as normalized rows in ``musehub_snapshot_entries`` (snapshot_id, path, object_id, size_bytes) rather than as a JSON blob in ``musehub_snapshots.manifest``. - A utility ``get_snapshot_manifest`` reconstructs {path: object_id} from those rows for callers that need the dict form. - A utility ``upsert_snapshot_entries`` is the canonical write path, used by wire_push, ingest_push, and merge_proposaloposal. - Re-pushing a snapshot whose row already exists STILL writes / updates its entries — the old "skip if exists" guard is the root cause of the bug being fixed here. All tests are RED until the implementation is in place. """ from __future__ import annotations import uuid from datetime import datetime, timezone import pytest from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from musehub.db import musehub_models as db from musehub.models.musehub import CommitInput, SnapshotInput from musehub.models.wire import WireBundle, WireCommit, WirePushRequest, WireSnapshot from musehub.services.musehub_sync import ingest_push from musehub.services.musehub_wire import wire_push from musehub.muse_contracts.json_types import StrDict # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- MANIFEST_A = { "README.md": "obj-readme-001", "musehub/main.py": "obj-main-001", "musehub/db/models.py": "obj-models-001", } MANIFEST_B = { "README.md": "obj-readme-002", # changed "musehub/main.py": "obj-main-001", # unchanged "musehub/db/models.py": "obj-models-002", # changed "musehub/new_file.py": "obj-new-001", # added } def _uid() -> str: return str(uuid.uuid4()) def _repo(repo_id: str, owner_user_id: str = "user-test") -> db.MusehubRepo: return db.MusehubRepo( repo_id=repo_id, name="test-repo", owner=owner_user_id, slug=repo_id, visibility="private", owner_user_id=owner_user_id, ) def _commit_input( commit_id: str, snapshot_id: str, parent_ids: list[str] | None = None, ) -> CommitInput: return CommitInput( commit_id=commit_id, branch="dev", parent_ids=parent_ids or [], message="test commit", author="tester", timestamp="2026-01-01T00:00:00Z", snapshot_id=snapshot_id, ) def _snap_input( snapshot_id: str, manifest: StrDict, ) -> SnapshotInput: return SnapshotInput(snapshot_id=snapshot_id, manifest=manifest) def _wire_bundle( commit_id: str, snapshot_id: str, manifest: StrDict, parent_id: str | None = None, ) -> WireBundle: return WireBundle( commits=[ WireCommit( commit_id=commit_id, branch="dev", parent_commit_id=parent_id or "", message="test commit", author="tester", timestamp="2026-01-01T00:00:00Z", snapshot_id=snapshot_id, ) ], snapshots=[ WireSnapshot( snapshot_id=snapshot_id, manifest=manifest, created_at="2026-01-01T00:00:00Z", ) ], objects=[], ) async def _count_entries( session: AsyncSession, snapshot_id: str ) -> int: result = await session.execute( select(func.count()).select_from(db.MusehubSnapshotEntry).where( db.MusehubSnapshotEntry.snapshot_id == snapshot_id ) ) return result.scalar_one() async def _get_entries( session: AsyncSession, snapshot_id: str ) -> StrDict: """Return {path: object_id} for all entries of a snapshot.""" result = await session.execute( select(db.MusehubSnapshotEntry).where( db.MusehubSnapshotEntry.snapshot_id == snapshot_id ) ) return {e.path: e.object_id for e in result.scalars().all()} # --------------------------------------------------------------------------- # 1. Model existence # --------------------------------------------------------------------------- def test_snapshot_entry_model_exists() -> None: """MusehubSnapshotEntry ORM model must exist on the db module.""" assert hasattr(db, "MusehubSnapshotEntry"), ( "db.MusehubSnapshotEntry not found — add the ORM model and migration" ) def test_snapshot_entry_has_required_columns() -> None: """MusehubSnapshotEntry must have snapshot_id, path, object_id, size_bytes.""" entry = db.MusehubSnapshotEntry cols = {c.key for c in entry.__table__.columns} assert "snapshot_id" in cols assert "path" in cols assert "object_id" in cols assert "size_bytes" in cols, "size_bytes is the muse augmentation over git trees" def test_snapshot_manifest_column_removed() -> None: """MusehubSnapshot must NOT have a manifest column — it moved to entries.""" snap_cols = {c.key for c in db.MusehubSnapshot.__table__.columns} assert "manifest" not in snap_cols, ( "manifest JSON blob must be removed from musehub_snapshots — " "file trees now live in musehub_snapshot_entries" ) # --------------------------------------------------------------------------- # 2. Utility functions # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_snapshot_manifest_returns_empty_for_unknown( db_session: AsyncSession, ) -> None: """get_snapshot_manifest returns {} when snapshot_id has no entries.""" from musehub.services.musehub_snapshot import get_snapshot_manifest result = await get_snapshot_manifest(db_session, "nonexistent-snap") assert result == {} @pytest.mark.asyncio async def test_get_snapshot_manifest_reconstructs_from_entries( db_session: AsyncSession, ) -> None: """get_snapshot_manifest returns the correct {path: object_id} dict.""" from musehub.services.musehub_snapshot import get_snapshot_manifest, upsert_snapshot_entries snap_id = "snap-util-001" repo_id = "repo-util-001" db_session.add(_repo(repo_id)) db_session.add(db.MusehubSnapshot( snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A) await db_session.flush() result = await get_snapshot_manifest(db_session, snap_id) assert result == MANIFEST_A @pytest.mark.asyncio async def test_upsert_snapshot_entries_is_idempotent( db_session: AsyncSession, ) -> None: """Calling upsert_snapshot_entries twice with the same data is safe.""" from musehub.services.musehub_snapshot import upsert_snapshot_entries snap_id = "snap-idempotent-001" repo_id = "repo-idempotent-001" db_session.add(_repo(repo_id)) db_session.add(db.MusehubSnapshot( snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A) await db_session.flush() count = await _count_entries(db_session, snap_id) assert count == len(MANIFEST_A) @pytest.mark.asyncio async def test_upsert_snapshot_entries_updates_changed_object_id( db_session: AsyncSession, ) -> None: """Re-upserting with a different object_id for the same path updates the row.""" from musehub.services.musehub_snapshot import get_snapshot_manifest, upsert_snapshot_entries snap_id = "snap-update-001" repo_id = "repo-update-001" db_session.add(_repo(repo_id)) db_session.add(db.MusehubSnapshot( snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_id, {"README.md": "old-obj"}) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_id, {"README.md": "new-obj"}) await db_session.flush() result = await get_snapshot_manifest(db_session, snap_id) assert result["README.md"] == "new-obj" # --------------------------------------------------------------------------- # 3. ingest_push write path (REST API / MCP path) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_ingest_push_stores_snapshot_entries( db_session: AsyncSession, ) -> None: """ingest_push must write entries to musehub_snapshot_entries.""" repo_id = "repo-ip-entries-001" snap_id = "snap-ip-001" commit_id = _uid() db_session.add(_repo(repo_id)) await db_session.flush() await ingest_push( db_session, repo_id=repo_id, branch="dev", head_commit_id=commit_id, commits=[_commit_input(commit_id, snap_id)], snapshots=[_snap_input(snap_id, MANIFEST_A)], objects=[], force=True, author="tester", ) entries = await _get_entries(db_session, snap_id) assert entries == MANIFEST_A @pytest.mark.asyncio async def test_ingest_push_entries_idempotent_on_repush( db_session: AsyncSession, ) -> None: """Re-pushing the same snapshot via ingest_push does not duplicate entries.""" repo_id = "repo-ip-idem-001" snap_id = "snap-ip-idem-001" commit_id = _uid() db_session.add(_repo(repo_id)) await db_session.flush() for _ in range(3): await ingest_push( db_session, repo_id=repo_id, branch="dev", head_commit_id=commit_id, commits=[_commit_input(commit_id, snap_id)], snapshots=[_snap_input(snap_id, MANIFEST_A)], objects=[], force=True, author="tester", ) count = await _count_entries(db_session, snap_id) assert count == len(MANIFEST_A) # --------------------------------------------------------------------------- # 4. wire_push write path (muse CLI push path) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_wire_push_stores_snapshot_entries( db_session: AsyncSession, ) -> None: """wire_push must write entries to musehub_snapshot_entries.""" repo_id = "repo-wire-001" user_id = "user-wire-001" snap_id = "snap-wire-001" commit_id = _uid() db_session.add(_repo(repo_id, owner_user_id=user_id)) await db_session.flush() req = WirePushRequest( bundle=_wire_bundle(commit_id, snap_id, MANIFEST_A), branch="dev", force=False, ) result = await wire_push(db_session, repo_id, req, pusher_id=user_id) assert result.ok, f"wire_push failed: {result.message}" entries = await _get_entries(db_session, snap_id) assert entries == MANIFEST_A @pytest.mark.asyncio async def test_wire_push_entries_idempotent_on_repush( db_session: AsyncSession, ) -> None: """Re-pushing the same snapshot via wire_push does not duplicate entries.""" repo_id = "repo-wire-idem-001" user_id = "user-wire-idem-001" snap_id = "snap-wire-idem-001" commit_id = _uid() db_session.add(_repo(repo_id, owner_user_id=user_id)) await db_session.flush() req = WirePushRequest( bundle=_wire_bundle(commit_id, snap_id, MANIFEST_A), branch="dev", force=True, ) await wire_push(db_session, repo_id, req, pusher_id=user_id) await wire_push(db_session, repo_id, req, pusher_id=user_id) count = await _count_entries(db_session, snap_id) assert count == len(MANIFEST_A) @pytest.mark.asyncio async def test_wire_push_repairs_stale_snapshot( db_session: AsyncSession, ) -> None: """ Regression test for the root bug. A snapshot row that already exists in the DB with NO entries (e.g. from an old push before this fix) must have its entries written on the next push of the same snapshot_id. The old guard ``if existing_snap is not None: continue`` silently discarded the manifest forever. """ repo_id = "repo-wire-stale-001" user_id = "user-wire-stale-001" snap_id = "snap-stale-001" commit_id = _uid() # Seed the repo and a snapshot row with no entries — the old broken state. db_session.add(_repo(repo_id, owner_user_id=user_id)) db_session.add(db.MusehubSnapshot( snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc), )) await db_session.flush() # Verify there are no entries yet. assert await _count_entries(db_session, snap_id) == 0 # Now push the same snapshot_id with real manifest data. req = WirePushRequest( bundle=_wire_bundle(commit_id, snap_id, MANIFEST_A), branch="dev", force=True, ) result = await wire_push(db_session, repo_id, req, pusher_id=user_id) assert result.ok, f"wire_push failed: {result.message}" entries = await _get_entries(db_session, snap_id) assert entries == MANIFEST_A, ( "Stale snapshot was not repaired — entries must be written even when " "the snapshot row already exists" ) # --------------------------------------------------------------------------- # 5. Read path — repository service functions # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_file_at_ref_resolves_via_entries( db_session: AsyncSession, ) -> None: """get_file_at_ref must resolve object_id from snapshot entries, not manifest blob.""" from musehub.services.musehub_repository import get_file_at_ref from musehub.services.musehub_snapshot import upsert_snapshot_entries repo_id = "repo-gar-001" snap_id = "snap-gar-001" commit_id = _uid() db_session.add(db.MusehubRepo( repo_id=repo_id, name="r", owner="t", slug=repo_id, visibility="private", owner_user_id="u", )) db_session.add(db.MusehubSnapshot( snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) db_session.add(db.MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="dev", parent_ids=[], message="m", author="a", timestamp=datetime.now(timezone.utc), snapshot_id=snap_id, )) db_session.add(db.MusehubBranch( repo_id=repo_id, name="dev", head_commit_id=commit_id )) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A) await db_session.flush() result = await get_file_at_ref(db_session, repo_id, "dev", "README.md") assert result is not None assert result["object_id"] == MANIFEST_A["README.md"] assert result["path"] == "README.md" @pytest.mark.asyncio async def test_get_snapshot_diff_via_entries( db_session: AsyncSession, ) -> None: """get_snapshot_diff must diff two snapshots using entries, not manifest blobs.""" from musehub.services.musehub_repository import get_snapshot_diff from musehub.services.musehub_snapshot import upsert_snapshot_entries repo_id = "repo-diff-001" snap_a = "snap-diff-a" snap_b = "snap-diff-b" db_session.add(_repo(repo_id)) db_session.add(db.MusehubSnapshot( snapshot_id=snap_a, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) db_session.add(db.MusehubSnapshot( snapshot_id=snap_b, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_a, MANIFEST_A) await upsert_snapshot_entries(db_session, repo_id, snap_b, MANIFEST_B) await db_session.flush() diff = await get_snapshot_diff(db_session, repo_id, snap_b, snap_a) # musehub/new_file.py is in B but not A → added assert "musehub/new_file.py" in diff["added"] # README.md and models.py changed object_id → modified assert "README.md" in diff["modified"] assert "musehub/db/models.py" in diff["modified"] # main.py is unchanged assert "musehub/main.py" not in diff["modified"] assert "musehub/main.py" not in diff["added"] assert "musehub/main.py" not in diff["removed"] @pytest.mark.asyncio async def test_get_file_last_commits_via_entries( db_session: AsyncSession, ) -> None: """get_file_last_commits must walk entries, not manifest blobs.""" from musehub.services.musehub_repository import get_file_last_commits from musehub.services.musehub_snapshot import upsert_snapshot_entries repo_id = "repo-flc-001" snap_1 = "snap-flc-001" snap_2 = "snap-flc-002" commit_1 = _uid() commit_2 = _uid() db_session.add(_repo(repo_id)) db_session.add(db.MusehubSnapshot( snapshot_id=snap_1, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) db_session.add(db.MusehubSnapshot( snapshot_id=snap_2, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) # commit_1 (older): only MANIFEST_A db_session.add(db.MusehubCommit( commit_id=commit_1, repo_id=repo_id, branch="dev", parent_ids=[], message="initial", author="a", timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc), snapshot_id=snap_1, )) # commit_2 (newer): MANIFEST_B — README.md changed db_session.add(db.MusehubCommit( commit_id=commit_2, repo_id=repo_id, branch="dev", parent_ids=[commit_1], message="update readme", author="a", timestamp=datetime(2026, 1, 2, tzinfo=timezone.utc), snapshot_id=snap_2, )) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_1, MANIFEST_A) await upsert_snapshot_entries(db_session, repo_id, snap_2, MANIFEST_B) await db_session.flush() result = await get_file_last_commits(db_session, repo_id, ["README.md", "musehub/main.py"]) # README.md changed in commit_2 — should be attributed there assert "README.md" in result assert result["README.md"]["sha"] == commit_2[:8] # main.py was unchanged in commit_2 — attributed to commit_1 assert "musehub/main.py" in result assert result["musehub/main.py"]["sha"] == commit_1[:8] @pytest.mark.asyncio async def test_list_tree_resolves_via_entries( db_session: AsyncSession, ) -> None: """list_tree must build the directory listing from snapshot entries.""" from musehub.services.musehub_repository import list_tree from musehub.services.musehub_snapshot import upsert_snapshot_entries repo_id = "repo-lt-001" snap_id = "snap-lt-001" commit_id = _uid() db_session.add(db.MusehubRepo( repo_id=repo_id, name="r", owner="t", slug=repo_id, visibility="private", owner_user_id="u", )) db_session.add(db.MusehubSnapshot( snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) db_session.add(db.MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="dev", parent_ids=[], message="m", author="a", timestamp=datetime.now(timezone.utc), snapshot_id=snap_id, )) db_session.add(db.MusehubBranch( repo_id=repo_id, name="dev", head_commit_id=commit_id )) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A) await db_session.flush() tree = await list_tree(db_session, repo_id, "t", repo_id, ref="dev", dir_path="") names = {e.name for e in tree.entries} assert "README.md" in names assert "musehub" in names # directory inferred from paths # --------------------------------------------------------------------------- # 6. wire fetch — object dedup uses entries # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_wire_fetch_includes_objects_from_entries( db_session: AsyncSession, ) -> None: """ wire_fetch / _to_wire_snapshot must reconstruct manifest from entries so that object deduplication works correctly on pull. """ from musehub.services.musehub_wire import _to_wire_snapshot from musehub.services.musehub_snapshot import upsert_snapshot_entries repo_id = "repo-fetch-001" snap_id = "snap-fetch-001" db_session.add(_repo(repo_id)) db_session.add(db.MusehubSnapshot( snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc) )) await db_session.flush() await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A) await db_session.flush() snap_row = await db_session.get(db.MusehubSnapshot, snap_id) assert snap_row is not None wire_snap = await _to_wire_snapshot(db_session, snap_row) assert wire_snap.manifest == MANIFEST_A