gabriel / muse public
test_unpack_objects_supercharge.py python
486 lines 19.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """SUPERCHARGE tests for ``muse unpack-objects``.
2
3 Coverage tiers
4 --------------
5 - U (Unit): duration_ms / exit_code fields, tags_written field
6 - E (Error routing): JSON errors → stdout when --json; stderr otherwise
7 - S (Schema): error payload has exactly {error, message, duration_ms, exit_code}
8 - D (Data integrity): duration_ms is float >= 0; exit_code 0/1/3 semantics
9 - P (Performance): duration_ms is a sane duration for typical payloads
10 - Sec (Security): no traceback on any error path (mocked write failure)
11 """
12 from __future__ import annotations
13 from collections.abc import Mapping
14
15 import datetime
16 import json
17 import pathlib
18 from unittest import mock
19
20 import msgpack
21 import pytest
22
23 from muse.core.errors import ExitCode
24 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
25 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
26 from muse.core._types import Manifest
27 from tests.cli_test_helper import CliRunner, InvokeResult
28
29 runner = CliRunner()
30
31
32 # ---------------------------------------------------------------------------
33 # Helpers (copied from test_mpack_cmd_pack_unpack to stay self-contained)
34 # ---------------------------------------------------------------------------
35
36
37 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
38 repo = tmp_path / "repo"
39 muse = repo / ".muse"
40 for sub in ("objects", "commits", "snapshots", "refs/heads"):
41 (muse / sub).mkdir(parents=True)
42 (muse / "HEAD").write_text("ref: refs/heads/main")
43 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"}))
44 return repo
45
46
47 def _snap(repo: pathlib.Path, manifest: Manifest | None = None) -> str:
48 m = manifest or {}
49 snap_id = compute_snapshot_id(m)
50 write_snapshot(repo, SnapshotRecord(
51 snapshot_id=snap_id,
52 manifest=m,
53 created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
54 ))
55 return snap_id
56
57
58 def _commit(repo: pathlib.Path, snap_id: str, *, parent: str | None = None, message: str = "test") -> str:
59 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
60 parent_ids: list[str] = [parent] if parent else []
61 commit_id = compute_commit_id(
62 repo_id="test-repo",
63 parent_ids=parent_ids,
64 snapshot_id=snap_id,
65 message=message,
66 committed_at_iso=committed_at.isoformat(),
67 )
68 write_commit(repo, CommitRecord(
69 commit_id=commit_id,
70 repo_id="test-repo",
71 created_on_branch="main",
72 snapshot_id=snap_id,
73 message=message,
74 committed_at=committed_at,
75 parent_commit_id=parent,
76 ))
77 return commit_id
78
79
80 def _set_head(repo: pathlib.Path, commit_id: str) -> None:
81 (repo / ".muse" / "refs" / "heads" / "main").write_text(commit_id)
82
83
84 def _po(repo: pathlib.Path, *args: str) -> InvokeResult:
85 from muse.cli.app import main as cli
86 return runner.invoke(cli, ["pack-objects", *args], env={"MUSE_REPO_ROOT": str(repo)})
87
88
89 def _uo(repo: pathlib.Path, input_bytes: bytes, *args: str) -> InvokeResult:
90 from muse.cli.app import main as cli
91 return runner.invoke(cli, ["unpack-objects", *args], input=input_bytes, env={"MUSE_REPO_ROOT": str(repo)})
92
93
94 def _make_pack(tmp_path: pathlib.Path) -> tuple[pathlib.Path, bytes]:
95 """Create a minimal repo and return (repo_path, pack_bytes)."""
96 repo = _make_repo(tmp_path)
97 sid = _snap(repo)
98 cid = _commit(repo, sid)
99 _set_head(repo, cid)
100 pack_bytes = _po(repo, cid).stdout_bytes
101 assert pack_bytes, "pack-objects returned empty bytes"
102 return repo, pack_bytes
103
104
105 # ---------------------------------------------------------------------------
106 # U — Unit: duration_ms and exit_code in success JSON
107 # ---------------------------------------------------------------------------
108
109
110 class TestElapsedMsExitCode:
111 """U1–U5: success JSON always carries duration_ms and exit_code."""
112
113 def test_u1_success_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
114 """U1: duration_ms key present in JSON success response."""
115 src = _make_repo(tmp_path / "src")
116 dst = _make_repo(tmp_path / "dst")
117 sid = _snap(src)
118 cid = _commit(src, sid)
119 pack_bytes = _po(src, cid).stdout_bytes
120 r = _uo(dst, pack_bytes, "--json")
121 assert r.exit_code == 0
122 data = json.loads(r.output)
123 assert "duration_ms" in data, "duration_ms must be present in success JSON"
124
125 def test_u2_success_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
126 """U2: exit_code is 0 in JSON success response."""
127 src = _make_repo(tmp_path / "src")
128 dst = _make_repo(tmp_path / "dst")
129 sid = _snap(src)
130 cid = _commit(src, sid)
131 pack_bytes = _po(src, cid).stdout_bytes
132 r = _uo(dst, pack_bytes, "--json")
133 assert r.exit_code == 0
134 data = json.loads(r.output)
135 assert data["exit_code"] == 0
136
137 def test_u3_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
138 """U3: duration_ms is a float (not int, not string)."""
139 src = _make_repo(tmp_path / "src")
140 dst = _make_repo(tmp_path / "dst")
141 sid = _snap(src)
142 cid = _commit(src, sid)
143 pack_bytes = _po(src, cid).stdout_bytes
144 r = _uo(dst, pack_bytes, "--json")
145 assert r.exit_code == 0
146 data = json.loads(r.output)
147 assert isinstance(data["duration_ms"], float), f"expected float, got {type(data['duration_ms'])}"
148
149 def test_u4_duration_ms_non_negative(self, tmp_path: pathlib.Path) -> None:
150 """U4: duration_ms >= 0."""
151 src = _make_repo(tmp_path / "src")
152 dst = _make_repo(tmp_path / "dst")
153 sid = _snap(src)
154 cid = _commit(src, sid)
155 pack_bytes = _po(src, cid).stdout_bytes
156 r = _uo(dst, pack_bytes, "--json")
157 assert r.exit_code == 0
158 data = json.loads(r.output)
159 assert data["duration_ms"] >= 0.0
160
161 def test_u5_tags_written_in_success_json(self, tmp_path: pathlib.Path) -> None:
162 """U5: tags_written key present in JSON success response."""
163 src = _make_repo(tmp_path / "src")
164 dst = _make_repo(tmp_path / "dst")
165 sid = _snap(src)
166 cid = _commit(src, sid)
167 pack_bytes = _po(src, cid).stdout_bytes
168 r = _uo(dst, pack_bytes, "--json")
169 assert r.exit_code == 0
170 data = json.loads(r.output)
171 assert "tags_written" in data, "tags_written must be present in success JSON"
172 assert isinstance(data["tags_written"], int)
173
174
175 # ---------------------------------------------------------------------------
176 # E — Error routing: JSON errors → stdout when --json, stderr otherwise
177 # ---------------------------------------------------------------------------
178
179
180 class TestJsonErrorsToStdout:
181 """E1–E5: when --json, all errors land on stdout (not stderr)."""
182
183 def test_e1_corrupted_msgpack_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None:
184 """E1: corrupted msgpack with --json → parseable JSON error; stderr is empty.
185
186 r.output is stdout+stderr combined. Proving stderr is empty means everything
187 in r.output came from stdout — so the JSON is on stdout.
188 """
189 repo = _make_repo(tmp_path)
190 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
191 assert r.exit_code != 0
192 assert r.stderr.strip() == "", f"stderr must be empty in JSON mode, got: {r.stderr!r}"
193 data = json.loads(r.output)
194 assert "error" in data
195
196 def test_e2_corrupted_msgpack_error_is_valid_json(self, tmp_path: pathlib.Path) -> None:
197 """E2: corrupted msgpack with --json → output is parseable JSON."""
198 repo = _make_repo(tmp_path)
199 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
200 assert r.exit_code != 0
201 # If this parse fails, the error routing produced non-JSON output
202 data = json.loads(r.output)
203 assert "error" in data
204 assert "message" in data
205
206 def test_e3_not_a_dict_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None:
207 """E3: valid msgpack but not a map → JSON error; stderr is empty."""
208 repo = _make_repo(tmp_path)
209 not_a_map = msgpack.packb(["a", "list", "not", "a", "map"], use_bin_type=True)
210 r = _uo(repo, not_a_map, "--json")
211 assert r.exit_code != 0
212 assert r.stderr.strip() == "", f"stderr must be empty in JSON mode, got: {r.stderr!r}"
213 data = json.loads(r.output)
214 assert "error" in data
215
216 def test_e4_apply_mpack_oserror_exit_3_json_on_stdout(self, tmp_path: pathlib.Path) -> None:
217 """E4: apply_mpack OSError → exit 3, JSON error; stderr is empty."""
218 src = _make_repo(tmp_path / "src")
219 dst = _make_repo(tmp_path / "dst")
220 sid = _snap(src)
221 cid = _commit(src, sid)
222 pack_bytes = _po(src, cid).stdout_bytes
223
224 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("disk full")):
225 r = _uo(dst, pack_bytes, "--json")
226
227 assert r.exit_code == ExitCode.INTERNAL_ERROR
228 assert r.stderr.strip() == "", f"stderr must be empty in JSON mode, got: {r.stderr!r}"
229 data = json.loads(r.output)
230 assert "error" in data
231 assert "disk full" in data.get("message", "")
232
233 def test_e5_apply_mpack_oserror_stderr_empty_in_json_mode(self, tmp_path: pathlib.Path) -> None:
234 """E5: stderr must be empty when apply_mpack raises OSError in JSON mode."""
235 src = _make_repo(tmp_path / "src")
236 dst = _make_repo(tmp_path / "dst")
237 sid = _snap(src)
238 cid = _commit(src, sid)
239 pack_bytes = _po(src, cid).stdout_bytes
240
241 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("disk full")):
242 r = _uo(dst, pack_bytes, "--json")
243
244 assert r.stderr.strip() == "", f"stderr should be empty in JSON mode, got: {r.stderr!r}"
245
246 def test_e6_text_mode_errors_on_stderr(self, tmp_path: pathlib.Path) -> None:
247 """E6: in text mode (no --json), msgpack errors go to stderr (stdout_bytes is empty)."""
248 repo = _make_repo(tmp_path)
249 r = _uo(repo, b"\xff\xfe corrupted!")
250 assert r.exit_code != 0
251 assert r.stdout_bytes == b"", f"stdout_bytes should be empty in text mode, got: {r.stdout_bytes!r}"
252 assert "error" in r.stderr.lower() or "invalid" in r.stderr.lower()
253
254
255 # ---------------------------------------------------------------------------
256 # S — Schema: error payload structure
257 # ---------------------------------------------------------------------------
258
259
260 class TestErrorJsonSchema:
261 """S1–S3: every JSON error has exactly the right keys."""
262
263 def _parse_error(self, r: InvokeResult) -> Mapping[str, object]:
264 return json.loads(r.output)
265
266 def test_s1_corrupted_msgpack_error_schema(self, tmp_path: pathlib.Path) -> None:
267 """S1: corrupted msgpack error has {error, message, duration_ms, exit_code}."""
268 repo = _make_repo(tmp_path)
269 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
270 data = self._parse_error(r)
271 for key in ("error", "message", "duration_ms", "exit_code"):
272 assert key in data, f"missing key {key!r} in error JSON"
273
274 def test_s2_not_a_dict_error_schema(self, tmp_path: pathlib.Path) -> None:
275 """S2: not-a-map error has {error, message, duration_ms, exit_code}."""
276 repo = _make_repo(tmp_path)
277 not_a_map = msgpack.packb(42, use_bin_type=True)
278 r = _uo(repo, not_a_map, "--json")
279 data = self._parse_error(r)
280 for key in ("error", "message", "duration_ms", "exit_code"):
281 assert key in data, f"missing key {key!r} in error JSON"
282
283 def test_s3_apply_mpack_oserror_schema(self, tmp_path: pathlib.Path) -> None:
284 """S3: apply_mpack OSError has {error, message, duration_ms, exit_code}."""
285 src = _make_repo(tmp_path / "src")
286 dst = _make_repo(tmp_path / "dst")
287 sid = _snap(src)
288 cid = _commit(src, sid)
289 pack_bytes = _po(src, cid).stdout_bytes
290
291 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("no space left")):
292 r = _uo(dst, pack_bytes, "--json")
293
294 data = self._parse_error(r)
295 for key in ("error", "message", "duration_ms", "exit_code"):
296 assert key in data, f"missing key {key!r} in error JSON"
297
298 def test_s4_error_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
299 """S4: duration_ms in error JSON is a float >= 0."""
300 repo = _make_repo(tmp_path)
301 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
302 data = self._parse_error(r)
303 assert isinstance(data["duration_ms"], float)
304 assert data["duration_ms"] >= 0.0
305
306 def test_s5_error_exit_code_matches_process_exit(self, tmp_path: pathlib.Path) -> None:
307 """S5: exit_code in JSON matches actual process exit code."""
308 repo = _make_repo(tmp_path)
309 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
310 data = self._parse_error(r)
311 assert data["exit_code"] == r.exit_code
312
313
314 # ---------------------------------------------------------------------------
315 # D — Data integrity
316 # ---------------------------------------------------------------------------
317
318
319 class TestDataIntegrity:
320 """D1–D5: output values are semantically correct."""
321
322 def test_d1_exit_code_1_for_invalid_msgpack_json_mode(self, tmp_path: pathlib.Path) -> None:
323 """D1: corrupted msgpack → exit_code 1 (user error) in JSON."""
324 repo = _make_repo(tmp_path)
325 r = _uo(repo, b"\xff\xfe bad", "--json")
326 assert r.exit_code == ExitCode.USER_ERROR
327
328 def test_d2_exit_code_1_for_not_a_dict_json_mode(self, tmp_path: pathlib.Path) -> None:
329 """D2: msgpack integer → exit_code 1 (user error) in JSON."""
330 repo = _make_repo(tmp_path)
331 not_map = msgpack.packb(99, use_bin_type=True)
332 r = _uo(repo, not_map, "--json")
333 assert r.exit_code == ExitCode.USER_ERROR
334
335 def test_d3_exit_code_3_for_write_failure_json_mode(self, tmp_path: pathlib.Path) -> None:
336 """D3: apply_mpack OSError → exit_code 3 (internal error) in JSON."""
337 src = _make_repo(tmp_path / "src")
338 dst = _make_repo(tmp_path / "dst")
339 sid = _snap(src)
340 cid = _commit(src, sid)
341 pack_bytes = _po(src, cid).stdout_bytes
342
343 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("ENOSPC")):
344 r = _uo(dst, pack_bytes, "--json")
345
346 assert r.exit_code == ExitCode.INTERNAL_ERROR
347
348 def test_d4_tags_written_is_zero_for_tagless_pack(self, tmp_path: pathlib.Path) -> None:
349 """D4: a pack with no tags yields tags_written=0."""
350 src = _make_repo(tmp_path / "src")
351 dst = _make_repo(tmp_path / "dst")
352 sid = _snap(src)
353 cid = _commit(src, sid)
354 pack_bytes = _po(src, cid).stdout_bytes
355 r = _uo(dst, pack_bytes, "--json")
356 assert r.exit_code == 0
357 data = json.loads(r.output)
358 assert data["tags_written"] == 0
359
360 def test_d5_success_json_all_count_fields_present(self, tmp_path: pathlib.Path) -> None:
361 """D5: success JSON has all expected count fields."""
362 src = _make_repo(tmp_path / "src")
363 dst = _make_repo(tmp_path / "dst")
364 sid = _snap(src)
365 cid = _commit(src, sid)
366 pack_bytes = _po(src, cid).stdout_bytes
367 r = _uo(dst, pack_bytes, "--json")
368 assert r.exit_code == 0
369 data = json.loads(r.output)
370 expected_keys = {
371 "commits_written", "snapshots_written", "objects_written",
372 "objects_skipped", "tags_written", "duration_ms", "exit_code",
373 }
374 missing = expected_keys - data.keys()
375 assert not missing, f"missing keys in success JSON: {missing}"
376
377
378 # ---------------------------------------------------------------------------
379 # P — Performance
380 # ---------------------------------------------------------------------------
381
382
383 class TestPerformance:
384 """P1–P2: duration_ms is a realistic duration."""
385
386 def test_p1_duration_ms_under_5000ms_for_single_commit(self, tmp_path: pathlib.Path) -> None:
387 """P1: unpacking a single commit pack finishes in < 5 seconds."""
388 src = _make_repo(tmp_path / "src")
389 dst = _make_repo(tmp_path / "dst")
390 sid = _snap(src)
391 cid = _commit(src, sid)
392 pack_bytes = _po(src, cid).stdout_bytes
393 r = _uo(dst, pack_bytes, "--json")
394 assert r.exit_code == 0
395 data = json.loads(r.output)
396 assert data["duration_ms"] < 5000.0, f"too slow: {data['duration_ms']} ms"
397
398 def test_p2_duration_ms_under_5000ms_for_five_commit_chain(self, tmp_path: pathlib.Path) -> None:
399 """P2: unpacking a 5-commit chain finishes in < 5 seconds."""
400 src = _make_repo(tmp_path / "src")
401 dst = _make_repo(tmp_path / "dst")
402 sid = _snap(src)
403 prev = None
404 last_cid = ""
405 for i in range(5):
406 prev = _commit(src, sid, parent=prev, message=f"commit-{i}")
407 last_cid = prev
408 pack_bytes = _po(src, last_cid).stdout_bytes
409 r = _uo(dst, pack_bytes, "--json")
410 assert r.exit_code == 0
411 data = json.loads(r.output)
412 assert data["duration_ms"] < 5000.0, f"too slow for 5-commit chain: {data['duration_ms']} ms"
413
414
415 # ---------------------------------------------------------------------------
416 # Sec — Security: no traceback on any error path
417 # ---------------------------------------------------------------------------
418
419
420 class TestSecurity:
421 """Sec1–Sec3: error paths must never produce raw Python tracebacks."""
422
423 def test_sec1_no_traceback_on_corrupted_msgpack_json_mode(self, tmp_path: pathlib.Path) -> None:
424 """Sec1: corrupted msgpack with --json → no Traceback in output."""
425 repo = _make_repo(tmp_path)
426 r = _uo(repo, b"\x00\x01\x02 garbage", "--json")
427 assert r.exit_code != 0
428 assert "Traceback" not in r.output
429 assert "Traceback" not in r.stderr
430
431 def test_sec2_no_traceback_on_apply_mpack_oserror(self, tmp_path: pathlib.Path) -> None:
432 """Sec2: mocked write failure with --json → no Traceback in output."""
433 src = _make_repo(tmp_path / "src")
434 dst = _make_repo(tmp_path / "dst")
435 sid = _snap(src)
436 cid = _commit(src, sid)
437 pack_bytes = _po(src, cid).stdout_bytes
438
439 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("permission denied")):
440 r = _uo(dst, pack_bytes, "--json")
441
442 assert r.exit_code != 0
443 assert "Traceback" not in r.output
444 assert "Traceback" not in r.stderr
445
446 def test_sec3_no_traceback_on_apply_mpack_oserror_text_mode(self, tmp_path: pathlib.Path) -> None:
447 """Sec3: mocked write failure in text mode → no Traceback in output."""
448 src = _make_repo(tmp_path / "src")
449 dst = _make_repo(tmp_path / "dst")
450 sid = _snap(src)
451 cid = _commit(src, sid)
452 pack_bytes = _po(src, cid).stdout_bytes
453
454 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("permission denied")):
455 r = _uo(dst, pack_bytes)
456
457 assert r.exit_code != 0
458 assert "Traceback" not in r.output
459 assert "Traceback" not in r.stderr
460
461
462 # ---------------------------------------------------------------------------
463 # Flag registration
464 # ---------------------------------------------------------------------------
465
466
467 class TestRegisterFlags:
468 def _parse(self, *args: str):
469 import argparse
470 from muse.cli.commands.unpack_objects import register
471 p = argparse.ArgumentParser()
472 sub = p.add_subparsers()
473 register(sub)
474 return p.parse_args(["unpack-objects", *args])
475
476 def test_default_json_out_is_false(self) -> None:
477 ns = self._parse()
478 assert ns.json_out is False
479
480 def test_json_flag_sets_json_out(self) -> None:
481 ns = self._parse("--json")
482 assert ns.json_out is True
483
484 def test_j_shorthand_sets_json_out(self) -> None:
485 ns = self._parse("-j")
486 assert ns.json_out is True
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago