gabriel / musehub public
test_musehub_labels.py python
654 lines 22.8 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 157 days ago
1 """Tests for MuseHub label management endpoints.
2
3 Covers all acceptance criteria:
4 - GET /repos/{repo_id}/labels — list labels (public)
5 - POST /repos/{repo_id}/labels — create label (auth required)
6 - PATCH /repos/{repo_id}/labels/{label_id} — update label (auth required)
7 - DELETE /repos/{repo_id}/labels/{label_id} — delete label (auth required)
8 - POST .../issues/{number}/labels — assign labels to issue (auth required)
9 - DELETE .../issues/{number}/labels/{label_id} — remove label from issue (auth required)
10 - POST .../proposals/{proposal_id}/labels — assign labels to proposal (auth required)
11 - DELETE .../proposals/{proposal_id}/labels/{label_id} — remove label from proposal (auth required)
12
13 All tests use the shared ``client``, ``auth_headers``, and ``db_session``
14 fixtures from conftest.py.
15 """
16 from __future__ import annotations
17
18 import uuid
19 from datetime import datetime, timezone
20
21 import pytest
22 from httpx import AsyncClient
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from musehub.db.musehub_models import MusehubBranch, MusehubCommit
26 from musehub.types.json_types import JSONObject, StrDict
27
28
29 # ---------------------------------------------------------------------------
30 # Helpers
31 # ---------------------------------------------------------------------------
32
33
34 async def _create_repo(client: AsyncClient, auth_headers: StrDict, name: str = "label-test-repo") -> str:
35 """Create a repo and return its repo_id."""
36 response = await client.post(
37 "/api/repos",
38 json={"name": name, "owner": "testuser", "initialize": False},
39 headers=auth_headers,
40 )
41 assert response.status_code == 201
42 repo_id: str = response.json()["repoId"]
43 return repo_id
44
45
46 async def _create_label(
47 client: AsyncClient,
48 auth_headers: StrDict,
49 repo_id: str,
50 name: str = "test-label",
51 color: str = "#112233",
52 description: str | None = "A test label",
53 ) -> JSONObject:
54 """Create a label and return the response body."""
55 payload = {"name": name, "color": color}
56 if description is not None:
57 payload["description"] = description
58 response = await client.post(
59 f"/api/repos/{repo_id}/labels",
60 json=payload,
61 headers=auth_headers,
62 )
63 assert response.status_code == 201
64 label: JSONObject = response.json()
65 return label
66
67
68 async def _create_issue(
69 client: AsyncClient,
70 auth_headers: StrDict,
71 repo_id: str,
72 title: str = "Test issue",
73 ) -> JSONObject:
74 """Create an issue and return the response body."""
75 response = await client.post(
76 f"/api/repos/{repo_id}/issues",
77 json={"title": title, "body": "", "labels": []},
78 headers=auth_headers,
79 )
80 assert response.status_code == 201
81 issue: JSONObject = response.json()
82 return issue
83
84
85 async def _push_branch(db: AsyncSession, repo_id: str, branch_name: str) -> str:
86 """Insert a branch with one commit so the branch exists (required before creating a proposal)."""
87 commit_id = uuid.uuid4().hex
88 commit = MusehubCommit(
89 commit_id=commit_id,
90 repo_id=repo_id,
91 branch=branch_name,
92 parent_ids=[],
93 message=f"Initial commit on {branch_name}",
94 author="testuser",
95 timestamp=datetime.now(tz=timezone.utc),
96 )
97 branch = MusehubBranch(
98 repo_id=repo_id,
99 name=branch_name,
100 head_commit_id=commit_id,
101 )
102 db.add(commit)
103 db.add(branch)
104 await db.commit()
105 return commit_id
106
107
108 async def _create_proposal(
109 client: AsyncClient,
110 auth_headers: StrDict,
111 repo_id: str,
112 title: str = "Test Proposal",
113 ) -> JSONObject:
114 """Create a proposal and return the response body."""
115 response = await client.post(
116 f"/api/repos/{repo_id}/proposals",
117 json={"title": title, "body": "", "fromBranch": "feature", "toBranch": "main"},
118 headers=auth_headers,
119 )
120 assert response.status_code == 201, response.text
121 proposal: JSONObject = response.json()
122 return proposal
123
124
125 # ---------------------------------------------------------------------------
126 # POST /repos/{repo_id}/labels
127 # ---------------------------------------------------------------------------
128
129
130 async def test_create_label_returns_201(
131 client: AsyncClient,
132 auth_headers: StrDict,
133 ) -> None:
134 """POST /labels creates a label and returns 201 with the label data."""
135 repo_id = await _create_repo(client, auth_headers, "create-label-repo")
136 label = await _create_label(client, auth_headers, repo_id)
137
138 assert label["name"] == "test-label"
139 assert label["color"] == "#112233"
140 assert label["description"] == "A test label"
141 assert "labelId" in label or "label_id" in label
142 assert label.get("repoId") == repo_id or label.get("repo_id") == repo_id
143
144
145 async def test_create_label_requires_auth(
146 client: AsyncClient,
147 ) -> None:
148 """POST /labels without auth returns 401."""
149 response = await client.post(
150 "/api/repos/nonexistent/labels",
151 json={"name": "bug", "color": "#d73a4a"},
152 )
153 assert response.status_code == 401
154
155
156 async def test_create_label_unknown_repo_returns_404(
157 client: AsyncClient,
158 auth_headers: StrDict,
159 ) -> None:
160 """POST /labels for a non-existent repo returns 404."""
161 response = await client.post(
162 "/api/repos/does-not-exist/labels",
163 json={"name": "bug", "color": "#d73a4a"},
164 headers=auth_headers,
165 )
166 assert response.status_code == 404
167
168
169 async def test_create_label_duplicate_name_returns_409(
170 client: AsyncClient,
171 auth_headers: StrDict,
172 ) -> None:
173 """POST /labels with a duplicate name returns 409 Conflict."""
174 repo_id = await _create_repo(client, auth_headers, "dupe-label-repo")
175 # "bug" is seeded by default on repo creation — creating it again yields 409.
176 response = await client.post(
177 f"/api/repos/{repo_id}/labels",
178 json={"name": "bug", "color": "#aabbcc"},
179 headers=auth_headers,
180 )
181 assert response.status_code == 409
182
183
184 async def test_create_label_invalid_color_returns_422(
185 client: AsyncClient,
186 auth_headers: StrDict,
187 ) -> None:
188 """POST /labels with an invalid colour format returns 422."""
189 repo_id = await _create_repo(client, auth_headers, "color-invalid-repo")
190 response = await client.post(
191 f"/api/repos/{repo_id}/labels",
192 json={"name": "bug", "color": "red"},
193 headers=auth_headers,
194 )
195 assert response.status_code == 422
196
197
198 # ---------------------------------------------------------------------------
199 # GET /repos/{repo_id}/labels
200 # ---------------------------------------------------------------------------
201
202
203 async def test_list_labels_public_access(
204 client: AsyncClient,
205 auth_headers: StrDict,
206 ) -> None:
207 """GET /labels is publicly accessible and returns all repo labels."""
208 repo_id = await _create_repo(client, auth_headers, "list-labels-repo")
209 # Seeded defaults already include "bug" and "enhancement"; just add one extra.
210 await _create_label(client, auth_headers, repo_id, name="custom-label", color="#123456")
211
212 # No auth headers — public endpoint.
213 response = await client.get(f"/api/repos/{repo_id}/labels")
214 assert response.status_code == 200
215 body = response.json()
216 assert "items" in body
217 assert body["total"] > 0
218 names = [item["name"] for item in body["items"]]
219 # Default-seeded labels must be present.
220 assert "bug" in names
221 assert "enhancement" in names
222 # The extra label we created must also be there.
223 assert "custom-label" in names
224
225
226 async def test_list_labels_unknown_repo_returns_404(
227 client: AsyncClient,
228 ) -> None:
229 """GET /labels for a non-existent repo returns 404."""
230 response = await client.get("/api/repos/no-such-repo/labels")
231 assert response.status_code == 404
232
233
234 async def test_list_labels_empty_repo(
235 client: AsyncClient,
236 auth_headers: StrDict,
237 ) -> None:
238 """GET /labels for a new repo returns the seeded default labels."""
239 repo_id = await _create_repo(client, auth_headers, "empty-labels-repo")
240 response = await client.get(f"/api/repos/{repo_id}/labels")
241 assert response.status_code == 200
242 body = response.json()
243 # Repos are seeded with default labels on creation — the list is never truly empty.
244 assert isinstance(body["items"], list)
245 assert body["total"] > 0
246 names = {lbl["name"] for lbl in body["items"]}
247 assert "bug" in names
248
249
250 # ---------------------------------------------------------------------------
251 # PATCH /repos/{repo_id}/labels/{label_id}
252 # ---------------------------------------------------------------------------
253
254
255 async def test_update_label_name(
256 client: AsyncClient,
257 auth_headers: StrDict,
258 ) -> None:
259 """PATCH /labels/{id} updates the label name."""
260 repo_id = await _create_repo(client, auth_headers, "update-label-repo")
261 label = await _create_label(client, auth_headers, repo_id, name="old-name", color="#aabbcc")
262 label_id = label.get("label_id") or label.get("labelId")
263
264 response = await client.patch(
265 f"/api/repos/{repo_id}/labels/{label_id}",
266 json={"name": "new-name"},
267 headers=auth_headers,
268 )
269 assert response.status_code == 200
270 assert response.json()["name"] == "new-name"
271 assert response.json()["color"] == "#aabbcc"
272
273
274 async def test_update_label_requires_auth(
275 client: AsyncClient,
276 auth_headers: StrDict,
277 ) -> None:
278 """PATCH /labels/{id} without auth returns 401."""
279 from musehub.auth.request_signing import optional_signed_request, require_signed_request
280 from musehub.main import app as _app
281
282 repo_id = await _create_repo(client, auth_headers, "update-auth-label-repo")
283 label = await _create_label(client, auth_headers, repo_id)
284 label_id = label.get("label_id") or label.get("labelId")
285
286 _app.dependency_overrides.pop(require_signed_request, None)
287 _app.dependency_overrides.pop(optional_signed_request, None)
288 response = await client.patch(
289 f"/api/repos/{repo_id}/labels/{label_id}",
290 json={"name": "hacked"},
291 )
292 assert response.status_code == 401
293
294
295 async def test_update_label_not_found_returns_404(
296 client: AsyncClient,
297 auth_headers: StrDict,
298 ) -> None:
299 """PATCH /labels/{id} with an unknown label_id returns 404."""
300 repo_id = await _create_repo(client, auth_headers, "update-404-repo")
301 response = await client.patch(
302 f"/api/repos/{repo_id}/labels/00000000-0000-0000-0000-000000000000",
303 json={"name": "ghost"},
304 headers=auth_headers,
305 )
306 assert response.status_code == 404
307
308
309 # ---------------------------------------------------------------------------
310 # DELETE /repos/{repo_id}/labels/{label_id}
311 # ---------------------------------------------------------------------------
312
313
314 async def test_delete_label_returns_204(
315 client: AsyncClient,
316 auth_headers: StrDict,
317 ) -> None:
318 """DELETE /labels/{id} removes the label and returns 204."""
319 repo_id = await _create_repo(client, auth_headers, "delete-label-repo")
320 label = await _create_label(client, auth_headers, repo_id)
321 label_id = label.get("label_id") or label.get("labelId")
322
323 response = await client.delete(
324 f"/api/repos/{repo_id}/labels/{label_id}",
325 headers=auth_headers,
326 )
327 assert response.status_code == 204
328
329 # Confirm the specific label is gone (seeded defaults remain).
330 list_resp = await client.get(f"/api/repos/{repo_id}/labels")
331 remaining_ids = {lbl.get("label_id") or lbl.get("labelId") for lbl in list_resp.json()["items"]}
332 assert label_id not in remaining_ids
333
334
335 async def test_delete_label_requires_auth(
336 client: AsyncClient,
337 auth_headers: StrDict,
338 ) -> None:
339 """DELETE /labels/{id} without auth returns 401."""
340 from musehub.auth.request_signing import optional_signed_request, require_signed_request
341 from musehub.main import app as _app
342
343 repo_id = await _create_repo(client, auth_headers, "delete-auth-repo")
344 label = await _create_label(client, auth_headers, repo_id)
345 label_id = label.get("label_id") or label.get("labelId")
346
347 _app.dependency_overrides.pop(require_signed_request, None)
348 _app.dependency_overrides.pop(optional_signed_request, None)
349 response = await client.delete(
350 f"/api/repos/{repo_id}/labels/{label_id}",
351 )
352 assert response.status_code == 401
353
354
355 # ---------------------------------------------------------------------------
356 # Issue label assignments
357 # ---------------------------------------------------------------------------
358
359
360 async def test_assign_labels_to_issue(
361 client: AsyncClient,
362 auth_headers: StrDict,
363 ) -> None:
364 """POST .../issues/{number}/labels assigns labels and returns the updated issue."""
365 repo_id = await _create_repo(client, auth_headers, "issue-label-assign-repo")
366 # "bug" is seeded by default — no need to create it separately.
367 issue = await _create_issue(client, auth_headers, repo_id)
368 issue_number = issue["number"]
369
370 response = await client.post(
371 f"/api/repos/{repo_id}/issues/{issue_number}/labels",
372 json={"labels": ["bug"]},
373 headers=auth_headers,
374 )
375 assert response.status_code == 200
376 updated_issue = response.json()
377 assert "bug" in updated_issue.get("labels", [])
378
379
380 async def test_assign_labels_to_issue_idempotent(
381 client: AsyncClient,
382 auth_headers: StrDict,
383 ) -> None:
384 """Assigning the same label twice does not raise an error."""
385 repo_id = await _create_repo(client, auth_headers, "issue-label-idem-repo")
386 # "bug" is seeded by default — no need to create it separately.
387 issue = await _create_issue(client, auth_headers, repo_id)
388 issue_number = issue["number"]
389
390 for _ in range(2):
391 response = await client.post(
392 f"/api/repos/{repo_id}/issues/{issue_number}/labels",
393 json={"labels": ["bug"]},
394 headers=auth_headers,
395 )
396 assert response.status_code == 200
397
398
399 async def test_remove_label_from_issue(
400 client: AsyncClient,
401 auth_headers: StrDict,
402 ) -> None:
403 """DELETE .../issues/{number}/labels/{label_name} removes the association."""
404 repo_id = await _create_repo(client, auth_headers, "issue-label-remove-repo")
405 # "bug" is seeded by default — no need to create it separately.
406 issue = await _create_issue(client, auth_headers, repo_id)
407 issue_number = issue["number"]
408
409 # Assign first.
410 await client.post(
411 f"/api/repos/{repo_id}/issues/{issue_number}/labels",
412 json={"labels": ["bug"]},
413 headers=auth_headers,
414 )
415
416 # Then remove (by label name, returns updated issue with 200).
417 response = await client.delete(
418 f"/api/repos/{repo_id}/issues/{issue_number}/labels/bug",
419 headers=auth_headers,
420 )
421 assert response.status_code == 200
422 assert "bug" not in response.json().get("labels", [])
423
424
425 async def test_remove_label_from_issue_unknown_issue_returns_404(
426 client: AsyncClient,
427 auth_headers: StrDict,
428 ) -> None:
429 """DELETE .../issues/{number}/labels/{label_id} for an unknown issue returns 404."""
430 repo_id = await _create_repo(client, auth_headers, "issue-label-404-repo")
431 label = await _create_label(client, auth_headers, repo_id)
432 label_id = label.get("label_id") or label.get("labelId")
433
434 response = await client.delete(
435 f"/api/repos/{repo_id}/issues/9999/labels/{label_id}",
436 headers=auth_headers,
437 )
438 assert response.status_code == 404
439
440
441 # ---------------------------------------------------------------------------
442 # Proposal label assignments
443 # ---------------------------------------------------------------------------
444
445
446 async def test_assign_labels_to_proposal(
447 client: AsyncClient,
448 auth_headers: StrDict,
449 db_session: AsyncSession,
450 ) -> None:
451 """POST .../proposals/{proposal_id}/labels assigns labels and returns them."""
452 repo_id = await _create_repo(client, auth_headers, "proposal-label-assign-repo")
453 await _push_branch(db_session, repo_id, "main")
454 await _push_branch(db_session, repo_id, "feature")
455 # "enhancement" is seeded by default — look it up from the repo's label list.
456 labels_resp = await client.get(f"/api/repos/{repo_id}/labels")
457 enhancement = next(lbl for lbl in labels_resp.json()["items"] if lbl["name"] == "enhancement")
458 label_id = enhancement.get("label_id") or enhancement.get("labelId")
459 proposal = await _create_proposal(client, auth_headers, repo_id)
460 proposal_id = proposal.get("proposalId") or proposal.get("proposal_id")
461
462 response = await client.post(
463 f"/api/repos/{repo_id}/proposals/{proposal_id}/labels",
464 json={"label_ids": [label_id]},
465 headers=auth_headers,
466 )
467 assert response.status_code == 200
468 assigned = response.json()
469 assert len(assigned) == 1
470 assert assigned[0]["name"] == "enhancement"
471
472
473 async def test_remove_label_from_proposal(
474 client: AsyncClient,
475 auth_headers: StrDict,
476 db_session: AsyncSession,
477 ) -> None:
478 """DELETE .../proposals/{proposal_id}/labels/{label_id} removes the association."""
479 repo_id = await _create_repo(client, auth_headers, "proposal-label-remove-repo")
480 await _push_branch(db_session, repo_id, "main")
481 await _push_branch(db_session, repo_id, "feature")
482 label = await _create_label(client, auth_headers, repo_id)
483 label_id = label.get("label_id") or label.get("labelId")
484 proposal = await _create_proposal(client, auth_headers, repo_id)
485 proposal_id = proposal.get("proposalId") or proposal.get("proposal_id")
486
487 # Assign first.
488 await client.post(
489 f"/api/repos/{repo_id}/proposals/{proposal_id}/labels",
490 json={"label_ids": [label_id]},
491 headers=auth_headers,
492 )
493
494 # Then remove — should be idempotent too.
495 response = await client.delete(
496 f"/api/repos/{repo_id}/proposals/{proposal_id}/labels/{label_id}",
497 headers=auth_headers,
498 )
499 assert response.status_code == 204
500
501
502 async def test_remove_label_from_proposal_unknown_returns_404(
503 client: AsyncClient,
504 auth_headers: StrDict,
505 ) -> None:
506 """DELETE .../proposals/{proposal_id}/labels/{label_id} for an unknown proposal returns 404."""
507 repo_id = await _create_repo(client, auth_headers, "proposal-label-404-repo")
508 label = await _create_label(client, auth_headers, repo_id)
509 label_id = label.get("label_id") or label.get("labelId")
510
511 response = await client.delete(
512 f"/api/repos/{repo_id}/proposals/00000000-0000-0000-0000-000000000000/labels/{label_id}",
513 headers=auth_headers,
514 )
515 assert response.status_code == 404
516
517
518 async def test_delete_label_cascades_to_issue_associations(
519 client: AsyncClient,
520 auth_headers: StrDict,
521 ) -> None:
522 """Deleting a label removes it from all issue associations (cascade)."""
523 repo_id = await _create_repo(client, auth_headers, "cascade-delete-repo")
524 label = await _create_label(client, auth_headers, repo_id)
525 label_id = label.get("label_id") or label.get("labelId")
526 issue = await _create_issue(client, auth_headers, repo_id)
527 issue_number = issue["number"]
528
529 await client.post(
530 f"/api/repos/{repo_id}/issues/{issue_number}/labels",
531 json={"label_ids": [label_id]},
532 headers=auth_headers,
533 )
534
535 delete_resp = await client.delete(
536 f"/api/repos/{repo_id}/labels/{label_id}",
537 headers=auth_headers,
538 )
539 assert delete_resp.status_code == 204
540
541 # The deleted label must not appear in the repo's label list (seeded defaults remain).
542 list_resp = await client.get(f"/api/repos/{repo_id}/labels")
543 remaining_ids = {lbl.get("label_id") or lbl.get("labelId") for lbl in list_resp.json()["items"]}
544 assert label_id not in remaining_ids
545
546
547 # ── Seed default labels ──────────────────────────────────────────────────────
548
549
550 async def test_create_repo_seeds_default_labels(
551 client: AsyncClient,
552 auth_headers: StrDict,
553 ) -> None:
554 """Creating a repo must automatically seed the default label set."""
555 repo_resp = await client.post(
556 "/api/repos",
557 json={"name": "seed-test-repo", "owner": "testuser", "initialize": False},
558 headers=auth_headers,
559 )
560 assert repo_resp.status_code == 201
561 repo_id: str = repo_resp.json()["repoId"]
562
563 label_resp = await client.get(f"/api/repos/{repo_id}/labels")
564 assert label_resp.status_code == 200
565 data = label_resp.json()
566 assert data["total"] > 0
567 names = {lbl["name"] for lbl in data["items"]}
568 # Standard VCS labels expected.
569 assert "bug" in names
570 assert "enhancement" in names
571 assert "documentation" in names
572 # Music-domain labels must NOT be present.
573 assert "needs-arrangement" not in names
574 assert "musical-theory" not in names
575
576
577 async def test_create_label_forbidden_for_non_owner(
578 client: AsyncClient,
579 auth_headers: StrDict,
580 db_session: AsyncSession,
581 ) -> None:
582 """POST /labels as a non-owner returns 403."""
583 from musehub.db.musehub_models import MusehubRepo
584
585 # Create a repo owned by someone other than "testuser".
586 other_repo = MusehubRepo(
587 name="other-owner-repo",
588 owner="other-owner",
589 slug="other-owner-repo",
590 visibility="public",
591 owner_user_id="uid-other",
592 )
593 db_session.add(other_repo)
594 await db_session.commit()
595
596 response = await client.post(
597 f"/api/repos/{other_repo.repo_id}/labels",
598 json={"name": "bug", "color": "#d73a4a"},
599 headers=auth_headers,
600 )
601 assert response.status_code == 403
602
603
604 async def test_delete_label_forbidden_for_non_owner(
605 client: AsyncClient,
606 auth_headers: StrDict,
607 db_session: AsyncSession,
608 ) -> None:
609 """DELETE /labels/{id} as a non-owner returns 403."""
610 from musehub.db.musehub_models import MusehubRepo
611
612 other_repo = MusehubRepo(
613 name="other-owner-repo-del",
614 owner="other-owner",
615 slug="other-owner-repo-del",
616 visibility="public",
617 owner_user_id="uid-other",
618 )
619 db_session.add(other_repo)
620 await db_session.commit()
621
622 response = await client.delete(
623 f"/api/repos/{other_repo.repo_id}/labels/00000000-0000-0000-0000-000000000000",
624 headers=auth_headers,
625 )
626 assert response.status_code == 403
627
628
629 async def test_seed_default_labels_is_idempotent(
630 client: AsyncClient,
631 auth_headers: StrDict,
632 ) -> None:
633 """seed_default_labels must not create duplicates when called twice."""
634 from musehub.db.database import AsyncSessionLocal
635 from musehub.api.routes.musehub.labels import seed_default_labels
636
637 repo_resp = await client.post(
638 "/api/repos",
639 json={"name": "idempotent-seed-repo", "owner": "testuser", "initialize": False},
640 headers=auth_headers,
641 )
642 assert repo_resp.status_code == 201
643 repo_id: str = repo_resp.json()["repoId"]
644
645 # Call seed a second time — should not raise and should not add duplicates.
646 async with AsyncSessionLocal() as session:
647 await seed_default_labels(session, repo_id)
648 await session.commit()
649
650 label_resp = await client.get(f"/api/repos/{repo_id}/labels")
651 data = label_resp.json()
652 names = [lbl["name"] for lbl in data["items"]]
653 # No duplicate names.
654 assert len(names) == len(set(names))
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 157 days ago