gabriel / muse public

test_cmd_read_snapshot.py file-level

at sha256:1 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:e fix: muse read-snapshot silently dropped the directories field (musehub… · gabriel · Sep 18, 2026
1 """Comprehensive tests for ``muse read-snapshot``.
2
3 Coverage tiers
4 --------------
5 - Unit: schema keys constant
6 - Integration: JSON/text, --no-manifest, --path-prefix, manifest presence
7 - Security: ANSI in snapshot IDs rejected, no traceback on bad input
8 - Stress: 1000-path manifest, 200 sequential reads
9 """
10 from __future__ import annotations
11
12 import datetime
13 import json
14 import pathlib
15
16 from muse.core.errors import ExitCode
17 from muse.core.paths import muse_dir
18 from muse.core.ids import hash_snapshot
19 from muse.core.snapshots import (
20 SnapshotRecord,
21 write_snapshot,
22 )
23 from tests.cli_test_helper import CliRunner, InvokeResult
24 from muse.core.types import NULL_COMMIT_ID, fake_id, long_id, short_id
25
26 runner = CliRunner()
27
28 _CREATED_AT: datetime.datetime = datetime.datetime(
29 2026, 3, 18, 12, 0, tzinfo=datetime.timezone.utc
30 )
31
32
33 # ---------------------------------------------------------------------------
34 # Helpers
35 # ---------------------------------------------------------------------------
36
37 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
38 repo = tmp_path / "repo"
39 dot_muse = muse_dir(repo)
40 for sub in ("objects", "commits", "snapshots", "refs/heads"):
41 (dot_muse / sub).mkdir(parents=True)
42 (dot_muse / "HEAD").write_text("ref: refs/heads/main")
43 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"}))
44 return repo
45
46
47 def _snap(
48 repo: pathlib.Path,
49 manifest: Manifest | None = None,
50 directories: list[str] | None = None,
51 ) -> str:
52 """Write a snapshot with a real content-addressed ID; return the snapshot_id."""
53 m: Manifest = manifest or {}
54 dirs = directories or []
55 snap_id = hash_snapshot(m, dirs or None)
56 rec = SnapshotRecord(
57 snapshot_id=snap_id,
58 manifest=m,
59 directories=dirs,
60 created_at=_CREATED_AT,
61 )
62 write_snapshot(repo, rec)
63 return snap_id
64
65
66 def _rs(repo: pathlib.Path, *args: str) -> InvokeResult:
67 from muse.cli.app import main as cli
68 return runner.invoke(
69 cli,
70 ["read-snapshot", *args],
71 env={"MUSE_REPO_ROOT": str(repo)},
72 )
73
74
75 def _rsj(repo: pathlib.Path, *args: str) -> InvokeResult:
76 """Like _rs but always passes --json."""
77 return _rs(repo, "--json", *args)
78
79
80 def _fake_oid(n: int) -> str:
81 return format(n, "064x")
82
83
84 # ---------------------------------------------------------------------------
85 # Integration — JSON format
86 # ---------------------------------------------------------------------------
87
88
89 class TestJsonFormat:
90 def test_full_output_empty_manifest(self, tmp_path: pathlib.Path) -> None:
91 repo = _make_repo(tmp_path)
92 sid = _snap(repo)
93 result = _rsj(repo, sid)
94 assert result.exit_code == 0
95 data = json.loads(result.output)
96 assert data["snapshot_id"] == sid
97 assert data["file_count"] == 0
98 assert data["manifest"] == {}
99
100 def test_manifest_paths_present(self, tmp_path: pathlib.Path) -> None:
101 repo = _make_repo(tmp_path)
102 oid = NULL_COMMIT_ID
103 sid = _snap(repo, {"src/main.py": oid, "tests/test_main.py": oid})
104 data = json.loads(_rsj(repo, sid).output)
105 assert "src/main.py" in data["manifest"]
106 assert "tests/test_main.py" in data["manifest"]
107 assert data["file_count"] == 2
108
109 def test_json_flag_shorthand(self, tmp_path: pathlib.Path) -> None:
110 repo = _make_repo(tmp_path)
111 sid = _snap(repo, {"a.py": _fake_oid(1)})
112 result = _rs(repo, "--json", sid)
113 assert result.exit_code == 0
114 assert "snapshot_id" in json.loads(result.output)
115
116 def test_created_at_iso8601(self, tmp_path: pathlib.Path) -> None:
117 repo = _make_repo(tmp_path)
118 sid = _snap(repo, {"b.py": _fake_oid(2)})
119 data = json.loads(_rsj(repo, sid).output)
120 datetime.datetime.fromisoformat(data["created_at"])
121
122 def test_file_count_reflects_manifest(self, tmp_path: pathlib.Path) -> None:
123 repo = _make_repo(tmp_path)
124 manifest = {f"file{i}.py": _fake_oid(i) for i in range(5)}
125 sid = _snap(repo, manifest)
126 data = json.loads(_rsj(repo, sid).output)
127 assert data["file_count"] == 5
128
129
130 # ---------------------------------------------------------------------------
131 # Integration — text format
132 # ---------------------------------------------------------------------------
133
134
135 class TestTextFormat:
136 def test_text_contains_prefix(self, tmp_path: pathlib.Path) -> None:
137 repo = _make_repo(tmp_path)
138 sid = _snap(repo, {"c.py": _fake_oid(3)})
139 result = _rs(repo, sid)
140 assert result.exit_code == 0
141 assert short_id(sid) in result.output
142
143 def test_text_contains_file_count(self, tmp_path: pathlib.Path) -> None:
144 repo = _make_repo(tmp_path)
145 sid = _snap(repo, {"a.py": _fake_oid(1), "b.py": _fake_oid(2)})
146 result = _rs(repo, sid)
147 assert "2 files" in result.output
148
149 def test_text_single_line(self, tmp_path: pathlib.Path) -> None:
150 repo = _make_repo(tmp_path)
151 sid = _snap(repo)
152 result = _rs(repo, sid)
153 lines = [l for l in result.output.splitlines() if l.strip()]
154 assert len(lines) == 1
155
156
157 # ---------------------------------------------------------------------------
158 # Integration — --no-manifest
159 # ---------------------------------------------------------------------------
160
161
162 class TestNoManifest:
163 def test_manifest_absent(self, tmp_path: pathlib.Path) -> None:
164 repo = _make_repo(tmp_path)
165 sid = _snap(repo, {"a.py": _fake_oid(1)})
166 data = json.loads(_rsj(repo, "--no-manifest", sid).output)
167 assert "manifest" not in data
168 assert data["file_count"] == 1
169
170 def test_snapshot_id_and_created_at_still_present(self, tmp_path: pathlib.Path) -> None:
171 repo = _make_repo(tmp_path)
172 sid = _snap(repo)
173 data = json.loads(_rsj(repo, "--no-manifest", sid).output)
174 assert data["snapshot_id"] == sid
175 assert "created_at" in data
176
177 def test_no_manifest_with_text_errors(self, tmp_path: pathlib.Path) -> None:
178 repo = _make_repo(tmp_path)
179 sid = _snap(repo)
180 result = _rs(repo, "--no-manifest", sid)
181 assert result.exit_code == ExitCode.USER_ERROR
182
183
184 # ---------------------------------------------------------------------------
185 # Integration — --path-prefix
186 # ---------------------------------------------------------------------------
187
188
189 class TestPathPrefix:
190 def test_prefix_filters_manifest(self, tmp_path: pathlib.Path) -> None:
191 repo = _make_repo(tmp_path)
192 sid = _snap(repo, {
193 "src/a.py": _fake_oid(1),
194 "src/b.py": _fake_oid(2),
195 "tests/c.py": _fake_oid(3),
196 })
197 data = json.loads(_rsj(repo, "--path-prefix", "src/", sid).output)
198 assert set(data["manifest"].keys()) == {"src/a.py", "src/b.py"}
199 assert data["file_count"] == 2
200
201 def test_prefix_no_match_returns_empty(self, tmp_path: pathlib.Path) -> None:
202 repo = _make_repo(tmp_path)
203 sid = _snap(repo, {"src/a.py": _fake_oid(1)})
204 data = json.loads(_rsj(repo, "--path-prefix", "docs/", sid).output)
205 assert data["manifest"] == {}
206 assert data["file_count"] == 0
207
208 def test_prefix_with_text_errors(self, tmp_path: pathlib.Path) -> None:
209 repo = _make_repo(tmp_path)
210 sid = _snap(repo)
211 result = _rs(repo, "--path-prefix", "src/", sid)
212 assert result.exit_code == ExitCode.USER_ERROR
213
214
215 # ---------------------------------------------------------------------------
216 # Integration — directories (musehub#93: previously silently dropped)
217 # ---------------------------------------------------------------------------
218
219
220 class TestDirectories:
221 def test_directories_present_in_output(self, tmp_path: pathlib.Path) -> None:
222 repo = _make_repo(tmp_path)
223 sid = _snap(repo, {"tracks/drums.mid": _fake_oid(1)}, directories=["tracks", "empty_dir"])
224 data = json.loads(_rsj(repo, sid).output)
225 assert set(data["directories"]) == {"tracks", "empty_dir"}
226 assert data["dir_count"] == 2
227
228 def test_no_directories_is_empty_list_not_absent(self, tmp_path: pathlib.Path) -> None:
229 repo = _make_repo(tmp_path)
230 sid = _snap(repo, {"a.py": _fake_oid(1)})
231 data = json.loads(_rsj(repo, sid).output)
232 assert data["directories"] == []
233 assert data["dir_count"] == 0
234
235 def test_directories_present_with_no_manifest(self, tmp_path: pathlib.Path) -> None:
236 """--no-manifest omits the file manifest but must still surface
237 directories -- they're part of the snapshot's own identity, not
238 bulk data being economized on."""
239 repo = _make_repo(tmp_path)
240 sid = _snap(repo, {"a.py": _fake_oid(1)}, directories=["empty_dir"])
241 data = json.loads(_rsj(repo, "--no-manifest", sid).output)
242 assert "manifest" not in data
243 assert data["directories"] == ["empty_dir"]
244 assert data["dir_count"] == 1
245
246 def test_path_prefix_filters_directories_too(self, tmp_path: pathlib.Path) -> None:
247 repo = _make_repo(tmp_path)
248 sid = _snap(
249 repo,
250 {"src/a.py": _fake_oid(1), "tests/b.py": _fake_oid(2)},
251 directories=["src/empty", "tests/empty"],
252 )
253 data = json.loads(_rsj(repo, "--path-prefix", "src/", sid).output)
254 assert data["directories"] == ["src/empty"]
255 assert data["dir_count"] == 1
256
257 def test_text_format_shows_dir_count(self, tmp_path: pathlib.Path) -> None:
258 repo = _make_repo(tmp_path)
259 sid = _snap(repo, {"a.py": _fake_oid(1)}, directories=["empty_dir", "other_dir"])
260 result = _rs(repo, sid)
261 assert "2 dirs" in result.output
262
263 def test_directories_survive_write_and_read_roundtrip(self, tmp_path: pathlib.Path) -> None:
264 """Regression guard for musehub#93's core symptom: directories must
265 round-trip through write_snapshot/read_snapshot exactly, since they
266 are part of the hash -- any loss anywhere in this path breaks the
267 snapshot's own self-verification on the next read."""
268 repo = _make_repo(tmp_path)
269 dirs = sorted(f"dir{i}" for i in range(31))
270 sid = _snap(repo, {f"file{i}.py": _fake_oid(i) for i in range(5)}, directories=dirs)
271 data = json.loads(_rsj(repo, sid).output)
272 assert sorted(data["directories"]) == dirs
273 assert data["dir_count"] == 31
274
275
276 # ---------------------------------------------------------------------------
277 # Error cases
278 # ---------------------------------------------------------------------------
279
280
281 class TestErrors:
282 def test_missing_snapshot_errors(self, tmp_path: pathlib.Path) -> None:
283 repo = _make_repo(tmp_path)
284 # Valid sha256: format but not present in the store — must get "not found".
285 result = _rs(repo, long_id(f"dead{'beef' * 15}"))
286 assert result.exit_code == ExitCode.USER_ERROR
287
288 def test_invalid_snapshot_id_errors(self, tmp_path: pathlib.Path) -> None:
289 repo = _make_repo(tmp_path)
290 result = _rs(repo, "not-valid")
291 assert result.exit_code == ExitCode.USER_ERROR
292
293 def test_unknown_format_errors_argparse_rejects(self, tmp_path: pathlib.Path) -> None:
294 """--format flag no longer exists; argparse exits 2."""
295 repo = _make_repo(tmp_path)
296 sid = _snap(repo)
297 result = _rs(repo, "--format", "msgpack", sid)
298 assert result.exit_code != 0 # argparse rejects unknown flag with exit 2
299
300
301 # ---------------------------------------------------------------------------
302 # Security
303 # ---------------------------------------------------------------------------
304
305
306 class TestSecurity:
307 def test_ansi_in_snapshot_id_rejected(self, tmp_path: pathlib.Path) -> None:
308 repo = _make_repo(tmp_path)
309 result = _rs(repo, f"\x1b[31m{'a' * 64}")
310 assert result.exit_code == ExitCode.USER_ERROR
311
312 def test_no_traceback_on_bad_id(self, tmp_path: pathlib.Path) -> None:
313 repo = _make_repo(tmp_path)
314 result = _rs(repo, "bad-id")
315 assert "Traceback" not in result.output
316
317
318 # ---------------------------------------------------------------------------
319 # Stress
320 # ---------------------------------------------------------------------------
321
322
323 class TestStress:
324 def test_1000_file_manifest(self, tmp_path: pathlib.Path) -> None:
325 repo = _make_repo(tmp_path)
326 manifest = {f"src/module{i:04d}.py": _fake_oid(i) for i in range(1000)}
327 sid = _snap(repo, manifest)
328 result = _rsj(repo, sid)
329 assert result.exit_code == 0
330 data = json.loads(result.output)
331 assert data["file_count"] == 1000
332 assert len(data["manifest"]) == 1000
333
334 def test_1000_file_manifest_no_manifest(self, tmp_path: pathlib.Path) -> None:
335 repo = _make_repo(tmp_path)
336 manifest = {f"src/module{i:04d}.py": _fake_oid(i) for i in range(1000)}
337 sid = _snap(repo, manifest)
338 result = _rsj(repo, "--no-manifest", sid)
339 assert result.exit_code == 0
340 data = json.loads(result.output)
341 assert data["file_count"] == 1000
342 assert "manifest" not in data
343
344 def test_200_sequential_reads(self, tmp_path: pathlib.Path) -> None:
345 repo = _make_repo(tmp_path)
346 sid = _snap(repo, {"a.py": _fake_oid(0)})
347 for i in range(200):
348 result = _rsj(repo, sid)
349 assert result.exit_code == 0, f"failed at iteration {i}"
350 data = json.loads(result.output)
351 assert data["file_count"] == 1
352
353
354 class TestRegisterFlags:
355 def _parse(self, *args: str) -> "argparse.Namespace":
356 import argparse
357 from muse.cli.commands.read_snapshot import register
358 p = argparse.ArgumentParser()
359 subs = p.add_subparsers()
360 register(subs)
361 return p.parse_args(["read-snapshot", fake_id("a"), *args])
362
363 def test_json_short_flag(self) -> None:
364 args = self._parse("-j")
365 assert args.json_out is True
366
367 def test_json_long_flag(self) -> None:
368 args = self._parse("--json")
369 assert args.json_out is True
370
371 def test_default_no_json(self) -> None:
372 args = self._parse()
373 assert args.json_out is False