gabriel / musehub public
test_musehub_sync.py python
523 lines 17.3 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for Muse Hub push/pull sync protocol.
2
3 Covers every acceptance criterion:
4 - POST /push stores commits and objects (upsert)
5 - POST /push updates the branch head to head_commit_id
6 - POST /push rejects non-fast-forward updates with 409 (unless force=true)
7 - POST /pull returns commits/objects the caller does not have
8 - POST /push → POST /pull round-trip returns all committed data
9 - Both endpoints require valid JWT (401 without token)
10
11 All tests use the shared ``client``, ``auth_headers``, and ``db_session``
12 fixtures from conftest.py.
13 """
14 from __future__ import annotations
15
16 import base64
17 import tempfile
18 from pathlib import Path
19 from unittest.mock import patch
20
21 import pytest
22 from httpx import AsyncClient
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from musehub.services import musehub_repository
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31 _B64_MIDI = base64.b64encode(b"MIDI_CONTENT").decode()
32 _B64_MP3 = base64.b64encode(b"MP3_CONTENT").decode()
33
34
35 def _push_payload(
36 *,
37 branch: str = "main",
38 head_commit_id: str = "c001",
39 commits: list[dict[str, object]] | None = None,
40 objects: list[dict[str, object]] | None = None,
41 force: bool = False,
42 ) -> dict[str, object]:
43 return {
44 "branch": branch,
45 "headCommitId": head_commit_id,
46 "commits": commits
47 or [
48 {
49 "commitId": head_commit_id,
50 "parentIds": [],
51 "message": "init",
52 "timestamp": "2024-01-01T00:00:00Z",
53 }
54 ],
55 "objects": objects or [],
56 "force": force,
57 }
58
59
60 def _pull_payload(
61 *,
62 branch: str = "main",
63 have_commits: list[str] | None = None,
64 have_objects: list[str] | None = None,
65 ) -> dict[str, object]:
66 return {
67 "branch": branch,
68 "haveCommits": have_commits or [],
69 "haveObjects": have_objects or [],
70 }
71
72
73 async def _create_repo(client: AsyncClient, auth_headers: dict[str, str], name: str = "test-repo") -> str:
74 r = await client.post(
75 "/api/v1/musehub/repos",
76 json={"name": name, "owner": "testuser"},
77 headers=auth_headers,
78 )
79 assert r.status_code == 201
80 repo_id: str = r.json()["repoId"]
81 return repo_id
82
83
84 # ---------------------------------------------------------------------------
85 # POST /push — stores commits and objects
86 # ---------------------------------------------------------------------------
87
88
89 @pytest.mark.anyio
90 async def test_push_stores_commits_and_objects(
91 client: AsyncClient,
92 auth_headers: dict[str, str],
93 db_session: AsyncSession,
94 ) -> None:
95 """Commits and objects are queryable after a successful push."""
96 repo_id = await _create_repo(client, auth_headers, "push-test")
97
98 with tempfile.TemporaryDirectory() as tmp:
99 with patch("musehub.services.musehub_sync.settings") as mock_cfg:
100 mock_cfg.musehub_objects_dir = tmp
101
102 payload = _push_payload(
103 head_commit_id="c001",
104 commits=[
105 {
106 "commitId": "c001",
107 "parentIds": [],
108 "message": "Add jazz track",
109 "timestamp": "2024-01-01T10:00:00Z",
110 }
111 ],
112 objects=[
113 {
114 "objectId": "sha256:aabbcc",
115 "path": "tracks/jazz.mid",
116 "contentB64": _B64_MIDI,
117 }
118 ],
119 )
120 resp = await client.post(
121 f"/api/v1/musehub/repos/{repo_id}/push",
122 json=payload,
123 headers=auth_headers,
124 )
125
126 assert resp.status_code == 200
127 body = resp.json()
128 assert body["ok"] is True
129 assert body["remoteHead"] == "c001"
130
131 # Commits visible via list endpoint
132 commits_resp = await client.get(
133 f"/api/v1/musehub/repos/{repo_id}/commits",
134 headers=auth_headers,
135 )
136 assert commits_resp.status_code == 200
137 commit_ids = [c["commitId"] for c in commits_resp.json()["commits"]]
138 assert "c001" in commit_ids
139
140
141 # ---------------------------------------------------------------------------
142 # POST /push — updates branch head
143 # ---------------------------------------------------------------------------
144
145
146 @pytest.mark.anyio
147 async def test_push_updates_branch_head(
148 client: AsyncClient,
149 auth_headers: dict[str, str],
150 db_session: AsyncSession,
151 ) -> None:
152 """Branch head pointer is updated to head_commit_id after push."""
153 repo_id = await _create_repo(client, auth_headers, "head-test")
154
155 with tempfile.TemporaryDirectory() as tmp:
156 with patch("musehub.services.musehub_sync.settings") as mock_cfg:
157 mock_cfg.musehub_objects_dir = tmp
158
159 await client.post(
160 f"/api/v1/musehub/repos/{repo_id}/push",
161 json=_push_payload(head_commit_id="c001"),
162 headers=auth_headers,
163 )
164
165 branches_resp = await client.get(
166 f"/api/v1/musehub/repos/{repo_id}/branches",
167 headers=auth_headers,
168 )
169 assert branches_resp.status_code == 200
170 branches = branches_resp.json()["branches"]
171 main_branch = next((b for b in branches if b["name"] == "main"), None)
172 assert main_branch is not None
173 assert main_branch["headCommitId"] == "c001"
174
175
176 # ---------------------------------------------------------------------------
177 # POST /push — non-fast-forward rejected with 409
178 # ---------------------------------------------------------------------------
179
180
181 @pytest.mark.anyio
182 async def test_push_non_fast_forward_returns_409(
183 client: AsyncClient,
184 auth_headers: dict[str, str],
185 db_session: AsyncSession,
186 ) -> None:
187 """A push that would create a non-fast-forward update is rejected with 409."""
188 repo_id = await _create_repo(client, auth_headers, "nff-test")
189
190 with tempfile.TemporaryDirectory() as tmp:
191 with patch("musehub.services.musehub_sync.settings") as mock_cfg:
192 mock_cfg.musehub_objects_dir = tmp
193
194 # First push — sets remote head to c001
195 r1 = await client.post(
196 f"/api/v1/musehub/repos/{repo_id}/push",
197 json=_push_payload(head_commit_id="c001"),
198 headers=auth_headers,
199 )
200 assert r1.status_code == 200
201
202 # Second push — diverges: c002 does NOT descend from c001
203 r2 = await client.post(
204 f"/api/v1/musehub/repos/{repo_id}/push",
205 json=_push_payload(
206 head_commit_id="c002",
207 commits=[
208 {
209 "commitId": "c002",
210 "parentIds": [], # no parent → diverged
211 "message": "diverged commit",
212 "timestamp": "2024-01-02T00:00:00Z",
213 }
214 ],
215 ),
216 headers=auth_headers,
217 )
218
219 assert r2.status_code == 409
220 assert r2.json()["detail"]["error"] == "non_fast_forward"
221
222
223 @pytest.mark.anyio
224 async def test_push_force_allows_non_fast_forward(
225 client: AsyncClient,
226 auth_headers: dict[str, str],
227 db_session: AsyncSession,
228 ) -> None:
229 """force=true allows a non-fast-forward push."""
230 repo_id = await _create_repo(client, auth_headers, "force-test")
231
232 with tempfile.TemporaryDirectory() as tmp:
233 with patch("musehub.services.musehub_sync.settings") as mock_cfg:
234 mock_cfg.musehub_objects_dir = tmp
235
236 await client.post(
237 f"/api/v1/musehub/repos/{repo_id}/push",
238 json=_push_payload(head_commit_id="c001"),
239 headers=auth_headers,
240 )
241
242 r = await client.post(
243 f"/api/v1/musehub/repos/{repo_id}/push",
244 json=_push_payload(
245 head_commit_id="c002",
246 commits=[
247 {
248 "commitId": "c002",
249 "parentIds": [],
250 "message": "force rewrite",
251 "timestamp": "2024-01-03T00:00:00Z",
252 }
253 ],
254 force=True,
255 ),
256 headers=auth_headers,
257 )
258
259 assert r.status_code == 200
260 assert r.json()["remoteHead"] == "c002"
261
262
263 # ---------------------------------------------------------------------------
264 # POST /pull — returns missing commits
265 # ---------------------------------------------------------------------------
266
267
268 @pytest.mark.anyio
269 async def test_pull_returns_missing_commits(
270 client: AsyncClient,
271 auth_headers: dict[str, str],
272 db_session: AsyncSession,
273 ) -> None:
274 """Only commits not in have_commits are returned by pull."""
275 repo_id = await _create_repo(client, auth_headers, "pull-commits-test")
276
277 with tempfile.TemporaryDirectory() as tmp:
278 with patch("musehub.services.musehub_sync.settings") as mock_cfg:
279 mock_cfg.musehub_objects_dir = tmp
280
281 # Push two commits
282 await client.post(
283 f"/api/v1/musehub/repos/{repo_id}/push",
284 json=_push_payload(
285 head_commit_id="c002",
286 commits=[
287 {
288 "commitId": "c001",
289 "parentIds": [],
290 "message": "first",
291 "timestamp": "2024-01-01T00:00:00Z",
292 },
293 {
294 "commitId": "c002",
295 "parentIds": ["c001"],
296 "message": "second",
297 "timestamp": "2024-01-02T00:00:00Z",
298 },
299 ],
300 ),
301 headers=auth_headers,
302 )
303
304 # Pull with c001 already known
305 pull_resp = await client.post(
306 f"/api/v1/musehub/repos/{repo_id}/pull",
307 json=_pull_payload(have_commits=["c001"]),
308 headers=auth_headers,
309 )
310
311 assert pull_resp.status_code == 200
312 body = pull_resp.json()
313 commit_ids = [c["commitId"] for c in body["commits"]]
314 assert "c002" in commit_ids
315 assert "c001" not in commit_ids
316 assert body["remoteHead"] == "c002"
317
318
319 # ---------------------------------------------------------------------------
320 # POST /pull — returns missing objects
321 # ---------------------------------------------------------------------------
322
323
324 @pytest.mark.anyio
325 async def test_pull_returns_missing_objects(
326 client: AsyncClient,
327 auth_headers: dict[str, str],
328 db_session: AsyncSession,
329 ) -> None:
330 """Only objects not in have_objects are returned by pull."""
331 repo_id = await _create_repo(client, auth_headers, "pull-objects-test")
332
333 with tempfile.TemporaryDirectory() as tmp:
334 with patch("musehub.services.musehub_sync.settings") as mock_cfg:
335 mock_cfg.musehub_objects_dir = tmp
336
337 # Push two objects
338 await client.post(
339 f"/api/v1/musehub/repos/{repo_id}/push",
340 json=_push_payload(
341 head_commit_id="c001",
342 objects=[
343 {
344 "objectId": "sha256:aaa",
345 "path": "tracks/a.mid",
346 "contentB64": _B64_MIDI,
347 },
348 {
349 "objectId": "sha256:bbb",
350 "path": "tracks/b.mp3",
351 "contentB64": _B64_MP3,
352 },
353 ],
354 ),
355 headers=auth_headers,
356 )
357
358 # Pull with sha256:aaa already known
359 pull_resp = await client.post(
360 f"/api/v1/musehub/repos/{repo_id}/pull",
361 json=_pull_payload(have_objects=["sha256:aaa"]),
362 headers=auth_headers,
363 )
364
365 assert pull_resp.status_code == 200
366 body = pull_resp.json()
367 object_ids = [o["objectId"] for o in body["objects"]]
368 assert "sha256:bbb" in object_ids
369 assert "sha256:aaa" not in object_ids
370
371
372 # ---------------------------------------------------------------------------
373 # Push → pull round-trip
374 # ---------------------------------------------------------------------------
375
376
377 @pytest.mark.anyio
378 async def test_push_then_pull_roundtrip(
379 client: AsyncClient,
380 auth_headers: dict[str, str],
381 db_session: AsyncSession,
382 ) -> None:
383 """After a push, a pull from a fresh client returns all commits and objects."""
384 repo_id = await _create_repo(client, auth_headers, "roundtrip-test")
385
386 with tempfile.TemporaryDirectory() as tmp:
387 with patch("musehub.services.musehub_sync.settings") as mock_cfg:
388 mock_cfg.musehub_objects_dir = tmp
389
390 push_payload = _push_payload(
391 head_commit_id="c001",
392 commits=[
393 {
394 "commitId": "c001",
395 "parentIds": [],
396 "message": "round-trip commit",
397 "timestamp": "2024-01-01T00:00:00Z",
398 }
399 ],
400 objects=[
401 {
402 "objectId": "sha256:rt01",
403 "path": "tracks/rt.mid",
404 "contentB64": _B64_MIDI,
405 }
406 ],
407 )
408 push_resp = await client.post(
409 f"/api/v1/musehub/repos/{repo_id}/push",
410 json=push_payload,
411 headers=auth_headers,
412 )
413 assert push_resp.status_code == 200
414
415 pull_resp = await client.post(
416 f"/api/v1/musehub/repos/{repo_id}/pull",
417 json=_pull_payload(),
418 headers=auth_headers,
419 )
420
421 assert pull_resp.status_code == 200
422 body = pull_resp.json()
423 commit_ids = [c["commitId"] for c in body["commits"]]
424 object_ids = [o["objectId"] for o in body["objects"]]
425 assert "c001" in commit_ids
426 assert "sha256:rt01" in object_ids
427 assert body["remoteHead"] == "c001"
428
429 # Verify object content survived the round-trip
430 obj = next(o for o in body["objects"] if o["objectId"] == "sha256:rt01")
431 assert base64.b64decode(obj["contentB64"]) == b"MIDI_CONTENT"
432
433
434 # ---------------------------------------------------------------------------
435 # Auth enforcement
436 # ---------------------------------------------------------------------------
437
438
439 @pytest.mark.anyio
440 async def test_push_requires_auth(client: AsyncClient) -> None:
441 """POST /push returns 401 without a Bearer token."""
442 resp = await client.post(
443 "/api/v1/musehub/repos/any-repo/push",
444 json=_push_payload(),
445 )
446 assert resp.status_code == 401
447
448
449 @pytest.mark.anyio
450 async def test_pull_requires_auth(client: AsyncClient) -> None:
451 """POST /pull returns 401 without a Bearer token."""
452 resp = await client.post(
453 "/api/v1/musehub/repos/any-repo/pull",
454 json=_pull_payload(),
455 )
456 assert resp.status_code == 401
457
458
459 # ---------------------------------------------------------------------------
460 # 404 for unknown repo
461 # ---------------------------------------------------------------------------
462
463
464 @pytest.mark.anyio
465 async def test_push_unknown_repo_returns_404(
466 client: AsyncClient,
467 auth_headers: dict[str, str],
468 ) -> None:
469 """POST /push returns 404 when the repo does not exist."""
470 resp = await client.post(
471 "/api/v1/musehub/repos/ghost-repo/push",
472 json=_push_payload(),
473 headers=auth_headers,
474 )
475 assert resp.status_code == 404
476
477
478 @pytest.mark.anyio
479 async def test_pull_unknown_repo_returns_404(
480 client: AsyncClient,
481 auth_headers: dict[str, str],
482 ) -> None:
483 """POST /pull returns 404 when the repo does not exist."""
484 resp = await client.post(
485 "/api/v1/musehub/repos/ghost-repo/pull",
486 json=_pull_payload(),
487 headers=auth_headers,
488 )
489 assert resp.status_code == 404
490
491
492 # ---------------------------------------------------------------------------
493 # Idempotency — duplicate push does not create duplicate commits
494 # ---------------------------------------------------------------------------
495
496
497 @pytest.mark.anyio
498 async def test_push_idempotent_commits(
499 client: AsyncClient,
500 auth_headers: dict[str, str],
501 db_session: AsyncSession,
502 ) -> None:
503 """Pushing the same commit twice does not create duplicates."""
504 repo_id = await _create_repo(client, auth_headers, "idem-test")
505
506 with tempfile.TemporaryDirectory() as tmp:
507 with patch("musehub.services.musehub_sync.settings") as mock_cfg:
508 mock_cfg.musehub_objects_dir = tmp
509
510 for _ in range(2):
511 r = await client.post(
512 f"/api/v1/musehub/repos/{repo_id}/push",
513 json=_push_payload(head_commit_id="c001"),
514 headers=auth_headers,
515 )
516 assert r.status_code == 200
517
518 commits_resp = await client.get(
519 f"/api/v1/musehub/repos/{repo_id}/commits",
520 headers=auth_headers,
521 )
522 commit_ids = [c["commitId"] for c in commits_resp.json()["commits"]]
523 assert commit_ids.count("c001") == 1