gabriel / musehub public
test_issues_milestones.py python
1,294 lines 51.8 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 10 — Issues & Milestones: 7-layer test suite.
2
3 Covers:
4 - musehub/services/musehub_issues.py (all 19 service functions)
5 - musehub/api/routes/musehub/issues.py (14 endpoints)
6 - musehub/api/routes/musehub/milestones.py (5 endpoints)
7 - musehub/api/routes/musehub/labels.py (label CRUD + assignment)
8 - musehub/mcp/write_tools/issues.py (3 agent tools)
9 - musehub/db/musehub_models.py (MusehubIssue, MusehubIssueComment,
10 MusehubMilestone, MusehubIssueMilestone)
11
12 Layers:
13 1. Unit — pure service functions, no HTTP
14 2. Integration — service calls against real test DB, no HTTP
15 3. End-to-End — full HTTP via AsyncClient
16 4. Stress — 100-issue repos, bulk label ops, deep comment threads
17 5. Data Integrity — sequential numbering, soft-delete, constraint enforcement
18 6. Security — auth enforcement, cross-repo isolation, input limits
19 7. Performance — list under 200ms, pagination correct, N+1 not catastrophic
20 """
21 from __future__ import annotations
22
23 import time
24 import uuid
25 from datetime import datetime, timedelta, timezone
26
27 import pytest
28 from httpx import AsyncClient
29 from sqlalchemy.ext.asyncio import AsyncSession
30 from sqlalchemy import select, func
31
32 from musehub.db import musehub_models as db
33 from musehub.models.musehub import IssueResponse, MilestoneResponse
34 from musehub.services import musehub_issues
35 from tests.factories import create_repo
36 from musehub.muse_contracts.json_types import JSONObject, StrDict
37
38
39 # ── Helpers ───────────────────────────────────────────────────────────────────
40
41 def _uid() -> str:
42 return str(uuid.uuid4())
43
44
45 def _now() -> datetime:
46 return datetime.now(tz=timezone.utc)
47
48
49 async def _repo(session: AsyncSession, slug: str) -> db.MusehubRepo:
50 return await create_repo(session, slug=slug)
51
52
53 async def _issue(
54 session: AsyncSession,
55 repo_id: str,
56 *,
57 title: str = "Test issue",
58 body: str = "",
59 labels: list[str] | None = None,
60 author: str = "tester",
61 ) -> IssueResponse:
62 issue = await musehub_issues.create_issue(
63 session, repo_id=repo_id, title=title,
64 body=body, labels=labels or [], author=author,
65 )
66 await session.commit()
67 return issue
68
69
70 async def _milestone(
71 session: AsyncSession,
72 repo_id: str,
73 *,
74 title: str = "v1.0",
75 description: str = "",
76 due_on: datetime | None = None,
77 ) -> MilestoneResponse:
78 ms = await musehub_issues.create_milestone(
79 session, repo_id=repo_id, title=title,
80 description=description, due_on=due_on,
81 )
82 await session.commit()
83 return ms
84
85
86 # HTTP helpers
87 async def _api_repo(client: AsyncClient, auth_headers: StrDict, name: str = "repo") -> str:
88 r = await client.post(
89 "/api/repos", json={"name": name, "owner": "testuser"}, headers=auth_headers
90 )
91 assert r.status_code == 201
92 return r.json()["repoId"]
93
94
95 async def _api_issue(
96 client: AsyncClient,
97 auth_headers: StrDict,
98 repo_id: str,
99 title: str = "Issue",
100 **kwargs: str | list[str],
101 ) -> JSONObject:
102 r = await client.post(
103 f"/api/repos/{repo_id}/issues",
104 json={"title": title, "body": kwargs.get("body", ""), "labels": kwargs.get("labels", [])},
105 headers=auth_headers,
106 )
107 assert r.status_code == 201
108 return r.json()
109
110
111 # ===========================================================================
112 # Layer 1 — Unit tests (service layer, no HTTP)
113 # ===========================================================================
114
115
116 class TestUnitIssueCreate:
117 @pytest.mark.anyio
118 async def test_creates_in_open_state(self, db_session: AsyncSession) -> None:
119 repo = await _repo(db_session, "u-create-1")
120 issue = await _issue(db_session, repo.repo_id, title="My issue")
121 assert issue.state == "open"
122
123 @pytest.mark.anyio
124 async def test_sequential_numbers(self, db_session: AsyncSession) -> None:
125 repo = await _repo(db_session, "u-seq")
126 i1 = await _issue(db_session, repo.repo_id, title="First")
127 i2 = await _issue(db_session, repo.repo_id, title="Second")
128 i3 = await _issue(db_session, repo.repo_id, title="Third")
129 assert [i1.number, i2.number, i3.number] == [1, 2, 3]
130
131 @pytest.mark.anyio
132 async def test_numbers_independent_per_repo(self, db_session: AsyncSession) -> None:
133 r1 = await _repo(db_session, "u-seq-r1")
134 r2 = await _repo(db_session, "u-seq-r2")
135 i1 = await _issue(db_session, r1.repo_id, title="R1 issue")
136 i2 = await _issue(db_session, r2.repo_id, title="R2 issue")
137 assert i1.number == 1
138 assert i2.number == 1 # independent sequence
139
140 @pytest.mark.anyio
141 async def test_stores_author(self, db_session: AsyncSession) -> None:
142 repo = await _repo(db_session, "u-author")
143 issue = await _issue(db_session, repo.repo_id, author="alice")
144 assert issue.author == "alice"
145
146 @pytest.mark.anyio
147 async def test_stores_labels(self, db_session: AsyncSession) -> None:
148 repo = await _repo(db_session, "u-labels")
149 issue = await _issue(db_session, repo.repo_id, labels=["bug", "p1"])
150 assert set(issue.labels) == {"bug", "p1"}
151
152
153 class TestUnitIssueStateTransitions:
154 @pytest.mark.anyio
155 async def test_close_issue(self, db_session: AsyncSession) -> None:
156 repo = await _repo(db_session, "u-close")
157 issue = await _issue(db_session, repo.repo_id)
158 closed = await musehub_issues.close_issue(db_session, repo.repo_id, issue.number)
159 assert closed is not None
160 assert closed.state == "closed"
161
162 @pytest.mark.anyio
163 async def test_reopen_issue(self, db_session: AsyncSession) -> None:
164 repo = await _repo(db_session, "u-reopen")
165 issue = await _issue(db_session, repo.repo_id)
166 await musehub_issues.close_issue(db_session, repo.repo_id, issue.number)
167 reopened = await musehub_issues.reopen_issue(db_session, repo.repo_id, issue.number)
168 assert reopened is not None
169 assert reopened.state == "open"
170
171 @pytest.mark.anyio
172 async def test_close_nonexistent_returns_none(self, db_session: AsyncSession) -> None:
173 repo = await _repo(db_session, "u-close-miss")
174 result = await musehub_issues.close_issue(db_session, repo.repo_id, 9999)
175 assert result is None
176
177 @pytest.mark.anyio
178 async def test_reopen_idempotent(self, db_session: AsyncSession) -> None:
179 repo = await _repo(db_session, "u-reopen-idem")
180 issue = await _issue(db_session, repo.repo_id)
181 r1 = await musehub_issues.reopen_issue(db_session, repo.repo_id, issue.number)
182 r2 = await musehub_issues.reopen_issue(db_session, repo.repo_id, issue.number)
183 assert r1.state == "open"
184 assert r2.state == "open"
185
186
187 class TestUnitIssueUpdate:
188 @pytest.mark.anyio
189 async def test_update_title(self, db_session: AsyncSession) -> None:
190 repo = await _repo(db_session, "u-upd-title")
191 issue = await _issue(db_session, repo.repo_id, title="Old")
192 updated = await musehub_issues.update_issue(
193 db_session, repo.repo_id, issue.number, title="New"
194 )
195 assert updated is not None
196 assert updated.title == "New"
197
198 @pytest.mark.anyio
199 async def test_update_body_only(self, db_session: AsyncSession) -> None:
200 repo = await _repo(db_session, "u-upd-body")
201 issue = await _issue(db_session, repo.repo_id, title="Keep")
202 updated = await musehub_issues.update_issue(
203 db_session, repo.repo_id, issue.number, body="New body"
204 )
205 assert updated.title == "Keep"
206 assert updated.body == "New body"
207
208 @pytest.mark.anyio
209 async def test_update_labels_replaces_all(self, db_session: AsyncSession) -> None:
210 repo = await _repo(db_session, "u-upd-labels")
211 issue = await _issue(db_session, repo.repo_id, labels=["a", "b"])
212 updated = await musehub_issues.update_issue(
213 db_session, repo.repo_id, issue.number, labels=["c"]
214 )
215 assert updated.labels == ["c"]
216
217 @pytest.mark.anyio
218 async def test_update_nonexistent_returns_none(self, db_session: AsyncSession) -> None:
219 repo = await _repo(db_session, "u-upd-miss")
220 result = await musehub_issues.update_issue(
221 db_session, repo.repo_id, 9999, title="X"
222 )
223 assert result is None
224
225
226 class TestUnitLabelOps:
227 @pytest.mark.anyio
228 async def test_assign_labels_replaces(self, db_session: AsyncSession) -> None:
229 repo = await _repo(db_session, "u-lbl-replace")
230 issue = await _issue(db_session, repo.repo_id, labels=["x", "y"])
231 result = await musehub_issues.assign_labels(
232 db_session, repo.repo_id, issue.number, labels=["z"]
233 )
234 assert result.labels == ["z"]
235
236 @pytest.mark.anyio
237 async def test_remove_label_single(self, db_session: AsyncSession) -> None:
238 repo = await _repo(db_session, "u-lbl-rm")
239 issue = await _issue(db_session, repo.repo_id, labels=["bug", "p1"])
240 result = await musehub_issues.remove_label(
241 db_session, repo.repo_id, issue.number, label="bug"
242 )
243 assert "bug" not in result.labels
244 assert "p1" in result.labels
245
246 @pytest.mark.anyio
247 async def test_remove_label_idempotent(self, db_session: AsyncSession) -> None:
248 repo = await _repo(db_session, "u-lbl-idem")
249 issue = await _issue(db_session, repo.repo_id, labels=["bug"])
250 r1 = await musehub_issues.remove_label(
251 db_session, repo.repo_id, issue.number, label="bug"
252 )
253 r2 = await musehub_issues.remove_label(
254 db_session, repo.repo_id, issue.number, label="bug"
255 )
256 assert r1.labels == []
257 assert r2.labels == []
258
259
260 class TestUnitComments:
261 @pytest.mark.anyio
262 async def test_create_and_list_comment(self, db_session: AsyncSession) -> None:
263 repo = await _repo(db_session, "u-comment-1")
264 issue = await _issue(db_session, repo.repo_id)
265 comment = await musehub_issues.create_comment(
266 db_session, issue_id=issue.issue_id,
267 repo_id=repo.repo_id, body="Hello", author="alice",
268 )
269 await db_session.commit()
270 listed = await musehub_issues.list_comments(db_session, issue.issue_id)
271 assert listed.total == 1
272 assert listed.comments[0].body == "Hello"
273
274 @pytest.mark.anyio
275 async def test_soft_delete_hides_comment(self, db_session: AsyncSession) -> None:
276 repo = await _repo(db_session, "u-comment-del")
277 issue = await _issue(db_session, repo.repo_id)
278 comment = await musehub_issues.create_comment(
279 db_session, issue_id=issue.issue_id,
280 repo_id=repo.repo_id, body="Soon deleted", author="bob",
281 )
282 await db_session.commit()
283 await musehub_issues.delete_comment(db_session, comment.comment_id, issue.issue_id)
284 await db_session.commit()
285 listed = await musehub_issues.list_comments(db_session, issue.issue_id)
286 assert listed.total == 0
287
288 @pytest.mark.anyio
289 async def test_include_deleted_exposes_soft_deleted(self, db_session: AsyncSession) -> None:
290 repo = await _repo(db_session, "u-comment-incl")
291 issue = await _issue(db_session, repo.repo_id)
292 comment = await musehub_issues.create_comment(
293 db_session, issue_id=issue.issue_id,
294 repo_id=repo.repo_id, body="Deleted body", author="bob",
295 )
296 await db_session.commit()
297 await musehub_issues.delete_comment(db_session, comment.comment_id, issue.issue_id)
298 await db_session.commit()
299 listed = await musehub_issues.list_comments(db_session, issue.issue_id, include_deleted=True)
300 assert listed.total == 1
301 assert listed.comments[0].is_deleted is True
302
303 @pytest.mark.anyio
304 async def test_threaded_reply_validates_parent(self, db_session: AsyncSession) -> None:
305 repo = await _repo(db_session, "u-thread")
306 issue = await _issue(db_session, repo.repo_id)
307 parent = await musehub_issues.create_comment(
308 db_session, issue_id=issue.issue_id,
309 repo_id=repo.repo_id, body="Parent", author="alice",
310 )
311 await db_session.commit()
312 reply = await musehub_issues.create_comment(
313 db_session, issue_id=issue.issue_id,
314 repo_id=repo.repo_id, body="Reply", author="bob",
315 parent_id=parent.comment_id,
316 )
317 await db_session.commit()
318 assert reply.parent_id == parent.comment_id
319
320 @pytest.mark.anyio
321 async def test_invalid_parent_raises_value_error(self, db_session: AsyncSession) -> None:
322 repo = await _repo(db_session, "u-thread-invalid")
323 issue = await _issue(db_session, repo.repo_id)
324 with pytest.raises(ValueError, match="not found"):
325 await musehub_issues.create_comment(
326 db_session, issue_id=issue.issue_id,
327 repo_id=repo.repo_id, body="Orphan", author="eve",
328 parent_id=_uid(),
329 )
330
331
332 class TestUnitMilestones:
333 @pytest.mark.anyio
334 async def test_create_milestone(self, db_session: AsyncSession) -> None:
335 repo = await _repo(db_session, "u-ms-create")
336 ms = await _milestone(db_session, repo.repo_id, title="Sprint 1")
337 assert ms.title == "Sprint 1"
338 assert ms.state == "open"
339 assert ms.number == 1
340
341 @pytest.mark.anyio
342 async def test_milestone_numbers_sequential(self, db_session: AsyncSession) -> None:
343 repo = await _repo(db_session, "u-ms-seq")
344 m1 = await _milestone(db_session, repo.repo_id, title="M1")
345 m2 = await _milestone(db_session, repo.repo_id, title="M2")
346 assert m1.number == 1
347 assert m2.number == 2
348
349 @pytest.mark.anyio
350 async def test_set_issue_milestone(self, db_session: AsyncSession) -> None:
351 repo = await _repo(db_session, "u-ms-assign")
352 issue = await _issue(db_session, repo.repo_id)
353 ms = await _milestone(db_session, repo.repo_id, title="V1")
354 result = await musehub_issues.set_issue_milestone(
355 db_session, repo.repo_id, issue.number, milestone_id=ms.milestone_id
356 )
357 await db_session.commit()
358 assert result.milestone_id == ms.milestone_id
359 assert result.milestone_title == "V1"
360
361 @pytest.mark.anyio
362 async def test_clear_issue_milestone(self, db_session: AsyncSession) -> None:
363 repo = await _repo(db_session, "u-ms-clear")
364 issue = await _issue(db_session, repo.repo_id)
365 ms = await _milestone(db_session, repo.repo_id, title="V1")
366 await musehub_issues.set_issue_milestone(
367 db_session, repo.repo_id, issue.number, milestone_id=ms.milestone_id
368 )
369 await db_session.commit()
370 result = await musehub_issues.set_issue_milestone(
371 db_session, repo.repo_id, issue.number, milestone_id=None
372 )
373 await db_session.commit()
374 assert result.milestone_id is None
375
376 @pytest.mark.anyio
377 async def test_cross_repo_milestone_raises(self, db_session: AsyncSession) -> None:
378 r1 = await _repo(db_session, "u-ms-xrepo-1")
379 r2 = await _repo(db_session, "u-ms-xrepo-2")
380 issue = await _issue(db_session, r1.repo_id)
381 ms = await _milestone(db_session, r2.repo_id, title="Other")
382 with pytest.raises(ValueError):
383 await musehub_issues.set_issue_milestone(
384 db_session, r1.repo_id, issue.number, milestone_id=ms.milestone_id
385 )
386
387 @pytest.mark.anyio
388 async def test_list_milestones_open_only_by_default(self, db_session: AsyncSession) -> None:
389 repo = await _repo(db_session, "u-ms-state")
390 await _milestone(db_session, repo.repo_id, title="Open")
391 closed_ms = await _milestone(db_session, repo.repo_id, title="Closed")
392 # Manually close it
393 row = await db_session.get(db.MusehubMilestone, closed_ms.milestone_id)
394 row.state = "closed"
395 await db_session.commit()
396
397 result = await musehub_issues.list_milestones(db_session, repo.repo_id, state="open")
398 assert all(m.state == "open" for m in result.milestones)
399 assert len(result.milestones) == 1
400
401 @pytest.mark.anyio
402 async def test_completeness_sort(self, db_session: AsyncSession) -> None:
403 repo = await _repo(db_session, "u-ms-complete")
404 m1 = await _milestone(db_session, repo.repo_id, title="Empty")
405 m2 = await _milestone(db_session, repo.repo_id, title="Full")
406 # Assign an issue to m2 and close it
407 issue = await _issue(db_session, repo.repo_id)
408 await musehub_issues.set_issue_milestone(
409 db_session, repo.repo_id, issue.number, milestone_id=m2.milestone_id
410 )
411 await musehub_issues.close_issue(db_session, repo.repo_id, issue.number)
412 await db_session.commit()
413
414 result = await musehub_issues.list_milestones(
415 db_session, repo.repo_id, state="all", sort="completeness"
416 )
417 # "Full" (100% closed) should come before "Empty" (0%)
418 assert result.milestones[0].title == "Full"
419 assert result.milestones[1].title == "Empty"
420
421
422 # ===========================================================================
423 # Layer 2 — Integration tests (service + real DB, no HTTP)
424 # ===========================================================================
425
426
427 class TestIntegrationCommentCount:
428 @pytest.mark.anyio
429 async def test_comment_count_in_issue_response(self, db_session: AsyncSession) -> None:
430 repo = await _repo(db_session, "int-cc-1")
431 issue = await _issue(db_session, repo.repo_id)
432 for i in range(3):
433 await musehub_issues.create_comment(
434 db_session, issue_id=issue.issue_id,
435 repo_id=repo.repo_id, body=f"Comment {i}", author="u",
436 )
437 await db_session.commit()
438 fetched = await musehub_issues.get_issue(db_session, repo.repo_id, issue.number)
439 assert fetched.comment_count == 3
440
441 @pytest.mark.anyio
442 async def test_soft_deleted_comments_not_counted(self, db_session: AsyncSession) -> None:
443 repo = await _repo(db_session, "int-cc-2")
444 issue = await _issue(db_session, repo.repo_id)
445 c1 = await musehub_issues.create_comment(
446 db_session, issue_id=issue.issue_id,
447 repo_id=repo.repo_id, body="Keep", author="u",
448 )
449 c2 = await musehub_issues.create_comment(
450 db_session, issue_id=issue.issue_id,
451 repo_id=repo.repo_id, body="Delete me", author="u",
452 )
453 await db_session.commit()
454 await musehub_issues.delete_comment(db_session, c2.comment_id, issue.issue_id)
455 await db_session.commit()
456 fetched = await musehub_issues.get_issue(db_session, repo.repo_id, issue.number)
457 assert fetched.comment_count == 1
458
459
460 class TestIntegrationListFilters:
461 @pytest.mark.anyio
462 async def test_list_open_default(self, db_session: AsyncSession) -> None:
463 repo = await _repo(db_session, "int-lf-1")
464 i1 = await _issue(db_session, repo.repo_id, title="Open")
465 i2 = await _issue(db_session, repo.repo_id, title="Closed")
466 await musehub_issues.close_issue(db_session, repo.repo_id, i2.number)
467 await db_session.commit()
468 results = await musehub_issues.list_issues(db_session, repo.repo_id, state="open")
469 assert all(r.state == "open" for r in results)
470 assert len(results) == 1
471
472 @pytest.mark.anyio
473 async def test_list_all_state(self, db_session: AsyncSession) -> None:
474 repo = await _repo(db_session, "int-lf-2")
475 for i in range(3):
476 await _issue(db_session, repo.repo_id, title=f"Issue {i}")
477 await musehub_issues.close_issue(db_session, repo.repo_id, 1)
478 await db_session.commit()
479 results = await musehub_issues.list_issues(db_session, repo.repo_id, state="all")
480 assert len(results) == 3
481
482 @pytest.mark.anyio
483 async def test_list_by_label(self, db_session: AsyncSession) -> None:
484 repo = await _repo(db_session, "int-lf-3")
485 await _issue(db_session, repo.repo_id, labels=["bug"])
486 await _issue(db_session, repo.repo_id, labels=["enhancement"])
487 await _issue(db_session, repo.repo_id, labels=["bug", "p1"])
488 results = await musehub_issues.list_issues(db_session, repo.repo_id, label="bug")
489 assert len(results) == 2
490 assert all("bug" in r.labels for r in results)
491
492 @pytest.mark.anyio
493 async def test_list_by_milestone(self, db_session: AsyncSession) -> None:
494 repo = await _repo(db_session, "int-lf-4")
495 ms = await _milestone(db_session, repo.repo_id, title="Sprint")
496 i1 = await _issue(db_session, repo.repo_id, title="In milestone")
497 i2 = await _issue(db_session, repo.repo_id, title="No milestone")
498 await musehub_issues.set_issue_milestone(
499 db_session, repo.repo_id, i1.number, milestone_id=ms.milestone_id
500 )
501 await db_session.commit()
502 results = await musehub_issues.list_issues(
503 db_session, repo.repo_id, milestone_id=ms.milestone_id
504 )
505 assert len(results) == 1
506 assert results[0].number == i1.number
507
508 @pytest.mark.anyio
509 async def test_list_ordered_by_number(self, db_session: AsyncSession) -> None:
510 repo = await _repo(db_session, "int-lf-5")
511 for i in range(5):
512 await _issue(db_session, repo.repo_id, title=f"Issue {i}")
513 results = await musehub_issues.list_issues(db_session, repo.repo_id, state="all")
514 numbers = [r.number for r in results]
515 assert numbers == sorted(numbers)
516
517
518 class TestIntegrationMilestoneProgress:
519 @pytest.mark.anyio
520 async def test_open_and_closed_counts(self, db_session: AsyncSession) -> None:
521 repo = await _repo(db_session, "int-mp-1")
522 ms = await _milestone(db_session, repo.repo_id, title="Sprint")
523 for i in range(4):
524 issue = await _issue(db_session, repo.repo_id, title=f"Task {i}")
525 await musehub_issues.set_issue_milestone(
526 db_session, repo.repo_id, issue.number, milestone_id=ms.milestone_id
527 )
528 # Close 2 of them
529 await musehub_issues.close_issue(db_session, repo.repo_id, 1)
530 await musehub_issues.close_issue(db_session, repo.repo_id, 2)
531 await db_session.commit()
532
533 fetched = await musehub_issues.get_milestone(db_session, repo.repo_id, ms.number)
534 assert fetched.open_issues == 2
535 assert fetched.closed_issues == 2
536
537 @pytest.mark.anyio
538 async def test_delete_milestone_unlinks_issues(self, db_session: AsyncSession) -> None:
539 from musehub.api.routes.musehub import milestones as ms_routes
540 from sqlalchemy import update
541
542 repo = await _repo(db_session, "int-mp-del")
543 ms = await _milestone(db_session, repo.repo_id, title="Doomed")
544 issue = await _issue(db_session, repo.repo_id)
545 await musehub_issues.set_issue_milestone(
546 db_session, repo.repo_id, issue.number, milestone_id=ms.milestone_id
547 )
548 await db_session.commit()
549
550 # Unlink issues then delete (mirrors route handler logic)
551 await db_session.execute(
552 update(db.MusehubIssue)
553 .where(db.MusehubIssue.milestone_id == ms.milestone_id)
554 .values(milestone_id=None)
555 )
556 ms_row = await db_session.get(db.MusehubMilestone, ms.milestone_id)
557 await db_session.delete(ms_row)
558 await db_session.commit()
559
560 # Issue should now have no milestone
561 result = await musehub_issues.get_issue(db_session, repo.repo_id, issue.number)
562 assert result.milestone_id is None
563
564
565 # ===========================================================================
566 # Layer 3 — End-to-End tests (full HTTP via AsyncClient)
567 # ===========================================================================
568
569
570 class TestE2EIssues:
571 @pytest.mark.anyio
572 async def test_create_issue_201(
573 self, client: AsyncClient, auth_headers: StrDict
574 ) -> None:
575 repo_id = await _api_repo(client, auth_headers, "e2e-create")
576 r = await client.post(
577 f"/api/repos/{repo_id}/issues",
578 json={"title": "First issue", "body": "Details here", "labels": ["bug"]},
579 headers=auth_headers,
580 )
581 assert r.status_code == 201
582 data = r.json()
583 assert data["state"] == "open"
584 assert data["number"] == 1
585 assert data["title"] == "First issue"
586
587 @pytest.mark.anyio
588 async def test_get_issue_by_number(
589 self, client: AsyncClient, auth_headers: StrDict
590 ) -> None:
591 repo_id = await _api_repo(client, auth_headers, "e2e-get")
592 issue = await _api_issue(client, auth_headers, repo_id, title="Fetchable")
593 r = await client.get(f"/api/repos/{repo_id}/issues/{issue['number']}")
594 assert r.status_code == 200
595 assert r.json()["title"] == "Fetchable"
596
597 @pytest.mark.anyio
598 async def test_get_nonexistent_issue_404(
599 self, client: AsyncClient, auth_headers: StrDict
600 ) -> None:
601 repo_id = await _api_repo(client, auth_headers, "e2e-get-miss")
602 r = await client.get(f"/api/repos/{repo_id}/issues/9999")
603 assert r.status_code == 404
604
605 @pytest.mark.anyio
606 async def test_list_issues_default_open(
607 self, client: AsyncClient, auth_headers: StrDict
608 ) -> None:
609 repo_id = await _api_repo(client, auth_headers, "e2e-list")
610 issue = await _api_issue(client, auth_headers, repo_id, title="Open")
611 await client.post(
612 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
613 headers=auth_headers,
614 )
615 await _api_issue(client, auth_headers, repo_id, title="Still open")
616 r = await client.get(f"/api/repos/{repo_id}/issues")
617 assert r.status_code == 200
618 data = r.json()
619 assert all(i["state"] == "open" for i in data["issues"])
620
621 @pytest.mark.anyio
622 async def test_close_issue(
623 self, client: AsyncClient, auth_headers: StrDict
624 ) -> None:
625 repo_id = await _api_repo(client, auth_headers, "e2e-close")
626 issue = await _api_issue(client, auth_headers, repo_id)
627 r = await client.post(
628 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
629 headers=auth_headers,
630 )
631 assert r.status_code == 200
632 assert r.json()["state"] == "closed"
633
634 @pytest.mark.anyio
635 async def test_reopen_issue(
636 self, client: AsyncClient, auth_headers: StrDict
637 ) -> None:
638 repo_id = await _api_repo(client, auth_headers, "e2e-reopen")
639 issue = await _api_issue(client, auth_headers, repo_id)
640 await client.post(
641 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
642 headers=auth_headers,
643 )
644 r = await client.post(
645 f"/api/repos/{repo_id}/issues/{issue['number']}/reopen",
646 headers=auth_headers,
647 )
648 assert r.status_code == 200
649 assert r.json()["state"] == "open"
650
651 @pytest.mark.anyio
652 async def test_update_issue_patch(
653 self, client: AsyncClient, auth_headers: StrDict
654 ) -> None:
655 repo_id = await _api_repo(client, auth_headers, "e2e-patch")
656 issue = await _api_issue(client, auth_headers, repo_id, title="Old title")
657 r = await client.patch(
658 f"/api/repos/{repo_id}/issues/{issue['number']}",
659 json={"title": "New title", "labels": ["enhancement"]},
660 headers=auth_headers,
661 )
662 assert r.status_code == 200
663 data = r.json()
664 assert data["title"] == "New title"
665 assert "enhancement" in data["labels"]
666
667 @pytest.mark.anyio
668 async def test_assign_issue(
669 self, client: AsyncClient, auth_headers: StrDict
670 ) -> None:
671 repo_id = await _api_repo(client, auth_headers, "e2e-assign")
672 issue = await _api_issue(client, auth_headers, repo_id)
673 r = await client.post(
674 f"/api/repos/{repo_id}/issues/{issue['number']}/assign",
675 json={"assignee": "dev-team-lead"},
676 headers=auth_headers,
677 )
678 assert r.status_code == 200
679 assert r.json()["assignee"] == "dev-team-lead"
680
681 @pytest.mark.anyio
682 async def test_unassign_issue(
683 self, client: AsyncClient, auth_headers: StrDict
684 ) -> None:
685 repo_id = await _api_repo(client, auth_headers, "e2e-unassign")
686 issue = await _api_issue(client, auth_headers, repo_id)
687 await client.post(
688 f"/api/repos/{repo_id}/issues/{issue['number']}/assign",
689 json={"assignee": "somebody"},
690 headers=auth_headers,
691 )
692 r = await client.post(
693 f"/api/repos/{repo_id}/issues/{issue['number']}/assign",
694 json={"assignee": None},
695 headers=auth_headers,
696 )
697 assert r.json()["assignee"] is None
698
699
700 class TestE2EComments:
701 @pytest.mark.anyio
702 async def test_create_comment_201(
703 self, client: AsyncClient, auth_headers: StrDict
704 ) -> None:
705 repo_id = await _api_repo(client, auth_headers, "e2e-c-create")
706 issue = await _api_issue(client, auth_headers, repo_id)
707 r = await client.post(
708 f"/api/repos/{repo_id}/issues/{issue['number']}/comments",
709 json={"body": "Looks good to me"},
710 headers=auth_headers,
711 )
712 assert r.status_code == 201
713 data = r.json()
714 assert data["comments"][0]["body"] == "Looks good to me"
715
716 @pytest.mark.anyio
717 async def test_list_comments(
718 self, client: AsyncClient, auth_headers: StrDict
719 ) -> None:
720 repo_id = await _api_repo(client, auth_headers, "e2e-c-list")
721 issue = await _api_issue(client, auth_headers, repo_id)
722 for i in range(3):
723 await client.post(
724 f"/api/repos/{repo_id}/issues/{issue['number']}/comments",
725 json={"body": f"Comment {i}"},
726 headers=auth_headers,
727 )
728 r = await client.get(
729 f"/api/repos/{repo_id}/issues/{issue['number']}/comments"
730 )
731 assert r.status_code == 200
732 assert r.json()["total"] == 3
733
734 @pytest.mark.anyio
735 async def test_delete_comment_204(
736 self, client: AsyncClient, auth_headers: StrDict
737 ) -> None:
738 repo_id = await _api_repo(client, auth_headers, "e2e-c-del")
739 issue = await _api_issue(client, auth_headers, repo_id)
740 post_r = await client.post(
741 f"/api/repos/{repo_id}/issues/{issue['number']}/comments",
742 json={"body": "Delete me"},
743 headers=auth_headers,
744 )
745 comment_id = post_r.json()["comments"][0]["commentId"]
746 r = await client.delete(
747 f"/api/repos/{repo_id}/issues/{issue['number']}/comments/{comment_id}",
748 headers=auth_headers,
749 )
750 assert r.status_code == 204
751 # Not visible in subsequent list
752 listed = await client.get(
753 f"/api/repos/{repo_id}/issues/{issue['number']}/comments"
754 )
755 assert listed.json()["total"] == 0
756
757
758 class TestE2EMilestones:
759 @pytest.mark.anyio
760 async def test_create_milestone_201(
761 self, client: AsyncClient, auth_headers: StrDict
762 ) -> None:
763 repo_id = await _api_repo(client, auth_headers, "e2e-ms-create")
764 r = await client.post(
765 f"/api/repos/{repo_id}/milestones",
766 json={"title": "Sprint 1", "description": "First sprint"},
767 headers=auth_headers,
768 )
769 assert r.status_code == 201
770 data = r.json()
771 assert data["title"] == "Sprint 1"
772 assert data["number"] == 1
773 assert data["state"] == "open"
774
775 @pytest.mark.anyio
776 async def test_get_milestone_by_number(
777 self, client: AsyncClient, auth_headers: StrDict
778 ) -> None:
779 repo_id = await _api_repo(client, auth_headers, "e2e-ms-get")
780 await client.post(
781 f"/api/repos/{repo_id}/milestones",
782 json={"title": "M1"},
783 headers=auth_headers,
784 )
785 r = await client.get(f"/api/repos/{repo_id}/milestones/1")
786 assert r.status_code == 200
787 assert r.json()["title"] == "M1"
788
789 @pytest.mark.anyio
790 async def test_update_milestone(
791 self, client: AsyncClient, auth_headers: StrDict
792 ) -> None:
793 repo_id = await _api_repo(client, auth_headers, "e2e-ms-upd")
794 await client.post(
795 f"/api/repos/{repo_id}/milestones",
796 json={"title": "Old"},
797 headers=auth_headers,
798 )
799 r = await client.patch(
800 f"/api/repos/{repo_id}/milestones/1",
801 json={"title": "New", "state": "closed"},
802 headers=auth_headers,
803 )
804 assert r.status_code == 200
805 data = r.json()
806 assert data["title"] == "New"
807 assert data["state"] == "closed"
808
809 @pytest.mark.anyio
810 async def test_update_milestone_clear_due_on(
811 self, client: AsyncClient, auth_headers: StrDict
812 ) -> None:
813 repo_id = await _api_repo(client, auth_headers, "e2e-ms-due")
814 due = (_now() + timedelta(days=7)).isoformat()
815 await client.post(
816 f"/api/repos/{repo_id}/milestones",
817 json={"title": "Due soon", "dueOn": due},
818 headers=auth_headers,
819 )
820 r = await client.patch(
821 f"/api/repos/{repo_id}/milestones/1",
822 json={"dueOn": None},
823 headers=auth_headers,
824 )
825 assert r.status_code == 200
826 assert r.json()["dueOn"] is None
827
828 @pytest.mark.anyio
829 async def test_delete_milestone_204(
830 self, client: AsyncClient, auth_headers: StrDict
831 ) -> None:
832 repo_id = await _api_repo(client, auth_headers, "e2e-ms-del")
833 await client.post(
834 f"/api/repos/{repo_id}/milestones",
835 json={"title": "Gone"},
836 headers=auth_headers,
837 )
838 r = await client.delete(
839 f"/api/repos/{repo_id}/milestones/1", headers=auth_headers
840 )
841 assert r.status_code == 204
842 r2 = await client.get(f"/api/repos/{repo_id}/milestones/1")
843 assert r2.status_code == 404
844
845 @pytest.mark.anyio
846 async def test_set_issue_milestone_via_http(
847 self, client: AsyncClient, auth_headers: StrDict
848 ) -> None:
849 repo_id = await _api_repo(client, auth_headers, "e2e-ms-link")
850 issue = await _api_issue(client, auth_headers, repo_id)
851 ms_r = await client.post(
852 f"/api/repos/{repo_id}/milestones",
853 json={"title": "Sprint"},
854 headers=auth_headers,
855 )
856 ms_id = ms_r.json()["milestoneId"]
857 r = await client.post(
858 f"/api/repos/{repo_id}/issues/{issue['number']}/milestone",
859 params={"milestone_id": ms_id},
860 headers=auth_headers,
861 )
862 assert r.status_code == 200
863 assert r.json()["milestoneId"] == ms_id
864
865 @pytest.mark.anyio
866 async def test_remove_issue_milestone_via_http(
867 self, client: AsyncClient, auth_headers: StrDict
868 ) -> None:
869 repo_id = await _api_repo(client, auth_headers, "e2e-ms-unlink")
870 issue = await _api_issue(client, auth_headers, repo_id)
871 ms_r = await client.post(
872 f"/api/repos/{repo_id}/milestones", json={"title": "Sprint"}, headers=auth_headers
873 )
874 ms_id = ms_r.json()["milestoneId"]
875 await client.post(
876 f"/api/repos/{repo_id}/issues/{issue['number']}/milestone",
877 params={"milestone_id": ms_id}, headers=auth_headers,
878 )
879 r = await client.delete(
880 f"/api/repos/{repo_id}/issues/{issue['number']}/milestone",
881 headers=auth_headers,
882 )
883 assert r.status_code == 200
884 assert r.json()["milestoneId"] is None
885
886
887 class TestE2EMCPTools:
888 @pytest.mark.anyio
889 async def test_execute_create_issue(self, db_session: AsyncSession) -> None:
890 from musehub.mcp.write_tools.issues import execute_create_issue
891 from musehub.db.database import AsyncSessionLocal
892
893 repo = await _repo(db_session, "mcp-create")
894 await db_session.commit()
895
896 result = await execute_create_issue(
897 repo_id=repo.repo_id, title="Agent-filed issue",
898 body="From MCP", actor="agent-1",
899 )
900 assert result.ok is True
901 assert result.data["title"] == "Agent-filed issue"
902 assert result.data["number"] == 1
903
904 @pytest.mark.anyio
905 async def test_execute_create_issue_missing_repo(self, db_session: AsyncSession) -> None:
906 from musehub.mcp.write_tools.issues import execute_create_issue
907 result = await execute_create_issue(
908 repo_id=_uid(), title="Won't work", actor="agent"
909 )
910 assert result.ok is False
911 assert result.error_code == "repo_not_found"
912
913 @pytest.mark.anyio
914 async def test_execute_update_issue_close(self, db_session: AsyncSession) -> None:
915 from musehub.mcp.write_tools.issues import execute_update_issue
916
917 repo = await _repo(db_session, "mcp-update")
918 issue = await _issue(db_session, repo.repo_id, title="Open issue")
919
920 result = await execute_update_issue(
921 repo_id=repo.repo_id, issue_number=issue.number, state="closed"
922 )
923 assert result.ok is True
924 assert result.data["state"] == "closed"
925
926 @pytest.mark.anyio
927 async def test_execute_create_comment(self, db_session: AsyncSession) -> None:
928 from musehub.mcp.write_tools.issues import execute_create_issue_comment
929
930 repo = await _repo(db_session, "mcp-comment")
931 issue = await _issue(db_session, repo.repo_id)
932 await db_session.commit()
933
934 result = await execute_create_issue_comment(
935 repo_id=repo.repo_id, issue_number=issue.number,
936 body="Agent comment", actor="agent-2",
937 )
938 assert result.ok is True
939 assert result.data["body"] == "Agent comment"
940
941
942 # ===========================================================================
943 # Layer 4 — Stress tests
944 # ===========================================================================
945
946
947 class TestStress:
948 @pytest.mark.anyio
949 async def test_100_issues_in_one_repo(self, db_session: AsyncSession) -> None:
950 repo = await _repo(db_session, "stress-100")
951 for i in range(100):
952 await musehub_issues.create_issue(
953 db_session, repo_id=repo.repo_id,
954 title=f"Issue {i:03d}", body="", labels=[],
955 )
956 await db_session.commit()
957 results = await musehub_issues.list_issues(db_session, repo.repo_id, state="all")
958 assert len(results) == 100
959 assert results[-1].number == 100
960
961 @pytest.mark.anyio
962 async def test_bulk_label_replacement(self, db_session: AsyncSession) -> None:
963 repo = await _repo(db_session, "stress-labels")
964 issues = []
965 for i in range(20):
966 issues.append(await _issue(db_session, repo.repo_id, labels=["old"]))
967 await db_session.commit()
968 for issue in issues:
969 await musehub_issues.assign_labels(
970 db_session, repo.repo_id, issue.number, labels=["new"]
971 )
972 await db_session.commit()
973 results = await musehub_issues.list_issues(db_session, repo.repo_id, state="all")
974 assert all(r.labels == ["new"] for r in results)
975
976 @pytest.mark.anyio
977 async def test_deep_comment_thread(self, db_session: AsyncSession) -> None:
978 """10-level deep reply chain must be created without error."""
979 repo = await _repo(db_session, "stress-thread")
980 issue = await _issue(db_session, repo.repo_id)
981 parent_id = None
982 for depth in range(10):
983 comment = await musehub_issues.create_comment(
984 db_session, issue_id=issue.issue_id,
985 repo_id=repo.repo_id, body=f"Depth {depth}",
986 author="u", parent_id=parent_id,
987 )
988 await db_session.commit()
989 parent_id = comment.comment_id
990 listed = await musehub_issues.list_comments(db_session, issue.issue_id)
991 assert listed.total == 10
992
993 @pytest.mark.anyio
994 async def test_milestone_with_50_issues(self, db_session: AsyncSession) -> None:
995 repo = await _repo(db_session, "stress-ms-50")
996 ms = await _milestone(db_session, repo.repo_id, title="Big Sprint")
997 for i in range(50):
998 issue = await musehub_issues.create_issue(
999 db_session, repo_id=repo.repo_id, title=f"Task {i}", body="", labels=[],
1000 )
1001 await musehub_issues.set_issue_milestone(
1002 db_session, repo.repo_id, issue.number, milestone_id=ms.milestone_id
1003 )
1004 # Close half
1005 for n in range(1, 26):
1006 await musehub_issues.close_issue(db_session, repo.repo_id, n)
1007 await db_session.commit()
1008 fetched = await musehub_issues.get_milestone(db_session, repo.repo_id, ms.number)
1009 assert fetched.open_issues == 25
1010 assert fetched.closed_issues == 25
1011
1012
1013 # ===========================================================================
1014 # Layer 5 — Data Integrity tests
1015 # ===========================================================================
1016
1017
1018 class TestDataIntegrity:
1019 @pytest.mark.anyio
1020 async def test_issue_number_survives_deletions(self, db_session: AsyncSession) -> None:
1021 """Numbers must not be reused when issues are closed or deleted."""
1022 repo = await _repo(db_session, "di-number-gap")
1023 i1 = await _issue(db_session, repo.repo_id, title="First")
1024 i2 = await _issue(db_session, repo.repo_id, title="Second")
1025 i3 = await _issue(db_session, repo.repo_id, title="Third")
1026 # Close the middle one — next issue must still be #4
1027 await musehub_issues.close_issue(db_session, repo.repo_id, i2.number)
1028 i4 = await _issue(db_session, repo.repo_id, title="Fourth")
1029 assert i4.number == 4
1030
1031 @pytest.mark.anyio
1032 async def test_update_does_not_change_number(self, db_session: AsyncSession) -> None:
1033 repo = await _repo(db_session, "di-update-num")
1034 issue = await _issue(db_session, repo.repo_id)
1035 original_number = issue.number
1036 updated = await musehub_issues.update_issue(
1037 db_session, repo.repo_id, issue.number, title="Changed title"
1038 )
1039 assert updated.number == original_number
1040
1041 @pytest.mark.anyio
1042 async def test_cross_repo_issue_isolation(self, db_session: AsyncSession) -> None:
1043 r1 = await _repo(db_session, "di-iso-1")
1044 r2 = await _repo(db_session, "di-iso-2")
1045 await _issue(db_session, r1.repo_id, title="In R1")
1046 result = await musehub_issues.get_issue(db_session, r2.repo_id, 1)
1047 assert result is None
1048
1049 @pytest.mark.anyio
1050 async def test_assign_issue_does_not_affect_other_issues(
1051 self, db_session: AsyncSession
1052 ) -> None:
1053 repo = await _repo(db_session, "di-assign-iso")
1054 i1 = await _issue(db_session, repo.repo_id)
1055 i2 = await _issue(db_session, repo.repo_id)
1056 await musehub_issues.assign_issue(
1057 db_session, repo.repo_id, i1.number, assignee="alice"
1058 )
1059 await db_session.commit()
1060 fetched_i2 = await musehub_issues.get_issue(db_session, repo.repo_id, i2.number)
1061 assert fetched_i2.assignee is None
1062
1063 @pytest.mark.anyio
1064 async def test_soft_delete_preserves_comment_in_db(
1065 self, db_session: AsyncSession
1066 ) -> None:
1067 repo = await _repo(db_session, "di-softdel")
1068 issue = await _issue(db_session, repo.repo_id)
1069 comment = await musehub_issues.create_comment(
1070 db_session, issue_id=issue.issue_id,
1071 repo_id=repo.repo_id, body="Still in DB", author="u",
1072 )
1073 await db_session.commit()
1074 await musehub_issues.delete_comment(db_session, comment.comment_id, issue.issue_id)
1075 await db_session.commit()
1076 # Raw DB row still exists with is_deleted=True
1077 row = await db_session.get(db.MusehubIssueComment, comment.comment_id)
1078 assert row is not None
1079 assert row.is_deleted is True
1080 assert row.body == "Still in DB"
1081
1082 @pytest.mark.anyio
1083 async def test_milestone_state_closed_persists(self, db_session: AsyncSession) -> None:
1084 repo = await _repo(db_session, "di-ms-closed")
1085 ms = await _milestone(db_session, repo.repo_id, title="Done sprint")
1086 row = await db_session.get(db.MusehubMilestone, ms.milestone_id)
1087 row.state = "closed"
1088 await db_session.commit()
1089 fetched = await musehub_issues.get_milestone(db_session, repo.repo_id, ms.number)
1090 assert fetched.state == "closed"
1091
1092 @pytest.mark.anyio
1093 async def test_delete_comment_returns_false_for_wrong_issue(
1094 self, db_session: AsyncSession
1095 ) -> None:
1096 repo = await _repo(db_session, "di-del-wrong")
1097 i1 = await _issue(db_session, repo.repo_id)
1098 i2 = await _issue(db_session, repo.repo_id)
1099 comment = await musehub_issues.create_comment(
1100 db_session, issue_id=i1.issue_id,
1101 repo_id=repo.repo_id, body="On issue 1", author="u",
1102 )
1103 await db_session.commit()
1104 # Passing wrong issue_id should return False (comment not found on i2)
1105 result = await musehub_issues.delete_comment(
1106 db_session, comment.comment_id, i2.issue_id
1107 )
1108 assert result is False
1109
1110
1111 # ===========================================================================
1112 # Layer 6 — Security tests
1113 # ===========================================================================
1114
1115
1116 class TestSecurity:
1117 @pytest.mark.anyio
1118 async def test_create_issue_requires_auth(
1119 self, client: AsyncClient
1120 ) -> None:
1121 r = await client.post(
1122 "/api/repos/some-repo-id/issues",
1123 json={"title": "No auth"},
1124 )
1125 assert r.status_code in (401, 403, 422)
1126
1127 @pytest.mark.anyio
1128 async def test_close_requires_auth(
1129 self, client: AsyncClient, db_session: AsyncSession
1130 ) -> None:
1131 # Set up via DB directly (not HTTP auth) so auth override isn't active
1132 repo = await _repo(db_session, "sec-close-auth")
1133 issue = await _issue(db_session, repo.repo_id, title="Issue")
1134 await db_session.commit()
1135 r = await client.post(
1136 f"/api/repos/{repo.repo_id}/issues/{issue.number}/close"
1137 )
1138 assert r.status_code in (401, 403)
1139
1140 @pytest.mark.anyio
1141 async def test_comment_create_requires_auth(
1142 self, client: AsyncClient, db_session: AsyncSession
1143 ) -> None:
1144 repo = await _repo(db_session, "sec-comment-auth")
1145 issue = await _issue(db_session, repo.repo_id, title="Issue")
1146 await db_session.commit()
1147 r = await client.post(
1148 f"/api/repos/{repo.repo_id}/issues/{issue.number}/comments",
1149 json={"body": "Unauthenticated"},
1150 )
1151 assert r.status_code in (401, 403)
1152
1153 @pytest.mark.anyio
1154 async def test_milestone_create_requires_auth(
1155 self, client: AsyncClient, db_session: AsyncSession
1156 ) -> None:
1157 repo = await _repo(db_session, "sec-ms-auth")
1158 await db_session.commit()
1159 r = await client.post(
1160 f"/api/repos/{repo.repo_id}/milestones",
1161 json={"title": "No auth milestone"},
1162 )
1163 assert r.status_code in (401, 403)
1164
1165 @pytest.mark.anyio
1166 async def test_cross_repo_issue_not_accessible(
1167 self, client: AsyncClient, auth_headers: StrDict
1168 ) -> None:
1169 r1 = await _api_repo(client, auth_headers, "sec-xrepo-1")
1170 r2 = await _api_repo(client, auth_headers, "sec-xrepo-2")
1171 await _api_issue(client, auth_headers, r1, title="Private issue in R1")
1172 # Issue #1 of r2 doesn't exist
1173 r = await client.get(f"/api/repos/{r2}/issues/1")
1174 assert r.status_code == 404
1175
1176 @pytest.mark.anyio
1177 async def test_title_max_length_enforced(
1178 self, client: AsyncClient, auth_headers: StrDict
1179 ) -> None:
1180 repo_id = await _api_repo(client, auth_headers, "sec-title-len")
1181 r = await client.post(
1182 f"/api/repos/{repo_id}/issues",
1183 json={"title": "x" * 501, "body": ""},
1184 headers=auth_headers,
1185 )
1186 assert r.status_code == 422
1187
1188 @pytest.mark.anyio
1189 async def test_comment_body_max_length_enforced(
1190 self, client: AsyncClient, auth_headers: StrDict
1191 ) -> None:
1192 repo_id = await _api_repo(client, auth_headers, "sec-body-len")
1193 issue = await _api_issue(client, auth_headers, repo_id)
1194 r = await client.post(
1195 f"/api/repos/{repo_id}/issues/{issue['number']}/comments",
1196 json={"body": "x" * 10_001},
1197 headers=auth_headers,
1198 )
1199 assert r.status_code == 422
1200
1201 @pytest.mark.anyio
1202 async def test_read_endpoints_accessible_without_auth(
1203 self, client: AsyncClient, auth_headers: StrDict
1204 ) -> None:
1205 """Public repos: read endpoints must not require auth."""
1206 repo_id = await _api_repo(client, auth_headers, "sec-public-read")
1207 await _api_issue(client, auth_headers, repo_id)
1208 # Read without any auth headers
1209 r = await client.get(f"/api/repos/{repo_id}/issues")
1210 assert r.status_code == 200
1211
1212
1213 # ===========================================================================
1214 # Layer 7 — Performance tests
1215 # ===========================================================================
1216
1217
1218 class TestPerformance:
1219 @pytest.mark.anyio
1220 async def test_list_50_issues_under_200ms(self, db_session: AsyncSession) -> None:
1221 repo = await _repo(db_session, "perf-list-50")
1222 for i in range(50):
1223 await musehub_issues.create_issue(
1224 db_session, repo_id=repo.repo_id,
1225 title=f"Issue {i}", body="", labels=[],
1226 )
1227 await db_session.commit()
1228
1229 t0 = time.perf_counter()
1230 results = await musehub_issues.list_issues(db_session, repo.repo_id, state="all")
1231 elapsed_ms = (time.perf_counter() - t0) * 1000
1232
1233 assert len(results) == 50
1234 assert elapsed_ms < 200, f"list_issues(50) took {elapsed_ms:.1f}ms"
1235
1236 @pytest.mark.anyio
1237 async def test_list_milestones_with_counts_under_300ms(
1238 self, db_session: AsyncSession
1239 ) -> None:
1240 repo = await _repo(db_session, "perf-ms-counts")
1241 for i in range(10):
1242 ms = await musehub_issues.create_milestone(
1243 db_session, repo_id=repo.repo_id, title=f"Sprint {i}",
1244 )
1245 for j in range(5):
1246 issue = await musehub_issues.create_issue(
1247 db_session, repo_id=repo.repo_id,
1248 title=f"Task {i}-{j}", body="", labels=[],
1249 )
1250 await musehub_issues.set_issue_milestone(
1251 db_session, repo.repo_id, issue.number, milestone_id=ms.milestone_id
1252 )
1253 await db_session.commit()
1254
1255 t0 = time.perf_counter()
1256 result = await musehub_issues.list_milestones(
1257 db_session, repo.repo_id, state="open"
1258 )
1259 elapsed_ms = (time.perf_counter() - t0) * 1000
1260
1261 assert len(result.milestones) == 10
1262 assert elapsed_ms < 300, f"list_milestones(10, 5 issues each) took {elapsed_ms:.1f}ms"
1263
1264 @pytest.mark.anyio
1265 async def test_create_issue_under_50ms(self, db_session: AsyncSession) -> None:
1266 repo = await _repo(db_session, "perf-create")
1267
1268 t0 = time.perf_counter()
1269 await musehub_issues.create_issue(
1270 db_session, repo_id=repo.repo_id, title="Perf issue", body="", labels=[],
1271 )
1272 elapsed_ms = (time.perf_counter() - t0) * 1000
1273
1274 assert elapsed_ms < 50, f"create_issue took {elapsed_ms:.1f}ms"
1275
1276 @pytest.mark.anyio
1277 async def test_pagination_total_consistent(
1278 self, client: AsyncClient, auth_headers: StrDict
1279 ) -> None:
1280 """total in IssueListResponse must match actual record count."""
1281 repo_id = await _api_repo(client, auth_headers, "perf-pagination")
1282 for i in range(15):
1283 await _api_issue(client, auth_headers, repo_id, title=f"Issue {i}")
1284
1285 r1 = await client.get(
1286 f"/api/repos/{repo_id}/issues", params={"page": 1, "per_page": 10}
1287 )
1288 r2 = await client.get(
1289 f"/api/repos/{repo_id}/issues", params={"page": 2, "per_page": 10}
1290 )
1291 total = r1.json()["total"]
1292 assert total == 15
1293 assert len(r1.json()["issues"]) == 10
1294 assert len(r2.json()["issues"]) == 5
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago