gabriel / musehub public
test_repository_service.py python
1,163 lines 48.2 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Supplemental tests for the Repository Service — Section 4.
2
3 This file fills the gaps left by the existing test_musehub_repos.py (170 tests).
4 It does NOT duplicate what is already covered there. Focus:
5
6 Coverage layers
7 ───────────────
8 Unit — _generate_slug (all edge cases), _guard_visibility, _guard_owner
9 as pure-function unit tests; resolve_head_ref logic;
10 list_branches_with_detail ahead/behind computation.
11 Integration — get_repo_home_stats (commit counts, 14-day activity array, file
12 count from snapshot); get_recently_pushed_branches; collaborator
13 repos appearing in list_repos_for_user; template copy (private
14 template silently skipped); transfer on soft-deleted repo → None.
15 E2E — GET /api/repos/{repo_id}/stats; GET /api/repos/{repo_id}/branches/detail;
16 GET /api/repos/{repo_id}/snapshots/{snapshot_id};
17 private repo branches/commits → 401 without auth;
18 invalid owner pattern → 422; stats on private repo → 401.
19 Stress — Create and list 50 repos; cursor pagination through 100 repos;
20 200-commit history paging.
21 Data — Soft-delete preserves data in DB; double soft-delete is idempotent;
22 get_repo skips soft-deleted rows; transfer on deleted repo → None;
23 duplicate (owner, slug) → 409 on HTTP, IntegrityError at service level.
24 Security — Invalid owner pattern (spaces, uppercase, leading hyphen) → 422;
25 private branches endpoint → 401; private commits endpoint → 401;
26 private stats endpoint → 401; non-owner delete → 403;
27 non-owner transfer → 403.
28 Performance — _generate_slug 1 000 calls < 100 ms; list_repos_for_user 50 repos
29 < 500 ms; get_repo_home_stats with 200 commits < 500 ms;
30 list_commits 200-row page < 200 ms.
31 """
32 from __future__ import annotations
33
34 import secrets
35 import time
36 from datetime import datetime, timezone
37 from pathlib import Path
38
39 import pytest
40 from httpx import AsyncClient
41 from sqlalchemy.ext.asyncio import AsyncSession
42
43 from musehub.core.genesis import compute_branch_id, compute_collaborator_id, compute_identity_id, compute_repo_id
44 from musehub.db import musehub_models as db
45 from musehub.models.musehub import RepoResponse
46 from tests.factories import create_repo, create_branch, create_commit
47 from musehub.types.json_types import StrDict
48
49
50 # ─────────────────────────────────────────────────────────────────────────────
51 # Layer 1 — Unit: pure functions (no DB, no HTTP)
52 # ─────────────────────────────────────────────────────────────────────────────
53
54 class TestGenerateSlug:
55 """_generate_slug must produce valid URL-safe slugs from arbitrary names."""
56
57 def _slug(self, name: str) -> str:
58 from musehub.services.musehub_repository import _generate_slug
59 return _generate_slug(name)
60
61 def test_lowercase(self) -> None:
62 assert self._slug("Neo Soul Experiment") == "neo-soul-experiment"
63
64 def test_special_chars_collapsed_to_hyphens(self) -> None:
65 assert self._slug("jazz & blues / 2024") == "jazz-blues-2024"
66
67 def test_leading_trailing_hyphens_stripped(self) -> None:
68 assert self._slug("---beats---") == "beats"
69
70 def test_all_symbols_falls_back_to_repo(self) -> None:
71 assert self._slug("!!!@@@###") == "repo"
72
73 def test_empty_string_falls_back_to_repo(self) -> None:
74 assert self._slug("") == "repo"
75
76 def test_max_64_chars(self) -> None:
77 long_name = "a" * 100
78 result = self._slug(long_name)
79 assert len(result) <= 64
80
81 def test_truncation_does_not_leave_trailing_hyphen(self) -> None:
82 # Name that would produce a hyphen right at position 64
83 name = "a" * 63 + "-b" * 10
84 result = self._slug(name)
85 assert not result.endswith("-")
86 assert len(result) <= 64
87
88 def test_numbers_preserved(self) -> None:
89 assert self._slug("track-01") == "track-01"
90
91 def test_consecutive_special_chars_single_hyphen(self) -> None:
92 assert self._slug("a -- b") == "a-b"
93
94 def test_unicode_non_ascii_collapsed(self) -> None:
95 result = self._slug("café")
96 # "café" → "caf-" → "caf" (stripped) or similar — must be alphanumeric+hyphen only
97 assert all(c.isascii() and (c.isalnum() or c == "-") for c in result)
98
99
100 class TestGuardVisibility:
101 """_guard_visibility raises correct HTTP exceptions."""
102
103 def test_raises_404_when_repo_is_none(self) -> None:
104 from fastapi import HTTPException
105 from musehub.api.routes.musehub.repos import _guard_visibility
106 with pytest.raises(HTTPException) as exc_info:
107 _guard_visibility(None, None)
108 assert exc_info.value.status_code == 404
109
110 def test_raises_401_for_private_repo_without_auth(self) -> None:
111 from fastapi import HTTPException
112 from musehub.api.routes.musehub.repos import _guard_visibility
113 from musehub.models.musehub import RepoResponse
114 from datetime import datetime, timezone
115
116 _alice_id = compute_identity_id(b"alice")
117 _ts = datetime.now(tz=timezone.utc)
118 repo = RepoResponse(
119 repo_id=compute_repo_id(_alice_id, "secret", "code", _ts.isoformat()),
120 name="secret",
121 owner="alice",
122 slug="secret",
123 visibility="private",
124 owner_user_id=_alice_id,
125 description="",
126 tags=[],
127 clone_url="musehub://alice/secret",
128 created_at=_ts,
129 updated_at=_ts,
130 default_branch="main",
131 )
132 with pytest.raises(HTTPException) as exc_info:
133 _guard_visibility(repo, None)
134 assert exc_info.value.status_code == 401
135
136 def test_no_raise_for_public_repo_without_auth(self) -> None:
137 from musehub.api.routes.musehub.repos import _guard_visibility
138 from musehub.models.musehub import RepoResponse
139
140 _alice_id2 = compute_identity_id(b"alice")
141 _ts2 = datetime.now(tz=timezone.utc)
142 repo = RepoResponse(
143 repo_id=compute_repo_id(_alice_id2, "open", "code", _ts2.isoformat()),
144 name="open",
145 owner="alice",
146 slug="open",
147 visibility="public",
148 owner_user_id=_alice_id2,
149 description="",
150 tags=[],
151 clone_url="musehub://alice/open",
152 created_at=_ts2,
153 updated_at=_ts2,
154 default_branch="main",
155 )
156 _guard_visibility(repo, None) # must not raise
157
158
159 class TestGuardOwner:
160 """_guard_owner raises correct HTTP exceptions."""
161
162 def _repo(self, owner: str = "alice") -> RepoResponse:
163 _owner_id = compute_identity_id(owner.encode())
164 _ts = datetime.now(tz=timezone.utc)
165 return RepoResponse(
166 repo_id=compute_repo_id(_owner_id, "r", "code", _ts.isoformat()),
167 name="r",
168 owner=owner,
169 slug="r",
170 visibility="public",
171 owner_user_id=_owner_id,
172 description="",
173 tags=[],
174 clone_url=f"musehub://{owner}/r",
175 created_at=_ts,
176 updated_at=_ts,
177 default_branch="main",
178 )
179
180 def test_raises_404_when_repo_is_none(self) -> None:
181 from fastapi import HTTPException
182 from musehub.api.routes.musehub.repos import _guard_owner
183 with pytest.raises(HTTPException) as exc_info:
184 _guard_owner(None, "alice")
185 assert exc_info.value.status_code == 404
186
187 def test_raises_403_for_non_owner(self) -> None:
188 from fastapi import HTTPException
189 from musehub.api.routes.musehub.repos import _guard_owner
190 with pytest.raises(HTTPException) as exc_info:
191 _guard_owner(self._repo("alice"), "bob")
192 assert exc_info.value.status_code == 403
193
194 def test_no_raise_for_owner(self) -> None:
195 from musehub.api.routes.musehub.repos import _guard_owner
196 _guard_owner(self._repo("alice"), "alice") # must not raise
197
198
199 class TestResolveHeadRef:
200 """resolve_head_ref prefers 'main', falls back to first alphabetically."""
201
202 @pytest.mark.asyncio
203 async def test_empty_repo_returns_main(self, db_session: AsyncSession) -> None:
204 from musehub.services import musehub_repository
205 repo = await create_repo(db_session, slug="rhr-empty")
206 result = await musehub_repository.resolve_head_ref(db_session, repo.repo_id)
207 assert result == "main"
208
209 @pytest.mark.asyncio
210 async def test_prefers_main_branch(self, db_session: AsyncSession) -> None:
211 from musehub.services import musehub_repository
212 repo = await create_repo(db_session, slug="rhr-main")
213 await create_branch(db_session, repo.repo_id, name="dev")
214 await create_branch(db_session, repo.repo_id, name="main")
215 result = await musehub_repository.resolve_head_ref(db_session, repo.repo_id)
216 assert result == "main"
217
218 @pytest.mark.asyncio
219 async def test_falls_back_to_first_alpha_when_no_main(
220 self, db_session: AsyncSession
221 ) -> None:
222 from musehub.services import musehub_repository
223 repo = await create_repo(db_session, slug="rhr-alpha")
224 await create_branch(db_session, repo.repo_id, name="dev")
225 await create_branch(db_session, repo.repo_id, name="alpha")
226 result = await musehub_repository.resolve_head_ref(db_session, repo.repo_id)
227 assert result == "alpha" # first alphabetically
228
229
230 class TestListBranchesWithDetail:
231 """list_branches_with_detail computes ahead/behind counts correctly."""
232
233 @pytest.mark.asyncio
234 async def test_empty_repo_returns_empty(self, db_session: AsyncSession) -> None:
235 from musehub.services import musehub_repository
236 repo = await create_repo(db_session, slug="bwd-empty")
237 result = await musehub_repository.list_branches_with_detail(db_session, repo.repo_id)
238 assert result.branches == []
239
240 @pytest.mark.asyncio
241 async def test_default_branch_has_zero_ahead_behind(
242 self, db_session: AsyncSession
243 ) -> None:
244 from musehub.services import musehub_repository
245 repo = await create_repo(db_session, slug="bwd-default")
246 await create_branch(db_session, repo.repo_id, name="main")
247 await create_commit(db_session, repo.repo_id, branch="main")
248
249 result = await musehub_repository.list_branches_with_detail(db_session, repo.repo_id)
250 main_detail = next(b for b in result.branches if b.name == "main")
251 assert main_detail.is_default is True
252 assert main_detail.ahead_count == 0
253 assert main_detail.behind_count == 0
254
255 @pytest.mark.asyncio
256 async def test_feature_branch_ahead_count(self, db_session: AsyncSession) -> None:
257 from musehub.services import musehub_repository
258 repo = await create_repo(db_session, slug="bwd-ahead")
259 await create_branch(db_session, repo.repo_id, name="main")
260 await create_branch(db_session, repo.repo_id, name="feat")
261 # 1 commit on main, 3 on feat
262 await create_commit(db_session, repo.repo_id, branch="main")
263 for _ in range(3):
264 await create_commit(db_session, repo.repo_id, branch="feat")
265
266 result = await musehub_repository.list_branches_with_detail(db_session, repo.repo_id)
267 feat = next(b for b in result.branches if b.name == "feat")
268 # feat has 3 commits not in main → ahead=3; main has 1 commit not in feat → behind=1
269 assert feat.ahead_count == 3
270 assert feat.behind_count == 1
271
272
273 # ─────────────────────────────────────────────────────────────────────────────
274 # Layer 2 — Integration: service layer with real DB
275 # ─────────────────────────────────────────────────────────────────────────────
276
277 class TestGetRepoHomeStats:
278 @pytest.mark.asyncio
279 async def test_empty_repo_returns_zeros(self, db_session: AsyncSession) -> None:
280 from musehub.services import musehub_repository
281 repo = await create_repo(db_session, slug="stats-empty")
282 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
283 assert stats["total_commits"] == 0
284 assert stats["total_objects"] == 0
285 assert stats["total_size_bytes"] == 0
286 assert stats["commit_activity"] == [0] * 14
287
288 @pytest.mark.asyncio
289 async def test_commit_count_reflects_actual_commits(
290 self, db_session: AsyncSession
291 ) -> None:
292 from musehub.services import musehub_repository
293 repo = await create_repo(db_session, slug="stats-commits")
294 for _ in range(5):
295 await create_commit(db_session, repo.repo_id, branch="main")
296
297 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
298 assert stats["total_commits"] == 5
299
300 @pytest.mark.asyncio
301 async def test_activity_array_has_14_entries(self, db_session: AsyncSession) -> None:
302 from musehub.services import musehub_repository
303 repo = await create_repo(db_session, slug="stats-activity")
304 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
305 assert len(stats["commit_activity"]) == 14
306
307 @pytest.mark.asyncio
308 async def test_object_count_and_size_bytes(
309 self, db_session: AsyncSession, tmp_path: Path
310 ) -> None:
311 from musehub.services import musehub_repository
312 repo = await create_repo(db_session, slug="stats-objects")
313 for i in range(3):
314 oid = f"sha256:stats{i}"
315 obj = db.MusehubObject(
316 object_id=oid,
317 path=f"f{i}.bin",
318 size_bytes=100,
319 disk_path=f"/tmp/stats{i}.bin",
320 )
321 db_session.add(obj)
322 db_session.add(db.MusehubObjectRef(repo_id=repo.repo_id, object_id=oid))
323 await db_session.commit()
324
325 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
326 assert stats["total_objects"] == 3
327 assert stats["total_size_bytes"] == 300
328
329
330 class TestGetRecentlyPushedBranches:
331 @pytest.mark.asyncio
332 async def test_no_recent_branches_returns_empty(
333 self, db_session: AsyncSession
334 ) -> None:
335 from musehub.services import musehub_repository
336 repo = await create_repo(db_session, slug="recent-empty")
337 result = await musehub_repository.get_recently_pushed_branches(
338 db_session, repo.repo_id, "main"
339 )
340 assert result == []
341
342 @pytest.mark.asyncio
343 async def test_current_ref_excluded(self, db_session: AsyncSession) -> None:
344 from musehub.services import musehub_repository
345 repo = await create_repo(db_session, slug="recent-exclude")
346 commit = await create_commit(db_session, repo.repo_id, branch="main")
347 await create_branch(db_session, repo.repo_id, name="main",
348 head_commit_id=commit.commit_id)
349 result = await musehub_repository.get_recently_pushed_branches(
350 db_session, repo.repo_id, "main"
351 )
352 assert all(b["name"] != "main" for b in result)
353
354 @pytest.mark.asyncio
355 async def test_recent_branch_appears(self, db_session: AsyncSession) -> None:
356 from musehub.services import musehub_repository
357 repo = await create_repo(db_session, slug="recent-feat")
358 commit = await create_commit(db_session, repo.repo_id, branch="feat")
359 feat = db.MusehubBranch(
360 branch_id=compute_branch_id(repo.repo_id, "feat"),
361 repo_id=repo.repo_id,
362 name="feat",
363 head_commit_id=commit.commit_id,
364 )
365 db_session.add(feat)
366 await db_session.commit()
367
368 result = await musehub_repository.get_recently_pushed_branches(
369 db_session, repo.repo_id, "main", within_hours=72
370 )
371 assert any(b["name"] == "feat" for b in result)
372
373
374 class TestListReposForUserWithCollaborators:
375 @pytest.mark.asyncio
376 async def test_collab_repos_included_in_list(
377 self, db_session: AsyncSession
378 ) -> None:
379 from musehub.services import musehub_repository
380 from musehub.db.musehub_collaborator_models import MusehubCollaborator
381
382 owner_repo = await create_repo(db_session, slug="collab-owned",
383 owner="alice", owner_user_id=compute_identity_id(b"alice"))
384 other_repo = await create_repo(db_session, slug="collab-shared",
385 owner="bob", owner_user_id=compute_identity_id(b"bob"))
386 # alice is an accepted collaborator on bob's repo
387 _accepted_at = datetime.now(tz=timezone.utc)
388 collab = MusehubCollaborator(
389 id=compute_collaborator_id(other_repo.repo_id, compute_identity_id(b"alice"), _accepted_at.isoformat()),
390 repo_id=other_repo.repo_id,
391 identity_handle="alice",
392 permission="read",
393 accepted_at=_accepted_at,
394 )
395 db_session.add(collab)
396 await db_session.commit()
397
398 result = await musehub_repository.list_repos_for_user(db_session, "alice")
399 repo_ids = [r.repo_id for r in result.repos]
400 assert owner_repo.repo_id in repo_ids
401 assert other_repo.repo_id in repo_ids
402
403 @pytest.mark.asyncio
404 async def test_unaccepted_collab_not_included(
405 self, db_session: AsyncSession
406 ) -> None:
407 from musehub.services import musehub_repository
408 from musehub.db.musehub_collaborator_models import MusehubCollaborator
409
410 other_repo = await create_repo(db_session, slug="collab-pending",
411 owner="carol", owner_user_id=compute_identity_id(b"carol"))
412 _invited_at = datetime.now(tz=timezone.utc)
413 collab = MusehubCollaborator(
414 id=compute_collaborator_id(other_repo.repo_id, compute_identity_id(b"dave"), _invited_at.isoformat()),
415 repo_id=other_repo.repo_id,
416 identity_handle="dave",
417 permission="read",
418 accepted_at=None, # invitation not yet accepted
419 )
420 db_session.add(collab)
421 await db_session.commit()
422
423 result = await musehub_repository.list_repos_for_user(db_session, "dave")
424 assert all(r.repo_id != other_repo.repo_id for r in result.repos)
425
426
427 class TestTemplateRepoCopy:
428 @pytest.mark.asyncio
429 async def test_private_template_not_copied(self, db_session: AsyncSession) -> None:
430 from musehub.services import musehub_repository
431 tmpl = await create_repo(db_session, slug="tmpl-priv",
432 visibility="private", owner="alice",
433 owner_user_id=compute_identity_id(b"alice"))
434 # Give template a description
435 tmpl_row = await db_session.get(db.MusehubRepo, tmpl.repo_id)
436 assert tmpl_row is not None
437 tmpl_row.description = "Private description"
438 await db_session.commit()
439
440 new_repo = await musehub_repository.create_repo(
441 db_session,
442 name="my-new-repo",
443 owner="bob",
444 visibility="public",
445 owner_user_id=compute_identity_id(b"bob"),
446 template_repo_id=tmpl.repo_id,
447 )
448 await db_session.commit()
449 assert new_repo.description == "" # private template not applied
450
451
452 # ─────────────────────────────────────────────────────────────────────────────
453 # Layer 3 — E2E: HTTP endpoints not covered in test_musehub_repos.py
454 # ─────────────────────────────────────────────────────────────────────────────
455
456 class TestRepoStatsEndpoint:
457 @pytest.mark.asyncio
458 async def test_empty_repo_returns_zero_counts(
459 self, client: AsyncClient, db_session: AsyncSession
460 ) -> None:
461 repo = await create_repo(db_session, slug="e2e-stats-empty", visibility="public")
462 resp = await client.get(f"/api/repos/{repo.repo_id}/stats")
463 assert resp.status_code == 200
464 body = resp.json()
465 assert body["commitCount"] == 0
466 assert body["branchCount"] == 0
467 assert body["releaseCount"] == 0
468
469 @pytest.mark.asyncio
470 async def test_counts_reflect_data(
471 self, client: AsyncClient, db_session: AsyncSession
472 ) -> None:
473 repo = await create_repo(db_session, slug="e2e-stats-data", visibility="public")
474 await create_branch(db_session, repo.repo_id, name="main")
475 await create_branch(db_session, repo.repo_id, name="dev")
476 await create_commit(db_session, repo.repo_id, branch="main")
477
478 resp = await client.get(f"/api/repos/{repo.repo_id}/stats")
479 assert resp.status_code == 200
480 body = resp.json()
481 assert body["commitCount"] == 1
482 assert body["branchCount"] == 2
483
484 @pytest.mark.asyncio
485 async def test_unknown_repo_returns_404(
486 self, client: AsyncClient, db_session: AsyncSession
487 ) -> None:
488 resp = await client.get(f"/api/repos/{secrets.token_hex(16)}/stats")
489 assert resp.status_code == 404
490
491 @pytest.mark.asyncio
492 async def test_private_repo_without_auth_returns_401(
493 self, client: AsyncClient, db_session: AsyncSession
494 ) -> None:
495 repo = await create_repo(db_session, slug="e2e-stats-priv", visibility="private")
496 resp = await client.get(f"/api/repos/{repo.repo_id}/stats")
497 assert resp.status_code == 401
498
499
500 class TestBranchDetailEndpoint:
501 @pytest.mark.asyncio
502 async def test_returns_branch_list_with_detail(
503 self, client: AsyncClient, db_session: AsyncSession
504 ) -> None:
505 repo = await create_repo(db_session, slug="e2e-bwd-ok", visibility="public")
506 await create_branch(db_session, repo.repo_id, name="main")
507 await create_commit(db_session, repo.repo_id, branch="main")
508
509 resp = await client.get(f"/api/repos/{repo.repo_id}/branches/detail")
510 assert resp.status_code == 200
511 body = resp.json()
512 assert "branches" in body
513 assert "defaultBranch" in body
514 assert len(body["branches"]) == 1
515 branch = body["branches"][0]
516 assert branch["name"] == "main"
517 assert branch["isDefault"] is True
518 assert branch["aheadCount"] == 0
519 assert branch["behindCount"] == 0
520
521 @pytest.mark.asyncio
522 async def test_unknown_repo_returns_404(
523 self, client: AsyncClient, db_session: AsyncSession
524 ) -> None:
525 resp = await client.get(f"/api/repos/{secrets.token_hex(16)}/branches/detail")
526 assert resp.status_code == 404
527
528 @pytest.mark.asyncio
529 async def test_private_repo_without_auth_returns_401(
530 self, client: AsyncClient, db_session: AsyncSession
531 ) -> None:
532 repo = await create_repo(db_session, slug="e2e-bwd-priv", visibility="private")
533 resp = await client.get(f"/api/repos/{repo.repo_id}/branches/detail")
534 assert resp.status_code == 401
535
536
537 class TestSnapshotManifestEndpoint:
538 @pytest.mark.asyncio
539 async def test_returns_manifest(
540 self, client: AsyncClient, db_session: AsyncSession
541 ) -> None:
542 import msgpack
543 repo = await create_repo(db_session, slug="e2e-snap-ok", visibility="public")
544 snap_id = f"snap-{secrets.token_hex(4)}"
545 manifest = {"main.py": "sha256:abc"}
546 manifest_blob = msgpack.packb(manifest, use_bin_type=True)
547 snap = db.MusehubSnapshot(
548 snapshot_id=snap_id,
549 repo_id=repo.repo_id,
550 manifest_blob=manifest_blob,
551 entry_count=1,
552 )
553 db_session.add(snap)
554 await db_session.commit()
555
556 resp = await client.get(f"/api/repos/{repo.repo_id}/snapshots/{snap_id}")
557 assert resp.status_code == 200
558 body = resp.json()
559 assert body["snapshotId"] == snap_id
560 entry_paths = [e["path"] for e in body.get("entries", [])]
561 assert "main.py" in entry_paths
562
563 @pytest.mark.asyncio
564 async def test_unknown_snapshot_returns_404(
565 self, client: AsyncClient, db_session: AsyncSession
566 ) -> None:
567 repo = await create_repo(db_session, slug="e2e-snap-404", visibility="public")
568 resp = await client.get(f"/api/repos/{repo.repo_id}/snapshots/ghost-snap")
569 assert resp.status_code == 404
570
571
572 class TestPrivateRepoBranchesAndCommits:
573 @pytest.mark.asyncio
574 async def test_private_repo_branches_without_auth_returns_401(
575 self, client: AsyncClient, db_session: AsyncSession
576 ) -> None:
577 repo = await create_repo(db_session, slug="e2e-priv-branches", visibility="private")
578 resp = await client.get(f"/api/repos/{repo.repo_id}/branches")
579 assert resp.status_code == 401
580
581 @pytest.mark.asyncio
582 async def test_private_repo_commits_without_auth_returns_401(
583 self, client: AsyncClient, db_session: AsyncSession
584 ) -> None:
585 repo = await create_repo(db_session, slug="e2e-priv-commits", visibility="private")
586 resp = await client.get(f"/api/repos/{repo.repo_id}/commits")
587 assert resp.status_code == 401
588
589
590 class TestCreateRepoValidation:
591 @pytest.mark.asyncio
592 async def test_invalid_owner_with_spaces_returns_422(
593 self,
594 client: AsyncClient,
595 auth_headers: StrDict,
596 ) -> None:
597 resp = await client.post(
598 "/api/repos",
599 json={"name": "my-repo", "owner": "alice bob"},
600 headers=auth_headers,
601 )
602 assert resp.status_code == 422
603
604 @pytest.mark.asyncio
605 async def test_invalid_owner_uppercase_returns_422(
606 self,
607 client: AsyncClient,
608 auth_headers: StrDict,
609 ) -> None:
610 resp = await client.post(
611 "/api/repos",
612 json={"name": "my-repo", "owner": "Alice"},
613 headers=auth_headers,
614 )
615 assert resp.status_code == 422
616
617 @pytest.mark.asyncio
618 async def test_invalid_owner_leading_hyphen_returns_422(
619 self,
620 client: AsyncClient,
621 auth_headers: StrDict,
622 ) -> None:
623 resp = await client.post(
624 "/api/repos",
625 json={"name": "my-repo", "owner": "-alice"},
626 headers=auth_headers,
627 )
628 assert resp.status_code == 422
629
630 @pytest.mark.asyncio
631 async def test_empty_name_returns_422(
632 self,
633 client: AsyncClient,
634 auth_headers: StrDict,
635 ) -> None:
636 resp = await client.post(
637 "/api/repos",
638 json={"name": "", "owner": "testuser"},
639 headers=auth_headers,
640 )
641 assert resp.status_code == 422
642
643
644 # ─────────────────────────────────────────────────────────────────────────────
645 # Layer 4 — Stress
646 # ─────────────────────────────────────────────────────────────────────────────
647
648 class TestRepositoryServiceStress:
649 @pytest.mark.asyncio
650 async def test_create_50_repos_and_list_all(
651 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
652 ) -> None:
653 """Create 50 repos via HTTP; list must report total=50."""
654 COUNT = 50
655 for i in range(COUNT):
656 resp = await client.post(
657 "/api/repos",
658 json={"name": f"stress-repo-{i:03d}", "owner": "testuser",
659 "visibility": "public"},
660 headers=auth_headers,
661 )
662 assert resp.status_code == 201
663
664 resp = await client.get("/api/repos?limit=100", headers=auth_headers)
665 assert resp.status_code == 200
666 body = resp.json()
667 assert body["total"] >= COUNT
668
669 @pytest.mark.asyncio
670 async def test_cursor_pagination_traverses_all_repos(
671 self, db_session: AsyncSession
672 ) -> None:
673 """Insert 100 repos; cursor pagination must visit all of them."""
674 from musehub.services import musehub_repository
675
676 TOTAL = 100
677 owner_id = f"paginator-{secrets.token_hex(4)}"
678 for i in range(TOTAL):
679 await create_repo(db_session, slug=f"page-{i:03d}",
680 owner=owner_id, owner_user_id=owner_id)
681
682 collected: list[str] = []
683 cursor: str | None = None
684 while True:
685 page = await musehub_repository.list_repos_for_user(
686 db_session, owner_id, limit=10, cursor=cursor
687 )
688 collected.extend(r.repo_id for r in page.repos)
689 cursor = page.next_cursor
690 if cursor is None:
691 break
692
693 assert len(collected) == TOTAL
694 assert len(set(collected)) == TOTAL # no duplicates
695
696 @pytest.mark.asyncio
697 async def test_200_commit_history_pageable(self, db_session: AsyncSession) -> None:
698 """Push 200 commits; paging through them must yield all without duplicates."""
699 from musehub.services import musehub_repository
700
701 repo = await create_repo(db_session, slug="stress-200c")
702 for _ in range(200):
703 await create_commit(db_session, repo.repo_id, branch="main")
704
705 all_ids: list[str] = []
706 cursor: str | None = None
707 per_page = 50
708 for _ in range(1, 5):
709 result = await musehub_repository.list_commits(
710 db_session, repo.repo_id, limit=per_page, cursor=cursor
711 )
712 all_ids.extend(c.commit_id for c in result.commits)
713 cursor = result.next_cursor
714 if cursor is None:
715 break
716
717 assert result.total == 200
718 assert len(all_ids) == 200
719 assert len(set(all_ids)) == 200 # no duplicates across pages
720
721
722 # ─────────────────────────────────────────────────────────────────────────────
723 # Layer 5 — Data Integrity
724 # ─────────────────────────────────────────────────────────────────────────────
725
726 class TestDataIntegrity:
727 @pytest.mark.asyncio
728 async def test_delete_hard_deletes_row(
729 self, db_session: AsyncSession
730 ) -> None:
731 from musehub.services import musehub_repository
732
733 repo = await create_repo(db_session, slug="del-preserve")
734 repo_id = repo.repo_id
735 deleted = await musehub_repository.delete_repo(db_session, repo_id)
736 await db_session.commit()
737
738 assert deleted is True
739 # Row must be completely gone from the DB
740 row = await db_session.get(db.MusehubRepo, repo_id)
741 assert row is None
742
743 @pytest.mark.asyncio
744 async def test_double_soft_delete_is_idempotent(
745 self, db_session: AsyncSession
746 ) -> None:
747 from musehub.services import musehub_repository
748
749 repo = await create_repo(db_session, slug="del-idempotent")
750 first = await musehub_repository.delete_repo(db_session, repo.repo_id)
751 await db_session.commit()
752 second = await musehub_repository.delete_repo(db_session, repo.repo_id)
753 await db_session.commit()
754
755 assert first is True
756 assert second is False # already deleted
757
758 @pytest.mark.asyncio
759 async def test_transfer_on_deleted_repo_returns_none(
760 self, db_session: AsyncSession
761 ) -> None:
762 from musehub.services import musehub_repository
763
764 repo = await create_repo(db_session, slug="del-transfer")
765 await musehub_repository.delete_repo(db_session, repo.repo_id)
766 await db_session.commit()
767
768 result = await musehub_repository.transfer_repo_ownership(
769 db_session, repo.repo_id, "new-owner"
770 )
771 assert result is None
772
773 @pytest.mark.asyncio
774 async def test_duplicate_owner_slug_returns_409_via_http(
775 self,
776 client: AsyncClient,
777 db_session: AsyncSession,
778 auth_headers: StrDict,
779 ) -> None:
780 payload = {"name": "duplicate-name", "owner": "testuser"}
781 resp1 = await client.post("/api/repos", json=payload, headers=auth_headers)
782 assert resp1.status_code == 201
783 resp2 = await client.post("/api/repos", json=payload, headers=auth_headers)
784 assert resp2.status_code == 409
785
786 @pytest.mark.asyncio
787 async def test_create_repo_service_sets_correct_slug(
788 self, db_session: AsyncSession
789 ) -> None:
790 from musehub.services import musehub_repository
791
792 repo = await musehub_repository.create_repo(
793 db_session,
794 name="My Jazz Experiment!",
795 owner="gabriel",
796 visibility="public",
797 owner_user_id=compute_identity_id(b"gabriel"),
798 )
799 await db_session.commit()
800 assert repo.slug == "my-jazz-experiment"
801
802
803 # ─────────────────────────────────────────────────────────────────────────────
804 # Layer 6 — Security
805 # ─────────────────────────────────────────────────────────────────────────────
806
807 class TestSecurity:
808 @pytest.mark.asyncio
809 async def test_non_owner_delete_returns_403(
810 self,
811 client: AsyncClient,
812 db_session: AsyncSession,
813 auth_headers: StrDict,
814 ) -> None:
815 """Only owner may delete — authenticated non-owner gets 403."""
816 # Create a repo owned by someone else
817 _other_id = compute_identity_id(b"other-user")
818 _now = datetime.now(tz=timezone.utc)
819 repo_row = db.MusehubRepo(
820 repo_id=compute_repo_id(_other_id, "not-mine", "code", _now.isoformat()),
821 name="not-mine",
822 owner="other-user",
823 slug="not-mine",
824 visibility="public",
825 owner_user_id=_other_id,
826 description="",
827 tags=[],
828 created_at=_now,
829 updated_at=_now,
830 )
831 db_session.add(repo_row)
832 await db_session.commit()
833
834 resp = await client.delete(
835 f"/api/repos/{repo_row.repo_id}", headers=auth_headers
836 )
837 assert resp.status_code == 403
838
839 @pytest.mark.asyncio
840 async def test_non_owner_transfer_returns_403(
841 self,
842 client: AsyncClient,
843 db_session: AsyncSession,
844 auth_headers: StrDict,
845 ) -> None:
846 _stranger_id = compute_identity_id(b"stranger")
847 _now2 = datetime.now(tz=timezone.utc)
848 repo_row = db.MusehubRepo(
849 repo_id=compute_repo_id(_stranger_id, "no-transfer", "code", _now2.isoformat()),
850 name="no-transfer",
851 owner="stranger",
852 slug="no-transfer",
853 visibility="public",
854 owner_user_id=_stranger_id,
855 description="",
856 tags=[],
857 created_at=_now2,
858 updated_at=_now2,
859 )
860 db_session.add(repo_row)
861 await db_session.commit()
862
863 resp = await client.post(
864 f"/api/repos/{repo_row.repo_id}/transfer",
865 json={"newOwnerUserId": "hacker"},
866 headers=auth_headers,
867 )
868 assert resp.status_code == 403
869
870 @pytest.mark.asyncio
871 async def test_private_repo_get_without_auth_returns_401(
872 self, client: AsyncClient, db_session: AsyncSession
873 ) -> None:
874 repo = await create_repo(db_session, slug="sec-priv-get", visibility="private")
875 resp = await client.get(f"/api/repos/{repo.repo_id}")
876 assert resp.status_code == 401
877
878 @pytest.mark.asyncio
879 async def test_delete_requires_auth(
880 self, client: AsyncClient, db_session: AsyncSession
881 ) -> None:
882 repo = await create_repo(db_session, slug="sec-del-noauth", visibility="public")
883 resp = await client.delete(f"/api/repos/{repo.repo_id}")
884 assert resp.status_code == 401
885
886 @pytest.mark.asyncio
887 async def test_transfer_requires_auth(
888 self, client: AsyncClient, db_session: AsyncSession
889 ) -> None:
890 repo = await create_repo(db_session, slug="sec-xfer-noauth", visibility="public")
891 resp = await client.post(
892 f"/api/repos/{repo.repo_id}/transfer",
893 json={"newOwnerUserId": "anyone"},
894 )
895 assert resp.status_code == 401
896
897
898 # ─────────────────────────────────────────────────────────────────────────────
899 # Layer 7 — Performance
900 # ─────────────────────────────────────────────────────────────────────────────
901
902 class TestPerformance:
903 def test_generate_slug_1000_calls_under_100ms(self) -> None:
904 from musehub.services.musehub_repository import _generate_slug
905 names = [f"My Repo Number {i} — Special Édition!" for i in range(1000)]
906 t0 = time.perf_counter()
907 for name in names:
908 _generate_slug(name)
909 elapsed_ms = (time.perf_counter() - t0) * 1000
910 assert elapsed_ms < 100, f"1000 slug calls took {elapsed_ms:.1f}ms > 100ms"
911
912 @pytest.mark.asyncio
913 async def test_list_repos_50_users_under_500ms(
914 self, db_session: AsyncSession
915 ) -> None:
916 from musehub.services import musehub_repository
917
918 uid = f"perf-user-{secrets.token_hex(4)}"
919 for i in range(50):
920 await create_repo(db_session, slug=f"perf-r{i:02d}",
921 owner=uid, owner_user_id=uid)
922
923 # Warm-up
924 await musehub_repository.list_repos_for_user(db_session, uid, limit=50)
925
926 t0 = time.perf_counter()
927 result = await musehub_repository.list_repos_for_user(db_session, uid, limit=50)
928 elapsed_ms = (time.perf_counter() - t0) * 1000
929 assert len(result.repos) == 50
930 assert elapsed_ms < 500, f"list_repos 50 items took {elapsed_ms:.1f}ms > 500ms"
931
932 @pytest.mark.asyncio
933 async def test_get_repo_home_stats_200_commits_under_500ms(
934 self, db_session: AsyncSession
935 ) -> None:
936 from musehub.services import musehub_repository
937
938 repo = await create_repo(db_session, slug="perf-stats-200c")
939 for _ in range(200):
940 await create_commit(db_session, repo.repo_id, branch="main")
941
942 # Warm-up
943 await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
944
945 t0 = time.perf_counter()
946 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
947 elapsed_ms = (time.perf_counter() - t0) * 1000
948 assert stats["total_commits"] == 200
949 assert elapsed_ms < 500, f"get_repo_home_stats took {elapsed_ms:.1f}ms > 500ms"
950
951 @pytest.mark.asyncio
952 async def test_list_commits_200_rows_under_200ms(
953 self, db_session: AsyncSession
954 ) -> None:
955 from musehub.services import musehub_repository
956
957 repo = await create_repo(db_session, slug="perf-commits-200")
958 for _ in range(200):
959 await create_commit(db_session, repo.repo_id, branch="main")
960
961 # Warm-up
962 await musehub_repository.list_commits(db_session, repo.repo_id, limit=200)
963
964 t0 = time.perf_counter()
965 result = await musehub_repository.list_commits(
966 db_session, repo.repo_id, limit=200
967 )
968 elapsed_ms = (time.perf_counter() - t0) * 1000
969 assert result.total == 200
970 assert len(result.commits) == 200
971 assert elapsed_ms < 200, f"list_commits 200 rows took {elapsed_ms:.1f}ms > 200ms"
972
973
974 # ─────────────────────────────────────────────────────────────────────────────
975 # Layer 8 — Regression: soft-deleted repos must not surface via owner/slug path
976 # ─────────────────────────────────────────────────────────────────────────────
977
978 class TestDeleteOwnerSlugRegression:
979 """Regression suite: hard-deleted repos must not surface via owner/slug path."""
980
981 @pytest.mark.asyncio
982 async def test_owner_slug_http_returns_404_for_deleted_repo(
983 self, client: AsyncClient, db_session: AsyncSession
984 ) -> None:
985 """HTTP /{owner}/{slug} must return 404 for a hard-deleted repo."""
986 repo = await create_repo(db_session, slug="http-deleted", owner="httpuser",
987 owner_user_id=compute_identity_id(b"httpuser"), visibility="public")
988 await db_session.delete(repo)
989 await db_session.commit()
990
991 resp = await client.get("/api/httpuser/http-deleted")
992 assert resp.status_code == 404, (
993 f"Expected 404 for hard-deleted repo via /owner/slug path, got {resp.status_code}"
994 )
995
996
997 # ─────────────────────────────────────────────────────────────────────────────
998 # Layer 9 — domain_id always persisted on creation
999 # ─────────────────────────────────────────────────────────────────────────────
1000
1001 class TestDomainIdAlwaysPersisted:
1002 """domain_id must be written to the DB row on every creation path.
1003
1004 Previously create_repo() used the domain arg only for compute_repo_id()
1005 but never stored it — leaving domain_id NULL for every normal repo.
1006 """
1007
1008 @pytest.mark.asyncio
1009 async def test_create_repo_explicit_domain_stored(
1010 self, db_session: AsyncSession
1011 ) -> None:
1012 """When caller passes domain='midi', domain_id='midi' is on the DB row."""
1013 from musehub.services.musehub_repository import create_repo as svc_create_repo
1014 result = await svc_create_repo(
1015 db_session,
1016 name="midi-test",
1017 owner="testuser",
1018 visibility="public",
1019 owner_user_id="testuser",
1020 owner_identity_id="testuser",
1021 domain="midi",
1022 )
1023 await db_session.commit()
1024 row = await db_session.get(db.MusehubRepo, result.repo_id)
1025 assert row is not None
1026 assert row.domain_id == "midi"
1027
1028 @pytest.mark.asyncio
1029 async def test_create_repo_no_domain_defaults_to_code(
1030 self, db_session: AsyncSession
1031 ) -> None:
1032 """When no domain is passed, domain_id='code' is stored — never NULL."""
1033 from musehub.services.musehub_repository import create_repo as svc_create_repo
1034 result = await svc_create_repo(
1035 db_session,
1036 name="no-domain-test",
1037 owner="testuser",
1038 visibility="public",
1039 owner_user_id="testuser",
1040 owner_identity_id="testuser",
1041 )
1042 await db_session.commit()
1043 row = await db_session.get(db.MusehubRepo, result.repo_id)
1044 assert row is not None
1045 assert row.domain_id == "code"
1046
1047 @pytest.mark.asyncio
1048 async def test_create_repo_empty_domain_defaults_to_code(
1049 self, db_session: AsyncSession
1050 ) -> None:
1051 """Explicit empty-string domain also resolves to 'code', never NULL."""
1052 from musehub.services.musehub_repository import create_repo as svc_create_repo
1053 result = await svc_create_repo(
1054 db_session,
1055 name="empty-domain-test",
1056 owner="testuser",
1057 visibility="public",
1058 owner_user_id="testuser",
1059 owner_identity_id="testuser",
1060 domain="",
1061 )
1062 await db_session.commit()
1063 row = await db_session.get(db.MusehubRepo, result.repo_id)
1064 assert row is not None
1065 assert row.domain_id == "code"
1066
1067 @pytest.mark.asyncio
1068 async def test_fork_inherits_source_domain(
1069 self, db_session: AsyncSession
1070 ) -> None:
1071 """Forking a midi repo produces a fork with domain_id='midi'."""
1072 from musehub.services.musehub_repository import create_repo as svc_create_repo, fork_repo
1073 from musehub.models.musehub import ForkRepoRequest
1074
1075 source = await svc_create_repo(
1076 db_session,
1077 name="source-midi",
1078 owner="sourceuser",
1079 visibility="public",
1080 owner_user_id="sourceuser",
1081 owner_identity_id="sourceuser",
1082 domain="midi",
1083 )
1084 await db_session.commit()
1085
1086 fork_result = await fork_repo(
1087 db_session,
1088 source_repo_id=source.repo_id,
1089 forked_by_handle="forkuser",
1090 request=ForkRepoRequest(),
1091 )
1092 await db_session.commit()
1093 fork_row = await db_session.get(db.MusehubRepo, fork_result.fork_repo.repo_id)
1094 assert fork_row is not None
1095 assert fork_row.domain_id == "midi"
1096
1097 @pytest.mark.asyncio
1098 async def test_fork_source_null_domain_becomes_code(
1099 self, db_session: AsyncSession
1100 ) -> None:
1101 """Forking a repo whose domain_id is NULL in DB produces fork with domain_id='code'."""
1102 from musehub.services.musehub_repository import fork_repo
1103 from musehub.models.musehub import ForkRepoRequest
1104
1105 # Simulate a legacy row with NULL domain_id
1106 created_at = datetime.now(tz=timezone.utc)
1107 legacy = db.MusehubRepo(
1108 repo_id=compute_repo_id("legacyuser", "legacy-repo", "muse/generic", created_at.isoformat()),
1109 name="legacy-repo",
1110 owner="legacyuser",
1111 slug="legacy-repo",
1112 visibility="public",
1113 owner_user_id="legacyuser",
1114 domain_id=None,
1115 created_at=created_at,
1116 updated_at=created_at,
1117 )
1118 db_session.add(legacy)
1119 await db_session.commit()
1120
1121 fork_result = await fork_repo(
1122 db_session,
1123 source_repo_id=legacy.repo_id,
1124 forked_by_handle="forkuser2",
1125 request=ForkRepoRequest(),
1126 )
1127 await db_session.commit()
1128 fork_row = await db_session.get(db.MusehubRepo, fork_result.fork_repo.repo_id)
1129 assert fork_row is not None
1130 assert fork_row.domain_id == "code"
1131
1132 @pytest.mark.asyncio
1133 async def test_api_create_repo_domain_stored(
1134 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1135 ) -> None:
1136 """POST /api/repos with domain='midi' stores domain_id='midi' on the DB row."""
1137 resp = await client.post(
1138 "/api/repos",
1139 json={"name": "api-midi-repo", "owner": "testuser", "visibility": "public", "domain": "midi"},
1140 headers=auth_headers,
1141 )
1142 assert resp.status_code == 201
1143 repo_id = resp.json()["repoId"]
1144 row = await db_session.get(db.MusehubRepo, repo_id)
1145 assert row is not None
1146 assert row.domain_id == "midi"
1147
1148 @pytest.mark.asyncio
1149 async def test_api_create_repo_no_domain_defaults_to_code(
1150 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1151 ) -> None:
1152 """POST /api/repos without domain field stores domain_id='code' on the DB row."""
1153 resp = await client.post(
1154 "/api/repos",
1155 json={"name": "api-no-domain-repo", "owner": "testuser", "visibility": "public"},
1156 headers=auth_headers,
1157 )
1158 assert resp.status_code == 201
1159 repo_id = resp.json()["repoId"]
1160 row = await db_session.get(db.MusehubRepo, repo_id)
1161 assert row is not None
1162 assert row.domain_id == "code"
1163
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago