gabriel / muse public

test_cmd_cat_object.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:b adding issues docs to bust staging mpack prebuild cache. · gabriel · Jun 20, 2026
1 """Comprehensive tests for ``muse cat-object``.
2
3 Coverage tiers
4 --------------
5 - Unit: _CHUNK constant, _FORMAT_CHOICES
6 - Integration: raw/info formats, --json alias, missing/invalid object_id,
7 duration_ms in JSON output, --inline base64 content embedding
8 - Batch: --batch happy path, missing OIDs, mixed, binary, --batch-check,
9 sha256:-prefixed OIDs from stdin, invalid OIDs handled as missing,
10 empty lines skipped, large objects
11 - Security: ANSI in object_id error, path traversal object_id
12 - Stress: 10 MiB object streaming, 200 sequential reads
13 """
14 from __future__ import annotations
15
16 import json
17 import pathlib
18
19 from muse.core.types import blob_id, fake_id, long_id
20 from muse.core.errors import ExitCode
21 from muse.core.object_store import write_object
22 from muse.core.paths import muse_dir
23 from tests.cli_test_helper import CliRunner, InvokeResult
24
25 runner = CliRunner()
26
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
33 """Minimal .muse/ structure."""
34 repo = tmp_path / "repo"
35 dot_muse = muse_dir(repo)
36 for sub in ("objects", "commits", "snapshots", "refs/heads"):
37 (dot_muse / sub).mkdir(parents=True)
38 (dot_muse / "HEAD").write_text("ref: refs/heads/main")
39 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test", "domain": "code"}))
40 return repo
41
42
43 def _store(repo: pathlib.Path, content: bytes) -> str:
44 """Write content to the object store and return its canonical object_id (sha256: prefix)."""
45 oid = blob_id(content)
46 write_object(repo, oid, content)
47 return oid
48
49
50 def _cat(repo: pathlib.Path, *args: str, stdin: str | bytes | None = None) -> InvokeResult:
51 from muse.cli.app import main as cli
52 return runner.invoke(
53 cli,
54 ["cat-object", *args],
55 env={"MUSE_REPO_ROOT": str(repo)},
56 input=stdin,
57 )
58
59
60 # ---------------------------------------------------------------------------
61 # Unit — module constants
62 # ---------------------------------------------------------------------------
63
64
65 class TestConstants:
66 def test_chunk_size_is_64kib(self) -> None:
67 from muse.cli.commands.cat_object import _CHUNK
68 assert _CHUNK == 65536
69
70 def test_format_choices_correct(self) -> None:
71 from muse.cli.commands.cat_object import _FORMAT_CHOICES
72 assert "raw" in _FORMAT_CHOICES
73 assert "info" in _FORMAT_CHOICES
74 assert "json" not in _FORMAT_CHOICES
75
76
77 # ---------------------------------------------------------------------------
78 # Integration — raw format (single-object mode)
79 # ---------------------------------------------------------------------------
80
81
82 class TestRawFormat:
83 def test_raw_bytes_match_stored_content(self, tmp_path: pathlib.Path) -> None:
84 repo = _make_repo(tmp_path)
85 content = b"hello object store"
86 oid = _store(repo, content)
87 result = _cat(repo, oid)
88 assert result.exit_code == 0
89 assert result.stdout_bytes == content
90
91 def test_raw_is_default_format(self, tmp_path: pathlib.Path) -> None:
92 repo = _make_repo(tmp_path)
93 content = b"default format"
94 oid = _store(repo, content)
95 result = _cat(repo, oid)
96 assert result.exit_code == 0
97 assert result.stdout_bytes == content
98
99 def test_raw_binary_content_preserved(self, tmp_path: pathlib.Path) -> None:
100 repo = _make_repo(tmp_path)
101 content = bytes(range(256))
102 oid = _store(repo, content)
103 result = _cat(repo, oid)
104 assert result.exit_code == 0
105 assert result.stdout_bytes == content
106
107 def test_raw_empty_object(self, tmp_path: pathlib.Path) -> None:
108 repo = _make_repo(tmp_path)
109 content = b""
110 oid = _store(repo, content)
111 result = _cat(repo, oid)
112 assert result.exit_code == 0
113 assert result.stdout_bytes == content
114
115 def test_explicit_format_raw(self, tmp_path: pathlib.Path) -> None:
116 repo = _make_repo(tmp_path)
117 content = b"explicit raw"
118 oid = _store(repo, content)
119 result = _cat(repo, oid)
120 assert result.exit_code == 0
121 assert result.stdout_bytes == content
122
123
124 # ---------------------------------------------------------------------------
125 # Integration — info / --json format (single-object mode)
126 # ---------------------------------------------------------------------------
127
128
129 class TestInfoFormat:
130 def test_info_format_shape(self, tmp_path: pathlib.Path) -> None:
131 repo = _make_repo(tmp_path)
132 content = b"info content"
133 oid = _store(repo, content)
134 result = _cat(repo, "--json", oid)
135 assert result.exit_code == 0
136 data = json.loads(result.output)
137 assert data["object_id"] == oid
138 assert data["present"] is True
139 assert data["size_bytes"] == len(content)
140
141 def test_json_flag_is_alias_for_info(self, tmp_path: pathlib.Path) -> None:
142 repo = _make_repo(tmp_path)
143 content = b"json alias test"
144 oid = _store(repo, content)
145 result = _cat(repo, "--json", oid)
146 assert result.exit_code == 0, f"--json failed: {result.output}"
147 data = json.loads(result.output)
148 assert data["object_id"] == oid
149 assert data["present"] is True
150 assert data["size_bytes"] == len(content)
151
152 def test_info_does_not_emit_content(self, tmp_path: pathlib.Path) -> None:
153 repo = _make_repo(tmp_path)
154 content = b"secret bytes"
155 oid = _store(repo, content)
156 result = _cat(repo, "--json", oid)
157 assert result.exit_code == 0
158 data = json.loads(result.output)
159 assert "object_id" in data
160 assert content not in result.output.encode()
161
162 def test_info_size_matches_actual_file(self, tmp_path: pathlib.Path) -> None:
163 repo = _make_repo(tmp_path)
164 content = b"size check " * 100
165 oid = _store(repo, content)
166 result = _cat(repo, "--json", oid)
167 data = json.loads(result.output)
168 assert data["size_bytes"] == len(content)
169
170 def test_missing_object_info_has_present_false(self, tmp_path: pathlib.Path) -> None:
171 repo = _make_repo(tmp_path)
172 oid = fake_id("missing-info-a")
173 result = _cat(repo, "--json", oid)
174 assert result.exit_code == ExitCode.USER_ERROR
175 data = json.loads(result.output)
176 assert data["present"] is False
177 assert data["size_bytes"] == 0
178
179 def test_json_flag_missing_object_has_present_false(self, tmp_path: pathlib.Path) -> None:
180 repo = _make_repo(tmp_path)
181 oid = fake_id("missing-json-b")
182 result = _cat(repo, "--json", oid)
183 assert result.exit_code == ExitCode.USER_ERROR
184 data = json.loads(result.output)
185 assert data["present"] is False
186
187 def test_json_output_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
188 repo = _make_repo(tmp_path)
189 content = b"elapsed timing test"
190 oid = _store(repo, content)
191 result = _cat(repo, "--json", oid)
192 assert result.exit_code == 0
193 data = json.loads(result.output)
194 assert "duration_ms" in data
195 assert isinstance(data["duration_ms"], float)
196 assert data["duration_ms"] >= 0.0
197
198 def test_json_duration_ms_present_for_missing_object(self, tmp_path: pathlib.Path) -> None:
199 repo = _make_repo(tmp_path)
200 oid = fake_id("missing-duration-c")
201 result = _cat(repo, "--json", oid)
202 assert result.exit_code == ExitCode.USER_ERROR
203 data = json.loads(result.output)
204 assert "duration_ms" in data
205
206
207 # ---------------------------------------------------------------------------
208 # Integration — error paths (single-object mode)
209 # ---------------------------------------------------------------------------
210
211
212 class TestErrorPaths:
213 def test_missing_object_raw_errors(self, tmp_path: pathlib.Path) -> None:
214 repo = _make_repo(tmp_path)
215 result = _cat(repo, fake_id("missing-raw-c"))
216 assert result.exit_code == ExitCode.USER_ERROR
217
218 def test_invalid_object_id_bare_hex_rejected(self, tmp_path: pathlib.Path) -> None:
219 """Bare hex without sha256: prefix is rejected — use sha256:<hex> form."""
220 repo = _make_repo(tmp_path)
221 result = _cat(repo, "a" * 64)
222 assert result.exit_code == ExitCode.USER_ERROR
223
224 def test_invalid_object_id_too_short(self, tmp_path: pathlib.Path) -> None:
225 repo = _make_repo(tmp_path)
226 result = _cat(repo, "abc123")
227 assert result.exit_code == ExitCode.USER_ERROR
228
229 def test_invalid_object_id_uppercase_content(self, tmp_path: pathlib.Path) -> None:
230 repo = _make_repo(tmp_path)
231 result = _cat(repo, long_id("A" * 64))
232 assert result.exit_code == ExitCode.USER_ERROR
233
234 def test_invalid_object_id_non_hex_content(self, tmp_path: pathlib.Path) -> None:
235 repo = _make_repo(tmp_path)
236 result = _cat(repo, long_id("z" * 64))
237 assert result.exit_code == ExitCode.USER_ERROR
238
239 def test_unrecognized_flag_errors(self, tmp_path: pathlib.Path) -> None:
240 repo = _make_repo(tmp_path)
241 result = _cat(repo, "--no-such-flag", fake_id("bad-flag-a"))
242 assert result.exit_code != 0
243
244 def test_no_object_id_without_batch_flag_errors(self, tmp_path: pathlib.Path) -> None:
245 repo = _make_repo(tmp_path)
246 result = _cat(repo)
247 assert result.exit_code == ExitCode.USER_ERROR
248
249 def test_no_repo_errors(self, tmp_path: pathlib.Path) -> None:
250 from muse.cli.app import main as cli
251 result = runner.invoke(
252 cli,
253 ["cat-object", fake_id("no-repo-a")],
254 env={"MUSE_REPO_ROOT": str(tmp_path / "no_repo")},
255 )
256 assert result.exit_code != 0
257
258
259 # ---------------------------------------------------------------------------
260 # Batch mode — --batch
261 # ---------------------------------------------------------------------------
262
263
264 class TestBatchMode:
265 def test_batch_single_object_emits_header_and_content(self, tmp_path: pathlib.Path) -> None:
266 repo = _make_repo(tmp_path)
267 content = b"batch content"
268 oid = _store(repo, content)
269 result = _cat(repo, "--batch", stdin=f"{oid}\n")
270 assert result.exit_code == 0
271 raw = result.stdout_bytes
272 # Header: "<oid> blob <size>\n"
273 header_line = f"{oid} blob {len(content)}\n".encode()
274 assert raw.startswith(header_line)
275 # Content follows header, then a trailing newline
276 body = raw[len(header_line):]
277 assert body == content + b"\n"
278
279 def test_batch_missing_oid_emits_missing(self, tmp_path: pathlib.Path) -> None:
280 repo = _make_repo(tmp_path)
281 oid = fake_id("missing-batch-d")
282 result = _cat(repo, "--batch", stdin=f"{oid}\n")
283 assert result.exit_code == 0
284 assert result.stdout_bytes == f"{oid} missing\n".encode()
285
286 def test_batch_invalid_oid_emits_missing(self, tmp_path: pathlib.Path) -> None:
287 """Invalid OIDs should produce a 'missing' line, not an error exit."""
288 repo = _make_repo(tmp_path)
289 result = _cat(repo, "--batch", stdin="not-a-valid-oid\n")
290 assert result.exit_code == 0
291 assert b"missing" in result.stdout_bytes
292
293 def test_batch_mixed_present_and_missing(self, tmp_path: pathlib.Path) -> None:
294 repo = _make_repo(tmp_path)
295 c1 = b"first"
296 c2 = b"second"
297 oid1 = _store(repo, c1)
298 oid2 = _store(repo, c2)
299 missing = fake_id("missing-mixed-e")
300 stdin = f"{oid1}\n{missing}\n{oid2}\n"
301 result = _cat(repo, "--batch", stdin=stdin)
302 assert result.exit_code == 0
303 raw = result.stdout_bytes
304
305 # oid1 present
306 assert f"{oid1} blob {len(c1)}\n".encode() in raw
307 assert c1 in raw
308 # missing
309 assert f"{missing} missing\n".encode() in raw
310 # oid2 present
311 assert f"{oid2} blob {len(c2)}\n".encode() in raw
312 assert c2 in raw
313
314 def test_batch_empty_lines_skipped(self, tmp_path: pathlib.Path) -> None:
315 repo = _make_repo(tmp_path)
316 content = b"hello"
317 oid = _store(repo, content)
318 # stdin has empty lines before and after
319 result = _cat(repo, "--batch", stdin=f"\n\n{oid}\n\n")
320 assert result.exit_code == 0
321 assert f"{oid} blob {len(content)}\n".encode() in result.stdout_bytes
322
323 def test_batch_binary_content_round_trips(self, tmp_path: pathlib.Path) -> None:
324 repo = _make_repo(tmp_path)
325 content = bytes(range(256))
326 oid = _store(repo, content)
327 result = _cat(repo, "--batch", stdin=f"{oid}\n")
328 assert result.exit_code == 0
329 raw = result.stdout_bytes
330 header = f"{oid} blob {len(content)}\n".encode()
331 body = raw[len(header):-1] # strip trailing newline
332 assert body == content
333
334 def test_batch_empty_stdin_produces_no_output(self, tmp_path: pathlib.Path) -> None:
335 repo = _make_repo(tmp_path)
336 result = _cat(repo, "--batch", stdin="")
337 assert result.exit_code == 0
338 assert result.stdout_bytes == b""
339
340 def test_batch_multiple_objects_in_order(self, tmp_path: pathlib.Path) -> None:
341 repo = _make_repo(tmp_path)
342 objects = [(b"alpha", ), (b"beta",), (b"gamma",)]
343 oids = [_store(repo, c[0]) for c in objects]
344 stdin = "\n".join(oids) + "\n"
345 result = _cat(repo, "--batch", stdin=stdin)
346 assert result.exit_code == 0
347 raw = result.stdout_bytes
348 pos = 0
349 for oid, (content,) in zip(oids, objects):
350 header = f"{oid} blob {len(content)}\n".encode()
351 assert raw[pos:pos + len(header)] == header
352 pos += len(header)
353 assert raw[pos:pos + len(content)] == content
354 pos += len(content) + 1 # +1 for trailing '\n'
355
356 def test_batch_mutually_exclusive_with_batch_check(self, tmp_path: pathlib.Path) -> None:
357 repo = _make_repo(tmp_path)
358 result = _cat(repo, "--batch", "--batch-check", stdin="")
359 assert result.exit_code != 0
360
361
362 # ---------------------------------------------------------------------------
363 # Batch-check mode — --batch-check
364 # ---------------------------------------------------------------------------
365
366
367 class TestBatchCheckMode:
368 def test_batch_check_emits_header_only_no_content(self, tmp_path: pathlib.Path) -> None:
369 repo = _make_repo(tmp_path)
370 content = b"check only"
371 oid = _store(repo, content)
372 result = _cat(repo, "--batch-check", stdin=f"{oid}\n")
373 assert result.exit_code == 0
374 raw = result.stdout_bytes
375 expected = f"{oid} blob {len(content)}\n".encode()
376 assert raw == expected
377 # Content bytes must NOT appear
378 assert content not in raw
379
380 def test_batch_check_missing_emits_missing(self, tmp_path: pathlib.Path) -> None:
381 repo = _make_repo(tmp_path)
382 oid = fake_id("missing-check-f")
383 result = _cat(repo, "--batch-check", stdin=f"{oid}\n")
384 assert result.exit_code == 0
385 assert result.stdout_bytes == f"{oid} missing\n".encode()
386
387 def test_batch_check_invalid_oid_emits_missing(self, tmp_path: pathlib.Path) -> None:
388 repo = _make_repo(tmp_path)
389 result = _cat(repo, "--batch-check", stdin="bad\n")
390 assert result.exit_code == 0
391 assert b"missing" in result.stdout_bytes
392
393 def test_batch_check_mixed(self, tmp_path: pathlib.Path) -> None:
394 repo = _make_repo(tmp_path)
395 c1 = b"present"
396 oid1 = _store(repo, c1)
397 missing = fake_id("missing-check-0")
398 result = _cat(repo, "--batch-check", stdin=f"{oid1}\n{missing}\n")
399 assert result.exit_code == 0
400 raw = result.stdout_bytes
401 assert f"{oid1} blob {len(c1)}\n".encode() in raw
402 assert f"{missing} missing\n".encode() in raw
403 # No content bytes
404 assert c1 not in raw
405
406 def test_batch_check_size_accurate(self, tmp_path: pathlib.Path) -> None:
407 repo = _make_repo(tmp_path)
408 content = b"x" * 1000
409 oid = _store(repo, content)
410 result = _cat(repo, "--batch-check", stdin=f"{oid}\n")
411 assert result.exit_code == 0
412 line = result.stdout_bytes.decode()
413 parts = line.strip().split()
414 assert parts[0] == oid
415 assert parts[1] == "blob"
416 assert int(parts[2]) == len(content)
417
418
419 # ---------------------------------------------------------------------------
420 # Security
421 # ---------------------------------------------------------------------------
422
423
424 class TestSecurity:
425 def test_ansi_in_invalid_id_not_in_output(self, tmp_path: pathlib.Path) -> None:
426 repo = _make_repo(tmp_path)
427 malicious = f"\x1b[31m{'a' * 60}"
428 result = _cat(repo, malicious)
429 assert result.exit_code == ExitCode.USER_ERROR
430 assert "\x1b" not in result.output
431
432 def test_path_traversal_in_object_id_rejected(self, tmp_path: pathlib.Path) -> None:
433 repo = _make_repo(tmp_path)
434 result = _cat(repo, "../../../etc/passwd")
435 assert result.exit_code == ExitCode.USER_ERROR
436
437 def test_null_byte_in_object_id_rejected(self, tmp_path: pathlib.Path) -> None:
438 repo = _make_repo(tmp_path)
439 result = _cat(repo, f"{'a' * 32}\x00{'b' * 31}")
440 assert result.exit_code == ExitCode.USER_ERROR
441
442 def test_no_traceback_on_invalid_id(self, tmp_path: pathlib.Path) -> None:
443 repo = _make_repo(tmp_path)
444 result = _cat(repo, "not-a-valid-id")
445 assert "Traceback" not in result.output
446
447 def test_batch_path_traversal_treated_as_missing(self, tmp_path: pathlib.Path) -> None:
448 """In batch mode, bad OIDs are not errors — they are reported as missing."""
449 repo = _make_repo(tmp_path)
450 result = _cat(repo, "--batch", stdin="../../../etc/passwd\n")
451 assert result.exit_code == 0
452 assert b"missing" in result.stdout_bytes
453
454
455 # ---------------------------------------------------------------------------
456 # Stress
457 # ---------------------------------------------------------------------------
458
459
460 class TestStress:
461 def test_large_object_streams_without_oom(self, tmp_path: pathlib.Path) -> None:
462 repo = _make_repo(tmp_path)
463 content = b"Z" * (10 * 1024 * 1024) # 10 MiB
464 oid = _store(repo, content)
465 result = _cat(repo, oid)
466 assert result.exit_code == 0
467 assert len(result.stdout_bytes) == len(content)
468 assert result.stdout_bytes == content
469
470 def test_large_object_info_is_fast(self, tmp_path: pathlib.Path) -> None:
471 repo = _make_repo(tmp_path)
472 content = b"Y" * (10 * 1024 * 1024)
473 oid = _store(repo, content)
474 result = _cat(repo, "--json", oid)
475 assert result.exit_code == 0
476 data = json.loads(result.output)
477 assert data["size_bytes"] == len(content)
478
479 def test_200_sequential_reads(self, tmp_path: pathlib.Path) -> None:
480 repo = _make_repo(tmp_path)
481 content = b"repeated read"
482 oid = _store(repo, content)
483 for i in range(200):
484 result = _cat(repo, oid)
485 assert result.exit_code == 0, f"failed at iteration {i}"
486 assert result.stdout_bytes == content
487
488 def test_batch_50_objects(self, tmp_path: pathlib.Path) -> None:
489 """50 objects through a single --batch invocation."""
490 repo = _make_repo(tmp_path)
491 pairs: list[tuple[str, bytes]] = []
492 for i in range(50):
493 content = f"object-{i:03d}".encode()
494 oid = _store(repo, content)
495 pairs.append((oid, content))
496 stdin = "\n".join(oid for oid, _ in pairs) + "\n"
497 result = _cat(repo, "--batch", stdin=stdin)
498 assert result.exit_code == 0
499 raw = result.stdout_bytes
500 for oid, content in pairs:
501 assert f"{oid} blob {len(content)}\n".encode() in raw
502 assert content in raw
503
504 def test_batch_check_100_objects(self, tmp_path: pathlib.Path) -> None:
505 """100 objects through --batch-check — no content read."""
506 repo = _make_repo(tmp_path)
507 oids = []
508 sizes = []
509 for i in range(100):
510 content = b"x" * (i + 1)
511 oid = _store(repo, content)
512 oids.append(oid)
513 sizes.append(len(content))
514 stdin = "\n".join(oids) + "\n"
515 result = _cat(repo, "--batch-check", stdin=stdin)
516 assert result.exit_code == 0
517 lines = result.stdout_bytes.decode().strip().splitlines()
518 assert len(lines) == 100
519 for line, oid, size in zip(lines, oids, sizes):
520 parts = line.split()
521 assert parts[0] == oid
522 assert parts[1] == "blob"
523 assert int(parts[2]) == size
524
525
526 # ---------------------------------------------------------------------------
527 # --inline — base64 content embedding in JSON (agent round-trip saver)
528 # ---------------------------------------------------------------------------
529
530
531 class TestInline:
532 """--inline embeds base64-encoded content in the --json output.
533
534 Agents that need both metadata and content for small objects can get both
535 in a single invocation instead of two (--json for metadata, raw for bytes).
536 """
537
538 def test_inline_embeds_content_b64(self, tmp_path: pathlib.Path) -> None:
539 import base64
540 repo = _make_repo(tmp_path)
541 content = b"hello inline"
542 oid = _store(repo, content)
543 result = _cat(repo, "--json", "--inline", oid)
544 assert result.exit_code == 0
545 data = json.loads(result.output)
546 assert "content_b64" in data
547 assert base64.b64decode(data["content_b64"]) == content
548
549 def test_inline_requires_json_flag(self, tmp_path: pathlib.Path) -> None:
550 """--inline without --json is a user error."""
551 repo = _make_repo(tmp_path)
552 content = b"inline needs json"
553 oid = _store(repo, content)
554 result = _cat(repo, "--inline", oid)
555 assert result.exit_code == ExitCode.USER_ERROR
556
557 def test_inline_binary_content_round_trips(self, tmp_path: pathlib.Path) -> None:
558 import base64
559 repo = _make_repo(tmp_path)
560 content = bytes(range(256))
561 oid = _store(repo, content)
562 result = _cat(repo, "--json", "--inline", oid)
563 assert result.exit_code == 0
564 data = json.loads(result.output)
565 assert base64.b64decode(data["content_b64"]) == content
566
567 def test_inline_missing_object_no_content_b64(self, tmp_path: pathlib.Path) -> None:
568 repo = _make_repo(tmp_path)
569 oid = fake_id("missing-inline-a")
570 result = _cat(repo, "--json", "--inline", oid)
571 assert result.exit_code == ExitCode.USER_ERROR
572 data = json.loads(result.output)
573 assert data["present"] is False
574 assert "content_b64" not in data
575
576 def test_inline_json_still_has_standard_fields(self, tmp_path: pathlib.Path) -> None:
577 import base64
578 repo = _make_repo(tmp_path)
579 content = b"standard fields check"
580 oid = _store(repo, content)
581 result = _cat(repo, "--json", "--inline", oid)
582 assert result.exit_code == 0
583 data = json.loads(result.output)
584 assert data["object_id"] == oid
585 assert data["present"] is True
586 assert data["size_bytes"] == len(content)
587 assert "duration_ms" in data
588 assert isinstance(base64.b64decode(data["content_b64"]), bytes)
589
590 def test_inline_empty_object(self, tmp_path: pathlib.Path) -> None:
591 import base64
592 repo = _make_repo(tmp_path)
593 content = b""
594 oid = _store(repo, content)
595 result = _cat(repo, "--json", "--inline", oid)
596 assert result.exit_code == 0
597 data = json.loads(result.output)
598 assert data["content_b64"] == base64.b64encode(b"").decode()
599
600 def test_no_inline_flag_has_no_content_b64(self, tmp_path: pathlib.Path) -> None:
601 """Without --inline the JSON output must NOT include content_b64."""
602 repo = _make_repo(tmp_path)
603 content = b"no inline here"
604 oid = _store(repo, content)
605 result = _cat(repo, "--json", oid)
606 assert result.exit_code == 0
607 data = json.loads(result.output)
608 assert "content_b64" not in data
609
610
611 # ---------------------------------------------------------------------------
612 # Flag registration tests
613 # ---------------------------------------------------------------------------
614
615 import argparse as _argparse
616 from muse.cli.commands.cat_object import register as _register_cat_object
617
618
619 def _parse_co(*args: str) -> _argparse.Namespace:
620 """Build an argument parser via register() and parse args."""
621 root_p = _argparse.ArgumentParser()
622 subs = root_p.add_subparsers(dest="cmd")
623 _register_cat_object(subs)
624 return root_p.parse_args(["cat-object", *args])
625
626
627 class TestRegisterFlags:
628 def test_default_json_out_is_false(self) -> None:
629 ns = _parse_co(fake_id("a"))
630 assert ns.json_out is False
631
632 def test_json_flag_sets_json_out(self) -> None:
633 ns = _parse_co(fake_id("a"), "--json")
634 assert ns.json_out is True
635
636 def test_j_shorthand_sets_json_out(self) -> None:
637 ns = _parse_co(fake_id("a"), "-j")
638 assert ns.json_out is True
639
640 def test_inline_flag(self) -> None:
641 ns = _parse_co(fake_id("a"), "--json", "--inline")
642 assert ns.inline is True
643
644 def test_format_flag_no_longer_exists(self) -> None:
645 import pytest
646 with pytest.raises(SystemExit):
647 _parse_co(fake_id("a"), "--format", "info")