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