gabriel / musehub public
test_musehub_raw.py python
348 lines 11.0 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for the MuseHub raw file download endpoint.
2
3 Covers every acceptance criterion:
4 - test_raw_midi_correct_mime — .mid served with audio/midi
5 - test_raw_mp3_correct_mime — .mp3 served with audio/mpeg
6 - test_raw_wav_correct_mime — .wav served with audio/wav
7 - test_raw_json_correct_mime — .json served with application/json
8 - test_raw_webp_correct_mime — .webp served with image/webp
9 - test_raw_xml_correct_mime — .xml served with application/xml
10 - test_raw_404_unknown_path — nonexistent path returns 404
11 - test_raw_404_unknown_repo — nonexistent repo_id returns 404
12 - test_raw_public_no_auth — public repo accessible without JWT
13 - test_raw_private_requires_auth — private repo returns 401 without JWT
14 - test_raw_private_with_auth — private repo accessible with valid JWT
15 - test_raw_range_request — Range request returns 206 with partial content
16 - test_raw_content_disposition — Content-Disposition header carries filename
17 - test_raw_accept_ranges_header — Accept-Ranges: bytes is present in response
18
19 The endpoint under test:
20 GET /api/v1/repos/{repo_id}/raw/{ref}/{path:path}
21 """
22 from __future__ import annotations
23
24 import os
25 import tempfile
26
27 import pytest
28 from httpx import AsyncClient
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from musehub.db.musehub_models import MusehubObject, MusehubRepo
32
33
34 # ---------------------------------------------------------------------------
35 # Helpers / fixtures
36 # ---------------------------------------------------------------------------
37
38
39 async def _make_repo(db: AsyncSession, *, visibility: str = "public") -> str:
40 """Seed a minimal MuseHub repo and return its repo_id."""
41 repo = MusehubRepo(
42 name="test-beats",
43 owner="testuser",
44 slug="test-beats",
45 visibility=visibility,
46 owner_user_id="test-owner",
47 )
48 db.add(repo)
49 await db.commit()
50 await db.refresh(repo)
51 return str(repo.repo_id)
52
53
54 async def _make_object(
55 db: AsyncSession,
56 repo_id: str,
57 *,
58 path: str,
59 content: bytes = b"FAKE_CONTENT",
60 tmp_dir: str,
61 ) -> str:
62 """Write content to a temp file, seed an object row, return the object_id."""
63 filename = os.path.basename(path)
64 disk_path = os.path.join(tmp_dir, filename)
65 with open(disk_path, "wb") as fh:
66 fh.write(content)
67
68 obj = MusehubObject(
69 object_id=f"sha256:test-{filename}",
70 repo_id=repo_id,
71 path=path,
72 size_bytes=len(content),
73 disk_path=disk_path,
74 )
75 db.add(obj)
76 await db.commit()
77 return str(obj.object_id)
78
79
80 # ---------------------------------------------------------------------------
81 # MIME type tests
82 # ---------------------------------------------------------------------------
83
84
85 @pytest.mark.anyio
86 async def test_raw_midi_correct_mime(
87 client: AsyncClient,
88 db_session: AsyncSession,
89 ) -> None:
90 """.mid file is served with Content-Type: audio/midi."""
91 with tempfile.TemporaryDirectory() as tmp:
92 repo_id = await _make_repo(db_session, visibility="public")
93 await _make_object(db_session, repo_id, path="tracks/bass.mid", tmp_dir=tmp)
94
95 resp = await client.get(f"/api/v1/repos/{repo_id}/raw/main/tracks/bass.mid")
96
97 assert resp.status_code == 200
98 assert "audio/midi" in resp.headers["content-type"]
99
100
101 @pytest.mark.anyio
102 async def test_raw_mp3_correct_mime(
103 client: AsyncClient,
104 db_session: AsyncSession,
105 ) -> None:
106 """.mp3 file is served with Content-Type: audio/mpeg."""
107 with tempfile.TemporaryDirectory() as tmp:
108 repo_id = await _make_repo(db_session, visibility="public")
109 await _make_object(db_session, repo_id, path="mix/final.mp3", tmp_dir=tmp)
110
111 resp = await client.get(f"/api/v1/repos/{repo_id}/raw/main/mix/final.mp3")
112
113 assert resp.status_code == 200
114 assert "audio/mpeg" in resp.headers["content-type"]
115
116
117 @pytest.mark.anyio
118 async def test_raw_wav_correct_mime(
119 client: AsyncClient,
120 db_session: AsyncSession,
121 ) -> None:
122 """.wav file is served with Content-Type: audio/wav."""
123 with tempfile.TemporaryDirectory() as tmp:
124 repo_id = await _make_repo(db_session, visibility="public")
125 await _make_object(db_session, repo_id, path="stems/drums.wav", tmp_dir=tmp)
126
127 resp = await client.get(f"/api/v1/repos/{repo_id}/raw/main/stems/drums.wav")
128
129 assert resp.status_code == 200
130 assert "audio/wav" in resp.headers["content-type"]
131
132
133 @pytest.mark.anyio
134 async def test_raw_json_correct_mime(
135 client: AsyncClient,
136 db_session: AsyncSession,
137 ) -> None:
138 """.json file is served with Content-Type: application/json."""
139 with tempfile.TemporaryDirectory() as tmp:
140 repo_id = await _make_repo(db_session, visibility="public")
141 await _make_object(
142 db_session,
143 repo_id,
144 path="metadata/track.json",
145 content=b'{"bpm": 120}',
146 tmp_dir=tmp,
147 )
148
149 resp = await client.get(
150 f"/api/v1/repos/{repo_id}/raw/main/metadata/track.json"
151 )
152
153 assert resp.status_code == 200
154 assert "application/json" in resp.headers["content-type"]
155
156
157 @pytest.mark.anyio
158 async def test_raw_webp_correct_mime(
159 client: AsyncClient,
160 db_session: AsyncSession,
161 ) -> None:
162 """.webp file is served with Content-Type: image/webp."""
163 with tempfile.TemporaryDirectory() as tmp:
164 repo_id = await _make_repo(db_session, visibility="public")
165 await _make_object(db_session, repo_id, path="previews/piano_roll.webp", tmp_dir=tmp)
166
167 resp = await client.get(
168 f"/api/v1/repos/{repo_id}/raw/main/previews/piano_roll.webp"
169 )
170
171 assert resp.status_code == 200
172 assert "image/webp" in resp.headers["content-type"]
173
174
175 @pytest.mark.anyio
176 async def test_raw_xml_correct_mime(
177 client: AsyncClient,
178 db_session: AsyncSession,
179 ) -> None:
180 """.xml file is served with Content-Type: application/xml."""
181 with tempfile.TemporaryDirectory() as tmp:
182 repo_id = await _make_repo(db_session, visibility="public")
183 await _make_object(
184 db_session,
185 repo_id,
186 path="scores/piece.xml",
187 content=b"<score></score>",
188 tmp_dir=tmp,
189 )
190
191 resp = await client.get(f"/api/v1/repos/{repo_id}/raw/main/scores/piece.xml")
192
193 assert resp.status_code == 200
194 assert "application/xml" in resp.headers["content-type"]
195
196
197 # ---------------------------------------------------------------------------
198 # 404 / error cases
199 # ---------------------------------------------------------------------------
200
201
202 @pytest.mark.anyio
203 async def test_raw_404_unknown_path(
204 client: AsyncClient,
205 db_session: AsyncSession,
206 ) -> None:
207 """Path that has no matching object returns 404."""
208 repo_id = await _make_repo(db_session, visibility="public")
209 resp = await client.get(
210 f"/api/v1/repos/{repo_id}/raw/main/does/not/exist.mid"
211 )
212 assert resp.status_code == 404
213
214
215 @pytest.mark.anyio
216 async def test_raw_404_unknown_repo(
217 client: AsyncClient,
218 db_session: AsyncSession,
219 ) -> None:
220 """Nonexistent repo_id returns 404 immediately."""
221 resp = await client.get(
222 "/api/v1/repos/nonexistent-repo-uuid/raw/main/track.mid"
223 )
224 assert resp.status_code == 404
225
226
227 # ---------------------------------------------------------------------------
228 # Auth tests
229 # ---------------------------------------------------------------------------
230
231
232 @pytest.mark.anyio
233 async def test_raw_public_no_auth(
234 client: AsyncClient,
235 db_session: AsyncSession,
236 ) -> None:
237 """Public repo files are accessible without any Authorization header."""
238 with tempfile.TemporaryDirectory() as tmp:
239 repo_id = await _make_repo(db_session, visibility="public")
240 await _make_object(db_session, repo_id, path="tracks/open.mid", tmp_dir=tmp)
241
242 resp = await client.get(
243 f"/api/v1/repos/{repo_id}/raw/main/tracks/open.mid"
244 )
245
246 assert resp.status_code == 200
247
248
249 @pytest.mark.anyio
250 async def test_raw_private_requires_auth(
251 client: AsyncClient,
252 db_session: AsyncSession,
253 ) -> None:
254 """Private repo raw download without a JWT returns 401."""
255 repo_id = await _make_repo(db_session, visibility="private")
256 resp = await client.get(
257 f"/api/v1/repos/{repo_id}/raw/main/tracks/secret.mid"
258 )
259 assert resp.status_code == 401
260
261
262 @pytest.mark.anyio
263 async def test_raw_private_with_auth(
264 client: AsyncClient,
265 db_session: AsyncSession,
266 auth_headers: dict[str, str],
267 ) -> None:
268 """Private repo raw download WITH a valid JWT returns 200."""
269 with tempfile.TemporaryDirectory() as tmp:
270 repo_id = await _make_repo(db_session, visibility="private")
271 await _make_object(db_session, repo_id, path="tracks/secret.mid", tmp_dir=tmp)
272
273 resp = await client.get(
274 f"/api/v1/repos/{repo_id}/raw/main/tracks/secret.mid",
275 headers={"Authorization": auth_headers["Authorization"]},
276 )
277
278 assert resp.status_code == 200
279
280
281 # ---------------------------------------------------------------------------
282 # Header correctness
283 # ---------------------------------------------------------------------------
284
285
286 @pytest.mark.anyio
287 async def test_raw_content_disposition(
288 client: AsyncClient,
289 db_session: AsyncSession,
290 ) -> None:
291 """Response carries a Content-Disposition header with the original filename."""
292 with tempfile.TemporaryDirectory() as tmp:
293 repo_id = await _make_repo(db_session, visibility="public")
294 await _make_object(
295 db_session, repo_id, path="tracks/groove_42.mid", tmp_dir=tmp
296 )
297
298 resp = await client.get(
299 f"/api/v1/repos/{repo_id}/raw/main/tracks/groove_42.mid"
300 )
301
302 assert resp.status_code == 200
303 cd = resp.headers.get("content-disposition", "")
304 assert "groove_42.mid" in cd
305
306
307 @pytest.mark.anyio
308 async def test_raw_accept_ranges_header(
309 client: AsyncClient,
310 db_session: AsyncSession,
311 ) -> None:
312 """Response includes Accept-Ranges: bytes so Range requests are signalled."""
313 with tempfile.TemporaryDirectory() as tmp:
314 repo_id = await _make_repo(db_session, visibility="public")
315 await _make_object(db_session, repo_id, path="mix/audio.mp3", tmp_dir=tmp)
316
317 resp = await client.get(
318 f"/api/v1/repos/{repo_id}/raw/main/mix/audio.mp3"
319 )
320
321 assert resp.status_code == 200
322 assert resp.headers.get("accept-ranges") == "bytes"
323
324
325 @pytest.mark.anyio
326 async def test_raw_range_request(
327 client: AsyncClient,
328 db_session: AsyncSession,
329 ) -> None:
330 """Range request returns 206 Partial Content with the requested byte slice."""
331 content = b"HELLO_WORLD_AUDIO_DATA_BYTES_HERE"
332 with tempfile.TemporaryDirectory() as tmp:
333 repo_id = await _make_repo(db_session, visibility="public")
334 await _make_object(
335 db_session,
336 repo_id,
337 path="mix/partial.mp3",
338 content=content,
339 tmp_dir=tmp,
340 )
341
342 resp = await client.get(
343 f"/api/v1/repos/{repo_id}/raw/main/mix/partial.mp3",
344 headers={"Range": "bytes=0-4"},
345 )
346
347 assert resp.status_code == 206
348 assert resp.content == content[:5]