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