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