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