"""TDD — HttpTransport.push_mpack_unpack: client-side Step 3 of the mpack push protocol. MU-1 Happy path: POST returns job_id, head, branch, objects_in_mpack, commits_in_mpack. MU-2 POST body encodes all five unpack fields correctly. MU-3 POST is sent to /{owner}/{slug}/push/unpack-mpack. MU-4 Non-2xx response raises TransportError. MU-5 Response missing job_id raises TransportError. MU-6 MSign Authorization header is present (unlike step 2). """ from __future__ import annotations from unittest.mock import MagicMock, patch import msgpack import pytest from muse.core.transport import HttpTransport, TransportError from muse.core.types import blob_id, fake_id _Headers = dict[str, str] _URL = "https://staging.musehub.ai/gabriel/muse" _MPACK_KEY = blob_id(b"mpack bytes") _HEAD = fake_id("tip-commit") _JOB_ID = fake_id("job-id") def _unpack_response( job_id: str = _JOB_ID, head: str = _HEAD, branch: str = "main", objects_in_mpack: int = 5, commits_in_mpack: int = 2, ) -> bytes: return msgpack.packb({ "job_id": job_id, "head": head, "branch": branch, "objects_in_mpack": objects_in_mpack, "commits_in_mpack": commits_in_mpack, }, use_bin_type=True) class _Resp: def __init__(self, body: bytes, status: int = 200) -> None: self.status_code = status self.content = body def _mock_client(resp: _Resp) -> MagicMock: mock = MagicMock() mock.__enter__ = MagicMock(return_value=mock) mock.__exit__ = MagicMock(return_value=False) mock.post = MagicMock(return_value=resp) return mock # ── MU-1 ────────────────────────────────────────────────────────────────────── def test_mu1_happy_path_returns_all_fields() -> None: """200 response → all result fields present and correctly parsed.""" mock_client = _mock_client(_Resp(_unpack_response())) transport = HttpTransport() with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): result = transport.push_mpack_unpack( _URL, None, _MPACK_KEY, branch="main", head=_HEAD, commits_count=2, objects_count=5, ) assert result["job_id"] == _JOB_ID assert result["head"] == _HEAD assert result["branch"] == "main" assert result["objects_in_mpack"] == 5 assert result["commits_in_mpack"] == 2 # ── MU-2 ────────────────────────────────────────────────────────────────────── def test_mu2_post_body_encodes_all_fields() -> None: """POST body must contain all five unpack fields with correct values.""" mock_client = _mock_client(_Resp(_unpack_response())) transport = HttpTransport() with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): transport.push_mpack_unpack( _URL, None, _MPACK_KEY, branch="dev", head=_HEAD, commits_count=3, objects_count=10, ) call_kwargs = mock_client.post.call_args sent_body: bytes = call_kwargs[1].get("content") or call_kwargs[0][1] payload = msgpack.unpackb(sent_body, raw=False) assert payload["mpack_key"] == _MPACK_KEY assert payload["branch"] == "dev" assert payload["head"] == _HEAD assert payload["commits_count"] == 3 assert payload["objects_count"] == 10 # ── MU-3 ────────────────────────────────────────────────────────────────────── def test_mu3_posts_to_correct_endpoint() -> None: """POST must go to /{owner}/{slug}/push/unpack-mpack.""" mock_client = _mock_client(_Resp(_unpack_response())) transport = HttpTransport() with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): transport.push_mpack_unpack(_URL, None, _MPACK_KEY, head=_HEAD) posted_url: str = mock_client.post.call_args[0][0] assert posted_url.endswith("/push/unpack-mpack") # ── MU-4 ────────────────────────────────────────────────────────────────────── def test_mu4_non_200_raises_transport_error() -> None: """4xx/5xx from server raises TransportError.""" mock_client = _mock_client(_Resp(b"integrity failure", status=422)) transport = HttpTransport() with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): with pytest.raises(TransportError): transport.push_mpack_unpack(_URL, None, _MPACK_KEY, head=_HEAD) # ── MU-5 ────────────────────────────────────────────────────────────────────── def test_mu5_missing_job_id_raises_transport_error() -> None: """Response without job_id raises TransportError.""" bad_resp = msgpack.packb({"head": _HEAD, "branch": "main"}, use_bin_type=True) mock_client = _mock_client(_Resp(bad_resp)) transport = HttpTransport() with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): with pytest.raises(TransportError, match="job_id"): transport.push_mpack_unpack(_URL, None, _MPACK_KEY, head=_HEAD) # ── MU-6 ────────────────────────────────────────────────────────────────────── def test_mu6_authorization_header_present() -> None: """Unlike step 2, step 3 is a MuseHub API call — MSign auth header required.""" from muse.core.transport import SigningIdentity from unittest.mock import MagicMock as MM mock_key = MM() mock_key.sign = MM(return_value=b"\x00" * 64) mock_key.public_key = MM(return_value=MM( public_bytes=MM(return_value=b"\x01" * 32) )) signing = SigningIdentity(handle="gabriel", private_key=mock_key) mock_client = _mock_client(_Resp(_unpack_response())) with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client): with patch("muse.core.msign.build_msign_header", return_value="MSign fake") as mock_sign: transport = HttpTransport() transport.push_mpack_unpack(_URL, signing, _MPACK_KEY, head=_HEAD) mock_sign.assert_called_once() call_kwargs = mock_client.post.call_args headers: _Headers = call_kwargs[1].get("headers") or {} assert any(k.lower() == "authorization" for k in headers)