gabriel / musehub public
test_musehub_releases.py python
1200 lines 39.7 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for Muse Hub release management endpoints.
2
3 Covers every acceptance criterion:
4 - POST /musehub/repos/{repo_id}/releases creates a release tied to a tag
5 - GET /musehub/repos/{repo_id}/releases lists all releases (newest first)
6 - GET /musehub/repos/{repo_id}/releases/{tag} returns release detail with download URLs
7 - Duplicate tag within the same repo returns 409 Conflict
8 - All endpoints require valid JWT (401 without token)
9 - Service layer: create_release, list_releases, get_release_by_tag, get_latest_release
10
11 Covers (asset management and download stats):
12 - GET /repos/{repo_id}/releases/{tag}/downloads — download count per asset
13 - POST /repos/{repo_id}/releases/{tag}/assets — attach asset to release
14 - DELETE /repos/{repo_id}/releases/{tag}/assets/{asset_id} — remove asset
15 - Service layer: attach_asset, get_asset, remove_asset, get_download_stats
16
17 All tests use the shared ``client``, ``auth_headers``, and ``db_session``
18 fixtures from conftest.py.
19 """
20 from __future__ import annotations
21
22 import pytest
23 from httpx import AsyncClient
24 from sqlalchemy.ext.asyncio import AsyncSession
25
26 from musehub.services import musehub_releases, musehub_repository
27 from musehub.services.musehub_release_packager import (
28 build_download_urls,
29 build_empty_download_urls,
30 )
31
32
33 # ---------------------------------------------------------------------------
34 # Helpers
35 # ---------------------------------------------------------------------------
36
37
38 async def _create_repo(
39 client: AsyncClient,
40 auth_headers: dict[str, str],
41 name: str = "release-test-repo",
42 ) -> str:
43 """Create a repo via the API and return its repo_id."""
44 response = await client.post(
45 "/api/v1/musehub/repos",
46 json={"name": name, "owner": "testuser"},
47 headers=auth_headers,
48 )
49 assert response.status_code == 201
50 repo_id: str = response.json()["repoId"]
51 return repo_id
52
53
54 async def _create_release(
55 client: AsyncClient,
56 auth_headers: dict[str, str],
57 repo_id: str,
58 tag: str = "v1.0",
59 title: str = "First Release",
60 body: str = "# Release notes\n\nInitial release.",
61 commit_id: str | None = None,
62 ) -> dict[str, object]:
63 """Create a release via the API and return the response body."""
64 payload: dict[str, object] = {"tag": tag, "title": title, "body": body}
65 if commit_id is not None:
66 payload["commitId"] = commit_id
67 response = await client.post(
68 f"/api/v1/musehub/repos/{repo_id}/releases",
69 json=payload,
70 headers=auth_headers,
71 )
72 assert response.status_code == 201, response.text
73 result: dict[str, object] = response.json()
74 return result
75
76
77 # ---------------------------------------------------------------------------
78 # POST /musehub/repos/{repo_id}/releases
79 # ---------------------------------------------------------------------------
80
81
82 @pytest.mark.anyio
83 async def test_create_release_returns_all_fields(
84 client: AsyncClient,
85 auth_headers: dict[str, str],
86 ) -> None:
87 """POST /releases creates a release and returns all required fields."""
88 repo_id = await _create_repo(client, auth_headers, "create-release-repo")
89 response = await client.post(
90 f"/api/v1/musehub/repos/{repo_id}/releases",
91 json={
92 "tag": "v1.0",
93 "title": "First Release",
94 "body": "## Release Notes\n\nInitial composition released.",
95 },
96 headers=auth_headers,
97 )
98 assert response.status_code == 201
99 body = response.json()
100 assert body["tag"] == "v1.0"
101 assert body["title"] == "First Release"
102 assert "body" in body
103 assert "releaseId" in body
104 assert "createdAt" in body
105 assert "downloadUrls" in body
106
107
108 @pytest.mark.anyio
109 async def test_create_release_with_commit_id(
110 client: AsyncClient,
111 auth_headers: dict[str, str],
112 ) -> None:
113 """POST /releases with a commitId stores the commit reference."""
114 repo_id = await _create_repo(client, auth_headers, "release-commit-repo")
115 commit_sha = "abc123def456abc123def456abc123def456abc1"
116 response = await client.post(
117 f"/api/v1/musehub/repos/{repo_id}/releases",
118 json={"tag": "v2.0", "title": "Tagged Release", "commitId": commit_sha},
119 headers=auth_headers,
120 )
121 assert response.status_code == 201
122 assert response.json()["commitId"] == commit_sha
123
124
125 @pytest.mark.anyio
126 async def test_create_release_duplicate_tag_returns_409(
127 client: AsyncClient,
128 auth_headers: dict[str, str],
129 ) -> None:
130 """POST /releases with a duplicate tag for the same repo returns 409 Conflict."""
131 repo_id = await _create_repo(client, auth_headers, "dup-tag-repo")
132 await _create_release(client, auth_headers, repo_id, tag="v1.0")
133
134 response = await client.post(
135 f"/api/v1/musehub/repos/{repo_id}/releases",
136 json={"tag": "v1.0", "title": "Duplicate", "body": ""},
137 headers=auth_headers,
138 )
139 assert response.status_code == 409
140
141
142 @pytest.mark.anyio
143 async def test_create_release_same_tag_different_repos_ok(
144 client: AsyncClient,
145 auth_headers: dict[str, str],
146 ) -> None:
147 """The same tag can be used in different repos without conflict."""
148 repo_a = await _create_repo(client, auth_headers, "tag-repo-a")
149 repo_b = await _create_repo(client, auth_headers, "tag-repo-b")
150
151 await _create_release(client, auth_headers, repo_a, tag="v1.0", title="A v1.0")
152 # Creating the same tag in a different repo must succeed.
153 response = await client.post(
154 f"/api/v1/musehub/repos/{repo_b}/releases",
155 json={"tag": "v1.0", "title": "B v1.0"},
156 headers=auth_headers,
157 )
158 assert response.status_code == 201
159
160
161 @pytest.mark.anyio
162 async def test_create_release_repo_not_found_returns_404(
163 client: AsyncClient,
164 auth_headers: dict[str, str],
165 ) -> None:
166 """POST /releases returns 404 when the repo does not exist."""
167 response = await client.post(
168 "/api/v1/musehub/repos/nonexistent-repo-id/releases",
169 json={"tag": "v1.0", "title": "Ghost release"},
170 headers=auth_headers,
171 )
172 assert response.status_code == 404
173
174
175 # ---------------------------------------------------------------------------
176 # GET /musehub/repos/{repo_id}/releases
177 # ---------------------------------------------------------------------------
178
179
180 @pytest.mark.anyio
181 async def test_list_releases_empty_repo(
182 client: AsyncClient,
183 auth_headers: dict[str, str],
184 ) -> None:
185 """GET /releases returns an empty list for a repo with no releases."""
186 repo_id = await _create_repo(client, auth_headers, "empty-releases-repo")
187 response = await client.get(
188 f"/api/v1/musehub/repos/{repo_id}/releases",
189 headers=auth_headers,
190 )
191 assert response.status_code == 200
192 assert response.json()["releases"] == []
193
194
195 @pytest.mark.anyio
196 async def test_list_releases_ordered_newest_first(
197 client: AsyncClient,
198 auth_headers: dict[str, str],
199 ) -> None:
200 """GET /releases returns releases ordered newest first."""
201 repo_id = await _create_repo(client, auth_headers, "ordered-releases-repo")
202 await _create_release(client, auth_headers, repo_id, tag="v1.0", title="First")
203 await _create_release(client, auth_headers, repo_id, tag="v2.0", title="Second")
204 await _create_release(client, auth_headers, repo_id, tag="v3.0", title="Third")
205
206 response = await client.get(
207 f"/api/v1/musehub/repos/{repo_id}/releases",
208 headers=auth_headers,
209 )
210 assert response.status_code == 200
211 releases = response.json()["releases"]
212 assert len(releases) == 3
213 # Newest created last → appears first in the response.
214 tags = [r["tag"] for r in releases]
215 assert tags[0] == "v3.0"
216 assert tags[-1] == "v1.0"
217
218
219 @pytest.mark.anyio
220 async def test_list_releases_repo_not_found_returns_404(
221 client: AsyncClient,
222 auth_headers: dict[str, str],
223 ) -> None:
224 """GET /releases returns 404 when the repo does not exist."""
225 response = await client.get(
226 "/api/v1/musehub/repos/ghost-repo/releases",
227 headers=auth_headers,
228 )
229 assert response.status_code == 404
230
231
232 # ---------------------------------------------------------------------------
233 # GET /musehub/repos/{repo_id}/releases/{tag}
234 # ---------------------------------------------------------------------------
235
236
237 @pytest.mark.anyio
238 async def test_release_detail_includes_download_urls(
239 client: AsyncClient,
240 auth_headers: dict[str, str],
241 ) -> None:
242 """GET /releases/{tag} returns a release with a downloadUrls structure."""
243 repo_id = await _create_repo(client, auth_headers, "detail-url-repo")
244 await _create_release(client, auth_headers, repo_id, tag="v1.0")
245
246 response = await client.get(
247 f"/api/v1/musehub/repos/{repo_id}/releases/v1.0",
248 headers=auth_headers,
249 )
250 assert response.status_code == 200
251 body = response.json()
252 assert body["tag"] == "v1.0"
253 assert "downloadUrls" in body
254 urls = body["downloadUrls"]
255 # A freshly created release with no objects has no download URLs.
256 assert "midiBubdle" not in urls or urls.get("midiBundle") is None
257 assert "stems" in urls
258 assert "mp3" in urls
259 assert "musicxml" in urls
260 assert "metadata" in urls
261
262
263 @pytest.mark.anyio
264 async def test_release_detail_tag_not_found_returns_404(
265 client: AsyncClient,
266 auth_headers: dict[str, str],
267 ) -> None:
268 """GET /releases/{tag} returns 404 when the tag does not exist."""
269 repo_id = await _create_repo(client, auth_headers, "tag-404-repo")
270 response = await client.get(
271 f"/api/v1/musehub/repos/{repo_id}/releases/nonexistent-tag",
272 headers=auth_headers,
273 )
274 assert response.status_code == 404
275
276
277 @pytest.mark.anyio
278 async def test_release_detail_body_preserved(
279 client: AsyncClient,
280 auth_headers: dict[str, str],
281 ) -> None:
282 """GET /releases/{tag} returns the full release notes body."""
283 repo_id = await _create_repo(client, auth_headers, "body-preserve-repo")
284 notes = "# v1.0 Release\n\n- Added bass groove\n- Fixed timing drift in measure 4"
285 await _create_release(
286 client, auth_headers, repo_id, tag="v1.0", title="Groovy Release", body=notes
287 )
288
289 response = await client.get(
290 f"/api/v1/musehub/repos/{repo_id}/releases/v1.0",
291 headers=auth_headers,
292 )
293 assert response.status_code == 200
294 assert response.json()["body"] == notes
295
296
297 # ---------------------------------------------------------------------------
298 # Auth guard
299 # ---------------------------------------------------------------------------
300
301
302 @pytest.mark.anyio
303 async def test_release_write_requires_auth(client: AsyncClient) -> None:
304 """POST release endpoint returns 401 without a Bearer token (always requires auth)."""
305 response = await client.post("/api/v1/musehub/repos/some-repo/releases", json={})
306 assert response.status_code == 401, "POST /releases should require auth"
307
308
309 @pytest.mark.anyio
310 async def test_release_read_endpoints_return_404_for_nonexistent_repo_without_auth(
311 client: AsyncClient,
312 ) -> None:
313 """GET release endpoints return 404 for non-existent repos without a token.
314
315 Read endpoints use optional_token — auth is visibility-based; missing repo → 404.
316 """
317 read_endpoints = [
318 "/api/v1/musehub/repos/non-existent-repo/releases",
319 "/api/v1/musehub/repos/non-existent-repo/releases/v1.0",
320 ]
321 for url in read_endpoints:
322 response = await client.get(url)
323 assert response.status_code == 404, f"GET {url} should return 404 for non-existent repo"
324
325
326 # ---------------------------------------------------------------------------
327 # Service layer — direct DB tests (no HTTP)
328 # ---------------------------------------------------------------------------
329
330
331 @pytest.mark.anyio
332 async def test_create_release_service_persists_to_db(db_session: AsyncSession) -> None:
333 """musehub_releases.create_release() persists the row and all fields are correct."""
334 repo = await musehub_repository.create_repo(
335 db_session,
336 name="service-release-repo",
337 owner="testuser",
338 visibility="private",
339 owner_user_id="user-001",
340 )
341 await db_session.commit()
342
343 release = await musehub_releases.create_release(
344 db_session,
345 repo_id=repo.repo_id,
346 tag="v1.0",
347 title="First Release",
348 body="Initial cut of the jazz arrangement.",
349 commit_id=None,
350 )
351 await db_session.commit()
352
353 fetched = await musehub_releases.get_release_by_tag(db_session, repo.repo_id, "v1.0")
354 assert fetched is not None
355 assert fetched.release_id == release.release_id
356 assert fetched.tag == "v1.0"
357 assert fetched.title == "First Release"
358 assert fetched.commit_id is None
359
360
361 @pytest.mark.anyio
362 async def test_create_release_duplicate_tag_raises_value_error(
363 db_session: AsyncSession,
364 ) -> None:
365 """create_release() raises ValueError on duplicate tag within the same repo."""
366 repo = await musehub_repository.create_repo(
367 db_session,
368 name="dup-tag-svc-repo",
369 owner="testuser",
370 visibility="private",
371 owner_user_id="user-002",
372 )
373 await db_session.commit()
374
375 await musehub_releases.create_release(
376 db_session,
377 repo_id=repo.repo_id,
378 tag="v1.0",
379 title="Original",
380 body="",
381 commit_id=None,
382 )
383 await db_session.commit()
384
385 with pytest.raises(ValueError, match="v1.0"):
386 await musehub_releases.create_release(
387 db_session,
388 repo_id=repo.repo_id,
389 tag="v1.0",
390 title="Duplicate",
391 body="",
392 commit_id=None,
393 )
394
395
396 @pytest.mark.anyio
397 async def test_list_releases_newest_first_service(db_session: AsyncSession) -> None:
398 """list_releases() returns releases ordered by created_at descending."""
399 repo = await musehub_repository.create_repo(
400 db_session,
401 name="list-svc-repo",
402 owner="testuser",
403 visibility="private",
404 owner_user_id="user-003",
405 )
406 await db_session.commit()
407
408 r1 = await musehub_releases.create_release(
409 db_session, repo_id=repo.repo_id, tag="v1.0", title="One", body="", commit_id=None
410 )
411 await db_session.commit()
412 r2 = await musehub_releases.create_release(
413 db_session, repo_id=repo.repo_id, tag="v2.0", title="Two", body="", commit_id=None
414 )
415 await db_session.commit()
416
417 result = await musehub_releases.list_releases(db_session, repo.repo_id)
418 assert len(result) == 2
419 # Newest first
420 assert result[0].release_id == r2.release_id
421 assert result[1].release_id == r1.release_id
422
423
424 @pytest.mark.anyio
425 async def test_get_latest_release_returns_newest(db_session: AsyncSession) -> None:
426 """get_latest_release() returns the most recently created release."""
427 repo = await musehub_repository.create_repo(
428 db_session,
429 name="latest-svc-repo",
430 owner="testuser",
431 visibility="private",
432 owner_user_id="user-004",
433 )
434 await db_session.commit()
435
436 await musehub_releases.create_release(
437 db_session, repo_id=repo.repo_id, tag="v1.0", title="Old", body="", commit_id=None
438 )
439 await db_session.commit()
440 r2 = await musehub_releases.create_release(
441 db_session, repo_id=repo.repo_id, tag="v2.0", title="Latest", body="", commit_id=None
442 )
443 await db_session.commit()
444
445 latest = await musehub_releases.get_latest_release(db_session, repo.repo_id)
446 assert latest is not None
447 assert latest.release_id == r2.release_id
448 assert latest.tag == "v2.0"
449
450
451 @pytest.mark.anyio
452 async def test_get_latest_release_empty_repo_returns_none(
453 db_session: AsyncSession,
454 ) -> None:
455 """get_latest_release() returns None when no releases exist for the repo."""
456 repo = await musehub_repository.create_repo(
457 db_session,
458 name="no-releases-repo",
459 owner="testuser",
460 visibility="private",
461 owner_user_id="user-005",
462 )
463 await db_session.commit()
464
465 latest = await musehub_releases.get_latest_release(db_session, repo.repo_id)
466 assert latest is None
467
468
469 # ---------------------------------------------------------------------------
470 # Release packager unit tests
471 # ---------------------------------------------------------------------------
472
473
474 def test_build_download_urls_all_packages_available() -> None:
475 """build_download_urls() returns URLs for every package type when all flags are set."""
476 urls = build_download_urls(
477 "repo-abc",
478 "release-xyz",
479 has_midi=True,
480 has_stems=True,
481 has_mp3=True,
482 has_musicxml=True,
483 )
484 assert urls.midi_bundle is not None
485 assert "midi" in urls.midi_bundle
486 assert urls.stems is not None
487 assert "stems" in urls.stems
488 assert urls.mp3 is not None
489 assert "mp3" in urls.mp3
490 assert urls.musicxml is not None
491 assert "musicxml" in urls.musicxml
492 assert urls.metadata is not None
493 assert "metadata" in urls.metadata
494
495
496 def test_build_download_urls_partial_packages() -> None:
497 """build_download_urls() only sets URLs for enabled packages."""
498 urls = build_download_urls("repo-abc", "release-xyz", has_midi=True)
499 assert urls.midi_bundle is not None
500 assert urls.stems is None
501 assert urls.mp3 is None
502 assert urls.musicxml is None
503 # Metadata is available when any package is available.
504 assert urls.metadata is not None
505
506
507 def test_build_empty_download_urls_all_none() -> None:
508 """build_empty_download_urls() returns a model with all fields set to None."""
509 urls = build_empty_download_urls()
510 assert urls.midi_bundle is None
511 assert urls.stems is None
512 assert urls.mp3 is None
513 assert urls.musicxml is None
514 assert urls.metadata is None
515
516
517 def test_build_download_urls_no_packages() -> None:
518 """build_download_urls() with no flags set returns None for all fields including metadata."""
519 urls = build_download_urls("repo-abc", "release-xyz")
520 assert urls.midi_bundle is None
521 assert urls.stems is None
522 assert urls.mp3 is None
523 assert urls.musicxml is None
524 assert urls.metadata is None
525
526
527 # ---------------------------------------------------------------------------
528 # Regression tests — author field on Release
529 # ---------------------------------------------------------------------------
530
531
532 @pytest.mark.anyio
533 async def test_create_release_author_in_response(
534 client: AsyncClient,
535 auth_headers: dict[str, str],
536 ) -> None:
537 """POST /releases response includes the author field (JWT sub) — regression f."""
538 repo_id = await _create_repo(client, auth_headers, "author-release-repo")
539 response = await client.post(
540 f"/api/v1/musehub/repos/{repo_id}/releases",
541 json={"tag": "v1.0", "title": "Author Field Test", "body": ""},
542 headers=auth_headers,
543 )
544 assert response.status_code == 201
545 body = response.json()
546 assert "author" in body
547 assert isinstance(body["author"], str)
548
549
550 @pytest.mark.anyio
551 async def test_create_release_author_persisted_in_list(
552 client: AsyncClient,
553 auth_headers: dict[str, str],
554 ) -> None:
555 """Author field is persisted and returned in the release list endpoint — regression f."""
556 repo_id = await _create_repo(client, auth_headers, "author-release-list-repo")
557 await client.post(
558 f"/api/v1/musehub/repos/{repo_id}/releases",
559 json={"tag": "v0.1", "title": "Listed Release", "body": ""},
560 headers=auth_headers,
561 )
562 list_response = await client.get(
563 f"/api/v1/musehub/repos/{repo_id}/releases",
564 headers=auth_headers,
565 )
566 assert list_response.status_code == 200
567 releases = list_response.json()["releases"]
568 assert len(releases) == 1
569 assert "author" in releases[0]
570 assert isinstance(releases[0]["author"], str)
571
572
573 # ---------------------------------------------------------------------------
574 # Issue #421 — Asset management and download stats
575 # ---------------------------------------------------------------------------
576
577 # ── Helper ────────────────────────────────────────────────────────────────────
578
579
580 async def _attach_asset(
581 client: AsyncClient,
582 auth_headers: dict[str, str],
583 repo_id: str,
584 tag: str,
585 name: str = "track.mid",
586 label: str = "MIDI Bundle",
587 download_url: str = "https://cdn.example.com/track.mid",
588 ) -> dict[str, object]:
589 """Attach an asset to a release via the API and return the response body."""
590 response = await client.post(
591 f"/api/v1/musehub/repos/{repo_id}/releases/{tag}/assets",
592 json={
593 "name": name,
594 "label": label,
595 "contentType": "audio/midi",
596 "size": 1024,
597 "downloadUrl": download_url,
598 },
599 headers=auth_headers,
600 )
601 assert response.status_code == 201, response.text
602 result: dict[str, object] = response.json()
603 return result
604
605
606 # ── POST /releases/{tag}/assets ───────────────────────────────────────────────
607
608
609 @pytest.mark.anyio
610 async def test_attach_asset_returns_all_fields(
611 client: AsyncClient,
612 auth_headers: dict[str, str],
613 ) -> None:
614 """POST /assets creates an asset and returns all required fields."""
615 repo_id = await _create_repo(client, auth_headers, "attach-asset-repo")
616 await _create_release(client, auth_headers, repo_id, tag="v1.0")
617
618 asset = await _attach_asset(client, auth_headers, repo_id, "v1.0")
619
620 assert "assetId" in asset
621 assert "releaseId" in asset
622 assert asset["name"] == "track.mid"
623 assert asset["label"] == "MIDI Bundle"
624 assert asset["downloadCount"] == 0
625 assert "createdAt" in asset
626
627
628 @pytest.mark.anyio
629 async def test_attach_asset_release_not_found_returns_404(
630 client: AsyncClient,
631 auth_headers: dict[str, str],
632 ) -> None:
633 """POST /assets returns 404 when the release tag does not exist."""
634 repo_id = await _create_repo(client, auth_headers, "attach-asset-404-repo")
635 response = await client.post(
636 f"/api/v1/musehub/repos/{repo_id}/releases/nonexistent-tag/assets",
637 json={"name": "file.mid", "downloadUrl": "https://cdn.example.com/file.mid"},
638 headers=auth_headers,
639 )
640 assert response.status_code == 404
641
642
643 @pytest.mark.anyio
644 async def test_attach_asset_repo_not_found_returns_404(
645 client: AsyncClient,
646 auth_headers: dict[str, str],
647 ) -> None:
648 """POST /assets returns 404 when the repo does not exist."""
649 response = await client.post(
650 "/api/v1/musehub/repos/ghost-repo/releases/v1.0/assets",
651 json={"name": "file.mid", "downloadUrl": "https://cdn.example.com/file.mid"},
652 headers=auth_headers,
653 )
654 assert response.status_code == 404
655
656
657 @pytest.mark.anyio
658 async def test_attach_asset_requires_auth(client: AsyncClient) -> None:
659 """POST /assets returns 401 without a Bearer token."""
660 response = await client.post(
661 "/api/v1/musehub/repos/some-repo/releases/v1.0/assets",
662 json={"name": "file.mid", "downloadUrl": "https://cdn.example.com/file.mid"},
663 )
664 assert response.status_code == 401
665
666
667 # ── DELETE /releases/{tag}/assets/{asset_id} ─────────────────────────────────
668
669
670 @pytest.mark.anyio
671 async def test_delete_asset_removes_from_release(
672 client: AsyncClient,
673 auth_headers: dict[str, str],
674 ) -> None:
675 """DELETE /assets/{asset_id} removes the asset and subsequent download stats show 0 assets."""
676 repo_id = await _create_repo(client, auth_headers, "delete-asset-repo")
677 await _create_release(client, auth_headers, repo_id, tag="v1.0")
678 asset = await _attach_asset(client, auth_headers, repo_id, "v1.0")
679 asset_id = asset["assetId"]
680
681 response = await client.delete(
682 f"/api/v1/musehub/repos/{repo_id}/releases/v1.0/assets/{asset_id}",
683 headers=auth_headers,
684 )
685 assert response.status_code == 204
686
687 # Confirm asset is gone from download stats.
688 stats_response = await client.get(
689 f"/api/v1/musehub/repos/{repo_id}/releases/v1.0/downloads",
690 headers=auth_headers,
691 )
692 assert stats_response.status_code == 200
693 assert stats_response.json()["assets"] == []
694
695
696 @pytest.mark.anyio
697 async def test_delete_asset_not_found_returns_404(
698 client: AsyncClient,
699 auth_headers: dict[str, str],
700 ) -> None:
701 """DELETE /assets/{asset_id} returns 404 when the asset_id does not exist."""
702 repo_id = await _create_repo(client, auth_headers, "delete-asset-404-repo")
703 await _create_release(client, auth_headers, repo_id, tag="v1.0")
704
705 response = await client.delete(
706 f"/api/v1/musehub/repos/{repo_id}/releases/v1.0/assets/nonexistent-id",
707 headers=auth_headers,
708 )
709 assert response.status_code == 404
710
711
712 @pytest.mark.anyio
713 async def test_delete_asset_wrong_release_returns_404(
714 client: AsyncClient,
715 auth_headers: dict[str, str],
716 ) -> None:
717 """DELETE /assets/{asset_id} returns 404 when the asset belongs to a different release."""
718 repo_id = await _create_repo(client, auth_headers, "delete-asset-wrong-rel-repo")
719 await _create_release(client, auth_headers, repo_id, tag="v1.0")
720 await _create_release(client, auth_headers, repo_id, tag="v2.0")
721
722 # Attach to v1.0 but try to delete from v2.0.
723 asset = await _attach_asset(client, auth_headers, repo_id, "v1.0")
724 asset_id = asset["assetId"]
725
726 response = await client.delete(
727 f"/api/v1/musehub/repos/{repo_id}/releases/v2.0/assets/{asset_id}",
728 headers=auth_headers,
729 )
730 assert response.status_code == 404
731
732
733 @pytest.mark.anyio
734 async def test_delete_asset_requires_auth(client: AsyncClient) -> None:
735 """DELETE /assets/{asset_id} returns 401 without a Bearer token."""
736 response = await client.delete(
737 "/api/v1/musehub/repos/repo/releases/v1.0/assets/some-asset-id"
738 )
739 assert response.status_code == 401
740
741
742 # ── GET /releases/{tag}/downloads ────────────────────────────────────────────
743
744
745 @pytest.mark.anyio
746 async def test_download_stats_empty_when_no_assets(
747 client: AsyncClient,
748 auth_headers: dict[str, str],
749 ) -> None:
750 """GET /downloads returns zero assets and total when no assets have been attached."""
751 repo_id = await _create_repo(client, auth_headers, "dl-stats-empty-repo")
752 await _create_release(client, auth_headers, repo_id, tag="v1.0")
753
754 response = await client.get(
755 f"/api/v1/musehub/repos/{repo_id}/releases/v1.0/downloads",
756 headers=auth_headers,
757 )
758 assert response.status_code == 200
759 body = response.json()
760 assert body["assets"] == []
761 assert body["totalDownloads"] == 0
762 assert "releaseId" in body
763 assert body["tag"] == "v1.0"
764
765
766 @pytest.mark.anyio
767 async def test_download_stats_lists_assets_with_counts(
768 client: AsyncClient,
769 auth_headers: dict[str, str],
770 ) -> None:
771 """GET /downloads returns one entry per attached asset with its download count."""
772 repo_id = await _create_repo(client, auth_headers, "dl-stats-populated-repo")
773 await _create_release(client, auth_headers, repo_id, tag="v1.0")
774
775 await _attach_asset(
776 client, auth_headers, repo_id, "v1.0",
777 name="track.mid", label="MIDI Bundle", download_url="https://cdn.example.com/track.mid"
778 )
779 await _attach_asset(
780 client, auth_headers, repo_id, "v1.0",
781 name="stems.zip", label="Stems", download_url="https://cdn.example.com/stems.zip"
782 )
783
784 response = await client.get(
785 f"/api/v1/musehub/repos/{repo_id}/releases/v1.0/downloads",
786 headers=auth_headers,
787 )
788 assert response.status_code == 200
789 body = response.json()
790 assert len(body["assets"]) == 2
791 # Fresh assets always start at zero.
792 assert all(a["downloadCount"] == 0 for a in body["assets"])
793 assert body["totalDownloads"] == 0
794 names = {a["name"] for a in body["assets"]}
795 assert names == {"track.mid", "stems.zip"}
796
797
798 @pytest.mark.anyio
799 async def test_download_stats_release_not_found_returns_404(
800 client: AsyncClient,
801 auth_headers: dict[str, str],
802 ) -> None:
803 """GET /downloads returns 404 when the release tag does not exist."""
804 repo_id = await _create_repo(client, auth_headers, "dl-stats-404-repo")
805 response = await client.get(
806 f"/api/v1/musehub/repos/{repo_id}/releases/nonexistent-tag/downloads",
807 headers=auth_headers,
808 )
809 assert response.status_code == 404
810
811
812 # ── Service layer — direct DB tests ──────────────────────────────────────────
813
814
815 @pytest.mark.anyio
816 async def test_attach_asset_service_persists_to_db(db_session: AsyncSession) -> None:
817 """attach_asset() persists a row and all fields are correct."""
818 repo = await musehub_repository.create_repo(
819 db_session,
820 name="asset-svc-repo",
821 owner="testuser",
822 visibility="private",
823 owner_user_id="user-101",
824 )
825 await db_session.commit()
826
827 release = await musehub_releases.create_release(
828 db_session,
829 repo_id=repo.repo_id,
830 tag="v1.0",
831 title="Asset Test Release",
832 body="",
833 commit_id=None,
834 )
835 await db_session.commit()
836
837 asset = await musehub_releases.attach_asset(
838 db_session,
839 release_id=release.release_id,
840 repo_id=repo.repo_id,
841 name="bass.mid",
842 label="Bass MIDI",
843 content_type="audio/midi",
844 size=2048,
845 download_url="https://cdn.example.com/bass.mid",
846 )
847 await db_session.commit()
848
849 assert asset.asset_id
850 assert asset.release_id == release.release_id
851 assert asset.name == "bass.mid"
852 assert asset.download_count == 0
853
854
855 @pytest.mark.anyio
856 async def test_remove_asset_service_deletes_row(db_session: AsyncSession) -> None:
857 """remove_asset() deletes the row and returns True; subsequent get returns None."""
858 repo = await musehub_repository.create_repo(
859 db_session,
860 name="remove-asset-svc-repo",
861 owner="testuser",
862 visibility="private",
863 owner_user_id="user-102",
864 )
865 await db_session.commit()
866
867 release = await musehub_releases.create_release(
868 db_session,
869 repo_id=repo.repo_id,
870 tag="v1.0",
871 title="Remove Asset Test",
872 body="",
873 commit_id=None,
874 )
875 await db_session.commit()
876
877 asset = await musehub_releases.attach_asset(
878 db_session,
879 release_id=release.release_id,
880 repo_id=repo.repo_id,
881 name="keys.mid",
882 download_url="https://cdn.example.com/keys.mid",
883 )
884 await db_session.commit()
885
886 removed = await musehub_releases.remove_asset(db_session, asset.asset_id)
887 await db_session.commit()
888 assert removed is True
889
890 gone = await musehub_releases.get_asset(db_session, asset.asset_id)
891 assert gone is None
892
893
894 @pytest.mark.anyio
895 async def test_remove_asset_nonexistent_returns_false(db_session: AsyncSession) -> None:
896 """remove_asset() returns False when the asset_id does not exist."""
897 removed = await musehub_releases.remove_asset(db_session, "no-such-asset-id")
898 assert removed is False
899
900
901 @pytest.mark.anyio
902 async def test_get_download_stats_aggregates_correctly(db_session: AsyncSession) -> None:
903 """get_download_stats() returns correct counts and total across multiple assets."""
904 repo = await musehub_repository.create_repo(
905 db_session,
906 name="dl-stats-svc-repo",
907 owner="testuser",
908 visibility="private",
909 owner_user_id="user-103",
910 )
911 await db_session.commit()
912
913 release = await musehub_releases.create_release(
914 db_session,
915 repo_id=repo.repo_id,
916 tag="v1.0",
917 title="Stats Test Release",
918 body="",
919 commit_id=None,
920 )
921 await db_session.commit()
922
923 await musehub_releases.attach_asset(
924 db_session,
925 release_id=release.release_id,
926 repo_id=repo.repo_id,
927 name="a.mid",
928 download_url="https://cdn.example.com/a.mid",
929 )
930 await musehub_releases.attach_asset(
931 db_session,
932 release_id=release.release_id,
933 repo_id=repo.repo_id,
934 name="b.zip",
935 download_url="https://cdn.example.com/b.zip",
936 )
937 await db_session.commit()
938
939 stats = await musehub_releases.get_download_stats(db_session, release.release_id, "v1.0")
940 assert stats.release_id == release.release_id
941 assert stats.tag == "v1.0"
942 assert len(stats.assets) == 2
943 assert stats.total_downloads == 0
944
945
946
947 # ── Regression tests ───────────────────────────────────────────
948 # New fields: is_prerelease, is_draft, gpg_signature, list_release_assets,
949 # increment_asset_download_count, and the GET/POST asset endpoints.
950
951
952 @pytest.mark.anyio
953 async def test_create_release_is_prerelease_flag(
954 client: AsyncClient,
955 auth_headers: dict[str, str],
956 db_session: AsyncSession,
957 ) -> None:
958 """is_prerelease is stored and returned on create."""
959 repo = await musehub_repository.create_repo(
960 db_session,
961 name="prerelease-flag-repo",
962 owner="testuser",
963 visibility="public",
964 owner_user_id="user-pr1",
965 )
966 await db_session.commit()
967
968 resp = await client.post(
969 f"/api/v1/musehub/repos/{repo.repo_id}/releases",
970 json={"tag": "v0.9-beta", "title": "Beta build", "body": "", "isPrerelease": True},
971 headers=auth_headers,
972 )
973 assert resp.status_code == 201
974 data = resp.json()
975 assert data["isPrerelease"] is True
976 assert data["isDraft"] is False
977 assert data["gpgSignature"] is None
978
979
980 @pytest.mark.anyio
981 async def test_create_release_is_draft_flag(
982 client: AsyncClient,
983 auth_headers: dict[str, str],
984 db_session: AsyncSession,
985 ) -> None:
986 """is_draft is stored and returned on create."""
987 repo = await musehub_repository.create_repo(
988 db_session,
989 name="draft-flag-repo",
990 owner="testuser",
991 visibility="public",
992 owner_user_id="user-dr1",
993 )
994 await db_session.commit()
995
996 resp = await client.post(
997 f"/api/v1/musehub/repos/{repo.repo_id}/releases",
998 json={"tag": "v1.0-draft", "title": "Draft release", "body": "", "isDraft": True},
999 headers=auth_headers,
1000 )
1001 assert resp.status_code == 201
1002 data = resp.json()
1003 assert data["isDraft"] is True
1004
1005
1006 @pytest.mark.anyio
1007 async def test_create_release_gpg_signature(
1008 client: AsyncClient,
1009 auth_headers: dict[str, str],
1010 db_session: AsyncSession,
1011 ) -> None:
1012 """gpg_signature is stored and returned when provided."""
1013 repo = await musehub_repository.create_repo(
1014 db_session,
1015 name="gpg-sig-repo",
1016 owner="testuser",
1017 visibility="public",
1018 owner_user_id="user-gpg1",
1019 )
1020 await db_session.commit()
1021
1022 sig = "-----BEGIN PGP SIGNATURE-----\nMockSignatureData==\n-----END PGP SIGNATURE-----"
1023 resp = await client.post(
1024 f"/api/v1/musehub/repos/{repo.repo_id}/releases",
1025 json={"tag": "v1.0", "title": "Signed release", "body": "", "gpgSignature": sig},
1026 headers=auth_headers,
1027 )
1028 assert resp.status_code == 201
1029 data = resp.json()
1030 assert data["gpgSignature"] == sig
1031
1032
1033 @pytest.mark.anyio
1034 async def test_create_release_defaults_for_new_fields(
1035 client: AsyncClient,
1036 auth_headers: dict[str, str],
1037 db_session: AsyncSession,
1038 ) -> None:
1039 """New optional fields default to safe values when not specified."""
1040 repo = await musehub_repository.create_repo(
1041 db_session,
1042 name="defaults-new-fields-repo",
1043 owner="testuser",
1044 visibility="public",
1045 owner_user_id="user-def1",
1046 )
1047 await db_session.commit()
1048
1049 resp = await client.post(
1050 f"/api/v1/musehub/repos/{repo.repo_id}/releases",
1051 json={"tag": "v1.0", "title": "Default fields release", "body": ""},
1052 headers=auth_headers,
1053 )
1054 assert resp.status_code == 201
1055 data = resp.json()
1056 assert data["isPrerelease"] is False
1057 assert data["isDraft"] is False
1058 assert data["gpgSignature"] is None
1059
1060
1061 @pytest.mark.anyio
1062 async def test_list_release_assets_endpoint(
1063 client: AsyncClient,
1064 auth_headers: dict[str, str],
1065 db_session: AsyncSession,
1066 ) -> None:
1067 """GET /repos/{repo_id}/releases/{tag}/assets returns attached assets."""
1068 repo = await musehub_repository.create_repo(
1069 db_session,
1070 name="list-assets-endpoint-repo",
1071 owner="testuser",
1072 visibility="public",
1073 owner_user_id="user-la1",
1074 )
1075 await db_session.commit()
1076
1077 rel = await musehub_releases.create_release(
1078 db_session,
1079 repo_id=repo.repo_id,
1080 tag="v1.0",
1081 title="Asset list test",
1082 body="",
1083 commit_id=None,
1084 )
1085 await musehub_releases.attach_asset(
1086 db_session,
1087 release_id=rel.release_id,
1088 repo_id=repo.repo_id,
1089 name="mix.mp3",
1090 label="MP3 Mix",
1091 content_type="audio/mpeg",
1092 size=4096000,
1093 download_url="https://cdn.example.com/mix.mp3",
1094 )
1095 await db_session.commit()
1096
1097 resp = await client.get(
1098 f"/api/v1/musehub/repos/{repo.repo_id}/releases/v1.0/assets"
1099 )
1100 assert resp.status_code == 200
1101 data = resp.json()
1102 assert data["tag"] == "v1.0"
1103 assert len(data["assets"]) == 1
1104 asset = data["assets"][0]
1105 assert asset["name"] == "mix.mp3"
1106 assert asset["label"] == "MP3 Mix"
1107 assert asset["size"] == 4096000
1108 assert asset["downloadCount"] == 0
1109
1110
1111 @pytest.mark.anyio
1112 async def test_record_asset_download_increments_counter(
1113 client: AsyncClient,
1114 auth_headers: dict[str, str],
1115 db_session: AsyncSession,
1116 ) -> None:
1117 """POST /repos/{repo_id}/releases/{tag}/assets/{id}/download increments download_count."""
1118 repo = await musehub_repository.create_repo(
1119 db_session,
1120 name="record-dl-endpoint-repo",
1121 owner="testuser",
1122 visibility="public",
1123 owner_user_id="user-rdl1",
1124 )
1125 await db_session.commit()
1126
1127 rel = await musehub_releases.create_release(
1128 db_session,
1129 repo_id=repo.repo_id,
1130 tag="v1.0",
1131 title="Download tracking test",
1132 body="",
1133 commit_id=None,
1134 )
1135 asset = await musehub_releases.attach_asset(
1136 db_session,
1137 release_id=rel.release_id,
1138 repo_id=repo.repo_id,
1139 name="stems.zip",
1140 download_url="https://cdn.example.com/stems.zip",
1141 )
1142 await db_session.commit()
1143
1144 resp = await client.post(
1145 f"/api/v1/musehub/repos/{repo.repo_id}/releases/v1.0/assets/{asset.asset_id}/download"
1146 )
1147 assert resp.status_code == 204
1148
1149 stats = await musehub_releases.get_download_stats(db_session, rel.release_id, "v1.0")
1150 assert stats.total_downloads == 1
1151
1152
1153 @pytest.mark.anyio
1154 async def test_increment_asset_download_count_service(
1155 db_session: AsyncSession,
1156 ) -> None:
1157 """increment_asset_download_count atomically increments the counter and returns True."""
1158 repo = await musehub_repository.create_repo(
1159 db_session,
1160 name="incr-dl-svc-repo",
1161 owner="testuser",
1162 visibility="public",
1163 owner_user_id="user-idl1",
1164 )
1165 await db_session.commit()
1166
1167 rel = await musehub_releases.create_release(
1168 db_session,
1169 repo_id=repo.repo_id,
1170 tag="v1.0",
1171 title="Increment test",
1172 body="",
1173 commit_id=None,
1174 )
1175 asset = await musehub_releases.attach_asset(
1176 db_session,
1177 release_id=rel.release_id,
1178 repo_id=repo.repo_id,
1179 name="test.mid",
1180 download_url="https://cdn.example.com/test.mid",
1181 )
1182 await db_session.commit()
1183
1184 found = await musehub_releases.increment_asset_download_count(db_session, asset.asset_id)
1185 assert found is True
1186 await db_session.commit()
1187
1188 stats = await musehub_releases.get_download_stats(db_session, rel.release_id, "v1.0")
1189 assert stats.total_downloads == 1
1190
1191
1192 @pytest.mark.anyio
1193 async def test_increment_asset_download_count_missing_asset(
1194 db_session: AsyncSession,
1195 ) -> None:
1196 """increment_asset_download_count returns False for a non-existent asset_id."""
1197 found = await musehub_releases.increment_asset_download_count(
1198 db_session, "non-existent-uuid"
1199 )
1200 assert found is False