gabriel / musehub public
test_mist_routes.py python
1,053 lines 39.9 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days 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_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_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_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=prose")
481 body = r.json()
482 assert body["total"] >= 1
483 assert all(m["artifactType"] == "prose" 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_not_found_returns_404(
534 self, client: AsyncClient, auth_headers: StrDict
535 ) -> None:
536 r = await client.patch(
537 "/api/mists/notexist0000",
538 json={"title": "x"},
539 headers=auth_headers,
540 )
541 assert r.status_code == 404
542
543 @pytest.mark.asyncio
544 async def test_update_requires_auth(
545 self, client: AsyncClient, db_session: AsyncSession
546 ) -> None:
547 from muse.plugins.mist.plugin import compute_mist_id
548 from musehub.services.musehub_mists import create_mist as _svc_create
549
550 content = f"noauth {secrets.token_hex(16)}"
551 mid = compute_mist_id(content.encode())
552 repo = await _db_repo(db_session)
553 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
554 owner=_OWNER, repo_id=str(repo.repo_id))
555 await db_session.commit()
556 r = await client.patch(f"/api/mists/{mid}", json={"title": "x"})
557 assert r.status_code == 401
558
559
560 class TestIntegrationDelete:
561 """DELETE /api/mists/{mist_id}"""
562
563 @pytest.mark.asyncio
564 async def test_delete_returns_204(
565 self, client: AsyncClient, auth_headers: StrDict
566 ) -> None:
567 m = await _create(client, auth_headers, content=f"del {secrets.token_hex(16)}")
568 r = await client.delete(f"/api/mists/{m['mistId']}", headers=auth_headers)
569 assert r.status_code == 204
570
571 @pytest.mark.asyncio
572 async def test_delete_removes_mist(
573 self, client: AsyncClient, auth_headers: StrDict
574 ) -> None:
575 m = await _create(client, auth_headers, content=f"gone {secrets.token_hex(16)}")
576 await client.delete(f"/api/mists/{m['mistId']}", headers=auth_headers)
577 r = await client.get(f"/api/mists/{m['mistId']}")
578 assert r.status_code == 404
579
580 @pytest.mark.asyncio
581 async def test_delete_not_found_returns_404(
582 self, client: AsyncClient, auth_headers: StrDict
583 ) -> None:
584 r = await client.delete("/api/mists/notexist0000", headers=auth_headers)
585 assert r.status_code == 404
586
587 @pytest.mark.asyncio
588 async def test_delete_requires_auth(
589 self, client: AsyncClient, db_session: AsyncSession
590 ) -> None:
591 from muse.plugins.mist.plugin import compute_mist_id
592 from musehub.services.musehub_mists import create_mist as _svc_create
593
594 content = f"delnoauth {secrets.token_hex(16)}"
595 mid = compute_mist_id(content.encode())
596 repo = await _db_repo(db_session)
597 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
598 owner=_OWNER, repo_id=str(repo.repo_id))
599 await db_session.commit()
600 r = await client.delete(f"/api/mists/{mid}")
601 assert r.status_code == 401
602
603
604 class TestIntegrationFork:
605 """POST /api/mists/{mist_id}/fork"""
606
607 @pytest.mark.asyncio
608 async def test_fork_returns_201(
609 self, client: AsyncClient, auth_headers: StrDict
610 ) -> None:
611 m = await _create(client, auth_headers, content=f"forkme {secrets.token_hex(16)}")
612 r = await client.post(f"/api/mists/{m['mistId']}/fork", headers=auth_headers)
613 assert r.status_code == 201
614 body = r.json()
615 assert body["forkParentId"] == m["mistId"]
616 assert body["owner"] == _OWNER
617
618 @pytest.mark.asyncio
619 async def test_fork_creates_unique_id(
620 self, client: AsyncClient, auth_headers: StrDict
621 ) -> None:
622 m = await _create(client, auth_headers, content=f"forkid {secrets.token_hex(16)}")
623 r = await client.post(f"/api/mists/{m['mistId']}/fork", headers=auth_headers)
624 fork = r.json()
625 assert fork["mistId"] != m["mistId"]
626 assert len(fork["mistId"]) == 12
627
628 @pytest.mark.asyncio
629 async def test_fork_not_found_returns_404(
630 self, client: AsyncClient, auth_headers: StrDict
631 ) -> None:
632 r = await client.post("/api/mists/notexist0000/fork", headers=auth_headers)
633 assert r.status_code == 404
634
635 @pytest.mark.asyncio
636 async def test_fork_requires_auth(
637 self, client: AsyncClient, db_session: AsyncSession
638 ) -> None:
639 from muse.plugins.mist.plugin import compute_mist_id
640 from musehub.services.musehub_mists import create_mist as _svc_create
641
642 content = f"forknoauth {secrets.token_hex(16)}"
643 mid = compute_mist_id(content.encode())
644 repo = await _db_repo(db_session)
645 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
646 owner=_OWNER, repo_id=str(repo.repo_id))
647 await db_session.commit()
648 r = await client.post(f"/api/mists/{mid}/fork")
649 assert r.status_code == 401
650
651
652 class TestIntegrationForkList:
653 """GET /api/mists/{mist_id}/forks"""
654
655 @pytest.mark.asyncio
656 async def test_list_forks_empty(
657 self, client: AsyncClient, auth_headers: StrDict
658 ) -> None:
659 m = await _create(client, auth_headers, content=f"noforks {secrets.token_hex(16)}")
660 r = await client.get(f"/api/mists/{m['mistId']}/forks")
661 assert r.status_code == 200
662 assert r.json() == []
663
664 @pytest.mark.asyncio
665 async def test_list_forks_after_fork(
666 self, client: AsyncClient, auth_headers: StrDict
667 ) -> None:
668 m = await _create(client, auth_headers, content=f"hasforks {secrets.token_hex(16)}")
669 mid = m["mistId"]
670 await client.post(f"/api/mists/{mid}/fork", headers=auth_headers)
671
672 r = await client.get(f"/api/mists/{mid}/forks")
673 assert r.status_code == 200
674 forks = r.json()
675 assert len(forks) == 1
676 assert forks[0]["forkParentId"] == mid
677
678 @pytest.mark.asyncio
679 async def test_list_forks_parent_not_found(self, client: AsyncClient) -> None:
680 r = await client.get("/api/mists/notexist0000/forks")
681 assert r.status_code == 404
682
683
684 class TestIntegrationEmbed:
685 """GET /api/{owner}/mists/{mist_id}/embed"""
686
687 @pytest.mark.asyncio
688 async def test_embed_returns_200(
689 self, client: AsyncClient, auth_headers: StrDict
690 ) -> None:
691 m = await _create(client, auth_headers, content=f"emb {secrets.token_hex(16)}")
692 r = await client.get(f"/api/{_OWNER}/mists/{m['mistId']}/embed", headers=auth_headers)
693 assert r.status_code == 200
694
695 @pytest.mark.asyncio
696 async def test_embed_increments_embed_count(
697 self, client: AsyncClient, auth_headers: StrDict
698 ) -> None:
699 m = await _create(client, auth_headers, content=f"ec {secrets.token_hex(16)}")
700 mid = m["mistId"]
701 await client.get(f"/api/{_OWNER}/mists/{mid}/embed", headers=auth_headers)
702 await client.get(f"/api/{_OWNER}/mists/{mid}/embed", headers=auth_headers)
703 r = await client.get(f"/api/mists/{mid}", headers=auth_headers)
704 assert r.json()["embedCount"] >= 2
705
706 @pytest.mark.asyncio
707 async def test_embed_wrong_owner_returns_404(
708 self, client: AsyncClient, auth_headers: StrDict
709 ) -> None:
710 m = await _create(client, auth_headers, content=f"wo {secrets.token_hex(16)}")
711 r = await client.get(f"/api/wrongowner/mists/{m['mistId']}/embed", headers=auth_headers)
712 assert r.status_code == 404
713
714
715 # ===========================================================================
716 # Layer 3 — Edge Cases
717 # ===========================================================================
718
719
720 class TestEdgeCases:
721 """Boundary and routing conditions."""
722
723 @pytest.mark.asyncio
724 async def test_explore_route_not_shadowed_by_mist_id(
725 self, client: AsyncClient
726 ) -> None:
727 """GET /api/mists/explore must not be routed to get_mist(mist_id='explore')."""
728 r = await client.get("/api/mists/explore")
729 # Must return a list response, not 404 for a missing mist named "explore"
730 assert r.status_code == 200
731 body = r.json()
732 assert "mists" in body
733
734 @pytest.mark.asyncio
735 async def test_content_analysis_prose(
736 self, client: AsyncClient, auth_headers: StrDict
737 ) -> None:
738 m = await _create(
739 client, auth_headers,
740 filename="essay.md",
741 content=f"# Essay\n{secrets.token_hex(16)}",
742 )
743 assert m["artifactType"] == "prose"
744
745 @pytest.mark.asyncio
746 async def test_content_analysis_json_schema(
747 self, client: AsyncClient, auth_headers: StrDict
748 ) -> None:
749 import json
750 schema = json.dumps({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"})
751 m = await _create(
752 client, auth_headers,
753 filename="schema.json",
754 content=schema + f" {secrets.token_hex(16)}",
755 )
756 # Artifact type varies by content detection — just ensure it parsed
757 assert m["artifactType"] in ("json_schema", "schema", "code", "unknown")
758
759 @pytest.mark.asyncio
760 async def test_fork_depth_limit_enforced(
761 self, client: AsyncClient, auth_headers: StrDict
762 ) -> None:
763 m = await _create(client, auth_headers, content=f"depth {secrets.token_hex(16)}")
764 current_id = m["mistId"]
765
766 for _ in range(5):
767 r = await client.post(f"/api/mists/{current_id}/fork", headers=auth_headers)
768 if r.status_code == 201:
769 current_id = r.json()["mistId"]
770 else:
771 # Hit the limit — that's expected
772 assert r.status_code == 422
773 break
774
775 @pytest.mark.asyncio
776 async def test_update_no_fields_noop(
777 self, client: AsyncClient, auth_headers: StrDict
778 ) -> None:
779 m = await _create(
780 client, auth_headers,
781 content=f"noop {secrets.token_hex(16)}",
782 title="original",
783 )
784 mid = m["mistId"]
785 r = await client.patch(f"/api/mists/{mid}", json={}, headers=auth_headers)
786 assert r.status_code == 200
787 assert r.json()["title"] == "original"
788
789
790 # ===========================================================================
791 # Layer 4 — Stress
792 # ===========================================================================
793
794
795 class TestStress:
796 """Bulk operations."""
797
798 @pytest.mark.asyncio
799 async def test_create_20_mists_and_explore(
800 self, client: AsyncClient, db_session: AsyncSession
801 ) -> None:
802 # Use DB to avoid the 20/min HTTP rate limit.
803 from muse.plugins.mist.plugin import compute_mist_id
804 from musehub.services.musehub_mists import create_mist as _svc_create
805
806 for i in range(20):
807 content = f"stress{i} {secrets.token_hex(16)}"
808 repo = await _db_repo(db_session)
809 await _svc_create(db_session, mist_id=compute_mist_id(content.encode()),
810 filename=f"s{i}.py", content=content, owner=_OWNER,
811 repo_id=str(repo.repo_id), visibility="public")
812 await db_session.commit()
813
814 r = await client.get("/api/mists/explore?limit=50")
815 assert r.status_code == 200
816 body = r.json()
817 assert body["total"] >= 20
818
819
820 # ===========================================================================
821 # Layer 5 — Data Integrity
822 # ===========================================================================
823
824
825 class TestDataIntegrity:
826 """Counters and state are consistent across operations."""
827
828 @pytest.mark.asyncio
829 async def test_view_count_increments_per_get(
830 self, client: AsyncClient, auth_headers: StrDict
831 ) -> None:
832 m = await _create(client, auth_headers, content=f"vci {secrets.token_hex(16)}")
833 mid = m["mistId"]
834 r1 = await client.get(f"/api/mists/{mid}")
835 r2 = await client.get(f"/api/mists/{mid}")
836 # r2's viewCount should be larger than r1's — proves counter increments
837 assert r2.json()["viewCount"] > r1.json()["viewCount"]
838
839 @pytest.mark.asyncio
840 async def test_fork_count_increments_on_parent(
841 self, client: AsyncClient, auth_headers: StrDict
842 ) -> None:
843 m = await _create(client, auth_headers, content=f"fc {secrets.token_hex(16)}")
844 mid = m["mistId"]
845 await client.post(f"/api/mists/{mid}/fork", headers=auth_headers)
846 await client.post(f"/api/mists/{mid}/fork", headers=auth_headers)
847 r = await client.get(f"/api/mists/{mid}", headers=auth_headers)
848 assert r.json()["forkCount"] >= 2
849
850 @pytest.mark.asyncio
851 async def test_version_increments_on_content_update(
852 self, client: AsyncClient, auth_headers: StrDict
853 ) -> None:
854 m = await _create(client, auth_headers, content=f"ver {secrets.token_hex(16)}")
855 mid = m["mistId"]
856 await client.patch(f"/api/mists/{mid}", json={"content": "v2"}, headers=auth_headers)
857 await client.patch(f"/api/mists/{mid}", json={"content": "v3"}, headers=auth_headers)
858 r = await client.get(f"/api/mists/{mid}", headers=auth_headers)
859 assert r.json()["version"] == 3
860
861 @pytest.mark.asyncio
862 async def test_delete_removes_from_list(
863 self, client: AsyncClient, auth_headers: StrDict
864 ) -> None:
865 m1 = await _create(client, auth_headers, content=f"rm1 {secrets.token_hex(16)}")
866 await _create(client, auth_headers, content=f"rm2 {secrets.token_hex(16)}")
867 await client.delete(f"/api/mists/{m1['mistId']}", headers=auth_headers)
868
869 r = await client.get(f"/api/{_OWNER}/mists", headers=auth_headers)
870 ids = [e["mistId"] for e in r.json()["mists"]]
871 assert m1["mistId"] not in ids
872
873 @pytest.mark.asyncio
874 async def test_embed_count_independent_per_mist(
875 self, client: AsyncClient, auth_headers: StrDict
876 ) -> None:
877 m1 = await _create(client, auth_headers, content=f"ec1 {secrets.token_hex(16)}")
878 m2 = await _create(client, auth_headers, content=f"ec2 {secrets.token_hex(16)}")
879 await client.get(f"/api/{_OWNER}/mists/{m1['mistId']}/embed", headers=auth_headers)
880
881 r2 = await client.get(f"/api/mists/{m2['mistId']}", headers=auth_headers)
882 assert r2.json()["embedCount"] == 0
883
884
885 # ===========================================================================
886 # Layer 6 — Performance
887 # ===========================================================================
888
889
890 class TestPerformance:
891 @pytest.mark.asyncio
892 async def test_explore_50_mists_under_1s(
893 self, client: AsyncClient, db_session: AsyncSession
894 ) -> None:
895 # Create via DB to avoid the HTTP rate limit (20/min per handle).
896 from muse.plugins.mist.plugin import compute_mist_id
897 from musehub.services.musehub_mists import create_mist as _svc_create
898
899 for i in range(50):
900 content = f"perf{i} {secrets.token_hex(16)}"
901 repo = await _db_repo(db_session)
902 await _svc_create(db_session, mist_id=compute_mist_id(content.encode()),
903 filename=f"p{i}.py", content=content, owner=_OWNER,
904 repo_id=str(repo.repo_id), visibility="public")
905 await db_session.commit()
906
907 t0 = time.perf_counter()
908 r = await client.get("/api/mists/explore?limit=50")
909 elapsed = time.perf_counter() - t0
910 assert r.status_code == 200
911 assert r.json()["total"] >= 50
912 assert elapsed < 1.0, f"explore 50 took {elapsed:.3f}s"
913
914
915 # ===========================================================================
916 # Layer 7 — Security
917 # ===========================================================================
918
919
920 class TestSecurity:
921 """Auth enforcement and access control."""
922
923 @pytest.mark.asyncio
924 async def test_create_without_auth_returns_401(self, client: AsyncClient) -> None:
925 r = await client.post("/api/mists", json=_mist_payload())
926 assert r.status_code == 401
927
928 @pytest.mark.asyncio
929 async def test_update_without_auth_returns_401(
930 self, client: AsyncClient, db_session: AsyncSession
931 ) -> None:
932 from muse.plugins.mist.plugin import compute_mist_id
933 from musehub.services.musehub_mists import create_mist as _svc_create
934
935 content = f"sec_upd {secrets.token_hex(16)}"
936 mid = compute_mist_id(content.encode())
937 repo = await _db_repo(db_session)
938 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
939 owner=_OWNER, repo_id=str(repo.repo_id))
940 await db_session.commit()
941 r = await client.patch(f"/api/mists/{mid}", json={"title": "x"})
942 assert r.status_code == 401
943
944 @pytest.mark.asyncio
945 async def test_delete_without_auth_returns_401(
946 self, client: AsyncClient, db_session: AsyncSession
947 ) -> None:
948 from muse.plugins.mist.plugin import compute_mist_id
949 from musehub.services.musehub_mists import create_mist as _svc_create
950
951 content = f"sec_del {secrets.token_hex(16)}"
952 mid = compute_mist_id(content.encode())
953 repo = await _db_repo(db_session)
954 await _svc_create(db_session, mist_id=mid, filename="f.py", content=content,
955 owner=_OWNER, repo_id=str(repo.repo_id))
956 await db_session.commit()
957 r = await client.delete(f"/api/mists/{mid}")
958 assert r.status_code == 401
959
960 @pytest.mark.asyncio
961 async def test_secret_mist_hidden_in_explore(
962 self, client: AsyncClient, db_session: AsyncSession
963 ) -> None:
964 from muse.plugins.mist.plugin import compute_mist_id
965 from musehub.services.musehub_mists import create_mist as _svc_create
966
967 content = f"secret_exp {secrets.token_hex(16)}"
968 mid = compute_mist_id(content.encode())
969 repo = await _db_repo(db_session)
970 await _svc_create(
971 db_session, mist_id=mid,
972 filename="s.py", content=content, owner=_OWNER,
973 repo_id=str(repo.repo_id), visibility="secret",
974 )
975 await db_session.commit()
976
977 r = await client.get("/api/mists/explore")
978 ids = [e["mistId"] for e in r.json()["mists"]]
979 assert mid not in ids
980
981 @pytest.mark.asyncio
982 async def test_secret_mist_direct_get_403_for_anon(
983 self, client: AsyncClient, db_session: AsyncSession
984 ) -> None:
985 from muse.plugins.mist.plugin import compute_mist_id
986 from musehub.services.musehub_mists import create_mist as _svc_create
987
988 content = f"s_anon {secrets.token_hex(16)}"
989 mid = compute_mist_id(content.encode())
990 repo = await _db_repo(db_session)
991 await _svc_create(
992 db_session, mist_id=mid,
993 filename="s.py", content=content, owner=_OWNER,
994 repo_id=str(repo.repo_id), visibility="secret",
995 )
996 await db_session.commit()
997
998 r = await client.get(f"/api/mists/{mid}")
999 assert r.status_code == 403
1000
1001 @pytest.mark.asyncio
1002 async def test_fork_depth_limit_prevents_over_5(
1003 self, client: AsyncClient, auth_headers: StrDict
1004 ) -> None:
1005 """Chain of forks at depth 5 must be rejected with 422."""
1006 m = await _create(client, auth_headers, content=f"dlimit {secrets.token_hex(16)}")
1007 current_id = m["mistId"]
1008 rejected = False
1009
1010 for _ in range(6):
1011 r = await client.post(f"/api/mists/{current_id}/fork", headers=auth_headers)
1012 if r.status_code == 422:
1013 rejected = True
1014 break
1015 elif r.status_code == 201:
1016 current_id = r.json()["mistId"]
1017
1018 assert rejected, "Expected 422 after exceeding fork depth 5"
1019
1020
1021 # ===========================================================================
1022 # Layer 8 — Docstrings / API
1023 # ===========================================================================
1024
1025
1026 class TestDocstrings:
1027 """All route handlers have docstrings."""
1028
1029 def test_route_handlers_have_docstrings(self) -> None:
1030 import musehub.api.routes.musehub.mists as m
1031
1032 handlers = [
1033 m.create_mist,
1034 m.explore_mists,
1035 m.get_mist,
1036 m.update_mist,
1037 m.delete_mist,
1038 m.fork_mist,
1039 m.list_mist_forks,
1040 m.list_owner_mists,
1041 m.get_mist_embed,
1042 ]
1043 missing = [f.__name__ for f in handlers if not (f.__doc__ or "").strip()]
1044 assert missing == [], f"Route handlers missing docstrings: {missing}"
1045
1046 def test_guard_helper_has_docstring(self) -> None:
1047 from musehub.api.routes.musehub.mists import _guard_mist_read
1048 assert (_guard_mist_read.__doc__ or "").strip()
1049
1050 def test_rate_limit_constants_exported(self) -> None:
1051 from musehub.rate_limits import MIST_CREATE_LIMIT, MIST_FORK_LIMIT
1052 assert "/" in MIST_CREATE_LIMIT
1053 assert "/" in MIST_FORK_LIMIT
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago