gabriel / musehub public
test_musehub_issues.py python
1121 lines 38.0 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for MuseHub issue tracking endpoints.
2
3 Covers every acceptance criterion:
4 - POST /repos/{repo_id}/issues creates an issue in open state
5 - Issue numbers are sequential per repo starting at 1
6 - GET /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/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/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 /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/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 /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/repos/{repo_id}/issues/{issue['number']}/close",
137 headers=auth_headers,
138 )
139
140 response = await client.get(
141 f"/api/v1/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/repos/{repo_id}/issues/{issue['number']}/close",
161 headers=auth_headers,
162 )
163
164 response = await client.get(
165 f"/api/v1/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/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 /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/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/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 /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/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/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/repos/some-repo/issues"),
288 ("POST", "/api/v1/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/repos/non-existent-repo/issues",
306 "/api/v1/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/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/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/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 auth_headers: dict[str, str],
430 ) -> None:
431 """issue_detail.html template contains the 'Author' meta-label — regression f."""
432 repo_id = await _create_repo(client, auth_headers, "author-detail-beats")
433 issue = await _create_issue(
434 client,
435 auth_headers,
436 repo_id,
437 title="Author label regression check",
438 )
439 number = issue["number"]
440
441 response = await client.get(f"/testuser/author-detail-beats/issues/{number}")
442 assert response.status_code == 200
443 body = response.text
444 # The SSR template renders author inline: "Opened … by <strong>…</strong>"
445 assert "by <strong>" in body
446
447
448 # ---------------------------------------------------------------------------
449 # Issue #218 — enhanced issue detail: comments, assignees, milestones
450 # ---------------------------------------------------------------------------
451
452
453 @pytest.mark.anyio
454 async def test_create_issue_comment(
455 client: AsyncClient,
456 auth_headers: dict[str, str],
457 ) -> None:
458 """POST /issues/{number}/comments creates a comment with body and author."""
459 repo_id = await _create_repo(client, auth_headers, "comment-repo-create")
460 issue = await _create_issue(client, auth_headers, repo_id, title="Bass clash in chorus")
461
462 response = await client.post(
463 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/comments",
464 json={"body": "The section:chorus beats:16-24 has a frequency clash with track:bass."},
465 headers=auth_headers,
466 )
467 assert response.status_code == 201
468 data = response.json()
469 assert "comments" in data
470 assert len(data["comments"]) == 1
471 comment = data["comments"][0]
472 assert comment["body"] == "The section:chorus beats:16-24 has a frequency clash with track:bass."
473 assert isinstance(comment["author"], str)
474 assert comment["parentId"] is None
475
476
477 @pytest.mark.anyio
478 async def test_list_issue_comments(
479 client: AsyncClient,
480 auth_headers: dict[str, str],
481 ) -> None:
482 """GET /issues/{number}/comments returns comments chronologically."""
483 repo_id = await _create_repo(client, auth_headers, "comment-repo-list")
484 issue = await _create_issue(client, auth_headers, repo_id, title="Kick timing issue")
485
486 await client.post(
487 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/comments",
488 json={"body": "First comment."},
489 headers=auth_headers,
490 )
491 await client.post(
492 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/comments",
493 json={"body": "Second comment."},
494 headers=auth_headers,
495 )
496
497 response = await client.get(
498 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/comments",
499 headers=auth_headers,
500 )
501 assert response.status_code == 200
502 data = response.json()
503 assert data["total"] == 2
504 assert data["comments"][0]["body"] == "First comment."
505 assert data["comments"][1]["body"] == "Second comment."
506
507
508 @pytest.mark.anyio
509 async def test_musical_context_parsed(
510 client: AsyncClient,
511 auth_headers: dict[str, str],
512 ) -> None:
513 """Musical context references (track:X, section:X, beats:X-Y) are parsed into musicalRefs."""
514 repo_id = await _create_repo(client, auth_headers, "musical-ref-repo")
515 issue = await _create_issue(client, auth_headers, repo_id, title="Musical context test")
516
517 response = await client.post(
518 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/comments",
519 json={"body": "Check track:bass at section:chorus around beats:16-24 please."},
520 headers=auth_headers,
521 )
522 assert response.status_code == 201
523 comment = response.json()["comments"][0]
524 refs = comment["musicalRefs"]
525 assert len(refs) == 3
526 ref_types = {r["type"]: r["value"] for r in refs}
527 assert ref_types.get("track") == "bass"
528 assert ref_types.get("section") == "chorus"
529 assert ref_types.get("beats") == "16-24"
530
531
532 @pytest.mark.anyio
533 async def test_assign_issue(
534 client: AsyncClient,
535 auth_headers: dict[str, str],
536 ) -> None:
537 """POST /issues/{number}/assign sets the assignee field."""
538 repo_id = await _create_repo(client, auth_headers, "assignee-repo")
539 issue = await _create_issue(client, auth_headers, repo_id, title="Assign test issue")
540
541 response = await client.post(
542 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/assign",
543 json={"assignee": "miles_davis"},
544 headers=auth_headers,
545 )
546 assert response.status_code == 200
547 data = response.json()
548 assert data["assignee"] == "miles_davis"
549
550
551 @pytest.mark.anyio
552 async def test_unassign_issue(
553 client: AsyncClient,
554 auth_headers: dict[str, str],
555 ) -> None:
556 """POST /issues/{number}/assign with null assignee clears the field."""
557 repo_id = await _create_repo(client, auth_headers, "unassign-repo")
558 issue = await _create_issue(client, auth_headers, repo_id, title="Unassign test")
559
560 await client.post(
561 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/assign",
562 json={"assignee": "coltrane"},
563 headers=auth_headers,
564 )
565 response = await client.post(
566 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/assign",
567 json={"assignee": None},
568 headers=auth_headers,
569 )
570 assert response.status_code == 200
571 assert response.json()["assignee"] is None
572
573
574 @pytest.mark.anyio
575 async def test_create_milestone(
576 client: AsyncClient,
577 auth_headers: dict[str, str],
578 ) -> None:
579 """POST /milestones creates a milestone with title and sequential number."""
580 repo_id = await _create_repo(client, auth_headers, "milestone-create-repo")
581
582 response = await client.post(
583 f"/api/v1/repos/{repo_id}/milestones",
584 json={"title": "Album v1.0", "description": "First release cut"},
585 headers=auth_headers,
586 )
587 assert response.status_code == 201
588 data = response.json()
589 assert data["title"] == "Album v1.0"
590 assert data["state"] == "open"
591 assert data["number"] == 1
592 assert data["openIssues"] == 0
593 assert data["closedIssues"] == 0
594
595
596 @pytest.mark.anyio
597 async def test_assign_issue_to_milestone(
598 client: AsyncClient,
599 auth_headers: dict[str, str],
600 ) -> None:
601 """POST /issues/{number}/milestone links the issue to a milestone."""
602 repo_id = await _create_repo(client, auth_headers, "milestone-assign-repo")
603 issue = await _create_issue(client, auth_headers, repo_id, title="Milestone target issue")
604
605 ms_resp = await client.post(
606 f"/api/v1/repos/{repo_id}/milestones",
607 json={"title": "Mix Revision 2"},
608 headers=auth_headers,
609 )
610 milestone_id: str = ms_resp.json()["milestoneId"]
611
612 response = await client.post(
613 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/milestone",
614 params={"milestone_id": milestone_id},
615 headers=auth_headers,
616 )
617 assert response.status_code == 200
618 data = response.json()
619 assert data["milestoneId"] == milestone_id
620 assert data["milestoneTitle"] == "Mix Revision 2"
621
622
623 @pytest.mark.anyio
624 async def test_reopen_issue(
625 client: AsyncClient,
626 auth_headers: dict[str, str],
627 ) -> None:
628 """POST /issues/{number}/reopen transitions a closed issue back to open."""
629 repo_id = await _create_repo(client, auth_headers, "reopen-repo")
630 issue = await _create_issue(client, auth_headers, repo_id, title="Reopen test")
631 number = issue["number"]
632
633 await client.post(
634 f"/api/v1/repos/{repo_id}/issues/{number}/close",
635 headers=auth_headers,
636 )
637
638 response = await client.post(
639 f"/api/v1/repos/{repo_id}/issues/{number}/reopen",
640 headers=auth_headers,
641 )
642 assert response.status_code == 200
643 assert response.json()["state"] == "open"
644
645
646 @pytest.mark.anyio
647 async def test_threaded_comment_reply(
648 client: AsyncClient,
649 auth_headers: dict[str, str],
650 ) -> None:
651 """POST /comments with parentId creates a threaded reply."""
652 repo_id = await _create_repo(client, auth_headers, "thread-repo")
653 issue = await _create_issue(client, auth_headers, repo_id, title="Threading test")
654 number = issue["number"]
655
656 first_resp = await client.post(
657 f"/api/v1/repos/{repo_id}/issues/{number}/comments",
658 json={"body": "Top-level comment."},
659 headers=auth_headers,
660 )
661 parent_id = first_resp.json()["comments"][0]["commentId"]
662
663 reply_resp = await client.post(
664 f"/api/v1/repos/{repo_id}/issues/{number}/comments",
665 json={"body": "Reply to the top-level.", "parentId": parent_id},
666 headers=auth_headers,
667 )
668 assert reply_resp.status_code == 201
669 comments = reply_resp.json()["comments"]
670 replies = [c for c in comments if c["parentId"] == parent_id]
671 assert len(replies) == 1
672 assert replies[0]["body"] == "Reply to the top-level."
673
674
675 @pytest.mark.anyio
676 async def test_issue_comment_count_in_response(
677 client: AsyncClient,
678 auth_headers: dict[str, str],
679 ) -> None:
680 """GET /issues/{number} returns commentCount reflecting current non-deleted comments."""
681 repo_id = await _create_repo(client, auth_headers, "comment-count-repo")
682 issue = await _create_issue(client, auth_headers, repo_id, title="Count test")
683 number = issue["number"]
684
685 await client.post(
686 f"/api/v1/repos/{repo_id}/issues/{number}/comments",
687 json={"body": "Comment one."},
688 headers=auth_headers,
689 )
690 await client.post(
691 f"/api/v1/repos/{repo_id}/issues/{number}/comments",
692 json={"body": "Comment two."},
693 headers=auth_headers,
694 )
695
696 response = await client.get(
697 f"/api/v1/repos/{repo_id}/issues/{number}",
698 headers=auth_headers,
699 )
700 assert response.status_code == 200
701 assert response.json()["commentCount"] == 2
702
703
704 @pytest.mark.anyio
705 async def test_edit_issue_title_and_body(
706 client: AsyncClient,
707 auth_headers: dict[str, str],
708 ) -> None:
709 """PATCH /issues/{number} updates title and body."""
710 repo_id = await _create_repo(client, auth_headers, "edit-issue-repo")
711 issue = await _create_issue(client, auth_headers, repo_id, title="Original title")
712
713 response = await client.patch(
714 f"/api/v1/repos/{repo_id}/issues/{issue['number']}",
715 json={"title": "Updated title", "body": "Updated body."},
716 headers=auth_headers,
717 )
718 assert response.status_code == 200
719 data = response.json()
720 assert data["title"] == "Updated title"
721 assert data["body"] == "Updated body."
722
723
724 @pytest.mark.anyio
725 async def test_list_milestones(
726 client: AsyncClient,
727 auth_headers: dict[str, str],
728 ) -> None:
729 """GET /milestones returns all open milestones."""
730 repo_id = await _create_repo(client, auth_headers, "milestone-list-repo")
731
732 await client.post(
733 f"/api/v1/repos/{repo_id}/milestones",
734 json={"title": "Phase 1"},
735 headers=auth_headers,
736 )
737 await client.post(
738 f"/api/v1/repos/{repo_id}/milestones",
739 json={"title": "Phase 2"},
740 headers=auth_headers,
741 )
742
743 response = await client.get(
744 f"/api/v1/repos/{repo_id}/milestones",
745 headers=auth_headers,
746 )
747 assert response.status_code == 200
748 data = response.json()
749 assert len(data["milestones"]) == 2
750 assert data["milestones"][0]["title"] == "Phase 1"
751 assert data["milestones"][1]["title"] == "Phase 2"
752
753
754 @pytest.mark.anyio
755 async def test_get_milestone_by_number(
756 client: AsyncClient,
757 auth_headers: dict[str, str],
758 ) -> None:
759 """GET /milestones/{number} returns a single milestone with issue counts."""
760 repo_id = await _create_repo(client, auth_headers, "milestone-get-repo")
761
762 ms_resp = await client.post(
763 f"/api/v1/repos/{repo_id}/milestones",
764 json={"title": "Single Milestone", "description": "Only one here"},
765 headers=auth_headers,
766 )
767 assert ms_resp.status_code == 201
768 number = ms_resp.json()["number"]
769
770 response = await client.get(
771 f"/api/v1/repos/{repo_id}/milestones/{number}",
772 headers=auth_headers,
773 )
774 assert response.status_code == 200
775 data = response.json()
776 assert data["title"] == "Single Milestone"
777 assert data["description"] == "Only one here"
778 assert data["state"] == "open"
779 assert data["openIssues"] == 0
780 assert data["closedIssues"] == 0
781
782
783 @pytest.mark.anyio
784 async def test_get_milestone_not_found(
785 client: AsyncClient,
786 auth_headers: dict[str, str],
787 ) -> None:
788 """GET /milestones/{number} returns 404 for a non-existent milestone number."""
789 repo_id = await _create_repo(client, auth_headers, "milestone-get-404-repo")
790
791 response = await client.get(
792 f"/api/v1/repos/{repo_id}/milestones/999",
793 headers=auth_headers,
794 )
795 assert response.status_code == 404
796
797
798 @pytest.mark.anyio
799 async def test_update_milestone_title_and_state(
800 client: AsyncClient,
801 auth_headers: dict[str, str],
802 ) -> None:
803 """PATCH /milestones/{number} updates only the provided fields."""
804 repo_id = await _create_repo(client, auth_headers, "milestone-patch-repo")
805
806 ms_resp = await client.post(
807 f"/api/v1/repos/{repo_id}/milestones",
808 json={"title": "Initial Title"},
809 headers=auth_headers,
810 )
811 number = ms_resp.json()["number"]
812
813 response = await client.patch(
814 f"/api/v1/repos/{repo_id}/milestones/{number}",
815 json={"title": "Revised Title", "state": "closed"},
816 headers=auth_headers,
817 )
818 assert response.status_code == 200
819 data = response.json()
820 assert data["title"] == "Revised Title"
821 assert data["state"] == "closed"
822
823
824 @pytest.mark.anyio
825 async def test_update_milestone_clear_due_on(
826 client: AsyncClient,
827 auth_headers: dict[str, str],
828 ) -> None:
829 """PATCH /milestones/{number} with due_on=null clears the due date."""
830 repo_id = await _create_repo(client, auth_headers, "milestone-clear-due-repo")
831
832 ms_resp = await client.post(
833 f"/api/v1/repos/{repo_id}/milestones",
834 json={"title": "Dated Milestone", "dueOn": "2026-12-31T00:00:00Z"},
835 headers=auth_headers,
836 )
837 number = ms_resp.json()["number"]
838
839 response = await client.patch(
840 f"/api/v1/repos/{repo_id}/milestones/{number}",
841 json={"dueOn": None},
842 headers=auth_headers,
843 )
844 assert response.status_code == 200
845 assert response.json()["dueOn"] is None
846
847
848 @pytest.mark.anyio
849 async def test_delete_milestone_unlinks_issues(
850 client: AsyncClient,
851 auth_headers: dict[str, str],
852 ) -> None:
853 """DELETE /milestones/{number} removes the milestone but leaves issues intact."""
854 repo_id = await _create_repo(client, auth_headers, "milestone-delete-repo")
855
856 ms_resp = await client.post(
857 f"/api/v1/repos/{repo_id}/milestones",
858 json={"title": "Ephemeral Milestone"},
859 headers=auth_headers,
860 )
861 number = ms_resp.json()["number"]
862 milestone_id: str = ms_resp.json()["milestoneId"]
863
864 issue = await _create_issue(client, auth_headers, repo_id, title="Issue to unlink")
865 await client.post(
866 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/milestone",
867 params={"milestone_id": milestone_id},
868 headers=auth_headers,
869 )
870
871 delete_resp = await client.delete(
872 f"/api/v1/repos/{repo_id}/milestones/{number}",
873 headers=auth_headers,
874 )
875 assert delete_resp.status_code == 204
876
877 # Milestone is gone
878 get_resp = await client.get(
879 f"/api/v1/repos/{repo_id}/milestones/{number}",
880 headers=auth_headers,
881 )
882 assert get_resp.status_code == 404
883
884 # Issue still exists with milestone unlinked
885 issue_resp = await client.get(
886 f"/api/v1/repos/{repo_id}/issues/{issue['number']}",
887 headers=auth_headers,
888 )
889 assert issue_resp.status_code == 200
890 assert issue_resp.json()["milestoneId"] is None
891
892
893 @pytest.mark.anyio
894 async def test_list_milestones_sort_by_title(
895 client: AsyncClient,
896 auth_headers: dict[str, str],
897 ) -> None:
898 """GET /milestones?sort=title returns milestones sorted alphabetically."""
899 repo_id = await _create_repo(client, auth_headers, "milestone-sort-title-repo")
900
901 for title in ["Zeta", "Alpha", "Mu"]:
902 await client.post(
903 f"/api/v1/repos/{repo_id}/milestones",
904 json={"title": title},
905 headers=auth_headers,
906 )
907
908 response = await client.get(
909 f"/api/v1/repos/{repo_id}/milestones",
910 params={"sort": "title"},
911 headers=auth_headers,
912 )
913 assert response.status_code == 200
914 titles = [m["title"] for m in response.json()["milestones"]]
915 assert titles == sorted(titles)
916
917
918 # ---------------------------------------------------------------------------
919 # Issue #419 — milestone and label assignment endpoints
920 # ---------------------------------------------------------------------------
921
922
923 @pytest.mark.anyio
924 async def test_delete_issue_milestone_removes_link(
925 client: AsyncClient,
926 auth_headers: dict[str, str],
927 ) -> None:
928 """DELETE /issues/{number}/milestone clears the milestone link on an issue."""
929 repo_id = await _create_repo(client, auth_headers, "del-milestone-repo")
930 issue = await _create_issue(client, auth_headers, repo_id, title="Issue with milestone")
931
932 ms_resp = await client.post(
933 f"/api/v1/repos/{repo_id}/milestones",
934 json={"title": "Temp Milestone"},
935 headers=auth_headers,
936 )
937 milestone_id: str = ms_resp.json()["milestoneId"]
938
939 # Link milestone
940 await client.post(
941 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/milestone",
942 params={"milestone_id": milestone_id},
943 headers=auth_headers,
944 )
945
946 # Remove milestone via DELETE
947 response = await client.delete(
948 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/milestone",
949 headers=auth_headers,
950 )
951 assert response.status_code == 200
952 data = response.json()
953 assert data["milestoneId"] is None
954 assert data["milestoneTitle"] is None
955
956
957 @pytest.mark.anyio
958 async def test_delete_issue_milestone_idempotent(
959 client: AsyncClient,
960 auth_headers: dict[str, str],
961 ) -> None:
962 """DELETE /milestone on an issue with no milestone succeeds silently."""
963 repo_id = await _create_repo(client, auth_headers, "del-milestone-idempotent-repo")
964 issue = await _create_issue(client, auth_headers, repo_id, title="No milestone issue")
965
966 response = await client.delete(
967 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/milestone",
968 headers=auth_headers,
969 )
970 assert response.status_code == 200
971 assert response.json()["milestoneId"] is None
972
973
974 @pytest.mark.anyio
975 async def test_delete_issue_milestone_not_found(
976 client: AsyncClient,
977 auth_headers: dict[str, str],
978 ) -> None:
979 """DELETE /issues/999/milestone returns 404 for an unknown issue."""
980 repo_id = await _create_repo(client, auth_headers, "del-milestone-404-repo")
981
982 response = await client.delete(
983 f"/api/v1/repos/{repo_id}/issues/999/milestone",
984 headers=auth_headers,
985 )
986 assert response.status_code == 404
987
988
989 @pytest.mark.anyio
990 async def test_assign_issue_labels_replaces_labels(
991 client: AsyncClient,
992 auth_headers: dict[str, str],
993 ) -> None:
994 """POST /issues/{number}/labels replaces the entire label list."""
995 repo_id = await _create_repo(client, auth_headers, "label-assign-repo")
996 issue = await _create_issue(
997 client, auth_headers, repo_id, title="Label test issue", labels=["old-label"]
998 )
999 assert issue["labels"] == ["old-label"]
1000
1001 response = await client.post(
1002 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/labels",
1003 json={"labels": ["harmony", "needs-review"]},
1004 headers=auth_headers,
1005 )
1006 assert response.status_code == 200
1007 data = response.json()
1008 assert data["labels"] == ["harmony", "needs-review"]
1009 assert "old-label" not in data["labels"]
1010
1011
1012 @pytest.mark.anyio
1013 async def test_assign_issue_labels_empty_clears_labels(
1014 client: AsyncClient,
1015 auth_headers: dict[str, str],
1016 ) -> None:
1017 """POST /issues/{number}/labels with empty list clears all labels."""
1018 repo_id = await _create_repo(client, auth_headers, "label-clear-repo")
1019 issue = await _create_issue(
1020 client, auth_headers, repo_id, title="Labelled issue", labels=["bug", "musical"]
1021 )
1022
1023 response = await client.post(
1024 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/labels",
1025 json={"labels": []},
1026 headers=auth_headers,
1027 )
1028 assert response.status_code == 200
1029 assert response.json()["labels"] == []
1030
1031
1032 @pytest.mark.anyio
1033 async def test_assign_issue_labels_not_found(
1034 client: AsyncClient,
1035 auth_headers: dict[str, str],
1036 ) -> None:
1037 """POST /issues/999/labels returns 404 for an unknown issue."""
1038 repo_id = await _create_repo(client, auth_headers, "label-assign-404-repo")
1039
1040 response = await client.post(
1041 f"/api/v1/repos/{repo_id}/issues/999/labels",
1042 json={"labels": ["bug"]},
1043 headers=auth_headers,
1044 )
1045 assert response.status_code == 404
1046
1047
1048 @pytest.mark.anyio
1049 async def test_remove_issue_label_removes_single_label(
1050 client: AsyncClient,
1051 auth_headers: dict[str, str],
1052 ) -> None:
1053 """DELETE /issues/{number}/labels/{name} removes one label and leaves the rest."""
1054 repo_id = await _create_repo(client, auth_headers, "label-remove-repo")
1055 issue = await _create_issue(
1056 client,
1057 auth_headers,
1058 repo_id,
1059 title="Multi-label issue",
1060 labels=["bug", "harmony", "needs-review"],
1061 )
1062
1063 response = await client.delete(
1064 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/labels/harmony",
1065 headers=auth_headers,
1066 )
1067 assert response.status_code == 200
1068 remaining = response.json()["labels"]
1069 assert "harmony" not in remaining
1070 assert "bug" in remaining
1071 assert "needs-review" in remaining
1072
1073
1074 @pytest.mark.anyio
1075 async def test_remove_issue_label_idempotent(
1076 client: AsyncClient,
1077 auth_headers: dict[str, str],
1078 ) -> None:
1079 """DELETE /labels/{name} silently succeeds when the label is not present."""
1080 repo_id = await _create_repo(client, auth_headers, "label-remove-idempotent-repo")
1081 issue = await _create_issue(
1082 client, auth_headers, repo_id, title="No such label issue", labels=["bug"]
1083 )
1084
1085 response = await client.delete(
1086 f"/api/v1/repos/{repo_id}/issues/{issue['number']}/labels/nonexistent",
1087 headers=auth_headers,
1088 )
1089 assert response.status_code == 200
1090 assert response.json()["labels"] == ["bug"]
1091
1092
1093 @pytest.mark.anyio
1094 async def test_remove_issue_label_not_found(
1095 client: AsyncClient,
1096 auth_headers: dict[str, str],
1097 ) -> None:
1098 """DELETE /issues/999/labels/{name} returns 404 for an unknown issue."""
1099 repo_id = await _create_repo(client, auth_headers, "label-remove-404-repo")
1100
1101 response = await client.delete(
1102 f"/api/v1/repos/{repo_id}/issues/999/labels/bug",
1103 headers=auth_headers,
1104 )
1105 assert response.status_code == 404
1106
1107
1108 @pytest.mark.anyio
1109 async def test_new_endpoints_require_auth(client: AsyncClient) -> None:
1110 """DELETE /milestone, POST /labels, DELETE /labels/{name} all require authentication."""
1111 endpoints: list[tuple[str, str, dict[str, object]]] = [
1112 ("DELETE", "/api/v1/repos/some-repo/issues/1/milestone", {}),
1113 ("POST", "/api/v1/repos/some-repo/issues/1/labels", {"labels": ["bug"]}),
1114 ("DELETE", "/api/v1/repos/some-repo/issues/1/labels/bug", {}),
1115 ]
1116 for method, url, payload in endpoints:
1117 if method == "DELETE":
1118 response = await client.delete(url)
1119 else:
1120 response = await client.post(url, json=payload)
1121 assert response.status_code == 401, f"{method} {url} should require auth"