gabriel / muse public
test_push_force_flag.py python
272 lines 9.5 KB
Raw
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor ⚠ breaking 19 days ago
1 """TDD — --force flag must appear in the unpack-mpack request body.
2
3 Gap 2: _run_mpack_path() never includes "force" in the unpack body. The
4 server receives the unpack request with no force field and has no way to
5 distinguish a force push from a normal push.
6
7 Test plan
8 ---------
9 F1 (RED) _push_mpack with force=True sends {"force": True} in the
10 unpack-mpack POST body.
11 F2 (RED) _push_mpack with force=False sends {"force": False} (or omits it
12 and server defaults to False).
13 F3 _push_mpack without --force does NOT set force=True by accident.
14 F4 The "force" key is present in the unpack body regardless of the
15 force value (protocol stability).
16 """
17 from __future__ import annotations
18
19 import asyncio
20 import datetime
21 import json
22 import pathlib
23 from unittest.mock import MagicMock, patch
24
25 import msgpack
26 import pytest
27
28 from muse._version import __version__
29 from muse.core.mpack import PushResult, RemoteInfo
30 from muse.core.object_store import write_object
31 from muse.core.paths import heads_dir, muse_dir
32
33 _Headers = dict[str, str] # HTTP header map
34 _JsonDict = dict[str, str | int | float | bool | None | list[str]] # JSON object
35 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
36 from muse.core.commits import (
37 CommitRecord,
38 write_commit,
39 )
40 from muse.core.snapshots import (
41 SnapshotRecord,
42 write_snapshot,
43 )
44 from muse.core.types import Manifest, blob_id
45
46
47 # ---------------------------------------------------------------------------
48 # Helpers
49 # ---------------------------------------------------------------------------
50
51 def _bare_repo(tmp_path: pathlib.Path) -> pathlib.Path:
52 muse = muse_dir(tmp_path)
53 for d in ("commits", "snapshots", "objects", "refs/heads", "remotes"):
54 (muse / d).mkdir(parents=True, exist_ok=True)
55 (muse / "HEAD").write_text("ref: refs/heads/main\n")
56 (muse / "repo.json").write_text(
57 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
58 )
59 (muse / "config.toml").write_text('[remotes.origin]\nurl = "https://hub.example.com/r"\n')
60 return tmp_path
61
62
63 def _make_commit(
64 root: pathlib.Path,
65 label: str,
66 parent_id: str | None = None,
67 content: bytes | None = None,
68 ) -> CommitRecord:
69 raw = content if content is not None else f"content-{label}".encode()
70 oid = blob_id(raw)
71 write_object(root, oid, raw)
72 manifest: Manifest = {"file.txt": oid}
73 snap_id = compute_snapshot_id(manifest)
74 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
75 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
76 parent_ids = [parent_id] if parent_id else []
77 cid = compute_commit_id(
78 parent_ids=parent_ids,
79 snapshot_id=snap_id,
80 message=f"commit {label}",
81 committed_at_iso=committed_at.isoformat(),
82 )
83 write_commit(root, CommitRecord(
84 commit_id=cid,
85 branch="main",
86 snapshot_id=snap_id,
87 message=f"commit {label}",
88 committed_at=committed_at,
89 parent_commit_id=parent_id,
90 ))
91 return CommitRecord(
92 commit_id=cid,
93 branch="main",
94 snapshot_id=snap_id,
95 message=f"commit {label}",
96 committed_at=committed_at,
97 parent_commit_id=parent_id,
98 )
99
100
101 def _fake_resp(body: bytes, status: int = 200) -> MagicMock:
102 r = MagicMock()
103 r.status_code = status
104 r.content = body
105 r.headers = {"content-type": "application/x-msgpack"}
106 r.text = ""
107 return r
108
109
110 class _FakeHttpxClient:
111 """Captures all HTTP requests made by _run_mpack_path."""
112
113 def __init__(self, upload_url: str = "https://minio.example.com/put?sig=x") -> None:
114 self.posts: list[tuple[str, bytes]] = [] # (url, body)
115 self.puts: list[tuple[str, bytes]] = [] # (url, body)
116 self._upload_url = upload_url
117
118 async def __aenter__(self) -> "_FakeHttpxClient":
119 return self
120
121 async def __aexit__(self, *_: object) -> None:
122 pass
123
124 async def post(self, url: str, *, content: bytes, headers: _Headers) -> MagicMock:
125 self.posts.append((url, content))
126 if "mpack-presign" in url:
127 return _fake_resp(msgpack.packb(
128 {"upload_url": self._upload_url},
129 use_bin_type=True,
130 ))
131 # unpack-mpack
132 return _fake_resp(msgpack.packb(
133 {"job_id": "job-test", "head": "", "branch": "main",
134 "objects_in_mpack": 0, "commits_in_mpack": 0},
135 use_bin_type=True,
136 ))
137
138 async def put(self, url: str, *, content: bytes) -> MagicMock:
139 self.puts.append((url, content))
140 return _fake_resp(b"", 200)
141
142
143 def _run_push_mpack(
144 root: pathlib.Path,
145 local_head: str,
146 remote_head: str | None,
147 force: bool,
148 ) -> "_FakeHttpxClient":
149 """Run _push_mpack with a fake transport and capture the HTTP requests."""
150 from muse.cli.commands.push import _push_mpack
151
152 transport = MagicMock()
153 transport.fetch_remote_info.return_value = RemoteInfo(
154 domain="code",
155 default_branch="main",
156 branch_heads={"main": remote_head} if remote_head else {},
157 )
158 # _build_request is called to build auth headers — return a minimal mock
159 mock_req = MagicMock()
160 mock_req.headers = {"Authorization": "MSign stub", "Content-Type": "application/x-msgpack"}
161 transport._build_request.return_value = mock_req
162
163 fake_client = _FakeHttpxClient()
164
165 with (
166 patch("muse.cli.commands.push._httpx.AsyncClient", return_value=fake_client),
167 patch("muse.cli.commands.push._make_r2_client", return_value=fake_client),
168 ):
169 _push_mpack(
170 transport,
171 "https://hub.example.com/repos/r1",
172 None,
173 root,
174 local_head,
175 [],
176 "main",
177 force,
178 )
179
180 return fake_client
181
182
183 def _unpack_body(body: bytes) -> _JsonDict:
184 """Decode the unpack-mpack POST body."""
185 import json
186 return json.loads(body)
187
188
189 # ---------------------------------------------------------------------------
190 # F1 — force=True appears in unpack body (currently RED)
191 # ---------------------------------------------------------------------------
192
193 def test_f1_force_true_sent_in_unpack_body(
194 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
195 ) -> None:
196 """_push_mpack(force=True) must send {"force": True} in the unpack body.
197
198 This is currently RED: _run_mpack_path does not include "force" in the
199 unpack payload.
200 """
201 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
202 monkeypatch.chdir(tmp_path)
203 root = _bare_repo(tmp_path)
204 commit = _make_commit(root, "f1", content=b"force flag test")
205 (heads_dir(root) / "main").write_text(commit.commit_id)
206
207 fake = _run_push_mpack(root, commit.commit_id, remote_head=None, force=True)
208
209 unpack_posts = [(url, body) for url, body in fake.posts if "unpack-mpack" in url]
210 assert unpack_posts, "No POST to unpack-mpack was made"
211
212 body = _unpack_body(unpack_posts[0][1])
213 assert "force" in body, (
214 f"'force' key missing from unpack body. Got keys: {list(body.keys())}. "
215 "Fix: add 'force': force to the unpack_body dict in _run_mpack_path."
216 )
217 assert body["force"] is True, (
218 f"Expected force=True in unpack body, got {body['force']!r}"
219 )
220
221
222 # ---------------------------------------------------------------------------
223 # F2 — force=False is also sent explicitly (currently RED)
224 # ---------------------------------------------------------------------------
225
226 def test_f2_force_false_sent_in_unpack_body(
227 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
228 ) -> None:
229 """_push_mpack(force=False) must send {"force": False} in the unpack body."""
230 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
231 monkeypatch.chdir(tmp_path)
232 root = _bare_repo(tmp_path)
233 commit = _make_commit(root, "f2", content=b"no force test")
234 (heads_dir(root) / "main").write_text(commit.commit_id)
235
236 fake = _run_push_mpack(root, commit.commit_id, remote_head=None, force=False)
237
238 unpack_posts = [(url, body) for url, body in fake.posts if "unpack-mpack" in url]
239 assert unpack_posts, "No POST to unpack-mpack was made"
240
241 body = _unpack_body(unpack_posts[0][1])
242 assert "force" in body, (
243 f"'force' key missing from unpack body. Got keys: {list(body.keys())}."
244 )
245 assert body["force"] is False, (
246 f"Expected force=False in unpack body, got {body['force']!r}"
247 )
248
249
250 # ---------------------------------------------------------------------------
251 # F3 — "force" key is always present regardless of value
252 # ---------------------------------------------------------------------------
253
254 def test_f3_force_key_present_in_all_pushes(
255 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
256 ) -> None:
257 """The 'force' key must always be in the unpack body (protocol stability)."""
258 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
259 monkeypatch.chdir(tmp_path)
260 root = _bare_repo(tmp_path)
261 commit = _make_commit(root, "f3", content=b"always force key")
262 (heads_dir(root) / "main").write_text(commit.commit_id)
263
264 for force_value in (True, False):
265 fake = _run_push_mpack(root, commit.commit_id, remote_head=None, force=force_value)
266 unpack_posts = [(url, body) for url, body in fake.posts if "unpack-mpack" in url]
267 assert unpack_posts
268 body = _unpack_body(unpack_posts[0][1])
269 assert "force" in body, f"force={force_value}: 'force' missing from unpack body"
270 assert isinstance(body["force"], bool), (
271 f"force={force_value}: body['force'] must be bool, got {type(body['force'])}"
272 )
File History 2 commits
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor 19 days ago
sha256:0bea7600d1eee83e87950be49933b1006fa9dc2c71e7c4ee748d324f61138156 chore: bump version to 0.2.0rc11; fix typing audit violatio… Sonnet 4.6 minor 19 days ago