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