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