gabriel / musehub public
test_mist_routes.py python
1,084 lines 41.1 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
1 """Section 16 — Mists API Routes: 8-layer test suite.
2
3 Tests cover all nine JSON API endpoints in musehub/api/routes/musehub/mists.py.
4
5 Layer 1 Unit
6 - TestUnitEmbedCodes: embed code shapes (iframe, js, badge)
7
8 Layer 2 Integration
9 - TestIntegrationCreate: POST /api/mists — create, 409 on duplicate, fields stored
10 - TestIntegrationGet: GET /api/mists/{id} — found/not found, view count
11 - TestIntegrationExplore: GET /api/mists/explore — public feed, artifact_type filter, pagination
12 - TestIntegrationList: GET /api/{owner}/mists — owner filter, secret visibility
13 - TestIntegrationUpdate: PATCH /api/mists/{id} — partial update, owner guard
14 - TestIntegrationDelete: DELETE /api/mists/{id} — 204, owner guard, 404
15 - TestIntegrationFork: POST /api/mists/{id}/fork — creates fork, depth limit
16 - TestIntegrationForkList: GET /api/mists/{id}/forks — direct forks list
17 - TestIntegrationEmbed: GET /api/{owner}/mists/{id}/embed — embed codes, counter
18
19 Layer 3 Edge Cases
20 - TestEdgeCases: explore before /{id} route ordering; no auth returns 403 on secret;
21 content analysis fills artifact_type/language; idempotent content → same mist_id → 409
22
23 Layer 4 Stress
24 - TestStress: create 20 mists via HTTP, explore paginates correctly
25
26 Layer 5 Data Integrity
27 - TestDataIntegrity: view_count increments on each GET; embed_count on embed;
28 fork_count on parent after fork; version increments on PATCH content
29
30 Layer 6 Performance
31 - TestPerformance: explore 50 mists <1 s
32
33 Layer 7 Security
34 - TestSecurity: write endpoints require auth (401 without auth_headers);
35 non-owner update/delete returns 404; secret mist returns 403 for non-owner
36
37 Layer 8 Docstrings / API
38 - TestDocstrings: every route handler has a docstring
39 """
40
41 from __future__ import annotations
42
43 import secrets
44 import time
45
46 import pytest
47 from httpx import AsyncClient
48 from sqlalchemy.ext.asyncio import AsyncSession
49
50 from datetime import datetime, timezone
51 from musehub.core.genesis import compute_identity_id, compute_repo_id
52 from musehub.db.musehub_repo_models import MusehubRepo
53 from musehub.types.json_types import JSONObject, JSONValue, StrDict
54
55
56 # ===========================================================================
57 # Helpers
58 # ===========================================================================
59
60 _OWNER = "testuser" # matches conftest._TEST_HANDLE
61
62 _PY_CONTENT = "def hello():\n return 'hello'\n"
63 _MD_CONTENT = "# Hello World\n\nThis is a test mist.\n"
64
65
66 def _mist_payload(**overrides: JSONValue) -> JSONObject:
67 base: JSONObject = {
68 "filename": "hello.py",
69 "content": _PY_CONTENT,
70 "visibility": "public",
71 "tags": ["python", "test"],
72 }
73 base.update(overrides)
74 return base
75
76
77 async def _db_repo(session: AsyncSession, owner: str = _OWNER) -> MusehubRepo:
78 """Create a MusehubRepo directly in the DB (no HTTP)."""
79 from musehub.db.musehub_repo_models import MusehubRepo
80
81 slug = secrets.token_hex(6)
82 created_at = datetime.now(tz=timezone.utc)
83 owner_id = compute_identity_id(owner.encode())
84 repo = MusehubRepo(
85 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
86 name=slug,
87 owner=owner,
88 slug=slug,
89 visibility="public",
90 owner_user_id=owner_id,
91 created_at=created_at,
92 updated_at=created_at,
93 )
94 session.add(repo)
95 await session.flush()
96 await session.refresh(repo)
97 return repo
98
99
100 async def _create(client: AsyncClient, auth_headers: StrDict, **overrides: JSONValue) -> JSONObject:
101 r = await client.post("/api/mists", json=_mist_payload(**overrides), headers=auth_headers)
102 assert r.status_code == 201, r.text
103 return dict(r.json())
104
105
106 # ===========================================================================
107 # Layer 1 — Unit
108 # ===========================================================================
109
110
111 class TestUnitEmbedCodes:
112 """Embed code shapes are well-formed."""
113
114 @pytest.mark.asyncio
115 async def test_embed_returns_three_codes(
116 self, client: AsyncClient, auth_headers: StrDict
117 ) -> None:
118 m = await _create(client, auth_headers)
119 mid = m["mistId"]
120 r = await client.get(f"/api/{_OWNER}/mists/{mid}/embed", headers=auth_headers)
121 assert r.status_code == 200
122 body = r.json()
123 assert "<iframe" in body["iframe"]
124 assert "<script" in body["js"]
125 assert "[![Mist" in body["badge"]
126
127 @pytest.mark.asyncio
128 async def test_embed_iframe_contains_mist_id(
129 self, client: AsyncClient, auth_headers: StrDict
130 ) -> None:
131 m = await _create(client, auth_headers)
132 mid = m["mistId"]
133 r = await client.get(f"/api/{_OWNER}/mists/{mid}/embed", headers=auth_headers)
134 assert mid in r.json()["iframe"]
135
136 @pytest.mark.asyncio
137 async def test_embed_badge_links_to_detail(
138 self, client: AsyncClient, auth_headers: StrDict
139 ) -> None:
140 m = await _create(client, auth_headers)
141 mid = m["mistId"]
142 r = await client.get(f"/api/{_OWNER}/mists/{mid}/embed", headers=auth_headers)
143 badge = r.json()["badge"]
144 assert mid in badge
145 assert _OWNER in badge
146
147
148 # ===========================================================================
149 # Layer 2 — Integration
150 # ===========================================================================
151
152
153 class TestIntegrationCreate:
154 """POST /api/mists"""
155
156 @pytest.mark.asyncio
157 async def test_create_returns_201_and_mist_id(
158 self, client: AsyncClient, auth_headers: StrDict
159 ) -> None:
160 r = await client.post("/api/mists", json=_mist_payload(), headers=auth_headers)
161 assert r.status_code == 201
162 body = r.json()
163 assert len(body["mistId"]) == 12
164 assert body["owner"] == _OWNER
165 assert body["filename"] == "hello.py"
166
167 @pytest.mark.asyncio
168 async def test_create_detects_artifact_type(
169 self, client: AsyncClient, auth_headers: StrDict
170 ) -> None:
171 r = await client.post("/api/mists", json=_mist_payload(), headers=auth_headers)
172 body = r.json()
173 assert body["artifactType"] == "code"
174 assert body["language"] == "python"
175
176 @pytest.mark.asyncio
177 async def test_create_stores_tags(
178 self, client: AsyncClient, auth_headers: StrDict
179 ) -> None:
180 m = await _create(client, auth_headers, tags=["ai", "music"])
181 assert m["tags"] == ["ai", "music"]
182
183 @pytest.mark.asyncio
184 async def test_create_stores_title_description(
185 self, client: AsyncClient, auth_headers: StrDict
186 ) -> None:
187 m = await _create(
188 client, auth_headers,
189 content=f"# unique {secrets.token_hex(16)}",
190 filename="notes.md",
191 title="My Notes",
192 description="A description",
193 )
194 assert m["title"] == "My Notes"
195 assert m["description"] == "A description"
196
197 @pytest.mark.asyncio
198 async def test_create_secret_visibility(
199 self, client: AsyncClient, auth_headers: StrDict
200 ) -> None:
201 m = await _create(
202 client, auth_headers,
203 content=f"secret {secrets.token_hex(16)}",
204 visibility="secret",
205 )
206 assert m["visibility"] == "secret"
207
208 @pytest.mark.asyncio
209 async def test_create_duplicate_content_returns_409(
210 self, client: AsyncClient, auth_headers: StrDict
211 ) -> None:
212 payload = _mist_payload(content="exactly the same content")
213 r1 = await client.post("/api/mists", json=payload, headers=auth_headers)
214 assert r1.status_code == 201
215
216 r2 = await client.post("/api/mists", json=payload, headers=auth_headers)
217 assert r2.status_code == 409
218
219 @pytest.mark.asyncio
220 async def test_create_returns_url_when_base_url_available(
221 self, client: AsyncClient, auth_headers: StrDict
222 ) -> None:
223 m = await _create(client, auth_headers, content=f"unique {secrets.token_hex(16)}")
224 # base_url from test client is "http://test"
225 assert m["url"].startswith("http://")
226 assert _OWNER in m["url"]
227 assert "mists" in m["url"]
228
229 @pytest.mark.asyncio
230 async def test_create_signed_flag_when_gpg_signature_provided(
231 self, client: AsyncClient, auth_headers: StrDict
232 ) -> None:
233 m = await _create(
234 client, auth_headers,
235 content=f"signed {secrets.token_hex(16)}",
236 gpgSignature="-----BEGIN PGP SIGNATURE-----\n...\n-----END PGP SIGNATURE-----",
237 )
238 assert m["signed"] is True
239
240 @pytest.mark.asyncio
241 async def test_create_requires_auth(self, client: AsyncClient) -> None:
242 r = await client.post("/api/mists", json=_mist_payload())
243 assert r.status_code == 401
244
245
246 class TestIntegrationGet:
247 """GET /api/mists/{mist_id}"""
248
249 @pytest.mark.asyncio
250 async def test_get_existing_returns_200(
251 self, client: AsyncClient, auth_headers: StrDict
252 ) -> None:
253 m = await _create(client, auth_headers, content=f"x {secrets.token_hex(16)}")
254 r = await client.get(f"/api/mists/{m['mistId']}")
255 assert r.status_code == 200
256 body = r.json()
257 assert body["mistId"] == m["mistId"]
258 assert body["content"] == m["content"]
259
260 @pytest.mark.asyncio
261 async def test_get_not_found_returns_404(self, client: AsyncClient) -> None:
262 r = await client.get("/api/mists/notexist0000")
263 assert r.status_code == 404
264
265 @pytest.mark.asyncio
266 async def test_get_increments_view_count(
267 self, client: AsyncClient, auth_headers: StrDict
268 ) -> None:
269 m = await _create(client, auth_headers, content=f"vc {secrets.token_hex(16)}")
270 mid = m["mistId"]
271 # Each GET increments the counter; the response shows the pre-increment value.
272 # After 3 GETs the DB has view_count=3; the 4th GET reads 3 then increments to 4.
273 # We conservatively assert >= 2 to tolerate any read-committed visibility edge cases.
274 await client.get(f"/api/mists/{mid}")
275 await client.get(f"/api/mists/{mid}")
276 r = await client.get(f"/api/mists/{mid}")
277 assert r.json()["viewCount"] >= 2
278
279 @pytest.mark.asyncio
280 async def test_get_secret_by_owner_succeeds(
281 self, client: AsyncClient, auth_headers: StrDict
282 ) -> None:
283 m = await _create(
284 client, auth_headers,
285 content=f"sec {secrets.token_hex(16)}",
286 visibility="secret",
287 )
288 r = await client.get(f"/api/mists/{m['mistId']}", headers=auth_headers)
289 assert r.status_code == 200
290
291 @pytest.mark.asyncio
292 async def test_get_secret_without_auth_returns_403(
293 self, client: AsyncClient, db_session: AsyncSession
294 ) -> None:
295 # Create the secret mist directly via service (no auth_headers fixture active,
296 # so client.get() is truly unauthenticated).
297 from musehub.services.musehub_mists import create_mist as _svc_create
298 repo = await _db_repo(db_session)
299 m = await _svc_create(
300 db_session,
301 mist_id=secrets.token_hex(6),
302 filename="secret.py",
303 content=f"sec403 {secrets.token_hex(16)}",
304 owner=_OWNER,
305 repo_id=str(repo.repo_id),
306 visibility="secret",
307 )
308 await db_session.commit()
309 r = await client.get(f"/api/mists/{m.mist_id}")
310 assert r.status_code == 403
311
312
313 class TestIntegrationExplore:
314 """GET /api/mists/explore"""
315
316 @pytest.mark.asyncio
317 async def test_explore_returns_public_mists(
318 self, client: AsyncClient, auth_headers: StrDict
319 ) -> None:
320 await _create(client, auth_headers, content=f"exp1 {secrets.token_hex(16)}")
321 await _create(client, auth_headers, content=f"exp2 {secrets.token_hex(16)}")
322 r = await client.get("/api/mists/explore")
323 assert r.status_code == 200
324 body = r.json()
325 assert body["total"] >= 2
326
327 @pytest.mark.asyncio
328 async def test_explore_excludes_secret_mists(
329 self, client: AsyncClient, db_session: AsyncSession
330 ) -> None:
331 # Use DB directly so the explore request is unauthenticated (no auth_headers fixture).
332 from muse.plugins.mist.plugin import compute_mist_id
333 from musehub.services.musehub_mists import create_mist as _svc_create
334
335 pub_content = f"pub {secrets.token_hex(16)}"
336 sec_content = f"sec {secrets.token_hex(16)}"
337 repo = await _db_repo(db_session)
338 await _svc_create(
339 db_session, mist_id=compute_mist_id(pub_content.encode()),
340 filename="pub.py", content=pub_content, owner=_OWNER,
341 repo_id=str(repo.repo_id), visibility="public",
342 )
343 repo2 = await _db_repo(db_session)
344 secret_id = compute_mist_id(sec_content.encode())
345 await _svc_create(
346 db_session, mist_id=secret_id,
347 filename="sec.py", content=sec_content, owner=_OWNER,
348 repo_id=str(repo2.repo_id), visibility="secret",
349 )
350 await db_session.commit()
351
352 r = await client.get("/api/mists/explore")
353 mist_ids = [m["mistId"] for m in r.json()["mists"]]
354 assert secret_id not in mist_ids
355
356 @pytest.mark.asyncio
357 async def test_explore_artifact_type_filter(
358 self, client: AsyncClient, auth_headers: StrDict
359 ) -> None:
360 await _create(client, auth_headers, content=f"code {secrets.token_hex(16)}", filename="a.py")
361 await _create(client, auth_headers, content=f"prose {secrets.token_hex(16)}", filename="b.md")
362
363 r = await client.get("/api/mists/explore?artifact_type=code")
364 assert r.status_code == 200
365 body = r.json()
366 assert all(m["artifactType"] == "code" for m in body["mists"])
367
368 @pytest.mark.asyncio
369 async def test_explore_pagination(
370 self, client: AsyncClient, db_session: AsyncSession
371 ) -> None:
372 from datetime import datetime, timezone, timedelta
373 from muse.plugins.mist.plugin import compute_mist_id
374 from musehub.services.musehub_mists import create_mist as _svc_create
375 from musehub.db.musehub_repo_models import MusehubMist
376
377 # Use a unique artifact_type so these rows are isolated from all other
378 # test data regardless of test execution order.
379 unique_type = f"dataset_{secrets.token_hex(4)}"
380 base_time = datetime.now(tz=timezone.utc)
381 mist_ids = []
382 for i in range(5):
383 content = f"pag{i} {secrets.token_hex(16)}"
384 mid = compute_mist_id(content.encode())
385 mist_ids.append(mid)
386 repo = await _db_repo(db_session)
387 row = MusehubMist(
388 mist_id=mid,
389 repo_id=str(repo.repo_id),
390 owner=_OWNER,
391 filename="p.py",
392 content=content,
393 artifact_type=unique_type,
394 language="python",
395 visibility="public",
396 tags=[],
397 symbol_anchors=[],
398 created_at=base_time + timedelta(seconds=i),
399 updated_at=base_time + timedelta(seconds=i),
400 )
401 db_session.add(row)
402 await db_session.commit()
403
404 r1 = await client.get(
405 "/api/mists/explore",
406 params={"artifact_type": unique_type, "limit": 3},
407 )
408 body1 = r1.json()
409 assert len(body1["mists"]) == 3
410 assert body1["nextCursor"] is not None
411
412 r2 = await client.get(
413 "/api/mists/explore",
414 params={"artifact_type": unique_type, "limit": 3, "cursor": body1["nextCursor"]},
415 )
416 body2 = r2.json()
417 assert len(body2["mists"]) == 2
418 # No overlap
419 ids1 = {m["mistId"] for m in body1["mists"]}
420 ids2 = {m["mistId"] for m in body2["mists"]}
421 assert ids1.isdisjoint(ids2)
422
423
424 class TestIntegrationList:
425 """GET /api/{owner}/mists"""
426
427 @pytest.mark.asyncio
428 async def test_list_owner_mists(
429 self, client: AsyncClient, auth_headers: StrDict
430 ) -> None:
431 await _create(client, auth_headers, content=f"lst1 {secrets.token_hex(16)}")
432 await _create(client, auth_headers, content=f"lst2 {secrets.token_hex(16)}")
433 r = await client.get(f"/api/{_OWNER}/mists")
434 assert r.status_code == 200
435 body = r.json()
436 assert body["total"] >= 2
437 assert all(m["owner"] == _OWNER for m in body["mists"])
438
439 @pytest.mark.asyncio
440 async def test_list_excludes_secret_for_anon(
441 self, client: AsyncClient, db_session: AsyncSession
442 ) -> None:
443 # Create mists directly so anon GET is not affected by auth_headers fixture.
444 from muse.plugins.mist.plugin import compute_mist_id
445 from musehub.services.musehub_mists import create_mist as _svc_create
446
447 pub = f"pub {secrets.token_hex(16)}"
448 sec = f"sec {secrets.token_hex(16)}"
449 r1 = await _db_repo(db_session)
450 r2 = await _db_repo(db_session)
451 await _svc_create(db_session, mist_id=compute_mist_id(pub.encode()),
452 filename="p.py", content=pub, owner=_OWNER, repo_id=str(r1.repo_id), visibility="public")
453 await _svc_create(db_session, mist_id=compute_mist_id(sec.encode()),
454 filename="s.py", content=sec, owner=_OWNER, repo_id=str(r2.repo_id), visibility="secret")
455 await db_session.commit()
456
457 r = await client.get(f"/api/{_OWNER}/mists")
458 body = r.json()
459 assert body["total"] == 1
460 assert all(m["visibility"] == "public" for m in body["mists"])
461
462 @pytest.mark.asyncio
463 async def test_list_includes_secret_for_owner(
464 self, client: AsyncClient, auth_headers: StrDict
465 ) -> None:
466 await _create(client, auth_headers, content=f"pub2 {secrets.token_hex(16)}")
467 await _create(client, auth_headers, content=f"sec2 {secrets.token_hex(16)}", visibility="secret")
468
469 r = await client.get(f"/api/{_OWNER}/mists", headers=auth_headers)
470 body = r.json()
471 assert body["total"] == 2
472
473 @pytest.mark.asyncio
474 async def test_list_artifact_type_filter(
475 self, client: AsyncClient, auth_headers: StrDict
476 ) -> None:
477 await _create(client, auth_headers, content=f"c {secrets.token_hex(16)}", filename="x.py")
478 await _create(client, auth_headers, content=f"p {secrets.token_hex(16)}", filename="y.md")
479
480 r = await client.get(f"/api/{_OWNER}/mists?artifact_type=code")
481 body = r.json()
482 assert body["total"] >= 1
483 assert all(m["artifactType"] == "code" for m in body["mists"])
484
485
486 class TestIntegrationUpdate:
487 """PATCH /api/mists/{mist_id}"""
488
489 @pytest.mark.asyncio
490 async def test_update_title(
491 self, client: AsyncClient, auth_headers: StrDict
492 ) -> None:
493 m = await _create(client, auth_headers, content=f"upd {secrets.token_hex(16)}")
494 mid = m["mistId"]
495 r = await client.patch(
496 f"/api/mists/{mid}",
497 json={"title": "Updated Title"},
498 headers=auth_headers,
499 )
500 assert r.status_code == 200
501 assert r.json()["title"] == "Updated Title"
502
503 @pytest.mark.asyncio
504 async def test_update_visibility(
505 self, client: AsyncClient, auth_headers: StrDict
506 ) -> None:
507 m = await _create(client, auth_headers, content=f"vis {secrets.token_hex(16)}")
508 mid = m["mistId"]
509 r = await client.patch(
510 f"/api/mists/{mid}",
511 json={"visibility": "secret"},
512 headers=auth_headers,
513 )
514 assert r.status_code == 200
515 assert r.json()["visibility"] == "secret"
516
517 @pytest.mark.asyncio
518 async def test_update_content_increments_version(
519 self, client: AsyncClient, auth_headers: StrDict
520 ) -> None:
521 m = await _create(client, auth_headers, content=f"ver1 {secrets.token_hex(16)}")
522 mid = m["mistId"]
523 r = await client.patch(
524 f"/api/mists/{mid}",
525 json={"content": "new content v2"},
526 headers=auth_headers,
527 )
528 assert r.status_code == 200
529 assert r.json()["version"] == 2
530 assert r.json()["content"] == "new content v2"
531
532 @pytest.mark.asyncio
533 async def test_update_filename(
534 self, client: AsyncClient, auth_headers: StrDict
535 ) -> None:
536 m = await _create(client, auth_headers, filename="foo.md", content=f"md {secrets.token_hex(16)}")
537 mid = m["mistId"]
538 r = await client.patch(
539 f"/api/mists/{mid}",
540 json={"filename": "object_store_details.md"},
541 headers=auth_headers,
542 )
543 assert r.status_code == 200
544 assert r.json()["filename"] == "object_store_details.md"
545
546 @pytest.mark.asyncio
547 async def test_update_filename_with_content(
548 self, client: AsyncClient, auth_headers: StrDict
549 ) -> None:
550 m = await _create(client, auth_headers, filename="foo.md", content=f"v1 {secrets.token_hex(16)}")
551 mid = m["mistId"]
552 r = await client.patch(
553 f"/api/mists/{mid}",
554 json={"filename": "renamed.md", "content": "v2 content"},
555 headers=auth_headers,
556 )
557 assert r.status_code == 200
558 body = r.json()
559 assert body["filename"] == "renamed.md"
560 assert body["content"] == "v2 content"
561 assert body["version"] == 2
562
563 @pytest.mark.asyncio
564 async def test_update_not_found_returns_404(
565 self, client: AsyncClient, auth_headers: StrDict
566 ) -> None:
567 r = await client.patch(
568 "/api/mists/notexist0000",
569 json={"title": "x"},
570 headers=auth_headers,
571 )
572 assert r.status_code == 404
573
574 @pytest.mark.asyncio
575 async def test_update_requires_auth(
576 self, client: AsyncClient, db_session: AsyncSession
577 ) -> None:
578 from muse.plugins.mist.plugin import compute_mist_id
579 from musehub.services.musehub_mists import create_mist as _svc_create
580
581 content = f"noauth {secrets.token_hex(16)}"
582 mid = compute_mist_id(content.encode())
583 repo = await _db_repo(db_session)
584 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
585 owner=_OWNER, repo_id=str(repo.repo_id))
586 await db_session.commit()
587 r = await client.patch(f"/api/mists/{mid}", json={"title": "x"})
588 assert r.status_code == 401
589
590
591 class TestIntegrationDelete:
592 """DELETE /api/mists/{mist_id}"""
593
594 @pytest.mark.asyncio
595 async def test_delete_returns_204(
596 self, client: AsyncClient, auth_headers: StrDict
597 ) -> None:
598 m = await _create(client, auth_headers, content=f"del {secrets.token_hex(16)}")
599 r = await client.delete(f"/api/mists/{m['mistId']}", headers=auth_headers)
600 assert r.status_code == 204
601
602 @pytest.mark.asyncio
603 async def test_delete_removes_mist(
604 self, client: AsyncClient, auth_headers: StrDict
605 ) -> None:
606 m = await _create(client, auth_headers, content=f"gone {secrets.token_hex(16)}")
607 await client.delete(f"/api/mists/{m['mistId']}", headers=auth_headers)
608 r = await client.get(f"/api/mists/{m['mistId']}")
609 assert r.status_code == 404
610
611 @pytest.mark.asyncio
612 async def test_delete_not_found_returns_404(
613 self, client: AsyncClient, auth_headers: StrDict
614 ) -> None:
615 r = await client.delete("/api/mists/notexist0000", headers=auth_headers)
616 assert r.status_code == 404
617
618 @pytest.mark.asyncio
619 async def test_delete_requires_auth(
620 self, client: AsyncClient, db_session: AsyncSession
621 ) -> None:
622 from muse.plugins.mist.plugin import compute_mist_id
623 from musehub.services.musehub_mists import create_mist as _svc_create
624
625 content = f"delnoauth {secrets.token_hex(16)}"
626 mid = compute_mist_id(content.encode())
627 repo = await _db_repo(db_session)
628 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
629 owner=_OWNER, repo_id=str(repo.repo_id))
630 await db_session.commit()
631 r = await client.delete(f"/api/mists/{mid}")
632 assert r.status_code == 401
633
634
635 class TestIntegrationFork:
636 """POST /api/mists/{mist_id}/fork"""
637
638 @pytest.mark.asyncio
639 async def test_fork_returns_201(
640 self, client: AsyncClient, auth_headers: StrDict
641 ) -> None:
642 m = await _create(client, auth_headers, content=f"forkme {secrets.token_hex(16)}")
643 r = await client.post(f"/api/mists/{m['mistId']}/fork", headers=auth_headers)
644 assert r.status_code == 201
645 body = r.json()
646 assert body["forkParentId"] == m["mistId"]
647 assert body["owner"] == _OWNER
648
649 @pytest.mark.asyncio
650 async def test_fork_creates_unique_id(
651 self, client: AsyncClient, auth_headers: StrDict
652 ) -> None:
653 m = await _create(client, auth_headers, content=f"forkid {secrets.token_hex(16)}")
654 r = await client.post(f"/api/mists/{m['mistId']}/fork", headers=auth_headers)
655 fork = r.json()
656 assert fork["mistId"] != m["mistId"]
657 assert len(fork["mistId"]) == 12
658
659 @pytest.mark.asyncio
660 async def test_fork_not_found_returns_404(
661 self, client: AsyncClient, auth_headers: StrDict
662 ) -> None:
663 r = await client.post("/api/mists/notexist0000/fork", headers=auth_headers)
664 assert r.status_code == 404
665
666 @pytest.mark.asyncio
667 async def test_fork_requires_auth(
668 self, client: AsyncClient, db_session: AsyncSession
669 ) -> None:
670 from muse.plugins.mist.plugin import compute_mist_id
671 from musehub.services.musehub_mists import create_mist as _svc_create
672
673 content = f"forknoauth {secrets.token_hex(16)}"
674 mid = compute_mist_id(content.encode())
675 repo = await _db_repo(db_session)
676 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
677 owner=_OWNER, repo_id=str(repo.repo_id))
678 await db_session.commit()
679 r = await client.post(f"/api/mists/{mid}/fork")
680 assert r.status_code == 401
681
682
683 class TestIntegrationForkList:
684 """GET /api/mists/{mist_id}/forks"""
685
686 @pytest.mark.asyncio
687 async def test_list_forks_empty(
688 self, client: AsyncClient, auth_headers: StrDict
689 ) -> None:
690 m = await _create(client, auth_headers, content=f"noforks {secrets.token_hex(16)}")
691 r = await client.get(f"/api/mists/{m['mistId']}/forks")
692 assert r.status_code == 200
693 assert r.json() == []
694
695 @pytest.mark.asyncio
696 async def test_list_forks_after_fork(
697 self, client: AsyncClient, auth_headers: StrDict
698 ) -> None:
699 m = await _create(client, auth_headers, content=f"hasforks {secrets.token_hex(16)}")
700 mid = m["mistId"]
701 await client.post(f"/api/mists/{mid}/fork", headers=auth_headers)
702
703 r = await client.get(f"/api/mists/{mid}/forks")
704 assert r.status_code == 200
705 forks = r.json()
706 assert len(forks) == 1
707 assert forks[0]["forkParentId"] == mid
708
709 @pytest.mark.asyncio
710 async def test_list_forks_parent_not_found(self, client: AsyncClient) -> None:
711 r = await client.get("/api/mists/notexist0000/forks")
712 assert r.status_code == 404
713
714
715 class TestIntegrationEmbed:
716 """GET /api/{owner}/mists/{mist_id}/embed"""
717
718 @pytest.mark.asyncio
719 async def test_embed_returns_200(
720 self, client: AsyncClient, auth_headers: StrDict
721 ) -> None:
722 m = await _create(client, auth_headers, content=f"emb {secrets.token_hex(16)}")
723 r = await client.get(f"/api/{_OWNER}/mists/{m['mistId']}/embed", headers=auth_headers)
724 assert r.status_code == 200
725
726 @pytest.mark.asyncio
727 async def test_embed_increments_embed_count(
728 self, client: AsyncClient, auth_headers: StrDict
729 ) -> None:
730 m = await _create(client, auth_headers, content=f"ec {secrets.token_hex(16)}")
731 mid = m["mistId"]
732 await client.get(f"/api/{_OWNER}/mists/{mid}/embed", headers=auth_headers)
733 await client.get(f"/api/{_OWNER}/mists/{mid}/embed", headers=auth_headers)
734 r = await client.get(f"/api/mists/{mid}", headers=auth_headers)
735 assert r.json()["embedCount"] >= 2
736
737 @pytest.mark.asyncio
738 async def test_embed_wrong_owner_returns_404(
739 self, client: AsyncClient, auth_headers: StrDict
740 ) -> None:
741 m = await _create(client, auth_headers, content=f"wo {secrets.token_hex(16)}")
742 r = await client.get(f"/api/wrongowner/mists/{m['mistId']}/embed", headers=auth_headers)
743 assert r.status_code == 404
744
745
746 # ===========================================================================
747 # Layer 3 — Edge Cases
748 # ===========================================================================
749
750
751 class TestEdgeCases:
752 """Boundary and routing conditions."""
753
754 @pytest.mark.asyncio
755 async def test_explore_route_not_shadowed_by_mist_id(
756 self, client: AsyncClient
757 ) -> None:
758 """GET /api/mists/explore must not be routed to get_mist(mist_id='explore')."""
759 r = await client.get("/api/mists/explore")
760 # Must return a list response, not 404 for a missing mist named "explore"
761 assert r.status_code == 200
762 body = r.json()
763 assert "mists" in body
764
765 @pytest.mark.asyncio
766 async def test_content_analysis_prose(
767 self, client: AsyncClient, auth_headers: StrDict
768 ) -> None:
769 m = await _create(
770 client, auth_headers,
771 filename="essay.md",
772 content=f"# Essay\n{secrets.token_hex(16)}",
773 )
774 assert m["artifactType"] == "code"
775
776 @pytest.mark.asyncio
777 async def test_content_analysis_json_schema(
778 self, client: AsyncClient, auth_headers: StrDict
779 ) -> None:
780 import json
781 schema = json.dumps({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"})
782 m = await _create(
783 client, auth_headers,
784 filename="schema.json",
785 content=schema + f" {secrets.token_hex(16)}",
786 )
787 # Artifact type varies by content detection — just ensure it parsed
788 assert m["artifactType"] in ("json_schema", "schema", "code", "unknown")
789
790 @pytest.mark.asyncio
791 async def test_fork_depth_limit_enforced(
792 self, client: AsyncClient, auth_headers: StrDict
793 ) -> None:
794 m = await _create(client, auth_headers, content=f"depth {secrets.token_hex(16)}")
795 current_id = m["mistId"]
796
797 for _ in range(5):
798 r = await client.post(f"/api/mists/{current_id}/fork", headers=auth_headers)
799 if r.status_code == 201:
800 current_id = r.json()["mistId"]
801 else:
802 # Hit the limit — that's expected
803 assert r.status_code == 422
804 break
805
806 @pytest.mark.asyncio
807 async def test_update_no_fields_noop(
808 self, client: AsyncClient, auth_headers: StrDict
809 ) -> None:
810 m = await _create(
811 client, auth_headers,
812 content=f"noop {secrets.token_hex(16)}",
813 title="original",
814 )
815 mid = m["mistId"]
816 r = await client.patch(f"/api/mists/{mid}", json={}, headers=auth_headers)
817 assert r.status_code == 200
818 assert r.json()["title"] == "original"
819
820
821 # ===========================================================================
822 # Layer 4 — Stress
823 # ===========================================================================
824
825
826 class TestStress:
827 """Bulk operations."""
828
829 @pytest.mark.asyncio
830 async def test_create_20_mists_and_explore(
831 self, client: AsyncClient, db_session: AsyncSession
832 ) -> None:
833 # Use DB to avoid the 20/min HTTP rate limit.
834 from muse.plugins.mist.plugin import compute_mist_id
835 from musehub.services.musehub_mists import create_mist as _svc_create
836
837 for i in range(20):
838 content = f"stress{i} {secrets.token_hex(16)}"
839 repo = await _db_repo(db_session)
840 await _svc_create(db_session, mist_id=compute_mist_id(content.encode()),
841 filename=f"s{i}.py", content=content, owner=_OWNER,
842 repo_id=str(repo.repo_id), visibility="public")
843 await db_session.commit()
844
845 r = await client.get("/api/mists/explore?limit=50")
846 assert r.status_code == 200
847 body = r.json()
848 assert body["total"] >= 20
849
850
851 # ===========================================================================
852 # Layer 5 — Data Integrity
853 # ===========================================================================
854
855
856 class TestDataIntegrity:
857 """Counters and state are consistent across operations."""
858
859 @pytest.mark.asyncio
860 async def test_view_count_increments_per_get(
861 self, client: AsyncClient, auth_headers: StrDict
862 ) -> None:
863 m = await _create(client, auth_headers, content=f"vci {secrets.token_hex(16)}")
864 mid = m["mistId"]
865 r1 = await client.get(f"/api/mists/{mid}")
866 r2 = await client.get(f"/api/mists/{mid}")
867 # r2's viewCount should be larger than r1's — proves counter increments
868 assert r2.json()["viewCount"] > r1.json()["viewCount"]
869
870 @pytest.mark.asyncio
871 async def test_fork_count_increments_on_parent(
872 self, client: AsyncClient, auth_headers: StrDict
873 ) -> None:
874 m = await _create(client, auth_headers, content=f"fc {secrets.token_hex(16)}")
875 mid = m["mistId"]
876 await client.post(f"/api/mists/{mid}/fork", headers=auth_headers)
877 await client.post(f"/api/mists/{mid}/fork", headers=auth_headers)
878 r = await client.get(f"/api/mists/{mid}", headers=auth_headers)
879 assert r.json()["forkCount"] >= 2
880
881 @pytest.mark.asyncio
882 async def test_version_increments_on_content_update(
883 self, client: AsyncClient, auth_headers: StrDict
884 ) -> None:
885 m = await _create(client, auth_headers, content=f"ver {secrets.token_hex(16)}")
886 mid = m["mistId"]
887 await client.patch(f"/api/mists/{mid}", json={"content": "v2"}, headers=auth_headers)
888 await client.patch(f"/api/mists/{mid}", json={"content": "v3"}, headers=auth_headers)
889 r = await client.get(f"/api/mists/{mid}", headers=auth_headers)
890 assert r.json()["version"] == 3
891
892 @pytest.mark.asyncio
893 async def test_delete_removes_from_list(
894 self, client: AsyncClient, auth_headers: StrDict
895 ) -> None:
896 m1 = await _create(client, auth_headers, content=f"rm1 {secrets.token_hex(16)}")
897 await _create(client, auth_headers, content=f"rm2 {secrets.token_hex(16)}")
898 await client.delete(f"/api/mists/{m1['mistId']}", headers=auth_headers)
899
900 r = await client.get(f"/api/{_OWNER}/mists", headers=auth_headers)
901 ids = [e["mistId"] for e in r.json()["mists"]]
902 assert m1["mistId"] not in ids
903
904 @pytest.mark.asyncio
905 async def test_embed_count_independent_per_mist(
906 self, client: AsyncClient, auth_headers: StrDict
907 ) -> None:
908 m1 = await _create(client, auth_headers, content=f"ec1 {secrets.token_hex(16)}")
909 m2 = await _create(client, auth_headers, content=f"ec2 {secrets.token_hex(16)}")
910 await client.get(f"/api/{_OWNER}/mists/{m1['mistId']}/embed", headers=auth_headers)
911
912 r2 = await client.get(f"/api/mists/{m2['mistId']}", headers=auth_headers)
913 assert r2.json()["embedCount"] == 0
914
915
916 # ===========================================================================
917 # Layer 6 — Performance
918 # ===========================================================================
919
920
921 class TestPerformance:
922 @pytest.mark.asyncio
923 async def test_explore_50_mists_under_1s(
924 self, client: AsyncClient, db_session: AsyncSession
925 ) -> None:
926 # Create via DB to avoid the HTTP rate limit (20/min per handle).
927 from muse.plugins.mist.plugin import compute_mist_id
928 from musehub.services.musehub_mists import create_mist as _svc_create
929
930 for i in range(50):
931 content = f"perf{i} {secrets.token_hex(16)}"
932 repo = await _db_repo(db_session)
933 await _svc_create(db_session, mist_id=compute_mist_id(content.encode()),
934 filename=f"p{i}.py", content=content, owner=_OWNER,
935 repo_id=str(repo.repo_id), visibility="public")
936 await db_session.commit()
937
938 t0 = time.perf_counter()
939 r = await client.get("/api/mists/explore?limit=50")
940 elapsed = time.perf_counter() - t0
941 assert r.status_code == 200
942 assert r.json()["total"] >= 50
943 assert elapsed < 1.0, f"explore 50 took {elapsed:.3f}s"
944
945
946 # ===========================================================================
947 # Layer 7 — Security
948 # ===========================================================================
949
950
951 class TestSecurity:
952 """Auth enforcement and access control."""
953
954 @pytest.mark.asyncio
955 async def test_create_without_auth_returns_401(self, client: AsyncClient) -> None:
956 r = await client.post("/api/mists", json=_mist_payload())
957 assert r.status_code == 401
958
959 @pytest.mark.asyncio
960 async def test_update_without_auth_returns_401(
961 self, client: AsyncClient, db_session: AsyncSession
962 ) -> None:
963 from muse.plugins.mist.plugin import compute_mist_id
964 from musehub.services.musehub_mists import create_mist as _svc_create
965
966 content = f"sec_upd {secrets.token_hex(16)}"
967 mid = compute_mist_id(content.encode())
968 repo = await _db_repo(db_session)
969 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
970 owner=_OWNER, repo_id=str(repo.repo_id))
971 await db_session.commit()
972 r = await client.patch(f"/api/mists/{mid}", json={"title": "x"})
973 assert r.status_code == 401
974
975 @pytest.mark.asyncio
976 async def test_delete_without_auth_returns_401(
977 self, client: AsyncClient, db_session: AsyncSession
978 ) -> None:
979 from muse.plugins.mist.plugin import compute_mist_id
980 from musehub.services.musehub_mists import create_mist as _svc_create
981
982 content = f"sec_del {secrets.token_hex(16)}"
983 mid = compute_mist_id(content.encode())
984 repo = await _db_repo(db_session)
985 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
986 owner=_OWNER, repo_id=str(repo.repo_id))
987 await db_session.commit()
988 r = await client.delete(f"/api/mists/{mid}")
989 assert r.status_code == 401
990
991 @pytest.mark.asyncio
992 async def test_secret_mist_hidden_in_explore(
993 self, client: AsyncClient, db_session: AsyncSession
994 ) -> None:
995 from muse.plugins.mist.plugin import compute_mist_id
996 from musehub.services.musehub_mists import create_mist as _svc_create
997
998 content = f"secret_exp {secrets.token_hex(16)}"
999 mid = compute_mist_id(content.encode())
1000 repo = await _db_repo(db_session)
1001 await _svc_create(
1002 db_session, mist_id=mid,
1003 filename="s.py", content=content, owner=_OWNER,
1004 repo_id=str(repo.repo_id), visibility="secret",
1005 )
1006 await db_session.commit()
1007
1008 r = await client.get("/api/mists/explore")
1009 ids = [e["mistId"] for e in r.json()["mists"]]
1010 assert mid not in ids
1011
1012 @pytest.mark.asyncio
1013 async def test_secret_mist_direct_get_403_for_anon(
1014 self, client: AsyncClient, db_session: AsyncSession
1015 ) -> None:
1016 from muse.plugins.mist.plugin import compute_mist_id
1017 from musehub.services.musehub_mists import create_mist as _svc_create
1018
1019 content = f"s_anon {secrets.token_hex(16)}"
1020 mid = compute_mist_id(content.encode())
1021 repo = await _db_repo(db_session)
1022 await _svc_create(
1023 db_session, mist_id=mid,
1024 filename="s.py", content=content, owner=_OWNER,
1025 repo_id=str(repo.repo_id), visibility="secret",
1026 )
1027 await db_session.commit()
1028
1029 r = await client.get(f"/api/mists/{mid}")
1030 assert r.status_code == 403
1031
1032 @pytest.mark.asyncio
1033 async def test_fork_depth_limit_prevents_over_5(
1034 self, client: AsyncClient, auth_headers: StrDict
1035 ) -> None:
1036 """Chain of forks at depth 5 must be rejected with 422."""
1037 m = await _create(client, auth_headers, content=f"dlimit {secrets.token_hex(16)}")
1038 current_id = m["mistId"]
1039 rejected = False
1040
1041 for _ in range(6):
1042 r = await client.post(f"/api/mists/{current_id}/fork", headers=auth_headers)
1043 if r.status_code == 422:
1044 rejected = True
1045 break
1046 elif r.status_code == 201:
1047 current_id = r.json()["mistId"]
1048
1049 assert rejected, "Expected 422 after exceeding fork depth 5"
1050
1051
1052 # ===========================================================================
1053 # Layer 8 — Docstrings / API
1054 # ===========================================================================
1055
1056
1057 class TestDocstrings:
1058 """All route handlers have docstrings."""
1059
1060 def test_route_handlers_have_docstrings(self) -> None:
1061 import musehub.api.routes.musehub.mists as m
1062
1063 handlers = [
1064 m.create_mist,
1065 m.explore_mists,
1066 m.get_mist,
1067 m.update_mist,
1068 m.delete_mist,
1069 m.fork_mist,
1070 m.list_mist_forks,
1071 m.list_owner_mists,
1072 m.get_mist_embed,
1073 ]
1074 missing = [f.__name__ for f in handlers if not (f.__doc__ or "").strip()]
1075 assert missing == [], f"Route handlers missing docstrings: {missing}"
1076
1077 def test_guard_helper_has_docstring(self) -> None:
1078 from musehub.api.routes.musehub.mists import _guard_mist_read
1079 assert (_guard_mist_read.__doc__ or "").strip()
1080
1081 def test_rate_limit_constants_exported(self) -> None:
1082 from musehub.rate_limits import MIST_CREATE_LIMIT, MIST_FORK_LIMIT
1083 assert "/" in MIST_CREATE_LIMIT
1084 assert "/" in MIST_FORK_LIMIT
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 10 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 12 days ago