"""TDD — HTTP route POST /{owner}/{slug}/fetch/mpack (issue #47 Phase 2). Tests the new route that calls wire_fetch_mpack and returns a msgpack response. Tests: RB0 404 for a repo that does not exist. RB1 Small public repo → 200, presign=False, mpack_id present, mpack bytes non-empty. RB2 mpack_id == sha256(mpack bytes) — the route preserves the content-addressing proof. RB3 Private repo → 404 without auth (don't leak existence). RB4 Empty want list → 200, commit_count=0, object_count=0. """ from __future__ import annotations import hashlib import msgpack import pytest from httpx import AsyncClient from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime, timezone import msgpack as _msgpack_mod from muse.core.types import blob_id, fake_id from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitGraph, MusehubCommitRef, MusehubMPackIndex, MusehubObject, MusehubObjectRef, MusehubSnapshot, MusehubSnapshotRef from tests.factories import create_repo # ── helpers ─────────────────────────────────────────────────────────────────── def _now() -> datetime: return datetime.now(tz=timezone.utc) async def _store_object( session: AsyncSession, repo_id: str, oid: str, content: bytes, ) -> None: from musehub.services.musehub_wire import get_backend backend = get_backend() uri = await backend.put(oid, content) # Build a minimal mpack blob and register it so the mpack index gate passes. mpack_blob = _msgpack_mod.packb( {"objects": [{"object_id": oid, "content": content}]}, use_bin_type=True, ) mpack_id = blob_id(mpack_blob) await backend.put_mpack(mpack_id, mpack_blob) await session.execute( pg_insert(MusehubObject) .values( object_id=oid, path="file.dat", size_bytes=len(content), storage_uri=uri, ) .on_conflict_do_nothing(index_elements=["object_id"]) ) await session.execute( pg_insert(MusehubObjectRef) .values(repo_id=repo_id, object_id=oid) .on_conflict_do_nothing() ) await session.execute( pg_insert(MusehubMPackIndex) .values(entity_id=oid, mpack_id=mpack_id, entity_type="object") .on_conflict_do_nothing() ) await session.commit() async def _make_commit( session: AsyncSession, repo_id: str, *, manifest: dict[str, str], seed: str = "c1", parent_ids: list[str] | None = None, ) -> tuple[MusehubCommit, MusehubSnapshot]: snap_id = fake_id(f"snap-route-{seed}") snap = MusehubSnapshot( snapshot_id=snap_id, directories=[], manifest_blob=msgpack.packb(manifest, use_bin_type=True), entry_count=len(manifest), created_at=_now(), ) session.add(snap) await session.execute( pg_insert(MusehubSnapshotRef) .values(repo_id=repo_id, snapshot_id=snap_id) .on_conflict_do_nothing() ) commit_id = fake_id(f"commit-route-{seed}") commit = MusehubCommit( commit_id=commit_id, branch="main", parent_ids=parent_ids or [], message=f"commit {seed}", author="gabriel", timestamp=_now(), snapshot_id=snap_id, ) session.add(commit) await session.execute( pg_insert(MusehubCommitRef) .values(repo_id=repo_id, commit_id=commit_id) .on_conflict_do_nothing() ) await session.execute( pg_insert(MusehubCommitGraph) .values( commit_id=commit_id, parent_ids=parent_ids or [], generation=0, snapshot_id=snap_id, ) .on_conflict_do_nothing() ) branch_q = await session.execute( select(MusehubBranch).where( MusehubBranch.repo_id == repo_id, MusehubBranch.name == "main", ) ) branch = branch_q.scalar_one_or_none() if branch: branch.head_commit_id = commit_id await session.commit() return commit, snap # ══════════════════════════════════════════════════════════════════════════════ # RB0 — 404 for missing repo # ══════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_rb0_route_404_missing_repo(client: AsyncClient) -> None: """POST /nobody/no-such-repo/fetch/mpack → 404.""" resp = await client.post( "/nobody/no-such-repo/fetch/mpack", content=msgpack.packb({"want": [], "have": []}, use_bin_type=True), headers={"Content-Type": "application/x-msgpack"}, ) assert resp.status_code == 404 # ══════════════════════════════════════════════════════════════════════════════ # RB1 — small public repo → 200, presign=False, mpack_id and mpack_bytes # ══════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_rb1_small_public_repo_returns_inline_mpack( client: AsyncClient, db_session: AsyncSession, wire_headers: dict[str, str] ) -> None: """Small public repo → 200, presign=False, mpack_id non-empty, mpack bytes present.""" repo = await create_repo(db_session, owner="gabriel", visibility="public") raw = b"route test content" oid = blob_id(raw) await _store_object(db_session, repo.repo_id, oid, raw) commit, _ = await _make_commit( db_session, repo.repo_id, manifest={"route.bin": oid}, seed="rb1" ) resp = await client.post( f"/gabriel/{repo.slug}/fetch/mpack", content=msgpack.packb({"want": [commit.commit_id], "have": []}, use_bin_type=True), headers={**wire_headers, "Content-Type": "application/x-msgpack"}, ) assert resp.status_code == 200 data = msgpack.unpackb(resp.content, raw=False) assert data["mpack_url"], "mpack_url must be non-empty" assert data["mpack_id"].startswith("sha256:") assert data["commit_count"] == 1 assert data["blob_count"] == 1 # ══════════════════════════════════════════════════════════════════════════════ # RB2 — mpack_id == sha256(mpack_bytes) — proof preserved end-to-end # ══════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_rb2_route_preserves_content_addressing_proof( client: AsyncClient, db_session: AsyncSession, wire_headers: dict[str, str] ) -> None: """mpack_id must equal sha256(mpack_bytes) in the HTTP response.""" repo = await create_repo(db_session, owner="gabriel", visibility="public") raw = b"proof content through the route" oid = blob_id(raw) await _store_object(db_session, repo.repo_id, oid, raw) commit, _ = await _make_commit( db_session, repo.repo_id, manifest={"proof.bin": oid}, seed="rb2" ) resp = await client.post( f"/gabriel/{repo.slug}/fetch/mpack", content=msgpack.packb({"want": [commit.commit_id], "have": []}, use_bin_type=True), headers={**wire_headers, "Content-Type": "application/x-msgpack"}, ) assert resp.status_code == 200 data = msgpack.unpackb(resp.content, raw=False) assert data["mpack_id"].startswith("sha256:") assert data["mpack_url"], "mpack_url must be non-empty" # mpack_url encodes the mpack_id as its object key mpack_hex = data["mpack_id"].removeprefix("sha256:") assert mpack_hex in data["mpack_url"], ( f"mpack_url {data['mpack_url']!r} must reference mpack_id {data['mpack_id']!r}" ) # ══════════════════════════════════════════════════════════════════════════════ # RB3 — private repo → 404 without auth # ══════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_rb3_private_repo_returns_404_without_auth( client: AsyncClient, db_session: AsyncSession ) -> None: """Private repo must return 404 to unauthenticated request (don't leak existence).""" repo = await create_repo(db_session, owner="gabriel", visibility="private") resp = await client.post( f"/gabriel/{repo.slug}/fetch/mpack", content=msgpack.packb({"want": [], "have": []}, use_bin_type=True), headers={"Content-Type": "application/x-msgpack"}, ) assert resp.status_code == 404 # ══════════════════════════════════════════════════════════════════════════════ # RB4 — empty want → 200, zero counts # ══════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_rb4_empty_want_returns_zero_counts( client: AsyncClient, db_session: AsyncSession, wire_headers: dict[str, str] ) -> None: """Empty want list → 200, commit_count=0, object_count=0.""" repo = await create_repo(db_session, owner="gabriel", visibility="public") resp = await client.post( f"/gabriel/{repo.slug}/fetch/mpack", content=msgpack.packb({"want": [], "have": []}, use_bin_type=True), headers={**wire_headers, "Content-Type": "application/x-msgpack"}, ) assert resp.status_code == 200 data = msgpack.unpackb(resp.content, raw=False) assert data["commit_count"] == 0 assert data["blob_count"] == 0 assert data["mpack_url"] is None