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