gabriel / musehub public
test_musehub_labels.py python
548 lines 18.9 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for Muse Hub label management endpoints.
2
3 Covers all acceptance criteria:
4 - GET /musehub/repos/{repo_id}/labels — list labels (public)
5 - POST /musehub/repos/{repo_id}/labels — create label (auth required)
6 - PATCH /musehub/repos/{repo_id}/labels/{label_id} — update label (auth required)
7 - DELETE /musehub/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 .../pull-requests/{pr_id}/labels — assign labels to PR (auth required)
11 - DELETE .../pull-requests/{pr_id}/labels/{label_id} — remove label from PR (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
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32
33 async def _create_repo(client: AsyncClient, auth_headers: dict[str, str], name: str = "label-test-repo") -> str:
34 """Create a repo and return its repo_id."""
35 response = await client.post(
36 "/api/v1/musehub/repos",
37 json={"name": name, "owner": "testuser"},
38 headers=auth_headers,
39 )
40 assert response.status_code == 201
41 repo_id: str = response.json()["repoId"]
42 return repo_id
43
44
45 async def _create_label(
46 client: AsyncClient,
47 auth_headers: dict[str, str],
48 repo_id: str,
49 name: str = "bug",
50 color: str = "#d73a4a",
51 description: str | None = "Something isn't working",
52 ) -> dict[str, object]:
53 """Create a label and return the response body."""
54 payload: dict[str, object] = {"name": name, "color": color}
55 if description is not None:
56 payload["description"] = description
57 response = await client.post(
58 f"/api/v1/musehub/repos/{repo_id}/labels",
59 json=payload,
60 headers=auth_headers,
61 )
62 assert response.status_code == 201
63 label: dict[str, object] = response.json()
64 return label
65
66
67 async def _create_issue(
68 client: AsyncClient,
69 auth_headers: dict[str, str],
70 repo_id: str,
71 title: str = "Test issue",
72 ) -> dict[str, object]:
73 """Create an issue and return the response body."""
74 response = await client.post(
75 f"/api/v1/musehub/repos/{repo_id}/issues",
76 json={"title": title, "body": "", "labels": []},
77 headers=auth_headers,
78 )
79 assert response.status_code == 201
80 issue: dict[str, object] = response.json()
81 return issue
82
83
84 async def _push_branch(db: AsyncSession, repo_id: str, branch_name: str) -> str:
85 """Insert a branch with one commit so the branch exists (required before creating a PR)."""
86 commit_id = uuid.uuid4().hex
87 commit = MusehubCommit(
88 commit_id=commit_id,
89 repo_id=repo_id,
90 branch=branch_name,
91 parent_ids=[],
92 message=f"Initial commit on {branch_name}",
93 author="testuser",
94 timestamp=datetime.now(tz=timezone.utc),
95 )
96 branch = MusehubBranch(
97 repo_id=repo_id,
98 name=branch_name,
99 head_commit_id=commit_id,
100 )
101 db.add(commit)
102 db.add(branch)
103 await db.commit()
104 return commit_id
105
106
107 async def _create_pr(
108 client: AsyncClient,
109 auth_headers: dict[str, str],
110 repo_id: str,
111 title: str = "Test PR",
112 ) -> dict[str, object]:
113 """Create a pull request and return the response body."""
114 response = await client.post(
115 f"/api/v1/musehub/repos/{repo_id}/pull-requests",
116 json={"title": title, "body": "", "fromBranch": "feature", "toBranch": "main"},
117 headers=auth_headers,
118 )
119 assert response.status_code == 201, response.text
120 pr: dict[str, object] = response.json()
121 return pr
122
123
124 # ---------------------------------------------------------------------------
125 # POST /musehub/repos/{repo_id}/labels
126 # ---------------------------------------------------------------------------
127
128
129 @pytest.mark.anyio
130 async def test_create_label_returns_201(
131 client: AsyncClient,
132 auth_headers: dict[str, str],
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"] == "bug"
139 assert label["color"] == "#d73a4a"
140 assert label["description"] == "Something isn't working"
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 @pytest.mark.anyio
146 async def test_create_label_requires_auth(
147 client: AsyncClient,
148 ) -> None:
149 """POST /labels without auth returns 401."""
150 response = await client.post(
151 "/api/v1/musehub/repos/nonexistent/labels",
152 json={"name": "bug", "color": "#d73a4a"},
153 )
154 assert response.status_code == 401
155
156
157 @pytest.mark.anyio
158 async def test_create_label_unknown_repo_returns_404(
159 client: AsyncClient,
160 auth_headers: dict[str, str],
161 ) -> None:
162 """POST /labels for a non-existent repo returns 404."""
163 response = await client.post(
164 "/api/v1/musehub/repos/does-not-exist/labels",
165 json={"name": "bug", "color": "#d73a4a"},
166 headers=auth_headers,
167 )
168 assert response.status_code == 404
169
170
171 @pytest.mark.anyio
172 async def test_create_label_duplicate_name_returns_409(
173 client: AsyncClient,
174 auth_headers: dict[str, str],
175 ) -> None:
176 """POST /labels with a duplicate name returns 409 Conflict."""
177 repo_id = await _create_repo(client, auth_headers, "dupe-label-repo")
178 await _create_label(client, auth_headers, repo_id, name="bug")
179
180 response = await client.post(
181 f"/api/v1/musehub/repos/{repo_id}/labels",
182 json={"name": "bug", "color": "#aabbcc"},
183 headers=auth_headers,
184 )
185 assert response.status_code == 409
186
187
188 @pytest.mark.anyio
189 async def test_create_label_invalid_color_returns_422(
190 client: AsyncClient,
191 auth_headers: dict[str, str],
192 ) -> None:
193 """POST /labels with an invalid colour format returns 422."""
194 repo_id = await _create_repo(client, auth_headers, "color-invalid-repo")
195 response = await client.post(
196 f"/api/v1/musehub/repos/{repo_id}/labels",
197 json={"name": "bug", "color": "red"},
198 headers=auth_headers,
199 )
200 assert response.status_code == 422
201
202
203 # ---------------------------------------------------------------------------
204 # GET /musehub/repos/{repo_id}/labels
205 # ---------------------------------------------------------------------------
206
207
208 @pytest.mark.anyio
209 async def test_list_labels_public_access(
210 client: AsyncClient,
211 auth_headers: dict[str, str],
212 ) -> None:
213 """GET /labels is publicly accessible and returns all repo labels."""
214 repo_id = await _create_repo(client, auth_headers, "list-labels-repo")
215 await _create_label(client, auth_headers, repo_id, name="bug", color="#d73a4a")
216 await _create_label(client, auth_headers, repo_id, name="enhancement", color="#a2eeef")
217
218 # No auth headers — public endpoint.
219 response = await client.get(f"/api/v1/musehub/repos/{repo_id}/labels")
220 assert response.status_code == 200
221 body = response.json()
222 assert "items" in body
223 assert body["total"] == 2
224 names = [item["name"] for item in body["items"]]
225 assert "bug" in names
226 assert "enhancement" in names
227
228
229 @pytest.mark.anyio
230 async def test_list_labels_unknown_repo_returns_404(
231 client: AsyncClient,
232 ) -> None:
233 """GET /labels for a non-existent repo returns 404."""
234 response = await client.get("/api/v1/musehub/repos/no-such-repo/labels")
235 assert response.status_code == 404
236
237
238 @pytest.mark.anyio
239 async def test_list_labels_empty_repo(
240 client: AsyncClient,
241 auth_headers: dict[str, str],
242 ) -> None:
243 """GET /labels for a repo with no labels returns an empty list."""
244 repo_id = await _create_repo(client, auth_headers, "empty-labels-repo")
245 response = await client.get(f"/api/v1/musehub/repos/{repo_id}/labels")
246 assert response.status_code == 200
247 body = response.json()
248 assert body["items"] == []
249 assert body["total"] == 0
250
251
252 # ---------------------------------------------------------------------------
253 # PATCH /musehub/repos/{repo_id}/labels/{label_id}
254 # ---------------------------------------------------------------------------
255
256
257 @pytest.mark.anyio
258 async def test_update_label_name(
259 client: AsyncClient,
260 auth_headers: dict[str, str],
261 ) -> None:
262 """PATCH /labels/{id} updates the label name."""
263 repo_id = await _create_repo(client, auth_headers, "update-label-repo")
264 label = await _create_label(client, auth_headers, repo_id, name="old-name", color="#aabbcc")
265 label_id = label.get("label_id") or label.get("labelId")
266
267 response = await client.patch(
268 f"/api/v1/musehub/repos/{repo_id}/labels/{label_id}",
269 json={"name": "new-name"},
270 headers=auth_headers,
271 )
272 assert response.status_code == 200
273 assert response.json()["name"] == "new-name"
274 assert response.json()["color"] == "#aabbcc"
275
276
277 @pytest.mark.anyio
278 async def test_update_label_requires_auth(
279 client: AsyncClient,
280 auth_headers: dict[str, str],
281 ) -> None:
282 """PATCH /labels/{id} without auth returns 401."""
283 repo_id = await _create_repo(client, auth_headers, "update-auth-label-repo")
284 label = await _create_label(client, auth_headers, repo_id)
285 label_id = label.get("label_id") or label.get("labelId")
286
287 response = await client.patch(
288 f"/api/v1/musehub/repos/{repo_id}/labels/{label_id}",
289 json={"name": "hacked"},
290 )
291 assert response.status_code == 401
292
293
294 @pytest.mark.anyio
295 async def test_update_label_not_found_returns_404(
296 client: AsyncClient,
297 auth_headers: dict[str, str],
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/v1/musehub/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 /musehub/repos/{repo_id}/labels/{label_id}
311 # ---------------------------------------------------------------------------
312
313
314 @pytest.mark.anyio
315 async def test_delete_label_returns_204(
316 client: AsyncClient,
317 auth_headers: dict[str, str],
318 ) -> None:
319 """DELETE /labels/{id} removes the label and returns 204."""
320 repo_id = await _create_repo(client, auth_headers, "delete-label-repo")
321 label = await _create_label(client, auth_headers, repo_id)
322 label_id = label.get("label_id") or label.get("labelId")
323
324 response = await client.delete(
325 f"/api/v1/musehub/repos/{repo_id}/labels/{label_id}",
326 headers=auth_headers,
327 )
328 assert response.status_code == 204
329
330 # Confirm the label is gone.
331 list_resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/labels")
332 assert list_resp.json()["total"] == 0
333
334
335 @pytest.mark.anyio
336 async def test_delete_label_requires_auth(
337 client: AsyncClient,
338 auth_headers: dict[str, str],
339 ) -> None:
340 """DELETE /labels/{id} without auth returns 401."""
341 repo_id = await _create_repo(client, auth_headers, "delete-auth-repo")
342 label = await _create_label(client, auth_headers, repo_id)
343 label_id = label.get("label_id") or label.get("labelId")
344
345 response = await client.delete(
346 f"/api/v1/musehub/repos/{repo_id}/labels/{label_id}",
347 )
348 assert response.status_code == 401
349
350
351 # ---------------------------------------------------------------------------
352 # Issue label assignments
353 # ---------------------------------------------------------------------------
354
355
356 @pytest.mark.anyio
357 async def test_assign_labels_to_issue(
358 client: AsyncClient,
359 auth_headers: dict[str, str],
360 ) -> None:
361 """POST .../issues/{number}/labels assigns labels and returns them."""
362 repo_id = await _create_repo(client, auth_headers, "issue-label-assign-repo")
363 label = await _create_label(client, auth_headers, repo_id, name="bug", color="#d73a4a")
364 label_id = label.get("label_id") or label.get("labelId")
365 issue = await _create_issue(client, auth_headers, repo_id)
366 issue_number = issue["number"]
367
368 response = await client.post(
369 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels",
370 json={"label_ids": [label_id]},
371 headers=auth_headers,
372 )
373 assert response.status_code == 200
374 assigned = response.json()
375 assert len(assigned) == 1
376 assert assigned[0]["name"] == "bug"
377
378
379 @pytest.mark.anyio
380 async def test_assign_labels_to_issue_idempotent(
381 client: AsyncClient,
382 auth_headers: dict[str, str],
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 label = await _create_label(client, auth_headers, repo_id)
387 label_id = label.get("label_id") or label.get("labelId")
388 issue = await _create_issue(client, auth_headers, repo_id)
389 issue_number = issue["number"]
390
391 for _ in range(2):
392 response = await client.post(
393 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels",
394 json={"label_ids": [label_id]},
395 headers=auth_headers,
396 )
397 assert response.status_code == 200
398
399
400 @pytest.mark.anyio
401 async def test_remove_label_from_issue(
402 client: AsyncClient,
403 auth_headers: dict[str, str],
404 ) -> None:
405 """DELETE .../issues/{number}/labels/{label_id} removes the association."""
406 repo_id = await _create_repo(client, auth_headers, "issue-label-remove-repo")
407 label = await _create_label(client, auth_headers, repo_id)
408 label_id = label.get("label_id") or label.get("labelId")
409 issue = await _create_issue(client, auth_headers, repo_id)
410 issue_number = issue["number"]
411
412 # Assign first.
413 await client.post(
414 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels",
415 json={"label_ids": [label_id]},
416 headers=auth_headers,
417 )
418
419 # Then remove.
420 response = await client.delete(
421 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels/{label_id}",
422 headers=auth_headers,
423 )
424 assert response.status_code == 204
425
426
427 @pytest.mark.anyio
428 async def test_remove_label_from_issue_unknown_issue_returns_404(
429 client: AsyncClient,
430 auth_headers: dict[str, str],
431 ) -> None:
432 """DELETE .../issues/{number}/labels/{label_id} for an unknown issue returns 404."""
433 repo_id = await _create_repo(client, auth_headers, "issue-label-404-repo")
434 label = await _create_label(client, auth_headers, repo_id)
435 label_id = label.get("label_id") or label.get("labelId")
436
437 response = await client.delete(
438 f"/api/v1/musehub/repos/{repo_id}/issues/9999/labels/{label_id}",
439 headers=auth_headers,
440 )
441 assert response.status_code == 404
442
443
444 # ---------------------------------------------------------------------------
445 # PR label assignments
446 # ---------------------------------------------------------------------------
447
448
449 @pytest.mark.anyio
450 async def test_assign_labels_to_pr(
451 client: AsyncClient,
452 auth_headers: dict[str, str],
453 db_session: AsyncSession,
454 ) -> None:
455 """POST .../pull-requests/{pr_id}/labels assigns labels and returns them."""
456 repo_id = await _create_repo(client, auth_headers, "pr-label-assign-repo")
457 await _push_branch(db_session, repo_id, "main")
458 await _push_branch(db_session, repo_id, "feature")
459 label = await _create_label(client, auth_headers, repo_id, name="enhancement", color="#a2eeef")
460 label_id = label.get("label_id") or label.get("labelId")
461 pr = await _create_pr(client, auth_headers, repo_id)
462 pr_id = pr.get("prId") or pr.get("pr_id")
463
464 response = await client.post(
465 f"/api/v1/musehub/repos/{repo_id}/pull-requests/{pr_id}/labels",
466 json={"label_ids": [label_id]},
467 headers=auth_headers,
468 )
469 assert response.status_code == 200
470 assigned = response.json()
471 assert len(assigned) == 1
472 assert assigned[0]["name"] == "enhancement"
473
474
475 @pytest.mark.anyio
476 async def test_remove_label_from_pr(
477 client: AsyncClient,
478 auth_headers: dict[str, str],
479 db_session: AsyncSession,
480 ) -> None:
481 """DELETE .../pull-requests/{pr_id}/labels/{label_id} removes the association."""
482 repo_id = await _create_repo(client, auth_headers, "pr-label-remove-repo")
483 await _push_branch(db_session, repo_id, "main")
484 await _push_branch(db_session, repo_id, "feature")
485 label = await _create_label(client, auth_headers, repo_id)
486 label_id = label.get("label_id") or label.get("labelId")
487 pr = await _create_pr(client, auth_headers, repo_id)
488 pr_id = pr.get("prId") or pr.get("pr_id")
489
490 # Assign first.
491 await client.post(
492 f"/api/v1/musehub/repos/{repo_id}/pull-requests/{pr_id}/labels",
493 json={"label_ids": [label_id]},
494 headers=auth_headers,
495 )
496
497 # Then remove — should be idempotent too.
498 response = await client.delete(
499 f"/api/v1/musehub/repos/{repo_id}/pull-requests/{pr_id}/labels/{label_id}",
500 headers=auth_headers,
501 )
502 assert response.status_code == 204
503
504
505 @pytest.mark.anyio
506 async def test_remove_label_from_pr_unknown_pr_returns_404(
507 client: AsyncClient,
508 auth_headers: dict[str, str],
509 ) -> None:
510 """DELETE .../pull-requests/{pr_id}/labels/{label_id} for an unknown PR returns 404."""
511 repo_id = await _create_repo(client, auth_headers, "pr-label-404-repo")
512 label = await _create_label(client, auth_headers, repo_id)
513 label_id = label.get("label_id") or label.get("labelId")
514
515 response = await client.delete(
516 f"/api/v1/musehub/repos/{repo_id}/pull-requests/00000000-0000-0000-0000-000000000000/labels/{label_id}",
517 headers=auth_headers,
518 )
519 assert response.status_code == 404
520
521
522 @pytest.mark.anyio
523 async def test_delete_label_cascades_to_issue_associations(
524 client: AsyncClient,
525 auth_headers: dict[str, str],
526 ) -> None:
527 """Deleting a label removes it from all issue associations (cascade)."""
528 repo_id = await _create_repo(client, auth_headers, "cascade-delete-repo")
529 label = await _create_label(client, auth_headers, repo_id)
530 label_id = label.get("label_id") or label.get("labelId")
531 issue = await _create_issue(client, auth_headers, repo_id)
532 issue_number = issue["number"]
533
534 await client.post(
535 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels",
536 json={"label_ids": [label_id]},
537 headers=auth_headers,
538 )
539
540 delete_resp = await client.delete(
541 f"/api/v1/musehub/repos/{repo_id}/labels/{label_id}",
542 headers=auth_headers,
543 )
544 assert delete_resp.status_code == 204
545
546 # The label should no longer appear in the repo's label list.
547 list_resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/labels")
548 assert list_resp.json()["total"] == 0