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