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