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