gabriel / musehub public
test_wire_protocol.py python
1,841 lines 66.3 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Wire protocol endpoint tests.
2
3 Covers the three Muse CLI transport endpoints (Git-style URLs):
4 GET /{owner}/{slug}/refs
5 POST /{owner}/{slug}/push
6 POST /{owner}/{slug}/fetch
7
8 And the content-addressed CDN endpoint:
9 GET /o/{object_id}
10
11 Remote URL format (same pattern as Git):
12 muse remote add origin https://musehub.ai/gabriel/muse
13 """
14 from __future__ import annotations
15
16 import time
17 import uuid
18 from datetime import datetime, timezone
19
20 import msgpack
21 import pytest
22 from httpx import AsyncClient
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from musehub.db import musehub_models as db
26 from musehub.db.musehub_collaborator_models import MusehubCollaborator
27 from musehub.main import app
28 from tests.factories import create_repo as factory_create_repo
29 from musehub.muse_contracts.json_types import JSONObject, StrDict
30
31
32 # ── helpers ────────────────────────────────────────────────────────────────────
33
34 def _utc_now() -> datetime:
35 return datetime.now(tz=timezone.utc)
36
37
38 def _make_commit(repo_id: str, commit_id: str | None = None, parent: str | None = None) -> JSONObject:
39 return {
40 "commit_id": commit_id or str(uuid.uuid4()),
41 "repo_id": repo_id,
42 "branch": "main",
43 "snapshot_id": f"snap_{uuid.uuid4().hex[:8]}",
44 "message": "chore: add test commit",
45 "committed_at": _utc_now().isoformat(),
46 "parent_commit_id": parent,
47 "author": "Test User <[email protected]>",
48 "sem_ver_bump": "patch",
49 }
50
51
52 def _make_object(content: bytes = b"hello world") -> JSONObject:
53 oid = uuid.uuid4().hex
54 return {
55 "object_id": oid,
56 "content": content,
57 "path": "README.md",
58 }
59
60
61 def _make_snapshot(snap_id: str, object_id: str) -> JSONObject:
62 return {
63 "snapshot_id": snap_id,
64 "manifest": {"README.md": object_id},
65 "created_at": _utc_now().isoformat(),
66 }
67
68
69
70
71 def _mp(data: JSONObject) -> bytes:
72 """Encode data as msgpack for test request bodies."""
73 return msgpack.packb(data, use_bin_type=True)
74
75
76 # ── refs endpoint ──────────────────────────────────────────────────────────────
77
78 @pytest.mark.asyncio
79 async def test_refs_returns_404_for_unknown_owner_slug(client: AsyncClient) -> None:
80 resp = await client.get("/no-such-owner/no-such-slug/refs")
81 assert resp.status_code == 404
82
83
84 @pytest.mark.asyncio
85 async def test_refs_returns_branch_heads(
86 client: AsyncClient,
87 db_session: AsyncSession,
88 ) -> None:
89 repo = await factory_create_repo(db_session, slug="muse-test", domain_meta={"domain": "code"})
90 branch = db.MusehubBranch(
91 repo_id=repo.repo_id,
92 name="main",
93 head_commit_id="abc123",
94 )
95 db_session.add(branch)
96 await db_session.commit()
97
98 owner = repo.owner
99 slug = repo.slug
100 resp = await client.get(f"/{owner}/{slug}/refs")
101 assert resp.status_code == 200
102 data = resp.json()
103 assert data["repo_id"] == repo.repo_id
104 assert data["default_branch"] == "main"
105 assert data["domain"] == "code"
106 assert data["branch_heads"]["main"] == "abc123"
107
108
109 @pytest.mark.asyncio
110 async def test_refs_url_is_owner_slash_slug(
111 client: AsyncClient,
112 db_session: AsyncSession,
113 ) -> None:
114 """Confirm the remote URL pattern matches Git: /{owner}/{slug}/refs — no /wire/ prefix."""
115 repo = await factory_create_repo(db_session, slug="git-style-test")
116 owner, slug = repo.owner, repo.slug
117
118 resp = await client.get(f"/{owner}/{slug}/refs")
119 assert resp.status_code == 200
120 # Should NOT need /wire/ in the path
121 resp_wire = await client.get(f"/wire/repos/{repo.repo_id}/refs")
122 assert resp_wire.status_code == 404
123
124
125 @pytest.mark.asyncio
126 async def test_refs_empty_repo_has_empty_branch_heads(
127 client: AsyncClient,
128 db_session: AsyncSession,
129 ) -> None:
130 repo = await factory_create_repo(db_session, slug="empty-test")
131 resp = await client.get(f"/{repo.owner}/{repo.slug}/refs")
132 assert resp.status_code == 200
133 data = resp.json()
134 assert data["branch_heads"] == {}
135
136
137 # ── push endpoint ──────────────────────────────────────────────────────────────
138
139 @pytest.mark.asyncio
140 async def test_push_requires_auth(client: AsyncClient, db_session: AsyncSession) -> None:
141 repo = await factory_create_repo(db_session, slug="push-auth-test", owner_user_id="test-user-wire")
142 resp = await client.post(
143 f"/{repo.owner}/{repo.slug}/push",
144 content=_mp({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}),
145 headers={"Content-Type": "application/x-msgpack"},
146 )
147 assert resp.status_code in (401, 403)
148
149
150 @pytest.mark.asyncio
151 async def test_push_404_for_unknown_repo(
152 client: AsyncClient,
153 wire_headers: StrDict,
154 ) -> None:
155 resp = await client.post(
156 "/nobody/no-such-repo/push",
157 content=_mp({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}),
158 headers=wire_headers,
159 )
160 assert resp.status_code == 404
161
162
163 @pytest.mark.asyncio
164 async def test_push_rejected_for_non_owner(
165 client: AsyncClient,
166 db_session: AsyncSession,
167 wire_headers: StrDict,
168 ) -> None:
169 """Authenticated user who is NOT the repo owner must be rejected."""
170 repo = await factory_create_repo(
171 db_session,
172 slug="push-nonowner-test",
173 owner_user_id="someone-else", # different from test-user-wire
174 )
175 resp = await client.post(
176 f"/{repo.owner}/{repo.slug}/push",
177 content=_mp({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}),
178 headers=wire_headers,
179 )
180 assert resp.status_code == 409
181 assert "not authorized" in resp.json()["detail"]
182
183
184 @pytest.mark.asyncio
185 async def test_push_ingests_commit_and_branch(
186 client: AsyncClient,
187 db_session: AsyncSession,
188 wire_headers: StrDict,
189 ) -> None:
190 repo = await factory_create_repo(db_session, slug="push-ingest-test", owner="test-user-wire")
191
192 commit_id = uuid.uuid4().hex
193 obj = _make_object()
194 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
195 snap = _make_snapshot(snap_id, obj["object_id"])
196 commit = _make_commit(repo.repo_id, commit_id=commit_id)
197 commit["snapshot_id"] = snap_id
198
199 payload = {
200 "bundle": {
201 "commits": [commit],
202 "snapshots": [snap],
203 "objects": [obj],
204 },
205 "branch": "main",
206 "force": False,
207 }
208 resp = await client.post(
209 f"/{repo.owner}/{repo.slug}/push",
210 content=_mp(payload),
211 headers=wire_headers,
212 )
213 assert resp.status_code == 200, resp.text
214 data = msgpack.unpackb(resp.content, raw=False)
215 assert data["ok"] is True
216 assert "main" in data["branch_heads"]
217 assert data["remote_head"] == commit_id
218
219
220 @pytest.mark.asyncio
221 async def test_push_is_idempotent(
222 client: AsyncClient,
223 db_session: AsyncSession,
224 wire_headers: StrDict,
225 ) -> None:
226 """Pushing the same commit twice must succeed both times."""
227 repo = await factory_create_repo(db_session, slug="push-idempotent-test", owner="test-user-wire")
228 commit = _make_commit(repo.repo_id)
229 payload = {
230 "bundle": {"commits": [commit], "snapshots": [], "objects": []},
231 "branch": "main",
232 }
233 url = f"/{repo.owner}/{repo.slug}/push"
234 resp1 = await client.post(url, content=_mp(payload), headers=wire_headers)
235 assert resp1.status_code == 200
236 resp2 = await client.post(url, content=_mp(payload), headers=wire_headers)
237 assert resp2.status_code == 200
238
239
240 @pytest.mark.asyncio
241 async def test_push_non_fast_forward_rejected(
242 client: AsyncClient,
243 db_session: AsyncSession,
244 wire_headers: StrDict,
245 ) -> None:
246 repo = await factory_create_repo(db_session, slug="push-nff-test", owner="test-user-wire")
247 existing_commit_id = uuid.uuid4().hex
248 branch = db.MusehubBranch(
249 repo_id=repo.repo_id,
250 name="main",
251 head_commit_id=existing_commit_id,
252 )
253 db_session.add(branch)
254 await db_session.commit()
255
256 # Push a commit without existing_commit_id as parent
257 new_commit = _make_commit(repo.repo_id, parent=None)
258 payload = {
259 "bundle": {"commits": [new_commit], "snapshots": [], "objects": []},
260 "branch": "main",
261 "force": False,
262 }
263 resp = await client.post(f"/{repo.owner}/{repo.slug}/push", content=_mp(payload), headers=wire_headers)
264 assert resp.status_code == 409 # 409 Conflict for non-fast-forward
265 assert "non-fast-forward" in resp.json()["detail"]
266
267
268 @pytest.mark.asyncio
269 async def test_push_force_overwrites_branch(
270 client: AsyncClient,
271 db_session: AsyncSession,
272 wire_headers: StrDict,
273 ) -> None:
274 repo = await factory_create_repo(db_session, slug="push-force-test", owner="test-user-wire")
275 old_head = uuid.uuid4().hex
276 branch = db.MusehubBranch(
277 repo_id=repo.repo_id,
278 name="main",
279 head_commit_id=old_head,
280 )
281 db_session.add(branch)
282 await db_session.commit()
283
284 new_commit = _make_commit(repo.repo_id, parent=None)
285 payload = {
286 "bundle": {"commits": [new_commit], "snapshots": [], "objects": []},
287 "branch": "main",
288 "force": True,
289 }
290 resp = await client.post(f"/{repo.owner}/{repo.slug}/push", content=_mp(payload), headers=wire_headers)
291 assert resp.status_code == 200
292 data = msgpack.unpackb(resp.content, raw=False)
293 assert data["ok"] is True
294 assert data["branch_heads"]["main"] != old_head
295
296
297 # ── fetch endpoint ─────────────────────────────────────────────────────────────
298
299 @pytest.mark.asyncio
300 async def test_fetch_404_for_unknown_repo(client: AsyncClient) -> None:
301 resp = await client.post(
302 "/nobody/no-such-repo/fetch",
303 content=_mp({"want": [], "have": []}),
304 headers={"Content-Type": "application/x-msgpack"},
305 )
306 assert resp.status_code == 404
307
308
309 @pytest.mark.asyncio
310 async def test_fetch_empty_want_returns_empty_bundle(
311 client: AsyncClient,
312 db_session: AsyncSession,
313 ) -> None:
314 repo = await factory_create_repo(db_session, slug="fetch-empty-test")
315 resp = await client.post(
316 f"/{repo.owner}/{repo.slug}/fetch",
317 content=_mp({"want": [], "have": []}),
318 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
319 )
320 assert resp.status_code == 200
321 data = msgpack.unpackb(resp.content, raw=False)
322 assert data["commits"] == []
323 assert data["snapshots"] == []
324 # fetch returns only VCS metadata — no object bytes (use /fetch/objects for that)
325
326
327 @pytest.mark.asyncio
328 async def test_fetch_returns_missing_commits(
329 client: AsyncClient,
330 db_session: AsyncSession,
331 ) -> None:
332 repo = await factory_create_repo(db_session, slug="fetch-commits-test")
333 commit_id = uuid.uuid4().hex
334 commit_row = db.MusehubCommit(
335 commit_id=commit_id,
336 repo_id=repo.repo_id,
337 branch="main",
338 parent_ids=[],
339 message="initial commit",
340 author="Test",
341 timestamp=_utc_now(),
342 snapshot_id=None,
343 commit_meta={},
344 )
345 branch_row = db.MusehubBranch(
346 repo_id=repo.repo_id,
347 name="main",
348 head_commit_id=commit_id,
349 )
350 db_session.add(commit_row)
351 db_session.add(branch_row)
352 await db_session.commit()
353
354 resp = await client.post(
355 f"/{repo.owner}/{repo.slug}/fetch",
356 content=_mp({"want": [commit_id], "have": []}),
357 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
358 )
359 assert resp.status_code == 200
360 data = msgpack.unpackb(resp.content, raw=False)
361 assert len(data["commits"]) == 1
362 assert data["commits"][0]["commit_id"] == commit_id
363 assert data["branch_heads"]["main"] == commit_id
364
365
366 # ── content-addressed CDN ──────────────────────────────────────────────────────
367
368 @pytest.mark.asyncio
369 async def test_object_cdn_returns_404_for_missing(client: AsyncClient) -> None:
370 resp = await client.get("/o/nonexistent-sha-12345")
371 assert resp.status_code == 404
372
373
374 # ── unit tests ─────────────────────────────────────────────────────────────────
375
376 @pytest.mark.asyncio
377 async def test_wire_models_parse_correctly() -> None:
378 """WireBundle Pydantic parsing mirrors Muse CLI format."""
379 from musehub.models.wire import WireBundle, WireCommit, WirePushRequest
380
381 commit_dict = {
382 "commit_id": "abc123",
383 "message": "feat: add track",
384 "committed_at": "2026-03-19T10:00:00+00:00",
385 "author": "Gabriel <[email protected]>",
386 "sem_ver_bump": "minor",
387 "breaking_changes": [],
388 "agent_id": "",
389 "format_version": 5,
390 }
391 req = WirePushRequest(
392 bundle=WireBundle(commits=[WireCommit.model_validate(commit_dict)], snapshots=[], objects=[]),
393 branch="main",
394 force=False,
395 )
396 assert req.bundle.commits[0].commit_id == "abc123"
397 assert req.bundle.commits[0].sem_ver_bump == "minor"
398 assert req.force is False
399
400
401 @pytest.mark.asyncio
402 async def test_topological_sort_orders_parents_first() -> None:
403 from musehub.models.wire import WireCommit
404 from musehub.services.musehub_wire import _topological_sort
405
406 c1 = WireCommit(commit_id="parent", message="parent")
407 c2 = WireCommit(commit_id="child", message="child", parent_commit_id="parent")
408 sorted_ = _topological_sort([c2, c1])
409 ids = [c.commit_id for c in sorted_]
410 assert ids.index("parent") < ids.index("child")
411
412
413 @pytest.mark.asyncio
414 async def test_remote_url_format_matches_git_pattern(
415 client: AsyncClient,
416 db_session: AsyncSession,
417 ) -> None:
418 """The remote URL is /{owner}/{slug} — no /wire/ prefix, no UUID.
419
420 This mirrors Git:
421 git remote add origin https://github.com/owner/repo
422 versus UUID-based alternatives like:
423 muse remote add origin https://musehub.ai/wire/repos/550e8400-.../
424 """
425 repo = await factory_create_repo(db_session, slug="url-format-test")
426
427 # /{owner}/{slug}/refs must work
428 resp = await client.get(f"/{repo.owner}/{repo.slug}/refs")
429 assert resp.status_code == 200
430
431 # The response confirms which repo was resolved — no UUID needed in the URL
432 data = resp.json()
433 assert data["repo_id"] == repo.repo_id
434
435
436 # ── filter-objects endpoint (MWP Phase 1) ───────────────────────────────────
437
438 @pytest.mark.asyncio
439 async def test_filter_objects_requires_auth(
440 client: AsyncClient,
441 db_session: AsyncSession,
442 ) -> None:
443 repo = await factory_create_repo(db_session, slug="filter-auth-test")
444 resp = await client.post(
445 f"/{repo.owner}/{repo.slug}/filter-objects",
446 content=_mp({"object_ids": ["abc123"]}),
447 headers={"Content-Type": "application/x-msgpack"},
448 )
449 assert resp.status_code in (401, 403)
450
451
452 @pytest.mark.asyncio
453 async def test_filter_objects_empty_list_returns_empty(
454 client: AsyncClient,
455 db_session: AsyncSession,
456 wire_headers: StrDict,
457 ) -> None:
458 repo = await factory_create_repo(db_session, slug="filter-empty-test", owner="test-user-wire")
459 resp = await client.post(
460 f"/{repo.owner}/{repo.slug}/filter-objects",
461 content=_mp({"object_ids": []}),
462 headers=wire_headers,
463 )
464 assert resp.status_code == 200
465 data = msgpack.unpackb(resp.content, raw=False)
466 assert data["missing"] == []
467
468
469 @pytest.mark.asyncio
470 async def test_filter_objects_all_missing(
471 client: AsyncClient,
472 db_session: AsyncSession,
473 wire_headers: StrDict,
474 ) -> None:
475 repo = await factory_create_repo(db_session, slug="filter-all-missing-test", owner="test-user-wire")
476 oids = [uuid.uuid4().hex for _ in range(5)]
477 resp = await client.post(
478 f"/{repo.owner}/{repo.slug}/filter-objects",
479 content=_mp({"object_ids": oids}),
480 headers=wire_headers,
481 )
482 assert resp.status_code == 200
483 data = msgpack.unpackb(resp.content, raw=False)
484 assert set(data["missing"]) == set(oids)
485
486
487 @pytest.mark.asyncio
488 async def test_filter_objects_returns_only_missing(
489 client: AsyncClient,
490 db_session: AsyncSession,
491 wire_headers: StrDict,
492 ) -> None:
493 """Push one object, then filter-objects — it should NOT appear in missing."""
494 repo = await factory_create_repo(db_session, slug="filter-delta-test", owner="test-user-wire")
495
496 obj = _make_object(b"stored content")
497 commit = _make_commit(repo.repo_id)
498 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
499 snap = _make_snapshot(snap_id, obj["object_id"])
500 commit["snapshot_id"] = snap_id
501
502 push_payload = {"bundle": {"commits": [commit], "snapshots": [snap], "objects": [obj]}, "branch": "main"}
503 r = await client.post(f"/{repo.owner}/{repo.slug}/push", content=_mp(push_payload), headers=wire_headers)
504 assert r.status_code == 200
505
506 new_oid = uuid.uuid4().hex
507 resp = await client.post(
508 f"/{repo.owner}/{repo.slug}/filter-objects",
509 content=_mp({"object_ids": [obj["object_id"], new_oid]}),
510 headers=wire_headers,
511 )
512 assert resp.status_code == 200
513 data = msgpack.unpackb(resp.content, raw=False)
514 assert obj["object_id"] not in data["missing"]
515 assert new_oid in data["missing"]
516
517
518 @pytest.mark.asyncio
519 async def test_filter_objects_accepts_json_body(
520 client: AsyncClient,
521 db_session: AsyncSession,
522 wire_headers: StrDict,
523 ) -> None:
524 """filter-objects must accept application/json as well as msgpack."""
525 repo = await factory_create_repo(db_session, slug="filter-json-test", owner="test-user-wire")
526 import json as _json
527 headers = {**wire_headers, "Content-Type": "application/json"}
528 resp = await client.post(
529 f"/{repo.owner}/{repo.slug}/filter-objects",
530 content=_json.dumps({"object_ids": ["abc"]}).encode(),
531 headers=headers,
532 )
533 assert resp.status_code == 200
534
535
536 # ── presign endpoint (MWP Phase 3) ──────────────────────────────────────────
537
538 @pytest.mark.asyncio
539 async def test_presign_local_backend_returns_all_inline(
540 client: AsyncClient,
541 db_session: AsyncSession,
542 wire_headers: StrDict,
543 ) -> None:
544 """Local backend has no presign_put/get; all object IDs must be returned in inline."""
545 repo = await factory_create_repo(db_session, slug="presign-inline-test", owner="test-user-wire")
546 oids = [uuid.uuid4().hex for _ in range(3)]
547 resp = await client.post(
548 f"/{repo.owner}/{repo.slug}/presign",
549 content=_mp({"object_ids": oids, "direction": "put", "ttl_seconds": 3600}),
550 headers=wire_headers,
551 )
552 assert resp.status_code == 200
553 data = msgpack.unpackb(resp.content, raw=False)
554 assert set(data["inline"]) == set(oids)
555 assert data["presigned"] == {}
556
557
558 @pytest.mark.asyncio
559 async def test_presign_requires_auth(
560 client: AsyncClient,
561 db_session: AsyncSession,
562 ) -> None:
563 repo = await factory_create_repo(db_session, slug="presign-auth-test")
564 resp = await client.post(
565 f"/{repo.owner}/{repo.slug}/presign",
566 content=_mp({"object_ids": ["abc"], "direction": "put", "ttl_seconds": 300}),
567 headers={"Content-Type": "application/x-msgpack"},
568 )
569 assert resp.status_code in (401, 403)
570
571
572 @pytest.mark.asyncio
573 async def test_presign_get_local_backend_returns_all_inline(
574 client: AsyncClient,
575 db_session: AsyncSession,
576 wire_headers: StrDict,
577 ) -> None:
578 """direction=get also falls back to inline on local backend."""
579 repo = await factory_create_repo(db_session, slug="presign-get-test", owner="test-user-wire")
580 oids = [uuid.uuid4().hex]
581 resp = await client.post(
582 f"/{repo.owner}/{repo.slug}/presign",
583 content=_mp({"object_ids": oids, "direction": "get", "ttl_seconds": 300}),
584 headers=wire_headers,
585 )
586 assert resp.status_code == 200
587 data = msgpack.unpackb(resp.content, raw=False)
588 assert oids[0] in data["inline"]
589
590
591 # ── negotiate endpoint (MWP Phase 5) ────────────────────────────────────────
592
593 @pytest.mark.asyncio
594 async def test_negotiate_full_clone_ready_immediately(
595 client: AsyncClient,
596 db_session: AsyncSession,
597 ) -> None:
598 """When client has no have-IDs (full clone), ready must be True immediately."""
599 repo = await factory_create_repo(db_session, slug="negotiate-clone-test")
600 commit_id = uuid.uuid4().hex
601 commit_row = db.MusehubCommit(
602 commit_id=commit_id,
603 repo_id=repo.repo_id,
604 branch="main",
605 parent_ids=[],
606 message="initial",
607 author="Test",
608 timestamp=_utc_now(),
609 snapshot_id=None,
610 commit_meta={},
611 )
612 db_session.add(commit_row)
613 await db_session.commit()
614
615 resp = await client.post(
616 f"/{repo.owner}/{repo.slug}/negotiate",
617 content=_mp({"have": [], "want": [commit_id]}),
618 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
619 )
620 assert resp.status_code == 200
621 data = msgpack.unpackb(resp.content, raw=False)
622 assert data["ready"] is True
623 assert data["ack"] == []
624
625
626 @pytest.mark.asyncio
627 async def test_negotiate_acks_known_have_ids(
628 client: AsyncClient,
629 db_session: AsyncSession,
630 ) -> None:
631 """Server acks have-IDs it recognises, reports common_base, and sets ready=True."""
632 repo = await factory_create_repo(db_session, slug="negotiate-ack-test")
633
634 parent_id = uuid.uuid4().hex
635 child_id = uuid.uuid4().hex
636 parent_row = db.MusehubCommit(
637 commit_id=parent_id, repo_id=repo.repo_id, branch="main",
638 parent_ids=[], message="parent", author="T", timestamp=_utc_now(),
639 snapshot_id=None, commit_meta={},
640 )
641 child_row = db.MusehubCommit(
642 commit_id=child_id, repo_id=repo.repo_id, branch="main",
643 parent_ids=[parent_id], message="child", author="T", timestamp=_utc_now(),
644 snapshot_id=None, commit_meta={},
645 )
646 db_session.add(parent_row)
647 db_session.add(child_row)
648 await db_session.commit()
649
650 resp = await client.post(
651 f"/{repo.owner}/{repo.slug}/negotiate",
652 content=_mp({"have": [parent_id], "want": [child_id]}),
653 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
654 )
655 assert resp.status_code == 200
656 data = msgpack.unpackb(resp.content, raw=False)
657 assert parent_id in data["ack"]
658 assert data["common_base"] == parent_id
659 assert data["ready"] is True
660
661
662 @pytest.mark.asyncio
663 async def test_negotiate_404_for_unknown_repo(client: AsyncClient) -> None:
664 resp = await client.post(
665 "/nobody/no-such/negotiate",
666 content=_mp({"have": [], "want": []}),
667 headers={"Content-Type": "application/x-msgpack"},
668 )
669 assert resp.status_code == 404
670
671
672 # ── push/objects endpoint (chunked pre-upload) ────────────────────────────────
673
674 @pytest.mark.asyncio
675 async def test_push_objects_stores_objects(
676 client: AsyncClient,
677 db_session: AsyncSession,
678 wire_headers: StrDict,
679 ) -> None:
680 repo = await factory_create_repo(db_session, slug="push-objects-store-test", owner="test-user-wire")
681 obj = _make_object(b"chunked content")
682 resp = await client.post(
683 f"/{repo.owner}/{repo.slug}/push/objects",
684 content=_mp({"objects": [obj]}),
685 headers=wire_headers,
686 )
687 assert resp.status_code == 200
688 data = msgpack.unpackb(resp.content, raw=False)
689 assert data["stored"] == 1
690 assert data["skipped"] == 0
691
692
693 @pytest.mark.asyncio
694 async def test_push_objects_skips_duplicates(
695 client: AsyncClient,
696 db_session: AsyncSession,
697 wire_headers: StrDict,
698 ) -> None:
699 """Uploading the same object twice: second upload counts as skipped."""
700 repo = await factory_create_repo(db_session, slug="push-objects-dedup-test", owner="test-user-wire")
701 obj = _make_object(b"dedup me")
702 url = f"/{repo.owner}/{repo.slug}/push/objects"
703
704 r1 = await client.post(url, content=_mp({"objects": [obj]}), headers=wire_headers)
705 assert r1.status_code == 200
706 d1 = msgpack.unpackb(r1.content, raw=False)
707 assert d1["stored"] == 1
708
709 r2 = await client.post(url, content=_mp({"objects": [obj]}), headers=wire_headers)
710 assert r2.status_code == 200
711 d2 = msgpack.unpackb(r2.content, raw=False)
712 assert d2["skipped"] == 1
713
714
715 @pytest.mark.asyncio
716 async def test_push_objects_requires_auth(
717 client: AsyncClient,
718 db_session: AsyncSession,
719 ) -> None:
720 repo = await factory_create_repo(db_session, slug="push-objects-auth-test")
721 resp = await client.post(
722 f"/{repo.owner}/{repo.slug}/push/objects",
723 content=_mp({"objects": []}),
724 headers={"Content-Type": "application/x-msgpack"},
725 )
726 assert resp.status_code in (401, 403)
727
728
729 # ── CDN endpoint ──────────────────────────────────────────────────────────────
730
731 @pytest.mark.asyncio
732 async def test_object_cdn_serves_pushed_object(
733 client: AsyncClient,
734 db_session: AsyncSession,
735 wire_headers: StrDict,
736 ) -> None:
737 """Object pushed via push/objects must be retrievable via the CDN endpoint."""
738 repo = await factory_create_repo(db_session, slug="cdn-happy-path-test", owner="test-user-wire")
739 content = b"cdn test content"
740 obj = _make_object(content)
741
742 upload = await client.post(
743 f"/{repo.owner}/{repo.slug}/push/objects",
744 content=_mp({"objects": [obj]}),
745 headers=wire_headers,
746 )
747 assert upload.status_code == 200
748
749 cdn_resp = await client.get(f"/o/{obj['object_id']}?repo_id={repo.repo_id}")
750 assert cdn_resp.status_code == 200
751 assert cdn_resp.content == content
752 assert cdn_resp.headers["cache-control"].startswith("public")
753
754
755 @pytest.mark.asyncio
756 async def test_object_cdn_has_immutable_cache_headers(
757 client: AsyncClient,
758 db_session: AsyncSession,
759 wire_headers: StrDict,
760 ) -> None:
761 repo = await factory_create_repo(db_session, slug="cdn-cache-test", owner="test-user-wire")
762 obj = _make_object(b"immutable blob")
763 await client.post(
764 f"/{repo.owner}/{repo.slug}/push/objects",
765 content=_mp({"objects": [obj]}),
766 headers=wire_headers,
767 )
768 cdn = await client.get(f"/o/{obj['object_id']}?repo_id={repo.repo_id}")
769 assert "immutable" in cdn.headers.get("cache-control", "")
770 assert cdn.headers.get("etag", "").strip('"') == obj["object_id"]
771
772
773 # ── private repo visibility ────────────────────────────────────────────────────
774
775 @pytest.mark.asyncio
776 async def test_refs_private_repo_returns_404_for_unauthenticated(
777 client: AsyncClient,
778 db_session: AsyncSession,
779 ) -> None:
780 """Private repos must be invisible to unauthenticated callers."""
781 repo = await factory_create_repo(db_session, slug="private-refs-test", visibility="private")
782 resp = await client.get(f"/{repo.owner}/{repo.slug}/refs")
783 assert resp.status_code == 404
784
785
786 @pytest.mark.asyncio
787 async def test_refs_private_repo_visible_to_owner(
788 client: AsyncClient,
789 db_session: AsyncSession,
790 wire_headers: StrDict,
791 ) -> None:
792 repo = await factory_create_repo(
793 db_session, slug="private-owner-test",
794 owner="test-user-wire", visibility="private",
795 )
796 resp = await client.get(f"/{repo.owner}/{repo.slug}/refs", headers=wire_headers)
797 assert resp.status_code == 200
798
799
800 @pytest.mark.asyncio
801 async def test_fetch_private_repo_returns_404_for_stranger(
802 client: AsyncClient,
803 db_session: AsyncSession,
804 ) -> None:
805 """Private repo fetch must 404 for unauthenticated stranger."""
806 repo = await factory_create_repo(db_session, slug="private-fetch-test", visibility="private")
807 resp = await client.post(
808 f"/{repo.owner}/{repo.slug}/fetch",
809 content=_mp({"want": [], "have": []}),
810 headers={"Content-Type": "application/x-msgpack"},
811 )
812 assert resp.status_code == 404
813
814
815 # ── collaborator auth ──────────────────────────────────────────────────────────
816
817 @pytest.mark.asyncio
818 async def test_push_write_collaborator_is_allowed(
819 client: AsyncClient,
820 db_session: AsyncSession,
821 wire_headers: StrDict,
822 ) -> None:
823 """A write collaborator must be permitted to push."""
824 repo = await factory_create_repo(db_session, slug="collab-write-push-test", owner="repo-owner-different")
825 collab = MusehubCollaborator(
826 repo_id=repo.repo_id,
827 identity_handle="test-user-wire",
828 permission="write",
829 accepted_at=_utc_now(),
830 )
831 db_session.add(collab)
832 await db_session.commit()
833
834 commit = _make_commit(repo.repo_id)
835 payload = {"bundle": {"commits": [commit], "snapshots": [], "objects": []}, "branch": "main"}
836 resp = await client.post(
837 f"/{repo.owner}/{repo.slug}/push",
838 content=_mp(payload),
839 headers=wire_headers,
840 )
841 assert resp.status_code == 200
842 data = msgpack.unpackb(resp.content, raw=False)
843 assert data["ok"] is True
844
845
846 @pytest.mark.asyncio
847 async def test_push_read_collaborator_is_rejected(
848 client: AsyncClient,
849 db_session: AsyncSession,
850 wire_headers: StrDict,
851 ) -> None:
852 """A read-only collaborator must NOT be able to push."""
853 repo = await factory_create_repo(db_session, slug="collab-read-push-test", owner="repo-owner-different-2")
854 collab = MusehubCollaborator(
855 repo_id=repo.repo_id,
856 identity_handle="test-user-wire",
857 permission="read",
858 accepted_at=_utc_now(),
859 )
860 db_session.add(collab)
861 await db_session.commit()
862
863 commit = _make_commit(repo.repo_id)
864 payload = {"bundle": {"commits": [commit], "snapshots": [], "objects": []}, "branch": "main"}
865 resp = await client.post(
866 f"/{repo.owner}/{repo.slug}/push",
867 content=_mp(payload),
868 headers=wire_headers,
869 )
870 assert resp.status_code == 409
871 assert "not authorized" in resp.json()["detail"]
872
873
874 @pytest.mark.asyncio
875 async def test_push_unaccepted_invite_is_rejected(
876 client: AsyncClient,
877 db_session: AsyncSession,
878 wire_headers: StrDict,
879 ) -> None:
880 """A collaborator with a pending (unaccepted) invite must NOT be able to push."""
881 repo = await factory_create_repo(db_session, slug="collab-pending-push-test", owner="repo-owner-different-3")
882 collab = MusehubCollaborator(
883 repo_id=repo.repo_id,
884 identity_handle="test-user-wire",
885 permission="write",
886 accepted_at=None, # not yet accepted
887 )
888 db_session.add(collab)
889 await db_session.commit()
890
891 commit = _make_commit(repo.repo_id)
892 payload = {"bundle": {"commits": [commit], "snapshots": [], "objects": []}, "branch": "main"}
893 resp = await client.post(
894 f"/{repo.owner}/{repo.slug}/push",
895 content=_mp(payload),
896 headers=wire_headers,
897 )
898 assert resp.status_code == 409
899 assert "not authorized" in resp.json()["detail"]
900
901
902 # ── delete branch endpoint ────────────────────────────────────────────────────
903
904 @pytest.mark.asyncio
905 async def test_delete_branch_owner_can_delete(
906 client: AsyncClient,
907 db_session: AsyncSession,
908 wire_headers: StrDict,
909 ) -> None:
910 repo = await factory_create_repo(db_session, slug="delete-branch-test", owner="test-user-wire")
911 branch = db.MusehubBranch(
912 repo_id=repo.repo_id,
913 name="feat/old",
914 head_commit_id=uuid.uuid4().hex,
915 )
916 db_session.add(branch)
917 await db_session.commit()
918
919 resp = await client.delete(
920 f"/{repo.owner}/{repo.slug}/branches/feat/old",
921 headers=wire_headers,
922 )
923 assert resp.status_code == 200
924 data = msgpack.unpackb(resp.content, raw=False)
925 assert data["deleted"] == "feat/old"
926
927
928 @pytest.mark.asyncio
929 async def test_delete_branch_cannot_delete_default(
930 client: AsyncClient,
931 db_session: AsyncSession,
932 wire_headers: StrDict,
933 ) -> None:
934 repo = await factory_create_repo(db_session, slug="delete-default-branch-test", owner="test-user-wire")
935 branch = db.MusehubBranch(
936 repo_id=repo.repo_id,
937 name="main",
938 head_commit_id=uuid.uuid4().hex,
939 )
940 db_session.add(branch)
941 await db_session.commit()
942
943 resp = await client.delete(
944 f"/{repo.owner}/{repo.slug}/branches/main",
945 headers=wire_headers,
946 )
947 assert resp.status_code == 409
948 assert "default branch" in resp.json()["detail"]
949
950
951 @pytest.mark.asyncio
952 async def test_delete_branch_non_owner_gets_403(
953 client: AsyncClient,
954 db_session: AsyncSession,
955 wire_headers: StrDict,
956 ) -> None:
957 repo = await factory_create_repo(db_session, slug="delete-branch-403-test", owner="someone-else")
958 branch = db.MusehubBranch(
959 repo_id=repo.repo_id,
960 name="feat/x",
961 head_commit_id=uuid.uuid4().hex,
962 )
963 db_session.add(branch)
964 await db_session.commit()
965
966 resp = await client.delete(
967 f"/{repo.owner}/{repo.slug}/branches/feat/x",
968 headers=wire_headers,
969 )
970 assert resp.status_code == 403
971
972
973 @pytest.mark.asyncio
974 async def test_delete_branch_missing_branch_404(
975 client: AsyncClient,
976 db_session: AsyncSession,
977 wire_headers: StrDict,
978 ) -> None:
979 repo = await factory_create_repo(db_session, slug="delete-branch-404-test", owner="test-user-wire")
980 resp = await client.delete(
981 f"/{repo.owner}/{repo.slug}/branches/does-not-exist",
982 headers=wire_headers,
983 )
984 assert resp.status_code == 404
985
986
987 # ── delete release endpoint ───────────────────────────────────────────────────
988
989 @pytest.mark.asyncio
990 async def test_delete_release_non_owner_gets_403(
991 client: AsyncClient,
992 db_session: AsyncSession,
993 wire_headers: StrDict,
994 ) -> None:
995 repo = await factory_create_repo(db_session, slug="delete-release-403-test", owner="someone-else")
996 resp = await client.delete(
997 f"/{repo.owner}/{repo.slug}/releases/1.0.0",
998 headers=wire_headers,
999 )
1000 assert resp.status_code == 403
1001
1002
1003 @pytest.mark.asyncio
1004 async def test_delete_release_missing_tag_404(
1005 client: AsyncClient,
1006 db_session: AsyncSession,
1007 wire_headers: StrDict,
1008 ) -> None:
1009 repo = await factory_create_repo(db_session, slug="delete-release-404-test", owner="test-user-wire")
1010 resp = await client.delete(
1011 f"/{repo.owner}/{repo.slug}/releases/99.0.0",
1012 headers=wire_headers,
1013 )
1014 assert resp.status_code == 404
1015
1016
1017 # ── push tags endpoint ────────────────────────────────────────────────────────
1018
1019 @pytest.mark.asyncio
1020 async def test_push_tags_stores_tags(
1021 client: AsyncClient,
1022 db_session: AsyncSession,
1023 wire_headers: StrDict,
1024 ) -> None:
1025 repo = await factory_create_repo(db_session, slug="push-tags-test", owner="test-user-wire")
1026 tag = {
1027 "tag_id": uuid.uuid4().hex,
1028 "commit_id": uuid.uuid4().hex,
1029 "tag": "status:reviewed",
1030 "created_at": _utc_now().isoformat(),
1031 }
1032 resp = await client.post(
1033 f"/{repo.owner}/{repo.slug}/tags",
1034 content=_mp({"tags": [tag]}),
1035 headers=wire_headers,
1036 )
1037 assert resp.status_code == 200
1038 data = msgpack.unpackb(resp.content, raw=False)
1039 assert data["stored"] == 1
1040
1041
1042 @pytest.mark.asyncio
1043 async def test_push_tags_invalid_body_422(
1044 client: AsyncClient,
1045 db_session: AsyncSession,
1046 wire_headers: StrDict,
1047 ) -> None:
1048 repo = await factory_create_repo(db_session, slug="push-tags-422-test", owner="test-user-wire")
1049 resp = await client.post(
1050 f"/{repo.owner}/{repo.slug}/tags",
1051 content=_mp({"tags": "not-a-list"}),
1052 headers=wire_headers,
1053 )
1054 assert resp.status_code == 422
1055
1056
1057 @pytest.mark.asyncio
1058 async def test_push_tags_requires_auth(
1059 client: AsyncClient,
1060 db_session: AsyncSession,
1061 ) -> None:
1062 repo = await factory_create_repo(db_session, slug="push-tags-auth-test")
1063 resp = await client.post(
1064 f"/{repo.owner}/{repo.slug}/tags",
1065 content=_mp({"tags": []}),
1066 headers={"Content-Type": "application/x-msgpack"},
1067 )
1068 assert resp.status_code in (401, 403)
1069
1070
1071 # ── DoS limits (Pydantic model validation) ────────────────────────────────────
1072
1073 def test_wire_object_rejects_oversized_content() -> None:
1074 """WireObject must reject content larger than MAX_OBJECT_BYTES."""
1075 from pydantic import ValidationError
1076 from musehub.models.wire import WireObject, MAX_OBJECT_BYTES
1077
1078 oversized = b"x" * (MAX_OBJECT_BYTES + 1)
1079 with pytest.raises(ValidationError, match="content"):
1080 WireObject(object_id="abc", content=oversized, path="big.bin")
1081
1082
1083 def test_wire_bundle_rejects_too_many_objects() -> None:
1084 """WireBundle.objects list must be capped at MAX_OBJECTS_PER_PUSH."""
1085 from pydantic import ValidationError
1086 from musehub.models.wire import WireBundle, MAX_OBJECTS_PER_PUSH
1087
1088 objs = [{"object_id": uuid.uuid4().hex, "content": b"x", "path": "f"} for _ in range(MAX_OBJECTS_PER_PUSH + 1)]
1089 with pytest.raises(ValidationError):
1090 WireBundle(commits=[], snapshots=[], objects=objs)
1091
1092
1093 def test_wire_bundle_rejects_too_many_commits() -> None:
1094 """WireBundle.commits list must be capped at MAX_COMMITS_PER_PUSH."""
1095 from pydantic import ValidationError
1096 from musehub.models.wire import WireBundle, WireCommit, MAX_COMMITS_PER_PUSH
1097
1098 commits = [WireCommit(commit_id=uuid.uuid4().hex, message="c") for _ in range(MAX_COMMITS_PER_PUSH + 1)]
1099 with pytest.raises(ValidationError):
1100 WireBundle(commits=commits, snapshots=[], objects=[])
1101
1102
1103 def test_wire_fetch_request_rejects_too_many_wants() -> None:
1104 """WireFetchRequest.want must be capped at MAX_WANT_PER_FETCH."""
1105 from pydantic import ValidationError
1106 from musehub.models.wire import WireFetchRequest, MAX_WANT_PER_FETCH
1107
1108 wants = [uuid.uuid4().hex for _ in range(MAX_WANT_PER_FETCH + 1)]
1109 with pytest.raises(ValidationError):
1110 WireFetchRequest(want=wants, have=[])
1111
1112
1113 # ── integration: push → fetch round-trip ──────────────────────────────────────
1114
1115 @pytest.mark.asyncio
1116 async def test_push_then_fetch_round_trip(
1117 client: AsyncClient,
1118 db_session: AsyncSession,
1119 wire_headers: StrDict,
1120 ) -> None:
1121 """Full round-trip: push a commit+snapshot+object, then fetch it back and verify content."""
1122 repo = await factory_create_repo(db_session, slug="round-trip-test", owner="test-user-wire")
1123
1124 content = b"round trip content"
1125 obj = _make_object(content)
1126 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
1127 snap = _make_snapshot(snap_id, obj["object_id"])
1128 commit_id = uuid.uuid4().hex
1129 commit = _make_commit(repo.repo_id, commit_id=commit_id)
1130 commit["snapshot_id"] = snap_id
1131
1132 push_resp = await client.post(
1133 f"/{repo.owner}/{repo.slug}/push",
1134 content=_mp({"bundle": {"commits": [commit], "snapshots": [snap], "objects": [obj]}, "branch": "main"}),
1135 headers=wire_headers,
1136 )
1137 assert push_resp.status_code == 200
1138
1139 # Phase 1: fetch VCS metadata (no object bytes)
1140 fetch_resp = await client.post(
1141 f"/{repo.owner}/{repo.slug}/fetch",
1142 content=_mp({"want": [commit_id], "have": []}),
1143 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1144 )
1145 assert fetch_resp.status_code == 200
1146 data = msgpack.unpackb(fetch_resp.content, raw=False)
1147 assert any(c["commit_id"] == commit_id for c in data["commits"])
1148 assert any(s["snapshot_id"] == snap_id for s in data["snapshots"])
1149
1150 # Phase 2: fetch object bytes
1151 objects_resp = await client.post(
1152 f"/{repo.owner}/{repo.slug}/fetch/objects",
1153 content=_mp({"object_ids": [obj["object_id"]]}),
1154 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1155 )
1156 assert objects_resp.status_code == 200
1157 objects_data = msgpack.unpackb(objects_resp.content, raw=False)
1158 assert any(bytes(o["content"]) == content for o in objects_data.get("objects", []))
1159
1160
1161 @pytest.mark.asyncio
1162 async def test_fetch_have_excludes_known_commits(
1163 client: AsyncClient,
1164 db_session: AsyncSession,
1165 wire_headers: StrDict,
1166 ) -> None:
1167 """Commits in have must not appear in the fetch response."""
1168 repo = await factory_create_repo(db_session, slug="fetch-have-test", owner="test-user-wire")
1169
1170 parent_id = uuid.uuid4().hex
1171 child_id = uuid.uuid4().hex
1172
1173 parent = _make_commit(repo.repo_id, commit_id=parent_id)
1174 child = _make_commit(repo.repo_id, commit_id=child_id, parent=parent_id)
1175
1176 for c in [parent, child]:
1177 push_resp = await client.post(
1178 f"/{repo.owner}/{repo.slug}/push",
1179 content=_mp({"bundle": {"commits": [c], "snapshots": [], "objects": []}, "branch": "main"}),
1180 headers=wire_headers,
1181 )
1182 assert push_resp.status_code == 200
1183
1184 # Client already has parent — should only receive child
1185 fetch_resp = await client.post(
1186 f"/{repo.owner}/{repo.slug}/fetch",
1187 content=_mp({"want": [child_id], "have": [parent_id]}),
1188 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1189 )
1190 assert fetch_resp.status_code == 200
1191 data = msgpack.unpackb(fetch_resp.content, raw=False)
1192 commit_ids = [c["commit_id"] for c in data["commits"]]
1193 assert child_id in commit_ids
1194 assert parent_id not in commit_ids
1195
1196
1197 # ── content negotiation ────────────────────────────────────────────────────────
1198
1199 @pytest.mark.asyncio
1200 async def test_refs_returns_json_when_no_msgpack_accept(
1201 client: AsyncClient,
1202 db_session: AsyncSession,
1203 ) -> None:
1204 """When Accept header is not msgpack, response must be application/json."""
1205 repo = await factory_create_repo(db_session, slug="content-neg-test")
1206 resp = await client.get(f"/{repo.owner}/{repo.slug}/refs", headers={"Accept": "application/json"})
1207 assert resp.status_code == 200
1208 assert "application/json" in resp.headers.get("content-type", "")
1209 data = resp.json()
1210 assert "repo_id" in data
1211
1212
1213 @pytest.mark.asyncio
1214 async def test_push_accepts_json_content_type(
1215 client: AsyncClient,
1216 db_session: AsyncSession,
1217 wire_headers: StrDict,
1218 ) -> None:
1219 """push endpoint must accept application/json bodies, not only msgpack."""
1220 import json as _json
1221 repo = await factory_create_repo(db_session, slug="push-json-ct-test", owner="test-user-wire")
1222 commit = _make_commit(repo.repo_id)
1223 payload = {"bundle": {"commits": [commit], "snapshots": [], "objects": []}, "branch": "main"}
1224 json_headers = {**wire_headers, "Content-Type": "application/json"}
1225 resp = await client.post(
1226 f"/{repo.owner}/{repo.slug}/push",
1227 content=_json.dumps(payload).encode(),
1228 headers=json_headers,
1229 )
1230 assert resp.status_code == 200
1231
1232
1233 # ── unit: _is_ancestor_in_bundle ──────────────────────────────────────────────
1234
1235 def test_is_ancestor_in_bundle_direct_parent() -> None:
1236 from musehub.models.wire import WireCommit
1237 from musehub.services.musehub_wire import _is_ancestor_in_bundle
1238
1239 parent = WireCommit(commit_id="p", message="parent")
1240 child = WireCommit(commit_id="c", message="child", parent_commit_id="p")
1241 assert _is_ancestor_in_bundle("p", [parent, child]) is True
1242
1243
1244 def test_is_ancestor_in_bundle_not_in_chain() -> None:
1245 from musehub.models.wire import WireCommit
1246 from musehub.services.musehub_wire import _is_ancestor_in_bundle
1247
1248 c1 = WireCommit(commit_id="a", message="a")
1249 c2 = WireCommit(commit_id="b", message="b", parent_commit_id="a")
1250 assert _is_ancestor_in_bundle("x", [c1, c2]) is False
1251
1252
1253 def test_is_ancestor_in_bundle_merge_commit() -> None:
1254 """Merge commits have two parents — both must be checked."""
1255 from musehub.models.wire import WireCommit
1256 from musehub.services.musehub_wire import _is_ancestor_in_bundle
1257
1258 p1 = WireCommit(commit_id="p1", message="first parent")
1259 p2 = WireCommit(commit_id="p2", message="second parent")
1260 merge = WireCommit(
1261 commit_id="m",
1262 message="merge",
1263 parent_commit_id="p1",
1264 parent2_commit_id="p2",
1265 )
1266 assert _is_ancestor_in_bundle("p2", [p1, p2, merge]) is True
1267
1268
1269 def test_topological_sort_handles_empty_list() -> None:
1270 from musehub.services.musehub_wire import _topological_sort
1271 assert _topological_sort([]) == []
1272
1273
1274 def test_topological_sort_handles_orphan_commits() -> None:
1275 """Commits with no parent relationship must all appear in the result."""
1276 from musehub.models.wire import WireCommit
1277 from musehub.services.musehub_wire import _topological_sort
1278
1279 commits = [WireCommit(commit_id=str(i), message=str(i)) for i in range(5)]
1280 sorted_ = _topological_sort(commits)
1281 assert len(sorted_) == 5
1282
1283
1284 # ── regression: empty-content objects (SHA-256("") = e3b0c44…) ───────────────
1285 #
1286 # Python's `not b""` is True, so a naive `if not content: continue` guard
1287 # silently drops empty files on push. These tests lock in the fix:
1288 # - push/objects: empty-content object is stored, not skipped
1289 # - push bundle: empty-content object in bundle is stored, not skipped
1290 # - fetch: empty-content object is returned inline (even if never
1291 # stored, the server synthesizes it from the known SHA)
1292
1293 _EMPTY_OID = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1294
1295
1296 @pytest.mark.asyncio
1297 async def test_push_objects_stores_empty_content_object(
1298 client: AsyncClient,
1299 db_session: AsyncSession,
1300 wire_headers: StrDict,
1301 ) -> None:
1302 """push/objects must store an object whose content is b'' (empty file).
1303
1304 Before the fix, `if not wire_obj.content: continue` treated b'' as
1305 falsy and silently dropped the object. The correct guard is
1306 `wire_obj.content is None`.
1307 """
1308 repo = await factory_create_repo(db_session, slug="empty-obj-push-test", owner="test-user-wire")
1309 empty_obj = {"object_id": _EMPTY_OID, "content": b"", "path": ".museattributes"}
1310
1311 resp = await client.post(
1312 f"/{repo.owner}/{repo.slug}/push/objects",
1313 content=_mp({"objects": [empty_obj]}),
1314 headers=wire_headers,
1315 )
1316 assert resp.status_code == 200
1317 data = msgpack.unpackb(resp.content, raw=False)
1318 assert data["stored"] == 1, f"empty object was not stored: {data}"
1319 assert data["skipped"] == 0
1320
1321
1322 @pytest.mark.asyncio
1323 async def test_push_bundle_stores_empty_content_object(
1324 client: AsyncClient,
1325 db_session: AsyncSession,
1326 wire_headers: StrDict,
1327 ) -> None:
1328 """push bundle must persist an object with content=b'' in the bundle.objects list."""
1329 repo = await factory_create_repo(db_session, slug="empty-obj-bundle-test", owner="test-user-wire")
1330
1331 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
1332 # Snapshot references both a normal file and an empty file
1333 snap = {
1334 "snapshot_id": snap_id,
1335 "manifest": {
1336 "README.md": uuid.uuid4().hex,
1337 ".museattributes": _EMPTY_OID,
1338 },
1339 "created_at": _utc_now().isoformat(),
1340 }
1341 normal_obj = _make_object(b"readme content")
1342 empty_obj = {"object_id": _EMPTY_OID, "content": b"", "path": ".museattributes"}
1343
1344 commit_id = uuid.uuid4().hex
1345 commit = _make_commit(repo.repo_id, commit_id=commit_id)
1346 commit["snapshot_id"] = snap_id
1347
1348 resp = await client.post(
1349 f"/{repo.owner}/{repo.slug}/push",
1350 content=_mp({
1351 "bundle": {
1352 "commits": [commit],
1353 "snapshots": [snap],
1354 "objects": [normal_obj, empty_obj],
1355 },
1356 "branch": "main",
1357 "force": False,
1358 }),
1359 headers=wire_headers,
1360 )
1361 assert resp.status_code == 200, resp.text
1362 data = msgpack.unpackb(resp.content, raw=False)
1363 assert data["ok"] is True
1364
1365 # Verify empty object survives a filter-objects check (i.e. it was stored)
1366 filter_resp = await client.post(
1367 f"/{repo.owner}/{repo.slug}/filter-objects",
1368 content=_mp({"object_ids": [_EMPTY_OID]}),
1369 headers=wire_headers,
1370 )
1371 assert filter_resp.status_code == 200
1372 filter_data = msgpack.unpackb(filter_resp.content, raw=False)
1373 assert _EMPTY_OID not in filter_data["missing"], (
1374 "empty object was dropped on push — 'not content' falsiness bug not fixed"
1375 )
1376
1377
1378 @pytest.mark.asyncio
1379 async def test_fetch_objects_returns_empty_object_inline(
1380 client: AsyncClient,
1381 db_session: AsyncSession,
1382 wire_headers: StrDict,
1383 ) -> None:
1384 """fetch/objects must synthesize the empty-content object inline even if never stored in DB.
1385
1386 The server synthesizes the EMPTY_OID on the fly when requested — no DB row
1387 or disk read needed. This covers repos pushed before the empty-object fix.
1388 """
1389 repo = await factory_create_repo(db_session, slug="empty-obj-fetch-test", owner="test-user-wire")
1390
1391 resp = await client.post(
1392 f"/{repo.owner}/{repo.slug}/fetch/objects",
1393 content=_mp({"object_ids": [_EMPTY_OID]}),
1394 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1395 )
1396 assert resp.status_code == 200
1397 data = msgpack.unpackb(resp.content, raw=False)
1398 objects = data.get("objects", [])
1399 assert len(objects) == 1
1400 assert objects[0]["object_id"] == _EMPTY_OID
1401 assert bytes(objects[0]["content"]) == b""
1402
1403
1404 # ── push self-healing: re-push recovers missing bytes ─────────────────────────
1405
1406 @pytest.mark.asyncio
1407 async def test_push_heals_missing_bytes(
1408 client: AsyncClient,
1409 db_session: AsyncSession,
1410 wire_headers: StrDict,
1411 ) -> None:
1412 """Re-pushing an object whose bytes are missing from disk must restore them.
1413
1414 Sequence:
1415 1. Push normally — DB row created, bytes written to disk.
1416 2. Delete the disk file to simulate data loss.
1417 3. Re-push the same object — wire_push must detect missing bytes and re-write.
1418 4. fetch/objects must return the original bytes.
1419 """
1420 from musehub.storage.backends import get_backend
1421
1422 repo = await factory_create_repo(db_session, slug="heal-missing-test", owner="test-user-wire")
1423 content = b"self-healing object content"
1424 obj = _make_object(content)
1425 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
1426 snap = _make_snapshot(snap_id, obj["object_id"])
1427 commit = _make_commit(repo.repo_id)
1428 commit["snapshot_id"] = snap_id
1429
1430 # 1. Push normally
1431 push_resp = await client.post(
1432 f"/{repo.owner}/{repo.slug}/push",
1433 content=_mp({"bundle": {"commits": [commit], "snapshots": [snap], "objects": [obj]}, "branch": "main"}),
1434 headers=wire_headers,
1435 )
1436 assert push_resp.status_code == 200, push_resp.text
1437
1438 # 2. Delete the disk file to simulate data loss
1439 backend = get_backend()
1440 disk_path = backend._path(repo.repo_id, obj["object_id"])
1441 assert disk_path.exists(), "bytes must be on disk after push"
1442 import stat as _stat
1443 disk_path.chmod(_stat.S_IRUSR | _stat.S_IWUSR) # make writable before unlink (put sets 0o444)
1444 disk_path.unlink()
1445 assert not disk_path.exists(), "sanity: file deleted"
1446
1447 # 3. Re-push — self-healing must restore the bytes
1448 push_resp2 = await client.post(
1449 f"/{repo.owner}/{repo.slug}/push",
1450 content=_mp({"bundle": {"commits": [commit], "snapshots": [snap], "objects": [obj]}, "branch": "main"}),
1451 headers=wire_headers,
1452 )
1453 assert push_resp2.status_code == 200, push_resp2.text
1454
1455 # 4. fetch/objects must now return the correct bytes
1456 fetch_resp = await client.post(
1457 f"/{repo.owner}/{repo.slug}/fetch/objects",
1458 content=_mp({"object_ids": [obj["object_id"]]}),
1459 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1460 )
1461 assert fetch_resp.status_code == 200, fetch_resp.text
1462 data = msgpack.unpackb(fetch_resp.content, raw=False)
1463 objects = data.get("objects", [])
1464 assert len(objects) == 1
1465 assert objects[0]["object_id"] == obj["object_id"]
1466 assert bytes(objects[0]["content"]) == content
1467
1468
1469 # ── fetch/objects endpoint (Phase 2 of two-phase fetch) ───────────────────────
1470
1471 @pytest.mark.asyncio
1472 async def test_fetch_objects_returns_bytes(
1473 client: AsyncClient,
1474 db_session: AsyncSession,
1475 wire_headers: StrDict,
1476 ) -> None:
1477 """Phase 2: /fetch/objects streams the bytes for requested object IDs."""
1478 repo = await factory_create_repo(
1479 db_session, slug="fetch-objects-test", owner="test-user-wire"
1480 )
1481 content = b"phase two object content"
1482 obj = _make_object(content)
1483 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
1484 snap = _make_snapshot(snap_id, obj["object_id"])
1485 commit = _make_commit(repo.repo_id)
1486 commit["snapshot_id"] = snap_id
1487
1488 push_resp = await client.post(
1489 f"/{repo.owner}/{repo.slug}/push",
1490 content=_mp({"bundle": {"commits": [commit], "snapshots": [snap], "objects": [obj]}, "branch": "main"}),
1491 headers=wire_headers,
1492 )
1493 assert push_resp.status_code == 200, push_resp.text
1494
1495 fetch_resp = await client.post(
1496 f"/{repo.owner}/{repo.slug}/fetch/objects",
1497 content=_mp({"object_ids": [obj["object_id"]]}),
1498 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1499 )
1500 assert fetch_resp.status_code == 200, fetch_resp.text
1501 data = msgpack.unpackb(fetch_resp.content, raw=False)
1502 objects = data.get("objects", [])
1503 assert len(objects) == 1
1504 assert objects[0]["object_id"] == obj["object_id"]
1505 assert bytes(objects[0]["content"]) == content
1506
1507
1508 @pytest.mark.asyncio
1509 async def test_fetch_objects_unknown_ids_returns_empty(
1510 client: AsyncClient,
1511 db_session: AsyncSession,
1512 ) -> None:
1513 """Unknown object IDs are silently omitted — no 404."""
1514 repo = await factory_create_repo(db_session, slug="fetch-objects-missing-test")
1515 resp = await client.post(
1516 f"/{repo.owner}/{repo.slug}/fetch/objects",
1517 content=_mp({"object_ids": ["nonexistent-id-abc123"]}),
1518 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1519 )
1520 assert resp.status_code == 200
1521 data = msgpack.unpackb(resp.content, raw=False)
1522 assert data.get("objects", []) == []
1523
1524
1525 @pytest.mark.asyncio
1526 async def test_fetch_objects_cross_repo_dedup(
1527 client: AsyncClient,
1528 db_session: AsyncSession,
1529 wire_headers: StrDict,
1530 ) -> None:
1531 """fetch/objects must return bytes for objects whose DB row belongs to a different repo.
1532
1533 Content-addressed objects are globally unique by hash. When repo_a pushes
1534 content X first, ``musehub_objects`` gets ``repo_id = A``. When repo_b
1535 pushes the same content, ``wire_push`` correctly skips the second byte-write
1536 (idempotent by hash), but no new DB row is created for repo_b.
1537
1538 A subsequent ``POST /fetch/objects`` for repo_b must still return the bytes —
1539 not silently omit the object — because knowing the hash is a sufficient
1540 content commitment (same hash → same bytes, always).
1541 """
1542 shared_oid = f"shared-dedup-oid-{uuid.uuid4().hex[:8]}"
1543 content = b"identical content pushed from two separate repos"
1544
1545 # ── Push to repo_a: creates the DB row with repo_id = A ──────────────────
1546 repo_a = await factory_create_repo(
1547 db_session, slug="cross-repo-dedup-a", owner="test-user-wire"
1548 )
1549 obj = {"object_id": shared_oid, "content": content, "path": "shared.txt"}
1550 snap_id_a = f"snap_{uuid.uuid4().hex[:8]}"
1551 snap_a = {
1552 "snapshot_id": snap_id_a,
1553 "manifest": {"shared.txt": shared_oid},
1554 "created_at": _utc_now().isoformat(),
1555 }
1556 commit_a = _make_commit(repo_a.repo_id)
1557 commit_a["snapshot_id"] = snap_id_a
1558
1559 push_a = await client.post(
1560 f"/{repo_a.owner}/{repo_a.slug}/push",
1561 content=_mp({
1562 "bundle": {"commits": [commit_a], "snapshots": [snap_a], "objects": [obj]},
1563 "branch": "main",
1564 }),
1565 headers=wire_headers,
1566 )
1567 assert push_a.status_code == 200, push_a.text
1568
1569 # ── Push same content to repo_b: server skips byte-write (idempotent) ────
1570 # No new DB row is created; the existing row still has repo_id = A.
1571 repo_b = await factory_create_repo(
1572 db_session, slug="cross-repo-dedup-b", owner="test-user-wire"
1573 )
1574 snap_id_b = f"snap_{uuid.uuid4().hex[:8]}"
1575 snap_b = {
1576 "snapshot_id": snap_id_b,
1577 "manifest": {"shared.txt": shared_oid},
1578 "created_at": _utc_now().isoformat(),
1579 }
1580 commit_b = _make_commit(repo_b.repo_id)
1581 commit_b["snapshot_id"] = snap_id_b
1582
1583 push_b = await client.post(
1584 f"/{repo_b.owner}/{repo_b.slug}/push",
1585 content=_mp({
1586 "bundle": {"commits": [commit_b], "snapshots": [snap_b], "objects": [obj]},
1587 "branch": "main",
1588 }),
1589 headers=wire_headers,
1590 )
1591 assert push_b.status_code == 200, push_b.text
1592
1593 # ── Phase 2 fetch from repo_b: bytes must be returned ────────────────────
1594 # Bug: WHERE repo_id = B AND object_id = X returns nothing (row has repo_id = A).
1595 resp = await client.post(
1596 f"/{repo_b.owner}/{repo_b.slug}/fetch/objects",
1597 content=_mp({"object_ids": [shared_oid]}),
1598 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1599 )
1600 assert resp.status_code == 200, resp.text
1601 data = msgpack.unpackb(resp.content, raw=False)
1602 objects = data.get("objects", [])
1603 assert len(objects) == 1, (
1604 f"Expected 1 object from repo_b, got {len(objects)}. "
1605 "Cross-repo dedup bug: DB row has repo_id=A so WHERE repo_id=B misses it."
1606 )
1607 assert objects[0]["object_id"] == shared_oid
1608 assert bytes(objects[0]["content"]) == content
1609
1610
1611 @pytest.mark.asyncio
1612 async def test_fetch_two_phase_separates_metadata_from_bytes(
1613 client: AsyncClient,
1614 db_session: AsyncSession,
1615 wire_headers: StrDict,
1616 ) -> None:
1617 """Two-phase protocol: Phase 1 returns only metadata; Phase 2 returns bytes.
1618
1619 This is the core architectural invariant:
1620 - POST /fetch → commits + snapshots + branch_heads (no objects key)
1621 - POST /fetch/objects → {"objects": [...]} with bytes
1622
1623 Separation ensures fetch latency is proportional to commit count (always
1624 small), not to repo object size (can be gigabytes).
1625 """
1626 repo = await factory_create_repo(
1627 db_session, slug="two-phase-fetch-test", owner="test-user-wire"
1628 )
1629 content = b"some file content for two-phase test"
1630 obj = _make_object(content)
1631 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
1632 snap = _make_snapshot(snap_id, obj["object_id"])
1633 commit_id = uuid.uuid4().hex
1634 commit = _make_commit(repo.repo_id, commit_id=commit_id)
1635 commit["snapshot_id"] = snap_id
1636
1637 push_resp = await client.post(
1638 f"/{repo.owner}/{repo.slug}/push",
1639 content=_mp({"bundle": {"commits": [commit], "snapshots": [snap], "objects": [obj]}, "branch": "main"}),
1640 headers=wire_headers,
1641 )
1642 assert push_resp.status_code == 200
1643
1644 # Phase 1: metadata only — no objects in response
1645 phase1 = await client.post(
1646 f"/{repo.owner}/{repo.slug}/fetch",
1647 content=_mp({"want": [commit_id], "have": []}),
1648 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1649 )
1650 assert phase1.status_code == 200
1651 p1_data = msgpack.unpackb(phase1.content, raw=False)
1652 assert len(p1_data["commits"]) == 1
1653 assert p1_data["commits"][0]["commit_id"] == commit_id
1654 assert "objects" not in p1_data or p1_data.get("objects") is None
1655
1656 # Phase 2: request the specific object ID — get bytes back
1657 phase2 = await client.post(
1658 f"/{repo.owner}/{repo.slug}/fetch/objects",
1659 content=_mp({"object_ids": [obj["object_id"]]}),
1660 headers={"Content-Type": "application/x-msgpack", "Accept": "application/x-msgpack"},
1661 )
1662 assert phase2.status_code == 200
1663 p2_data = msgpack.unpackb(phase2.content, raw=False)
1664 objects = p2_data.get("objects", [])
1665 assert len(objects) == 1
1666 assert objects[0]["object_id"] == obj["object_id"]
1667 assert bytes(objects[0]["content"]) == content
1668
1669
1670 # ── performance regression ─────────────────────────────────────────────────────
1671
1672 @pytest.mark.asyncio
1673 async def test_push_large_bundle_completes_under_budget(
1674 client: AsyncClient,
1675 db_session: AsyncSession,
1676 wire_headers: StrDict,
1677 ) -> None:
1678 """Large push bundle (100 commits × 1 snapshot each) must complete in < 5s.
1679
1680 This is the regression guard for the N+1 query bug fixed in wire_push:
1681 before the fix, 856 commits × per-row session.get() calls took 4m43s.
1682 After the fix (2 bulk SELECT INs + zero-DB-call loop), the same push
1683 is effectively O(1) round-trips regardless of bundle size.
1684
1685 100 commits is enough to catch a regression without inflating test time.
1686 """
1687 _BUDGET_SECONDS = 5.0
1688 _COMMIT_COUNT = 100
1689
1690 repo = await factory_create_repo(
1691 db_session, slug="perf-large-bundle", owner="test-user-wire"
1692 )
1693
1694 # Build a linear chain: commit_0 ← commit_1 ← … ← commit_99
1695 commits: list[dict] = []
1696 snapshots: list[dict] = []
1697 objects: list[dict] = []
1698
1699 prev_id: str | None = None
1700 for i in range(_COMMIT_COUNT):
1701 obj = _make_object(f"file content {i}".encode())
1702 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
1703 snap = _make_snapshot(snap_id, obj["object_id"])
1704 commit_id = uuid.uuid4().hex
1705 commit = _make_commit(repo.repo_id, commit_id=commit_id, parent=prev_id)
1706 commit["snapshot_id"] = snap_id
1707
1708 objects.append(obj)
1709 snapshots.append(snap)
1710 commits.append(commit)
1711 prev_id = commit_id
1712
1713 body = _mp({
1714 "bundle": {
1715 "commits": commits,
1716 "snapshots": snapshots,
1717 "objects": objects,
1718 },
1719 "branch": "main",
1720 })
1721
1722 t0 = time.monotonic()
1723 resp = await client.post(
1724 f"/{repo.owner}/{repo.slug}/push",
1725 content=body,
1726 headers=wire_headers,
1727 )
1728 elapsed = time.monotonic() - t0
1729
1730 assert resp.status_code == 200, f"push failed: {resp.text}"
1731 assert elapsed < _BUDGET_SECONDS, (
1732 f"push of {_COMMIT_COUNT} commits took {elapsed:.2f}s — "
1733 f"exceeds {_BUDGET_SECONDS}s budget (N+1 regression?)"
1734 )
1735
1736
1737 # ── structured_delta serialization regression tests ───────────────────────────
1738
1739
1740 @pytest.mark.asyncio
1741 async def test_push_commit_with_null_structured_delta(
1742 client: AsyncClient,
1743 db_session: AsyncSession,
1744 wire_headers: StrDict,
1745 ) -> None:
1746 """A commit with structured_delta=null must persist without 500."""
1747 repo = await factory_create_repo(db_session, slug="delta-null-test", owner="test-user-wire")
1748 commit_id = uuid.uuid4().hex
1749 obj = _make_object()
1750 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
1751 snap = _make_snapshot(snap_id, obj["object_id"])
1752 commit = _make_commit(repo.repo_id, commit_id=commit_id)
1753 commit["snapshot_id"] = snap_id
1754 commit["structured_delta"] = None # explicit null
1755
1756 payload = {
1757 "bundle": {"commits": [commit], "snapshots": [snap], "objects": [obj]},
1758 "branch": "main",
1759 "force": False,
1760 }
1761 resp = await client.post(
1762 f"/{repo.owner}/{repo.slug}/push",
1763 content=_mp(payload),
1764 headers=wire_headers,
1765 )
1766 assert resp.status_code == 200, resp.text
1767 data = msgpack.unpackb(resp.content, raw=False)
1768 assert data["ok"] is True
1769
1770
1771 @pytest.mark.asyncio
1772 async def test_push_commit_with_nested_structured_delta(
1773 client: AsyncClient,
1774 db_session: AsyncSession,
1775 wire_headers: StrDict,
1776 ) -> None:
1777 """A commit with a nested-dict structured_delta must persist without 500.
1778
1779 Regression: wire_push was calling .root on PydanticJson, which only
1780 unwraps one level — nested dicts/lists still contained PydanticJson
1781 instances that SQLAlchemy's json_serializer could not encode.
1782 Fix: use unwrap() for full recursive unwrapping.
1783 """
1784 repo = await factory_create_repo(db_session, slug="delta-nested-test", owner="test-user-wire")
1785 commit_id = uuid.uuid4().hex
1786 obj = _make_object()
1787 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
1788 snap = _make_snapshot(snap_id, obj["object_id"])
1789 commit = _make_commit(repo.repo_id, commit_id=commit_id)
1790 commit["snapshot_id"] = snap_id
1791 commit["structured_delta"] = {
1792 "kind": "code",
1793 "symbols": ["MyClass", "my_func"],
1794 "stats": {"added": 10, "removed": 2},
1795 "nested": {"deep": {"value": True}},
1796 }
1797
1798 payload = {
1799 "bundle": {"commits": [commit], "snapshots": [snap], "objects": [obj]},
1800 "branch": "main",
1801 "force": False,
1802 }
1803 resp = await client.post(
1804 f"/{repo.owner}/{repo.slug}/push",
1805 content=_mp(payload),
1806 headers=wire_headers,
1807 )
1808 assert resp.status_code == 200, resp.text
1809 data = msgpack.unpackb(resp.content, raw=False)
1810 assert data["ok"] is True
1811
1812
1813 @pytest.mark.asyncio
1814 async def test_push_commit_with_list_structured_delta(
1815 client: AsyncClient,
1816 db_session: AsyncSession,
1817 wire_headers: StrDict,
1818 ) -> None:
1819 """A commit with a list-typed structured_delta must persist without 500."""
1820 repo = await factory_create_repo(db_session, slug="delta-list-test", owner="test-user-wire")
1821 commit_id = uuid.uuid4().hex
1822 obj = _make_object()
1823 snap_id = f"snap_{uuid.uuid4().hex[:8]}"
1824 snap = _make_snapshot(snap_id, obj["object_id"])
1825 commit = _make_commit(repo.repo_id, commit_id=commit_id)
1826 commit["snapshot_id"] = snap_id
1827 commit["structured_delta"] = [{"op": "add", "path": "/foo"}, {"op": "remove", "path": "/bar"}]
1828
1829 payload = {
1830 "bundle": {"commits": [commit], "snapshots": [snap], "objects": [obj]},
1831 "branch": "main",
1832 "force": False,
1833 }
1834 resp = await client.post(
1835 f"/{repo.owner}/{repo.slug}/push",
1836 content=_mp(payload),
1837 headers=wire_headers,
1838 )
1839 assert resp.status_code == 200, resp.text
1840 data = msgpack.unpackb(resp.content, raw=False)
1841 assert data["ok"] is True
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago