gabriel / musehub public
test_musehub_labels.py python
556 lines 19.0 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 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.muse_contracts.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 = "bug",
51 color: str = "#d73a4a",
52 description: str | None = "Something isn't working",
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 @pytest.mark.anyio
131 async def test_create_label_returns_201(
132 client: AsyncClient,
133 auth_headers: StrDict,
134 ) -> None:
135 """POST /labels creates a label and returns 201 with the label data."""
136 repo_id = await _create_repo(client, auth_headers, "create-label-repo")
137 label = await _create_label(client, auth_headers, repo_id)
138
139 assert label["name"] == "bug"
140 assert label["color"] == "#d73a4a"
141 assert label["description"] == "Something isn't working"
142 assert "labelId" in label or "label_id" in label
143 assert label.get("repoId") == repo_id or label.get("repo_id") == repo_id
144
145
146 @pytest.mark.anyio
147 async def test_create_label_requires_auth(
148 client: AsyncClient,
149 ) -> None:
150 """POST /labels without auth returns 401."""
151 response = await client.post(
152 "/api/repos/nonexistent/labels",
153 json={"name": "bug", "color": "#d73a4a"},
154 )
155 assert response.status_code == 401
156
157
158 @pytest.mark.anyio
159 async def test_create_label_unknown_repo_returns_404(
160 client: AsyncClient,
161 auth_headers: StrDict,
162 ) -> None:
163 """POST /labels for a non-existent repo returns 404."""
164 response = await client.post(
165 "/api/repos/does-not-exist/labels",
166 json={"name": "bug", "color": "#d73a4a"},
167 headers=auth_headers,
168 )
169 assert response.status_code == 404
170
171
172 @pytest.mark.anyio
173 async def test_create_label_duplicate_name_returns_409(
174 client: AsyncClient,
175 auth_headers: StrDict,
176 ) -> None:
177 """POST /labels with a duplicate name returns 409 Conflict."""
178 repo_id = await _create_repo(client, auth_headers, "dupe-label-repo")
179 await _create_label(client, auth_headers, repo_id, name="bug")
180
181 response = await client.post(
182 f"/api/repos/{repo_id}/labels",
183 json={"name": "bug", "color": "#aabbcc"},
184 headers=auth_headers,
185 )
186 assert response.status_code == 409
187
188
189 @pytest.mark.anyio
190 async def test_create_label_invalid_color_returns_422(
191 client: AsyncClient,
192 auth_headers: StrDict,
193 ) -> None:
194 """POST /labels with an invalid colour format returns 422."""
195 repo_id = await _create_repo(client, auth_headers, "color-invalid-repo")
196 response = await client.post(
197 f"/api/repos/{repo_id}/labels",
198 json={"name": "bug", "color": "red"},
199 headers=auth_headers,
200 )
201 assert response.status_code == 422
202
203
204 # ---------------------------------------------------------------------------
205 # GET /repos/{repo_id}/labels
206 # ---------------------------------------------------------------------------
207
208
209 @pytest.mark.anyio
210 async def test_list_labels_public_access(
211 client: AsyncClient,
212 auth_headers: StrDict,
213 ) -> None:
214 """GET /labels is publicly accessible and returns all repo labels."""
215 repo_id = await _create_repo(client, auth_headers, "list-labels-repo")
216 await _create_label(client, auth_headers, repo_id, name="bug", color="#d73a4a")
217 await _create_label(client, auth_headers, repo_id, name="enhancement", color="#a2eeef")
218
219 # No auth headers — public endpoint.
220 response = await client.get(f"/api/repos/{repo_id}/labels")
221 assert response.status_code == 200
222 body = response.json()
223 assert "items" in body
224 assert body["total"] == 2
225 names = [item["name"] for item in body["items"]]
226 assert "bug" in names
227 assert "enhancement" in names
228
229
230 @pytest.mark.anyio
231 async def test_list_labels_unknown_repo_returns_404(
232 client: AsyncClient,
233 ) -> None:
234 """GET /labels for a non-existent repo returns 404."""
235 response = await client.get("/api/repos/no-such-repo/labels")
236 assert response.status_code == 404
237
238
239 @pytest.mark.anyio
240 async def test_list_labels_empty_repo(
241 client: AsyncClient,
242 auth_headers: StrDict,
243 ) -> None:
244 """GET /labels for a repo with no labels returns an empty list."""
245 repo_id = await _create_repo(client, auth_headers, "empty-labels-repo")
246 response = await client.get(f"/api/repos/{repo_id}/labels")
247 assert response.status_code == 200
248 body = response.json()
249 assert body["items"] == []
250 assert body["total"] == 0
251
252
253 # ---------------------------------------------------------------------------
254 # PATCH /repos/{repo_id}/labels/{label_id}
255 # ---------------------------------------------------------------------------
256
257
258 @pytest.mark.anyio
259 async def test_update_label_name(
260 client: AsyncClient,
261 auth_headers: StrDict,
262 ) -> None:
263 """PATCH /labels/{id} updates the label name."""
264 repo_id = await _create_repo(client, auth_headers, "update-label-repo")
265 label = await _create_label(client, auth_headers, repo_id, name="old-name", color="#aabbcc")
266 label_id = label.get("label_id") or label.get("labelId")
267
268 response = await client.patch(
269 f"/api/repos/{repo_id}/labels/{label_id}",
270 json={"name": "new-name"},
271 headers=auth_headers,
272 )
273 assert response.status_code == 200
274 assert response.json()["name"] == "new-name"
275 assert response.json()["color"] == "#aabbcc"
276
277
278 @pytest.mark.anyio
279 async def test_update_label_requires_auth(
280 client: AsyncClient,
281 auth_headers: StrDict,
282 ) -> None:
283 """PATCH /labels/{id} without auth returns 401."""
284 from musehub.auth.request_signing import optional_signed_request, require_signed_request
285 from musehub.main import app as _app
286
287 repo_id = await _create_repo(client, auth_headers, "update-auth-label-repo")
288 label = await _create_label(client, auth_headers, repo_id)
289 label_id = label.get("label_id") or label.get("labelId")
290
291 _app.dependency_overrides.pop(require_signed_request, None)
292 _app.dependency_overrides.pop(optional_signed_request, None)
293 response = await client.patch(
294 f"/api/repos/{repo_id}/labels/{label_id}",
295 json={"name": "hacked"},
296 )
297 assert response.status_code == 401
298
299
300 @pytest.mark.anyio
301 async def test_update_label_not_found_returns_404(
302 client: AsyncClient,
303 auth_headers: StrDict,
304 ) -> None:
305 """PATCH /labels/{id} with an unknown label_id returns 404."""
306 repo_id = await _create_repo(client, auth_headers, "update-404-repo")
307 response = await client.patch(
308 f"/api/repos/{repo_id}/labels/00000000-0000-0000-0000-000000000000",
309 json={"name": "ghost"},
310 headers=auth_headers,
311 )
312 assert response.status_code == 404
313
314
315 # ---------------------------------------------------------------------------
316 # DELETE /repos/{repo_id}/labels/{label_id}
317 # ---------------------------------------------------------------------------
318
319
320 @pytest.mark.anyio
321 async def test_delete_label_returns_204(
322 client: AsyncClient,
323 auth_headers: StrDict,
324 ) -> None:
325 """DELETE /labels/{id} removes the label and returns 204."""
326 repo_id = await _create_repo(client, auth_headers, "delete-label-repo")
327 label = await _create_label(client, auth_headers, repo_id)
328 label_id = label.get("label_id") or label.get("labelId")
329
330 response = await client.delete(
331 f"/api/repos/{repo_id}/labels/{label_id}",
332 headers=auth_headers,
333 )
334 assert response.status_code == 204
335
336 # Confirm the label is gone.
337 list_resp = await client.get(f"/api/repos/{repo_id}/labels")
338 assert list_resp.json()["total"] == 0
339
340
341 @pytest.mark.anyio
342 async def test_delete_label_requires_auth(
343 client: AsyncClient,
344 auth_headers: StrDict,
345 ) -> None:
346 """DELETE /labels/{id} without auth returns 401."""
347 from musehub.auth.request_signing import optional_signed_request, require_signed_request
348 from musehub.main import app as _app
349
350 repo_id = await _create_repo(client, auth_headers, "delete-auth-repo")
351 label = await _create_label(client, auth_headers, repo_id)
352 label_id = label.get("label_id") or label.get("labelId")
353
354 _app.dependency_overrides.pop(require_signed_request, None)
355 _app.dependency_overrides.pop(optional_signed_request, None)
356 response = await client.delete(
357 f"/api/repos/{repo_id}/labels/{label_id}",
358 )
359 assert response.status_code == 401
360
361
362 # ---------------------------------------------------------------------------
363 # Issue label assignments
364 # ---------------------------------------------------------------------------
365
366
367 @pytest.mark.anyio
368 async def test_assign_labels_to_issue(
369 client: AsyncClient,
370 auth_headers: StrDict,
371 ) -> None:
372 """POST .../issues/{number}/labels assigns labels and returns the updated issue."""
373 repo_id = await _create_repo(client, auth_headers, "issue-label-assign-repo")
374 await _create_label(client, auth_headers, repo_id, name="bug", color="#d73a4a")
375 issue = await _create_issue(client, auth_headers, repo_id)
376 issue_number = issue["number"]
377
378 response = await client.post(
379 f"/api/repos/{repo_id}/issues/{issue_number}/labels",
380 json={"labels": ["bug"]},
381 headers=auth_headers,
382 )
383 assert response.status_code == 200
384 updated_issue = response.json()
385 assert "bug" in updated_issue.get("labels", [])
386
387
388 @pytest.mark.anyio
389 async def test_assign_labels_to_issue_idempotent(
390 client: AsyncClient,
391 auth_headers: StrDict,
392 ) -> None:
393 """Assigning the same label twice does not raise an error."""
394 repo_id = await _create_repo(client, auth_headers, "issue-label-idem-repo")
395 await _create_label(client, auth_headers, repo_id)
396 issue = await _create_issue(client, auth_headers, repo_id)
397 issue_number = issue["number"]
398
399 for _ in range(2):
400 response = await client.post(
401 f"/api/repos/{repo_id}/issues/{issue_number}/labels",
402 json={"labels": ["bug"]},
403 headers=auth_headers,
404 )
405 assert response.status_code == 200
406
407
408 @pytest.mark.anyio
409 async def test_remove_label_from_issue(
410 client: AsyncClient,
411 auth_headers: StrDict,
412 ) -> None:
413 """DELETE .../issues/{number}/labels/{label_name} removes the association."""
414 repo_id = await _create_repo(client, auth_headers, "issue-label-remove-repo")
415 await _create_label(client, auth_headers, repo_id, name="bug", color="#d73a4a")
416 issue = await _create_issue(client, auth_headers, repo_id)
417 issue_number = issue["number"]
418
419 # Assign first.
420 await client.post(
421 f"/api/repos/{repo_id}/issues/{issue_number}/labels",
422 json={"labels": ["bug"]},
423 headers=auth_headers,
424 )
425
426 # Then remove (by label name, returns updated issue with 200).
427 response = await client.delete(
428 f"/api/repos/{repo_id}/issues/{issue_number}/labels/bug",
429 headers=auth_headers,
430 )
431 assert response.status_code == 200
432 assert "bug" not in response.json().get("labels", [])
433
434
435 @pytest.mark.anyio
436 async def test_remove_label_from_issue_unknown_issue_returns_404(
437 client: AsyncClient,
438 auth_headers: StrDict,
439 ) -> None:
440 """DELETE .../issues/{number}/labels/{label_id} for an unknown issue returns 404."""
441 repo_id = await _create_repo(client, auth_headers, "issue-label-404-repo")
442 label = await _create_label(client, auth_headers, repo_id)
443 label_id = label.get("label_id") or label.get("labelId")
444
445 response = await client.delete(
446 f"/api/repos/{repo_id}/issues/9999/labels/{label_id}",
447 headers=auth_headers,
448 )
449 assert response.status_code == 404
450
451
452 # ---------------------------------------------------------------------------
453 # Proposal label assignments
454 # ---------------------------------------------------------------------------
455
456
457 @pytest.mark.anyio
458 async def test_assign_labels_to_proposal(
459 client: AsyncClient,
460 auth_headers: StrDict,
461 db_session: AsyncSession,
462 ) -> None:
463 """POST .../proposals/{proposal_id}/labels assigns labels and returns them."""
464 repo_id = await _create_repo(client, auth_headers, "proposal-label-assign-repo")
465 await _push_branch(db_session, repo_id, "main")
466 await _push_branch(db_session, repo_id, "feature")
467 label = await _create_label(client, auth_headers, repo_id, name="enhancement", color="#a2eeef")
468 label_id = label.get("label_id") or label.get("labelId")
469 proposal = await _create_proposal(client, auth_headers, repo_id)
470 proposal_id = proposal.get("proposalId") or proposal.get("proposal_id")
471
472 response = await client.post(
473 f"/api/repos/{repo_id}/proposals/{proposal_id}/labels",
474 json={"label_ids": [label_id]},
475 headers=auth_headers,
476 )
477 assert response.status_code == 200
478 assigned = response.json()
479 assert len(assigned) == 1
480 assert assigned[0]["name"] == "enhancement"
481
482
483 @pytest.mark.anyio
484 async def test_remove_label_from_proposal(
485 client: AsyncClient,
486 auth_headers: StrDict,
487 db_session: AsyncSession,
488 ) -> None:
489 """DELETE .../proposals/{proposal_id}/labels/{label_id} removes the association."""
490 repo_id = await _create_repo(client, auth_headers, "proposal-label-remove-repo")
491 await _push_branch(db_session, repo_id, "main")
492 await _push_branch(db_session, repo_id, "feature")
493 label = await _create_label(client, auth_headers, repo_id)
494 label_id = label.get("label_id") or label.get("labelId")
495 proposal = await _create_proposal(client, auth_headers, repo_id)
496 proposal_id = proposal.get("proposalId") or proposal.get("proposal_id")
497
498 # Assign first.
499 await client.post(
500 f"/api/repos/{repo_id}/proposals/{proposal_id}/labels",
501 json={"label_ids": [label_id]},
502 headers=auth_headers,
503 )
504
505 # Then remove — should be idempotent too.
506 response = await client.delete(
507 f"/api/repos/{repo_id}/proposals/{proposal_id}/labels/{label_id}",
508 headers=auth_headers,
509 )
510 assert response.status_code == 204
511
512
513 @pytest.mark.anyio
514 async def test_remove_label_from_proposal_unknown_returns_404(
515 client: AsyncClient,
516 auth_headers: StrDict,
517 ) -> None:
518 """DELETE .../proposals/{proposal_id}/labels/{label_id} for an unknown proposal returns 404."""
519 repo_id = await _create_repo(client, auth_headers, "proposal-label-404-repo")
520 label = await _create_label(client, auth_headers, repo_id)
521 label_id = label.get("label_id") or label.get("labelId")
522
523 response = await client.delete(
524 f"/api/repos/{repo_id}/proposals/00000000-0000-0000-0000-000000000000/labels/{label_id}",
525 headers=auth_headers,
526 )
527 assert response.status_code == 404
528
529
530 @pytest.mark.anyio
531 async def test_delete_label_cascades_to_issue_associations(
532 client: AsyncClient,
533 auth_headers: StrDict,
534 ) -> None:
535 """Deleting a label removes it from all issue associations (cascade)."""
536 repo_id = await _create_repo(client, auth_headers, "cascade-delete-repo")
537 label = await _create_label(client, auth_headers, repo_id)
538 label_id = label.get("label_id") or label.get("labelId")
539 issue = await _create_issue(client, auth_headers, repo_id)
540 issue_number = issue["number"]
541
542 await client.post(
543 f"/api/repos/{repo_id}/issues/{issue_number}/labels",
544 json={"label_ids": [label_id]},
545 headers=auth_headers,
546 )
547
548 delete_resp = await client.delete(
549 f"/api/repos/{repo_id}/labels/{label_id}",
550 headers=auth_headers,
551 )
552 assert delete_resp.status_code == 204
553
554 # The label should no longer appear in the repo's label list.
555 list_resp = await client.get(f"/api/repos/{repo_id}/labels")
556 assert list_resp.json()["total"] == 0
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago