test_transport_fetch_phase2.py
python
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f
chore: merge main — carry all urllib/typing/test fixes from dev
Sonnet 4.6
minor
⚠ breaking
20 days ago
| 1 | """TDD — HttpTransport.fetch_mpack Phase 2: single presigned-URL protocol (issue #68). |
| 2 | |
| 3 | The Phase 2 server response has exactly four fields — no presign flag, no inline |
| 4 | bytes, no pack_urls list. The URL is always the delivery channel. |
| 5 | |
| 6 | Server response shape: |
| 7 | { |
| 8 | "mpack_url": str, # presigned GET URL; null/absent → up-to-date |
| 9 | "mpack_id": str, # sha256:<hex>; null/absent → up-to-date |
| 10 | "commit_count": int, |
| 11 | "object_count": int, |
| 12 | } |
| 13 | |
| 14 | Client protocol (Step 2 from issue #68 spec): |
| 15 | 1. GET mpack_url (direct to MinIO, bypasses Cloudflare) |
| 16 | 2. Verify sha256(mpack_bytes).hexdigest() == mpack_id[7:] |
| 17 | → abort with TransportError on mismatch (corrupt in transit) |
| 18 | 3. apply_mpack() on the verified bytes |
| 19 | |
| 20 | Tests: |
| 21 | FM-1 Normal fetch: server returns mpack_url + mpack_id, client GETs, sha256 |
| 22 | matches, FetchMPackResult returned. |
| 23 | FM-2 sha256 mismatch on downloaded bytes → TransportError raised immediately. |
| 24 | FM-3 Non-200 from GET mpack_url → TransportError. |
| 25 | FM-4 Non-200 from POST /fetch → TransportError. |
| 26 | FM-5 Server returns mpack_url=null (up-to-date) → empty result, no GET call. |
| 27 | FM-6 All objects from the downloaded mpack are present in FetchMPackResult.objects. |
| 28 | """ |
| 29 | from __future__ import annotations |
| 30 | |
| 31 | from unittest.mock import MagicMock, patch |
| 32 | |
| 33 | import msgpack |
| 34 | import pytest |
| 35 | |
| 36 | from muse.core.transport import FetchMPackResult, HttpTransport, TransportError |
| 37 | from muse.core.types import blob_id, fake_id |
| 38 | |
| 39 | _URL = "https://staging.musehub.ai/gabriel/muse" |
| 40 | |
| 41 | |
| 42 | # ── helpers ─────────────────────────────────────────────────────────────────── |
| 43 | |
| 44 | def _make_mpack(*, n_objects: int = 1, n_commits: int = 1) -> tuple[bytes, str]: |
| 45 | """Return (mpack_bytes, mpack_id).""" |
| 46 | raws = [f"content-{i}".encode() for i in range(n_objects)] |
| 47 | oids = [blob_id(r) for r in raws] |
| 48 | commit_ids = [fake_id(f"commit-{i}") for i in range(n_commits)] |
| 49 | snap_id = fake_id("snap-0") |
| 50 | mpack = { |
| 51 | "commits": [ |
| 52 | { |
| 53 | "commit_id": cid, "parent_commit_id": None, |
| 54 | "snapshot_id": snap_id, "branch": "main", |
| 55 | "message": f"c{i}", "author": "gabriel", |
| 56 | } |
| 57 | for i, cid in enumerate(commit_ids) |
| 58 | ], |
| 59 | "snapshots": [{"snapshot_id": snap_id, "manifest": {f"f{i}.bin": oid for i, oid in enumerate(oids)}}], |
| 60 | "objects": [{"object_id": oid, "content": raw} for oid, raw in zip(oids, raws)], |
| 61 | } |
| 62 | wire = msgpack.packb(mpack, use_bin_type=True) |
| 63 | return wire, blob_id(wire) |
| 64 | |
| 65 | |
| 66 | def _fetch_response(*, mpack_url: str | None, mpack_id: str | None, |
| 67 | commit_count: int = 1, object_count: int = 1) -> bytes: |
| 68 | """Phase 2 server response — no presign flag, no inline bytes.""" |
| 69 | return msgpack.packb({ |
| 70 | "mpack_url": mpack_url, |
| 71 | "mpack_id": mpack_id, |
| 72 | "commit_count": commit_count, |
| 73 | "object_count": object_count, |
| 74 | }, use_bin_type=True) |
| 75 | |
| 76 | |
| 77 | class _Resp: |
| 78 | def __init__(self, body: bytes, status: int = 200) -> None: |
| 79 | self.status_code = status |
| 80 | self.content = body |
| 81 | |
| 82 | |
| 83 | def _mock_client(post_body: bytes, get_body: bytes | None = None, |
| 84 | get_status: int = 200) -> MagicMock: |
| 85 | mock = MagicMock() |
| 86 | mock.__enter__ = MagicMock(return_value=mock) |
| 87 | mock.__exit__ = MagicMock(return_value=False) |
| 88 | mock.post = MagicMock(return_value=_Resp(post_body)) |
| 89 | if get_body is not None: |
| 90 | mock.get = MagicMock(return_value=_Resp(get_body, get_status)) |
| 91 | return mock |
| 92 | |
| 93 | |
| 94 | # ── FM-1 ────────────────────────────────────────────────────────────────────── |
| 95 | |
| 96 | def test_fm1_normal_fetch_returns_result() -> None: |
| 97 | """Server returns mpack_url + mpack_id; client GETs, verifies sha256, returns result.""" |
| 98 | mpack_bytes, mpack_id = _make_mpack(n_objects=2, n_commits=1) |
| 99 | mpack_url = f"https://minio.example.com/mpacks/{mpack_id}?sig=fake" |
| 100 | |
| 101 | mock_client = _mock_client( |
| 102 | post_body=_fetch_response(mpack_url=mpack_url, mpack_id=mpack_id, |
| 103 | commit_count=1, object_count=2), |
| 104 | get_body=mpack_bytes, |
| 105 | ) |
| 106 | |
| 107 | with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): |
| 108 | result = HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[]) |
| 109 | |
| 110 | mock_client.get.assert_called_once_with(mpack_url) |
| 111 | assert result["objects_received"] == 2 |
| 112 | assert len(result["commits"]) == 1 |
| 113 | |
| 114 | |
| 115 | # ── FM-2 ────────────────────────────────────────────────────────────────────── |
| 116 | |
| 117 | def test_fm2_sha256_mismatch_raises() -> None: |
| 118 | """sha256(downloaded_bytes) != mpack_id → TransportError before any apply.""" |
| 119 | mpack_bytes, mpack_id = _make_mpack() |
| 120 | mpack_url = f"https://minio.example.com/mpacks/{mpack_id}?sig=fake" |
| 121 | |
| 122 | tampered = mpack_bytes + b"\xff" # one extra byte breaks the hash |
| 123 | |
| 124 | mock_client = _mock_client( |
| 125 | post_body=_fetch_response(mpack_url=mpack_url, mpack_id=mpack_id), |
| 126 | get_body=tampered, |
| 127 | ) |
| 128 | |
| 129 | with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): |
| 130 | with pytest.raises(TransportError, match="integrity"): |
| 131 | HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[]) |
| 132 | |
| 133 | |
| 134 | # ── FM-3 ────────────────────────────────────────────────────────────────────── |
| 135 | |
| 136 | def test_fm3_non200_get_raises() -> None: |
| 137 | """Non-200 from GET mpack_url → TransportError.""" |
| 138 | mpack_bytes, mpack_id = _make_mpack() |
| 139 | mpack_url = f"https://minio.example.com/mpacks/{mpack_id}?sig=fake" |
| 140 | |
| 141 | mock_client = _mock_client( |
| 142 | post_body=_fetch_response(mpack_url=mpack_url, mpack_id=mpack_id), |
| 143 | get_body=b"Access Denied", |
| 144 | get_status=403, |
| 145 | ) |
| 146 | |
| 147 | with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): |
| 148 | with pytest.raises(TransportError, match="403"): |
| 149 | HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[]) |
| 150 | |
| 151 | |
| 152 | # ── FM-4 ────────────────────────────────────────────────────────────────────── |
| 153 | |
| 154 | def test_fm4_non200_post_raises() -> None: |
| 155 | """Non-200 from POST /fetch → TransportError, no GET attempted.""" |
| 156 | mock = MagicMock() |
| 157 | mock.__enter__ = MagicMock(return_value=mock) |
| 158 | mock.__exit__ = MagicMock(return_value=False) |
| 159 | mock.post = MagicMock(return_value=_Resp(b"Not Found", status=404)) |
| 160 | |
| 161 | with patch("muse.core.transport._httpx_mod.Client", return_value=mock): |
| 162 | with pytest.raises(TransportError): |
| 163 | HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[]) |
| 164 | |
| 165 | mock.get.assert_not_called() |
| 166 | |
| 167 | |
| 168 | # ── FM-5 ────────────────────────────────────────────────────────────────────── |
| 169 | |
| 170 | def test_fm5_null_mpack_url_means_up_to_date() -> None: |
| 171 | """Server returns mpack_url=null → client already up-to-date, no GET.""" |
| 172 | mock_client = _mock_client( |
| 173 | post_body=_fetch_response(mpack_url=None, mpack_id=None, |
| 174 | commit_count=0, object_count=0), |
| 175 | ) |
| 176 | |
| 177 | with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): |
| 178 | result = HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[]) |
| 179 | |
| 180 | mock_client.get.assert_not_called() |
| 181 | assert result["objects_received"] == 0 |
| 182 | assert result["commits"] == [] |
| 183 | |
| 184 | |
| 185 | # ── FM-6 ────────────────────────────────────────────────────────────────────── |
| 186 | |
| 187 | def test_fm6_all_objects_present_in_result() -> None: |
| 188 | """All objects from the downloaded mpack appear in FetchMPackResult.objects.""" |
| 189 | mpack_bytes, mpack_id = _make_mpack(n_objects=3) |
| 190 | mpack_url = f"https://minio.example.com/mpacks/{mpack_id}?sig=fake" |
| 191 | |
| 192 | mock_client = _mock_client( |
| 193 | post_body=_fetch_response(mpack_url=mpack_url, mpack_id=mpack_id, object_count=3), |
| 194 | get_body=mpack_bytes, |
| 195 | ) |
| 196 | |
| 197 | with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): |
| 198 | result = HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[]) |
| 199 | |
| 200 | assert result["objects_received"] == 3 |
| 201 | assert len(result["objects"]) == 3 |
| 202 | assert all(isinstance(o, dict) and "object_id" in o for o in result["objects"]) |
File History
2 commits
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f
chore: merge main — carry all urllib/typing/test fixes from dev
Sonnet 4.6
minor
⚠
20 days ago
sha256:0bea7600d1eee83e87950be49933b1006fa9dc2c71e7c4ee748d324f61138156
chore: bump version to 0.2.0rc11; fix typing audit violatio…
Sonnet 4.6
minor
⚠
20 days ago