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