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