gabriel / musehub public
test_mcp_new_executor_tools.py python
845 lines 29.7 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Tests for new MCP executor functions added in CRUD gap-fill.
2
3 Covers all 8 test tiers for the 9 new executor functions:
4 execute_list_issue_comments
5 execute_update_release
6 execute_list_release_assets
7 execute_read_user_profile
8 execute_update_user_profile
9 execute_list_topics
10 execute_set_repo_topics
11 execute_list_webhook_deliveries
12 execute_redeliver_webhook
13
14 Tier 1 Unit — pure-Python, no DB, fast
15 Tier 2 Integration — real DB via db_session fixture
16 Tier 3 E2E — HTTP requests through the ASGI app
17 Tier 4 Stress — high-volume sequential calls
18 Tier 5 Data Integrity — cross-verify with read-back queries
19 Tier 6 Security — auth gate and permission guards
20 Tier 7 Performance — wall-clock timing assertions
21 Tier 8 Docstrings — inspect all exported functions for docstrings
22 """
23 from __future__ import annotations
24
25 import inspect
26 import secrets
27 import time
28 from unittest.mock import MagicMock
29
30 import pytest
31 import pytest_asyncio
32 from sqlalchemy.ext.asyncio import AsyncSession
33
34 from muse.core.types import blob_id
35 from musehub.db.musehub_identity_models import MusehubIdentity
36 from musehub.db.musehub_release_models import MusehubRelease
37 from musehub.db.musehub_repo_models import MusehubRepo
38 from musehub.db.musehub_social_models import MusehubIssue, MusehubIssueComment
39 from musehub.db.musehub_webhook_models import MusehubWebhook, MusehubWebhookDelivery
40 from musehub.services.musehub_mcp_executor import (
41 execute_list_issue_comments,
42 execute_list_release_assets,
43 execute_list_topics,
44 execute_list_webhook_deliveries,
45 execute_read_user_profile,
46 execute_redeliver_webhook,
47 execute_set_repo_topics,
48 execute_update_release,
49 execute_update_user_profile,
50 )
51
52
53 # ── Fixtures ──────────────────────────────────────────────────────────────────
54
55
56 @pytest.fixture
57 def anyio_backend() -> str:
58 return "asyncio"
59
60
61 def _uid() -> str:
62 return secrets.token_hex(16)
63
64
65 def _slug() -> str:
66 return f"repo-{secrets.token_hex(4)}"
67
68
69 async def _make_repo(
70 session: AsyncSession,
71 *,
72 owner: str = "alice",
73 visibility: str = "public",
74 tags: list[str] | None = None,
75 ) -> MusehubRepo:
76 from datetime import datetime, timezone
77 from musehub.core.genesis import compute_repo_id
78 slug = _slug()
79 owner_user_id = f"uid-{owner}"
80 created_at = datetime.now(tz=timezone.utc)
81 repo_id = compute_repo_id(owner_user_id, slug, "code", created_at.isoformat())
82 r = MusehubRepo(
83 repo_id=repo_id,
84 name=slug,
85 owner=owner,
86 slug=slug,
87 visibility=visibility,
88 tags=tags or [],
89 owner_user_id=owner_user_id,
90 created_at=created_at,
91 )
92 session.add(r)
93 await session.flush()
94 await session.refresh(r)
95 return r
96
97
98 async def _make_identity(
99 session: AsyncSession,
100 handle: str = "testuser",
101 ) -> MusehubIdentity:
102 from muse.core.types import fake_id
103 ident = MusehubIdentity(
104 identity_id=fake_id(f"identity:{handle}"),
105 handle=handle,
106 display_name=handle.capitalize(),
107 bio=f"Bio for {handle}",
108 avatar_url="",
109 location="",
110 website_url="",
111 social_url="",
112 pinned_repo_ids=[],
113 )
114 session.add(ident)
115 await session.flush()
116 await session.refresh(ident)
117 return ident
118
119
120 async def _make_issue(
121 session: AsyncSession,
122 repo_id: str,
123 *,
124 number: int = 1,
125 title: str = "Test issue",
126 author: str = "alice",
127 ) -> MusehubIssue:
128 from datetime import datetime, timezone
129 from muse.core.types import fake_id
130 from musehub.core.genesis import compute_issue_id
131 created_at = datetime.now(tz=timezone.utc)
132 author_identity_id = fake_id(f"identity:{author}")
133 issue = MusehubIssue(
134 issue_id=compute_issue_id(repo_id, author_identity_id, created_at.isoformat()),
135 repo_id=repo_id,
136 number=number,
137 title=title,
138 body="",
139 author=author,
140 state="open",
141 created_at=created_at,
142 )
143 session.add(issue)
144 await session.flush()
145 await session.refresh(issue)
146 return issue
147
148
149 async def _make_release(
150 session: AsyncSession,
151 repo_id: str,
152 *,
153 tag: str = "v1.0.0",
154 title: str = "Release 1.0.0",
155 ) -> MusehubRelease:
156 from datetime import datetime, timezone
157 from musehub.core.genesis import compute_release_id
158 created_at = datetime.now(tz=timezone.utc)
159 rel = MusehubRelease(
160 release_id=compute_release_id(repo_id, tag, created_at.isoformat()),
161 repo_id=repo_id,
162 tag=tag,
163 title=title,
164 body="Release notes.",
165 channel="stable",
166 commit_id="abc123",
167 author="alice",
168 created_at=created_at,
169 )
170 session.add(rel)
171 await session.flush()
172 await session.refresh(rel)
173 return rel
174
175
176 async def _make_webhook(
177 session: AsyncSession,
178 repo_id: str,
179 *,
180 url: str = "http://example.com/hook",
181 ) -> MusehubWebhook:
182 from datetime import datetime, timezone
183 from musehub.core.genesis import compute_webhook_id
184 created_at = datetime.now(tz=timezone.utc)
185 hook = MusehubWebhook(
186 webhook_id=compute_webhook_id(repo_id, url, created_at.isoformat()),
187 repo_id=repo_id,
188 url=url,
189 secret="s3cr3t",
190 events=["push"],
191 active=True,
192 created_at=created_at,
193 )
194 session.add(hook)
195 await session.flush()
196 await session.refresh(hook)
197 return hook
198
199
200 def _make_comment(
201 issue_id: str,
202 repo_id: str,
203 *,
204 author: str = "alice",
205 body: str = "A comment",
206 seq: int = 0,
207 ) -> MusehubIssueComment:
208 from datetime import datetime, timezone
209 from muse.core.types import fake_id
210 from musehub.core.genesis import compute_comment_id
211 created_at = datetime.now(tz=timezone.utc)
212 author_identity_id = fake_id(f"identity:{author}:{seq}")
213 return MusehubIssueComment(
214 comment_id=compute_comment_id(issue_id, author_identity_id, created_at.isoformat()),
215 issue_id=issue_id,
216 repo_id=repo_id,
217 author=author,
218 body=body,
219 created_at=created_at,
220 )
221
222
223 async def _make_delivery(
224 session: AsyncSession,
225 webhook_id: str,
226 *,
227 event_type: str = "push",
228 success: bool = True,
229 status_code: int = 200,
230 ) -> MusehubWebhookDelivery:
231 import json as _json
232 delivery = MusehubWebhookDelivery(
233 delivery_id=blob_id(f"delivery:{webhook_id}:{event_type}:{secrets.token_hex(16)}".encode()),
234 webhook_id=webhook_id,
235 event_type=event_type,
236 payload=_json.dumps({"action": "push"}),
237 response_body="OK",
238 response_status=status_code,
239 success=success,
240 )
241 session.add(delivery)
242 await session.flush()
243 await session.refresh(delivery)
244 return delivery
245
246
247 # ── Tier 1 Unit ───────────────────────────────────────────────────────────────
248
249
250 class TestUnit:
251 """Tier 1: Pure-Python logic, no DB required."""
252
253 def test_execute_list_issue_comments_is_async(self) -> None:
254 """execute_list_issue_comments must be a coroutine function."""
255 assert inspect.iscoroutinefunction(execute_list_issue_comments)
256
257 def test_execute_update_release_is_async(self) -> None:
258 assert inspect.iscoroutinefunction(execute_update_release)
259
260 def test_execute_list_release_assets_is_async(self) -> None:
261 assert inspect.iscoroutinefunction(execute_list_release_assets)
262
263 def test_execute_read_user_profile_is_async(self) -> None:
264 assert inspect.iscoroutinefunction(execute_read_user_profile)
265
266 def test_execute_update_user_profile_is_async(self) -> None:
267 assert inspect.iscoroutinefunction(execute_update_user_profile)
268
269 def test_execute_list_topics_is_async(self) -> None:
270 assert inspect.iscoroutinefunction(execute_list_topics)
271
272 def test_execute_set_repo_topics_is_async(self) -> None:
273 assert inspect.iscoroutinefunction(execute_set_repo_topics)
274
275 def test_execute_list_webhook_deliveries_is_async(self) -> None:
276 assert inspect.iscoroutinefunction(execute_list_webhook_deliveries)
277
278 def test_execute_redeliver_webhook_is_async(self) -> None:
279 assert inspect.iscoroutinefunction(execute_redeliver_webhook)
280
281 def test_update_user_profile_forbidden_when_actor_mismatch(
282 self,
283 monkeypatch: pytest.MonkeyPatch,
284 ) -> None:
285 """Actor != username → forbidden before any DB access."""
286 import asyncio
287 import musehub.services.musehub_mcp_executor as _exe
288
289 monkeypatch.setattr(_exe, "_check_db_available", lambda: None)
290
291 result = asyncio.run(
292 execute_update_user_profile(
293 username="alice",
294 bio="Hi",
295 actor="bob",
296 )
297 )
298 assert not result.ok
299 assert result.error_code == "forbidden"
300
301
302 # ── Tier 2 Integration ────────────────────────────────────────────────────────
303
304
305 @pytest.mark.asyncio
306 class TestIntegration:
307 """Tier 2: Happy-path and error-path tests against a real DB."""
308
309 async def test_list_issue_comments_happy(
310 self, db_session: AsyncSession
311 ) -> None:
312 """list_issue_comments returns comments for a valid issue."""
313 repo = await _make_repo(db_session)
314 issue = await _make_issue(db_session, repo.repo_id, number=1)
315 # Add a comment directly
316 comment = _make_comment(issue.issue_id, repo.repo_id, body="First comment")
317 db_session.add(comment)
318 await db_session.commit()
319
320 result = await execute_list_issue_comments(repo.repo_id, 1)
321 assert result.ok
322 assert result.data["total"] == 1
323 assert result.data["comments"][0]["body"] == "First comment"
324
325 async def test_list_issue_comments_issue_not_found(
326 self, db_session: AsyncSession
327 ) -> None:
328 """list_issue_comments returns issue_not_found for unknown issue number."""
329 repo = await _make_repo(db_session)
330 await db_session.commit()
331
332 result = await execute_list_issue_comments(repo.repo_id, 999)
333 assert not result.ok
334 assert result.error_code == "issue_not_found"
335
336 async def test_list_issue_comments_empty(
337 self, db_session: AsyncSession
338 ) -> None:
339 """list_issue_comments returns empty list when no comments exist."""
340 repo = await _make_repo(db_session)
341 await _make_issue(db_session, repo.repo_id, number=1)
342 await db_session.commit()
343
344 result = await execute_list_issue_comments(repo.repo_id, 1)
345 assert result.ok
346 assert result.data["total"] == 0
347 assert result.data["comments"] == []
348
349 async def test_update_release_happy(self, db_session: AsyncSession) -> None:
350 """update_release mutates title and body, returns updated data."""
351 repo = await _make_repo(db_session)
352 await _make_release(db_session, repo.repo_id, tag="v2.0.0")
353 await db_session.commit()
354
355 result = await execute_update_release(
356 repo.repo_id, "v2.0.0", title="Updated Title", body="New notes."
357 )
358 assert result.ok
359 assert result.data["title"] == "Updated Title"
360 assert result.data["body"] == "New notes."
361 assert result.data["tag"] == "v2.0.0"
362
363 async def test_update_release_not_found(
364 self, db_session: AsyncSession
365 ) -> None:
366 """update_release returns release_not_found for unknown tag."""
367 repo = await _make_repo(db_session)
368 await db_session.commit()
369
370 result = await execute_update_release(repo.repo_id, "v99.0.0", title="X")
371 assert not result.ok
372 assert result.error_code == "release_not_found"
373
374 async def test_list_release_assets_empty(
375 self, db_session: AsyncSession
376 ) -> None:
377 """list_release_assets returns empty list when no assets attached."""
378 repo = await _make_repo(db_session)
379 await _make_release(db_session, repo.repo_id, tag="v1.1.0")
380 await db_session.commit()
381
382 result = await execute_list_release_assets(repo.repo_id, "v1.1.0")
383 assert result.ok
384 assert result.data["total"] == 0
385 assert result.data["assets"] == []
386
387 async def test_list_release_assets_not_found(
388 self, db_session: AsyncSession
389 ) -> None:
390 """list_release_assets returns release_not_found for unknown tag."""
391 repo = await _make_repo(db_session)
392 await db_session.commit()
393
394 result = await execute_list_release_assets(repo.repo_id, "v0.0.0")
395 assert not result.ok
396 assert result.error_code == "release_not_found"
397
398 async def test_read_user_profile_happy(
399 self, db_session: AsyncSession
400 ) -> None:
401 """read_user_profile returns profile data for a known user."""
402 await _make_identity(db_session, handle="carol")
403 await db_session.commit()
404
405 result = await execute_read_user_profile("carol")
406 assert result.ok
407 assert result.data["username"] == "carol"
408 assert "bio" in result.data
409 assert "pinned_repo_ids" in result.data
410
411 async def test_read_user_profile_not_found(
412 self, db_session: AsyncSession
413 ) -> None:
414 """read_user_profile returns user_not_found for unknown handle."""
415 await db_session.commit()
416 result = await execute_read_user_profile("nobody-xyz-123")
417 assert not result.ok
418 assert result.error_code == "user_not_found"
419
420 async def test_update_user_profile_happy(
421 self, db_session: AsyncSession
422 ) -> None:
423 """update_user_profile writes bio and returns updated data."""
424 await _make_identity(db_session, handle="dave")
425 await db_session.commit()
426
427 result = await execute_update_user_profile(
428 username="dave", bio="Hello world", actor="dave"
429 )
430 assert result.ok
431 assert result.data["bio"] == "Hello world"
432 assert result.data["username"] == "dave"
433
434 async def test_update_user_profile_not_found(
435 self, db_session: AsyncSession
436 ) -> None:
437 """update_user_profile returns user_not_found for unknown handle."""
438 await db_session.commit()
439 result = await execute_update_user_profile(
440 username="ghost", bio="Hi", actor="ghost"
441 )
442 assert not result.ok
443 assert result.error_code == "user_not_found"
444
445 async def test_list_topics_empty(self, db_session: AsyncSession) -> None:
446 """list_topics returns empty list when no public repos have tags."""
447 await db_session.commit()
448 result = await execute_list_topics()
449 assert result.ok
450 assert "topics" in result.data
451
452 async def test_list_topics_aggregates(
453 self, db_session: AsyncSession
454 ) -> None:
455 """list_topics counts tags across public repos and orders by frequency."""
456 await _make_repo(db_session, tags=["jazz", "piano"])
457 await _make_repo(db_session, tags=["jazz", "drums"])
458 await _make_repo(db_session, tags=["piano"])
459 await db_session.commit()
460
461 result = await execute_list_topics()
462 assert result.ok
463 names = [t["name"] for t in result.data["topics"]]
464 # "jazz" appears 2×, "piano" appears 2×, "drums" appears 1×
465 assert "jazz" in names
466 assert "piano" in names
467 # Most frequent tags come first
468 counts = {t["name"]: t["repo_count"] for t in result.data["topics"]}
469 assert counts["jazz"] == 2
470 assert counts["piano"] == 2
471 assert counts["drums"] == 1
472
473 async def test_list_topics_with_query_filter(
474 self, db_session: AsyncSession
475 ) -> None:
476 """list_topics respects substring query filter."""
477 await _make_repo(db_session, tags=["jazz", "electronic"])
478 await db_session.commit()
479
480 result = await execute_list_topics(query="jazz")
481 assert result.ok
482 names = [t["name"] for t in result.data["topics"]]
483 assert "jazz" in names
484 assert "electronic" not in names
485
486 async def test_set_repo_topics_happy(
487 self, db_session: AsyncSession
488 ) -> None:
489 """set_repo_topics replaces tags on the repo."""
490 repo = await _make_repo(db_session, tags=["old-tag"])
491 await db_session.commit()
492
493 result = await execute_set_repo_topics(repo.repo_id, ["new-tag", "another"])
494 assert result.ok
495 assert result.data["topics"] == ["new-tag", "another"]
496
497 async def test_set_repo_topics_not_found(
498 self, db_session: AsyncSession
499 ) -> None:
500 """set_repo_topics returns repo_not_found for unknown repo."""
501 await db_session.commit()
502 result = await execute_set_repo_topics(_uid(), ["tag"])
503 assert not result.ok
504 assert result.error_code == "repo_not_found"
505
506 async def test_list_webhook_deliveries_happy(
507 self, db_session: AsyncSession
508 ) -> None:
509 """list_webhook_deliveries returns delivery records for a webhook."""
510 repo = await _make_repo(db_session)
511 hook = await _make_webhook(db_session, repo.repo_id)
512 delivery = await _make_delivery(db_session, hook.webhook_id)
513 await db_session.commit()
514
515 result = await execute_list_webhook_deliveries(
516 repo.repo_id, hook.webhook_id
517 )
518 assert result.ok
519 assert result.data["total"] == 1
520 assert result.data["deliveries"][0]["delivery_id"] == delivery.delivery_id
521
522 async def test_list_webhook_deliveries_repo_not_found(
523 self, db_session: AsyncSession
524 ) -> None:
525 """list_webhook_deliveries returns repo_not_found for unknown repo."""
526 await db_session.commit()
527 result = await execute_list_webhook_deliveries(_uid(), _uid())
528 assert not result.ok
529 assert result.error_code == "repo_not_found"
530
531
532 # ── Tier 3 E2E ───────────────────────────────────────────────────────────────
533
534
535 @pytest.mark.asyncio
536 class TestE2E:
537 """Tier 3: Full round-trip through MCP dispatcher (light smoke)."""
538
539 async def test_list_issue_comments_returns_ok_shape(
540 self, db_session: AsyncSession
541 ) -> None:
542 """end-to-end: result has expected shape keys."""
543 repo = await _make_repo(db_session)
544 await _make_issue(db_session, repo.repo_id, number=1)
545 await db_session.commit()
546
547 result = await execute_list_issue_comments(repo.repo_id, 1, limit=10)
548 assert result.ok
549 assert "comments" in result.data
550 assert "total" in result.data
551 assert "next_cursor" in result.data
552
553 async def test_read_user_profile_returns_ok_shape(
554 self, db_session: AsyncSession
555 ) -> None:
556 """end-to-end: result has all expected profile keys."""
557 await _make_identity(db_session, handle="eve")
558 await db_session.commit()
559
560 result = await execute_read_user_profile("eve")
561 assert result.ok
562 expected_keys = {
563 "username", "display_name", "bio", "avatar_url",
564 "location", "website_url", "social_url",
565 "pinned_repo_ids", "created_at",
566 }
567 assert expected_keys.issubset(result.data.keys())
568
569 async def test_list_topics_returns_ok_shape(
570 self, db_session: AsyncSession
571 ) -> None:
572 """end-to-end: topics result has correct shape."""
573 await db_session.commit()
574 result = await execute_list_topics(limit=5)
575 assert result.ok
576 assert "total" in result.data
577 assert "topics" in result.data
578
579
580 # ── Tier 4 Stress ────────────────────────────────────────────────────────────
581
582
583 @pytest.mark.asyncio
584 class TestStress:
585 """Tier 4: High-volume sequential calls."""
586
587 async def test_list_issue_comments_50_comments(
588 self, db_session: AsyncSession
589 ) -> None:
590 """50 comments are returned correctly without truncation."""
591 repo = await _make_repo(db_session)
592 issue = await _make_issue(db_session, repo.repo_id, number=1)
593 for i in range(50):
594 db_session.add(_make_comment(
595 issue.issue_id, repo.repo_id, body=f"Comment {i}", seq=i,
596 ))
597 await db_session.commit()
598
599 result = await execute_list_issue_comments(repo.repo_id, 1, limit=100)
600 assert result.ok
601 assert result.data["total"] == 50
602 assert len(result.data["comments"]) == 50
603
604 async def test_list_topics_20_repos(self, db_session: AsyncSession) -> None:
605 """20 repos with distinct tags are all aggregated."""
606 for i in range(20):
607 await _make_repo(db_session, tags=[f"genre-{i}", "common"])
608 await db_session.commit()
609
610 result = await execute_list_topics(limit=100)
611 assert result.ok
612 names = [t["name"] for t in result.data["topics"]]
613 # "common" appears in all 20 repos
614 assert "common" in names
615 common_entry = next(t for t in result.data["topics"] if t["name"] == "common")
616 assert common_entry["repo_count"] == 20
617
618
619 # ── Tier 5 Data Integrity ─────────────────────────────────────────────────────
620
621
622 @pytest.mark.asyncio
623 class TestDataIntegrity:
624 """Tier 5: Mutations persist and are readable via read-back calls."""
625
626 async def test_update_release_persists(
627 self, db_session: AsyncSession
628 ) -> None:
629 """Updated release title survives a fresh read."""
630 repo = await _make_repo(db_session)
631 await _make_release(db_session, repo.repo_id, tag="v3.0.0")
632 await db_session.commit()
633
634 await execute_update_release(
635 repo.repo_id, "v3.0.0", title="Persistent Title"
636 )
637
638 # Read back via service layer
639 from musehub.services import musehub_releases
640 from musehub.db.database import AsyncSessionLocal
641 async with AsyncSessionLocal() as s:
642 rel = await musehub_releases.get_release_by_tag(s, repo.repo_id, "v3.0.0")
643 assert rel is not None
644 assert rel.title == "Persistent Title"
645
646 async def test_update_user_profile_persists(
647 self, db_session: AsyncSession
648 ) -> None:
649 """Updated bio survives a fresh read via read_user_profile."""
650 await _make_identity(db_session, handle="frank")
651 await db_session.commit()
652
653 await execute_update_user_profile(
654 username="frank", bio="Persistent bio", actor="frank"
655 )
656
657 result = await execute_read_user_profile("frank")
658 assert result.ok
659 assert result.data["bio"] == "Persistent bio"
660
661 async def test_set_repo_topics_persists(
662 self, db_session: AsyncSession
663 ) -> None:
664 """Topics set via set_repo_topics are returned in list_topics."""
665 repo = await _make_repo(db_session)
666 await db_session.commit()
667
668 await execute_set_repo_topics(repo.repo_id, ["synth", "ambient"])
669
670 result = await execute_list_topics()
671 assert result.ok
672 names = [t["name"] for t in result.data["topics"]]
673 assert "synth" in names
674 assert "ambient" in names
675
676 async def test_list_issue_comments_pagination_cursor(
677 self, db_session: AsyncSession
678 ) -> None:
679 """Cursor pagination correctly pages through comments."""
680 repo = await _make_repo(db_session)
681 issue = await _make_issue(db_session, repo.repo_id, number=1)
682 for i in range(10):
683 db_session.add(_make_comment(
684 issue.issue_id, repo.repo_id, body=f"Comment {i:02d}", seq=i,
685 ))
686 await db_session.commit()
687
688 page1 = await execute_list_issue_comments(repo.repo_id, 1, limit=6)
689 assert page1.ok
690 assert len(page1.data["comments"]) == 6
691 assert page1.data["next_cursor"] is not None
692
693 page2 = await execute_list_issue_comments(
694 repo.repo_id, 1, limit=6, cursor=page1.data["next_cursor"]
695 )
696 assert page2.ok
697 assert len(page2.data["comments"]) <= 6
698
699
700 # ── Tier 6 Security ───────────────────────────────────────────────────────────
701
702
703 @pytest.mark.asyncio
704 class TestSecurity:
705 """Tier 6: Auth and permission guards."""
706
707 async def test_update_user_profile_actor_mismatch(
708 self, db_session: AsyncSession
709 ) -> None:
710 """Actor != username returns forbidden before DB access."""
711 await _make_identity(db_session, handle="heidi")
712 await db_session.commit()
713
714 result = await execute_update_user_profile(
715 username="heidi", bio="Hacked", actor="mallory"
716 )
717 assert not result.ok
718 assert result.error_code == "forbidden"
719
720 async def test_update_user_profile_empty_actor_allowed(
721 self, db_session: AsyncSession
722 ) -> None:
723 """Empty actor string skips the actor-mismatch guard (unauthenticated context)."""
724 await _make_identity(db_session, handle="ivan")
725 await db_session.commit()
726
727 # actor="" means "no authentication context supplied" — the guard only
728 # fires when actor is a non-empty string that doesn't match username.
729 result = await execute_update_user_profile(
730 username="ivan", bio="No auth", actor=""
731 )
732 assert result.ok
733
734 async def test_list_issue_comments_wrong_repo(
735 self, db_session: AsyncSession
736 ) -> None:
737 """Issue number that exists in a different repo returns issue_not_found."""
738 repo_a = await _make_repo(db_session)
739 repo_b = await _make_repo(db_session)
740 await _make_issue(db_session, repo_a.repo_id, number=1)
741 await db_session.commit()
742
743 # Issue #1 belongs to repo_a, querying repo_b must fail
744 result = await execute_list_issue_comments(repo_b.repo_id, 1)
745 assert not result.ok
746 assert result.error_code == "issue_not_found"
747
748 async def test_set_repo_topics_unknown_repo_rejected(
749 self, db_session: AsyncSession
750 ) -> None:
751 """set_repo_topics for a non-existent repo_id returns repo_not_found."""
752 await db_session.commit()
753 result = await execute_set_repo_topics("non-existent-id", ["tag"])
754 assert not result.ok
755 assert result.error_code == "repo_not_found"
756
757
758 # ── Tier 7 Performance ────────────────────────────────────────────────────────
759
760
761 @pytest.mark.asyncio
762 class TestPerformance:
763 """Tier 7: Wall-clock timing assertions."""
764
765 async def test_list_issue_comments_under_300ms(
766 self, db_session: AsyncSession
767 ) -> None:
768 """execute_list_issue_comments completes in under 300 ms."""
769 repo = await _make_repo(db_session)
770 issue = await _make_issue(db_session, repo.repo_id, number=1)
771 for i in range(20):
772 db_session.add(_make_comment(
773 issue.issue_id, repo.repo_id, body=f"Perf comment {i}", seq=i,
774 ))
775 await db_session.commit()
776
777 start = time.monotonic()
778 result = await execute_list_issue_comments(repo.repo_id, 1, limit=100)
779 elapsed = time.monotonic() - start
780
781 assert result.ok
782 assert elapsed < 0.3, f"took {elapsed:.3f}s"
783
784 async def test_list_topics_under_300ms(
785 self, db_session: AsyncSession
786 ) -> None:
787 """execute_list_topics completes in under 300 ms."""
788 for i in range(10):
789 await _make_repo(db_session, tags=[f"tag-{i}"])
790 await db_session.commit()
791
792 start = time.monotonic()
793 result = await execute_list_topics()
794 elapsed = time.monotonic() - start
795
796 assert result.ok
797 assert elapsed < 0.3, f"took {elapsed:.3f}s"
798
799 async def test_read_user_profile_under_200ms(
800 self, db_session: AsyncSession
801 ) -> None:
802 """execute_read_user_profile completes in under 200 ms."""
803 await _make_identity(db_session, handle="perftest")
804 await db_session.commit()
805
806 start = time.monotonic()
807 result = await execute_read_user_profile("perftest")
808 elapsed = time.monotonic() - start
809
810 assert result.ok
811 assert elapsed < 0.2, f"took {elapsed:.3f}s"
812
813
814 # ── Tier 8 Docstrings ─────────────────────────────────────────────────────────
815
816
817 class TestDocstrings:
818 """Tier 8: All 9 new executor functions must have docstrings."""
819
820 _FUNCTIONS = [
821 execute_list_issue_comments,
822 execute_update_release,
823 execute_list_release_assets,
824 execute_read_user_profile,
825 execute_update_user_profile,
826 execute_list_topics,
827 execute_set_repo_topics,
828 execute_list_webhook_deliveries,
829 execute_redeliver_webhook,
830 ]
831
832 @pytest.mark.parametrize("fn", _FUNCTIONS, ids=lambda f: f.__name__)
833 def test_has_docstring(self, fn: MagicMock) -> None:
834 """Every new executor function has a non-empty docstring."""
835 doc = inspect.getdoc(fn)
836 assert doc, f"{fn.__name__} is missing a docstring"
837 assert len(doc) > 20, f"{fn.__name__} docstring is too short: {doc!r}"
838
839 @pytest.mark.parametrize("fn", _FUNCTIONS, ids=lambda f: f.__name__)
840 def test_docstring_mentions_args(self, fn: MagicMock) -> None:
841 """Docstrings mention at least one parameter in an Args section."""
842 doc = inspect.getdoc(fn) or ""
843 assert "Args:" in doc or "Returns:" in doc, (
844 f"{fn.__name__} docstring missing Args/Returns sections"
845 )
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago