gabriel / musehub public
test_repository_service.py python
938 lines 38.5 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 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 time
35 import uuid
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.db import musehub_models as db
44 from musehub.models.musehub import RepoResponse
45 from tests.factories import create_repo, create_branch, create_commit
46 from musehub.muse_contracts.json_types import StrDict
47
48
49 # ─────────────────────────────────────────────────────────────────────────────
50 # Layer 1 — Unit: pure functions (no DB, no HTTP)
51 # ─────────────────────────────────────────────────────────────────────────────
52
53 class TestGenerateSlug:
54 """_generate_slug must produce valid URL-safe slugs from arbitrary names."""
55
56 def _slug(self, name: str) -> str:
57 from musehub.services.musehub_repository import _generate_slug
58 return _generate_slug(name)
59
60 def test_lowercase(self) -> None:
61 assert self._slug("Neo Soul Experiment") == "neo-soul-experiment"
62
63 def test_special_chars_collapsed_to_hyphens(self) -> None:
64 assert self._slug("jazz & blues / 2024") == "jazz-blues-2024"
65
66 def test_leading_trailing_hyphens_stripped(self) -> None:
67 assert self._slug("---beats---") == "beats"
68
69 def test_all_symbols_falls_back_to_repo(self) -> None:
70 assert self._slug("!!!@@@###") == "repo"
71
72 def test_empty_string_falls_back_to_repo(self) -> None:
73 assert self._slug("") == "repo"
74
75 def test_max_64_chars(self) -> None:
76 long_name = "a" * 100
77 result = self._slug(long_name)
78 assert len(result) <= 64
79
80 def test_truncation_does_not_leave_trailing_hyphen(self) -> None:
81 # Name that would produce a hyphen right at position 64
82 name = "a" * 63 + "-b" * 10
83 result = self._slug(name)
84 assert not result.endswith("-")
85 assert len(result) <= 64
86
87 def test_numbers_preserved(self) -> None:
88 assert self._slug("track-01") == "track-01"
89
90 def test_consecutive_special_chars_single_hyphen(self) -> None:
91 assert self._slug("a -- b") == "a-b"
92
93 def test_unicode_non_ascii_collapsed(self) -> None:
94 result = self._slug("café")
95 # "café" → "caf-" → "caf" (stripped) or similar — must be alphanumeric+hyphen only
96 assert all(c.isascii() and (c.isalnum() or c == "-") for c in result)
97
98
99 class TestGuardVisibility:
100 """_guard_visibility raises correct HTTP exceptions."""
101
102 def test_raises_404_when_repo_is_none(self) -> None:
103 from fastapi import HTTPException
104 from musehub.api.routes.musehub.repos import _guard_visibility
105 with pytest.raises(HTTPException) as exc_info:
106 _guard_visibility(None, None)
107 assert exc_info.value.status_code == 404
108
109 def test_raises_401_for_private_repo_without_auth(self) -> None:
110 from fastapi import HTTPException
111 from musehub.api.routes.musehub.repos import _guard_visibility
112 from musehub.models.musehub import RepoResponse
113 from datetime import datetime, timezone
114
115 repo = RepoResponse(
116 repo_id=str(uuid.uuid4()),
117 name="secret",
118 owner="alice",
119 slug="secret",
120 visibility="private",
121 owner_user_id="alice",
122 description="",
123 tags=[],
124 clone_url="musehub://alice/secret",
125 created_at=datetime.now(tz=timezone.utc),
126 default_branch="main",
127 )
128 with pytest.raises(HTTPException) as exc_info:
129 _guard_visibility(repo, None)
130 assert exc_info.value.status_code == 401
131
132 def test_no_raise_for_public_repo_without_auth(self) -> None:
133 from musehub.api.routes.musehub.repos import _guard_visibility
134 from musehub.models.musehub import RepoResponse
135
136 repo = RepoResponse(
137 repo_id=str(uuid.uuid4()),
138 name="open",
139 owner="alice",
140 slug="open",
141 visibility="public",
142 owner_user_id="alice",
143 description="",
144 tags=[],
145 clone_url="musehub://alice/open",
146 created_at=datetime.now(tz=timezone.utc),
147 default_branch="main",
148 )
149 _guard_visibility(repo, None) # must not raise
150
151
152 class TestGuardOwner:
153 """_guard_owner raises correct HTTP exceptions."""
154
155 def _repo(self, owner: str = "alice") -> RepoResponse:
156 return RepoResponse(
157 repo_id=str(uuid.uuid4()),
158 name="r",
159 owner=owner,
160 slug="r",
161 visibility="public",
162 owner_user_id=owner,
163 description="",
164 tags=[],
165 clone_url=f"musehub://{owner}/r",
166 created_at=datetime.now(tz=timezone.utc),
167 default_branch="main",
168 )
169
170 def test_raises_404_when_repo_is_none(self) -> None:
171 from fastapi import HTTPException
172 from musehub.api.routes.musehub.repos import _guard_owner
173 with pytest.raises(HTTPException) as exc_info:
174 _guard_owner(None, "alice")
175 assert exc_info.value.status_code == 404
176
177 def test_raises_403_for_non_owner(self) -> None:
178 from fastapi import HTTPException
179 from musehub.api.routes.musehub.repos import _guard_owner
180 with pytest.raises(HTTPException) as exc_info:
181 _guard_owner(self._repo("alice"), "bob")
182 assert exc_info.value.status_code == 403
183
184 def test_no_raise_for_owner(self) -> None:
185 from musehub.api.routes.musehub.repos import _guard_owner
186 _guard_owner(self._repo("alice"), "alice") # must not raise
187
188
189 class TestResolveHeadRef:
190 """resolve_head_ref prefers 'main', falls back to first alphabetically."""
191
192 @pytest.mark.asyncio
193 async def test_empty_repo_returns_main(self, db_session: AsyncSession) -> None:
194 from musehub.services import musehub_repository
195 repo = await create_repo(db_session, slug="rhr-empty")
196 result = await musehub_repository.resolve_head_ref(db_session, repo.repo_id)
197 assert result == "main"
198
199 @pytest.mark.asyncio
200 async def test_prefers_main_branch(self, db_session: AsyncSession) -> None:
201 from musehub.services import musehub_repository
202 repo = await create_repo(db_session, slug="rhr-main")
203 await create_branch(db_session, repo.repo_id, name="dev")
204 await create_branch(db_session, repo.repo_id, name="main")
205 result = await musehub_repository.resolve_head_ref(db_session, repo.repo_id)
206 assert result == "main"
207
208 @pytest.mark.asyncio
209 async def test_falls_back_to_first_alpha_when_no_main(
210 self, db_session: AsyncSession
211 ) -> None:
212 from musehub.services import musehub_repository
213 repo = await create_repo(db_session, slug="rhr-alpha")
214 await create_branch(db_session, repo.repo_id, name="dev")
215 await create_branch(db_session, repo.repo_id, name="alpha")
216 result = await musehub_repository.resolve_head_ref(db_session, repo.repo_id)
217 assert result == "alpha" # first alphabetically
218
219
220 class TestListBranchesWithDetail:
221 """list_branches_with_detail computes ahead/behind counts correctly."""
222
223 @pytest.mark.asyncio
224 async def test_empty_repo_returns_empty(self, db_session: AsyncSession) -> None:
225 from musehub.services import musehub_repository
226 repo = await create_repo(db_session, slug="bwd-empty")
227 result = await musehub_repository.list_branches_with_detail(db_session, repo.repo_id)
228 assert result.branches == []
229
230 @pytest.mark.asyncio
231 async def test_default_branch_has_zero_ahead_behind(
232 self, db_session: AsyncSession
233 ) -> None:
234 from musehub.services import musehub_repository
235 repo = await create_repo(db_session, slug="bwd-default")
236 await create_branch(db_session, repo.repo_id, name="main")
237 await create_commit(db_session, repo.repo_id, branch="main")
238
239 result = await musehub_repository.list_branches_with_detail(db_session, repo.repo_id)
240 main_detail = next(b for b in result.branches if b.name == "main")
241 assert main_detail.is_default is True
242 assert main_detail.ahead_count == 0
243 assert main_detail.behind_count == 0
244
245 @pytest.mark.asyncio
246 async def test_feature_branch_ahead_count(self, db_session: AsyncSession) -> None:
247 from musehub.services import musehub_repository
248 repo = await create_repo(db_session, slug="bwd-ahead")
249 await create_branch(db_session, repo.repo_id, name="main")
250 await create_branch(db_session, repo.repo_id, name="feat")
251 # 1 commit on main, 3 on feat
252 await create_commit(db_session, repo.repo_id, branch="main")
253 for _ in range(3):
254 await create_commit(db_session, repo.repo_id, branch="feat")
255
256 result = await musehub_repository.list_branches_with_detail(db_session, repo.repo_id)
257 feat = next(b for b in result.branches if b.name == "feat")
258 # feat has 3 commits not in main → ahead=3; main has 1 commit not in feat → behind=1
259 assert feat.ahead_count == 3
260 assert feat.behind_count == 1
261
262
263 # ─────────────────────────────────────────────────────────────────────────────
264 # Layer 2 — Integration: service layer with real DB
265 # ─────────────────────────────────────────────────────────────────────────────
266
267 class TestGetRepoHomeStats:
268 @pytest.mark.asyncio
269 async def test_empty_repo_returns_zeros(self, db_session: AsyncSession) -> None:
270 from musehub.services import musehub_repository
271 repo = await create_repo(db_session, slug="stats-empty")
272 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
273 assert stats["total_commits"] == 0
274 assert stats["total_objects"] == 0
275 assert stats["total_size_bytes"] == 0
276 assert stats["commit_activity"] == [0] * 14
277
278 @pytest.mark.asyncio
279 async def test_commit_count_reflects_actual_commits(
280 self, db_session: AsyncSession
281 ) -> None:
282 from musehub.services import musehub_repository
283 repo = await create_repo(db_session, slug="stats-commits")
284 for _ in range(5):
285 await create_commit(db_session, repo.repo_id, branch="main")
286
287 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
288 assert stats["total_commits"] == 5
289
290 @pytest.mark.asyncio
291 async def test_activity_array_has_14_entries(self, db_session: AsyncSession) -> None:
292 from musehub.services import musehub_repository
293 repo = await create_repo(db_session, slug="stats-activity")
294 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
295 assert len(stats["commit_activity"]) == 14
296
297 @pytest.mark.asyncio
298 async def test_object_count_and_size_bytes(
299 self, db_session: AsyncSession, tmp_path: Path
300 ) -> None:
301 from musehub.services import musehub_repository
302 repo = await create_repo(db_session, slug="stats-objects")
303 for i in range(3):
304 obj = db.MusehubObject(
305 object_id=f"sha256:stats{i}",
306 repo_id=repo.repo_id,
307 path=f"f{i}.bin",
308 size_bytes=100,
309 disk_path=f"/tmp/stats{i}.bin",
310 )
311 db_session.add(obj)
312 await db_session.commit()
313
314 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
315 assert stats["total_objects"] == 3
316 assert stats["total_size_bytes"] == 300
317
318
319 class TestGetRecentlyPushedBranches:
320 @pytest.mark.asyncio
321 async def test_no_recent_branches_returns_empty(
322 self, db_session: AsyncSession
323 ) -> None:
324 from musehub.services import musehub_repository
325 repo = await create_repo(db_session, slug="recent-empty")
326 result = await musehub_repository.get_recently_pushed_branches(
327 db_session, repo.repo_id, "main"
328 )
329 assert result == []
330
331 @pytest.mark.asyncio
332 async def test_current_ref_excluded(self, db_session: AsyncSession) -> None:
333 from musehub.services import musehub_repository
334 repo = await create_repo(db_session, slug="recent-exclude")
335 commit = await create_commit(db_session, repo.repo_id, branch="main")
336 await create_branch(db_session, repo.repo_id, name="main",
337 head_commit_id=commit.commit_id)
338 result = await musehub_repository.get_recently_pushed_branches(
339 db_session, repo.repo_id, "main"
340 )
341 assert all(b["name"] != "main" for b in result)
342
343 @pytest.mark.asyncio
344 async def test_recent_branch_appears(self, db_session: AsyncSession) -> None:
345 from musehub.services import musehub_repository
346 repo = await create_repo(db_session, slug="recent-feat")
347 commit = await create_commit(db_session, repo.repo_id, branch="feat")
348 feat = db.MusehubBranch(
349 repo_id=repo.repo_id, name="feat", head_commit_id=commit.commit_id
350 )
351 db_session.add(feat)
352 await db_session.commit()
353
354 result = await musehub_repository.get_recently_pushed_branches(
355 db_session, repo.repo_id, "main", within_hours=72
356 )
357 assert any(b["name"] == "feat" for b in result)
358
359
360 class TestListReposForUserWithCollaborators:
361 @pytest.mark.asyncio
362 async def test_collab_repos_included_in_list(
363 self, db_session: AsyncSession
364 ) -> None:
365 from musehub.services import musehub_repository
366 from musehub.db.musehub_collaborator_models import MusehubCollaborator
367
368 owner_repo = await create_repo(db_session, slug="collab-owned",
369 owner="alice", owner_user_id="alice")
370 other_repo = await create_repo(db_session, slug="collab-shared",
371 owner="bob", owner_user_id="bob")
372 # alice is an accepted collaborator on bob's repo
373 collab = MusehubCollaborator(
374 repo_id=other_repo.repo_id,
375 identity_handle="alice",
376 permission="read",
377 accepted_at=datetime.now(tz=timezone.utc),
378 )
379 db_session.add(collab)
380 await db_session.commit()
381
382 result = await musehub_repository.list_repos_for_user(db_session, "alice")
383 repo_ids = [r.repo_id for r in result.repos]
384 assert owner_repo.repo_id in repo_ids
385 assert other_repo.repo_id in repo_ids
386
387 @pytest.mark.asyncio
388 async def test_unaccepted_collab_not_included(
389 self, db_session: AsyncSession
390 ) -> None:
391 from musehub.services import musehub_repository
392 from musehub.db.musehub_collaborator_models import MusehubCollaborator
393
394 other_repo = await create_repo(db_session, slug="collab-pending",
395 owner="carol", owner_user_id="carol")
396 collab = MusehubCollaborator(
397 repo_id=other_repo.repo_id,
398 identity_handle="dave",
399 permission="read",
400 accepted_at=None, # invitation not yet accepted
401 )
402 db_session.add(collab)
403 await db_session.commit()
404
405 result = await musehub_repository.list_repos_for_user(db_session, "dave")
406 assert all(r.repo_id != other_repo.repo_id for r in result.repos)
407
408
409 class TestTemplateRepoCopy:
410 @pytest.mark.asyncio
411 async def test_private_template_not_copied(self, db_session: AsyncSession) -> None:
412 from musehub.services import musehub_repository
413 tmpl = await create_repo(db_session, slug="tmpl-priv",
414 visibility="private", owner="alice",
415 owner_user_id="alice")
416 # Give template a description
417 tmpl_row = await db_session.get(db.MusehubRepo, tmpl.repo_id)
418 assert tmpl_row is not None
419 tmpl_row.description = "Private description"
420 await db_session.commit()
421
422 new_repo = await musehub_repository.create_repo(
423 db_session,
424 name="my-new-repo",
425 owner="bob",
426 visibility="public",
427 owner_user_id="bob",
428 template_repo_id=tmpl.repo_id,
429 )
430 await db_session.commit()
431 assert new_repo.description == "" # private template not applied
432
433
434 # ─────────────────────────────────────────────────────────────────────────────
435 # Layer 3 — E2E: HTTP endpoints not covered in test_musehub_repos.py
436 # ─────────────────────────────────────────────────────────────────────────────
437
438 class TestRepoStatsEndpoint:
439 @pytest.mark.asyncio
440 async def test_empty_repo_returns_zero_counts(
441 self, client: AsyncClient, db_session: AsyncSession
442 ) -> None:
443 repo = await create_repo(db_session, slug="e2e-stats-empty", visibility="public")
444 resp = await client.get(f"/api/repos/{repo.repo_id}/stats")
445 assert resp.status_code == 200
446 body = resp.json()
447 assert body["commitCount"] == 0
448 assert body["branchCount"] == 0
449 assert body["releaseCount"] == 0
450
451 @pytest.mark.asyncio
452 async def test_counts_reflect_data(
453 self, client: AsyncClient, db_session: AsyncSession
454 ) -> None:
455 repo = await create_repo(db_session, slug="e2e-stats-data", visibility="public")
456 await create_branch(db_session, repo.repo_id, name="main")
457 await create_branch(db_session, repo.repo_id, name="dev")
458 await create_commit(db_session, repo.repo_id, branch="main")
459
460 resp = await client.get(f"/api/repos/{repo.repo_id}/stats")
461 assert resp.status_code == 200
462 body = resp.json()
463 assert body["commitCount"] == 1
464 assert body["branchCount"] == 2
465
466 @pytest.mark.asyncio
467 async def test_unknown_repo_returns_404(
468 self, client: AsyncClient, db_session: AsyncSession
469 ) -> None:
470 resp = await client.get(f"/api/repos/{uuid.uuid4()}/stats")
471 assert resp.status_code == 404
472
473 @pytest.mark.asyncio
474 async def test_private_repo_without_auth_returns_401(
475 self, client: AsyncClient, db_session: AsyncSession
476 ) -> None:
477 repo = await create_repo(db_session, slug="e2e-stats-priv", visibility="private")
478 resp = await client.get(f"/api/repos/{repo.repo_id}/stats")
479 assert resp.status_code == 401
480
481
482 class TestBranchDetailEndpoint:
483 @pytest.mark.asyncio
484 async def test_returns_branch_list_with_detail(
485 self, client: AsyncClient, db_session: AsyncSession
486 ) -> None:
487 repo = await create_repo(db_session, slug="e2e-bwd-ok", visibility="public")
488 await create_branch(db_session, repo.repo_id, name="main")
489 await create_commit(db_session, repo.repo_id, branch="main")
490
491 resp = await client.get(f"/api/repos/{repo.repo_id}/branches/detail")
492 assert resp.status_code == 200
493 body = resp.json()
494 assert "branches" in body
495 assert "defaultBranch" in body
496 assert len(body["branches"]) == 1
497 branch = body["branches"][0]
498 assert branch["name"] == "main"
499 assert branch["isDefault"] is True
500 assert branch["aheadCount"] == 0
501 assert branch["behindCount"] == 0
502
503 @pytest.mark.asyncio
504 async def test_unknown_repo_returns_404(
505 self, client: AsyncClient, db_session: AsyncSession
506 ) -> None:
507 resp = await client.get(f"/api/repos/{uuid.uuid4()}/branches/detail")
508 assert resp.status_code == 404
509
510 @pytest.mark.asyncio
511 async def test_private_repo_without_auth_returns_401(
512 self, client: AsyncClient, db_session: AsyncSession
513 ) -> None:
514 repo = await create_repo(db_session, slug="e2e-bwd-priv", visibility="private")
515 resp = await client.get(f"/api/repos/{repo.repo_id}/branches/detail")
516 assert resp.status_code == 401
517
518
519 class TestSnapshotManifestEndpoint:
520 @pytest.mark.asyncio
521 async def test_returns_manifest(
522 self, client: AsyncClient, db_session: AsyncSession
523 ) -> None:
524 repo = await create_repo(db_session, slug="e2e-snap-ok", visibility="public")
525 snap_id = f"snap-{uuid.uuid4().hex[:8]}"
526 snap = db.MusehubSnapshot(snapshot_id=snap_id, repo_id=repo.repo_id)
527 entry = db.MusehubSnapshotEntry(
528 snapshot_id=snap_id, path="main.py", object_id="sha256:abc", size_bytes=42
529 )
530 db_session.add(snap)
531 db_session.add(entry)
532 await db_session.commit()
533
534 resp = await client.get(f"/api/repos/{repo.repo_id}/snapshots/{snap_id}")
535 assert resp.status_code == 200
536 body = resp.json()
537 assert body["snapshot_id"] == snap_id
538 assert "main.py" in body["manifest"]
539
540 @pytest.mark.asyncio
541 async def test_unknown_snapshot_returns_404(
542 self, client: AsyncClient, db_session: AsyncSession
543 ) -> None:
544 repo = await create_repo(db_session, slug="e2e-snap-404", visibility="public")
545 resp = await client.get(f"/api/repos/{repo.repo_id}/snapshots/ghost-snap")
546 assert resp.status_code == 404
547
548
549 class TestPrivateRepoBranchesAndCommits:
550 @pytest.mark.asyncio
551 async def test_private_repo_branches_without_auth_returns_401(
552 self, client: AsyncClient, db_session: AsyncSession
553 ) -> None:
554 repo = await create_repo(db_session, slug="e2e-priv-branches", visibility="private")
555 resp = await client.get(f"/api/repos/{repo.repo_id}/branches")
556 assert resp.status_code == 401
557
558 @pytest.mark.asyncio
559 async def test_private_repo_commits_without_auth_returns_401(
560 self, client: AsyncClient, db_session: AsyncSession
561 ) -> None:
562 repo = await create_repo(db_session, slug="e2e-priv-commits", visibility="private")
563 resp = await client.get(f"/api/repos/{repo.repo_id}/commits")
564 assert resp.status_code == 401
565
566
567 class TestCreateRepoValidation:
568 @pytest.mark.asyncio
569 async def test_invalid_owner_with_spaces_returns_422(
570 self,
571 client: AsyncClient,
572 auth_headers: StrDict,
573 ) -> None:
574 resp = await client.post(
575 "/api/repos",
576 json={"name": "my-repo", "owner": "alice bob"},
577 headers=auth_headers,
578 )
579 assert resp.status_code == 422
580
581 @pytest.mark.asyncio
582 async def test_invalid_owner_uppercase_returns_422(
583 self,
584 client: AsyncClient,
585 auth_headers: StrDict,
586 ) -> None:
587 resp = await client.post(
588 "/api/repos",
589 json={"name": "my-repo", "owner": "Alice"},
590 headers=auth_headers,
591 )
592 assert resp.status_code == 422
593
594 @pytest.mark.asyncio
595 async def test_invalid_owner_leading_hyphen_returns_422(
596 self,
597 client: AsyncClient,
598 auth_headers: StrDict,
599 ) -> None:
600 resp = await client.post(
601 "/api/repos",
602 json={"name": "my-repo", "owner": "-alice"},
603 headers=auth_headers,
604 )
605 assert resp.status_code == 422
606
607 @pytest.mark.asyncio
608 async def test_empty_name_returns_422(
609 self,
610 client: AsyncClient,
611 auth_headers: StrDict,
612 ) -> None:
613 resp = await client.post(
614 "/api/repos",
615 json={"name": "", "owner": "testuser"},
616 headers=auth_headers,
617 )
618 assert resp.status_code == 422
619
620
621 # ─────────────────────────────────────────────────────────────────────────────
622 # Layer 4 — Stress
623 # ─────────────────────────────────────────────────────────────────────────────
624
625 class TestRepositoryServiceStress:
626 @pytest.mark.asyncio
627 async def test_create_50_repos_and_list_all(
628 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
629 ) -> None:
630 """Create 50 repos via HTTP; list must report total=50."""
631 COUNT = 50
632 for i in range(COUNT):
633 resp = await client.post(
634 "/api/repos",
635 json={"name": f"stress-repo-{i:03d}", "owner": "testuser",
636 "visibility": "public"},
637 headers=auth_headers,
638 )
639 assert resp.status_code == 201
640
641 resp = await client.get("/api/repos?limit=100", headers=auth_headers)
642 assert resp.status_code == 200
643 body = resp.json()
644 assert body["total"] >= COUNT
645
646 @pytest.mark.asyncio
647 async def test_cursor_pagination_traverses_all_repos(
648 self, db_session: AsyncSession
649 ) -> None:
650 """Insert 100 repos; cursor pagination must visit all of them."""
651 from musehub.services import musehub_repository
652
653 TOTAL = 100
654 owner_id = f"paginator-{uuid.uuid4().hex[:8]}"
655 for i in range(TOTAL):
656 await create_repo(db_session, slug=f"page-{i:03d}",
657 owner=owner_id, owner_user_id=owner_id)
658
659 collected: list[str] = []
660 cursor: str | None = None
661 while True:
662 page = await musehub_repository.list_repos_for_user(
663 db_session, owner_id, limit=10, cursor=cursor
664 )
665 collected.extend(r.repo_id for r in page.repos)
666 cursor = page.next_cursor
667 if cursor is None:
668 break
669
670 assert len(collected) == TOTAL
671 assert len(set(collected)) == TOTAL # no duplicates
672
673 @pytest.mark.asyncio
674 async def test_200_commit_history_pageable(self, db_session: AsyncSession) -> None:
675 """Push 200 commits; paging through them must yield all without duplicates."""
676 from musehub.services import musehub_repository
677
678 repo = await create_repo(db_session, slug="stress-200c")
679 for _ in range(200):
680 await create_commit(db_session, repo.repo_id, branch="main")
681
682 all_ids: list[str] = []
683 per_page = 50
684 for page_num in range(1, 5):
685 commits, total = await musehub_repository.list_commits(
686 db_session, repo.repo_id, limit=per_page,
687 offset=(page_num - 1) * per_page
688 )
689 all_ids.extend(c.commit_id for c in commits)
690
691 assert total == 200
692 assert len(all_ids) == 200
693 assert len(set(all_ids)) == 200 # no duplicates across pages
694
695
696 # ─────────────────────────────────────────────────────────────────────────────
697 # Layer 5 — Data Integrity
698 # ─────────────────────────────────────────────────────────────────────────────
699
700 class TestDataIntegrity:
701 @pytest.mark.asyncio
702 async def test_soft_delete_hides_repo_but_preserves_db_row(
703 self, db_session: AsyncSession
704 ) -> None:
705 from musehub.services import musehub_repository
706
707 repo = await create_repo(db_session, slug="del-preserve")
708 deleted = await musehub_repository.delete_repo(db_session, repo.repo_id)
709 await db_session.commit()
710
711 assert deleted is True
712 # get_repo returns None (soft-deleted)
713 result = await musehub_repository.get_repo(db_session, repo.repo_id)
714 assert result is None
715 # But the DB row still exists with deleted_at set
716 row = await db_session.get(db.MusehubRepo, repo.repo_id)
717 assert row is not None
718 assert row.deleted_at is not None
719
720 @pytest.mark.asyncio
721 async def test_double_soft_delete_is_idempotent(
722 self, db_session: AsyncSession
723 ) -> None:
724 from musehub.services import musehub_repository
725
726 repo = await create_repo(db_session, slug="del-idempotent")
727 first = await musehub_repository.delete_repo(db_session, repo.repo_id)
728 await db_session.commit()
729 second = await musehub_repository.delete_repo(db_session, repo.repo_id)
730 await db_session.commit()
731
732 assert first is True
733 assert second is False # already deleted
734
735 @pytest.mark.asyncio
736 async def test_transfer_on_deleted_repo_returns_none(
737 self, db_session: AsyncSession
738 ) -> None:
739 from musehub.services import musehub_repository
740
741 repo = await create_repo(db_session, slug="del-transfer")
742 await musehub_repository.delete_repo(db_session, repo.repo_id)
743 await db_session.commit()
744
745 result = await musehub_repository.transfer_repo_ownership(
746 db_session, repo.repo_id, "new-owner"
747 )
748 assert result is None
749
750 @pytest.mark.asyncio
751 async def test_duplicate_owner_slug_returns_409_via_http(
752 self,
753 client: AsyncClient,
754 db_session: AsyncSession,
755 auth_headers: StrDict,
756 ) -> None:
757 payload = {"name": "duplicate-name", "owner": "testuser"}
758 resp1 = await client.post("/api/repos", json=payload, headers=auth_headers)
759 assert resp1.status_code == 201
760 resp2 = await client.post("/api/repos", json=payload, headers=auth_headers)
761 assert resp2.status_code == 409
762
763 @pytest.mark.asyncio
764 async def test_create_repo_service_sets_correct_slug(
765 self, db_session: AsyncSession
766 ) -> None:
767 from musehub.services import musehub_repository
768
769 repo = await musehub_repository.create_repo(
770 db_session,
771 name="My Jazz Experiment!",
772 owner="gabriel",
773 visibility="public",
774 owner_user_id="gabriel",
775 )
776 await db_session.commit()
777 assert repo.slug == "my-jazz-experiment"
778
779
780 # ─────────────────────────────────────────────────────────────────────────────
781 # Layer 6 — Security
782 # ─────────────────────────────────────────────────────────────────────────────
783
784 class TestSecurity:
785 @pytest.mark.asyncio
786 async def test_non_owner_delete_returns_403(
787 self,
788 client: AsyncClient,
789 db_session: AsyncSession,
790 auth_headers: StrDict,
791 ) -> None:
792 """Only owner may delete — authenticated non-owner gets 403."""
793 # Create a repo owned by someone else
794 repo_row = db.MusehubRepo(
795 name="not-mine",
796 owner="other-user",
797 slug="not-mine",
798 visibility="public",
799 owner_user_id="other-user",
800 description="",
801 tags=[],
802 )
803 db_session.add(repo_row)
804 await db_session.commit()
805
806 resp = await client.delete(
807 f"/api/repos/{repo_row.repo_id}", headers=auth_headers
808 )
809 assert resp.status_code == 403
810
811 @pytest.mark.asyncio
812 async def test_non_owner_transfer_returns_403(
813 self,
814 client: AsyncClient,
815 db_session: AsyncSession,
816 auth_headers: StrDict,
817 ) -> None:
818 repo_row = db.MusehubRepo(
819 name="no-transfer",
820 owner="stranger",
821 slug="no-transfer",
822 visibility="public",
823 owner_user_id="stranger",
824 description="",
825 tags=[],
826 )
827 db_session.add(repo_row)
828 await db_session.commit()
829
830 resp = await client.post(
831 f"/api/repos/{repo_row.repo_id}/transfer",
832 json={"newOwnerUserId": "hacker"},
833 headers=auth_headers,
834 )
835 assert resp.status_code == 403
836
837 @pytest.mark.asyncio
838 async def test_private_repo_get_without_auth_returns_401(
839 self, client: AsyncClient, db_session: AsyncSession
840 ) -> None:
841 repo = await create_repo(db_session, slug="sec-priv-get", visibility="private")
842 resp = await client.get(f"/api/repos/{repo.repo_id}")
843 assert resp.status_code == 401
844
845 @pytest.mark.asyncio
846 async def test_delete_requires_auth(
847 self, client: AsyncClient, db_session: AsyncSession
848 ) -> None:
849 repo = await create_repo(db_session, slug="sec-del-noauth", visibility="public")
850 resp = await client.delete(f"/api/repos/{repo.repo_id}")
851 assert resp.status_code == 401
852
853 @pytest.mark.asyncio
854 async def test_transfer_requires_auth(
855 self, client: AsyncClient, db_session: AsyncSession
856 ) -> None:
857 repo = await create_repo(db_session, slug="sec-xfer-noauth", visibility="public")
858 resp = await client.post(
859 f"/api/repos/{repo.repo_id}/transfer",
860 json={"newOwnerUserId": "anyone"},
861 )
862 assert resp.status_code == 401
863
864
865 # ─────────────────────────────────────────────────────────────────────────────
866 # Layer 7 — Performance
867 # ─────────────────────────────────────────────────────────────────────────────
868
869 class TestPerformance:
870 def test_generate_slug_1000_calls_under_100ms(self) -> None:
871 from musehub.services.musehub_repository import _generate_slug
872 names = [f"My Repo Number {i} — Special Édition!" for i in range(1000)]
873 t0 = time.perf_counter()
874 for name in names:
875 _generate_slug(name)
876 elapsed_ms = (time.perf_counter() - t0) * 1000
877 assert elapsed_ms < 100, f"1000 slug calls took {elapsed_ms:.1f}ms > 100ms"
878
879 @pytest.mark.asyncio
880 async def test_list_repos_50_users_under_500ms(
881 self, db_session: AsyncSession
882 ) -> None:
883 from musehub.services import musehub_repository
884
885 uid = f"perf-user-{uuid.uuid4().hex[:8]}"
886 for i in range(50):
887 await create_repo(db_session, slug=f"perf-r{i:02d}",
888 owner=uid, owner_user_id=uid)
889
890 # Warm-up
891 await musehub_repository.list_repos_for_user(db_session, uid, limit=50)
892
893 t0 = time.perf_counter()
894 result = await musehub_repository.list_repos_for_user(db_session, uid, limit=50)
895 elapsed_ms = (time.perf_counter() - t0) * 1000
896 assert len(result.repos) == 50
897 assert elapsed_ms < 500, f"list_repos 50 items took {elapsed_ms:.1f}ms > 500ms"
898
899 @pytest.mark.asyncio
900 async def test_get_repo_home_stats_200_commits_under_500ms(
901 self, db_session: AsyncSession
902 ) -> None:
903 from musehub.services import musehub_repository
904
905 repo = await create_repo(db_session, slug="perf-stats-200c")
906 for _ in range(200):
907 await create_commit(db_session, repo.repo_id, branch="main")
908
909 # Warm-up
910 await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
911
912 t0 = time.perf_counter()
913 stats = await musehub_repository.get_repo_home_stats(db_session, repo.repo_id, "main")
914 elapsed_ms = (time.perf_counter() - t0) * 1000
915 assert stats["total_commits"] == 200
916 assert elapsed_ms < 500, f"get_repo_home_stats took {elapsed_ms:.1f}ms > 500ms"
917
918 @pytest.mark.asyncio
919 async def test_list_commits_200_rows_under_200ms(
920 self, db_session: AsyncSession
921 ) -> None:
922 from musehub.services import musehub_repository
923
924 repo = await create_repo(db_session, slug="perf-commits-200")
925 for _ in range(200):
926 await create_commit(db_session, repo.repo_id, branch="main")
927
928 # Warm-up
929 await musehub_repository.list_commits(db_session, repo.repo_id, limit=200)
930
931 t0 = time.perf_counter()
932 commits, total = await musehub_repository.list_commits(
933 db_session, repo.repo_id, limit=200
934 )
935 elapsed_ms = (time.perf_counter() - t0) * 1000
936 assert total == 200
937 assert len(commits) == 200
938 assert elapsed_ms < 200, f"list_commits 200 rows took {elapsed_ms:.1f}ms > 200ms"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago