gabriel / musehub public
test_musehub_ui_labels.py python
517 lines 17.6 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for Muse Hub label management UI endpoints.
2
3 Covers GET /musehub/ui/{owner}/{repo_slug}/labels:
4 - test_labels_page_returns_200 — page renders without auth
5 - test_labels_page_no_auth_required — GET needs no JWT
6 - test_labels_page_unknown_repo_404 — unknown owner/slug → 404
7 - test_labels_page_has_color_picker_js — colour picker rendered in template
8 - test_labels_page_has_label_list_js — label list JS present
9 - test_labels_page_json_format — ?format=json returns structured data
10 - test_labels_page_json_has_items_key — JSON payload includes 'items' array
11 - test_labels_page_shows_issue_count — issue counts included in JSON response
12 - test_labels_page_base_url_uses_slug — base_url in context uses owner/slug not repo_id
13
14 Covers POST /musehub/ui/{owner}/{repo_slug}/labels:
15 - test_create_label_success — 201 + label_id returned
16 - test_create_label_requires_auth — 401 without Bearer token
17 - test_create_label_duplicate_name_409 — duplicate name → 409
18 - test_create_label_invalid_color_422 — bad hex color → 422
19 - test_create_label_unknown_repo_404 — unknown repo → 404
20
21 Covers POST /musehub/ui/{owner}/{repo_slug}/labels/{label_id}/edit:
22 - test_edit_label_success — 200 + updated values
23 - test_edit_label_requires_auth — 401 without token
24 - test_edit_label_unknown_label_404 — unknown label_id → 404
25 - test_edit_label_name_conflict_409 — name collision → 409
26 - test_edit_label_partial_update — partial body updates only supplied fields
27
28 Covers POST /musehub/ui/{owner}/{repo_slug}/labels/{label_id}/delete:
29 - test_delete_label_success — 200 ok=True
30 - test_delete_label_requires_auth — 401 without token
31 - test_delete_label_unknown_label_404 — unknown label_id → 404
32
33 Covers POST /musehub/ui/{owner}/{repo_slug}/labels/reset:
34 - test_reset_labels_success — 200 + 10 defaults seeded
35 - test_reset_labels_requires_auth — 401 without token
36 - test_reset_labels_wipes_custom_labels — existing labels replaced
37 - test_reset_labels_unknown_repo_404 — unknown repo → 404
38 """
39 from __future__ import annotations
40
41 import pytest
42 from httpx import AsyncClient
43 from sqlalchemy.ext.asyncio import AsyncSession
44
45 from musehub.db.musehub_label_models import MusehubLabel
46 from musehub.db.musehub_models import MusehubIssue, MusehubRepo
47
48
49 # ---------------------------------------------------------------------------
50 # Helpers
51 # ---------------------------------------------------------------------------
52
53
54 async def _make_repo(
55 db: AsyncSession,
56 owner: str = "beatmaker",
57 slug: str = "deep-cuts",
58 ) -> str:
59 """Seed a public repo and return its repo_id string."""
60 repo = MusehubRepo(
61 name=slug,
62 owner=owner,
63 slug=slug,
64 visibility="public",
65 owner_user_id="uid-beatmaker",
66 )
67 db.add(repo)
68 await db.commit()
69 await db.refresh(repo)
70 return str(repo.repo_id)
71
72
73 async def _make_label(
74 db: AsyncSession,
75 repo_id: str,
76 *,
77 name: str = "bug",
78 color: str = "#d73a4a",
79 description: str | None = "Something isn't working",
80 ) -> MusehubLabel:
81 """Seed a label and return it."""
82 label = MusehubLabel(
83 repo_id=repo_id,
84 name=name,
85 color=color,
86 description=description,
87 )
88 db.add(label)
89 await db.commit()
90 await db.refresh(label)
91 return label
92
93
94 # ---------------------------------------------------------------------------
95 # GET /musehub/ui/{owner}/{repo_slug}/labels — label list page
96 # ---------------------------------------------------------------------------
97
98
99 @pytest.mark.anyio
100 async def test_labels_page_returns_200(
101 client: AsyncClient, db_session: AsyncSession
102 ) -> None:
103 """GET /musehub/ui/{owner}/{slug}/labels returns 200 HTML."""
104 await _make_repo(db_session)
105 response = await client.get("/musehub/ui/beatmaker/deep-cuts/labels")
106 assert response.status_code == 200
107 assert "text/html" in response.headers["content-type"]
108
109
110 @pytest.mark.anyio
111 async def test_labels_page_no_auth_required(
112 client: AsyncClient, db_session: AsyncSession
113 ) -> None:
114 """Label list page is publicly accessible — no JWT required."""
115 await _make_repo(db_session)
116 response = await client.get("/musehub/ui/beatmaker/deep-cuts/labels")
117 assert response.status_code == 200
118
119
120 @pytest.mark.anyio
121 async def test_labels_page_unknown_repo_404(
122 client: AsyncClient, db_session: AsyncSession
123 ) -> None:
124 """Unknown owner/slug combination → 404."""
125 response = await client.get("/musehub/ui/nobody/nonexistent/labels")
126 assert response.status_code == 404
127
128
129 @pytest.mark.anyio
130 async def test_labels_page_has_color_picker_js(
131 client: AsyncClient, db_session: AsyncSession
132 ) -> None:
133 """The labels page HTML includes a colour picker for creating labels."""
134 await _make_repo(db_session)
135 response = await client.get("/musehub/ui/beatmaker/deep-cuts/labels")
136 assert response.status_code == 200
137 body = response.text
138 assert 'type="color"' in body or "color-picker" in body or "input" in body
139
140
141 @pytest.mark.anyio
142 async def test_labels_page_has_label_list_js(
143 client: AsyncClient, db_session: AsyncSession
144 ) -> None:
145 """The labels page contains JavaScript to render the label list."""
146 await _make_repo(db_session)
147 response = await client.get("/musehub/ui/beatmaker/deep-cuts/labels")
148 assert response.status_code == 200
149 body = response.text
150 assert "renderLabel" in body or "label-list" in body or "label-row" in body
151
152
153 @pytest.mark.anyio
154 async def test_labels_page_json_format(
155 client: AsyncClient, db_session: AsyncSession
156 ) -> None:
157 """?format=json returns a JSON response with a 200 status."""
158 await _make_repo(db_session)
159 response = await client.get("/musehub/ui/beatmaker/deep-cuts/labels?format=json")
160 assert response.status_code == 200
161 assert "application/json" in response.headers["content-type"]
162
163
164 @pytest.mark.anyio
165 async def test_labels_page_json_has_items_key(
166 client: AsyncClient, db_session: AsyncSession
167 ) -> None:
168 """JSON response contains 'labels' and 'total' keys."""
169 repo_id = await _make_repo(db_session)
170 await _make_label(db_session, repo_id, name="bug", color="#d73a4a")
171 response = await client.get("/musehub/ui/beatmaker/deep-cuts/labels?format=json")
172 assert response.status_code == 200
173 data = response.json()
174 assert "labels" in data
175 assert "total" in data
176 assert data["total"] == 1
177
178
179 @pytest.mark.anyio
180 async def test_labels_page_shows_issue_count(
181 client: AsyncClient, db_session: AsyncSession
182 ) -> None:
183 """JSON response includes issue_count for each label."""
184 repo_id = await _make_repo(db_session)
185 await _make_label(db_session, repo_id, name="enhancement", color="#a2eeef")
186 response = await client.get("/musehub/ui/beatmaker/deep-cuts/labels?format=json")
187 assert response.status_code == 200
188 data = response.json()
189 labels = data["labels"]
190 assert len(labels) == 1
191 assert "issue_count" in labels[0]
192 assert labels[0]["issue_count"] == 0
193
194
195 @pytest.mark.anyio
196 async def test_labels_page_base_url_uses_slug(
197 client: AsyncClient, db_session: AsyncSession
198 ) -> None:
199 """The HTML page embeds the owner/slug base URL, not the repo UUID."""
200 await _make_repo(db_session)
201 response = await client.get("/musehub/ui/beatmaker/deep-cuts/labels")
202 assert response.status_code == 200
203 body = response.text
204 assert "beatmaker" in body
205 assert "deep-cuts" in body
206
207
208 # ---------------------------------------------------------------------------
209 # POST /musehub/ui/{owner}/{repo_slug}/labels — create label
210 # ---------------------------------------------------------------------------
211
212
213 @pytest.mark.anyio
214 async def test_create_label_success(
215 client: AsyncClient,
216 db_session: AsyncSession,
217 auth_headers: dict[str, str],
218 ) -> None:
219 """POST /labels with valid body + auth returns 201 with label_id."""
220 await _make_repo(db_session)
221 response = await client.post(
222 "/musehub/ui/beatmaker/deep-cuts/labels",
223 json={"name": "needs-arrangement", "color": "#e4e669", "description": "Track needs arrangement"},
224 headers=auth_headers,
225 )
226 assert response.status_code == 201
227 data = response.json()
228 assert data["ok"] is True
229 assert "label_id" in data
230 assert data["label_id"] is not None
231
232
233 @pytest.mark.anyio
234 async def test_create_label_requires_auth(
235 client: AsyncClient, db_session: AsyncSession
236 ) -> None:
237 """POST /labels without a JWT returns 401 or 403."""
238 await _make_repo(db_session)
239 response = await client.post(
240 "/musehub/ui/beatmaker/deep-cuts/labels",
241 json={"name": "bug", "color": "#d73a4a"},
242 )
243 assert response.status_code in (401, 403)
244
245
246 @pytest.mark.anyio
247 async def test_create_label_duplicate_name_409(
248 client: AsyncClient,
249 db_session: AsyncSession,
250 auth_headers: dict[str, str],
251 ) -> None:
252 """Creating a label with an existing name within the repo → 409."""
253 repo_id = await _make_repo(db_session)
254 await _make_label(db_session, repo_id, name="bug", color="#d73a4a")
255 response = await client.post(
256 "/musehub/ui/beatmaker/deep-cuts/labels",
257 json={"name": "bug", "color": "#ff0000"},
258 headers=auth_headers,
259 )
260 assert response.status_code == 409
261
262
263 @pytest.mark.anyio
264 async def test_create_label_invalid_color_422(
265 client: AsyncClient,
266 db_session: AsyncSession,
267 auth_headers: dict[str, str],
268 ) -> None:
269 """A malformed hex colour string → 422 validation error."""
270 await _make_repo(db_session)
271 response = await client.post(
272 "/musehub/ui/beatmaker/deep-cuts/labels",
273 json={"name": "test", "color": "not-a-color"},
274 headers=auth_headers,
275 )
276 assert response.status_code == 422
277
278
279 @pytest.mark.anyio
280 async def test_create_label_unknown_repo_404(
281 client: AsyncClient,
282 db_session: AsyncSession,
283 auth_headers: dict[str, str],
284 ) -> None:
285 """Creating a label on a nonexistent repo → 404."""
286 response = await client.post(
287 "/musehub/ui/nobody/ghost-repo/labels",
288 json={"name": "bug", "color": "#d73a4a"},
289 headers=auth_headers,
290 )
291 assert response.status_code == 404
292
293
294 # ---------------------------------------------------------------------------
295 # POST /musehub/ui/{owner}/{repo_slug}/labels/{label_id}/edit
296 # ---------------------------------------------------------------------------
297
298
299 @pytest.mark.anyio
300 async def test_edit_label_success(
301 client: AsyncClient,
302 db_session: AsyncSession,
303 auth_headers: dict[str, str],
304 ) -> None:
305 """POST /labels/{label_id}/edit updates the label and returns ok=True."""
306 repo_id = await _make_repo(db_session)
307 label = await _make_label(db_session, repo_id, name="bug", color="#d73a4a")
308 response = await client.post(
309 f"/musehub/ui/beatmaker/deep-cuts/labels/{label.id}/edit",
310 json={"name": "critical-bug", "color": "#ff0000"},
311 headers=auth_headers,
312 )
313 assert response.status_code == 200
314 data = response.json()
315 assert data["ok"] is True
316 assert data["label_id"] == str(label.id)
317
318
319 @pytest.mark.anyio
320 async def test_edit_label_requires_auth(
321 client: AsyncClient, db_session: AsyncSession
322 ) -> None:
323 """POST /labels/{label_id}/edit without JWT → 401 or 403."""
324 repo_id = await _make_repo(db_session)
325 label = await _make_label(db_session, repo_id, name="bug", color="#d73a4a")
326 response = await client.post(
327 f"/musehub/ui/beatmaker/deep-cuts/labels/{label.id}/edit",
328 json={"name": "new-name"},
329 )
330 assert response.status_code in (401, 403)
331
332
333 @pytest.mark.anyio
334 async def test_edit_label_unknown_label_404(
335 client: AsyncClient,
336 db_session: AsyncSession,
337 auth_headers: dict[str, str],
338 ) -> None:
339 """Editing a non-existent label_id → 404."""
340 await _make_repo(db_session)
341 response = await client.post(
342 "/musehub/ui/beatmaker/deep-cuts/labels/00000000-0000-0000-0000-000000000000/edit",
343 json={"name": "x"},
344 headers=auth_headers,
345 )
346 assert response.status_code == 404
347
348
349 @pytest.mark.anyio
350 async def test_edit_label_name_conflict_409(
351 client: AsyncClient,
352 db_session: AsyncSession,
353 auth_headers: dict[str, str],
354 ) -> None:
355 """Renaming a label to an already-existing name in the same repo → 409."""
356 repo_id = await _make_repo(db_session)
357 await _make_label(db_session, repo_id, name="bug", color="#d73a4a")
358 label_b = await _make_label(db_session, repo_id, name="enhancement", color="#a2eeef")
359 response = await client.post(
360 f"/musehub/ui/beatmaker/deep-cuts/labels/{label_b.id}/edit",
361 json={"name": "bug"},
362 headers=auth_headers,
363 )
364 assert response.status_code == 409
365
366
367 @pytest.mark.anyio
368 async def test_edit_label_partial_update(
369 client: AsyncClient,
370 db_session: AsyncSession,
371 auth_headers: dict[str, str],
372 ) -> None:
373 """Sending only 'color' in the body preserves the existing name."""
374 repo_id = await _make_repo(db_session)
375 label = await _make_label(db_session, repo_id, name="bug", color="#d73a4a")
376 response = await client.post(
377 f"/musehub/ui/beatmaker/deep-cuts/labels/{label.id}/edit",
378 json={"color": "#ff6600"},
379 headers=auth_headers,
380 )
381 assert response.status_code == 200
382 data = response.json()
383 assert data["ok"] is True
384 # Name should remain "bug" — verified by the message containing the name
385 assert "bug" in data["message"]
386
387
388 # ---------------------------------------------------------------------------
389 # POST /musehub/ui/{owner}/{repo_slug}/labels/{label_id}/delete
390 # ---------------------------------------------------------------------------
391
392
393 @pytest.mark.anyio
394 async def test_delete_label_success(
395 client: AsyncClient,
396 db_session: AsyncSession,
397 auth_headers: dict[str, str],
398 ) -> None:
399 """POST /labels/{label_id}/delete removes the label and returns ok=True."""
400 repo_id = await _make_repo(db_session)
401 label = await _make_label(db_session, repo_id, name="bug", color="#d73a4a")
402 response = await client.post(
403 f"/musehub/ui/beatmaker/deep-cuts/labels/{label.id}/delete",
404 headers=auth_headers,
405 )
406 assert response.status_code == 200
407 data = response.json()
408 assert data["ok"] is True
409
410
411 @pytest.mark.anyio
412 async def test_delete_label_requires_auth(
413 client: AsyncClient, db_session: AsyncSession
414 ) -> None:
415 """POST /labels/{label_id}/delete without JWT → 401 or 403."""
416 repo_id = await _make_repo(db_session)
417 label = await _make_label(db_session, repo_id, name="bug", color="#d73a4a")
418 response = await client.post(
419 f"/musehub/ui/beatmaker/deep-cuts/labels/{label.id}/delete",
420 )
421 assert response.status_code in (401, 403)
422
423
424 @pytest.mark.anyio
425 async def test_delete_label_unknown_label_404(
426 client: AsyncClient,
427 db_session: AsyncSession,
428 auth_headers: dict[str, str],
429 ) -> None:
430 """Deleting a non-existent label_id → 404."""
431 await _make_repo(db_session)
432 response = await client.post(
433 "/musehub/ui/beatmaker/deep-cuts/labels/00000000-0000-0000-0000-000000000000/delete",
434 headers=auth_headers,
435 )
436 assert response.status_code == 404
437
438
439 # ---------------------------------------------------------------------------
440 # POST /musehub/ui/{owner}/{repo_slug}/labels/reset
441 # ---------------------------------------------------------------------------
442
443
444 @pytest.mark.anyio
445 async def test_reset_labels_success(
446 client: AsyncClient,
447 db_session: AsyncSession,
448 auth_headers: dict[str, str],
449 ) -> None:
450 """POST /labels/reset returns 200 with ok=True and seeds 10 defaults."""
451 from musehub.api.routes.musehub.labels import DEFAULT_LABELS
452
453 await _make_repo(db_session)
454 response = await client.post(
455 "/musehub/ui/beatmaker/deep-cuts/labels/reset",
456 headers=auth_headers,
457 )
458 assert response.status_code == 200
459 data = response.json()
460 assert data["ok"] is True
461 assert str(len(DEFAULT_LABELS)) in data["message"]
462
463
464 @pytest.mark.anyio
465 async def test_reset_labels_requires_auth(
466 client: AsyncClient, db_session: AsyncSession
467 ) -> None:
468 """POST /labels/reset without JWT → 401 or 403."""
469 await _make_repo(db_session)
470 response = await client.post("/musehub/ui/beatmaker/deep-cuts/labels/reset")
471 assert response.status_code in (401, 403)
472
473
474 @pytest.mark.anyio
475 async def test_reset_labels_wipes_custom_labels(
476 client: AsyncClient,
477 db_session: AsyncSession,
478 auth_headers: dict[str, str],
479 ) -> None:
480 """Reset removes all existing custom labels and replaces with defaults."""
481 from musehub.api.routes.musehub.labels import DEFAULT_LABELS
482
483 repo_id = await _make_repo(db_session)
484 # Seed a custom label that is NOT in the defaults list.
485 await _make_label(db_session, repo_id, name="my-custom-label", color="#123456")
486
487 response = await client.post(
488 "/musehub/ui/beatmaker/deep-cuts/labels/reset",
489 headers=auth_headers,
490 )
491 assert response.status_code == 200
492
493 # After reset, the JSON endpoint should return exactly the defaults.
494 list_response = await client.get(
495 "/musehub/ui/beatmaker/deep-cuts/labels?format=json"
496 )
497 assert list_response.status_code == 200
498 data = list_response.json()
499 label_names = {lbl["name"] for lbl in data["labels"]}
500 default_names = {d["name"] for d in DEFAULT_LABELS}
501 # Custom label must be gone; all defaults must be present.
502 assert "my-custom-label" not in label_names
503 assert default_names == label_names
504
505
506 @pytest.mark.anyio
507 async def test_reset_labels_unknown_repo_404(
508 client: AsyncClient,
509 db_session: AsyncSession,
510 auth_headers: dict[str, str],
511 ) -> None:
512 """POST /labels/reset on nonexistent repo → 404."""
513 response = await client.post(
514 "/musehub/ui/nobody/ghost-repo/labels/reset",
515 headers=auth_headers,
516 )
517 assert response.status_code == 404