gabriel / musehub public
test_musehub_labels.py python
545 lines 18.8 KB
c2319918 fix(ci): resolve all test failures blocking PR #3 Gabriel Cardona <gabriel@tellurstori.com> 6d 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", "initialize": False},
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 the updated issue."""
362 repo_id = await _create_repo(client, auth_headers, "issue-label-assign-repo")
363 await _create_label(client, auth_headers, repo_id, name="bug", color="#d73a4a")
364 issue = await _create_issue(client, auth_headers, repo_id)
365 issue_number = issue["number"]
366
367 response = await client.post(
368 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels",
369 json={"labels": ["bug"]},
370 headers=auth_headers,
371 )
372 assert response.status_code == 200
373 updated_issue = response.json()
374 assert "bug" in updated_issue.get("labels", [])
375
376
377 @pytest.mark.anyio
378 async def test_assign_labels_to_issue_idempotent(
379 client: AsyncClient,
380 auth_headers: dict[str, str],
381 ) -> None:
382 """Assigning the same label twice does not raise an error."""
383 repo_id = await _create_repo(client, auth_headers, "issue-label-idem-repo")
384 await _create_label(client, auth_headers, repo_id)
385 issue = await _create_issue(client, auth_headers, repo_id)
386 issue_number = issue["number"]
387
388 for _ in range(2):
389 response = await client.post(
390 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels",
391 json={"labels": ["bug"]},
392 headers=auth_headers,
393 )
394 assert response.status_code == 200
395
396
397 @pytest.mark.anyio
398 async def test_remove_label_from_issue(
399 client: AsyncClient,
400 auth_headers: dict[str, str],
401 ) -> None:
402 """DELETE .../issues/{number}/labels/{label_name} removes the association."""
403 repo_id = await _create_repo(client, auth_headers, "issue-label-remove-repo")
404 await _create_label(client, auth_headers, repo_id, name="bug", color="#d73a4a")
405 issue = await _create_issue(client, auth_headers, repo_id)
406 issue_number = issue["number"]
407
408 # Assign first.
409 await client.post(
410 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels",
411 json={"labels": ["bug"]},
412 headers=auth_headers,
413 )
414
415 # Then remove (by label name, returns updated issue with 200).
416 response = await client.delete(
417 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels/bug",
418 headers=auth_headers,
419 )
420 assert response.status_code == 200
421 assert "bug" not in response.json().get("labels", [])
422
423
424 @pytest.mark.anyio
425 async def test_remove_label_from_issue_unknown_issue_returns_404(
426 client: AsyncClient,
427 auth_headers: dict[str, str],
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/v1/musehub/repos/{repo_id}/issues/9999/labels/{label_id}",
436 headers=auth_headers,
437 )
438 assert response.status_code == 404
439
440
441 # ---------------------------------------------------------------------------
442 # PR label assignments
443 # ---------------------------------------------------------------------------
444
445
446 @pytest.mark.anyio
447 async def test_assign_labels_to_pr(
448 client: AsyncClient,
449 auth_headers: dict[str, str],
450 db_session: AsyncSession,
451 ) -> None:
452 """POST .../pull-requests/{pr_id}/labels assigns labels and returns them."""
453 repo_id = await _create_repo(client, auth_headers, "pr-label-assign-repo")
454 await _push_branch(db_session, repo_id, "main")
455 await _push_branch(db_session, repo_id, "feature")
456 label = await _create_label(client, auth_headers, repo_id, name="enhancement", color="#a2eeef")
457 label_id = label.get("label_id") or label.get("labelId")
458 pr = await _create_pr(client, auth_headers, repo_id)
459 pr_id = pr.get("prId") or pr.get("pr_id")
460
461 response = await client.post(
462 f"/api/v1/musehub/repos/{repo_id}/pull-requests/{pr_id}/labels",
463 json={"label_ids": [label_id]},
464 headers=auth_headers,
465 )
466 assert response.status_code == 200
467 assigned = response.json()
468 assert len(assigned) == 1
469 assert assigned[0]["name"] == "enhancement"
470
471
472 @pytest.mark.anyio
473 async def test_remove_label_from_pr(
474 client: AsyncClient,
475 auth_headers: dict[str, str],
476 db_session: AsyncSession,
477 ) -> None:
478 """DELETE .../pull-requests/{pr_id}/labels/{label_id} removes the association."""
479 repo_id = await _create_repo(client, auth_headers, "pr-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 pr = await _create_pr(client, auth_headers, repo_id)
485 pr_id = pr.get("prId") or pr.get("pr_id")
486
487 # Assign first.
488 await client.post(
489 f"/api/v1/musehub/repos/{repo_id}/pull-requests/{pr_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/v1/musehub/repos/{repo_id}/pull-requests/{pr_id}/labels/{label_id}",
497 headers=auth_headers,
498 )
499 assert response.status_code == 204
500
501
502 @pytest.mark.anyio
503 async def test_remove_label_from_pr_unknown_pr_returns_404(
504 client: AsyncClient,
505 auth_headers: dict[str, str],
506 ) -> None:
507 """DELETE .../pull-requests/{pr_id}/labels/{label_id} for an unknown PR returns 404."""
508 repo_id = await _create_repo(client, auth_headers, "pr-label-404-repo")
509 label = await _create_label(client, auth_headers, repo_id)
510 label_id = label.get("label_id") or label.get("labelId")
511
512 response = await client.delete(
513 f"/api/v1/musehub/repos/{repo_id}/pull-requests/00000000-0000-0000-0000-000000000000/labels/{label_id}",
514 headers=auth_headers,
515 )
516 assert response.status_code == 404
517
518
519 @pytest.mark.anyio
520 async def test_delete_label_cascades_to_issue_associations(
521 client: AsyncClient,
522 auth_headers: dict[str, str],
523 ) -> None:
524 """Deleting a label removes it from all issue associations (cascade)."""
525 repo_id = await _create_repo(client, auth_headers, "cascade-delete-repo")
526 label = await _create_label(client, auth_headers, repo_id)
527 label_id = label.get("label_id") or label.get("labelId")
528 issue = await _create_issue(client, auth_headers, repo_id)
529 issue_number = issue["number"]
530
531 await client.post(
532 f"/api/v1/musehub/repos/{repo_id}/issues/{issue_number}/labels",
533 json={"label_ids": [label_id]},
534 headers=auth_headers,
535 )
536
537 delete_resp = await client.delete(
538 f"/api/v1/musehub/repos/{repo_id}/labels/{label_id}",
539 headers=auth_headers,
540 )
541 assert delete_resp.status_code == 204
542
543 # The label should no longer appear in the repo's label list.
544 list_resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/labels")
545 assert list_resp.json()["total"] == 0