gabriel / musehub public
test_musehub_issues.py python
1125 lines 38.7 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for Muse Hub issue tracking endpoints.
2
3 Covers every acceptance criterion:
4 - POST /musehub/repos/{repo_id}/issues creates an issue in open state
5 - Issue numbers are sequential per repo starting at 1
6 - GET /musehub/repos/{repo_id}/issues returns open issues by default
7 - GET .../issues?label=<label> filters by label
8 - POST .../issues/{number}/close sets state to closed
9 - GET .../issues/{number} returns 404 for unknown issue numbers
10 - All endpoints require valid JWT
11
12 All tests use the shared ``client``, ``auth_headers``, and ``db_session``
13 fixtures from conftest.py.
14 """
15 from __future__ import annotations
16
17 import pytest
18 from httpx import AsyncClient
19 from sqlalchemy.ext.asyncio import AsyncSession
20
21 from musehub.services import musehub_repository, musehub_issues
22
23
24 # ---------------------------------------------------------------------------
25 # Helpers
26 # ---------------------------------------------------------------------------
27
28
29 async def _create_repo(client: AsyncClient, auth_headers: dict[str, str], name: str = "test-repo") -> str:
30 """Create a repo via the API and return its repo_id."""
31 response = await client.post(
32 "/api/v1/musehub/repos",
33 json={"name": name, "owner": "testuser"},
34 headers=auth_headers,
35 )
36 assert response.status_code == 201
37 repo_id: str = response.json()["repoId"]
38 return repo_id
39
40
41 async def _create_issue(
42 client: AsyncClient,
43 auth_headers: dict[str, str],
44 repo_id: str,
45 title: str = "Kick clashes with bass in measure 4",
46 body: str = "",
47 labels: list[str] | None = None,
48 ) -> dict[str, object]:
49 response = await client.post(
50 f"/api/v1/musehub/repos/{repo_id}/issues",
51 json={"title": title, "body": body, "labels": labels or []},
52 headers=auth_headers,
53 )
54 assert response.status_code == 201
55 issue: dict[str, object] = response.json()
56 return issue
57
58
59 # ---------------------------------------------------------------------------
60 # POST /musehub/repos/{repo_id}/issues
61 # ---------------------------------------------------------------------------
62
63
64 @pytest.mark.anyio
65 async def test_create_issue_returns_open_state(
66 client: AsyncClient,
67 auth_headers: dict[str, str],
68 ) -> None:
69 """POST /issues creates an issue in 'open' state with all required fields."""
70 repo_id = await _create_repo(client, auth_headers, "open-state-repo")
71 response = await client.post(
72 f"/api/v1/musehub/repos/{repo_id}/issues",
73 json={"title": "Hi-hat / synth pad clash", "body": "Measure 8 has a frequency clash.", "labels": ["bug"]},
74 headers=auth_headers,
75 )
76 assert response.status_code == 201
77 body = response.json()
78 assert body["state"] == "open"
79 assert body["title"] == "Hi-hat / synth pad clash"
80 assert body["labels"] == ["bug"]
81 assert "issueId" in body
82 assert "number" in body
83 assert "createdAt" in body
84
85
86 @pytest.mark.anyio
87 async def test_issue_numbers_sequential(
88 client: AsyncClient,
89 auth_headers: dict[str, str],
90 ) -> None:
91 """Issue numbers within a repo are sequential starting at 1."""
92 repo_id = await _create_repo(client, auth_headers, "seq-repo")
93
94 first = await _create_issue(client, auth_headers, repo_id, title="First issue")
95 second = await _create_issue(client, auth_headers, repo_id, title="Second issue")
96 third = await _create_issue(client, auth_headers, repo_id, title="Third issue")
97
98 assert first["number"] == 1
99 assert second["number"] == 2
100 assert third["number"] == 3
101
102
103 @pytest.mark.anyio
104 async def test_issue_numbers_independent_per_repo(
105 client: AsyncClient,
106 auth_headers: dict[str, str],
107 ) -> None:
108 """Issue numbers restart at 1 for each repo independently."""
109 repo_a = await _create_repo(client, auth_headers, "repo-a")
110 repo_b = await _create_repo(client, auth_headers, "repo-b")
111
112 issue_a = await _create_issue(client, auth_headers, repo_a, title="Repo A issue")
113 issue_b = await _create_issue(client, auth_headers, repo_b, title="Repo B issue")
114
115 assert issue_a["number"] == 1
116 assert issue_b["number"] == 1
117
118
119 # ---------------------------------------------------------------------------
120 # GET /musehub/repos/{repo_id}/issues
121 # ---------------------------------------------------------------------------
122
123
124 @pytest.mark.anyio
125 async def test_list_issues_default_open_only(
126 client: AsyncClient,
127 auth_headers: dict[str, str],
128 ) -> None:
129 """GET /issues with no params returns only open issues."""
130 repo_id = await _create_repo(client, auth_headers, "default-open-repo")
131 await _create_issue(client, auth_headers, repo_id, title="Open issue")
132
133 # Create a second issue and close it
134 issue = await _create_issue(client, auth_headers, repo_id, title="Closed issue")
135 await client.post(
136 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/close",
137 headers=auth_headers,
138 )
139
140 response = await client.get(
141 f"/api/v1/musehub/repos/{repo_id}/issues",
142 headers=auth_headers,
143 )
144 assert response.status_code == 200
145 issues = response.json()["issues"]
146 assert len(issues) == 1
147 assert issues[0]["state"] == "open"
148
149
150 @pytest.mark.anyio
151 async def test_list_issues_state_all_returns_all(
152 client: AsyncClient,
153 auth_headers: dict[str, str],
154 ) -> None:
155 """?state=all returns both open and closed issues."""
156 repo_id = await _create_repo(client, auth_headers, "state-all-repo")
157 await _create_issue(client, auth_headers, repo_id, title="Open issue")
158 issue = await _create_issue(client, auth_headers, repo_id, title="To close")
159 await client.post(
160 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/close",
161 headers=auth_headers,
162 )
163
164 response = await client.get(
165 f"/api/v1/musehub/repos/{repo_id}/issues?state=all",
166 headers=auth_headers,
167 )
168 assert response.status_code == 200
169 assert len(response.json()["issues"]) == 2
170
171
172 @pytest.mark.anyio
173 async def test_list_issues_label_filter(
174 client: AsyncClient,
175 auth_headers: dict[str, str],
176 ) -> None:
177 """GET /issues?label=bug returns only issues that have the 'bug' label."""
178 repo_id = await _create_repo(client, auth_headers, "label-filter-repo")
179 await _create_issue(client, auth_headers, repo_id, title="Bug issue", labels=["bug"])
180 await _create_issue(client, auth_headers, repo_id, title="Feature issue", labels=["feature"])
181 await _create_issue(client, auth_headers, repo_id, title="Multi-label", labels=["bug", "musical"])
182
183 response = await client.get(
184 f"/api/v1/musehub/repos/{repo_id}/issues?label=bug",
185 headers=auth_headers,
186 )
187 assert response.status_code == 200
188 issues = response.json()["issues"]
189 assert len(issues) == 2
190 for issue in issues:
191 assert "bug" in issue["labels"]
192
193
194 # ---------------------------------------------------------------------------
195 # GET /musehub/repos/{repo_id}/issues/{issue_number}
196 # ---------------------------------------------------------------------------
197
198
199 @pytest.mark.anyio
200 async def test_get_issue_not_found_returns_404(
201 client: AsyncClient,
202 auth_headers: dict[str, str],
203 ) -> None:
204 """GET /issues/{number} returns 404 for a number that doesn't exist."""
205 repo_id = await _create_repo(client, auth_headers, "not-found-repo")
206
207 response = await client.get(
208 f"/api/v1/musehub/repos/{repo_id}/issues/999",
209 headers=auth_headers,
210 )
211 assert response.status_code == 404
212
213
214 @pytest.mark.anyio
215 async def test_get_issue_returns_full_object(
216 client: AsyncClient,
217 auth_headers: dict[str, str],
218 ) -> None:
219 """GET /issues/{number} returns the full issue object."""
220 repo_id = await _create_repo(client, auth_headers, "get-issue-repo")
221 created = await _create_issue(
222 client, auth_headers, repo_id,
223 title="Delay tail bleeds into next section",
224 body="The reverb tail from the bridge extends 200ms into the verse.",
225 labels=["musical", "mix"],
226 )
227
228 response = await client.get(
229 f"/api/v1/musehub/repos/{repo_id}/issues/{created['number']}",
230 headers=auth_headers,
231 )
232 assert response.status_code == 200
233 body = response.json()
234 assert body["issueId"] == created["issueId"]
235 assert body["title"] == "Delay tail bleeds into next section"
236 assert body["body"] == "The reverb tail from the bridge extends 200ms into the verse."
237 assert body["labels"] == ["musical", "mix"]
238
239
240 # ---------------------------------------------------------------------------
241 # POST /musehub/repos/{repo_id}/issues/{issue_number}/close
242 # ---------------------------------------------------------------------------
243
244
245 @pytest.mark.anyio
246 async def test_close_issue_changes_state(
247 client: AsyncClient,
248 auth_headers: dict[str, str],
249 ) -> None:
250 """POST /issues/{number}/close sets the issue state to 'closed'."""
251 repo_id = await _create_repo(client, auth_headers, "close-state-repo")
252 issue = await _create_issue(client, auth_headers, repo_id, title="Clipping at measure 12")
253 assert issue["state"] == "open"
254
255 response = await client.post(
256 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/close",
257 headers=auth_headers,
258 )
259 assert response.status_code == 200
260 assert response.json()["state"] == "closed"
261
262
263 @pytest.mark.anyio
264 async def test_close_nonexistent_issue_returns_404(
265 client: AsyncClient,
266 auth_headers: dict[str, str],
267 ) -> None:
268 """POST /issues/999/close returns 404 for an unknown issue number."""
269 repo_id = await _create_repo(client, auth_headers, "close-404-repo")
270
271 response = await client.post(
272 f"/api/v1/musehub/repos/{repo_id}/issues/999/close",
273 headers=auth_headers,
274 )
275 assert response.status_code == 404
276
277
278 # ---------------------------------------------------------------------------
279 # Auth guard
280 # ---------------------------------------------------------------------------
281
282
283 @pytest.mark.anyio
284 async def test_issue_write_endpoints_require_auth(client: AsyncClient) -> None:
285 """POST issue endpoints return 401 without a Bearer token (always require auth)."""
286 write_endpoints = [
287 ("POST", "/api/v1/musehub/repos/some-repo/issues"),
288 ("POST", "/api/v1/musehub/repos/some-repo/issues/1/close"),
289 ]
290 for method, url in write_endpoints:
291 response = await client.post(url, json={})
292 assert response.status_code == 401, f"{method} {url} should require auth"
293
294
295 @pytest.mark.anyio
296 async def test_issue_read_endpoints_return_404_for_nonexistent_repo_without_auth(
297 client: AsyncClient,
298 ) -> None:
299 """GET issue endpoints return 404 for non-existent repos without a token.
300
301 Read endpoints use optional_token — auth is visibility-based; the DB
302 lookup happens before the auth check, so a missing repo returns 404.
303 """
304 read_endpoints = [
305 "/api/v1/musehub/repos/non-existent-repo/issues",
306 "/api/v1/musehub/repos/non-existent-repo/issues/1",
307 ]
308 for url in read_endpoints:
309 response = await client.get(url)
310 assert response.status_code == 404, f"GET {url} should return 404 for non-existent repo"
311
312
313 # ---------------------------------------------------------------------------
314 # Service layer — direct DB tests (no HTTP)
315 # ---------------------------------------------------------------------------
316
317
318 @pytest.mark.anyio
319 async def test_create_issue_service_persists_to_db(db_session: AsyncSession) -> None:
320 """musehub_issues.create_issue() persists the row and returns correct fields."""
321 repo = await musehub_repository.create_repo(
322 db_session,
323 name="service-issue-repo",
324 owner="testuser",
325 visibility="private",
326 owner_user_id="user-abc",
327 )
328 await db_session.commit()
329
330 issue = await musehub_issues.create_issue(
331 db_session,
332 repo_id=repo.repo_id,
333 title="Bass note timing drift",
334 body="Measure 4, beat 3 — bass is 10ms late.",
335 labels=["timing", "bass"],
336 )
337 await db_session.commit()
338
339 fetched = await musehub_issues.get_issue(db_session, repo.repo_id, issue.number)
340 assert fetched is not None
341 assert fetched.title == "Bass note timing drift"
342 assert fetched.state == "open"
343 assert fetched.labels == ["timing", "bass"]
344 assert fetched.number == 1
345
346
347 @pytest.mark.anyio
348 async def test_list_issues_closed_state_filter(db_session: AsyncSession) -> None:
349 """list_issues() with state='closed' returns only closed issues."""
350 repo = await musehub_repository.create_repo(
351 db_session,
352 name="filter-state-repo",
353 owner="testuser",
354 visibility="private",
355 owner_user_id="user-xyz",
356 )
357 await db_session.commit()
358
359 open_issue = await musehub_issues.create_issue(
360 db_session, repo_id=repo.repo_id, title="Still open", body="", labels=[]
361 )
362 closed_issue = await musehub_issues.create_issue(
363 db_session, repo_id=repo.repo_id, title="Already closed", body="", labels=[]
364 )
365 await musehub_issues.close_issue(db_session, repo.repo_id, closed_issue.number)
366 await db_session.commit()
367
368 open_list = await musehub_issues.list_issues(db_session, repo.repo_id, state="open")
369 closed_list = await musehub_issues.list_issues(db_session, repo.repo_id, state="closed")
370 all_list = await musehub_issues.list_issues(db_session, repo.repo_id, state="all")
371
372 assert len(open_list) == 1
373 assert open_list[0].issue_id == open_issue.issue_id
374 assert len(closed_list) == 1
375 assert closed_list[0].issue_id == closed_issue.issue_id
376 assert len(all_list) == 2
377
378
379 # ---------------------------------------------------------------------------
380 # Regression tests — author field on Issue, PR, Release
381 # ---------------------------------------------------------------------------
382
383
384 @pytest.mark.anyio
385 async def test_create_issue_author_in_response(
386 client: AsyncClient,
387 auth_headers: dict[str, str],
388 ) -> None:
389 """POST /issues response includes the author field (JWT sub) — regression f."""
390 repo_id = await _create_repo(client, auth_headers, "author-issue-repo")
391 response = await client.post(
392 f"/api/v1/musehub/repos/{repo_id}/issues",
393 json={"title": "Author field regression", "body": "", "labels": []},
394 headers=auth_headers,
395 )
396 assert response.status_code == 201
397 body = response.json()
398 assert "author" in body
399 # The author is the JWT sub from the test token — must be a non-None string
400 assert isinstance(body["author"], str)
401
402
403 @pytest.mark.anyio
404 async def test_create_issue_author_persisted_in_list(
405 client: AsyncClient,
406 auth_headers: dict[str, str],
407 ) -> None:
408 """Author field is persisted and returned in the issue list endpoint — regression f."""
409 repo_id = await _create_repo(client, auth_headers, "author-list-repo")
410 await client.post(
411 f"/api/v1/musehub/repos/{repo_id}/issues",
412 json={"title": "Authored issue", "body": "", "labels": []},
413 headers=auth_headers,
414 )
415 list_response = await client.get(
416 f"/api/v1/musehub/repos/{repo_id}/issues",
417 headers=auth_headers,
418 )
419 assert list_response.status_code == 200
420 issues = list_response.json()["issues"]
421 assert len(issues) == 1
422 assert "author" in issues[0]
423 assert isinstance(issues[0]["author"], str)
424
425
426 @pytest.mark.anyio
427 async def test_issue_detail_page_shows_author_label(
428 client: AsyncClient,
429 db_session: AsyncSession,
430 ) -> None:
431 """issue_detail.html template contains the 'Author' meta-label — regression f."""
432 from musehub.db.musehub_models import MusehubRepo
433 repo = MusehubRepo(
434 name="author-detail-beats",
435 owner="testuser",
436 slug="author-detail-beats",
437 visibility="private",
438 owner_user_id="test-owner",
439 )
440 db_session.add(repo)
441 await db_session.commit()
442 await db_session.refresh(repo)
443
444 response = await client.get("/musehub/ui/testuser/author-detail-beats/issues/1")
445 assert response.status_code == 200
446 body = response.text
447 # The JS template string containing the author meta-item must be in the page
448 assert "Author" in body
449 assert "meta-label" in body
450
451
452 # ---------------------------------------------------------------------------
453 # Issue #218 — enhanced issue detail: comments, assignees, milestones
454 # ---------------------------------------------------------------------------
455
456
457 @pytest.mark.anyio
458 async def test_create_issue_comment(
459 client: AsyncClient,
460 auth_headers: dict[str, str],
461 ) -> None:
462 """POST /issues/{number}/comments creates a comment with body and author."""
463 repo_id = await _create_repo(client, auth_headers, "comment-repo-create")
464 issue = await _create_issue(client, auth_headers, repo_id, title="Bass clash in chorus")
465
466 response = await client.post(
467 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/comments",
468 json={"body": "The section:chorus beats:16-24 has a frequency clash with track:bass."},
469 headers=auth_headers,
470 )
471 assert response.status_code == 201
472 data = response.json()
473 assert "comments" in data
474 assert len(data["comments"]) == 1
475 comment = data["comments"][0]
476 assert comment["body"] == "The section:chorus beats:16-24 has a frequency clash with track:bass."
477 assert isinstance(comment["author"], str)
478 assert comment["parentId"] is None
479
480
481 @pytest.mark.anyio
482 async def test_list_issue_comments(
483 client: AsyncClient,
484 auth_headers: dict[str, str],
485 ) -> None:
486 """GET /issues/{number}/comments returns comments chronologically."""
487 repo_id = await _create_repo(client, auth_headers, "comment-repo-list")
488 issue = await _create_issue(client, auth_headers, repo_id, title="Kick timing issue")
489
490 await client.post(
491 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/comments",
492 json={"body": "First comment."},
493 headers=auth_headers,
494 )
495 await client.post(
496 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/comments",
497 json={"body": "Second comment."},
498 headers=auth_headers,
499 )
500
501 response = await client.get(
502 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/comments",
503 headers=auth_headers,
504 )
505 assert response.status_code == 200
506 data = response.json()
507 assert data["total"] == 2
508 assert data["comments"][0]["body"] == "First comment."
509 assert data["comments"][1]["body"] == "Second comment."
510
511
512 @pytest.mark.anyio
513 async def test_musical_context_parsed(
514 client: AsyncClient,
515 auth_headers: dict[str, str],
516 ) -> None:
517 """Musical context references (track:X, section:X, beats:X-Y) are parsed into musicalRefs."""
518 repo_id = await _create_repo(client, auth_headers, "musical-ref-repo")
519 issue = await _create_issue(client, auth_headers, repo_id, title="Musical context test")
520
521 response = await client.post(
522 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/comments",
523 json={"body": "Check track:bass at section:chorus around beats:16-24 please."},
524 headers=auth_headers,
525 )
526 assert response.status_code == 201
527 comment = response.json()["comments"][0]
528 refs = comment["musicalRefs"]
529 assert len(refs) == 3
530 ref_types = {r["type"]: r["value"] for r in refs}
531 assert ref_types.get("track") == "bass"
532 assert ref_types.get("section") == "chorus"
533 assert ref_types.get("beats") == "16-24"
534
535
536 @pytest.mark.anyio
537 async def test_assign_issue(
538 client: AsyncClient,
539 auth_headers: dict[str, str],
540 ) -> None:
541 """POST /issues/{number}/assign sets the assignee field."""
542 repo_id = await _create_repo(client, auth_headers, "assignee-repo")
543 issue = await _create_issue(client, auth_headers, repo_id, title="Assign test issue")
544
545 response = await client.post(
546 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/assign",
547 json={"assignee": "miles_davis"},
548 headers=auth_headers,
549 )
550 assert response.status_code == 200
551 data = response.json()
552 assert data["assignee"] == "miles_davis"
553
554
555 @pytest.mark.anyio
556 async def test_unassign_issue(
557 client: AsyncClient,
558 auth_headers: dict[str, str],
559 ) -> None:
560 """POST /issues/{number}/assign with null assignee clears the field."""
561 repo_id = await _create_repo(client, auth_headers, "unassign-repo")
562 issue = await _create_issue(client, auth_headers, repo_id, title="Unassign test")
563
564 await client.post(
565 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/assign",
566 json={"assignee": "coltrane"},
567 headers=auth_headers,
568 )
569 response = await client.post(
570 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/assign",
571 json={"assignee": None},
572 headers=auth_headers,
573 )
574 assert response.status_code == 200
575 assert response.json()["assignee"] is None
576
577
578 @pytest.mark.anyio
579 async def test_create_milestone(
580 client: AsyncClient,
581 auth_headers: dict[str, str],
582 ) -> None:
583 """POST /milestones creates a milestone with title and sequential number."""
584 repo_id = await _create_repo(client, auth_headers, "milestone-create-repo")
585
586 response = await client.post(
587 f"/api/v1/musehub/repos/{repo_id}/milestones",
588 json={"title": "Album v1.0", "description": "First release cut"},
589 headers=auth_headers,
590 )
591 assert response.status_code == 201
592 data = response.json()
593 assert data["title"] == "Album v1.0"
594 assert data["state"] == "open"
595 assert data["number"] == 1
596 assert data["openIssues"] == 0
597 assert data["closedIssues"] == 0
598
599
600 @pytest.mark.anyio
601 async def test_assign_issue_to_milestone(
602 client: AsyncClient,
603 auth_headers: dict[str, str],
604 ) -> None:
605 """POST /issues/{number}/milestone links the issue to a milestone."""
606 repo_id = await _create_repo(client, auth_headers, "milestone-assign-repo")
607 issue = await _create_issue(client, auth_headers, repo_id, title="Milestone target issue")
608
609 ms_resp = await client.post(
610 f"/api/v1/musehub/repos/{repo_id}/milestones",
611 json={"title": "Mix Revision 2"},
612 headers=auth_headers,
613 )
614 milestone_id: str = ms_resp.json()["milestoneId"]
615
616 response = await client.post(
617 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/milestone",
618 params={"milestone_id": milestone_id},
619 headers=auth_headers,
620 )
621 assert response.status_code == 200
622 data = response.json()
623 assert data["milestoneId"] == milestone_id
624 assert data["milestoneTitle"] == "Mix Revision 2"
625
626
627 @pytest.mark.anyio
628 async def test_reopen_issue(
629 client: AsyncClient,
630 auth_headers: dict[str, str],
631 ) -> None:
632 """POST /issues/{number}/reopen transitions a closed issue back to open."""
633 repo_id = await _create_repo(client, auth_headers, "reopen-repo")
634 issue = await _create_issue(client, auth_headers, repo_id, title="Reopen test")
635 number = issue["number"]
636
637 await client.post(
638 f"/api/v1/musehub/repos/{repo_id}/issues/{number}/close",
639 headers=auth_headers,
640 )
641
642 response = await client.post(
643 f"/api/v1/musehub/repos/{repo_id}/issues/{number}/reopen",
644 headers=auth_headers,
645 )
646 assert response.status_code == 200
647 assert response.json()["state"] == "open"
648
649
650 @pytest.mark.anyio
651 async def test_threaded_comment_reply(
652 client: AsyncClient,
653 auth_headers: dict[str, str],
654 ) -> None:
655 """POST /comments with parentId creates a threaded reply."""
656 repo_id = await _create_repo(client, auth_headers, "thread-repo")
657 issue = await _create_issue(client, auth_headers, repo_id, title="Threading test")
658 number = issue["number"]
659
660 first_resp = await client.post(
661 f"/api/v1/musehub/repos/{repo_id}/issues/{number}/comments",
662 json={"body": "Top-level comment."},
663 headers=auth_headers,
664 )
665 parent_id = first_resp.json()["comments"][0]["commentId"]
666
667 reply_resp = await client.post(
668 f"/api/v1/musehub/repos/{repo_id}/issues/{number}/comments",
669 json={"body": "Reply to the top-level.", "parentId": parent_id},
670 headers=auth_headers,
671 )
672 assert reply_resp.status_code == 201
673 comments = reply_resp.json()["comments"]
674 replies = [c for c in comments if c["parentId"] == parent_id]
675 assert len(replies) == 1
676 assert replies[0]["body"] == "Reply to the top-level."
677
678
679 @pytest.mark.anyio
680 async def test_issue_comment_count_in_response(
681 client: AsyncClient,
682 auth_headers: dict[str, str],
683 ) -> None:
684 """GET /issues/{number} returns commentCount reflecting current non-deleted comments."""
685 repo_id = await _create_repo(client, auth_headers, "comment-count-repo")
686 issue = await _create_issue(client, auth_headers, repo_id, title="Count test")
687 number = issue["number"]
688
689 await client.post(
690 f"/api/v1/musehub/repos/{repo_id}/issues/{number}/comments",
691 json={"body": "Comment one."},
692 headers=auth_headers,
693 )
694 await client.post(
695 f"/api/v1/musehub/repos/{repo_id}/issues/{number}/comments",
696 json={"body": "Comment two."},
697 headers=auth_headers,
698 )
699
700 response = await client.get(
701 f"/api/v1/musehub/repos/{repo_id}/issues/{number}",
702 headers=auth_headers,
703 )
704 assert response.status_code == 200
705 assert response.json()["commentCount"] == 2
706
707
708 @pytest.mark.anyio
709 async def test_edit_issue_title_and_body(
710 client: AsyncClient,
711 auth_headers: dict[str, str],
712 ) -> None:
713 """PATCH /issues/{number} updates title and body."""
714 repo_id = await _create_repo(client, auth_headers, "edit-issue-repo")
715 issue = await _create_issue(client, auth_headers, repo_id, title="Original title")
716
717 response = await client.patch(
718 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}",
719 json={"title": "Updated title", "body": "Updated body."},
720 headers=auth_headers,
721 )
722 assert response.status_code == 200
723 data = response.json()
724 assert data["title"] == "Updated title"
725 assert data["body"] == "Updated body."
726
727
728 @pytest.mark.anyio
729 async def test_list_milestones(
730 client: AsyncClient,
731 auth_headers: dict[str, str],
732 ) -> None:
733 """GET /milestones returns all open milestones."""
734 repo_id = await _create_repo(client, auth_headers, "milestone-list-repo")
735
736 await client.post(
737 f"/api/v1/musehub/repos/{repo_id}/milestones",
738 json={"title": "Phase 1"},
739 headers=auth_headers,
740 )
741 await client.post(
742 f"/api/v1/musehub/repos/{repo_id}/milestones",
743 json={"title": "Phase 2"},
744 headers=auth_headers,
745 )
746
747 response = await client.get(
748 f"/api/v1/musehub/repos/{repo_id}/milestones",
749 headers=auth_headers,
750 )
751 assert response.status_code == 200
752 data = response.json()
753 assert len(data["milestones"]) == 2
754 assert data["milestones"][0]["title"] == "Phase 1"
755 assert data["milestones"][1]["title"] == "Phase 2"
756
757
758 @pytest.mark.anyio
759 async def test_get_milestone_by_number(
760 client: AsyncClient,
761 auth_headers: dict[str, str],
762 ) -> None:
763 """GET /milestones/{number} returns a single milestone with issue counts."""
764 repo_id = await _create_repo(client, auth_headers, "milestone-get-repo")
765
766 ms_resp = await client.post(
767 f"/api/v1/musehub/repos/{repo_id}/milestones",
768 json={"title": "Single Milestone", "description": "Only one here"},
769 headers=auth_headers,
770 )
771 assert ms_resp.status_code == 201
772 number = ms_resp.json()["number"]
773
774 response = await client.get(
775 f"/api/v1/musehub/repos/{repo_id}/milestones/{number}",
776 headers=auth_headers,
777 )
778 assert response.status_code == 200
779 data = response.json()
780 assert data["title"] == "Single Milestone"
781 assert data["description"] == "Only one here"
782 assert data["state"] == "open"
783 assert data["openIssues"] == 0
784 assert data["closedIssues"] == 0
785
786
787 @pytest.mark.anyio
788 async def test_get_milestone_not_found(
789 client: AsyncClient,
790 auth_headers: dict[str, str],
791 ) -> None:
792 """GET /milestones/{number} returns 404 for a non-existent milestone number."""
793 repo_id = await _create_repo(client, auth_headers, "milestone-get-404-repo")
794
795 response = await client.get(
796 f"/api/v1/musehub/repos/{repo_id}/milestones/999",
797 headers=auth_headers,
798 )
799 assert response.status_code == 404
800
801
802 @pytest.mark.anyio
803 async def test_update_milestone_title_and_state(
804 client: AsyncClient,
805 auth_headers: dict[str, str],
806 ) -> None:
807 """PATCH /milestones/{number} updates only the provided fields."""
808 repo_id = await _create_repo(client, auth_headers, "milestone-patch-repo")
809
810 ms_resp = await client.post(
811 f"/api/v1/musehub/repos/{repo_id}/milestones",
812 json={"title": "Initial Title"},
813 headers=auth_headers,
814 )
815 number = ms_resp.json()["number"]
816
817 response = await client.patch(
818 f"/api/v1/musehub/repos/{repo_id}/milestones/{number}",
819 json={"title": "Revised Title", "state": "closed"},
820 headers=auth_headers,
821 )
822 assert response.status_code == 200
823 data = response.json()
824 assert data["title"] == "Revised Title"
825 assert data["state"] == "closed"
826
827
828 @pytest.mark.anyio
829 async def test_update_milestone_clear_due_on(
830 client: AsyncClient,
831 auth_headers: dict[str, str],
832 ) -> None:
833 """PATCH /milestones/{number} with due_on=null clears the due date."""
834 repo_id = await _create_repo(client, auth_headers, "milestone-clear-due-repo")
835
836 ms_resp = await client.post(
837 f"/api/v1/musehub/repos/{repo_id}/milestones",
838 json={"title": "Dated Milestone", "dueOn": "2026-12-31T00:00:00Z"},
839 headers=auth_headers,
840 )
841 number = ms_resp.json()["number"]
842
843 response = await client.patch(
844 f"/api/v1/musehub/repos/{repo_id}/milestones/{number}",
845 json={"dueOn": None},
846 headers=auth_headers,
847 )
848 assert response.status_code == 200
849 assert response.json()["dueOn"] is None
850
851
852 @pytest.mark.anyio
853 async def test_delete_milestone_unlinks_issues(
854 client: AsyncClient,
855 auth_headers: dict[str, str],
856 ) -> None:
857 """DELETE /milestones/{number} removes the milestone but leaves issues intact."""
858 repo_id = await _create_repo(client, auth_headers, "milestone-delete-repo")
859
860 ms_resp = await client.post(
861 f"/api/v1/musehub/repos/{repo_id}/milestones",
862 json={"title": "Ephemeral Milestone"},
863 headers=auth_headers,
864 )
865 number = ms_resp.json()["number"]
866 milestone_id: str = ms_resp.json()["milestoneId"]
867
868 issue = await _create_issue(client, auth_headers, repo_id, title="Issue to unlink")
869 await client.post(
870 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/milestone",
871 params={"milestone_id": milestone_id},
872 headers=auth_headers,
873 )
874
875 delete_resp = await client.delete(
876 f"/api/v1/musehub/repos/{repo_id}/milestones/{number}",
877 headers=auth_headers,
878 )
879 assert delete_resp.status_code == 204
880
881 # Milestone is gone
882 get_resp = await client.get(
883 f"/api/v1/musehub/repos/{repo_id}/milestones/{number}",
884 headers=auth_headers,
885 )
886 assert get_resp.status_code == 404
887
888 # Issue still exists with milestone unlinked
889 issue_resp = await client.get(
890 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}",
891 headers=auth_headers,
892 )
893 assert issue_resp.status_code == 200
894 assert issue_resp.json()["milestoneId"] is None
895
896
897 @pytest.mark.anyio
898 async def test_list_milestones_sort_by_title(
899 client: AsyncClient,
900 auth_headers: dict[str, str],
901 ) -> None:
902 """GET /milestones?sort=title returns milestones sorted alphabetically."""
903 repo_id = await _create_repo(client, auth_headers, "milestone-sort-title-repo")
904
905 for title in ["Zeta", "Alpha", "Mu"]:
906 await client.post(
907 f"/api/v1/musehub/repos/{repo_id}/milestones",
908 json={"title": title},
909 headers=auth_headers,
910 )
911
912 response = await client.get(
913 f"/api/v1/musehub/repos/{repo_id}/milestones",
914 params={"sort": "title"},
915 headers=auth_headers,
916 )
917 assert response.status_code == 200
918 titles = [m["title"] for m in response.json()["milestones"]]
919 assert titles == sorted(titles)
920
921
922 # ---------------------------------------------------------------------------
923 # Issue #419 — milestone and label assignment endpoints
924 # ---------------------------------------------------------------------------
925
926
927 @pytest.mark.anyio
928 async def test_delete_issue_milestone_removes_link(
929 client: AsyncClient,
930 auth_headers: dict[str, str],
931 ) -> None:
932 """DELETE /issues/{number}/milestone clears the milestone link on an issue."""
933 repo_id = await _create_repo(client, auth_headers, "del-milestone-repo")
934 issue = await _create_issue(client, auth_headers, repo_id, title="Issue with milestone")
935
936 ms_resp = await client.post(
937 f"/api/v1/musehub/repos/{repo_id}/milestones",
938 json={"title": "Temp Milestone"},
939 headers=auth_headers,
940 )
941 milestone_id: str = ms_resp.json()["milestoneId"]
942
943 # Link milestone
944 await client.post(
945 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/milestone",
946 params={"milestone_id": milestone_id},
947 headers=auth_headers,
948 )
949
950 # Remove milestone via DELETE
951 response = await client.delete(
952 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/milestone",
953 headers=auth_headers,
954 )
955 assert response.status_code == 200
956 data = response.json()
957 assert data["milestoneId"] is None
958 assert data["milestoneTitle"] is None
959
960
961 @pytest.mark.anyio
962 async def test_delete_issue_milestone_idempotent(
963 client: AsyncClient,
964 auth_headers: dict[str, str],
965 ) -> None:
966 """DELETE /milestone on an issue with no milestone succeeds silently."""
967 repo_id = await _create_repo(client, auth_headers, "del-milestone-idempotent-repo")
968 issue = await _create_issue(client, auth_headers, repo_id, title="No milestone issue")
969
970 response = await client.delete(
971 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/milestone",
972 headers=auth_headers,
973 )
974 assert response.status_code == 200
975 assert response.json()["milestoneId"] is None
976
977
978 @pytest.mark.anyio
979 async def test_delete_issue_milestone_not_found(
980 client: AsyncClient,
981 auth_headers: dict[str, str],
982 ) -> None:
983 """DELETE /issues/999/milestone returns 404 for an unknown issue."""
984 repo_id = await _create_repo(client, auth_headers, "del-milestone-404-repo")
985
986 response = await client.delete(
987 f"/api/v1/musehub/repos/{repo_id}/issues/999/milestone",
988 headers=auth_headers,
989 )
990 assert response.status_code == 404
991
992
993 @pytest.mark.anyio
994 async def test_assign_issue_labels_replaces_labels(
995 client: AsyncClient,
996 auth_headers: dict[str, str],
997 ) -> None:
998 """POST /issues/{number}/labels replaces the entire label list."""
999 repo_id = await _create_repo(client, auth_headers, "label-assign-repo")
1000 issue = await _create_issue(
1001 client, auth_headers, repo_id, title="Label test issue", labels=["old-label"]
1002 )
1003 assert issue["labels"] == ["old-label"]
1004
1005 response = await client.post(
1006 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/labels",
1007 json={"labels": ["harmony", "needs-review"]},
1008 headers=auth_headers,
1009 )
1010 assert response.status_code == 200
1011 data = response.json()
1012 assert data["labels"] == ["harmony", "needs-review"]
1013 assert "old-label" not in data["labels"]
1014
1015
1016 @pytest.mark.anyio
1017 async def test_assign_issue_labels_empty_clears_labels(
1018 client: AsyncClient,
1019 auth_headers: dict[str, str],
1020 ) -> None:
1021 """POST /issues/{number}/labels with empty list clears all labels."""
1022 repo_id = await _create_repo(client, auth_headers, "label-clear-repo")
1023 issue = await _create_issue(
1024 client, auth_headers, repo_id, title="Labelled issue", labels=["bug", "musical"]
1025 )
1026
1027 response = await client.post(
1028 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/labels",
1029 json={"labels": []},
1030 headers=auth_headers,
1031 )
1032 assert response.status_code == 200
1033 assert response.json()["labels"] == []
1034
1035
1036 @pytest.mark.anyio
1037 async def test_assign_issue_labels_not_found(
1038 client: AsyncClient,
1039 auth_headers: dict[str, str],
1040 ) -> None:
1041 """POST /issues/999/labels returns 404 for an unknown issue."""
1042 repo_id = await _create_repo(client, auth_headers, "label-assign-404-repo")
1043
1044 response = await client.post(
1045 f"/api/v1/musehub/repos/{repo_id}/issues/999/labels",
1046 json={"labels": ["bug"]},
1047 headers=auth_headers,
1048 )
1049 assert response.status_code == 404
1050
1051
1052 @pytest.mark.anyio
1053 async def test_remove_issue_label_removes_single_label(
1054 client: AsyncClient,
1055 auth_headers: dict[str, str],
1056 ) -> None:
1057 """DELETE /issues/{number}/labels/{name} removes one label and leaves the rest."""
1058 repo_id = await _create_repo(client, auth_headers, "label-remove-repo")
1059 issue = await _create_issue(
1060 client,
1061 auth_headers,
1062 repo_id,
1063 title="Multi-label issue",
1064 labels=["bug", "harmony", "needs-review"],
1065 )
1066
1067 response = await client.delete(
1068 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/labels/harmony",
1069 headers=auth_headers,
1070 )
1071 assert response.status_code == 200
1072 remaining = response.json()["labels"]
1073 assert "harmony" not in remaining
1074 assert "bug" in remaining
1075 assert "needs-review" in remaining
1076
1077
1078 @pytest.mark.anyio
1079 async def test_remove_issue_label_idempotent(
1080 client: AsyncClient,
1081 auth_headers: dict[str, str],
1082 ) -> None:
1083 """DELETE /labels/{name} silently succeeds when the label is not present."""
1084 repo_id = await _create_repo(client, auth_headers, "label-remove-idempotent-repo")
1085 issue = await _create_issue(
1086 client, auth_headers, repo_id, title="No such label issue", labels=["bug"]
1087 )
1088
1089 response = await client.delete(
1090 f"/api/v1/musehub/repos/{repo_id}/issues/{issue['number']}/labels/nonexistent",
1091 headers=auth_headers,
1092 )
1093 assert response.status_code == 200
1094 assert response.json()["labels"] == ["bug"]
1095
1096
1097 @pytest.mark.anyio
1098 async def test_remove_issue_label_not_found(
1099 client: AsyncClient,
1100 auth_headers: dict[str, str],
1101 ) -> None:
1102 """DELETE /issues/999/labels/{name} returns 404 for an unknown issue."""
1103 repo_id = await _create_repo(client, auth_headers, "label-remove-404-repo")
1104
1105 response = await client.delete(
1106 f"/api/v1/musehub/repos/{repo_id}/issues/999/labels/bug",
1107 headers=auth_headers,
1108 )
1109 assert response.status_code == 404
1110
1111
1112 @pytest.mark.anyio
1113 async def test_new_endpoints_require_auth(client: AsyncClient) -> None:
1114 """DELETE /milestone, POST /labels, DELETE /labels/{name} all require authentication."""
1115 endpoints: list[tuple[str, str, dict[str, object]]] = [
1116 ("DELETE", "/api/v1/musehub/repos/some-repo/issues/1/milestone", {}),
1117 ("POST", "/api/v1/musehub/repos/some-repo/issues/1/labels", {"labels": ["bug"]}),
1118 ("DELETE", "/api/v1/musehub/repos/some-repo/issues/1/labels/bug", {}),
1119 ]
1120 for method, url, payload in endpoints:
1121 if method == "DELETE":
1122 response = await client.delete(url)
1123 else:
1124 response = await client.post(url, json=payload)
1125 assert response.status_code == 401, f"{method} {url} should require auth"