gabriel / musehub public
test_musehub_render.py python
686 lines 22.5 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for the MuseHub render pipeline.
2
3 Covers the acceptance criteria from the issue:
4 test_push_triggers_render — Push endpoint triggers render task
5 test_render_creates_mp3_objects — Render creates MP3 objects in store
6 test_render_creates_piano_roll_images — Render creates PNG objects in store
7 test_render_idempotent — Re-push does not duplicate renders
8 test_render_failure_does_not_block_push — Failed render still allows push to complete
9 test_render_status_endpoint — Render status queryable by commit SHA
10
11 Unit tests (service-level, no HTTP client):
12 test_piano_roll_render_note_events — Valid MIDI produces non-blank PNG
13 test_piano_roll_render_empty_midi — Empty/blank MIDI produces stubbed=True result
14 test_piano_roll_render_invalid_bytes — Garbage bytes produce stubbed=True result
15 test_render_pipeline_midi_filter — Only .mid/.midi paths are processed
16 test_render_status_not_found — Missing commit returns not_found status
17
18 Integration tests (HTTP client + in-memory SQLite):
19 test_render_status_endpoint_not_found — Endpoint returns not_found for unknown commit
20 test_render_status_endpoint_complete — Endpoint returns complete job
21 test_render_status_endpoint_private_repo_no_auth — Private repo returns 401
22 """
23 from __future__ import annotations
24
25 import base64
26 import io
27 import struct
28 import tempfile
29 from pathlib import Path
30 from unittest.mock import AsyncMock, MagicMock, patch
31
32 import mido
33 import pytest
34 from httpx import AsyncClient
35 from sqlalchemy.ext.asyncio import AsyncSession
36
37 from musehub.db.musehub_models import (
38 MusehubBranch,
39 MusehubCommit,
40 MusehubRenderJob,
41 MusehubRepo,
42 )
43 from musehub.models.musehub import ObjectInput
44 from musehub.services.musehub_piano_roll_renderer import (
45 PianoRollRenderResult,
46 render_piano_roll,
47 )
48 from musehub.services.musehub_render_pipeline import (
49 RenderPipelineResult,
50 _midi_filter,
51 trigger_render_background,
52 )
53
54
55 # ---------------------------------------------------------------------------
56 # Helpers — minimal MIDI construction
57 # ---------------------------------------------------------------------------
58
59
60 def _make_minimal_midi_bytes() -> bytes:
61 """Return bytes for a minimal valid Standard MIDI File with two notes.
62
63 Constructs a type-0 MIDI file using mido in-memory so tests are
64 independent of external fixtures.
65 """
66 mid = mido.MidiFile(type=0)
67 track = mido.MidiTrack()
68 mid.tracks.append(track)
69 track.append(mido.Message("note_on", note=60, velocity=80, time=0))
70 track.append(mido.Message("note_on", note=64, velocity=80, time=100))
71 track.append(mido.Message("note_off", note=60, velocity=0, time=200))
72 track.append(mido.Message("note_off", note=64, velocity=0, time=400))
73 track.append(mido.MetaMessage("end_of_track", time=0))
74
75 buf = io.BytesIO()
76 mid.save(file=buf)
77 return buf.getvalue()
78
79
80 def _make_midi_b64() -> str:
81 """Return base64-encoded minimal MIDI."""
82 return base64.b64encode(_make_minimal_midi_bytes()).decode()
83
84
85 async def _seed_repo(db_session: AsyncSession) -> tuple[str, str]:
86 """Create a minimal repo + branch and return (repo_id, branch_name)."""
87 repo = MusehubRepo(
88 name="render-test",
89 owner="renderuser",
90 slug="render-test",
91 visibility="private",
92 owner_user_id="test-owner",
93 )
94 db_session.add(repo)
95 await db_session.flush()
96
97 branch = MusehubBranch(repo_id=repo.repo_id, name="main")
98 db_session.add(branch)
99 await db_session.commit()
100
101 return str(repo.repo_id), "main"
102
103
104 async def _seed_public_repo(db_session: AsyncSession) -> tuple[str, str]:
105 """Create a minimal public repo + branch and return (repo_id, branch_name)."""
106 repo = MusehubRepo(
107 name="public-render",
108 owner="renderuser",
109 slug="public-render",
110 visibility="public",
111 owner_user_id="test-owner",
112 )
113 db_session.add(repo)
114 await db_session.flush()
115
116 branch = MusehubBranch(repo_id=repo.repo_id, name="main")
117 db_session.add(branch)
118 await db_session.commit()
119
120 return str(repo.repo_id), "main"
121
122
123 async def _seed_commit(
124 db_session: AsyncSession, repo_id: str, commit_id: str = "abc123" * 10 + "ab"
125 ) -> str:
126 """Seed a commit row and return its commit_id."""
127 from datetime import datetime, timezone
128 commit = MusehubCommit(
129 commit_id=commit_id[:64],
130 repo_id=repo_id,
131 branch="main",
132 parent_ids=[],
133 message="test commit",
134 author="tester",
135 timestamp=datetime.now(timezone.utc),
136 )
137 db_session.add(commit)
138 await db_session.commit()
139 return commit.commit_id
140
141
142 async def _seed_render_job(
143 db_session: AsyncSession,
144 repo_id: str,
145 commit_id: str,
146 status: str = "complete",
147 ) -> MusehubRenderJob:
148 """Seed a render job row and return it."""
149 job = MusehubRenderJob(
150 repo_id=repo_id,
151 commit_id=commit_id,
152 status=status,
153 midi_count=1,
154 mp3_object_ids=["sha256:mp3abc"],
155 image_object_ids=["sha256:imgxyz"],
156 )
157 db_session.add(job)
158 await db_session.commit()
159 return job
160
161
162 # ---------------------------------------------------------------------------
163 # Unit tests — musehub_piano_roll_renderer
164 # ---------------------------------------------------------------------------
165
166
167 @pytest.mark.anyio
168 async def test_piano_roll_render_note_events() -> None:
169 """Valid MIDI bytes produce a non-blank PNG with stubbed=False."""
170 midi_bytes = _make_minimal_midi_bytes()
171 with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
172 output_path = Path(f.name)
173
174 result = render_piano_roll(midi_bytes, output_path)
175
176 assert isinstance(result, PianoRollRenderResult)
177 assert result.stubbed is False
178 assert result.note_count > 0
179 assert output_path.exists()
180 # Verify PNG signature
181 png_bytes = output_path.read_bytes()
182 assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n"
183 assert len(png_bytes) > 100
184
185
186 @pytest.mark.anyio
187 async def test_piano_roll_render_empty_midi() -> None:
188 """A MIDI file with no note events produces a blank canvas (stubbed=True)."""
189 mid = mido.MidiFile(type=0)
190 track = mido.MidiTrack()
191 mid.tracks.append(track)
192 track.append(mido.MetaMessage("end_of_track", time=0))
193 buf = io.BytesIO()
194 mid.save(file=buf)
195 empty_midi = buf.getvalue()
196
197 with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
198 output_path = Path(f.name)
199
200 result = render_piano_roll(empty_midi, output_path)
201
202 assert result.stubbed is True
203 assert result.note_count == 0
204 assert output_path.exists()
205 png_bytes = output_path.read_bytes()
206 assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n"
207
208
209 @pytest.mark.anyio
210 async def test_piano_roll_render_invalid_bytes() -> None:
211 """Garbage bytes produce a blank canvas (stubbed=True) without raising."""
212 garbage = b"\x00\x01\x02\x03" * 10
213 with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
214 output_path = Path(f.name)
215
216 result = render_piano_roll(garbage, output_path)
217
218 assert result.stubbed is True
219 assert output_path.exists()
220 png_bytes = output_path.read_bytes()
221 assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n"
222
223
224 @pytest.mark.anyio
225 async def test_piano_roll_render_output_dimensions() -> None:
226 """Rendered PNG has the correct width and height."""
227 from musehub.services.musehub_piano_roll_renderer import IMAGE_HEIGHT, MAX_WIDTH_PX
228
229 midi_bytes = _make_minimal_midi_bytes()
230 with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
231 output_path = Path(f.name)
232
233 render_piano_roll(midi_bytes, output_path, target_width=MAX_WIDTH_PX)
234 png_bytes = output_path.read_bytes()
235
236 # PNG IHDR starts at byte 16 (after signature + chunk-length + "IHDR")
237 ihdr_offset = 16
238 width = struct.unpack(">I", png_bytes[ihdr_offset : ihdr_offset + 4])[0]
239 height = struct.unpack(">I", png_bytes[ihdr_offset + 4 : ihdr_offset + 8])[0]
240
241 assert width == MAX_WIDTH_PX
242 assert height == IMAGE_HEIGHT
243
244
245 # ---------------------------------------------------------------------------
246 # Unit tests — musehub_render_pipeline (service layer, no HTTP)
247 # ---------------------------------------------------------------------------
248
249
250 def test_render_pipeline_midi_filter() -> None:
251 """Only .mid and .midi paths pass the MIDI filter."""
252 assert _midi_filter("tracks/jazz.mid") is True
253 assert _midi_filter("tracks/JAZZ.MID") is True
254 assert _midi_filter("tracks/bass.midi") is True
255 assert _midi_filter("renders/output.mp3") is False
256 assert _midi_filter("images/piano_roll.png") is False
257 assert _midi_filter("session.json") is False
258
259
260 @pytest.mark.anyio
261 async def test_render_creates_mp3_objects(
262 db_session: AsyncSession,
263 ) -> None:
264 """Render pipeline writes MP3 stub objects to the DB for each MIDI file."""
265 repo_id, _ = await _seed_repo(db_session)
266 commit_id = "a" * 64
267
268 objects = [
269 ObjectInput(
270 object_id="sha256:midi001",
271 path="tracks/bass.mid",
272 content_b64=_make_midi_b64(),
273 )
274 ]
275
276 with tempfile.TemporaryDirectory() as tmpdir:
277 with patch("musehub.services.musehub_render_pipeline.settings") as mock_settings:
278 mock_settings.musehub_objects_dir = tmpdir
279 await trigger_render_background(
280 repo_id=repo_id,
281 commit_id=commit_id,
282 objects=objects,
283 )
284
285 from sqlalchemy import select as sa_select
286 from musehub.db.musehub_models import MusehubRenderJob as RJ
287 stmt = sa_select(RJ).where(RJ.repo_id == repo_id, RJ.commit_id == commit_id)
288 job = (await db_session.execute(stmt)).scalar_one_or_none()
289
290 assert job is not None
291 assert job.status == "complete"
292 assert len(job.mp3_object_ids) == 1
293
294
295 @pytest.mark.anyio
296 async def test_render_creates_piano_roll_images(
297 db_session: AsyncSession,
298 ) -> None:
299 """Render pipeline writes piano-roll PNG objects to the DB for each MIDI file."""
300 repo_id, _ = await _seed_repo(db_session)
301 commit_id = "b" * 64
302
303 objects = [
304 ObjectInput(
305 object_id="sha256:midi002",
306 path="tracks/keys.mid",
307 content_b64=_make_midi_b64(),
308 )
309 ]
310
311 with tempfile.TemporaryDirectory() as tmpdir:
312 with patch("musehub.services.musehub_render_pipeline.settings") as mock_settings:
313 mock_settings.musehub_objects_dir = tmpdir
314 await trigger_render_background(
315 repo_id=repo_id,
316 commit_id=commit_id,
317 objects=objects,
318 )
319
320 from sqlalchemy import select as sa_select
321 from musehub.db.musehub_models import MusehubRenderJob as RJ
322 stmt = sa_select(RJ).where(RJ.repo_id == repo_id, RJ.commit_id == commit_id)
323 job = (await db_session.execute(stmt)).scalar_one_or_none()
324
325 assert job is not None
326 assert job.status == "complete"
327 assert len(job.image_object_ids) == 1
328
329
330 @pytest.mark.anyio
331 async def test_render_idempotent(
332 db_session: AsyncSession,
333 ) -> None:
334 """Re-triggering render for the same commit does not create a second render job."""
335 repo_id, _ = await _seed_repo(db_session)
336 commit_id = "c" * 64
337
338 objects = [
339 ObjectInput(
340 object_id="sha256:midi003",
341 path="tracks/lead.mid",
342 content_b64=_make_midi_b64(),
343 )
344 ]
345
346 with tempfile.TemporaryDirectory() as tmpdir:
347 with patch("musehub.services.musehub_render_pipeline.settings") as mock_settings:
348 mock_settings.musehub_objects_dir = tmpdir
349 # First call
350 await trigger_render_background(
351 repo_id=repo_id,
352 commit_id=commit_id,
353 objects=objects,
354 )
355 # Second call — must be a no-op
356 await trigger_render_background(
357 repo_id=repo_id,
358 commit_id=commit_id,
359 objects=objects,
360 )
361
362 from sqlalchemy import select as sa_select, func
363 from musehub.db.musehub_models import MusehubRenderJob as RJ
364 stmt = sa_select(func.count()).select_from(RJ).where(
365 RJ.repo_id == repo_id, RJ.commit_id == commit_id
366 )
367 count = (await db_session.execute(stmt)).scalar_one()
368 assert count == 1
369
370
371 @pytest.mark.anyio
372 async def test_render_no_midi_objects(
373 db_session: AsyncSession,
374 ) -> None:
375 """A push with no MIDI objects creates a render job with midi_count=0 and empty artifacts."""
376 repo_id, _ = await _seed_repo(db_session)
377 commit_id = "d" * 64
378
379 objects = [
380 ObjectInput(
381 object_id="sha256:doc001",
382 path="README.md",
383 content_b64=base64.b64encode(b"# Project").decode(),
384 )
385 ]
386
387 with tempfile.TemporaryDirectory() as tmpdir:
388 with patch("musehub.services.musehub_render_pipeline.settings") as mock_settings:
389 mock_settings.musehub_objects_dir = tmpdir
390 await trigger_render_background(
391 repo_id=repo_id,
392 commit_id=commit_id,
393 objects=objects,
394 )
395
396 from sqlalchemy import select as sa_select
397 from musehub.db.musehub_models import MusehubRenderJob as RJ
398 stmt = sa_select(RJ).where(RJ.repo_id == repo_id, RJ.commit_id == commit_id)
399 job = (await db_session.execute(stmt)).scalar_one_or_none()
400
401 assert job is not None
402 assert job.midi_count == 0
403 assert job.mp3_object_ids == []
404 assert job.image_object_ids == []
405 assert job.status == "complete"
406
407
408 # ---------------------------------------------------------------------------
409 # Integration tests — render-status HTTP endpoint
410 # ---------------------------------------------------------------------------
411
412
413 @pytest.mark.anyio
414 async def test_render_status_endpoint_not_found(
415 client: AsyncClient,
416 db_session: AsyncSession,
417 auth_headers: dict[str, str],
418 ) -> None:
419 """GET /repos/{repo_id}/commits/{sha}/render-status returns not_found for unknown commit."""
420 repo_id, _ = await _seed_repo(db_session)
421 commit_id = "e" * 64
422
423 resp = await client.get(
424 f"/api/v1/repos/{repo_id}/commits/{commit_id}/render-status",
425 headers=auth_headers,
426 )
427
428 assert resp.status_code == 200
429 data = resp.json()
430 assert data["status"] == "not_found"
431 assert data["commitId"] == commit_id
432
433
434 @pytest.mark.anyio
435 async def test_render_status_endpoint_complete(
436 client: AsyncClient,
437 db_session: AsyncSession,
438 auth_headers: dict[str, str],
439 ) -> None:
440 """GET /repos/{repo_id}/commits/{sha}/render-status returns complete job."""
441 repo_id, _ = await _seed_repo(db_session)
442 commit_id = "f" * 64
443 await _seed_commit(db_session, repo_id, commit_id)
444 job = await _seed_render_job(db_session, repo_id, commit_id, status="complete")
445
446 resp = await client.get(
447 f"/api/v1/repos/{repo_id}/commits/{commit_id}/render-status",
448 headers=auth_headers,
449 )
450
451 assert resp.status_code == 200
452 data = resp.json()
453 assert data["status"] == "complete"
454 assert data["commitId"] == commit_id
455 assert data["midiCount"] == 1
456 assert "sha256:mp3abc" in data["mp3ObjectIds"]
457 assert "sha256:imgxyz" in data["imageObjectIds"]
458
459
460 @pytest.mark.anyio
461 async def test_render_status_endpoint_pending(
462 client: AsyncClient,
463 db_session: AsyncSession,
464 auth_headers: dict[str, str],
465 ) -> None:
466 """GET render-status returns pending status for in-flight jobs."""
467 repo_id, _ = await _seed_repo(db_session)
468 commit_id = "0" * 64
469 await _seed_commit(db_session, repo_id, commit_id)
470 await _seed_render_job(db_session, repo_id, commit_id, status="pending")
471
472 resp = await client.get(
473 f"/api/v1/repos/{repo_id}/commits/{commit_id}/render-status",
474 headers=auth_headers,
475 )
476
477 assert resp.status_code == 200
478 assert resp.json()["status"] == "pending"
479
480
481 @pytest.mark.anyio
482 async def test_render_status_endpoint_private_repo_no_auth(
483 client: AsyncClient,
484 db_session: AsyncSession,
485 ) -> None:
486 """Private repo render-status endpoint requires auth — returns 401 without JWT."""
487 repo_id, _ = await _seed_repo(db_session)
488 commit_id = "1" * 64
489
490 resp = await client.get(
491 f"/api/v1/repos/{repo_id}/commits/{commit_id}/render-status",
492 )
493
494 assert resp.status_code == 401
495
496
497 @pytest.mark.anyio
498 async def test_render_status_endpoint_public_repo_no_auth(
499 client: AsyncClient,
500 db_session: AsyncSession,
501 ) -> None:
502 """Public repo render-status endpoint is accessible without JWT."""
503 repo_id, _ = await _seed_public_repo(db_session)
504 commit_id = "2" * 64
505
506 resp = await client.get(
507 f"/api/v1/repos/{repo_id}/commits/{commit_id}/render-status",
508 )
509
510 assert resp.status_code == 200
511 assert resp.json()["status"] == "not_found"
512
513
514 # ---------------------------------------------------------------------------
515 # Integration tests — push endpoint triggers render
516 # ---------------------------------------------------------------------------
517
518
519 @pytest.mark.anyio
520 async def test_push_triggers_render(
521 client: AsyncClient,
522 db_session: AsyncSession,
523 auth_headers: dict[str, str],
524 ) -> None:
525 """POST /repos/{repo_id}/push triggers the render background task."""
526 repo_id, _ = await _seed_repo(db_session)
527
528 trigger_calls: list[dict[str, object]] = []
529
530 async def fake_trigger(
531 *,
532 repo_id: str,
533 commit_id: str,
534 objects: list[ObjectInput],
535 ) -> None:
536 trigger_calls.append(
537 {"repo_id": repo_id, "commit_id": commit_id, "objects": objects}
538 )
539
540 with tempfile.TemporaryDirectory() as tmp, patch(
541 "musehub.services.musehub_sync.settings"
542 ) as mock_cfg, patch(
543 "musehub.api.routes.musehub.sync.trigger_render_background",
544 side_effect=fake_trigger,
545 ):
546 mock_cfg.musehub_objects_dir = tmp
547 payload = {
548 "branch": "main",
549 "headCommitId": "a" * 64,
550 "commits": [
551 {
552 "commitId": "a" * 64,
553 "branch": "main",
554 "parentIds": [],
555 "message": "add bass line",
556 "author": "testuser",
557 "timestamp": "2026-01-01T00:00:00Z",
558 "snapshotId": None,
559 }
560 ],
561 "objects": [
562 {
563 "objectId": "sha256:midiobj",
564 "path": "tracks/bass.mid",
565 "contentB64": _make_midi_b64(),
566 }
567 ],
568 "force": False,
569 }
570
571 resp = await client.post(
572 f"/api/v1/repos/{repo_id}/push",
573 json=payload,
574 headers=auth_headers,
575 )
576
577 assert resp.status_code == 200
578 # The trigger must have been called once for this push
579 assert len(trigger_calls) == 1
580 assert trigger_calls[0]["commit_id"] == "a" * 64
581 assert trigger_calls[0]["repo_id"] == repo_id
582
583
584 @pytest.mark.anyio
585 async def test_render_failure_does_not_block_push(
586 client: AsyncClient,
587 db_session: AsyncSession,
588 auth_headers: dict[str, str],
589 ) -> None:
590 """Push succeeds even when the render background task is a no-op.
591
592 The push HTTP response is returned before background tasks run, so the
593 push is never blocked by render pipeline work. We verify this by patching
594 ``trigger_render_background`` to a no-op (which never interferes with the
595 HTTP response) and confirming the push still returns 200.
596
597 The internal error-handling contract (render errors logged, not raised) is
598 covered by test_render_pipeline_internal_error_is_logged.
599 """
600 repo_id, _ = await _seed_repo(db_session)
601
602 async def noop_trigger(**kwargs: object) -> None:
603 pass # Deliberately does nothing — simulates a render that never runs
604
605 with patch(
606 "musehub.api.routes.musehub.sync.trigger_render_background",
607 side_effect=noop_trigger,
608 ):
609 payload = {
610 "branch": "main",
611 "headCommitId": "b" * 64,
612 "commits": [
613 {
614 "commitId": "b" * 64,
615 "branch": "main",
616 "parentIds": [],
617 "message": "keys riff",
618 "author": "testuser",
619 "timestamp": "2026-01-01T00:00:00Z",
620 "snapshotId": None,
621 }
622 ],
623 "objects": [],
624 "force": False,
625 }
626
627 resp = await client.post(
628 f"/api/v1/repos/{repo_id}/push",
629 json=payload,
630 headers=auth_headers,
631 )
632
633 # Push must succeed regardless of what the render task does
634 assert resp.status_code == 200
635 data = resp.json()
636 assert data["ok"] is True
637
638
639 @pytest.mark.anyio
640 async def test_render_pipeline_internal_error_is_logged(
641 db_session: AsyncSession,
642 ) -> None:
643 """Render pipeline marks job as failed and logs when _render_commit raises.
644
645 This verifies the contract that render errors never propagate — they are
646 caught inside ``trigger_render_background`` and stored as job.status=failed.
647 """
648 repo_id, _ = await _seed_repo(db_session)
649 commit_id = "e" * 64
650
651 objects = [
652 ObjectInput(
653 object_id="sha256:mididead",
654 path="tracks/broken.mid",
655 # Intentionally bad base64 — _render_commit will log a warning for each
656 # object that fails to decode, but the job itself should still complete
657 content_b64=_make_midi_b64(),
658 )
659 ]
660
661 # Patch _render_commit itself to raise — tests the outer catch
662 from musehub.services import musehub_render_pipeline as pipeline_mod
663
664 async def exploding_render(session: object, **kwargs: object) -> RenderPipelineResult:
665 raise RuntimeError("simulated internal render error")
666
667 with tempfile.TemporaryDirectory() as tmpdir:
668 with patch("musehub.services.musehub_render_pipeline.settings") as mock_settings:
669 mock_settings.musehub_objects_dir = tmpdir
670 with patch.object(pipeline_mod, "_render_commit", side_effect=exploding_render):
671 # Must not raise — errors are swallowed inside trigger_render_background
672 await trigger_render_background(
673 repo_id=repo_id,
674 commit_id=commit_id,
675 objects=objects,
676 )
677
678 from sqlalchemy import select as sa_select
679 from musehub.db.musehub_models import MusehubRenderJob as RJ
680 stmt = sa_select(RJ).where(RJ.repo_id == repo_id, RJ.commit_id == commit_id)
681 job = (await db_session.execute(stmt)).scalar_one_or_none()
682
683 assert job is not None
684 assert job.status == "failed"
685 assert job.error_message is not None
686 assert "simulated internal render error" in job.error_message