gabriel / musehub public
test_musehub_export.py python
533 lines 19.2 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for the Muse Hub export endpoint and musehub_exporter service.
2
3 Covers every acceptance criterion:
4 - GET /musehub/repos/{repo_id}/export/{ref}?format=midi returns a .mid file
5 - GET /musehub/repos/{repo_id}/export/{ref}?format=json returns valid JSON
6 - split_tracks=true bundles artifacts into a ZIP with per-track files
7 - sections filter restricts artifacts to matching path substrings
8 - Unknown format string returns 422 Unprocessable Entity
9 - Unresolvable ref returns 404
10 - No matching artifacts for a format returns 404
11
12 All tests use the shared fixtures from conftest.py.
13 """
14 from __future__ import annotations
15
16 import base64
17 import io
18 import json
19 import tempfile
20 import zipfile
21 from pathlib import Path
22 from unittest.mock import AsyncMock, patch
23
24 import pytest
25 from httpx import AsyncClient
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from musehub.services.musehub_exporter import (
29 ExportFormat,
30 ExportResult,
31 export_repo_at_ref,
32 )
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39 _MIDI_BYTES = b"MThd\x00\x00\x00\x06\x00\x01\x00\x01\x01\xe0" # minimal valid MIDI header
40 _MP3_BYTES = b"\xff\xfb\x90\x00" + b"\x00" * 60 # minimal MP3 frame marker
41
42
43 def _b64(data: bytes) -> str:
44 return base64.b64encode(data).decode()
45
46
47 async def _create_repo(client: AsyncClient, auth_headers: dict[str, str], name: str = "export-test") -> str:
48 r = await client.post("/api/v1/musehub/repos", json={"name": name, "owner": "testuser"}, headers=auth_headers)
49 assert r.status_code == 201
50 repo_id: str = r.json()["repoId"]
51 return repo_id
52
53
54 async def _push_with_objects(
55 client: AsyncClient,
56 auth_headers: dict[str, str],
57 repo_id: str,
58 commit_id: str,
59 objects: list[dict[str, object]],
60 tmp_dir: str,
61 ) -> None:
62 """Push a commit + objects and patch musehub_sync.settings to use tmp_dir."""
63 with patch("musehub.services.musehub_sync.settings") as mock_cfg:
64 mock_cfg.musehub_objects_dir = tmp_dir
65 r = await client.post(
66 f"/api/v1/musehub/repos/{repo_id}/push",
67 json={
68 "branch": "main",
69 "headCommitId": commit_id,
70 "commits": [
71 {
72 "commitId": commit_id,
73 "parentIds": [],
74 "message": "test commit",
75 "timestamp": "2024-01-01T00:00:00Z",
76 }
77 ],
78 "objects": objects,
79 "force": False,
80 },
81 headers=auth_headers,
82 )
83 assert r.status_code == 200, r.text
84
85
86 # ---------------------------------------------------------------------------
87 # test_export_midi — MIDI export returns a .mid file
88 # ---------------------------------------------------------------------------
89
90
91 @pytest.mark.anyio
92 async def test_export_midi(
93 client: AsyncClient,
94 auth_headers: dict[str, str],
95 db_session: AsyncSession,
96 ) -> None:
97 """A single MIDI object → direct .mid download with correct MIME type."""
98 with tempfile.TemporaryDirectory() as tmp:
99 repo_id = await _create_repo(client, auth_headers, "midi-export")
100 await _push_with_objects(
101 client,
102 auth_headers,
103 repo_id,
104 commit_id="c-midi-001",
105 objects=[
106 {
107 "objectId": "sha256:midi001",
108 "path": "tracks/bass.mid",
109 "contentB64": _b64(_MIDI_BYTES),
110 }
111 ],
112 tmp_dir=tmp,
113 )
114
115 with patch("musehub.services.musehub_exporter.musehub_repository") as mock_repo:
116 from musehub.models.musehub import CommitResponse, ObjectMetaResponse
117 from datetime import datetime, timezone
118
119 mock_repo.get_commit = AsyncMock(
120 return_value=CommitResponse(
121 commit_id="c-midi-001",
122 branch="main",
123 parent_ids=[],
124 message="test",
125 author="alice",
126 timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
127 snapshot_id=None,
128 )
129 )
130 mock_repo.list_branches = AsyncMock(return_value=[])
131 meta = ObjectMetaResponse(
132 object_id="sha256:midi001",
133 path="tracks/bass.mid",
134 size_bytes=len(_MIDI_BYTES),
135 created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
136 )
137 mock_repo.list_objects = AsyncMock(return_value=[meta])
138
139 from musehub.db import musehub_models as db_models
140
141 disk_path = str(Path(tmp) / "sha256_midi001.mid")
142 Path(disk_path).write_bytes(_MIDI_BYTES)
143
144 fake_row = db_models.MusehubObject()
145 fake_row.object_id = "sha256:midi001"
146 fake_row.path = "tracks/bass.mid"
147 fake_row.disk_path = disk_path
148 fake_row.size_bytes = len(_MIDI_BYTES)
149 mock_repo.get_object_row = AsyncMock(return_value=fake_row)
150
151 r = await client.get(
152 f"/api/v1/musehub/repos/{repo_id}/export/c-midi-001?format=midi",
153 headers=auth_headers,
154 )
155
156 assert r.status_code == 200
157 assert r.headers["content-type"] == "audio/midi"
158 assert "bass.mid" in r.headers.get("content-disposition", "")
159 assert r.content == _MIDI_BYTES
160
161
162 # ---------------------------------------------------------------------------
163 # test_export_json — JSON export returns valid JSON
164 # ---------------------------------------------------------------------------
165
166
167 @pytest.mark.anyio
168 async def test_export_json(
169 client: AsyncClient,
170 auth_headers: dict[str, str],
171 db_session: AsyncSession,
172 ) -> None:
173 """format=json returns a valid JSON document with commit and object metadata."""
174 with tempfile.TemporaryDirectory() as tmp:
175 repo_id = await _create_repo(client, auth_headers, "json-export")
176
177 with patch("musehub.services.musehub_exporter.musehub_repository") as mock_repo:
178 from musehub.models.musehub import CommitResponse, ObjectMetaResponse
179 from datetime import datetime, timezone
180
181 mock_repo.get_commit = AsyncMock(
182 return_value=CommitResponse(
183 commit_id="c-json-001",
184 branch="main",
185 parent_ids=[],
186 message="json export test",
187 author="bob",
188 timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
189 snapshot_id=None,
190 )
191 )
192 mock_repo.list_branches = AsyncMock(return_value=[])
193 mock_repo.list_objects = AsyncMock(
194 return_value=[
195 ObjectMetaResponse(
196 object_id="sha256:obj001",
197 path="tracks/keys.mid",
198 size_bytes=100,
199 created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
200 )
201 ]
202 )
203
204 r = await client.get(
205 f"/api/v1/musehub/repos/{repo_id}/export/c-json-001?format=json",
206 headers=auth_headers,
207 )
208
209 assert r.status_code == 200
210 assert "application/json" in r.headers["content-type"]
211 payload = json.loads(r.content)
212 assert payload["commit_id"] == "c-json-001"
213 assert payload["repo_id"] == repo_id
214 assert len(payload["objects"]) == 1
215 assert payload["objects"][0]["path"] == "tracks/keys.mid"
216
217
218 # ---------------------------------------------------------------------------
219 # test_export_split_tracks_zip — split_tracks produces a ZIP
220 # ---------------------------------------------------------------------------
221
222
223 @pytest.mark.anyio
224 async def test_export_split_tracks_zip(
225 client: AsyncClient,
226 auth_headers: dict[str, str],
227 db_session: AsyncSession,
228 ) -> None:
229 """split_tracks=true with two MIDI objects produces a ZIP with both files."""
230 with tempfile.TemporaryDirectory() as tmp:
231 repo_id = await _create_repo(client, auth_headers, "zip-export")
232
233 bass_path = str(Path(tmp) / "bass.mid")
234 keys_path = str(Path(tmp) / "keys.mid")
235 Path(bass_path).write_bytes(_MIDI_BYTES)
236 Path(keys_path).write_bytes(_MIDI_BYTES + b"\x00")
237
238 with patch("musehub.services.musehub_exporter.musehub_repository") as mock_repo:
239 from musehub.models.musehub import CommitResponse, ObjectMetaResponse
240 from datetime import datetime, timezone
241 from musehub.db import musehub_models as db_models
242
243 mock_repo.get_commit = AsyncMock(
244 return_value=CommitResponse(
245 commit_id="c-zip-001",
246 branch="main",
247 parent_ids=[],
248 message="zip test",
249 author="carol",
250 timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
251 snapshot_id=None,
252 )
253 )
254 mock_repo.list_branches = AsyncMock(return_value=[])
255 mock_repo.list_objects = AsyncMock(
256 return_value=[
257 ObjectMetaResponse(
258 object_id="sha256:bass",
259 path="tracks/bass.mid",
260 size_bytes=len(_MIDI_BYTES),
261 created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
262 ),
263 ObjectMetaResponse(
264 object_id="sha256:keys",
265 path="tracks/keys.mid",
266 size_bytes=len(_MIDI_BYTES) + 1,
267 created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
268 ),
269 ]
270 )
271
272 def _fake_get_object_row(
273 session: object, repo_id: str, object_id: str
274 ) -> db_models.MusehubObject:
275 row = db_models.MusehubObject()
276 row.object_id = object_id
277 if object_id == "sha256:bass":
278 row.path = "tracks/bass.mid"
279 row.disk_path = bass_path
280 else:
281 row.path = "tracks/keys.mid"
282 row.disk_path = keys_path
283 row.size_bytes = 0
284 return row
285
286 mock_repo.get_object_row = AsyncMock(side_effect=_fake_get_object_row)
287
288 r = await client.get(
289 f"/api/v1/musehub/repos/{repo_id}/export/c-zip-001?format=midi&splitTracks=true",
290 headers=auth_headers,
291 )
292
293 assert r.status_code == 200
294 assert r.headers["content-type"] == "application/zip"
295 zf = zipfile.ZipFile(io.BytesIO(r.content))
296 names = zf.namelist()
297 assert "bass.mid" in names
298 assert "keys.mid" in names
299
300
301 # ---------------------------------------------------------------------------
302 # test_export_section_filter — sections param filters artifacts by path substring
303 # ---------------------------------------------------------------------------
304
305
306 @pytest.mark.anyio
307 async def test_export_section_filter(
308 client: AsyncClient,
309 auth_headers: dict[str, str],
310 db_session: AsyncSession,
311 ) -> None:
312 """sections=verse includes only artifacts whose path contains 'verse'."""
313 with tempfile.TemporaryDirectory() as tmp:
314 repo_id = await _create_repo(client, auth_headers, "section-export")
315
316 verse_path = str(Path(tmp) / "verse_bass.mid")
317 chorus_path = str(Path(tmp) / "chorus_bass.mid")
318 Path(verse_path).write_bytes(_MIDI_BYTES)
319 Path(chorus_path).write_bytes(_MIDI_BYTES + b"\x01")
320
321 with patch("musehub.services.musehub_exporter.musehub_repository") as mock_repo:
322 from musehub.models.musehub import CommitResponse, ObjectMetaResponse
323 from datetime import datetime, timezone
324 from musehub.db import musehub_models as db_models
325
326 mock_repo.get_commit = AsyncMock(
327 return_value=CommitResponse(
328 commit_id="c-sec-001",
329 branch="main",
330 parent_ids=[],
331 message="section test",
332 author="dave",
333 timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
334 snapshot_id=None,
335 )
336 )
337 mock_repo.list_branches = AsyncMock(return_value=[])
338 mock_repo.list_objects = AsyncMock(
339 return_value=[
340 ObjectMetaResponse(
341 object_id="sha256:verse",
342 path="tracks/verse_bass.mid",
343 size_bytes=len(_MIDI_BYTES),
344 created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
345 ),
346 ObjectMetaResponse(
347 object_id="sha256:chorus",
348 path="tracks/chorus_bass.mid",
349 size_bytes=len(_MIDI_BYTES) + 1,
350 created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
351 ),
352 ]
353 )
354
355 verse_row = db_models.MusehubObject()
356 verse_row.object_id = "sha256:verse"
357 verse_row.path = "tracks/verse_bass.mid"
358 verse_row.disk_path = verse_path
359 verse_row.size_bytes = len(_MIDI_BYTES)
360 mock_repo.get_object_row = AsyncMock(return_value=verse_row)
361
362 r = await client.get(
363 f"/api/v1/musehub/repos/{repo_id}/export/c-sec-001?format=midi&sections=verse",
364 headers=auth_headers,
365 )
366
367 assert r.status_code == 200
368 assert r.content == _MIDI_BYTES
369 assert "verse_bass.mid" in r.headers.get("content-disposition", "")
370
371
372 # ---------------------------------------------------------------------------
373 # test_export_unknown_format_422 — invalid format returns 422
374 # ---------------------------------------------------------------------------
375
376
377 @pytest.mark.anyio
378 async def test_export_unknown_format_422(
379 client: AsyncClient,
380 auth_headers: dict[str, str],
381 db_session: AsyncSession,
382 ) -> None:
383 """An unrecognised format query param returns HTTP 422."""
384 repo_id = await _create_repo(client, auth_headers, "bad-format")
385 r = await client.get(
386 f"/api/v1/musehub/repos/{repo_id}/export/main?format=flac",
387 headers=auth_headers,
388 )
389 assert r.status_code == 422
390
391
392 # ---------------------------------------------------------------------------
393 # test_export_ref_not_found_404 — unresolvable ref returns 404
394 # ---------------------------------------------------------------------------
395
396
397 @pytest.mark.anyio
398 async def test_export_ref_not_found_404(
399 client: AsyncClient,
400 auth_headers: dict[str, str],
401 db_session: AsyncSession,
402 ) -> None:
403 """A ref that does not match any commit or branch returns HTTP 404."""
404 repo_id = await _create_repo(client, auth_headers, "missing-ref")
405
406 with patch("musehub.services.musehub_exporter.musehub_repository") as mock_repo:
407 mock_repo.get_commit = AsyncMock(return_value=None)
408 mock_repo.list_branches = AsyncMock(return_value=[])
409
410 r = await client.get(
411 f"/api/v1/musehub/repos/{repo_id}/export/nonexistent-sha?format=midi",
412 headers=auth_headers,
413 )
414
415 assert r.status_code == 404
416 assert "nonexistent-sha" in r.json()["detail"]
417
418
419 # ---------------------------------------------------------------------------
420 # Unit tests for export_repo_at_ref service function
421 # ---------------------------------------------------------------------------
422
423
424 @pytest.mark.anyio
425 async def test_export_service_returns_ref_not_found_sentinel() -> None:
426 """export_repo_at_ref returns 'ref_not_found' when the ref resolves to nothing."""
427 from unittest.mock import MagicMock
428
429 mock_session = MagicMock()
430
431 with patch("musehub.services.musehub_exporter.musehub_repository") as mock_repo:
432 mock_repo.get_commit = AsyncMock(return_value=None)
433 mock_repo.list_branches = AsyncMock(return_value=[])
434
435 result = await export_repo_at_ref(
436 mock_session,
437 repo_id="repo-x",
438 ref="deadbeef",
439 format=ExportFormat.midi,
440 )
441
442 assert result == "ref_not_found"
443
444
445 @pytest.mark.anyio
446 async def test_export_service_returns_no_matching_objects_sentinel() -> None:
447 """export_repo_at_ref returns 'no_matching_objects' when no objects match the format."""
448 from unittest.mock import MagicMock
449 from datetime import datetime, timezone
450 from musehub.models.musehub import CommitResponse, ObjectMetaResponse
451
452 mock_session = MagicMock()
453
454 with patch("musehub.services.musehub_exporter.musehub_repository") as mock_repo:
455 mock_repo.get_commit = AsyncMock(
456 return_value=CommitResponse(
457 commit_id="abc123",
458 branch="main",
459 parent_ids=[],
460 message="x",
461 author="x",
462 timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
463 snapshot_id=None,
464 )
465 )
466 mock_repo.list_branches = AsyncMock(return_value=[])
467 mock_repo.list_objects = AsyncMock(
468 return_value=[
469 ObjectMetaResponse(
470 object_id="sha256:img",
471 path="piano_roll.webp",
472 size_bytes=512,
473 created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
474 )
475 ]
476 )
477
478 result = await export_repo_at_ref(
479 mock_session,
480 repo_id="repo-y",
481 ref="abc123",
482 format=ExportFormat.midi,
483 )
484
485 assert result == "no_matching_objects"
486
487
488 @pytest.mark.anyio
489 async def test_export_service_json_format_no_disk_access() -> None:
490 """format=json returns ExportResult with JSON bytes without reading any disk file."""
491 from unittest.mock import MagicMock
492 from datetime import datetime, timezone
493 from musehub.models.musehub import CommitResponse, ObjectMetaResponse
494
495 mock_session = MagicMock()
496
497 with patch("musehub.services.musehub_exporter.musehub_repository") as mock_repo:
498 mock_repo.get_commit = AsyncMock(
499 return_value=CommitResponse(
500 commit_id="abc999",
501 branch="dev",
502 parent_ids=[],
503 message="json only",
504 author="eve",
505 timestamp=datetime(2024, 3, 1, tzinfo=timezone.utc),
506 snapshot_id=None,
507 )
508 )
509 mock_repo.list_branches = AsyncMock(return_value=[])
510 mock_repo.list_objects = AsyncMock(
511 return_value=[
512 ObjectMetaResponse(
513 object_id="sha256:mid",
514 path="track.mid",
515 size_bytes=200,
516 created_at=datetime(2024, 3, 1, tzinfo=timezone.utc),
517 )
518 ]
519 )
520
521 result = await export_repo_at_ref(
522 mock_session,
523 repo_id="repo-z",
524 ref="abc999",
525 format=ExportFormat.json,
526 )
527
528 assert isinstance(result, ExportResult)
529 assert result.content_type == "application/json"
530 parsed = json.loads(result.content)
531 assert parsed["commit_id"] == "abc999"
532 assert parsed["repo_id"] == "repo-z"
533 assert len(parsed["objects"]) == 1