gabriel / musehub public
test_mist_cli.py python
756 lines 30.7 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Section 19 — Mist CLI layer tests (all eight tiers).
2
3 Covers the three new CLI subcommands added by issue #10:
4
5 update PATCH /api/mists/{id} — partial update of metadata / content
6 forks GET /api/mists/{id}/forks — list direct forks
7 raw GET /api/mists/{id}/raw — raw artifact bytes download
8
9 Each test class is labelled with its tier so the suite mirrors the project's
10 standard eight-tier structure:
11
12 Tier 1 Unit — pure-function / argparse logic, no I/O
13 Tier 2 Schema — HTTP request/response body shape assertions
14 Tier 3 DB state — database contents after CLI-driven mutation
15 Tier 4 Stress — concurrent or volume requests
16 Tier 5 Integration — full HTTP round-trip via AsyncClient
17 Tier 6 Performance — latency assertions
18 Tier 7 Security — auth enforcement, access-control boundaries
19 Tier 8 Docstrings — every new public symbol has a docstring
20 """
21 from __future__ import annotations
22
23 import asyncio
24 import inspect
25 import pathlib
26 import time
27 import uuid
28
29 import pytest
30 import pytest_asyncio
31 from httpx import AsyncClient
32 from sqlalchemy.ext.asyncio import AsyncSession
33
34 from musehub.mcp.write_tools.mists import execute_create_mist
35 from musehub.services.musehub_mcp_executor import (
36 execute_list_mist_forks,
37 execute_read_mist_raw,
38 )
39 from musehub.types.json_types import JSONObject
40
41 _OWNER = "testuser" # matches conftest._TEST_HANDLE
42 _OTHER = "otheruser"
43
44
45 # ---------------------------------------------------------------------------
46 # Helpers
47 # ---------------------------------------------------------------------------
48
49 def _unique_content() -> str:
50 """Return content that is unique across test runs."""
51 return f"# cli test\nvalue = {uuid.uuid4().hex!r}\n"
52
53
54 async def _create_mist(
55 owner: str = _OWNER,
56 visibility: str = "public",
57 content: str | None = None,
58 filename: str | None = None,
59 ) -> str:
60 """Create a mist via the MCP executor and return its mist_id."""
61 result = await execute_create_mist(
62 filename=filename or f"cli_{uuid.uuid4().hex[:8]}.py",
63 content=content or _unique_content(),
64 actor=owner,
65 visibility=visibility,
66 )
67 assert result.ok, f"create_mist failed: {result.error_message}"
68 return str(result.data["mist_id"])
69
70
71 async def _post_fork(client: AsyncClient, auth_headers: dict, mist_id: str) -> str:
72 """Fork a mist via the REST API and return the new mist_id."""
73 r = await client.post(f"/api/mists/{mist_id}/fork", headers=auth_headers)
74 assert r.status_code == 201, r.text
75 return str(r.json()["mistId"])
76
77
78 # ═══════════════════════════════════════════════════════════════════════════════
79 # Tier 1 — Unit (pure logic, no I/O)
80 # ═══════════════════════════════════════════════════════════════════════════════
81
82
83 class TestUnitUpdate:
84 """Tier 1: argument-level logic for the update subcommand."""
85
86 def test_update_tags_splits_on_comma(self) -> None:
87 """Comma-separated tags produce a list of trimmed strings."""
88 raw = "security, auth, v2"
89 tags = [t.strip() for t in raw.split(",") if t.strip()]
90 assert tags == ["security", "auth", "v2"]
91
92 def test_update_empty_tags_string_yields_empty_list(self) -> None:
93 """An empty or whitespace-only tags string produces no tags."""
94 raw = " "
95 tags = [t.strip() for t in raw.split(",") if t.strip()]
96 assert tags == []
97
98 def test_update_valid_visibilities(self) -> None:
99 """Only 'public' and 'secret' are accepted visibility values."""
100 from muse.cli.commands.mist import _ALLOWED_VISIBILITY
101
102 assert "public" in _ALLOWED_VISIBILITY
103 assert "secret" in _ALLOWED_VISIBILITY
104 assert "private" not in _ALLOWED_VISIBILITY
105
106 def test_update_payload_excludes_none_fields(self) -> None:
107 """Fields left as None must not appear in the PATCH payload."""
108 title = "hello"
109 description = None
110 visibility = None
111 payload: dict[str, object] = {}
112 if title is not None:
113 payload["title"] = title
114 if description is not None:
115 payload["description"] = description
116 if visibility is not None:
117 payload["visibility"] = visibility
118 assert "title" in payload
119 assert "description" not in payload
120 assert "visibility" not in payload
121
122
123 class TestUnitForks:
124 """Tier 1: argument-level logic for the forks subcommand."""
125
126 def test_forks_limit_clamped_to_max_100(self) -> None:
127 """Limits above 100 are clamped server-side; client clamps locally."""
128 user_limit = 999
129 clamped = max(1, min(user_limit, 100))
130 assert clamped == 100
131
132 def test_forks_limit_clamped_to_min_1(self) -> None:
133 """Limits below 1 are raised to 1."""
134 user_limit = 0
135 clamped = max(1, min(user_limit, 100))
136 assert clamped == 1
137
138
139 class TestUnitRaw:
140 """Tier 1: argument-level logic for the raw subcommand."""
141
142 def test_raw_accepts_owner_slash_id_format(self) -> None:
143 """'owner/id' format is split correctly."""
144 mist_id = "gabriel/aB3xKq9dPwNm"
145 if "/" in mist_id:
146 id_part = mist_id.split("/", 1)[1].strip()
147 else:
148 id_part = mist_id
149 assert id_part == "aB3xKq9dPwNm"
150
151 def test_raw_plain_id_format_unchanged(self) -> None:
152 """A bare 12-char ID is passed through as-is."""
153 mist_id = "aB3xKq9dPwNm"
154 if "/" in mist_id:
155 id_part = mist_id.split("/", 1)[1].strip()
156 else:
157 id_part = mist_id
158 assert id_part == "aB3xKq9dPwNm"
159
160
161 # ═══════════════════════════════════════════════════════════════════════════════
162 # Tier 2 — Schema (HTTP request/response shape)
163 # ═══════════════════════════════════════════════════════════════════════════════
164
165
166 class TestSchemaUpdate:
167 """Tier 2: PATCH /api/mists/{id} request and response body shape."""
168
169 @pytest.mark.anyio
170 async def test_update_response_contains_mist_id(
171 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
172 ) -> None:
173 """PATCH response always carries mistId."""
174 mid = await _create_mist()
175 r = await client.patch(
176 f"/api/mists/{mid}",
177 json={"title": "Schema check"},
178 headers=auth_headers,
179 )
180 assert r.status_code == 200
181 body = r.json()
182 assert "mistId" in body
183
184 @pytest.mark.anyio
185 async def test_update_partial_body_only_changes_named_fields(
186 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
187 ) -> None:
188 """Omitted fields are NOT reset to null or empty."""
189 mid = await _create_mist()
190 # Set initial title and tags.
191 await client.patch(
192 f"/api/mists/{mid}",
193 json={"title": "Original", "tags": ["a", "b"]},
194 headers=auth_headers,
195 )
196 # Update only title — tags must survive.
197 r = await client.patch(
198 f"/api/mists/{mid}",
199 json={"title": "Revised"},
200 headers=auth_headers,
201 )
202 assert r.status_code == 200
203 r2 = await client.get(f"/api/mists/{mid}")
204 assert r2.status_code == 200
205 assert r2.json()["tags"] == ["a", "b"]
206
207 @pytest.mark.anyio
208 async def test_update_content_increments_version(
209 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
210 ) -> None:
211 """Updating content bumps the version counter by 1."""
212 mid = await _create_mist()
213 r0 = await client.get(f"/api/mists/{mid}")
214 initial_version = r0.json()["version"]
215
216 r = await client.patch(
217 f"/api/mists/{mid}",
218 json={"content": f"# new version\nvalue = {uuid.uuid4().hex!r}\n"},
219 headers=auth_headers,
220 )
221 assert r.status_code == 200
222 assert r.json()["version"] == initial_version + 1
223
224
225 class TestSchemaForks:
226 """Tier 2: GET /api/mists/{id}/forks response shape."""
227
228 @pytest.mark.anyio
229 async def test_forks_response_is_list(
230 self, client: AsyncClient, db_session: AsyncSession
231 ) -> None:
232 """An unfollowed mist returns an empty list, not null."""
233 mid = await _create_mist()
234 r = await client.get(f"/api/mists/{mid}/forks")
235 assert r.status_code == 200
236 assert isinstance(r.json(), list)
237
238 @pytest.mark.anyio
239 async def test_forks_entry_shape(
240 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
241 ) -> None:
242 """Each fork entry carries the expected keys."""
243 mid = await _create_mist()
244 await _post_fork(client, auth_headers, mid)
245
246 r = await client.get(f"/api/mists/{mid}/forks")
247 assert r.status_code == 200
248 fork = r.json()[0]
249 for key in ("mistId", "owner", "filename", "forkDepth", "createdAt"):
250 assert key in fork, f"Missing key: {key}"
251
252
253 class TestSchemaRaw:
254 """Tier 2: GET /api/mists/{id}/raw response headers and body."""
255
256 @pytest.mark.anyio
257 async def test_raw_content_disposition_contains_filename(
258 self, client: AsyncClient, db_session: AsyncSession
259 ) -> None:
260 """Content-Disposition header includes the original filename."""
261 mid = await _create_mist(filename="mymodule.py")
262 r = await client.get(f"/api/mists/{mid}/raw")
263 assert r.status_code == 200
264 cd = r.headers.get("content-disposition", "")
265 assert "mymodule.py" in cd
266
267 @pytest.mark.anyio
268 async def test_raw_content_type_code_is_text_plain(
269 self, client: AsyncClient, db_session: AsyncSession
270 ) -> None:
271 """Python code artifacts are served as text/plain."""
272 mid = await _create_mist(filename="validate.py")
273 r = await client.get(f"/api/mists/{mid}/raw")
274 assert r.status_code == 200
275 assert "text/plain" in r.headers.get("content-type", "")
276
277 @pytest.mark.anyio
278 async def test_raw_body_matches_stored_content(
279 self, client: AsyncClient, db_session: AsyncSession
280 ) -> None:
281 """Raw body bytes equal the UTF-8 encoding of the stored content."""
282 content = f"def hello(): return {uuid.uuid4().hex!r}\n"
283 mid = await _create_mist(content=content, filename="hello.py")
284 r = await client.get(f"/api/mists/{mid}/raw")
285 assert r.status_code == 200
286 assert r.content == content.encode("utf-8")
287
288
289 # ═══════════════════════════════════════════════════════════════════════════════
290 # Tier 3 — DB state (database contents after mutation)
291 # ═══════════════════════════════════════════════════════════════════════════════
292
293
294 class TestDbStateUpdate:
295 """Tier 3: verify DB state after CLI-driven update operations."""
296
297 @pytest.mark.anyio
298 async def test_update_title_persisted(
299 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
300 ) -> None:
301 """Updated title is readable back via GET."""
302 mid = await _create_mist()
303 await client.patch(
304 f"/api/mists/{mid}", json={"title": "DB title check"}, headers=auth_headers
305 )
306 r = await client.get(f"/api/mists/{mid}")
307 assert r.json()["title"] == "DB title check"
308
309 @pytest.mark.anyio
310 async def test_update_visibility_persisted(
311 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
312 ) -> None:
313 """Updated visibility is readable back via GET (owner only for secret)."""
314 mid = await _create_mist()
315 await client.patch(
316 f"/api/mists/{mid}", json={"visibility": "secret"}, headers=auth_headers
317 )
318 r = await client.get(f"/api/mists/{mid}", headers=auth_headers)
319 assert r.json()["visibility"] == "secret"
320
321 @pytest.mark.anyio
322 async def test_update_tags_replaced_atomically(
323 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
324 ) -> None:
325 """Tags update replaces the full list, not appends."""
326 mid = await _create_mist()
327 await client.patch(
328 f"/api/mists/{mid}", json={"tags": ["old"]}, headers=auth_headers
329 )
330 await client.patch(
331 f"/api/mists/{mid}", json={"tags": ["new1", "new2"]}, headers=auth_headers
332 )
333 r = await client.get(f"/api/mists/{mid}")
334 assert sorted(r.json()["tags"]) == ["new1", "new2"]
335
336 @pytest.mark.anyio
337 async def test_update_content_updates_size_bytes(
338 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
339 ) -> None:
340 """size_bytes is recalculated after content update."""
341 short_content = "x = 1\n"
342 mid = await _create_mist(content=short_content)
343 long_content = "x = 1\n" + "# " + "a" * 500 + "\n"
344 await client.patch(
345 f"/api/mists/{mid}", json={"content": long_content}, headers=auth_headers
346 )
347 r = await client.get(f"/api/mists/{mid}")
348 assert r.json()["sizeBytes"] == len(long_content.encode("utf-8"))
349
350
351 # ═══════════════════════════════════════════════════════════════════════════════
352 # Tier 4 — Stress
353 # ═══════════════════════════════════════════════════════════════════════════════
354
355
356 class TestStressUpdate:
357 """Tier 4: concurrent updates on distinct mists."""
358
359 @pytest.mark.anyio
360 async def test_10_concurrent_updates_all_succeed(
361 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
362 ) -> None:
363 """10 concurrent title updates on different mists all return 200."""
364 mids = [await _create_mist() for _ in range(10)]
365
366 async def _update(mid: str) -> int:
367 r = await client.patch(
368 f"/api/mists/{mid}",
369 json={"title": f"concurrent-{mid}"},
370 headers=auth_headers,
371 )
372 return r.status_code
373
374 statuses = await asyncio.gather(*[_update(m) for m in mids])
375 assert all(s == 200 for s in statuses), statuses
376
377
378 class TestStressForks:
379 """Tier 4: fork list on a parent with many children."""
380
381 @pytest.mark.anyio
382 async def test_list_forks_15_children(
383 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
384 ) -> None:
385 """A parent with 15 forks returns all 15 in a single page."""
386 mid = await _create_mist()
387 for _ in range(15):
388 await _post_fork(client, auth_headers, mid)
389
390 r = await client.get(f"/api/mists/{mid}/forks?limit=100")
391 assert r.status_code == 200
392 assert len(r.json()) == 15
393
394
395 class TestStressRaw:
396 """Tier 4: concurrent raw downloads."""
397
398 @pytest.mark.anyio
399 async def test_10_concurrent_raw_downloads(
400 self, client: AsyncClient, db_session: AsyncSession
401 ) -> None:
402 """10 concurrent raw downloads of the same mist all return 200."""
403 mid = await _create_mist()
404
405 async def _get() -> int:
406 r = await client.get(f"/api/mists/{mid}/raw")
407 return r.status_code
408
409 statuses = await asyncio.gather(*[_get() for _ in range(10)])
410 assert all(s == 200 for s in statuses), statuses
411
412
413 # ═══════════════════════════════════════════════════════════════════════════════
414 # Tier 5 — Integration (full HTTP round-trips)
415 # ═══════════════════════════════════════════════════════════════════════════════
416
417
418 class TestIntegrationUpdate:
419 """Tier 5: full PATCH round-trips."""
420
421 @pytest.mark.anyio
422 async def test_update_title_roundtrip(
423 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
424 ) -> None:
425 mid = await _create_mist()
426 r = await client.patch(
427 f"/api/mists/{mid}", json={"title": "Round-trip title"}, headers=auth_headers
428 )
429 assert r.status_code == 200
430 assert r.json()["title"] == "Round-trip title"
431
432 @pytest.mark.anyio
433 async def test_update_unknown_mist_returns_404(
434 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
435 ) -> None:
436 r = await client.patch(
437 "/api/mists/doesNotExist1", json={"title": "x"}, headers=auth_headers
438 )
439 assert r.status_code == 404
440
441 @pytest.mark.anyio
442 async def test_update_non_owner_returns_404(
443 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
444 ) -> None:
445 """Non-owner update returns 404 (not 403, to avoid leaking existence)."""
446 mid = await _create_mist(owner=_OTHER)
447 r = await client.patch(
448 f"/api/mists/{mid}", json={"title": "stolen"}, headers=auth_headers
449 )
450 assert r.status_code == 404
451
452
453 class TestIntegrationForks:
454 """Tier 5: full GET /api/mists/{id}/forks round-trips."""
455
456 @pytest.mark.anyio
457 async def test_forks_empty_on_root(
458 self, client: AsyncClient, db_session: AsyncSession
459 ) -> None:
460 mid = await _create_mist()
461 r = await client.get(f"/api/mists/{mid}/forks")
462 assert r.status_code == 200
463 assert r.json() == []
464
465 @pytest.mark.anyio
466 async def test_forks_after_one_fork(
467 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
468 ) -> None:
469 mid = await _create_mist()
470 fork_id = await _post_fork(client, auth_headers, mid)
471 r = await client.get(f"/api/mists/{mid}/forks")
472 assert r.status_code == 200
473 ids = [f["mistId"] for f in r.json()]
474 assert fork_id in ids
475
476 @pytest.mark.anyio
477 async def test_forks_unknown_parent_returns_404(
478 self, client: AsyncClient, db_session: AsyncSession
479 ) -> None:
480 r = await client.get("/api/mists/doesNotExist1/forks")
481 assert r.status_code == 404
482
483
484 class TestIntegrationRaw:
485 """Tier 5: full GET /api/mists/{id}/raw round-trips."""
486
487 @pytest.mark.anyio
488 async def test_raw_public_mist_returns_content(
489 self, client: AsyncClient, db_session: AsyncSession
490 ) -> None:
491 content = f"def hello(): return {uuid.uuid4().hex!r}\n"
492 mid = await _create_mist(content=content)
493 r = await client.get(f"/api/mists/{mid}/raw")
494 assert r.status_code == 200
495 assert r.content == content.encode("utf-8")
496
497 @pytest.mark.anyio
498 async def test_raw_unknown_mist_returns_404(
499 self, client: AsyncClient, db_session: AsyncSession
500 ) -> None:
501 r = await client.get("/api/mists/doesNotExist1/raw")
502 assert r.status_code == 404
503
504
505 # ═══════════════════════════════════════════════════════════════════════════════
506 # Tier 6 — Performance
507 # ═══════════════════════════════════════════════════════════════════════════════
508
509
510 class TestPerformanceRaw:
511 """Tier 6: raw download latency for a small artifact."""
512
513 @pytest.mark.anyio
514 async def test_raw_1kb_under_200ms(
515 self, client: AsyncClient, db_session: AsyncSession
516 ) -> None:
517 """A 1 KiB artifact should respond in under 200 ms."""
518 content = "x = 1\n" * 170 # ~1 KiB
519 mid = await _create_mist(content=content)
520 start = time.monotonic()
521 r = await client.get(f"/api/mists/{mid}/raw")
522 elapsed = time.monotonic() - start
523 assert r.status_code == 200
524 assert elapsed < 0.2, f"Raw took {elapsed:.3f}s — expected < 200ms"
525
526
527 class TestPerformanceForkList:
528 """Tier 6: fork list latency."""
529
530 @pytest.mark.anyio
531 async def test_forks_10_under_500ms(
532 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
533 ) -> None:
534 """Listing 10 forks should respond in under 500 ms."""
535 mid = await _create_mist()
536 for _ in range(10):
537 await _post_fork(client, auth_headers, mid)
538
539 start = time.monotonic()
540 r = await client.get(f"/api/mists/{mid}/forks?limit=100")
541 elapsed = time.monotonic() - start
542 assert r.status_code == 200
543 assert elapsed < 0.5, f"Fork list took {elapsed:.3f}s — expected < 500ms"
544
545
546 # ═══════════════════════════════════════════════════════════════════════════════
547 # Tier 7 — Security
548 # ═══════════════════════════════════════════════════════════════════════════════
549
550
551 class TestSecurityUpdate:
552 """Tier 7: auth enforcement on PATCH /api/mists/{id}."""
553
554 @pytest.mark.anyio
555 async def test_update_without_auth_returns_401(
556 self, client: AsyncClient, db_session: AsyncSession
557 ) -> None:
558 """PATCH without Authorization header returns 401."""
559 mid = await _create_mist()
560 r = await client.patch(f"/api/mists/{mid}", json={"title": "x"})
561 assert r.status_code == 401
562
563 @pytest.mark.anyio
564 async def test_update_non_owner_returns_404(
565 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
566 ) -> None:
567 """testuser cannot update a mist owned by otheruser."""
568 mid = await _create_mist(owner=_OTHER)
569 r = await client.patch(
570 f"/api/mists/{mid}", json={"title": "hijack"}, headers=auth_headers
571 )
572 assert r.status_code == 404
573
574
575 class TestSecurityRaw:
576 """Tier 7: access control on GET /api/mists/{id}/raw."""
577
578 @pytest.mark.anyio
579 async def test_raw_public_mist_no_auth_returns_200(
580 self, client: AsyncClient, db_session: AsyncSession
581 ) -> None:
582 mid = await _create_mist(visibility="public")
583 r = await client.get(f"/api/mists/{mid}/raw")
584 assert r.status_code == 200
585
586 @pytest.mark.anyio
587 async def test_raw_secret_mist_non_owner_returns_403(
588 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
589 ) -> None:
590 """testuser cannot download raw bytes from otheruser's secret mist."""
591 mid = await _create_mist(owner=_OTHER, visibility="secret")
592 r = await client.get(f"/api/mists/{mid}/raw", headers=auth_headers)
593 assert r.status_code == 403
594
595 @pytest.mark.anyio
596 async def test_raw_secret_mist_unauthenticated_returns_403(
597 self, client: AsyncClient, db_session: AsyncSession
598 ) -> None:
599 """Anonymous callers cannot download a secret mist's raw bytes."""
600 mid = await _create_mist(owner=_OTHER, visibility="secret")
601 r = await client.get(f"/api/mists/{mid}/raw")
602 assert r.status_code == 403
603
604
605 class TestSecurityForks:
606 """Tier 7: access control on GET /api/mists/{id}/forks."""
607
608 @pytest.mark.anyio
609 async def test_forks_of_public_mist_visible_to_anonymous(
610 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
611 ) -> None:
612 """Anyone can see forks of a public mist — no auth required."""
613 mid = await _create_mist(visibility="public")
614 await _post_fork(client, auth_headers, mid)
615 r = await client.get(f"/api/mists/{mid}/forks")
616 assert r.status_code == 200
617 assert len(r.json()) >= 1
618
619 @pytest.mark.anyio
620 async def test_forks_of_secret_mist_non_owner_returns_403(
621 self, client: AsyncClient, auth_headers: dict, db_session: AsyncSession
622 ) -> None:
623 """testuser cannot list forks of otheruser's secret mist."""
624 mid = await _create_mist(owner=_OTHER, visibility="secret")
625 r = await client.get(f"/api/mists/{mid}/forks", headers=auth_headers)
626 assert r.status_code == 403
627
628
629 # ═══════════════════════════════════════════════════════════════════════════════
630 # Tier 7 (MCP executor) — Security via executor functions
631 # ═══════════════════════════════════════════════════════════════════════════════
632
633
634 class TestSecurityMcpExecutors:
635 """Tier 7: access control in the MCP executor layer."""
636
637 @pytest.mark.anyio
638 async def test_list_forks_missing_mist_id_returns_error(
639 self, db_session: AsyncSession
640 ) -> None:
641 result = await execute_list_mist_forks("")
642 assert result.ok is False
643 assert result.error_code == "missing_args"
644
645 @pytest.mark.anyio
646 async def test_list_forks_unknown_mist_returns_not_found(
647 self, db_session: AsyncSession
648 ) -> None:
649 result = await execute_list_mist_forks("doesNotExist1")
650 assert result.ok is False
651 assert result.error_code == "not_found"
652
653 @pytest.mark.anyio
654 async def test_list_forks_secret_non_owner_returns_forbidden(
655 self, db_session: AsyncSession
656 ) -> None:
657 mid = await _create_mist(owner=_OTHER, visibility="secret")
658 result = await execute_list_mist_forks(mid, actor="bob")
659 assert result.ok is False
660 assert result.error_code == "forbidden"
661
662 @pytest.mark.anyio
663 async def test_list_forks_owner_can_list_secret_forks(
664 self, db_session: AsyncSession
665 ) -> None:
666 mid = await _create_mist(owner=_OTHER, visibility="secret")
667 result = await execute_list_mist_forks(mid, actor=_OTHER)
668 assert result.ok is True
669 assert result.data["mist_id"] == mid
670
671 @pytest.mark.anyio
672 async def test_raw_missing_mist_id_returns_error(
673 self, db_session: AsyncSession
674 ) -> None:
675 result = await execute_read_mist_raw("")
676 assert result.ok is False
677 assert result.error_code == "missing_args"
678
679 @pytest.mark.anyio
680 async def test_raw_unknown_mist_returns_not_found(
681 self, db_session: AsyncSession
682 ) -> None:
683 result = await execute_read_mist_raw("doesNotExist1")
684 assert result.ok is False
685 assert result.error_code == "not_found"
686
687 @pytest.mark.anyio
688 async def test_raw_secret_non_owner_returns_forbidden(
689 self, db_session: AsyncSession
690 ) -> None:
691 mid = await _create_mist(owner=_OTHER, visibility="secret")
692 result = await execute_read_mist_raw(mid, actor="bob")
693 assert result.ok is False
694 assert result.error_code == "forbidden"
695
696 @pytest.mark.anyio
697 async def test_raw_public_mist_anonymous_returns_content(
698 self, db_session: AsyncSession
699 ) -> None:
700 content = f"def public(): return {uuid.uuid4().hex!r}\n"
701 mid = await _create_mist(content=content, visibility="public")
702 result = await execute_read_mist_raw(mid, actor="")
703 assert result.ok is True
704 assert result.data["content"] == content
705
706
707 # ═══════════════════════════════════════════════════════════════════════════════
708 # Tier 8 — Docstrings
709 # ═══════════════════════════════════════════════════════════════════════════════
710
711
712 class TestDocstrings:
713 """Tier 8: every new public symbol has a non-empty Google-style docstring."""
714
715 def _assert_doc(self, obj: object, name: str) -> None:
716 doc = inspect.getdoc(obj)
717 assert doc, f"{name} has no docstring"
718 assert len(doc) > 20, f"{name} docstring is too short: {doc!r}"
719
720 def test_run_update_has_docstring(self) -> None:
721 from muse.cli.commands.mist import run_update
722 self._assert_doc(run_update, "run_update")
723
724 def test_run_forks_has_docstring(self) -> None:
725 from muse.cli.commands.mist import run_forks
726 self._assert_doc(run_forks, "run_forks")
727
728 def test_run_raw_has_docstring(self) -> None:
729 from muse.cli.commands.mist import run_raw
730 self._assert_doc(run_raw, "run_raw")
731
732 def test_get_mist_raw_route_has_docstring(self) -> None:
733 from musehub.api.routes.musehub.mists import get_mist_raw
734 self._assert_doc(get_mist_raw, "get_mist_raw")
735
736 def test_content_type_helper_has_docstring(self) -> None:
737 from musehub.api.routes.musehub.mists import _content_type_for_mist
738 self._assert_doc(_content_type_for_mist, "_content_type_for_mist")
739
740 def test_execute_list_mist_forks_has_docstring(self) -> None:
741 self._assert_doc(execute_list_mist_forks, "execute_list_mist_forks")
742
743 def test_execute_read_mist_raw_has_docstring(self) -> None:
744 self._assert_doc(execute_read_mist_raw, "execute_read_mist_raw")
745
746 def test_muse_mist_list_forks_tool_has_description(self) -> None:
747 from musehub.mcp.tools.musehub import MUSEHUB_TOOL_NAMES, MUSEHUB_READ_TOOLS
748 assert "muse_mist_list_forks" in MUSEHUB_TOOL_NAMES
749 tool = next(t for t in MUSEHUB_READ_TOOLS if t["name"] == "muse_mist_list_forks")
750 assert tool.get("description"), "muse_mist_list_forks tool has no description"
751
752 def test_muse_mist_raw_tool_has_description(self) -> None:
753 from musehub.mcp.tools.musehub import MUSEHUB_TOOL_NAMES, MUSEHUB_READ_TOOLS
754 assert "muse_mist_raw" in MUSEHUB_TOOL_NAMES
755 tool = next(t for t in MUSEHUB_READ_TOOLS if t["name"] == "muse_mist_raw")
756 assert tool.get("description"), "muse_mist_raw tool has no description"
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago