gabriel / musehub public
test_snapshot_entries.py python
623 lines 20.5 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """TDD tests for the snapshot entries refactor.
2
3 Defines the new contract:
4 - Snapshot file trees are stored as normalized rows in ``musehub_snapshot_entries``
5 (snapshot_id, path, object_id, size_bytes) rather than as a JSON blob in
6 ``musehub_snapshots.manifest``.
7 - A utility ``get_snapshot_manifest`` reconstructs {path: object_id} from
8 those rows for callers that need the dict form.
9 - A utility ``upsert_snapshot_entries`` is the canonical write path, used by
10 wire_push, ingest_push, and merge_proposaloposal.
11 - Re-pushing a snapshot whose row already exists STILL writes / updates its
12 entries — the old "skip if exists" guard is the root cause of the bug being
13 fixed here.
14
15 All tests are RED until the implementation is in place.
16 """
17 from __future__ import annotations
18
19 import uuid
20 from datetime import datetime, timezone
21
22 import pytest
23 from sqlalchemy import func, select
24 from sqlalchemy.ext.asyncio import AsyncSession
25
26 from musehub.db import musehub_models as db
27 from musehub.models.musehub import CommitInput, SnapshotInput
28 from musehub.models.wire import WireBundle, WireCommit, WirePushRequest, WireSnapshot
29 from musehub.services.musehub_sync import ingest_push
30 from musehub.services.musehub_wire import wire_push
31 from musehub.muse_contracts.json_types import StrDict
32
33
34 # ---------------------------------------------------------------------------
35 # Helpers
36 # ---------------------------------------------------------------------------
37
38 MANIFEST_A = {
39 "README.md": "obj-readme-001",
40 "musehub/main.py": "obj-main-001",
41 "musehub/db/models.py": "obj-models-001",
42 }
43
44 MANIFEST_B = {
45 "README.md": "obj-readme-002", # changed
46 "musehub/main.py": "obj-main-001", # unchanged
47 "musehub/db/models.py": "obj-models-002", # changed
48 "musehub/new_file.py": "obj-new-001", # added
49 }
50
51
52 def _uid() -> str:
53 return str(uuid.uuid4())
54
55
56 def _repo(repo_id: str, owner_user_id: str = "user-test") -> db.MusehubRepo:
57 return db.MusehubRepo(
58 repo_id=repo_id,
59 name="test-repo",
60 owner=owner_user_id,
61 slug=repo_id,
62 visibility="private",
63 owner_user_id=owner_user_id,
64 )
65
66
67 def _commit_input(
68 commit_id: str,
69 snapshot_id: str,
70 parent_ids: list[str] | None = None,
71 ) -> CommitInput:
72 return CommitInput(
73 commit_id=commit_id,
74 branch="dev",
75 parent_ids=parent_ids or [],
76 message="test commit",
77 author="tester",
78 timestamp="2026-01-01T00:00:00Z",
79 snapshot_id=snapshot_id,
80 )
81
82
83 def _snap_input(
84 snapshot_id: str,
85 manifest: StrDict,
86 ) -> SnapshotInput:
87 return SnapshotInput(snapshot_id=snapshot_id, manifest=manifest)
88
89
90 def _wire_bundle(
91 commit_id: str,
92 snapshot_id: str,
93 manifest: StrDict,
94 parent_id: str | None = None,
95 ) -> WireBundle:
96 return WireBundle(
97 commits=[
98 WireCommit(
99 commit_id=commit_id,
100 branch="dev",
101 parent_commit_id=parent_id or "",
102 message="test commit",
103 author="tester",
104 timestamp="2026-01-01T00:00:00Z",
105 snapshot_id=snapshot_id,
106 )
107 ],
108 snapshots=[
109 WireSnapshot(
110 snapshot_id=snapshot_id,
111 manifest=manifest,
112 created_at="2026-01-01T00:00:00Z",
113 )
114 ],
115 objects=[],
116 )
117
118
119 async def _count_entries(
120 session: AsyncSession, snapshot_id: str
121 ) -> int:
122 result = await session.execute(
123 select(func.count()).select_from(db.MusehubSnapshotEntry).where(
124 db.MusehubSnapshotEntry.snapshot_id == snapshot_id
125 )
126 )
127 return result.scalar_one()
128
129
130 async def _get_entries(
131 session: AsyncSession, snapshot_id: str
132 ) -> StrDict:
133 """Return {path: object_id} for all entries of a snapshot."""
134 result = await session.execute(
135 select(db.MusehubSnapshotEntry).where(
136 db.MusehubSnapshotEntry.snapshot_id == snapshot_id
137 )
138 )
139 return {e.path: e.object_id for e in result.scalars().all()}
140
141
142 # ---------------------------------------------------------------------------
143 # 1. Model existence
144 # ---------------------------------------------------------------------------
145
146
147 def test_snapshot_entry_model_exists() -> None:
148 """MusehubSnapshotEntry ORM model must exist on the db module."""
149 assert hasattr(db, "MusehubSnapshotEntry"), (
150 "db.MusehubSnapshotEntry not found — add the ORM model and migration"
151 )
152
153
154 def test_snapshot_entry_has_required_columns() -> None:
155 """MusehubSnapshotEntry must have snapshot_id, path, object_id, size_bytes."""
156 entry = db.MusehubSnapshotEntry
157 cols = {c.key for c in entry.__table__.columns}
158 assert "snapshot_id" in cols
159 assert "path" in cols
160 assert "object_id" in cols
161 assert "size_bytes" in cols, "size_bytes is the muse augmentation over git trees"
162
163
164 def test_snapshot_manifest_column_removed() -> None:
165 """MusehubSnapshot must NOT have a manifest column — it moved to entries."""
166 snap_cols = {c.key for c in db.MusehubSnapshot.__table__.columns}
167 assert "manifest" not in snap_cols, (
168 "manifest JSON blob must be removed from musehub_snapshots — "
169 "file trees now live in musehub_snapshot_entries"
170 )
171
172
173 # ---------------------------------------------------------------------------
174 # 2. Utility functions
175 # ---------------------------------------------------------------------------
176
177
178 @pytest.mark.asyncio
179 async def test_get_snapshot_manifest_returns_empty_for_unknown(
180 db_session: AsyncSession,
181 ) -> None:
182 """get_snapshot_manifest returns {} when snapshot_id has no entries."""
183 from musehub.services.musehub_snapshot import get_snapshot_manifest
184
185 result = await get_snapshot_manifest(db_session, "nonexistent-snap")
186 assert result == {}
187
188
189 @pytest.mark.asyncio
190 async def test_get_snapshot_manifest_reconstructs_from_entries(
191 db_session: AsyncSession,
192 ) -> None:
193 """get_snapshot_manifest returns the correct {path: object_id} dict."""
194 from musehub.services.musehub_snapshot import get_snapshot_manifest, upsert_snapshot_entries
195
196 snap_id = "snap-util-001"
197 repo_id = "repo-util-001"
198 db_session.add(_repo(repo_id))
199 db_session.add(db.MusehubSnapshot(
200 snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc)
201 ))
202 await db_session.flush()
203
204 await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A)
205 await db_session.flush()
206
207 result = await get_snapshot_manifest(db_session, snap_id)
208 assert result == MANIFEST_A
209
210
211 @pytest.mark.asyncio
212 async def test_upsert_snapshot_entries_is_idempotent(
213 db_session: AsyncSession,
214 ) -> None:
215 """Calling upsert_snapshot_entries twice with the same data is safe."""
216 from musehub.services.musehub_snapshot import upsert_snapshot_entries
217
218 snap_id = "snap-idempotent-001"
219 repo_id = "repo-idempotent-001"
220 db_session.add(_repo(repo_id))
221 db_session.add(db.MusehubSnapshot(
222 snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc)
223 ))
224 await db_session.flush()
225
226 await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A)
227 await db_session.flush()
228 await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A)
229 await db_session.flush()
230
231 count = await _count_entries(db_session, snap_id)
232 assert count == len(MANIFEST_A)
233
234
235 @pytest.mark.asyncio
236 async def test_upsert_snapshot_entries_updates_changed_object_id(
237 db_session: AsyncSession,
238 ) -> None:
239 """Re-upserting with a different object_id for the same path updates the row."""
240 from musehub.services.musehub_snapshot import get_snapshot_manifest, upsert_snapshot_entries
241
242 snap_id = "snap-update-001"
243 repo_id = "repo-update-001"
244 db_session.add(_repo(repo_id))
245 db_session.add(db.MusehubSnapshot(
246 snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc)
247 ))
248 await db_session.flush()
249
250 await upsert_snapshot_entries(db_session, repo_id, snap_id, {"README.md": "old-obj"})
251 await db_session.flush()
252 await upsert_snapshot_entries(db_session, repo_id, snap_id, {"README.md": "new-obj"})
253 await db_session.flush()
254
255 result = await get_snapshot_manifest(db_session, snap_id)
256 assert result["README.md"] == "new-obj"
257
258
259 # ---------------------------------------------------------------------------
260 # 3. ingest_push write path (REST API / MCP path)
261 # ---------------------------------------------------------------------------
262
263
264 @pytest.mark.asyncio
265 async def test_ingest_push_stores_snapshot_entries(
266 db_session: AsyncSession,
267 ) -> None:
268 """ingest_push must write entries to musehub_snapshot_entries."""
269 repo_id = "repo-ip-entries-001"
270 snap_id = "snap-ip-001"
271 commit_id = _uid()
272
273 db_session.add(_repo(repo_id))
274 await db_session.flush()
275
276 await ingest_push(
277 db_session,
278 repo_id=repo_id,
279 branch="dev",
280 head_commit_id=commit_id,
281 commits=[_commit_input(commit_id, snap_id)],
282 snapshots=[_snap_input(snap_id, MANIFEST_A)],
283 objects=[],
284 force=True,
285 author="tester",
286 )
287
288 entries = await _get_entries(db_session, snap_id)
289 assert entries == MANIFEST_A
290
291
292 @pytest.mark.asyncio
293 async def test_ingest_push_entries_idempotent_on_repush(
294 db_session: AsyncSession,
295 ) -> None:
296 """Re-pushing the same snapshot via ingest_push does not duplicate entries."""
297 repo_id = "repo-ip-idem-001"
298 snap_id = "snap-ip-idem-001"
299 commit_id = _uid()
300
301 db_session.add(_repo(repo_id))
302 await db_session.flush()
303
304 for _ in range(3):
305 await ingest_push(
306 db_session,
307 repo_id=repo_id,
308 branch="dev",
309 head_commit_id=commit_id,
310 commits=[_commit_input(commit_id, snap_id)],
311 snapshots=[_snap_input(snap_id, MANIFEST_A)],
312 objects=[],
313 force=True,
314 author="tester",
315 )
316
317 count = await _count_entries(db_session, snap_id)
318 assert count == len(MANIFEST_A)
319
320
321 # ---------------------------------------------------------------------------
322 # 4. wire_push write path (muse CLI push path)
323 # ---------------------------------------------------------------------------
324
325
326 @pytest.mark.asyncio
327 async def test_wire_push_stores_snapshot_entries(
328 db_session: AsyncSession,
329 ) -> None:
330 """wire_push must write entries to musehub_snapshot_entries."""
331 repo_id = "repo-wire-001"
332 user_id = "user-wire-001"
333 snap_id = "snap-wire-001"
334 commit_id = _uid()
335
336 db_session.add(_repo(repo_id, owner_user_id=user_id))
337 await db_session.flush()
338
339 req = WirePushRequest(
340 bundle=_wire_bundle(commit_id, snap_id, MANIFEST_A),
341 branch="dev",
342 force=False,
343 )
344 result = await wire_push(db_session, repo_id, req, pusher_id=user_id)
345
346 assert result.ok, f"wire_push failed: {result.message}"
347 entries = await _get_entries(db_session, snap_id)
348 assert entries == MANIFEST_A
349
350
351 @pytest.mark.asyncio
352 async def test_wire_push_entries_idempotent_on_repush(
353 db_session: AsyncSession,
354 ) -> None:
355 """Re-pushing the same snapshot via wire_push does not duplicate entries."""
356 repo_id = "repo-wire-idem-001"
357 user_id = "user-wire-idem-001"
358 snap_id = "snap-wire-idem-001"
359 commit_id = _uid()
360
361 db_session.add(_repo(repo_id, owner_user_id=user_id))
362 await db_session.flush()
363
364 req = WirePushRequest(
365 bundle=_wire_bundle(commit_id, snap_id, MANIFEST_A),
366 branch="dev",
367 force=True,
368 )
369 await wire_push(db_session, repo_id, req, pusher_id=user_id)
370 await wire_push(db_session, repo_id, req, pusher_id=user_id)
371
372 count = await _count_entries(db_session, snap_id)
373 assert count == len(MANIFEST_A)
374
375
376 @pytest.mark.asyncio
377 async def test_wire_push_repairs_stale_snapshot(
378 db_session: AsyncSession,
379 ) -> None:
380 """
381 Regression test for the root bug.
382
383 A snapshot row that already exists in the DB with NO entries (e.g. from an
384 old push before this fix) must have its entries written on the next push of
385 the same snapshot_id. The old guard ``if existing_snap is not None: continue``
386 silently discarded the manifest forever.
387 """
388 repo_id = "repo-wire-stale-001"
389 user_id = "user-wire-stale-001"
390 snap_id = "snap-stale-001"
391 commit_id = _uid()
392
393 # Seed the repo and a snapshot row with no entries — the old broken state.
394 db_session.add(_repo(repo_id, owner_user_id=user_id))
395 db_session.add(db.MusehubSnapshot(
396 snapshot_id=snap_id,
397 repo_id=repo_id,
398 created_at=datetime.now(timezone.utc),
399 ))
400 await db_session.flush()
401
402 # Verify there are no entries yet.
403 assert await _count_entries(db_session, snap_id) == 0
404
405 # Now push the same snapshot_id with real manifest data.
406 req = WirePushRequest(
407 bundle=_wire_bundle(commit_id, snap_id, MANIFEST_A),
408 branch="dev",
409 force=True,
410 )
411 result = await wire_push(db_session, repo_id, req, pusher_id=user_id)
412
413 assert result.ok, f"wire_push failed: {result.message}"
414 entries = await _get_entries(db_session, snap_id)
415 assert entries == MANIFEST_A, (
416 "Stale snapshot was not repaired — entries must be written even when "
417 "the snapshot row already exists"
418 )
419
420
421 # ---------------------------------------------------------------------------
422 # 5. Read path — repository service functions
423 # ---------------------------------------------------------------------------
424
425
426 @pytest.mark.asyncio
427 async def test_get_file_at_ref_resolves_via_entries(
428 db_session: AsyncSession,
429 ) -> None:
430 """get_file_at_ref must resolve object_id from snapshot entries, not manifest blob."""
431 from musehub.services.musehub_repository import get_file_at_ref
432 from musehub.services.musehub_snapshot import upsert_snapshot_entries
433
434 repo_id = "repo-gar-001"
435 snap_id = "snap-gar-001"
436 commit_id = _uid()
437
438 db_session.add(db.MusehubRepo(
439 repo_id=repo_id, name="r", owner="t", slug=repo_id,
440 visibility="private", owner_user_id="u",
441 ))
442 db_session.add(db.MusehubSnapshot(
443 snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc)
444 ))
445 db_session.add(db.MusehubCommit(
446 commit_id=commit_id, repo_id=repo_id, branch="dev",
447 parent_ids=[], message="m", author="a",
448 timestamp=datetime.now(timezone.utc), snapshot_id=snap_id,
449 ))
450 db_session.add(db.MusehubBranch(
451 repo_id=repo_id, name="dev", head_commit_id=commit_id
452 ))
453 await db_session.flush()
454 await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A)
455 await db_session.flush()
456
457 result = await get_file_at_ref(db_session, repo_id, "dev", "README.md")
458
459 assert result is not None
460 assert result["object_id"] == MANIFEST_A["README.md"]
461 assert result["path"] == "README.md"
462
463
464 @pytest.mark.asyncio
465 async def test_get_snapshot_diff_via_entries(
466 db_session: AsyncSession,
467 ) -> None:
468 """get_snapshot_diff must diff two snapshots using entries, not manifest blobs."""
469 from musehub.services.musehub_repository import get_snapshot_diff
470 from musehub.services.musehub_snapshot import upsert_snapshot_entries
471
472 repo_id = "repo-diff-001"
473 snap_a = "snap-diff-a"
474 snap_b = "snap-diff-b"
475
476 db_session.add(_repo(repo_id))
477 db_session.add(db.MusehubSnapshot(
478 snapshot_id=snap_a, repo_id=repo_id, created_at=datetime.now(timezone.utc)
479 ))
480 db_session.add(db.MusehubSnapshot(
481 snapshot_id=snap_b, repo_id=repo_id, created_at=datetime.now(timezone.utc)
482 ))
483 await db_session.flush()
484
485 await upsert_snapshot_entries(db_session, repo_id, snap_a, MANIFEST_A)
486 await upsert_snapshot_entries(db_session, repo_id, snap_b, MANIFEST_B)
487 await db_session.flush()
488
489 diff = await get_snapshot_diff(db_session, repo_id, snap_b, snap_a)
490
491 # musehub/new_file.py is in B but not A → added
492 assert "musehub/new_file.py" in diff["added"]
493 # README.md and models.py changed object_id → modified
494 assert "README.md" in diff["modified"]
495 assert "musehub/db/models.py" in diff["modified"]
496 # main.py is unchanged
497 assert "musehub/main.py" not in diff["modified"]
498 assert "musehub/main.py" not in diff["added"]
499 assert "musehub/main.py" not in diff["removed"]
500
501
502 @pytest.mark.asyncio
503 async def test_get_file_last_commits_via_entries(
504 db_session: AsyncSession,
505 ) -> None:
506 """get_file_last_commits must walk entries, not manifest blobs."""
507 from musehub.services.musehub_repository import get_file_last_commits
508 from musehub.services.musehub_snapshot import upsert_snapshot_entries
509
510 repo_id = "repo-flc-001"
511 snap_1 = "snap-flc-001"
512 snap_2 = "snap-flc-002"
513 commit_1 = _uid()
514 commit_2 = _uid()
515
516 db_session.add(_repo(repo_id))
517 db_session.add(db.MusehubSnapshot(
518 snapshot_id=snap_1, repo_id=repo_id, created_at=datetime.now(timezone.utc)
519 ))
520 db_session.add(db.MusehubSnapshot(
521 snapshot_id=snap_2, repo_id=repo_id, created_at=datetime.now(timezone.utc)
522 ))
523
524 # commit_1 (older): only MANIFEST_A
525 db_session.add(db.MusehubCommit(
526 commit_id=commit_1, repo_id=repo_id, branch="dev",
527 parent_ids=[], message="initial", author="a",
528 timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc), snapshot_id=snap_1,
529 ))
530 # commit_2 (newer): MANIFEST_B — README.md changed
531 db_session.add(db.MusehubCommit(
532 commit_id=commit_2, repo_id=repo_id, branch="dev",
533 parent_ids=[commit_1], message="update readme", author="a",
534 timestamp=datetime(2026, 1, 2, tzinfo=timezone.utc), snapshot_id=snap_2,
535 ))
536 await db_session.flush()
537
538 await upsert_snapshot_entries(db_session, repo_id, snap_1, MANIFEST_A)
539 await upsert_snapshot_entries(db_session, repo_id, snap_2, MANIFEST_B)
540 await db_session.flush()
541
542 result = await get_file_last_commits(db_session, repo_id, ["README.md", "musehub/main.py"])
543
544 # README.md changed in commit_2 — should be attributed there
545 assert "README.md" in result
546 assert result["README.md"]["sha"] == commit_2[:8]
547
548 # main.py was unchanged in commit_2 — attributed to commit_1
549 assert "musehub/main.py" in result
550 assert result["musehub/main.py"]["sha"] == commit_1[:8]
551
552
553 @pytest.mark.asyncio
554 async def test_list_tree_resolves_via_entries(
555 db_session: AsyncSession,
556 ) -> None:
557 """list_tree must build the directory listing from snapshot entries."""
558 from musehub.services.musehub_repository import list_tree
559 from musehub.services.musehub_snapshot import upsert_snapshot_entries
560
561 repo_id = "repo-lt-001"
562 snap_id = "snap-lt-001"
563 commit_id = _uid()
564
565 db_session.add(db.MusehubRepo(
566 repo_id=repo_id, name="r", owner="t", slug=repo_id,
567 visibility="private", owner_user_id="u",
568 ))
569 db_session.add(db.MusehubSnapshot(
570 snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc)
571 ))
572 db_session.add(db.MusehubCommit(
573 commit_id=commit_id, repo_id=repo_id, branch="dev",
574 parent_ids=[], message="m", author="a",
575 timestamp=datetime.now(timezone.utc), snapshot_id=snap_id,
576 ))
577 db_session.add(db.MusehubBranch(
578 repo_id=repo_id, name="dev", head_commit_id=commit_id
579 ))
580 await db_session.flush()
581 await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A)
582 await db_session.flush()
583
584 tree = await list_tree(db_session, repo_id, "t", repo_id, ref="dev", dir_path="")
585
586 names = {e.name for e in tree.entries}
587 assert "README.md" in names
588 assert "musehub" in names # directory inferred from paths
589
590
591 # ---------------------------------------------------------------------------
592 # 6. wire fetch — object dedup uses entries
593 # ---------------------------------------------------------------------------
594
595
596 @pytest.mark.asyncio
597 async def test_wire_fetch_includes_objects_from_entries(
598 db_session: AsyncSession,
599 ) -> None:
600 """
601 wire_fetch / _to_wire_snapshot must reconstruct manifest from entries
602 so that object deduplication works correctly on pull.
603 """
604 from musehub.services.musehub_wire import _to_wire_snapshot
605 from musehub.services.musehub_snapshot import upsert_snapshot_entries
606
607 repo_id = "repo-fetch-001"
608 snap_id = "snap-fetch-001"
609
610 db_session.add(_repo(repo_id))
611 db_session.add(db.MusehubSnapshot(
612 snapshot_id=snap_id, repo_id=repo_id, created_at=datetime.now(timezone.utc)
613 ))
614 await db_session.flush()
615 await upsert_snapshot_entries(db_session, repo_id, snap_id, MANIFEST_A)
616 await db_session.flush()
617
618 snap_row = await db_session.get(db.MusehubSnapshot, snap_id)
619 assert snap_row is not None
620
621 wire_snap = await _to_wire_snapshot(db_session, snap_row)
622
623 assert wire_snap.manifest == MANIFEST_A
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago