gabriel / musehub public
test_musehub_issues.py python
869 lines 30.2 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 155 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.types.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 async def test_create_issue_returns_open_state(
66 client: AsyncClient,
67 auth_headers: StrDict,
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/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 async def test_issue_numbers_sequential(
87 client: AsyncClient,
88 auth_headers: StrDict,
89 ) -> None:
90 """Issue numbers within a repo are sequential starting at 1."""
91 repo_id = await _create_repo(client, auth_headers, "seq-repo")
92
93 first = await _create_issue(client, auth_headers, repo_id, title="First issue")
94 second = await _create_issue(client, auth_headers, repo_id, title="Second issue")
95 third = await _create_issue(client, auth_headers, repo_id, title="Third issue")
96
97 assert first["number"] == 1
98 assert second["number"] == 2
99 assert third["number"] == 3
100
101
102 async def test_issue_numbers_independent_per_repo(
103 client: AsyncClient,
104 auth_headers: StrDict,
105 ) -> None:
106 """Issue numbers restart at 1 for each repo independently."""
107 repo_a = await _create_repo(client, auth_headers, "repo-a")
108 repo_b = await _create_repo(client, auth_headers, "repo-b")
109
110 issue_a = await _create_issue(client, auth_headers, repo_a, title="Repo A issue")
111 issue_b = await _create_issue(client, auth_headers, repo_b, title="Repo B issue")
112
113 assert issue_a["number"] == 1
114 assert issue_b["number"] == 1
115
116
117 # ---------------------------------------------------------------------------
118 # GET /repos/{repo_id}/issues
119 # ---------------------------------------------------------------------------
120
121
122 async def test_list_issues_default_open_only(
123 client: AsyncClient,
124 auth_headers: StrDict,
125 ) -> None:
126 """GET /issues with no params returns only open issues."""
127 repo_id = await _create_repo(client, auth_headers, "default-open-repo")
128 await _create_issue(client, auth_headers, repo_id, title="Open issue")
129
130 # Create a second issue and close it
131 issue = await _create_issue(client, auth_headers, repo_id, title="Closed issue")
132 await client.post(
133 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
134 headers=auth_headers,
135 )
136
137 response = await client.get(
138 f"/api/repos/{repo_id}/issues",
139 headers=auth_headers,
140 )
141 assert response.status_code == 200
142 issues = response.json()["issues"]
143 assert len(issues) == 1
144 assert issues[0]["state"] == "open"
145
146
147 async def test_list_issues_state_all_returns_all(
148 client: AsyncClient,
149 auth_headers: StrDict,
150 ) -> None:
151 """?state=all returns both open and closed issues."""
152 repo_id = await _create_repo(client, auth_headers, "state-all-repo")
153 await _create_issue(client, auth_headers, repo_id, title="Open issue")
154 issue = await _create_issue(client, auth_headers, repo_id, title="To close")
155 await client.post(
156 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
157 headers=auth_headers,
158 )
159
160 response = await client.get(
161 f"/api/repos/{repo_id}/issues?state=all",
162 headers=auth_headers,
163 )
164 assert response.status_code == 200
165 assert len(response.json()["issues"]) == 2
166
167
168 async def test_list_issues_label_filter(
169 client: AsyncClient,
170 auth_headers: StrDict,
171 ) -> None:
172 """GET /issues?label=bug returns only issues that have the 'bug' label."""
173 repo_id = await _create_repo(client, auth_headers, "label-filter-repo")
174 await _create_issue(client, auth_headers, repo_id, title="Bug issue", labels=["bug"])
175 await _create_issue(client, auth_headers, repo_id, title="Feature issue", labels=["feature"])
176 await _create_issue(client, auth_headers, repo_id, title="Multi-label", labels=["bug", "musical"])
177
178 response = await client.get(
179 f"/api/repos/{repo_id}/issues?label=bug",
180 headers=auth_headers,
181 )
182 assert response.status_code == 200
183 issues = response.json()["issues"]
184 assert len(issues) == 2
185 for issue in issues:
186 assert "bug" in issue["labels"]
187
188
189 # ---------------------------------------------------------------------------
190 # GET /repos/{repo_id}/issues/{issue_number}
191 # ---------------------------------------------------------------------------
192
193
194 async def test_get_issue_not_found_returns_404(
195 client: AsyncClient,
196 auth_headers: StrDict,
197 ) -> None:
198 """GET /issues/{number} returns 404 for a number that doesn't exist."""
199 repo_id = await _create_repo(client, auth_headers, "not-found-repo")
200
201 response = await client.get(
202 f"/api/repos/{repo_id}/issues/999",
203 headers=auth_headers,
204 )
205 assert response.status_code == 404
206
207
208 async def test_get_issue_returns_full_object(
209 client: AsyncClient,
210 auth_headers: StrDict,
211 ) -> None:
212 """GET /issues/{number} returns the full issue object."""
213 repo_id = await _create_repo(client, auth_headers, "get-issue-repo")
214 created = await _create_issue(
215 client, auth_headers, repo_id,
216 title="Delay tail bleeds into next section",
217 body="The reverb tail from the bridge extends 200ms into the verse.",
218 labels=["musical", "mix"],
219 )
220
221 response = await client.get(
222 f"/api/repos/{repo_id}/issues/{created['number']}",
223 headers=auth_headers,
224 )
225 assert response.status_code == 200
226 body = response.json()
227 assert body["issueId"] == created["issueId"]
228 assert body["title"] == "Delay tail bleeds into next section"
229 assert body["body"] == "The reverb tail from the bridge extends 200ms into the verse."
230 assert body["labels"] == ["musical", "mix"]
231
232
233 # ---------------------------------------------------------------------------
234 # POST /repos/{repo_id}/issues/{issue_number}/close
235 # ---------------------------------------------------------------------------
236
237
238 async def test_close_issue_changes_state(
239 client: AsyncClient,
240 auth_headers: StrDict,
241 ) -> None:
242 """POST /issues/{number}/close sets the issue state to 'closed'."""
243 repo_id = await _create_repo(client, auth_headers, "close-state-repo")
244 issue = await _create_issue(client, auth_headers, repo_id, title="Clipping at measure 12")
245 assert issue["state"] == "open"
246
247 response = await client.post(
248 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
249 headers=auth_headers,
250 )
251 assert response.status_code == 200
252 assert response.json()["state"] == "closed"
253
254
255 async def test_close_nonexistent_issue_returns_404(
256 client: AsyncClient,
257 auth_headers: StrDict,
258 ) -> None:
259 """POST /issues/999/close returns 404 for an unknown issue number."""
260 repo_id = await _create_repo(client, auth_headers, "close-404-repo")
261
262 response = await client.post(
263 f"/api/repos/{repo_id}/issues/999/close",
264 headers=auth_headers,
265 )
266 assert response.status_code == 404
267
268
269 # ---------------------------------------------------------------------------
270 # Auth guard
271 # ---------------------------------------------------------------------------
272
273
274 async def test_issue_write_endpoints_require_auth(client: AsyncClient) -> None:
275 """POST issue endpoints return 401 without a MSign Authorization header (always require auth)."""
276 write_endpoints = [
277 ("POST", "/api/repos/some-repo/issues"),
278 ("POST", "/api/repos/some-repo/issues/1/close"),
279 ]
280 for method, url in write_endpoints:
281 response = await client.post(url, json={})
282 assert response.status_code == 401, f"{method} {url} should require auth"
283
284
285 async def test_issue_read_endpoints_return_404_for_nonexistent_repo_without_auth(
286 client: AsyncClient,
287 ) -> None:
288 """GET issue endpoints return 404 for non-existent repos without a token.
289
290 Read endpoints use optional_token — auth is visibility-based; the DB
291 lookup happens before the auth check, so a missing repo returns 404.
292 """
293 read_endpoints = [
294 "/api/repos/non-existent-repo/issues",
295 "/api/repos/non-existent-repo/issues/1",
296 ]
297 for url in read_endpoints:
298 response = await client.get(url)
299 assert response.status_code == 404, f"GET {url} should return 404 for non-existent repo"
300
301
302 # ---------------------------------------------------------------------------
303 # Service layer — direct DB tests (no HTTP)
304 # ---------------------------------------------------------------------------
305
306
307 async def test_create_issue_service_persists_to_db(db_session: AsyncSession) -> None:
308 """musehub_issues.create_issue() persists the row and returns correct fields."""
309 repo = await musehub_repository.create_repo(
310 db_session,
311 name="service-issue-repo",
312 owner="testuser",
313 visibility="private",
314 owner_user_id="user-abc",
315 )
316 await db_session.commit()
317
318 issue = await musehub_issues.create_issue(
319 db_session,
320 repo_id=repo.repo_id,
321 title="Bass note timing drift",
322 body="Measure 4, beat 3 — bass is 10ms late.",
323 labels=["timing", "bass"],
324 )
325 await db_session.commit()
326
327 fetched = await musehub_issues.get_issue(db_session, repo.repo_id, issue.number)
328 assert fetched is not None
329 assert fetched.title == "Bass note timing drift"
330 assert fetched.state == "open"
331 assert fetched.labels == ["timing", "bass"]
332 assert fetched.number == 1
333
334
335 async def test_list_issues_closed_state_filter(db_session: AsyncSession) -> None:
336 """list_issues() with state='closed' returns only closed issues."""
337 repo = await musehub_repository.create_repo(
338 db_session,
339 name="filter-state-repo",
340 owner="testuser",
341 visibility="private",
342 owner_user_id="user-xyz",
343 )
344 await db_session.commit()
345
346 open_issue = await musehub_issues.create_issue(
347 db_session, repo_id=repo.repo_id, title="Still open", body="", labels=[]
348 )
349 closed_issue = await musehub_issues.create_issue(
350 db_session, repo_id=repo.repo_id, title="Already closed", body="", labels=[]
351 )
352 await musehub_issues.close_issue(db_session, repo.repo_id, closed_issue.number)
353 await db_session.commit()
354
355 open_result = await musehub_issues.list_issues(db_session, repo.repo_id, state="open")
356 closed_result = await musehub_issues.list_issues(db_session, repo.repo_id, state="closed")
357 all_result = await musehub_issues.list_issues(db_session, repo.repo_id, state="all")
358
359 assert len(open_result.issues) == 1
360 assert open_result.issues[0].issue_id == open_issue.issue_id
361 assert len(closed_result.issues) == 1
362 assert closed_result.issues[0].issue_id == closed_issue.issue_id
363 assert len(all_result.issues) == 2
364
365
366 # ---------------------------------------------------------------------------
367 # Regression tests — author field on Issue, Proposal, Release
368 # ---------------------------------------------------------------------------
369
370
371 async def test_create_issue_author_in_response(
372 client: AsyncClient,
373 auth_headers: StrDict,
374 ) -> None:
375 """POST /issues response includes the author field (caller handle) — regression f."""
376 repo_id = await _create_repo(client, auth_headers, "author-issue-repo")
377 response = await client.post(
378 f"/api/repos/{repo_id}/issues",
379 json={"title": "Author field regression", "body": "", "labels": []},
380 headers=auth_headers,
381 )
382 assert response.status_code == 201
383 body = response.json()
384 assert "author" in body
385 # The author is the MSign handle from the verified request — must be a non-None string
386 assert isinstance(body["author"], str)
387
388
389 async def test_create_issue_author_persisted_in_list(
390 client: AsyncClient,
391 auth_headers: StrDict,
392 ) -> None:
393 """Author field is persisted and returned in the issue list endpoint — regression f."""
394 repo_id = await _create_repo(client, auth_headers, "author-list-repo")
395 await client.post(
396 f"/api/repos/{repo_id}/issues",
397 json={"title": "Authored issue", "body": "", "labels": []},
398 headers=auth_headers,
399 )
400 list_response = await client.get(
401 f"/api/repos/{repo_id}/issues",
402 headers=auth_headers,
403 )
404 assert list_response.status_code == 200
405 issues = list_response.json()["issues"]
406 assert len(issues) == 1
407 assert "author" in issues[0]
408 assert isinstance(issues[0]["author"], str)
409
410
411 async def test_issue_detail_page_shows_author_label(
412 client: AsyncClient,
413 auth_headers: StrDict,
414 ) -> None:
415 """issue_detail.html template contains the 'Author' meta-label — regression f."""
416 repo_id = await _create_repo(client, auth_headers, "author-detail-beats")
417 issue = await _create_issue(
418 client,
419 auth_headers,
420 repo_id,
421 title="Author label regression check",
422 )
423 number = issue["number"]
424
425 response = await client.get(f"/testuser/author-detail-beats/issues/{number}")
426 assert response.status_code == 200
427 body = response.text
428 # The SSR template renders the issue author in the detail page
429 assert "testuser" in body
430
431
432 # ---------------------------------------------------------------------------
433 # Issue #218 — enhanced issue detail: comments, assignees
434 # ---------------------------------------------------------------------------
435
436
437 async def test_create_issue_comment(
438 client: AsyncClient,
439 auth_headers: StrDict,
440 ) -> None:
441 """POST /issues/{number}/comments creates a comment with body and author."""
442 repo_id = await _create_repo(client, auth_headers, "comment-repo-create")
443 issue = await _create_issue(client, auth_headers, repo_id, title="Bass clash in chorus")
444
445 response = await client.post(
446 f"/api/repos/{repo_id}/issues/{issue['number']}/comments",
447 json={"body": "The section:chorus beats:16-24 has a frequency clash with track:bass."},
448 headers=auth_headers,
449 )
450 assert response.status_code == 201
451 comment = response.json()
452 assert comment["body"] == "The section:chorus beats:16-24 has a frequency clash with track:bass."
453 assert isinstance(comment["author"], str)
454 assert comment["parentId"] is None
455 assert "commentId" in comment
456
457
458 async def test_list_issue_comments(
459 client: AsyncClient,
460 auth_headers: StrDict,
461 ) -> None:
462 """GET /issues/{number}/comments returns comments chronologically."""
463 repo_id = await _create_repo(client, auth_headers, "comment-repo-list")
464 issue = await _create_issue(client, auth_headers, repo_id, title="Kick timing issue")
465
466 await client.post(
467 f"/api/repos/{repo_id}/issues/{issue['number']}/comments",
468 json={"body": "First comment."},
469 headers=auth_headers,
470 )
471 await client.post(
472 f"/api/repos/{repo_id}/issues/{issue['number']}/comments",
473 json={"body": "Second comment."},
474 headers=auth_headers,
475 )
476
477 response = await client.get(
478 f"/api/repos/{repo_id}/issues/{issue['number']}/comments",
479 headers=auth_headers,
480 )
481 assert response.status_code == 200
482 data = response.json()
483 assert data["total"] == 2
484 assert data["comments"][0]["body"] == "First comment."
485 assert data["comments"][1]["body"] == "Second comment."
486
487
488 async def test_assign_issue(
489 client: AsyncClient,
490 auth_headers: StrDict,
491 ) -> None:
492 """POST /issues/{number}/assign sets the assignee field."""
493 repo_id = await _create_repo(client, auth_headers, "assignee-repo")
494 issue = await _create_issue(client, auth_headers, repo_id, title="Assign test issue")
495
496 response = await client.post(
497 f"/api/repos/{repo_id}/issues/{issue['number']}/assign",
498 json={"assignee": "miles_davis"},
499 headers=auth_headers,
500 )
501 assert response.status_code == 200
502 data = response.json()
503 assert data["assignee"] == "miles_davis"
504
505
506 async def test_unassign_issue(
507 client: AsyncClient,
508 auth_headers: StrDict,
509 ) -> None:
510 """POST /issues/{number}/assign with null assignee clears the field."""
511 repo_id = await _create_repo(client, auth_headers, "unassign-repo")
512 issue = await _create_issue(client, auth_headers, repo_id, title="Unassign test")
513
514 await client.post(
515 f"/api/repos/{repo_id}/issues/{issue['number']}/assign",
516 json={"assignee": "coltrane"},
517 headers=auth_headers,
518 )
519 response = await client.post(
520 f"/api/repos/{repo_id}/issues/{issue['number']}/assign",
521 json={"assignee": None},
522 headers=auth_headers,
523 )
524 assert response.status_code == 200
525 assert response.json()["assignee"] is None
526
527
528 async def test_assign_issue_labels_replaces_labels(
529 client: AsyncClient,
530 auth_headers: StrDict,
531 ) -> None:
532 """POST /issues/{number}/labels replaces the entire label list."""
533 repo_id = await _create_repo(client, auth_headers, "label-assign-repo")
534 issue = await _create_issue(
535 client, auth_headers, repo_id, title="Label test issue", labels=["old-label"]
536 )
537 assert issue["labels"] == ["old-label"]
538
539 response = await client.post(
540 f"/api/repos/{repo_id}/issues/{issue['number']}/labels",
541 json={"labels": ["harmony", "needs-review"]},
542 headers=auth_headers,
543 )
544 assert response.status_code == 200
545 data = response.json()
546 assert data["labels"] == ["harmony", "needs-review"]
547 assert "old-label" not in data["labels"]
548
549
550 async def test_assign_issue_labels_empty_clears_labels(
551 client: AsyncClient,
552 auth_headers: StrDict,
553 ) -> None:
554 """POST /issues/{number}/labels with empty list clears all labels."""
555 repo_id = await _create_repo(client, auth_headers, "label-clear-repo")
556 issue = await _create_issue(
557 client, auth_headers, repo_id, title="Labelled issue", labels=["bug", "musical"]
558 )
559
560 response = await client.post(
561 f"/api/repos/{repo_id}/issues/{issue['number']}/labels",
562 json={"labels": []},
563 headers=auth_headers,
564 )
565 assert response.status_code == 200
566 assert response.json()["labels"] == []
567
568
569 async def test_assign_issue_labels_not_found(
570 client: AsyncClient,
571 auth_headers: StrDict,
572 ) -> None:
573 """POST /issues/999/labels returns 404 for an unknown issue."""
574 repo_id = await _create_repo(client, auth_headers, "label-assign-404-repo")
575
576 response = await client.post(
577 f"/api/repos/{repo_id}/issues/999/labels",
578 json={"labels": ["bug"]},
579 headers=auth_headers,
580 )
581 assert response.status_code == 404
582
583
584 async def test_remove_issue_label_removes_single_label(
585 client: AsyncClient,
586 auth_headers: StrDict,
587 ) -> None:
588 """DELETE /issues/{number}/labels/{name} removes one label and leaves the rest."""
589 repo_id = await _create_repo(client, auth_headers, "label-remove-repo")
590 issue = await _create_issue(
591 client,
592 auth_headers,
593 repo_id,
594 title="Multi-label issue",
595 labels=["bug", "harmony", "needs-review"],
596 )
597
598 response = await client.delete(
599 f"/api/repos/{repo_id}/issues/{issue['number']}/labels/harmony",
600 headers=auth_headers,
601 )
602 assert response.status_code == 200
603 remaining = response.json()["labels"]
604 assert "harmony" not in remaining
605 assert "bug" in remaining
606 assert "needs-review" in remaining
607
608
609 async def test_remove_issue_label_idempotent(
610 client: AsyncClient,
611 auth_headers: StrDict,
612 ) -> None:
613 """DELETE /labels/{name} silently succeeds when the label is not present."""
614 repo_id = await _create_repo(client, auth_headers, "label-remove-idempotent-repo")
615 issue = await _create_issue(
616 client, auth_headers, repo_id, title="No such label issue", labels=["bug"]
617 )
618
619 response = await client.delete(
620 f"/api/repos/{repo_id}/issues/{issue['number']}/labels/nonexistent",
621 headers=auth_headers,
622 )
623 assert response.status_code == 200
624 assert response.json()["labels"] == ["bug"]
625
626
627 async def test_remove_issue_label_not_found(
628 client: AsyncClient,
629 auth_headers: StrDict,
630 ) -> None:
631 """DELETE /issues/999/labels/{name} returns 404 for an unknown issue."""
632 repo_id = await _create_repo(client, auth_headers, "label-remove-404-repo")
633
634 response = await client.delete(
635 f"/api/repos/{repo_id}/issues/999/labels/bug",
636 headers=auth_headers,
637 )
638 assert response.status_code == 404
639
640
641 async def test_new_endpoints_require_auth(client: AsyncClient) -> None:
642 """POST /labels and DELETE /labels/{name} all require authentication."""
643 endpoints: list[tuple[str, str, JSONObject]] = [
644 ("POST", "/api/repos/some-repo/issues/1/labels", {"labels": ["bug"]}),
645 ("DELETE", "/api/repos/some-repo/issues/1/labels/bug", {}),
646 ]
647 for method, url, payload in endpoints:
648 if method == "DELETE":
649 response = await client.delete(url)
650 else:
651 response = await client.post(url, json=payload)
652 assert response.status_code == 401, f"{method} {url} should require auth"
653
654
655 # ---------------------------------------------------------------------------
656 # Idempotency: close_issue and reopen_issue service functions
657 # ---------------------------------------------------------------------------
658
659
660 async def test_close_already_closed_issue_is_idempotent(
661 client: AsyncClient,
662 auth_headers: StrDict,
663 db_session: AsyncSession,
664 ) -> None:
665 """Closing an already-closed issue must not emit a second 'closed' event.
666
667 The service guard introduced to fix duplicate timeline entries should
668 detect that state == 'closed' and return early without writing a new
669 MusehubIssueEvent row.
670 """
671 from musehub.db import musehub_models as db
672 from sqlalchemy import select, func as sa_func
673
674 repo_id = await _create_repo(client, auth_headers, "idempotent-close-repo")
675 issue = await _create_issue(client, auth_headers, repo_id, title="Already closed")
676
677 # First close — should emit one 'closed' event.
678 resp1 = await client.post(
679 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
680 headers=auth_headers,
681 )
682 assert resp1.status_code == 200
683 assert resp1.json()["state"] == "closed"
684
685 count_after_first = (
686 await db_session.execute(
687 select(sa_func.count()).where(
688 db.MusehubIssueEvent.event_type == "closed"
689 )
690 )
691 ).scalar_one()
692
693 # Second close — must not emit another event.
694 resp2 = await client.post(
695 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
696 headers=auth_headers,
697 )
698 assert resp2.status_code == 200
699 assert resp2.json()["state"] == "closed"
700
701 count_after_second = (
702 await db_session.execute(
703 select(sa_func.count()).where(
704 db.MusehubIssueEvent.event_type == "closed"
705 )
706 )
707 ).scalar_one()
708
709 assert count_after_second == count_after_first, (
710 "Closing an already-closed issue must not emit a second 'closed' event"
711 )
712
713
714 async def test_reopen_already_open_issue_is_idempotent(
715 client: AsyncClient,
716 auth_headers: StrDict,
717 db_session: AsyncSession,
718 ) -> None:
719 """Reopening an already-open issue must not emit a second 'reopened' event."""
720 from musehub.db import musehub_models as db
721 from sqlalchemy import select, func as sa_func
722
723 repo_id = await _create_repo(client, auth_headers, "idempotent-reopen-repo")
724 issue = await _create_issue(client, auth_headers, repo_id, title="Already open")
725
726 # Close then reopen once to establish a baseline reopened event count.
727 await client.post(
728 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
729 headers=auth_headers,
730 )
731 resp1 = await client.post(
732 f"/api/repos/{repo_id}/issues/{issue['number']}/reopen",
733 headers=auth_headers,
734 )
735 assert resp1.status_code == 200
736 assert resp1.json()["state"] == "open"
737
738 count_after_first = (
739 await db_session.execute(
740 select(sa_func.count()).where(
741 db.MusehubIssueEvent.event_type == "reopened"
742 )
743 )
744 ).scalar_one()
745
746 # Second reopen — must not emit another event.
747 resp2 = await client.post(
748 f"/api/repos/{repo_id}/issues/{issue['number']}/reopen",
749 headers=auth_headers,
750 )
751 assert resp2.status_code == 200
752 assert resp2.json()["state"] == "open"
753
754 count_after_second = (
755 await db_session.execute(
756 select(sa_func.count()).where(
757 db.MusehubIssueEvent.event_type == "reopened"
758 )
759 )
760 ).scalar_one()
761
762 assert count_after_second == count_after_first, (
763 "Reopening an already-open issue must not emit a second 'reopened' event"
764 )
765
766
767 async def test_close_returns_current_state_without_db_write_when_already_closed(
768 client: AsyncClient,
769 auth_headers: StrDict,
770 db_session: AsyncSession,
771 ) -> None:
772 """Service-level guard: close_issue on an already-closed issue must not
773 insert a second MusehubIssueEvent row.
774
775 Uses the HTTP API to create the repo and issue (avoiding fragile direct ORM
776 construction), then closes via the service directly so we can count events
777 scoped to this repo without cross-test interference.
778 """
779 from musehub.db import musehub_models as db_mod
780 from sqlalchemy import select, func as sa_func
781
782 repo_id = await _create_repo(client, auth_headers, "svc-close-idempotent")
783 issue = await _create_issue(client, auth_headers, repo_id, title="will be closed twice")
784
785 # First close via HTTP (normal path, emits one event).
786 r1 = await client.post(
787 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
788 headers=auth_headers,
789 )
790 assert r1.status_code == 200
791
792 event_count_before = (
793 await db_session.execute(
794 select(sa_func.count()).where(
795 db_mod.MusehubIssueEvent.repo_id == repo_id,
796 db_mod.MusehubIssueEvent.event_type == "closed",
797 )
798 )
799 ).scalar_one()
800
801 # Second close directly via service — must be a no-op on the event table.
802 result = await musehub_issues.close_issue(db_session, repo_id, issue["number"])
803
804 event_count_after = (
805 await db_session.execute(
806 select(sa_func.count()).where(
807 db_mod.MusehubIssueEvent.repo_id == repo_id,
808 db_mod.MusehubIssueEvent.event_type == "closed",
809 )
810 )
811 ).scalar_one()
812
813 assert result is not None
814 assert result.state == "closed"
815 assert event_count_after == event_count_before, (
816 "close_issue on an already-closed issue must not insert a new event"
817 )
818
819
820 async def test_reopen_returns_current_state_without_db_write_when_already_open(
821 client: AsyncClient,
822 auth_headers: StrDict,
823 db_session: AsyncSession,
824 ) -> None:
825 """Service-level guard: reopen_issue on an already-open issue must not
826 insert a second MusehubIssueEvent row."""
827 from musehub.db import musehub_models as db_mod
828 from sqlalchemy import select, func as sa_func
829
830 repo_id = await _create_repo(client, auth_headers, "svc-reopen-idempotent")
831 issue = await _create_issue(client, auth_headers, repo_id, title="will be reopened twice")
832
833 # Close then reopen via HTTP to establish one 'reopened' event.
834 await client.post(
835 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
836 headers=auth_headers,
837 )
838 r1 = await client.post(
839 f"/api/repos/{repo_id}/issues/{issue['number']}/reopen",
840 headers=auth_headers,
841 )
842 assert r1.status_code == 200
843
844 event_count_before = (
845 await db_session.execute(
846 select(sa_func.count()).where(
847 db_mod.MusehubIssueEvent.repo_id == repo_id,
848 db_mod.MusehubIssueEvent.event_type == "reopened",
849 )
850 )
851 ).scalar_one()
852
853 # Second reopen directly via service — must be a no-op on the event table.
854 result = await musehub_issues.reopen_issue(db_session, repo_id, issue["number"])
855
856 event_count_after = (
857 await db_session.execute(
858 select(sa_func.count()).where(
859 db_mod.MusehubIssueEvent.repo_id == repo_id,
860 db_mod.MusehubIssueEvent.event_type == "reopened",
861 )
862 )
863 ).scalar_one()
864
865 assert result is not None
866 assert result.state == "open"
867 assert event_count_after == event_count_before, (
868 "reopen_issue on an already-open issue must not insert a new event"
869 )
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 155 days ago