gabriel / musehub public
test_api_snapshots.py python
971 lines 34.0 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Tests for the Snapshots REST API.
2
3 Endpoints covered
4 -----------------
5 GET /api/repos/{repo_id}/snapshots
6 GET /api/repos/{repo_id}/snapshots/{snapshot_id}
7 GET /api/repos/{repo_id}/snapshots/{snapshot_id}/entries
8 GET /api/repos/{repo_id}/commits/{commit_id}/snapshot
9 GET /api/repos/{repo_id}/snapshots/{snapshot_id}/diff
10 POST /api/repos/{repo_id}/snapshots/batch
11
12 Test strategy
13 -------------
14 - Every happy path is covered with a seeded DB fixture.
15 - Every 401 / 404 / 422 error path is covered.
16 - Cursor-based pagination (Link header + limit cap) is verified.
17 - The X-Snapshot-Entry-Count response header is verified on detail + entries.
18 - Diff counts (added / removed / modified / unchanged) are verified.
19 - Batch lookup is tested for both summary and full-entry modes.
20 - Private-repo visibility is tested end-to-end.
21 - Security: cross-repo snapshot access is blocked at the service layer.
22 """
23 from __future__ import annotations
24
25 import secrets
26 from datetime import datetime, timezone
27
28 import pytest
29 from httpx import AsyncClient
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 import msgpack
33 from muse.core.types import fake_id
34 from musehub.core.genesis import compute_identity_id, compute_repo_id
35 from musehub.db.musehub_repo_models import MusehubCommit, MusehubRepo, MusehubSnapshot, MusehubSnapshotRef
36 from musehub.types.json_types import StrDict
37
38
39 # ---------------------------------------------------------------------------
40 # Seed helpers
41 # ---------------------------------------------------------------------------
42
43
44 def _uid() -> str:
45 return secrets.token_hex(16)
46
47
48 async def _make_repo(
49 db: AsyncSession,
50 *,
51 visibility: str = "public",
52 name: str | None = None,
53 ) -> MusehubRepo:
54 """Seed a repo row, commit, and return it."""
55 slug = name or f"repo-{_uid()[:8]}"
56 created_at = datetime.now(tz=timezone.utc)
57 owner_id = compute_identity_id(b"testuser")
58 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
59 repo = MusehubRepo(
60 repo_id=repo_id,
61 name=slug,
62 owner="testuser",
63 slug=slug,
64 visibility=visibility,
65 owner_user_id=owner_id,
66 created_at=created_at,
67 updated_at=created_at,
68 )
69 db.add(repo)
70 await db.commit()
71 return repo
72
73
74 async def _make_snapshot(
75 db: AsyncSession,
76 repo_id: str,
77 *,
78 manifest: StrDict | None = None,
79 directories: list[str] | None = None,
80 snapshot_id: str | None = None,
81 created_at: datetime | None = None,
82 ) -> MusehubSnapshot:
83 """Seed a snapshot row with manifest_blob and entry_count, then commit."""
84 effective_manifest: StrDict = manifest or {}
85 sid = snapshot_id or fake_id(_uid())
86 now = created_at or datetime.now(timezone.utc)
87 snap = MusehubSnapshot(
88 snapshot_id=sid,
89 directories=sorted(directories or []),
90 manifest_blob=msgpack.packb(effective_manifest, use_bin_type=True),
91 entry_count=len(effective_manifest),
92 created_at=now,
93 )
94 db.add(snap)
95 db.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=sid, created_at=now))
96 await db.commit()
97 return snap
98
99
100 async def _make_commit(
101 db: AsyncSession,
102 repo_id: str,
103 *,
104 snapshot_id: str | None = None,
105 branch: str = "main",
106 ) -> MusehubCommit:
107 """Seed a commit row, commit, and return it."""
108 commit_id = fake_id(_uid())
109 commit = MusehubCommit(
110 commit_id=commit_id,
111 branch=branch,
112 parent_ids=[],
113 message="test commit",
114 author="testuser",
115 timestamp=datetime.now(timezone.utc),
116 snapshot_id=snapshot_id,
117 )
118 db.add(commit)
119 await db.commit()
120 return commit
121
122
123 _MANIFEST_A = {
124 "muse/core/store.py": fake_id("oid-store-001"),
125 "muse/core/snapshot.py": fake_id("oid-snap-001"),
126 "tests/test_store.py": fake_id("oid-test-001"),
127 }
128
129 _MANIFEST_B = {
130 "muse/core/store.py": fake_id("oid-store-002"), # modified
131 "muse/core/pack.py": fake_id("oid-pack-001"), # added
132 "tests/test_store.py": fake_id("oid-test-001"), # unchanged
133 # muse/core/snapshot.py removed
134 }
135
136
137 # ---------------------------------------------------------------------------
138 # GET /api/repos/{repo_id}/snapshots — list
139 # ---------------------------------------------------------------------------
140
141
142 class TestListSnapshots:
143 """GET /api/repos/{repo_id}/snapshots"""
144
145 async def test_empty_repo_returns_empty_list(
146 self,
147 client: AsyncClient,
148 auth_headers: StrDict,
149 db_session: AsyncSession,
150 ) -> None:
151 """A repo with no snapshots returns an empty list and total=0."""
152 repo = await _make_repo(db_session)
153 await db_session.commit()
154
155 resp = await client.get(
156 f"/api/repos/{repo.repo_id}/snapshots",
157 headers=auth_headers,
158 )
159 assert resp.status_code == 200
160 body = resp.json()
161 assert body["snapshots"] == []
162 assert body["total"] == 0
163
164 async def test_returns_snapshots_newest_first(
165 self,
166 client: AsyncClient,
167 auth_headers: StrDict,
168 db_session: AsyncSession,
169 ) -> None:
170 """Snapshots are returned newest-first by created_at."""
171 import asyncio
172 from datetime import timedelta
173
174 repo = await _make_repo(db_session)
175 now = datetime.now(timezone.utc)
176
177 snap_old = await _make_snapshot(
178 db_session, repo.repo_id, created_at=now - timedelta(hours=2)
179 )
180 snap_new = await _make_snapshot(db_session, repo.repo_id, created_at=now)
181
182 resp = await client.get(
183 f"/api/repos/{repo.repo_id}/snapshots",
184 headers=auth_headers,
185 )
186 assert resp.status_code == 200
187 body = resp.json()
188 assert body["total"] == 2
189 ids = [s["snapshotId"] for s in body["snapshots"]]
190 assert ids[0] == snap_new.snapshot_id
191 assert ids[1] == snap_old.snapshot_id
192
193 async def test_summary_includes_entry_count_and_size(
194 self,
195 client: AsyncClient,
196 auth_headers: StrDict,
197 db_session: AsyncSession,
198 ) -> None:
199 """Each summary carries entry_count and total_size_bytes without full manifest."""
200 repo = await _make_repo(db_session)
201 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
202
203 resp = await client.get(
204 f"/api/repos/{repo.repo_id}/snapshots",
205 headers=auth_headers,
206 )
207 assert resp.status_code == 200
208 summary = resp.json()["snapshots"][0]
209 assert summary["snapshotId"] == snap.snapshot_id
210 assert summary["entryCount"] == len(_MANIFEST_A)
211 assert summary["totalSizeBytes"] == 0
212 assert "entries" not in summary
213
214 async def test_pagination_link_header_present(
215 self,
216 client: AsyncClient,
217 auth_headers: StrDict,
218 db_session: AsyncSession,
219 ) -> None:
220 """Link header with rel=next is set when more pages exist."""
221 repo = await _make_repo(db_session)
222 for _ in range(5):
223 await _make_snapshot(db_session, repo.repo_id)
224 await db_session.commit()
225
226 resp = await client.get(
227 f"/api/repos/{repo.repo_id}/snapshots?limit=2",
228 headers=auth_headers,
229 )
230 assert resp.status_code == 200
231 assert "Link" in resp.headers
232 assert 'rel="next"' in resp.headers["Link"]
233 assert resp.json()["nextCursor"] is not None
234
235 async def test_limit_capped_at_200(
236 self,
237 client: AsyncClient,
238 auth_headers: StrDict,
239 db_session: AsyncSession,
240 ) -> None:
241 """Requesting limit > 200 returns 422."""
242 repo = await _make_repo(db_session)
243 await db_session.commit()
244
245 resp = await client.get(
246 f"/api/repos/{repo.repo_id}/snapshots?limit=201",
247 headers=auth_headers,
248 )
249 assert resp.status_code == 422
250
251 async def test_unknown_repo_returns_404(
252 self,
253 client: AsyncClient,
254 auth_headers: StrDict,
255 db_session: AsyncSession,
256 ) -> None:
257 """Non-existent repo_id returns 404."""
258 resp = await client.get(
259 f"/api/repos/{_uid()}/snapshots",
260 headers=auth_headers,
261 )
262 assert resp.status_code == 404
263
264 async def test_private_repo_requires_auth(
265 self,
266 client: AsyncClient,
267 db_session: AsyncSession,
268 ) -> None:
269 """Unauthenticated access to a private repo returns 401."""
270 repo = await _make_repo(db_session, visibility="private")
271 await db_session.commit()
272
273 resp = await client.get(f"/api/repos/{repo.repo_id}/snapshots")
274 assert resp.status_code == 401
275 assert resp.headers.get("WWW-Authenticate", "").startswith("MSign")
276
277 async def test_public_repo_allows_unauthenticated(
278 self,
279 client: AsyncClient,
280 db_session: AsyncSession,
281 ) -> None:
282 """Public repos can be read without auth."""
283 repo = await _make_repo(db_session, visibility="public")
284 await db_session.commit()
285
286 resp = await client.get(f"/api/repos/{repo.repo_id}/snapshots")
287 assert resp.status_code == 200
288
289
290 # ---------------------------------------------------------------------------
291 # GET /api/repos/{repo_id}/snapshots/{snapshot_id} — detail
292 # ---------------------------------------------------------------------------
293
294
295 class TestGetSnapshot:
296 """GET /api/repos/{repo_id}/snapshots/{snapshot_id}"""
297
298 async def test_returns_full_manifest_sorted_by_path(
299 self,
300 client: AsyncClient,
301 auth_headers: StrDict,
302 db_session: AsyncSession,
303 ) -> None:
304 """Entries are present and sorted alphabetically by path."""
305 repo = await _make_repo(db_session)
306 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
307 await db_session.commit()
308
309 resp = await client.get(
310 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}",
311 headers=auth_headers,
312 )
313 assert resp.status_code == 200
314 body = resp.json()
315 assert body["snapshotId"] == snap.snapshot_id
316 paths = [e["path"] for e in body["entries"]]
317 assert paths == sorted(_MANIFEST_A.keys())
318
319 async def test_entry_count_header_matches_body(
320 self,
321 client: AsyncClient,
322 auth_headers: StrDict,
323 db_session: AsyncSession,
324 ) -> None:
325 """X-Snapshot-Entry-Count header equals len(entries) in the body."""
326 repo = await _make_repo(db_session)
327 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
328 await db_session.commit()
329
330 resp = await client.get(
331 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}",
332 headers=auth_headers,
333 )
334 assert resp.status_code == 200
335 assert resp.headers["X-Snapshot-Entry-Count"] == str(len(_MANIFEST_A))
336 assert resp.json()["entryCount"] == len(_MANIFEST_A)
337
338 async def test_directories_round_trip(
339 self,
340 client: AsyncClient,
341 auth_headers: StrDict,
342 db_session: AsyncSession,
343 ) -> None:
344 """directories field is stored and returned faithfully."""
345 dirs = ["muse", "muse/core", "tests"]
346 repo = await _make_repo(db_session)
347 snap = await _make_snapshot(
348 db_session, repo.repo_id, manifest=_MANIFEST_A, directories=dirs
349 )
350 await db_session.commit()
351
352 resp = await client.get(
353 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}",
354 headers=auth_headers,
355 )
356 assert resp.status_code == 200
357 assert resp.json()["directories"] == sorted(dirs)
358
359 async def test_cross_repo_snapshot_returns_404(
360 self,
361 client: AsyncClient,
362 auth_headers: StrDict,
363 db_session: AsyncSession,
364 ) -> None:
365 """Snapshot owned by a different repo returns 404, not the snapshot."""
366 repo_a = await _make_repo(db_session)
367 repo_b = await _make_repo(db_session)
368 snap = await _make_snapshot(db_session, repo_a.repo_id, manifest=_MANIFEST_A)
369 await db_session.commit()
370
371 # Request via repo_b's ID — must not leak the snapshot
372 resp = await client.get(
373 f"/api/repos/{repo_b.repo_id}/snapshots/{snap.snapshot_id}",
374 headers=auth_headers,
375 )
376 assert resp.status_code == 404
377
378 async def test_unknown_snapshot_returns_404(
379 self,
380 client: AsyncClient,
381 auth_headers: StrDict,
382 db_session: AsyncSession,
383 ) -> None:
384 """Non-existent snapshot_id returns 404."""
385 repo = await _make_repo(db_session)
386 await db_session.commit()
387
388 resp = await client.get(
389 f"/api/repos/{repo.repo_id}/snapshots/{fake_id(_uid())}",
390 headers=auth_headers,
391 )
392 assert resp.status_code == 404
393
394 async def test_empty_snapshot_has_zero_entry_count(
395 self,
396 client: AsyncClient,
397 auth_headers: StrDict,
398 db_session: AsyncSession,
399 ) -> None:
400 """A snapshot with no entries returns entryCount=0 and entries=[]."""
401 repo = await _make_repo(db_session)
402 snap = await _make_snapshot(db_session, repo.repo_id, manifest={})
403 await db_session.commit()
404
405 resp = await client.get(
406 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}",
407 headers=auth_headers,
408 )
409 assert resp.status_code == 200
410 body = resp.json()
411 assert body["entries"] == []
412 assert body["entryCount"] == 0
413 assert body["totalSizeBytes"] == 0
414
415
416 # ---------------------------------------------------------------------------
417 # GET /api/repos/{repo_id}/snapshots/{snapshot_id}/entries — paginated
418 # ---------------------------------------------------------------------------
419
420
421 class TestListSnapshotEntries:
422 """GET /api/repos/{repo_id}/snapshots/{snapshot_id}/entries"""
423
424 async def test_paginated_entries_sorted_by_path(
425 self,
426 client: AsyncClient,
427 auth_headers: StrDict,
428 db_session: AsyncSession,
429 ) -> None:
430 """Entries are sorted by path and paginated correctly."""
431 # Build a 5-entry manifest
432 manifest = {f"file_{i:02d}.py": f"oid-{i:03d}" for i in range(5)}
433 repo = await _make_repo(db_session)
434 snap = await _make_snapshot(db_session, repo.repo_id, manifest=manifest)
435 await db_session.commit()
436
437 resp = await client.get(
438 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}/entries"
439 "?limit=2",
440 headers=auth_headers,
441 )
442 assert resp.status_code == 200
443 body = resp.json()
444 assert body["total"] == 5
445 assert len(body["entries"]) == 2
446 # First two alphabetically
447 assert body["entries"][0]["path"] == "file_00.py"
448 assert body["entries"][1]["path"] == "file_01.py"
449
450 async def test_entry_count_header_on_entries_endpoint(
451 self,
452 client: AsyncClient,
453 auth_headers: StrDict,
454 db_session: AsyncSession,
455 ) -> None:
456 """X-Snapshot-Entry-Count is set and second page loads via cursor."""
457 manifest = {f"file_{i:02d}.py": f"oid-{i:03d}" for i in range(6)}
458 repo = await _make_repo(db_session)
459 snap = await _make_snapshot(db_session, repo.repo_id, manifest=manifest)
460 await db_session.commit()
461
462 # Get first page to obtain cursor
463 r1 = await client.get(
464 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}/entries"
465 "?limit=3",
466 headers=auth_headers,
467 )
468 assert r1.status_code == 200
469 assert r1.headers["X-Snapshot-Entry-Count"] == "6"
470 cursor = r1.json()["nextCursor"]
471 assert cursor is not None
472
473 # Get second page via cursor
474 r2 = await client.get(
475 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}/entries"
476 f"?limit=3&cursor={cursor}",
477 headers=auth_headers,
478 )
479 assert r2.status_code == 200
480 assert r2.headers["X-Snapshot-Entry-Count"] == "6"
481 assert len(r2.json()["entries"]) == 3
482
483 async def test_link_header_on_entries(
484 self,
485 client: AsyncClient,
486 auth_headers: StrDict,
487 db_session: AsyncSession,
488 ) -> None:
489 """RFC 8288 Link header is present when entries span multiple pages."""
490 manifest = {f"file_{i:02d}.py": f"oid-{i:03d}" for i in range(10)}
491 repo = await _make_repo(db_session)
492 snap = await _make_snapshot(db_session, repo.repo_id, manifest=manifest)
493 await db_session.commit()
494
495 resp = await client.get(
496 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}/entries"
497 "?limit=3",
498 headers=auth_headers,
499 )
500 assert resp.status_code == 200
501 assert 'rel="next"' in resp.headers["Link"]
502
503 async def test_limit_capped_at_200_for_entries(
504 self,
505 client: AsyncClient,
506 auth_headers: StrDict,
507 db_session: AsyncSession,
508 ) -> None:
509 """limit > 200 returns 422 for entries endpoint."""
510 repo = await _make_repo(db_session)
511 snap = await _make_snapshot(db_session, repo.repo_id)
512 await db_session.commit()
513
514 resp = await client.get(
515 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}/entries"
516 "?limit=201",
517 headers=auth_headers,
518 )
519 assert resp.status_code == 422
520
521 async def test_cross_repo_entries_returns_404(
522 self,
523 client: AsyncClient,
524 auth_headers: StrDict,
525 db_session: AsyncSession,
526 ) -> None:
527 """Entries request via wrong repo_id returns 404."""
528 repo_a = await _make_repo(db_session)
529 repo_b = await _make_repo(db_session)
530 snap = await _make_snapshot(db_session, repo_a.repo_id, manifest=_MANIFEST_A)
531 await db_session.commit()
532
533 resp = await client.get(
534 f"/api/repos/{repo_b.repo_id}/snapshots/{snap.snapshot_id}/entries",
535 headers=auth_headers,
536 )
537 assert resp.status_code == 404
538
539
540 # ---------------------------------------------------------------------------
541 # GET /api/repos/{repo_id}/commits/{commit_id}/snapshot — commit shortcut
542 # ---------------------------------------------------------------------------
543
544
545 class TestGetCommitSnapshot:
546 """GET /api/repos/{repo_id}/commits/{commit_id}/snapshot"""
547
548 async def test_resolves_commit_to_snapshot(
549 self,
550 client: AsyncClient,
551 auth_headers: StrDict,
552 db_session: AsyncSession,
553 ) -> None:
554 """Returns the snapshot attached to the commit."""
555 repo = await _make_repo(db_session)
556 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
557 commit = await _make_commit(db_session, repo.repo_id, snapshot_id=snap.snapshot_id)
558 await db_session.commit()
559
560 resp = await client.get(
561 f"/api/repos/{repo.repo_id}/commits/{commit.commit_id}/snapshot",
562 headers=auth_headers,
563 )
564 assert resp.status_code == 200
565 body = resp.json()
566 assert body["snapshotId"] == snap.snapshot_id
567 paths = {e["path"] for e in body["entries"]}
568 assert paths == set(_MANIFEST_A.keys())
569
570 async def test_commit_without_snapshot_returns_404(
571 self,
572 client: AsyncClient,
573 auth_headers: StrDict,
574 db_session: AsyncSession,
575 ) -> None:
576 """Commit with snapshot_id=None returns 404."""
577 repo = await _make_repo(db_session)
578 commit = await _make_commit(db_session, repo.repo_id, snapshot_id=None)
579 await db_session.commit()
580
581 resp = await client.get(
582 f"/api/repos/{repo.repo_id}/commits/{commit.commit_id}/snapshot",
583 headers=auth_headers,
584 )
585 assert resp.status_code == 404
586
587 async def test_unknown_commit_returns_404(
588 self,
589 client: AsyncClient,
590 auth_headers: StrDict,
591 db_session: AsyncSession,
592 ) -> None:
593 """Non-existent commit_id returns 404."""
594 repo = await _make_repo(db_session)
595 await db_session.commit()
596
597 resp = await client.get(
598 f"/api/repos/{repo.repo_id}/commits/{fake_id(_uid())}/snapshot",
599 headers=auth_headers,
600 )
601 assert resp.status_code == 404
602
603 async def test_entry_count_header_is_set(
604 self,
605 client: AsyncClient,
606 auth_headers: StrDict,
607 db_session: AsyncSession,
608 ) -> None:
609 """X-Snapshot-Entry-Count is set on the commit → snapshot shortcut."""
610 repo = await _make_repo(db_session)
611 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
612 commit = await _make_commit(db_session, repo.repo_id, snapshot_id=snap.snapshot_id)
613 await db_session.commit()
614
615 resp = await client.get(
616 f"/api/repos/{repo.repo_id}/commits/{commit.commit_id}/snapshot",
617 headers=auth_headers,
618 )
619 assert resp.status_code == 200
620 assert resp.headers["X-Snapshot-Entry-Count"] == str(len(_MANIFEST_A))
621
622 async def test_cross_repo_commit_returns_404(
623 self,
624 client: AsyncClient,
625 auth_headers: StrDict,
626 db_session: AsyncSession,
627 ) -> None:
628 """Commit from a different repo returns 404."""
629 repo_a = await _make_repo(db_session)
630 repo_b = await _make_repo(db_session)
631 snap = await _make_snapshot(db_session, repo_a.repo_id, manifest=_MANIFEST_A)
632 commit = await _make_commit(db_session, repo_a.repo_id, snapshot_id=snap.snapshot_id)
633 await db_session.commit()
634
635 resp = await client.get(
636 f"/api/repos/{repo_b.repo_id}/commits/{commit.commit_id}/snapshot",
637 headers=auth_headers,
638 )
639 assert resp.status_code == 404
640
641
642 # ---------------------------------------------------------------------------
643 # GET /api/repos/{repo_id}/snapshots/{snapshot_id}/diff — diff
644 # ---------------------------------------------------------------------------
645
646
647 class TestDiffSnapshots:
648 """GET /api/repos/{repo_id}/snapshots/{snapshot_id}/diff?base={base_id}"""
649
650 async def test_diff_counts_added_removed_modified(
651 self,
652 client: AsyncClient,
653 auth_headers: StrDict,
654 db_session: AsyncSession,
655 ) -> None:
656 """Diff between MANIFEST_A (base) and MANIFEST_B (new) is computed correctly.
657
658 MANIFEST_B vs MANIFEST_A:
659 added: muse/core/pack.py
660 removed: muse/core/snapshot.py
661 modified: muse/core/store.py
662 unchanged: tests/test_store.py
663 """
664 repo = await _make_repo(db_session)
665 snap_base = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
666 snap_new = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_B)
667 await db_session.commit()
668
669 resp = await client.get(
670 f"/api/repos/{repo.repo_id}/snapshots/{snap_new.snapshot_id}/diff"
671 f"?base={snap_base.snapshot_id}",
672 headers=auth_headers,
673 )
674 assert resp.status_code == 200
675 body = resp.json()
676 assert body["snapshotId"] == snap_new.snapshot_id
677 assert body["baseSnapshotId"] == snap_base.snapshot_id
678 assert body["addedCount"] == 1
679 assert body["removedCount"] == 1
680 assert body["modifiedCount"] == 1
681 assert body["unchangedCount"] == 1 # tests/test_store.py
682
683 async def test_diff_changes_list_excludes_unchanged_by_default(
684 self,
685 client: AsyncClient,
686 auth_headers: StrDict,
687 db_session: AsyncSession,
688 ) -> None:
689 """changes list does not include unchanged entries unless includeUnchanged=true."""
690 repo = await _make_repo(db_session)
691 snap_base = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
692 snap_new = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_B)
693 await db_session.commit()
694
695 resp = await client.get(
696 f"/api/repos/{repo.repo_id}/snapshots/{snap_new.snapshot_id}/diff"
697 f"?base={snap_base.snapshot_id}",
698 headers=auth_headers,
699 )
700 assert resp.status_code == 200
701 statuses = {e["status"] for e in resp.json()["changes"]}
702 assert "unchanged" not in statuses
703
704 async def test_diff_include_unchanged(
705 self,
706 client: AsyncClient,
707 auth_headers: StrDict,
708 db_session: AsyncSession,
709 ) -> None:
710 """includeUnchanged=true emits unchanged entries."""
711 repo = await _make_repo(db_session)
712 snap_base = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
713 snap_new = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_B)
714 await db_session.commit()
715
716 resp = await client.get(
717 f"/api/repos/{repo.repo_id}/snapshots/{snap_new.snapshot_id}/diff"
718 f"?base={snap_base.snapshot_id}&includeUnchanged=true",
719 headers=auth_headers,
720 )
721 assert resp.status_code == 200
722 statuses = [e["status"] for e in resp.json()["changes"]]
723 assert "unchanged" in statuses
724
725 async def test_diff_same_snapshot_returns_422(
726 self,
727 client: AsyncClient,
728 auth_headers: StrDict,
729 db_session: AsyncSession,
730 ) -> None:
731 """Diffing a snapshot against itself returns 422."""
732 repo = await _make_repo(db_session)
733 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
734 await db_session.commit()
735
736 resp = await client.get(
737 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}/diff"
738 f"?base={snap.snapshot_id}",
739 headers=auth_headers,
740 )
741 assert resp.status_code == 422
742
743 async def test_diff_missing_base_returns_404(
744 self,
745 client: AsyncClient,
746 auth_headers: StrDict,
747 db_session: AsyncSession,
748 ) -> None:
749 """Non-existent base snapshot returns 404."""
750 repo = await _make_repo(db_session)
751 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
752 await db_session.commit()
753
754 resp = await client.get(
755 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}/diff"
756 f"?base={fake_id(_uid())}",
757 headers=auth_headers,
758 )
759 assert resp.status_code == 404
760
761 async def test_diff_missing_base_query_param_returns_422(
762 self,
763 client: AsyncClient,
764 auth_headers: StrDict,
765 db_session: AsyncSession,
766 ) -> None:
767 """Omitting the required 'base' query parameter returns 422."""
768 repo = await _make_repo(db_session)
769 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
770 await db_session.commit()
771
772 resp = await client.get(
773 f"/api/repos/{repo.repo_id}/snapshots/{snap.snapshot_id}/diff",
774 headers=auth_headers,
775 )
776 assert resp.status_code == 422
777
778 async def test_diff_bytes_delta_is_accurate(
779 self,
780 client: AsyncClient,
781 auth_headers: StrDict,
782 db_session: AsyncSession,
783 ) -> None:
784 """bytes_added and bytes_removed are computed from size_bytes of changed entries."""
785 repo = await _make_repo(db_session)
786 snap_base = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
787 snap_new = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_B)
788 await db_session.commit()
789
790 resp = await client.get(
791 f"/api/repos/{repo.repo_id}/snapshots/{snap_new.snapshot_id}/diff"
792 f"?base={snap_base.snapshot_id}",
793 headers=auth_headers,
794 )
795 assert resp.status_code == 200
796 body = resp.json()
797 # bytes_added / bytes_removed are non-negative
798 assert body["bytesAdded"] >= 0
799 assert body["bytesRemoved"] >= 0
800
801 async def test_diff_cross_repo_returns_404(
802 self,
803 client: AsyncClient,
804 auth_headers: StrDict,
805 db_session: AsyncSession,
806 ) -> None:
807 """A base snapshot owned by a different repo returns 404."""
808 repo_a = await _make_repo(db_session)
809 repo_b = await _make_repo(db_session)
810 snap_a = await _make_snapshot(db_session, repo_a.repo_id, manifest=_MANIFEST_A)
811 snap_b = await _make_snapshot(db_session, repo_b.repo_id, manifest=_MANIFEST_B)
812 await db_session.commit()
813
814 # snap_b belongs to repo_b — using it as a base against repo_a's snapshot must fail
815 resp = await client.get(
816 f"/api/repos/{repo_a.repo_id}/snapshots/{snap_a.snapshot_id}/diff"
817 f"?base={snap_b.snapshot_id}",
818 headers=auth_headers,
819 )
820 assert resp.status_code == 404
821
822
823 # ---------------------------------------------------------------------------
824 # POST /api/repos/{repo_id}/snapshots/batch — bulk lookup
825 # ---------------------------------------------------------------------------
826
827
828 class TestBatchGetSnapshots:
829 """POST /api/repos/{repo_id}/snapshots/batch"""
830
831 async def test_batch_summary_mode(
832 self,
833 client: AsyncClient,
834 auth_headers: StrDict,
835 db_session: AsyncSession,
836 ) -> None:
837 """Batch without include_entries returns lightweight summaries."""
838 repo = await _make_repo(db_session)
839 snap_a = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
840 snap_b = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_B)
841 await db_session.commit()
842
843 resp = await client.post(
844 f"/api/repos/{repo.repo_id}/snapshots/batch",
845 json={"snapshotIds": [snap_a.snapshot_id, snap_b.snapshot_id]},
846 headers=auth_headers,
847 )
848 assert resp.status_code == 200
849 results = resp.json()
850 assert len(results) == 2
851 ids = {r["snapshotId"] for r in results}
852 assert snap_a.snapshot_id in ids
853 assert snap_b.snapshot_id in ids
854 # Summaries do not include entries
855 for r in results:
856 assert "entries" not in r
857
858 async def test_batch_full_mode_includes_entries(
859 self,
860 client: AsyncClient,
861 auth_headers: StrDict,
862 db_session: AsyncSession,
863 ) -> None:
864 """include_entries=true returns full SnapshotResponse with entries."""
865 repo = await _make_repo(db_session)
866 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
867 await db_session.commit()
868
869 resp = await client.post(
870 f"/api/repos/{repo.repo_id}/snapshots/batch",
871 json={"snapshotIds": [snap.snapshot_id], "includeEntries": True},
872 headers=auth_headers,
873 )
874 assert resp.status_code == 200
875 result = resp.json()[0]
876 assert result["snapshotId"] == snap.snapshot_id
877 assert "entries" in result
878 assert len(result["entries"]) == len(_MANIFEST_A)
879
880 async def test_batch_unknown_ids_are_omitted(
881 self,
882 client: AsyncClient,
883 auth_headers: StrDict,
884 db_session: AsyncSession,
885 ) -> None:
886 """Unknown snapshot IDs are silently omitted from the result."""
887 repo = await _make_repo(db_session)
888 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
889 await db_session.commit()
890
891 resp = await client.post(
892 f"/api/repos/{repo.repo_id}/snapshots/batch",
893 json={"snapshotIds": [snap.snapshot_id, fake_id(_uid())]},
894 headers=auth_headers,
895 )
896 assert resp.status_code == 200
897 assert len(resp.json()) == 1 # only the known snapshot
898
899 async def test_batch_cross_repo_ids_are_omitted(
900 self,
901 client: AsyncClient,
902 auth_headers: StrDict,
903 db_session: AsyncSession,
904 ) -> None:
905 """Snapshot IDs from a different repo are silently omitted."""
906 repo_a = await _make_repo(db_session)
907 repo_b = await _make_repo(db_session)
908 snap_a = await _make_snapshot(db_session, repo_a.repo_id, manifest=_MANIFEST_A)
909 snap_b = await _make_snapshot(db_session, repo_b.repo_id, manifest=_MANIFEST_B)
910 await db_session.commit()
911
912 # Ask repo_a for both IDs — snap_b belongs to repo_b and must be omitted
913 resp = await client.post(
914 f"/api/repos/{repo_a.repo_id}/snapshots/batch",
915 json={"snapshotIds": [snap_a.snapshot_id, snap_b.snapshot_id]},
916 headers=auth_headers,
917 )
918 assert resp.status_code == 200
919 result_ids = {r["snapshotId"] for r in resp.json()}
920 assert snap_a.snapshot_id in result_ids
921 assert snap_b.snapshot_id not in result_ids
922
923 async def test_batch_exceeds_100_returns_422(
924 self,
925 client: AsyncClient,
926 auth_headers: StrDict,
927 db_session: AsyncSession,
928 ) -> None:
929 """More than 100 snapshot IDs returns 422."""
930 repo = await _make_repo(db_session)
931 await db_session.commit()
932
933 resp = await client.post(
934 f"/api/repos/{repo.repo_id}/snapshots/batch",
935 json={"snapshotIds": [fake_id(str(i)) for i in range(101)]},
936 headers=auth_headers,
937 )
938 assert resp.status_code == 422
939
940 async def test_batch_empty_list_returns_422(
941 self,
942 client: AsyncClient,
943 auth_headers: StrDict,
944 db_session: AsyncSession,
945 ) -> None:
946 """Empty snapshot_ids list returns 422 (min_length=1)."""
947 repo = await _make_repo(db_session)
948 await db_session.commit()
949
950 resp = await client.post(
951 f"/api/repos/{repo.repo_id}/snapshots/batch",
952 json={"snapshotIds": []},
953 headers=auth_headers,
954 )
955 assert resp.status_code == 422
956
957 async def test_batch_private_repo_requires_auth(
958 self,
959 client: AsyncClient,
960 db_session: AsyncSession,
961 ) -> None:
962 """Batch on a private repo without auth returns 401."""
963 repo = await _make_repo(db_session, visibility="private")
964 snap = await _make_snapshot(db_session, repo.repo_id, manifest=_MANIFEST_A)
965 await db_session.commit()
966
967 resp = await client.post(
968 f"/api/repos/{repo.repo_id}/snapshots/batch",
969 json={"snapshotIds": [snap.snapshot_id]},
970 )
971 assert resp.status_code == 401
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago