gabriel / musehub public
test_domains.py python
1,799 lines 65.3 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 49 days ago
1 """Section 25 — Domains: 8-layer test suite.
2
3 Covers musehub/services/musehub_domains.py and
4 musehub/api/routes/musehub/domains.py.
5
6 Layer map
7 ---------
8 1. Unit — compute_manifest_hash, _to_response, dataclasses
9 2. Integration — service functions against real PostgreSQL DB
10 3. E2E — HTTP client against full app
11 4. Stress — many domains, concurrent queries
12 5. Data Integrity — sort order, deprecated exclusion, hash correctness
13 6. Security — auth enforcement, duplicate scoped_id
14 7. Performance — timing budgets
15 8. Admin — domain verification endpoint
16 """
17 from __future__ import annotations
18
19 import asyncio
20 import json
21 import secrets
22 from collections.abc import Generator, Mapping
23 import time
24
25 import pytest
26 import pytest_asyncio
27 from httpx import AsyncClient
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from muse.core.types import blob_id
31 from musehub.auth.request_signing import MSignContext, optional_signed_request, require_signed_request
32 from musehub.db.musehub_domain_models import MusehubDomain, MusehubDomainInstall
33 from datetime import datetime, timezone
34
35 from musehub.core.genesis import compute_identity_id, compute_repo_id
36 from musehub.db.musehub_identity_models import MusehubIdentity
37 from musehub.db.musehub_repo_models import MusehubRepo
38 from musehub.main import app
39 from musehub.types.json_types import JSONObject, StrDict
40 from musehub.services.musehub_domains import (
41 DomainListResponse,
42 DomainReposResponse,
43 DomainResponse,
44 _to_response,
45 compute_manifest_hash,
46 count_public_repos_by_domain,
47 create_domain,
48 get_domain_by_id,
49 get_domain_by_scoped_id,
50 list_domains,
51 list_repos_for_domain,
52 record_domain_install,
53 record_domain_uninstall,
54 resolve_unambiguous_domain_id_by_category,
55 )
56
57
58 # ---------------------------------------------------------------------------
59 # DB helpers
60 # ---------------------------------------------------------------------------
61
62
63 def _uid() -> str:
64 return secrets.token_hex(16)
65
66
67 async def _db_domain(
68 session: AsyncSession,
69 *,
70 author_slug: str = "alice",
71 slug: str | None = None,
72 display_name: str = "Test Domain",
73 description: str = "A test domain",
74 capabilities: JSONObject | None = None,
75 viewer_type: str = "generic",
76 version: str = "1.0.0",
77 install_count: int = 0,
78 is_verified: bool = False,
79 is_deprecated: bool = False,
80 ) -> MusehubDomain:
81 from datetime import datetime, timezone
82
83 slug = slug or f"domain-{_uid()[:8]}"
84 caps = capabilities or {"dimensions": [], "merge_semantics": "three_way"}
85 manifest_hash = compute_manifest_hash(caps)
86 domain = MusehubDomain(
87 domain_id=_uid(),
88 author_user_id=author_slug,
89 author_slug=author_slug,
90 slug=slug,
91 display_name=display_name,
92 description=description,
93 version=version,
94 manifest_hash=manifest_hash,
95 capabilities=caps,
96 viewer_type=viewer_type,
97 install_count=install_count,
98 is_verified=is_verified,
99 is_deprecated=is_deprecated,
100 created_at=datetime.now(timezone.utc),
101 updated_at=datetime.now(timezone.utc),
102 )
103 session.add(domain)
104 await session.flush()
105 return domain
106
107
108 async def _db_repo(
109 session: AsyncSession,
110 owner: str = "alice",
111 *,
112 domain_id: str | None = None,
113 marketplace_domain_id: str | None = None,
114 visibility: str = "public",
115 deleted: bool = False,
116 ) -> MusehubRepo:
117 slug = f"repo-{_uid()[:8]}"
118 owner_id = compute_identity_id(owner.encode())
119 created_at = datetime.now(tz=timezone.utc)
120 repo = MusehubRepo(
121 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
122 name=slug,
123 slug=slug,
124 owner=owner,
125 owner_user_id=owner_id,
126 visibility=visibility,
127 domain_id=domain_id,
128 marketplace_domain_id=marketplace_domain_id,
129 created_at=created_at,
130 updated_at=created_at,
131 )
132 session.add(repo)
133 await session.flush()
134 if deleted:
135 await session.delete(repo)
136 await session.flush()
137 return repo
138
139
140 # ===========================================================================
141 # Layer 1 — Unit
142 # ===========================================================================
143
144
145 class TestUnitComputeManifestHash:
146 def test_returns_hex_string(self) -> None:
147 h = compute_manifest_hash({"dimensions": []})
148 assert isinstance(h, str)
149 assert h.startswith("sha256:")
150 assert len(h) == 71 # sha256:<64-hex>
151
152 def test_deterministic(self) -> None:
153 caps = {"dimensions": [{"name": "tempo"}], "merge_semantics": "ot"}
154 assert compute_manifest_hash(caps) == compute_manifest_hash(caps)
155
156 def test_sorted_keys_order_independent(self) -> None:
157 caps_a = {"b": 1, "a": 2}
158 caps_b = {"a": 2, "b": 1}
159 assert compute_manifest_hash(caps_a) == compute_manifest_hash(caps_b)
160
161 def test_different_capabilities_different_hash(self) -> None:
162 h1 = compute_manifest_hash({"dimensions": []})
163 h2 = compute_manifest_hash({"dimensions": [{"name": "tempo"}]})
164 assert h1 != h2
165
166 def test_matches_manual_sha256(self) -> None:
167 caps = {"x": 1}
168 blob = json.dumps(caps, sort_keys=True, separators=(",", ":")).encode()
169 expected = blob_id(blob)
170 assert compute_manifest_hash(caps) == expected
171
172
173 class TestUnitToResponse:
174 """Unit tests for _to_response using an integration DB fixture to create real ORM rows."""
175
176 async def test_scoped_id_format(self, db_session: AsyncSession) -> None:
177 d = await _db_domain(db_session, author_slug="gabriel", slug="midi")
178 resp = _to_response(d)
179 assert resp.scoped_id == "@gabriel/midi"
180
181 async def test_install_count_preserved(self, db_session: AsyncSession) -> None:
182 d = await _db_domain(db_session, slug="midi-count", install_count=5)
183 resp = _to_response(d)
184 assert resp.install_count == 5
185
186 async def test_is_verified_preserved(self, db_session: AsyncSession) -> None:
187 d = await _db_domain(db_session, slug="midi-verified", is_verified=True)
188 resp = _to_response(d)
189 assert resp.is_verified is True
190
191 async def test_capabilities_copied(self, db_session: AsyncSession) -> None:
192 caps = {"dimensions": [{"name": "tempo"}], "merge_semantics": "ot"}
193 d = await _db_domain(db_session, slug="midi-caps", capabilities=caps)
194 resp = _to_response(d)
195 assert resp.capabilities == caps
196
197 async def test_none_capabilities_become_empty_dict(
198 self, db_session: AsyncSession
199 ) -> None:
200 d = await _db_domain(db_session, slug="midi-no-caps")
201 d.capabilities = None
202 resp = _to_response(d)
203 assert resp.capabilities == {}
204
205
206 class TestUnitDataclasses:
207 def test_domain_response_fields(self) -> None:
208 from datetime import datetime, timezone
209
210 dr = DomainResponse(
211 domain_id=_uid(),
212 author_slug="alice",
213 slug="midi",
214 scoped_id="@alice/midi",
215 display_name="MIDI",
216 description="",
217 version="1.0.0",
218 manifest_hash="abc",
219 capabilities={},
220 viewer_type="generic",
221 install_count=0,
222 is_verified=False,
223 is_deprecated=False,
224 created_at=datetime.now(timezone.utc),
225 updated_at=datetime.now(timezone.utc),
226 )
227 assert dr.scoped_id == "@alice/midi"
228
229 def test_domain_list_response_fields(self) -> None:
230 dlr = DomainListResponse(domains=[], total=0)
231 assert dlr.total == 0
232
233 def test_domain_repos_response_fields(self) -> None:
234 drr = DomainReposResponse(
235 domain_id=_uid(), scoped_id="@a/b", repos=[], total=0
236 )
237 assert drr.repos == []
238
239
240 # ===========================================================================
241 # Layer 2 — Integration
242 # ===========================================================================
243
244
245 class TestIntegrationListDomains:
246 async def test_returns_all_non_deprecated(self, db_session: AsyncSession) -> None:
247 await _db_domain(db_session, slug="midi", display_name="MIDI")
248 await _db_domain(db_session, slug="code", display_name="Code")
249 await _db_domain(db_session, slug="old", is_deprecated=True)
250 await db_session.flush()
251
252 result = await list_domains(db_session)
253 slugs = [d.slug for d in result.domains]
254 assert "midi" in slugs
255 assert "code" in slugs
256 assert "old" not in slugs
257
258 async def test_query_filters_by_display_name(self, db_session: AsyncSession) -> None:
259 await _db_domain(db_session, slug="piano", display_name="Piano Roll Domain")
260 await _db_domain(db_session, slug="genome", display_name="Genomics Domain")
261 await db_session.flush()
262
263 result = await list_domains(db_session, query="Piano")
264 assert len(result.domains) == 1
265 assert result.domains[0].slug == "piano"
266
267 async def test_verified_only_filter(self, db_session: AsyncSession) -> None:
268 await _db_domain(db_session, slug="v", is_verified=True)
269 await _db_domain(db_session, slug="u", is_verified=False)
270 await db_session.flush()
271
272 result = await list_domains(db_session, verified_only=True)
273 slugs = [d.slug for d in result.domains]
274 assert "v" in slugs
275 assert "u" not in slugs
276
277 async def test_pagination_page_size(self, db_session: AsyncSession) -> None:
278 for i in range(5):
279 await _db_domain(db_session, slug=f"d{i}")
280 await db_session.flush()
281
282 result = await list_domains(db_session, limit=2)
283 assert len(result.domains) == 2
284 assert result.total == 5
285 assert result.next_cursor is not None
286
287
288 class TestIntegrationGetDomain:
289 async def test_get_by_scoped_id_found(self, db_session: AsyncSession) -> None:
290 await _db_domain(db_session, author_slug="alice", slug="midi")
291 await db_session.flush()
292
293 result = await get_domain_by_scoped_id(db_session, "alice", "midi")
294 assert result is not None
295 assert result.scoped_id == "@alice/midi"
296
297 async def test_get_by_scoped_id_not_found(self, db_session: AsyncSession) -> None:
298 result = await get_domain_by_scoped_id(db_session, "nobody", "missing")
299 assert result is None
300
301 async def test_get_by_id_found(self, db_session: AsyncSession) -> None:
302 d = await _db_domain(db_session, slug="code")
303 await db_session.flush()
304
305 result = await get_domain_by_id(db_session, d.domain_id)
306 assert result is not None
307 assert result.domain_id == d.domain_id
308
309 async def test_get_by_id_not_found(self, db_session: AsyncSession) -> None:
310 result = await get_domain_by_id(db_session, "nonexistent-id")
311 assert result is None
312
313
314 class TestIntegrationListReposForDomain:
315 async def test_returns_public_repos(self, db_session: AsyncSession) -> None:
316 d = await _db_domain(db_session, slug="midi")
317 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="public")
318 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="public")
319 await db_session.flush()
320
321 result = await list_repos_for_domain(db_session, d.domain_id)
322 assert result.total == 2
323 assert len(result.repos) == 2
324
325 async def test_private_repos_excluded(self, db_session: AsyncSession) -> None:
326 d = await _db_domain(db_session, slug="midi")
327 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="public")
328 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="private")
329 await db_session.flush()
330
331 result = await list_repos_for_domain(db_session, d.domain_id)
332 assert result.total == 1
333
334 async def test_deleted_repos_excluded(self, db_session: AsyncSession) -> None:
335 d = await _db_domain(db_session, slug="midi")
336 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="public")
337 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="public", deleted=True)
338 await db_session.flush()
339
340 result = await list_repos_for_domain(db_session, d.domain_id)
341 assert result.total == 1
342
343 async def test_nonexistent_domain_returns_empty(self, db_session: AsyncSession) -> None:
344 result = await list_repos_for_domain(db_session, "nonexistent-id")
345 assert result.total == 0
346 assert result.repos == []
347
348 async def test_repo_with_matching_plugin_category_but_no_marketplace_link_excluded(
349 self, db_session: AsyncSession
350 ) -> None:
351 """Regression test for the column-mismatch bug this function once had.
352
353 list_repos_for_domain used to compare MusehubRepo.domain_id (a plain
354 VCS-plugin category string like "code") against the marketplace
355 domain's genesis-addressed domain_id (a sha256 hash) — two different
356 namespaces that could never match, so every domain always showed 0
357 repos. A repo whose *plugin category string* happens to equal the
358 domain's slug must NOT be returned unless it's also explicitly
359 linked via marketplace_domain_id.
360 """
361 d = await _db_domain(db_session, slug="code")
362 await _db_repo(db_session, domain_id="code", marketplace_domain_id=None, visibility="public")
363 await db_session.flush()
364
365 result = await list_repos_for_domain(db_session, d.domain_id)
366 assert result.total == 0
367 assert result.repos == []
368
369
370 class TestIntegrationCountPublicReposByDomain:
371 """musehub#120 follow-up — the /domains listing page showed
372 domain.install_count mislabeled as a repo count. Fixes require a
373 batch per-domain repo count (this function) rather than an N+1 call
374 to list_repos_for_domain per row.
375 """
376
377 async def test_counts_public_repos_per_domain(self, db_session: AsyncSession) -> None:
378 code = await _db_domain(db_session, slug="code")
379 mist = await _db_domain(db_session, slug="mist")
380 await _db_repo(db_session, marketplace_domain_id=code.domain_id, visibility="public")
381 await _db_repo(db_session, marketplace_domain_id=code.domain_id, visibility="public")
382 await _db_repo(db_session, marketplace_domain_id=mist.domain_id, visibility="public")
383 await db_session.flush()
384
385 counts = await count_public_repos_by_domain(
386 db_session, [code.domain_id, mist.domain_id]
387 )
388 assert counts[code.domain_id] == 2
389 assert counts[mist.domain_id] == 1
390
391 async def test_private_repos_excluded(self, db_session: AsyncSession) -> None:
392 d = await _db_domain(db_session, slug="identity")
393 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="private")
394 await db_session.flush()
395
396 counts = await count_public_repos_by_domain(db_session, [d.domain_id])
397 assert counts.get(d.domain_id, 0) == 0
398
399 async def test_domain_with_zero_repos_absent_from_result(
400 self, db_session: AsyncSession
401 ) -> None:
402 d = await _db_domain(db_session, slug="lonely")
403 await db_session.flush()
404
405 counts = await count_public_repos_by_domain(db_session, [d.domain_id])
406 assert d.domain_id not in counts
407 assert counts.get(d.domain_id, 0) == 0
408
409 async def test_empty_domain_ids_returns_empty_dict(
410 self, db_session: AsyncSession
411 ) -> None:
412 assert await count_public_repos_by_domain(db_session, []) == {}
413
414 async def test_does_not_count_other_domains_repos(
415 self, db_session: AsyncSession
416 ) -> None:
417 target = await _db_domain(db_session, slug="target")
418 other = await _db_domain(db_session, slug="other")
419 await _db_repo(db_session, marketplace_domain_id=other.domain_id, visibility="public")
420 await db_session.flush()
421
422 counts = await count_public_repos_by_domain(db_session, [target.domain_id])
423 assert counts.get(target.domain_id, 0) == 0
424
425
426 class TestIntegrationResolveUnambiguousDomainByCategory:
427 """musehub#120 Phase 1 (MDL_01-04) — the never-guess resolution helper.
428
429 Consulted later by both create_repo (Phase 2) and the backfill script
430 (Phase 3) to auto-link a repo's plain category string (e.g. "code") to
431 a specific marketplace MusehubDomain, without ever guessing when the
432 category is ambiguous or unmatched.
433 """
434
435 async def test_mdl01_resolves_the_sole_matching_live_domain(
436 self, db_session: AsyncSession
437 ) -> None:
438 d = await _db_domain(db_session, author_slug="gabriel", slug="code")
439 await db_session.flush()
440
441 result = await resolve_unambiguous_domain_id_by_category(db_session, "code")
442 assert result == d.domain_id
443
444 async def test_mdl02_returns_none_when_zero_domains_match(
445 self, db_session: AsyncSession
446 ) -> None:
447 result = await resolve_unambiguous_domain_id_by_category(db_session, "no-such-category")
448 assert result is None
449
450 async def test_mdl03_returns_none_when_multiple_live_domains_share_the_category(
451 self, db_session: AsyncSession
452 ) -> None:
453 await _db_domain(db_session, author_slug="gabriel", slug="code")
454 await _db_domain(db_session, author_slug="alice", slug="code")
455 await db_session.flush()
456
457 result = await resolve_unambiguous_domain_id_by_category(db_session, "code")
458 assert result is None
459
460 async def test_mdl04_deprecated_sibling_does_not_create_ambiguity(
461 self, db_session: AsyncSession
462 ) -> None:
463 live = await _db_domain(db_session, author_slug="gabriel", slug="code")
464 await _db_domain(db_session, author_slug="alice", slug="code", is_deprecated=True)
465 await db_session.flush()
466
467 result = await resolve_unambiguous_domain_id_by_category(db_session, "code")
468 assert result == live.domain_id
469
470
471 class TestIntegrationCreateDomain:
472 async def test_creates_domain_with_hash(self, db_session: AsyncSession) -> None:
473 caps = {"dimensions": [{"name": "tempo"}], "merge_semantics": "ot"}
474 result = await create_domain(
475 db_session,
476 author_user_id="alice",
477 author_slug="alice",
478 slug="midi",
479 display_name="MIDI",
480 description="MIDI domain",
481 capabilities=caps,
482 )
483 assert result.domain_id != ""
484 assert result.manifest_hash == compute_manifest_hash(caps)
485
486 async def test_scoped_id_format(self, db_session: AsyncSession) -> None:
487 result = await create_domain(
488 db_session,
489 author_user_id="bob",
490 author_slug="bob",
491 slug="code",
492 display_name="Code",
493 description="",
494 capabilities={},
495 )
496 assert result.scoped_id == "@bob/code"
497
498 async def test_not_verified_by_default(self, db_session: AsyncSession) -> None:
499 result = await create_domain(
500 db_session,
501 author_user_id="alice",
502 author_slug="alice",
503 slug="genome",
504 display_name="Genome",
505 description="",
506 capabilities={},
507 )
508 assert result.is_verified is False
509 assert result.is_deprecated is False
510
511
512 class TestIntegrationRecordDomainInstall:
513 async def test_increments_install_count(self, db_session: AsyncSession) -> None:
514 d = await _db_domain(db_session, slug="midi", install_count=0)
515 await db_session.flush()
516
517 await record_domain_install(db_session, "user1", d.domain_id)
518 await db_session.flush()
519
520 # Verify via get_domain
521 domain = await get_domain_by_id(db_session, d.domain_id)
522 assert domain is not None
523 assert domain.install_count == 1
524
525 async def test_idempotent_same_user(self, db_session: AsyncSession) -> None:
526 d = await _db_domain(db_session, slug="midi", install_count=0)
527 await db_session.flush()
528
529 await record_domain_install(db_session, "user1", d.domain_id)
530 await record_domain_install(db_session, "user1", d.domain_id) # duplicate
531 await db_session.flush()
532
533 domain = await get_domain_by_id(db_session, d.domain_id)
534 assert domain is not None
535 assert domain.install_count == 1 # not 2
536
537 async def test_different_users_each_increment(self, db_session: AsyncSession) -> None:
538 d = await _db_domain(db_session, slug="midi", install_count=0)
539 await db_session.flush()
540
541 await record_domain_install(db_session, "user1", d.domain_id)
542 await record_domain_install(db_session, "user2", d.domain_id)
543 await db_session.flush()
544
545 domain = await get_domain_by_id(db_session, d.domain_id)
546 assert domain is not None
547 assert domain.install_count == 2
548
549
550 class TestIntegrationRecordDomainUninstall:
551 """musehub#117 DOM_13: record_domain_uninstall reverses install_count."""
552
553 async def test_decrements_install_count(self, db_session: AsyncSession) -> None:
554 d = await _db_domain(db_session, slug="midi-uninstall", install_count=0)
555 await db_session.flush()
556
557 await record_domain_install(db_session, "user1", d.domain_id)
558 await record_domain_uninstall(db_session, "user1", d.domain_id)
559 await db_session.flush()
560
561 domain = await get_domain_by_id(db_session, d.domain_id)
562 assert domain is not None
563 assert domain.install_count == 0
564
565 async def test_idempotent_when_not_installed(self, db_session: AsyncSession) -> None:
566 d = await _db_domain(db_session, slug="midi-never-installed", install_count=0)
567 await db_session.flush()
568
569 await record_domain_uninstall(db_session, "user1", d.domain_id)
570 await db_session.flush()
571
572 domain = await get_domain_by_id(db_session, d.domain_id)
573 assert domain is not None
574 assert domain.install_count == 0
575
576 async def test_never_goes_negative(self, db_session: AsyncSession) -> None:
577 d = await _db_domain(db_session, slug="midi-floor", install_count=0)
578 await db_session.flush()
579
580 # Uninstall twice in a row without a matching install between them
581 await record_domain_install(db_session, "user1", d.domain_id)
582 await record_domain_uninstall(db_session, "user1", d.domain_id)
583 await record_domain_uninstall(db_session, "user1", d.domain_id)
584 await db_session.flush()
585
586 domain = await get_domain_by_id(db_session, d.domain_id)
587 assert domain is not None
588 assert domain.install_count == 0
589
590 async def test_only_affects_the_uninstalling_user(self, db_session: AsyncSession) -> None:
591 d = await _db_domain(db_session, slug="midi-per-user", install_count=0)
592 await db_session.flush()
593
594 await record_domain_install(db_session, "user1", d.domain_id)
595 await record_domain_install(db_session, "user2", d.domain_id)
596 await record_domain_uninstall(db_session, "user1", d.domain_id)
597 await db_session.flush()
598
599 domain = await get_domain_by_id(db_session, d.domain_id)
600 assert domain is not None
601 assert domain.install_count == 1
602
603
604 # ===========================================================================
605 # Layer 3 — E2E
606 # ===========================================================================
607
608
609 class TestE2EListDomains:
610 async def test_list_returns_200(
611 self,
612 client: AsyncClient,
613 db_session: AsyncSession,
614 ) -> None:
615 await _db_domain(db_session, slug="midi-api")
616 await db_session.commit()
617
618 r = await client.get("/api/domains")
619 assert r.status_code == 200
620 body = r.json()
621 assert "domains" in body
622 assert "total" in body
623 assert isinstance(body["domains"], list)
624
625 async def test_list_no_auth_required(
626 self,
627 client: AsyncClient,
628 db_session: AsyncSession,
629 ) -> None:
630 await db_session.commit()
631 r = await client.get("/api/domains")
632 assert r.status_code == 200
633
634 async def test_list_query_param_filters(
635 self,
636 client: AsyncClient,
637 db_session: AsyncSession,
638 ) -> None:
639 await _db_domain(db_session, slug="piano-e2e", display_name="Piano Roll E2E")
640 await _db_domain(db_session, slug="genome-e2e", display_name="Genome E2E")
641 await db_session.commit()
642
643 r = await client.get("/api/domains?q=Piano+Roll")
644 assert r.status_code == 200
645 body = r.json()
646 slugs = [d["slug"] for d in body["domains"]]
647 assert "piano-e2e" in slugs
648 assert "genome-e2e" not in slugs
649
650 async def test_list_page_size_param(
651 self,
652 client: AsyncClient,
653 db_session: AsyncSession,
654 ) -> None:
655 for i in range(5):
656 await _db_domain(db_session, slug=f"e2e-page-{i}")
657 await db_session.commit()
658
659 r = await client.get("/api/domains?limit=2")
660 assert r.status_code == 200
661 body = r.json()
662 assert len(body["domains"]) <= 2
663 assert body["nextCursor"] is not None
664
665
666 class TestE2ERegisterDomain:
667 async def test_register_201(
668 self,
669 client: AsyncClient,
670 auth_headers: StrDict,
671 db_session: AsyncSession,
672 ) -> None:
673 await db_session.commit()
674 body = {
675 "author_slug": "testuser",
676 "slug": "my-domain",
677 "display_name": "My Domain",
678 "description": "A domain for testing",
679 "capabilities": {"dimensions": [], "merge_semantics": "three_way"},
680 "viewer_type": "generic",
681 "version": "1.0.0",
682 }
683 r = await client.post("/api/domains", json=body, headers=auth_headers)
684 assert r.status_code == 201
685 resp = r.json()
686 assert "domain_id" in resp
687 assert "scoped_id" in resp
688 assert "manifest_hash" in resp
689
690 async def test_register_requires_auth(
691 self,
692 client: AsyncClient,
693 db_session: AsyncSession,
694 ) -> None:
695 await db_session.commit()
696 body = {
697 "author_slug": "testuser",
698 "slug": "unauthed",
699 "display_name": "Unauthed",
700 "description": "",
701 "capabilities": {},
702 }
703 r = await client.post("/api/domains", json=body)
704 assert r.status_code == 401
705
706 async def test_register_duplicate_409(
707 self,
708 client: AsyncClient,
709 auth_headers: StrDict,
710 db_session: AsyncSession,
711 ) -> None:
712 await db_session.commit()
713 body = {
714 "author_slug": "testuser",
715 "slug": "dup-domain",
716 "display_name": "Dup",
717 "description": "",
718 "capabilities": {},
719 }
720 r1 = await client.post("/api/domains", json=body, headers=auth_headers)
721 assert r1.status_code == 201
722
723 r2 = await client.post("/api/domains", json=body, headers=auth_headers)
724 assert r2.status_code == 409
725
726
727 class TestE2EGetDomain:
728 async def test_get_domain_200(
729 self,
730 client: AsyncClient,
731 db_session: AsyncSession,
732 ) -> None:
733 await _db_domain(db_session, author_slug="alice", slug="midi-detail")
734 await db_session.commit()
735
736 r = await client.get("/api/domains/@alice/midi-detail")
737 assert r.status_code == 200
738 body = r.json()
739 assert body["scoped_id"] == "@alice/midi-detail"
740 assert "capabilities" in body
741 assert "manifest_hash" in body
742
743 async def test_get_domain_404(
744 self,
745 client: AsyncClient,
746 ) -> None:
747 r = await client.get("/api/domains/@nobody/nonexistent")
748 assert r.status_code == 404
749
750 async def test_get_domain_repos_200(
751 self,
752 client: AsyncClient,
753 db_session: AsyncSession,
754 ) -> None:
755 d = await _db_domain(db_session, author_slug="alice", slug="midi-repos")
756 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="public")
757 await db_session.commit()
758
759 r = await client.get("/api/domains/@alice/midi-repos/repos")
760 assert r.status_code == 200
761 body = r.json()
762 assert body["total"] == 1
763 assert len(body["repos"]) == 1
764
765 async def test_get_domain_repos_404_unknown_domain(
766 self,
767 client: AsyncClient,
768 ) -> None:
769 r = await client.get("/api/domains/@nobody/missing/repos")
770 assert r.status_code == 404
771
772 async def test_domain_detail_page_json_carries_viewer_type_and_dimensions(
773 self,
774 client: AsyncClient,
775 db_session: AsyncSession,
776 ) -> None:
777 """musehub#117 DOM_10: the UI page must thread viewer_type and
778 dimensions into page_json so the client-side viewer (domain-viewers.ts)
779 can actually render them — not just the API JSON response."""
780 await _db_domain(
781 db_session,
782 author_slug="alice",
783 slug="piano-roll-detail",
784 viewer_type="piano_roll",
785 capabilities={
786 "dimensions": [{"name": "drums", "description": "Percussion track"}],
787 },
788 )
789 await db_session.commit()
790
791 r = await client.get("/domains/@alice/piano-roll-detail")
792 assert r.status_code == 200
793 assert '"viewerType": "piano_roll"' in r.text
794 assert "drums" in r.text
795 assert 'id="dd-viewer-preview"' in r.text
796
797 async def test_domains_listing_page_reports_real_repo_count_not_install_count(
798 self,
799 client: AsyncClient,
800 db_session: AsyncSession,
801 ) -> None:
802 """Regression test: the /domains listing page once showed
803 domain.install_count mislabeled as a repo count — two repos
804 sharing one domain (one install, two repos) rendered "1 repo"
805 instead of "2 repos". Uses ?format=json to assert on the exact
806 repo_count field the template consumes."""
807 d = await _db_domain(db_session, author_slug="alice", slug="listing-count")
808 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="public")
809 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="public")
810 await db_session.commit()
811
812 r = await client.get("/domains?format=json")
813 assert r.status_code == 200
814 body = r.json()
815 entry = next(dm for dm in body["domains"] if dm["scoped_id"] == "@alice/listing-count")
816 assert entry["repo_count"] == 2
817 assert entry["install_count"] == 0 # distinct metric — nobody installed it
818
819
820 # ===========================================================================
821 # Layer 4 — Stress
822 # ===========================================================================
823
824
825 class TestStress:
826 async def test_list_100_domains(self, db_session: AsyncSession) -> None:
827 for i in range(100):
828 await _db_domain(db_session, slug=f"domain-{i}")
829 await db_session.flush()
830
831 result = await list_domains(db_session, limit=100)
832 assert result.total == 100
833
834 async def test_concurrent_list_domains(self, db_session: AsyncSession) -> None:
835 for i in range(10):
836 await _db_domain(db_session, slug=f"c{i}")
837 await db_session.flush()
838
839 results = await asyncio.gather(
840 *[list_domains(db_session) for _ in range(5)]
841 )
842 assert all(r.total == 10 for r in results)
843
844 async def test_list_repos_for_domain_50_repos(
845 self, db_session: AsyncSession
846 ) -> None:
847 d = await _db_domain(db_session, slug="big-domain")
848 for i in range(50):
849 await _db_repo(db_session, marketplace_domain_id=d.domain_id, visibility="public")
850 await db_session.flush()
851
852 result = await list_repos_for_domain(db_session, d.domain_id, limit=50)
853 assert result.total == 50
854
855
856 # ===========================================================================
857 # Layer 5 — Data Integrity
858 # ===========================================================================
859
860
861 class TestDataIntegrity:
862 async def test_sorted_by_install_count_desc(self, db_session: AsyncSession) -> None:
863 await _db_domain(db_session, slug="low", install_count=1)
864 await _db_domain(db_session, slug="high", install_count=10)
865 await _db_domain(db_session, slug="mid", install_count=5)
866 await db_session.flush()
867
868 result = await list_domains(db_session)
869 counts = [d.install_count for d in result.domains]
870 assert counts == sorted(counts, reverse=True)
871
872 async def test_deprecated_excluded_from_list(self, db_session: AsyncSession) -> None:
873 await _db_domain(db_session, slug="active")
874 await _db_domain(db_session, slug="old", is_deprecated=True)
875 await db_session.flush()
876
877 result = await list_domains(db_session)
878 slugs = [d.slug for d in result.domains]
879 assert "active" in slugs
880 assert "old" not in slugs
881
882 async def test_manifest_hash_matches_capabilities(
883 self, db_session: AsyncSession
884 ) -> None:
885 caps = {"dimensions": [{"name": "tempo"}], "merge_semantics": "ot"}
886 result = await create_domain(
887 db_session,
888 author_user_id="alice",
889 author_slug="alice",
890 slug="verify-hash",
891 display_name="Verify",
892 description="",
893 capabilities=caps,
894 )
895 assert result.manifest_hash == compute_manifest_hash(caps)
896
897 async def test_capabilities_json_not_lossy(self, db_session: AsyncSession) -> None:
898 caps = {
899 "dimensions": [{"name": "tempo", "unit": "bpm"}],
900 "artifact_types": ["audio/midi"],
901 "merge_semantics": "ot",
902 }
903 created = await create_domain(
904 db_session,
905 author_user_id="alice",
906 author_slug="alice",
907 slug="caps-test",
908 display_name="Caps",
909 description="",
910 capabilities=caps,
911 )
912 await db_session.flush()
913
914 retrieved = await get_domain_by_id(db_session, created.domain_id)
915 assert retrieved is not None
916 assert retrieved.capabilities["dimensions"][0]["name"] == "tempo"
917
918 async def test_total_count_reflects_query_filter(
919 self, db_session: AsyncSession
920 ) -> None:
921 await _db_domain(db_session, slug="match-a", display_name="Match This")
922 await _db_domain(db_session, slug="no-match", display_name="Something Else")
923 await db_session.flush()
924
925 result = await list_domains(db_session, query="Match This")
926 assert result.total == 1
927
928
929 # ===========================================================================
930 # Layer 6 — Security
931 # ===========================================================================
932
933
934 class TestSecurity:
935 async def test_post_without_auth_returns_401(
936 self,
937 client: AsyncClient,
938 db_session: AsyncSession,
939 ) -> None:
940 await db_session.commit()
941 r = await client.post(
942 "/api/domains",
943 json={
944 "author_slug": "hack",
945 "slug": "hack-domain",
946 "display_name": "Hack",
947 "description": "",
948 "capabilities": {},
949 },
950 )
951 assert r.status_code == 401
952
953 async def test_duplicate_scoped_id_returns_409(
954 self,
955 client: AsyncClient,
956 auth_headers: StrDict,
957 db_session: AsyncSession,
958 ) -> None:
959 await db_session.commit()
960 body = {
961 "author_slug": "testuser",
962 "slug": "conflict-test",
963 "display_name": "Conflict",
964 "description": "",
965 "capabilities": {},
966 }
967 r1 = await client.post("/api/domains", json=body, headers=auth_headers)
968 assert r1.status_code == 201
969
970 r2 = await client.post("/api/domains", json=body, headers=auth_headers)
971 assert r2.status_code == 409
972 assert "already registered" in r2.json()["detail"]
973
974 async def test_sql_injection_in_query_param_safe(
975 self,
976 client: AsyncClient,
977 db_session: AsyncSession,
978 ) -> None:
979 await db_session.commit()
980 r = await client.get("/api/domains?q='; DROP TABLE musehub_domains; --")
981 assert r.status_code == 200 # parameterized query — safe
982
983 async def test_manifest_hash_tampering_detectable(self) -> None:
984 """Different capabilities always produce different hashes."""
985 original_caps = {"dimensions": [{"name": "tempo"}]}
986 tampered_caps = {"dimensions": [{"name": "tempo"}, {"name": "injected"}]}
987 assert compute_manifest_hash(original_caps) != compute_manifest_hash(tampered_caps)
988
989 async def test_author_slug_must_match_caller_handle(
990 self,
991 client: AsyncClient,
992 auth_headers: StrDict,
993 db_session: AsyncSession,
994 ) -> None:
995 """musehub#117 DOM_02: registering under someone else's author_slug is impersonation.
996
997 auth_headers authenticates as 'testuser' (see conftest._TEST_HANDLE).
998 Before this fix, author_slug was stored verbatim from the request
999 body with no check against the caller's real authenticated handle —
1000 any authenticated user could register '@aaronrene/anything' or
1001 '@gabriel/anything' while being neither.
1002 """
1003 await db_session.commit()
1004 body = {
1005 "author_slug": "someone-else",
1006 "slug": "impersonation-attempt",
1007 "display_name": "Not Mine",
1008 "description": "",
1009 "capabilities": {},
1010 }
1011 r = await client.post("/api/domains", json=body, headers=auth_headers)
1012 assert r.status_code == 403
1013 assert "author_slug" in r.json()["detail"].lower() or "handle" in r.json()["detail"].lower()
1014
1015 async def test_author_slug_matching_own_handle_still_succeeds(
1016 self,
1017 client: AsyncClient,
1018 auth_headers: StrDict,
1019 db_session: AsyncSession,
1020 ) -> None:
1021 """Sanity check: the identity check does not break the legitimate case."""
1022 await db_session.commit()
1023 body = {
1024 "author_slug": "testuser",
1025 "slug": "own-domain-after-fix",
1026 "display_name": "Mine",
1027 "description": "",
1028 "capabilities": {},
1029 }
1030 r = await client.post("/api/domains", json=body, headers=auth_headers)
1031 assert r.status_code == 201
1032
1033
1034 # ===========================================================================
1035 # Layer 7 — Performance
1036 # ===========================================================================
1037
1038
1039 class TestPerformance:
1040 async def test_list_50_domains_under_200ms(self, db_session: AsyncSession) -> None:
1041 for i in range(50):
1042 await _db_domain(db_session, slug=f"perf-{i}")
1043 await db_session.flush()
1044
1045 start = time.perf_counter()
1046 result = await list_domains(db_session, limit=50)
1047 elapsed = time.perf_counter() - start
1048
1049 assert result.total == 50
1050 assert elapsed < 0.2, f"list_domains took {elapsed:.3f}s"
1051
1052 async def test_compute_manifest_hash_fast(self) -> None:
1053 caps = {"dimensions": [{"name": f"dim_{i}"} for i in range(100)]}
1054 start = time.perf_counter()
1055 for _ in range(1000):
1056 compute_manifest_hash(caps)
1057 elapsed = time.perf_counter() - start
1058 assert elapsed < 0.5, f"1000 hash computations took {elapsed:.3f}s"
1059
1060 async def test_create_domain_under_100ms(self, db_session: AsyncSession) -> None:
1061 start = time.perf_counter()
1062 await create_domain(
1063 db_session,
1064 author_user_id="alice",
1065 author_slug="alice",
1066 slug="perf-create",
1067 display_name="Perf",
1068 description="",
1069 capabilities={"dimensions": [], "merge_semantics": "ot"},
1070 )
1071 elapsed = time.perf_counter() - start
1072 assert elapsed < 0.1, f"create_domain took {elapsed:.3f}s"
1073
1074
1075 # ===========================================================================
1076 # Layer 8 — Admin: domain verification
1077 # ===========================================================================
1078
1079 _ADMIN_IDENTITY_ID = compute_identity_id(b"adminuser")
1080 _ADMIN_HANDLE = "adminuser"
1081
1082 _ADMIN_CONTEXT = MSignContext(
1083 handle=_ADMIN_HANDLE,
1084 identity_id=_ADMIN_IDENTITY_ID,
1085 is_agent=False,
1086 is_admin=True,
1087 )
1088
1089
1090 @pytest_asyncio.fixture
1091 async def admin_user(db_session: AsyncSession) -> MusehubIdentity:
1092 identity = MusehubIdentity(
1093 identity_id=_ADMIN_IDENTITY_ID,
1094 handle=_ADMIN_HANDLE,
1095 display_name="Admin User",
1096 identity_type="human",
1097 is_admin=True,
1098 )
1099 db_session.add(identity)
1100 await db_session.commit()
1101 await db_session.refresh(identity)
1102 await db_session.commit()
1103 return identity
1104
1105
1106 @pytest.fixture
1107 def admin_headers(admin_user: MusehubIdentity) -> Generator[dict[str, str], None, None]:
1108 app.dependency_overrides[require_signed_request] = lambda: _ADMIN_CONTEXT
1109 app.dependency_overrides[optional_signed_request] = lambda: _ADMIN_CONTEXT
1110 yield {"Content-Type": "application/json"}
1111 app.dependency_overrides.pop(require_signed_request, None)
1112 app.dependency_overrides.pop(optional_signed_request, None)
1113
1114
1115 class TestAdminVerifyDomain:
1116 async def test_verify_sets_flag(
1117 self,
1118 client: AsyncClient,
1119 admin_headers: Mapping[str, str],
1120 db_session: AsyncSession,
1121 ) -> None:
1122 await _db_domain(db_session, author_slug="alice", slug="verifiable", is_verified=False)
1123 await db_session.commit()
1124
1125 r = await client.post("/api/domains/@alice/verifiable/verify", headers=admin_headers)
1126 assert r.status_code == 200
1127 assert r.json()["is_verified"] is True
1128
1129 async def test_verify_idempotent(
1130 self,
1131 client: AsyncClient,
1132 admin_headers: Mapping[str, str],
1133 db_session: AsyncSession,
1134 ) -> None:
1135 await _db_domain(db_session, author_slug="alice", slug="already-verified", is_verified=True)
1136 await db_session.commit()
1137
1138 r = await client.post("/api/domains/@alice/already-verified/verify", headers=admin_headers)
1139 assert r.status_code == 200
1140 assert r.json()["is_verified"] is True
1141
1142 async def test_unverify_clears_flag(
1143 self,
1144 client: AsyncClient,
1145 admin_headers: Mapping[str, str],
1146 db_session: AsyncSession,
1147 ) -> None:
1148 await _db_domain(db_session, author_slug="alice", slug="to-unverify", is_verified=True)
1149 await db_session.commit()
1150
1151 r = await client.delete("/api/domains/@alice/to-unverify/verify", headers=admin_headers)
1152 assert r.status_code == 200
1153 assert r.json()["is_verified"] is False
1154
1155 async def test_verify_domain_not_found(
1156 self,
1157 client: AsyncClient,
1158 admin_headers: Mapping[str, str],
1159 db_session: AsyncSession,
1160 ) -> None:
1161 await db_session.commit()
1162 r = await client.post("/api/domains/@nobody/ghost/verify", headers=admin_headers)
1163 assert r.status_code == 404
1164
1165 async def test_verify_requires_admin(
1166 self,
1167 client: AsyncClient,
1168 auth_headers: Mapping[str, str],
1169 db_session: AsyncSession,
1170 ) -> None:
1171 await _db_domain(db_session, author_slug="alice", slug="non-admin-target")
1172 await db_session.commit()
1173
1174 r = await client.post("/api/domains/@alice/non-admin-target/verify", headers=auth_headers)
1175 assert r.status_code == 403
1176
1177 async def test_verify_requires_auth(
1178 self,
1179 client: AsyncClient,
1180 db_session: AsyncSession,
1181 ) -> None:
1182 await _db_domain(db_session, author_slug="alice", slug="unauthed-verify")
1183 await db_session.commit()
1184
1185 r = await client.post("/api/domains/@alice/unauthed-verify/verify")
1186 assert r.status_code == 401
1187
1188
1189 # ===========================================================================
1190 # musehub#117 DOM_04 — one canonical URL, checked against the source of
1191 # truth (the actually-mounted FastAPI route), not a second hardcoded string.
1192 # Three places have historically drifted on this single feature: the muse
1193 # CLI targeted /api/v1/domains, the public docs claimed /api/musehub/domains,
1194 # and the real route was /api/domains the whole time. This guards the
1195 # musehub side (route + docs); the CLI side is guarded by
1196 # test_publish_targets_api_domains_not_v1 in the muse repo's
1197 # tests/test_domains_publish.py.
1198 # ===========================================================================
1199
1200
1201 class TestDomainRegistryURLConsistency:
1202 def test_register_domain_route_path(self) -> None:
1203 """The register-domain route must be mounted at /api/domains."""
1204 matches = [r for r in app.routes if getattr(r, "name", "") == "register_domain"]
1205 assert len(matches) == 1, "expected exactly one register_domain route"
1206 assert matches[0].path == "/api/domains"
1207
1208 def test_docs_page_references_the_real_route_path(self) -> None:
1209 """docs_muse_domains.html must reference the actual mounted path.
1210
1211 Previously claimed GET /api/musehub/domains — a URL that never
1212 existed on the server.
1213 """
1214 import pathlib
1215 matches = [r for r in app.routes if getattr(r, "name", "") == "register_domain"]
1216 real_path = matches[0].path
1217
1218 docs_path = (
1219 pathlib.Path(__file__).parent.parent
1220 / "musehub" / "templates" / "musehub" / "pages" / "docs_muse_domains.html"
1221 )
1222 content = docs_path.read_text(encoding="utf-8")
1223 assert f"/api/domains" in content and real_path in content
1224 assert "/api/musehub/domains" not in content, (
1225 "stale URL reintroduced — docs must reference the real mounted route"
1226 )
1227 assert "/api/v1/domains" not in content, (
1228 "stale URL reintroduced — docs must reference the real mounted route"
1229 )
1230
1231
1232 # ===========================================================================
1233 # musehub#117 Phase 1 — schema hardening on the publish path (DOM_05..DOM_08)
1234 # ===========================================================================
1235
1236
1237 class TestDomainSchemaHardening:
1238 """DOM_05/DOM_06/DOM_07: viewer_type and capabilities are now real,
1239 schema-validated fields — malformed input gets a specific 422, not a
1240 silent accept or a generic 500 further down the pipeline."""
1241
1242 async def test_invalid_viewer_type_rejected_422(
1243 self,
1244 client: AsyncClient,
1245 auth_headers: StrDict,
1246 db_session: AsyncSession,
1247 ) -> None:
1248 """DOM_05: viewer_type is a fixed enum, not free text."""
1249 await db_session.commit()
1250 body = {
1251 "author_slug": "testuser",
1252 "slug": "bad-viewer-type",
1253 "display_name": "Bad Viewer",
1254 "description": "",
1255 "capabilities": {},
1256 "viewer_type": "not_a_real_viewer",
1257 }
1258 r = await client.post("/api/domains", json=body, headers=auth_headers)
1259 assert r.status_code == 422
1260
1261 async def test_sequence_viewer_rejected_422(
1262 self,
1263 client: AsyncClient,
1264 auth_headers: StrDict,
1265 db_session: AsyncSession,
1266 ) -> None:
1267 """DOM_05: 'sequence_viewer' was documented but never implemented in
1268 the frontend — dropped from the enum rather than kept as a dead
1269 value (see musehub#117 Phase 2)."""
1270 await db_session.commit()
1271 body = {
1272 "author_slug": "testuser",
1273 "slug": "sequence-viewer-attempt",
1274 "display_name": "Sequence",
1275 "description": "",
1276 "capabilities": {},
1277 "viewer_type": "sequence_viewer",
1278 }
1279 r = await client.post("/api/domains", json=body, headers=auth_headers)
1280 assert r.status_code == 422
1281
1282 async def test_valid_viewer_types_accepted(
1283 self,
1284 client: AsyncClient,
1285 auth_headers: StrDict,
1286 db_session: AsyncSession,
1287 ) -> None:
1288 """Sanity check: the real palette values still work."""
1289 await db_session.commit()
1290 for viewer_type in ("symbol_graph", "piano_roll", "generic"):
1291 body = {
1292 "author_slug": "testuser",
1293 "slug": f"viewer-ok-{viewer_type}",
1294 "display_name": "OK",
1295 "description": "",
1296 "capabilities": {},
1297 "viewer_type": viewer_type,
1298 }
1299 r = await client.post("/api/domains", json=body, headers=auth_headers)
1300 assert r.status_code == 201, (viewer_type, r.text)
1301
1302 async def test_invalid_merge_semantics_rejected_422(
1303 self,
1304 client: AsyncClient,
1305 auth_headers: StrDict,
1306 db_session: AsyncSession,
1307 ) -> None:
1308 """DOM_06: merge_semantics is a fixed enum."""
1309 await db_session.commit()
1310 body = {
1311 "author_slug": "testuser",
1312 "slug": "bad-merge-semantics",
1313 "display_name": "Bad",
1314 "description": "",
1315 "capabilities": {"merge_semantics": "yolo"},
1316 }
1317 r = await client.post("/api/domains", json=body, headers=auth_headers)
1318 assert r.status_code == 422
1319
1320 async def test_malformed_dimensions_rejected_422(
1321 self,
1322 client: AsyncClient,
1323 auth_headers: StrDict,
1324 db_session: AsyncSession,
1325 ) -> None:
1326 """DOM_06: a dimension must be {name, description}, not an arbitrary shape."""
1327 await db_session.commit()
1328 body = {
1329 "author_slug": "testuser",
1330 "slug": "bad-dimensions",
1331 "display_name": "Bad",
1332 "description": "",
1333 "capabilities": {"dimensions": ["just-a-string-not-an-object"]},
1334 }
1335 r = await client.post("/api/domains", json=body, headers=auth_headers)
1336 assert r.status_code == 422
1337
1338 async def test_empty_capabilities_still_valid(
1339 self,
1340 client: AsyncClient,
1341 auth_headers: StrDict,
1342 db_session: AsyncSession,
1343 ) -> None:
1344 """Sanity check: an empty manifest (no capabilities declared yet)
1345 must remain valid — all fields default to safe empty values."""
1346 await db_session.commit()
1347 body = {
1348 "author_slug": "testuser",
1349 "slug": "minimal-manifest",
1350 "display_name": "Minimal",
1351 "description": "",
1352 "capabilities": {},
1353 }
1354 r = await client.post("/api/domains", json=body, headers=auth_headers)
1355 assert r.status_code == 201
1356
1357 async def test_too_many_dimensions_rejected_422(
1358 self,
1359 client: AsyncClient,
1360 auth_headers: StrDict,
1361 db_session: AsyncSession,
1362 ) -> None:
1363 """DOM_07: dimension count is capped."""
1364 await db_session.commit()
1365 body = {
1366 "author_slug": "testuser",
1367 "slug": "too-many-dimensions",
1368 "display_name": "Too Many",
1369 "description": "",
1370 "capabilities": {
1371 "dimensions": [{"name": f"dim{i}"} for i in range(51)],
1372 },
1373 }
1374 r = await client.post("/api/domains", json=body, headers=auth_headers)
1375 assert r.status_code == 422
1376
1377 async def test_oversized_capabilities_rejected_422(
1378 self,
1379 client: AsyncClient,
1380 auth_headers: StrDict,
1381 db_session: AsyncSession,
1382 ) -> None:
1383 """DOM_07: total serialized capabilities size is capped at 16 KB."""
1384 await db_session.commit()
1385 body = {
1386 "author_slug": "testuser",
1387 "slug": "oversized-capabilities",
1388 "display_name": "Oversized",
1389 "description": "",
1390 "capabilities": {
1391 "dimensions": [
1392 {"name": f"dim{i}", "description": "x" * 500} for i in range(50)
1393 ],
1394 },
1395 }
1396 r = await client.post("/api/domains", json=body, headers=auth_headers)
1397 assert r.status_code == 422
1398
1399
1400 class TestDomainRegistrationRateLimit:
1401 """DOM_08: POST /api/domains is rate limited — protects the schema-
1402 validated but still-unbounded-frequency publish path from spam/DoS."""
1403
1404 async def test_register_domain_429_after_limit_exceeded(
1405 self,
1406 client: AsyncClient,
1407 auth_headers: StrDict,
1408 db_session: AsyncSession,
1409 ) -> None:
1410 from musehub.rate_limits import DOMAIN_REGISTER_LIMIT
1411 limit_n = int(DOMAIN_REGISTER_LIMIT.split("/")[0])
1412 await db_session.commit()
1413 for i in range(limit_n):
1414 body = {
1415 "author_slug": "testuser",
1416 "slug": f"rate-limit-domain-{i}",
1417 "display_name": "RL",
1418 "description": "",
1419 "capabilities": {},
1420 }
1421 r = await client.post("/api/domains", json=body, headers=auth_headers)
1422 assert r.status_code == 201, r.text
1423
1424 over = await client.post(
1425 "/api/domains",
1426 json={
1427 "author_slug": "testuser",
1428 "slug": "rate-limit-domain-over",
1429 "display_name": "Over",
1430 "description": "",
1431 "capabilities": {},
1432 },
1433 headers=auth_headers,
1434 )
1435 assert over.status_code == 429
1436
1437
1438 # ===========================================================================
1439 # musehub#117 Phase 3 (DOM_12) — repo home page renders the marketplace
1440 # domain badge when a repo is linked, via repo_nav.html's dormant
1441 # repo_domain slot.
1442 # ===========================================================================
1443
1444
1445 class TestRepoHomeMarketplaceDomainBadge:
1446 async def test_linked_repo_shows_domain_badge(
1447 self,
1448 client: AsyncClient,
1449 db_session: AsyncSession,
1450 ) -> None:
1451 domain = await _db_domain(
1452 db_session,
1453 author_slug="alice",
1454 slug="badge-domain",
1455 viewer_type="piano_roll",
1456 )
1457 repo = await _db_repo(db_session, owner="alice", visibility="public")
1458 repo.marketplace_domain_id = domain.domain_id
1459 await db_session.commit()
1460
1461 resp = await client.get(f"/alice/{repo.slug}")
1462 assert resp.status_code == 200
1463 assert "nav-domain-badge" in resp.text
1464 assert domain.scoped_id in resp.text
1465
1466 async def test_unlinked_repo_shows_no_domain_badge(
1467 self,
1468 client: AsyncClient,
1469 db_session: AsyncSession,
1470 ) -> None:
1471 repo = await _db_repo(db_session, owner="alice", visibility="public")
1472 await db_session.commit()
1473
1474 resp = await client.get(f"/alice/{repo.slug}")
1475 assert resp.status_code == 200
1476 assert "nav-domain-badge" not in resp.text
1477
1478
1479 # ===========================================================================
1480 # musehub#117 Phase 5 (DOM_16, DOM_17) — full CRUD parity: Update (PATCH)
1481 # and Delete (DELETE, soft via is_deprecated) for the domains entity.
1482 # auth_headers authenticates as handle "testuser" (see conftest.py); domains
1483 # built with author_slug="testuser" are "owned" by that fixture for the
1484 # owner-only guard tests.
1485 # ===========================================================================
1486
1487
1488 class TestIntegrationUpdateDomain:
1489 async def test_update_display_name_and_description(
1490 self,
1491 db_session: AsyncSession,
1492 ) -> None:
1493 from musehub.services.musehub_domains import update_domain
1494
1495 d = await _db_domain(db_session, author_slug="alice", slug="to-update", display_name="Old", description="old desc")
1496 await db_session.commit()
1497
1498 updated = await update_domain(
1499 db_session,
1500 d.domain_id,
1501 display_name="New",
1502 description="new desc",
1503 )
1504 await db_session.commit()
1505
1506 assert updated is not None
1507 assert updated.display_name == "New"
1508 assert updated.description == "new desc"
1509 # untouched fields stay as-is
1510 assert updated.viewer_type == "generic"
1511
1512 async def test_update_capabilities_recomputes_manifest_hash(
1513 self,
1514 db_session: AsyncSession,
1515 ) -> None:
1516 from musehub.services.musehub_domains import compute_manifest_hash, update_domain
1517
1518 d = await _db_domain(db_session, author_slug="alice", slug="hash-update")
1519 old_hash = d.manifest_hash
1520 await db_session.commit()
1521
1522 new_caps: JSONObject = {"dimensions": [{"name": "bass", "description": ""}], "merge_semantics": "crdt"}
1523 updated = await update_domain(db_session, d.domain_id, capabilities=new_caps)
1524 await db_session.commit()
1525
1526 assert updated is not None
1527 assert updated.manifest_hash != old_hash
1528 assert updated.manifest_hash == compute_manifest_hash(new_caps)
1529
1530 async def test_update_partial_leaves_other_fields_unchanged(
1531 self,
1532 db_session: AsyncSession,
1533 ) -> None:
1534 from musehub.services.musehub_domains import update_domain
1535
1536 d = await _db_domain(db_session, author_slug="alice", slug="partial-update", viewer_type="piano_roll", version="2.0.0")
1537 await db_session.commit()
1538
1539 updated = await update_domain(db_session, d.domain_id, description="only this changed")
1540 await db_session.commit()
1541
1542 assert updated is not None
1543 assert updated.description == "only this changed"
1544 assert updated.viewer_type == "piano_roll"
1545 assert updated.version == "2.0.0"
1546
1547 async def test_update_nonexistent_domain_returns_none(
1548 self,
1549 db_session: AsyncSession,
1550 ) -> None:
1551 from musehub.services.musehub_domains import update_domain
1552
1553 result = await update_domain(db_session, "sha256:doesnotexist", display_name="X")
1554 assert result is None
1555
1556
1557 class TestIntegrationSetDomainDeprecated:
1558 async def test_deprecate_sets_flag(
1559 self,
1560 db_session: AsyncSession,
1561 ) -> None:
1562 from musehub.services.musehub_domains import set_domain_deprecated
1563
1564 d = await _db_domain(db_session, author_slug="alice", slug="to-deprecate", is_deprecated=False)
1565 await db_session.commit()
1566
1567 updated = await set_domain_deprecated(db_session, d.domain_id, deprecated=True)
1568 await db_session.commit()
1569
1570 assert updated is not None
1571 assert updated.is_deprecated is True
1572
1573 async def test_undeprecate_clears_flag(
1574 self,
1575 db_session: AsyncSession,
1576 ) -> None:
1577 from musehub.services.musehub_domains import set_domain_deprecated
1578
1579 d = await _db_domain(db_session, author_slug="alice", slug="to-undeprecate", is_deprecated=True)
1580 await db_session.commit()
1581
1582 updated = await set_domain_deprecated(db_session, d.domain_id, deprecated=False)
1583 await db_session.commit()
1584
1585 assert updated is not None
1586 assert updated.is_deprecated is False
1587
1588 async def test_deprecated_domain_excluded_from_list(
1589 self,
1590 db_session: AsyncSession,
1591 ) -> None:
1592 from musehub.services.musehub_domains import list_domains, set_domain_deprecated
1593
1594 d = await _db_domain(db_session, author_slug="alice", slug="hidden-after-deprecate")
1595 await db_session.commit()
1596
1597 await set_domain_deprecated(db_session, d.domain_id, deprecated=True)
1598 await db_session.commit()
1599
1600 result = await list_domains(db_session, query="hidden-after-deprecate")
1601 assert result.total == 0
1602
1603 async def test_deprecated_domain_still_fetchable_by_scoped_id(
1604 self,
1605 db_session: AsyncSession,
1606 ) -> None:
1607 from musehub.services.musehub_domains import get_domain_by_scoped_id, set_domain_deprecated
1608
1609 d = await _db_domain(db_session, author_slug="alice", slug="deprecated-but-fetchable")
1610 await db_session.commit()
1611
1612 await set_domain_deprecated(db_session, d.domain_id, deprecated=True)
1613 await db_session.commit()
1614
1615 fetched = await get_domain_by_scoped_id(db_session, "alice", "deprecated-but-fetchable")
1616 assert fetched is not None
1617 assert fetched.is_deprecated is True
1618
1619
1620 class TestE2EUpdateDomain:
1621 async def test_update_200(
1622 self,
1623 client: AsyncClient,
1624 auth_headers: StrDict,
1625 db_session: AsyncSession,
1626 ) -> None:
1627 await _db_domain(db_session, author_slug="testuser", slug="e2e-update", display_name="Before")
1628 await db_session.commit()
1629
1630 r = await client.patch(
1631 "/api/domains/@testuser/e2e-update",
1632 json={"display_name": "After"},
1633 headers=auth_headers,
1634 )
1635 assert r.status_code == 200, r.text
1636 assert r.json()["display_name"] == "After"
1637
1638 # Verify the change is really persisted, not just echoed back.
1639 get_r = await client.get("/api/domains/@testuser/e2e-update")
1640 assert get_r.json()["display_name"] == "After"
1641
1642 async def test_update_requires_auth(
1643 self,
1644 client: AsyncClient,
1645 db_session: AsyncSession,
1646 ) -> None:
1647 await _db_domain(db_session, author_slug="testuser", slug="e2e-update-unauthed")
1648 await db_session.commit()
1649
1650 r = await client.patch(
1651 "/api/domains/@testuser/e2e-update-unauthed",
1652 json={"display_name": "Nope"},
1653 )
1654 assert r.status_code == 401
1655
1656 async def test_update_requires_ownership(
1657 self,
1658 client: AsyncClient,
1659 auth_headers: StrDict,
1660 db_session: AsyncSession,
1661 ) -> None:
1662 await _db_domain(db_session, author_slug="alice", slug="e2e-update-not-mine")
1663 await db_session.commit()
1664
1665 r = await client.patch(
1666 "/api/domains/@alice/e2e-update-not-mine",
1667 json={"display_name": "Hijacked"},
1668 headers=auth_headers,
1669 )
1670 assert r.status_code == 403
1671
1672 async def test_update_404_unknown_domain(
1673 self,
1674 client: AsyncClient,
1675 auth_headers: StrDict,
1676 db_session: AsyncSession,
1677 ) -> None:
1678 await db_session.commit()
1679 r = await client.patch(
1680 "/api/domains/@testuser/does-not-exist",
1681 json={"display_name": "X"},
1682 headers=auth_headers,
1683 )
1684 assert r.status_code == 404
1685
1686 async def test_update_invalid_viewer_type_422(
1687 self,
1688 client: AsyncClient,
1689 auth_headers: StrDict,
1690 db_session: AsyncSession,
1691 ) -> None:
1692 await _db_domain(db_session, author_slug="testuser", slug="e2e-update-bad-viewer")
1693 await db_session.commit()
1694
1695 r = await client.patch(
1696 "/api/domains/@testuser/e2e-update-bad-viewer",
1697 json={"viewer_type": "not_a_real_viewer"},
1698 headers=auth_headers,
1699 )
1700 assert r.status_code == 422
1701
1702 async def test_update_capabilities_over_dimension_cap_422(
1703 self,
1704 client: AsyncClient,
1705 auth_headers: StrDict,
1706 db_session: AsyncSession,
1707 ) -> None:
1708 await _db_domain(db_session, author_slug="testuser", slug="e2e-update-too-many-dims")
1709 await db_session.commit()
1710
1711 r = await client.patch(
1712 "/api/domains/@testuser/e2e-update-too-many-dims",
1713 json={"capabilities": {"dimensions": [{"name": f"d{i}"} for i in range(51)]}},
1714 headers=auth_headers,
1715 )
1716 assert r.status_code == 422
1717
1718
1719 class TestE2EDeleteDomain:
1720 async def test_delete_204(
1721 self,
1722 client: AsyncClient,
1723 auth_headers: StrDict,
1724 db_session: AsyncSession,
1725 ) -> None:
1726 await _db_domain(db_session, author_slug="testuser", slug="e2e-delete")
1727 await db_session.commit()
1728
1729 r = await client.delete("/api/domains/@testuser/e2e-delete", headers=auth_headers)
1730 assert r.status_code == 204
1731
1732 async def test_deleted_domain_gone_from_list_but_fetchable_directly(
1733 self,
1734 client: AsyncClient,
1735 auth_headers: StrDict,
1736 db_session: AsyncSession,
1737 ) -> None:
1738 await _db_domain(db_session, author_slug="testuser", slug="e2e-delete-visibility")
1739 await db_session.commit()
1740
1741 del_r = await client.delete("/api/domains/@testuser/e2e-delete-visibility", headers=auth_headers)
1742 assert del_r.status_code == 204
1743
1744 list_r = await client.get("/api/domains", params={"q": "e2e-delete-visibility"})
1745 assert list_r.json()["total"] == 0
1746
1747 detail_r = await client.get("/api/domains/@testuser/e2e-delete-visibility")
1748 assert detail_r.status_code == 200
1749 assert detail_r.json()["is_deprecated"] is True
1750
1751 async def test_delete_requires_auth(
1752 self,
1753 client: AsyncClient,
1754 db_session: AsyncSession,
1755 ) -> None:
1756 await _db_domain(db_session, author_slug="testuser", slug="e2e-delete-unauthed")
1757 await db_session.commit()
1758
1759 r = await client.delete("/api/domains/@testuser/e2e-delete-unauthed")
1760 assert r.status_code == 401
1761
1762 async def test_delete_requires_ownership(
1763 self,
1764 client: AsyncClient,
1765 auth_headers: StrDict,
1766 db_session: AsyncSession,
1767 ) -> None:
1768 await _db_domain(db_session, author_slug="alice", slug="e2e-delete-not-mine")
1769 await db_session.commit()
1770
1771 r = await client.delete("/api/domains/@alice/e2e-delete-not-mine", headers=auth_headers)
1772 assert r.status_code == 403
1773
1774 async def test_delete_404_unknown_domain(
1775 self,
1776 client: AsyncClient,
1777 auth_headers: StrDict,
1778 db_session: AsyncSession,
1779 ) -> None:
1780 await db_session.commit()
1781 r = await client.delete("/api/domains/@testuser/does-not-exist", headers=auth_headers)
1782 assert r.status_code == 404
1783
1784 async def test_delete_idempotent_already_deprecated_404(
1785 self,
1786 client: AsyncClient,
1787 auth_headers: StrDict,
1788 db_session: AsyncSession,
1789 ) -> None:
1790 """Deleting an already-deprecated domain 404s, matching repos.py's
1791 delete_repo docstring convention ('already deleted' -> 404, not a
1792 silent no-op 204) — the caller can tell 'gone' from 'never existed'
1793 the same way either time, but a second delete on the same resource
1794 should not report fresh success."""
1795 await _db_domain(db_session, author_slug="testuser", slug="e2e-delete-twice", is_deprecated=True)
1796 await db_session.commit()
1797
1798 r = await client.delete("/api/domains/@testuser/e2e-delete-twice", headers=auth_headers)
1799 assert r.status_code == 404
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 49 days ago