test_fetch_mpack_route.py
python
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠ breaking
20 days ago
| 1 | """TDD — HTTP route POST /{owner}/{slug}/fetch/mpack (issue #47 Phase 2). |
| 2 | |
| 3 | Tests the new route that calls wire_fetch_mpack and returns a msgpack response. |
| 4 | |
| 5 | Tests: |
| 6 | RB0 404 for a repo that does not exist. |
| 7 | RB1 Small public repo → 200, presign=False, mpack_id present, mpack bytes non-empty. |
| 8 | RB2 mpack_id == sha256(mpack bytes) — the route preserves the content-addressing proof. |
| 9 | RB3 Private repo → 404 without auth (don't leak existence). |
| 10 | RB4 Empty want list → 200, commit_count=0, object_count=0. |
| 11 | """ |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | import hashlib |
| 15 | |
| 16 | import msgpack |
| 17 | import pytest |
| 18 | from httpx import AsyncClient |
| 19 | from sqlalchemy.dialects.postgresql import insert as pg_insert |
| 20 | from sqlalchemy.ext.asyncio import AsyncSession |
| 21 | from sqlalchemy import select |
| 22 | |
| 23 | from datetime import datetime, timezone |
| 24 | |
| 25 | import msgpack as _msgpack_mod |
| 26 | from muse.core.types import blob_id, fake_id |
| 27 | from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitGraph, MusehubCommitRef, MusehubMPackIndex, MusehubObject, MusehubObjectRef, MusehubSnapshot, MusehubSnapshotRef |
| 28 | from tests.factories import create_repo |
| 29 | |
| 30 | |
| 31 | # ── helpers ─────────────────────────────────────────────────────────────────── |
| 32 | |
| 33 | def _now() -> datetime: |
| 34 | return datetime.now(tz=timezone.utc) |
| 35 | |
| 36 | |
| 37 | async def _store_object( |
| 38 | session: AsyncSession, |
| 39 | repo_id: str, |
| 40 | oid: str, |
| 41 | content: bytes, |
| 42 | ) -> None: |
| 43 | from musehub.services.musehub_wire import get_backend |
| 44 | backend = get_backend() |
| 45 | uri = await backend.put(oid, content) |
| 46 | # Build a minimal mpack blob and register it so the mpack index gate passes. |
| 47 | mpack_blob = _msgpack_mod.packb( |
| 48 | {"objects": [{"object_id": oid, "content": content}]}, |
| 49 | use_bin_type=True, |
| 50 | ) |
| 51 | mpack_id = blob_id(mpack_blob) |
| 52 | await backend.put_mpack(mpack_id, mpack_blob) |
| 53 | await session.execute( |
| 54 | pg_insert(MusehubObject) |
| 55 | .values( |
| 56 | object_id=oid, |
| 57 | path="file.dat", |
| 58 | size_bytes=len(content), |
| 59 | storage_uri=uri, |
| 60 | ) |
| 61 | .on_conflict_do_nothing(index_elements=["object_id"]) |
| 62 | ) |
| 63 | await session.execute( |
| 64 | pg_insert(MusehubObjectRef) |
| 65 | .values(repo_id=repo_id, object_id=oid) |
| 66 | .on_conflict_do_nothing() |
| 67 | ) |
| 68 | await session.execute( |
| 69 | pg_insert(MusehubMPackIndex) |
| 70 | .values(entity_id=oid, mpack_id=mpack_id, entity_type="object") |
| 71 | .on_conflict_do_nothing() |
| 72 | ) |
| 73 | await session.commit() |
| 74 | |
| 75 | |
| 76 | async def _make_commit( |
| 77 | session: AsyncSession, |
| 78 | repo_id: str, |
| 79 | *, |
| 80 | manifest: dict[str, str], |
| 81 | seed: str = "c1", |
| 82 | parent_ids: list[str] | None = None, |
| 83 | ) -> tuple[MusehubCommit, MusehubSnapshot]: |
| 84 | snap_id = fake_id(f"snap-route-{seed}") |
| 85 | snap = MusehubSnapshot( |
| 86 | snapshot_id=snap_id, |
| 87 | directories=[], |
| 88 | manifest_blob=msgpack.packb(manifest, use_bin_type=True), |
| 89 | entry_count=len(manifest), |
| 90 | created_at=_now(), |
| 91 | ) |
| 92 | session.add(snap) |
| 93 | await session.execute( |
| 94 | pg_insert(MusehubSnapshotRef) |
| 95 | .values(repo_id=repo_id, snapshot_id=snap_id) |
| 96 | .on_conflict_do_nothing() |
| 97 | ) |
| 98 | commit_id = fake_id(f"commit-route-{seed}") |
| 99 | commit = MusehubCommit( |
| 100 | commit_id=commit_id, |
| 101 | branch="main", |
| 102 | parent_ids=parent_ids or [], |
| 103 | message=f"commit {seed}", |
| 104 | author="gabriel", |
| 105 | timestamp=_now(), |
| 106 | snapshot_id=snap_id, |
| 107 | ) |
| 108 | session.add(commit) |
| 109 | await session.execute( |
| 110 | pg_insert(MusehubCommitRef) |
| 111 | .values(repo_id=repo_id, commit_id=commit_id) |
| 112 | .on_conflict_do_nothing() |
| 113 | ) |
| 114 | await session.execute( |
| 115 | pg_insert(MusehubCommitGraph) |
| 116 | .values( |
| 117 | commit_id=commit_id, |
| 118 | parent_ids=parent_ids or [], |
| 119 | generation=0, |
| 120 | snapshot_id=snap_id, |
| 121 | ) |
| 122 | .on_conflict_do_nothing() |
| 123 | ) |
| 124 | branch_q = await session.execute( |
| 125 | select(MusehubBranch).where( |
| 126 | MusehubBranch.repo_id == repo_id, |
| 127 | MusehubBranch.name == "main", |
| 128 | ) |
| 129 | ) |
| 130 | branch = branch_q.scalar_one_or_none() |
| 131 | if branch: |
| 132 | branch.head_commit_id = commit_id |
| 133 | await session.commit() |
| 134 | return commit, snap |
| 135 | |
| 136 | |
| 137 | # ══════════════════════════════════════════════════════════════════════════════ |
| 138 | # RB0 — 404 for missing repo |
| 139 | # ══════════════════════════════════════════════════════════════════════════════ |
| 140 | |
| 141 | @pytest.mark.asyncio |
| 142 | async def test_rb0_route_404_missing_repo(client: AsyncClient) -> None: |
| 143 | """POST /nobody/no-such-repo/fetch/mpack → 404.""" |
| 144 | resp = await client.post( |
| 145 | "/nobody/no-such-repo/fetch/mpack", |
| 146 | content=msgpack.packb({"want": [], "have": []}, use_bin_type=True), |
| 147 | headers={"Content-Type": "application/x-msgpack"}, |
| 148 | ) |
| 149 | assert resp.status_code == 404 |
| 150 | |
| 151 | |
| 152 | # ══════════════════════════════════════════════════════════════════════════════ |
| 153 | # RB1 — small public repo → 200, presign=False, mpack_id and mpack_bytes |
| 154 | # ══════════════════════════════════════════════════════════════════════════════ |
| 155 | |
| 156 | @pytest.mark.asyncio |
| 157 | async def test_rb1_small_public_repo_returns_inline_mpack( |
| 158 | client: AsyncClient, db_session: AsyncSession, wire_headers: dict[str, str] |
| 159 | ) -> None: |
| 160 | """Small public repo → 200, presign=False, mpack_id non-empty, mpack bytes present.""" |
| 161 | repo = await create_repo(db_session, owner="gabriel", visibility="public") |
| 162 | raw = b"route test content" |
| 163 | oid = blob_id(raw) |
| 164 | await _store_object(db_session, repo.repo_id, oid, raw) |
| 165 | commit, _ = await _make_commit( |
| 166 | db_session, repo.repo_id, manifest={"route.bin": oid}, seed="rb1" |
| 167 | ) |
| 168 | |
| 169 | resp = await client.post( |
| 170 | f"/gabriel/{repo.slug}/fetch/mpack", |
| 171 | content=msgpack.packb({"want": [commit.commit_id], "have": []}, use_bin_type=True), |
| 172 | headers={**wire_headers, "Content-Type": "application/x-msgpack"}, |
| 173 | ) |
| 174 | |
| 175 | assert resp.status_code == 200 |
| 176 | data = msgpack.unpackb(resp.content, raw=False) |
| 177 | |
| 178 | assert data["mpack_url"], "mpack_url must be non-empty" |
| 179 | assert data["mpack_id"].startswith("sha256:") |
| 180 | assert data["commit_count"] == 1 |
| 181 | assert data["blob_count"] == 1 |
| 182 | |
| 183 | |
| 184 | # ══════════════════════════════════════════════════════════════════════════════ |
| 185 | # RB2 — mpack_id == sha256(mpack_bytes) — proof preserved end-to-end |
| 186 | # ══════════════════════════════════════════════════════════════════════════════ |
| 187 | |
| 188 | @pytest.mark.asyncio |
| 189 | async def test_rb2_route_preserves_content_addressing_proof( |
| 190 | client: AsyncClient, db_session: AsyncSession, wire_headers: dict[str, str] |
| 191 | ) -> None: |
| 192 | """mpack_id must equal sha256(mpack_bytes) in the HTTP response.""" |
| 193 | repo = await create_repo(db_session, owner="gabriel", visibility="public") |
| 194 | raw = b"proof content through the route" |
| 195 | oid = blob_id(raw) |
| 196 | await _store_object(db_session, repo.repo_id, oid, raw) |
| 197 | commit, _ = await _make_commit( |
| 198 | db_session, repo.repo_id, manifest={"proof.bin": oid}, seed="rb2" |
| 199 | ) |
| 200 | |
| 201 | resp = await client.post( |
| 202 | f"/gabriel/{repo.slug}/fetch/mpack", |
| 203 | content=msgpack.packb({"want": [commit.commit_id], "have": []}, use_bin_type=True), |
| 204 | headers={**wire_headers, "Content-Type": "application/x-msgpack"}, |
| 205 | ) |
| 206 | |
| 207 | assert resp.status_code == 200 |
| 208 | data = msgpack.unpackb(resp.content, raw=False) |
| 209 | |
| 210 | assert data["mpack_id"].startswith("sha256:") |
| 211 | assert data["mpack_url"], "mpack_url must be non-empty" |
| 212 | # mpack_url encodes the mpack_id as its object key |
| 213 | mpack_hex = data["mpack_id"].removeprefix("sha256:") |
| 214 | assert mpack_hex in data["mpack_url"], ( |
| 215 | f"mpack_url {data['mpack_url']!r} must reference mpack_id {data['mpack_id']!r}" |
| 216 | ) |
| 217 | |
| 218 | |
| 219 | # ══════════════════════════════════════════════════════════════════════════════ |
| 220 | # RB3 — private repo → 404 without auth |
| 221 | # ══════════════════════════════════════════════════════════════════════════════ |
| 222 | |
| 223 | @pytest.mark.asyncio |
| 224 | async def test_rb3_private_repo_returns_404_without_auth( |
| 225 | client: AsyncClient, db_session: AsyncSession |
| 226 | ) -> None: |
| 227 | """Private repo must return 404 to unauthenticated request (don't leak existence).""" |
| 228 | repo = await create_repo(db_session, owner="gabriel", visibility="private") |
| 229 | |
| 230 | resp = await client.post( |
| 231 | f"/gabriel/{repo.slug}/fetch/mpack", |
| 232 | content=msgpack.packb({"want": [], "have": []}, use_bin_type=True), |
| 233 | headers={"Content-Type": "application/x-msgpack"}, |
| 234 | ) |
| 235 | assert resp.status_code == 404 |
| 236 | |
| 237 | |
| 238 | # ══════════════════════════════════════════════════════════════════════════════ |
| 239 | # RB4 — empty want → 200, zero counts |
| 240 | # ══════════════════════════════════════════════════════════════════════════════ |
| 241 | |
| 242 | @pytest.mark.asyncio |
| 243 | async def test_rb4_empty_want_returns_zero_counts( |
| 244 | client: AsyncClient, db_session: AsyncSession, wire_headers: dict[str, str] |
| 245 | ) -> None: |
| 246 | """Empty want list → 200, commit_count=0, object_count=0.""" |
| 247 | repo = await create_repo(db_session, owner="gabriel", visibility="public") |
| 248 | |
| 249 | resp = await client.post( |
| 250 | f"/gabriel/{repo.slug}/fetch/mpack", |
| 251 | content=msgpack.packb({"want": [], "have": []}, use_bin_type=True), |
| 252 | headers={**wire_headers, "Content-Type": "application/x-msgpack"}, |
| 253 | ) |
| 254 | |
| 255 | assert resp.status_code == 200 |
| 256 | data = msgpack.unpackb(resp.content, raw=False) |
| 257 | |
| 258 | assert data["commit_count"] == 0 |
| 259 | assert data["blob_count"] == 0 |
| 260 | assert data["mpack_url"] is None |
File History
1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠
20 days ago