Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Commit 2aedc2c

Browse files
tschmclaude
andcommitted
test: assert command failure-path exit codes and messages (#592)
Add TestCommandFailureMessages covering each CLI command's realistic failure: sync (invalid strategy), init (non-git dir), validate (empty template), list (API unreachable), summarise (non-git dir), uninstall (undeletable path), status/tree (corrupt lock + missing-lock guidance). Each asserts the non-zero exit code and the accompanying actionable message. Adds a loguru_messages sink fixture (CliRunner does not capture loguru output) and documents that migrate has no non-zero exit path by design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 330eaa3 commit 2aedc2c

1 file changed

Lines changed: 151 additions & 0 deletions

File tree

tests/test_cli_commands.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import shutil
99
import subprocess # nosec B404
1010
import sys
11+
import urllib.error
12+
from pathlib import Path
1113
from unittest.mock import patch
1214

1315
import pytest
@@ -18,6 +20,44 @@
1820
from rhiza.cli import app, version_callback
1921

2022

23+
@pytest.fixture
24+
def loguru_messages():
25+
"""Capture loguru log messages emitted during the test.
26+
27+
loguru writes to the stderr reference bound at import time, which neither
28+
``capsys`` nor ``capfd`` reliably intercept under ``CliRunner``. A
29+
dedicated in-process sink is the robust way to assert on log output.
30+
31+
Yields:
32+
A list that accumulates each log record's message text.
33+
"""
34+
from loguru import logger
35+
36+
messages: list[str] = []
37+
sink_id = logger.add(messages.append, level="TRACE", format="{message}")
38+
try:
39+
yield messages
40+
finally:
41+
logger.remove(sink_id)
42+
43+
44+
def _init_git_repo(path: Path) -> Path:
45+
"""Initialise a minimal git repository at *path* for CLI failure tests.
46+
47+
Args:
48+
path: Directory to turn into a git repository (created if absent).
49+
50+
Returns:
51+
The repository path, for convenient chaining.
52+
"""
53+
path.mkdir(parents=True, exist_ok=True)
54+
git_cmd = shutil.which("git") or "git"
55+
subprocess.run([git_cmd, "init"], cwd=path, check=True) # nosec B603
56+
subprocess.run([git_cmd, "config", "user.email", "test@test.com"], cwd=path, check=True) # nosec B603
57+
subprocess.run([git_cmd, "config", "user.name", "Test"], cwd=path, check=True) # nosec B603
58+
return path
59+
60+
2161
class TestCliApp:
2262
"""Tests for the CLI Typer app."""
2363

@@ -237,3 +277,114 @@ def test_summarise_exits_with_code_1_on_runtime_error(self, tmp_path):
237277
with patch("rhiza.cli.summarise_cmd", side_effect=RuntimeError("summarise failed")):
238278
result = self.runner.invoke(app, ["summarise", str(tmp_path)])
239279
assert result.exit_code == 1
280+
281+
282+
class TestCommandFailureMessages:
283+
"""Each command's realistic failure path exits non-zero with an actionable message.
284+
285+
Messages emitted through ``typer.echo`` land in ``result.output``; messages
286+
logged via loguru are captured through the ``loguru_messages`` sink fixture
287+
(CliRunner's stream capture does not see loguru output).
288+
"""
289+
290+
runner = CliRunner()
291+
292+
def test_sync_invalid_strategy_reports_message(self, tmp_path):
293+
"""Sync with an unknown --strategy exits non-zero and names the valid options."""
294+
result = self.runner.invoke(app, ["sync", str(tmp_path), "--strategy", "bogus"])
295+
assert result.exit_code != 0
296+
assert "Unknown strategy: bogus" in result.output
297+
assert "merge" in result.output
298+
assert "diff" in result.output
299+
300+
def test_init_non_git_directory_reports_message(self, tmp_path, loguru_messages):
301+
"""Init on a non-git directory exits 1 and tells the user to run git init."""
302+
result = self.runner.invoke(app, ["init", str(tmp_path), "--git-host", "github"])
303+
assert result.exit_code == 1
304+
assert any("is not a git repository" in m for m in loguru_messages)
305+
306+
def test_validate_invalid_template_reports_message(self, tmp_path, loguru_messages):
307+
"""Validate on an empty template.yml exits 1 and lists the required keys."""
308+
repo = _init_git_repo(tmp_path / "repo")
309+
(repo / "src").mkdir()
310+
(repo / "tests").mkdir()
311+
(repo / "pyproject.toml").write_text("[project]\nname = 'x'\n")
312+
rhiza_dir = repo / ".rhiza"
313+
rhiza_dir.mkdir()
314+
(rhiza_dir / "template.yml").write_text("{}")
315+
316+
result = self.runner.invoke(app, ["validate", str(repo)])
317+
assert result.exit_code == 1
318+
assert any("Must specify at least one of 'profiles', 'templates', or 'include'" in m for m in loguru_messages)
319+
320+
def test_list_fetch_failure_reports_message(self, loguru_messages):
321+
"""List exits 1 and reports the failure when the GitHub API is unreachable."""
322+
with patch(
323+
"rhiza.commands.list_repos._fetch_repos",
324+
side_effect=urllib.error.URLError("no network"),
325+
):
326+
result = self.runner.invoke(app, ["list"])
327+
assert result.exit_code == 1
328+
assert any("Failed to fetch repositories" in m for m in loguru_messages)
329+
330+
def test_summarise_non_git_directory_reports_message(self, tmp_path, loguru_messages):
331+
"""Summarise on a non-git directory exits 1 and suggests git init."""
332+
result = self.runner.invoke(app, ["summarise", str(tmp_path)])
333+
assert result.exit_code == 1
334+
assert any("not a git repository" in m for m in loguru_messages)
335+
assert any("git init" in m for m in loguru_messages)
336+
337+
def test_uninstall_deletion_error_reports_message(self, tmp_path, loguru_messages):
338+
"""Uninstall exits 1 and reports per-file errors when a listed path cannot be removed."""
339+
repo = _init_git_repo(tmp_path / "repo")
340+
rhiza_dir = repo / ".rhiza"
341+
rhiza_dir.mkdir()
342+
# A lock listing a *directory* path: unlink() raises IsADirectoryError,
343+
# which _remove_files records as an error and surfaces as a RuntimeError.
344+
(repo / "tracked_dir").mkdir()
345+
(rhiza_dir / "template.lock").write_text(
346+
"sha: abc123\nrepo: my-org/t\nref: main\nsynced_at: '2024-11-01T10:00:00Z'\nfiles:\n - tracked_dir\n"
347+
)
348+
349+
result = self.runner.invoke(app, ["uninstall", str(repo), "--force"])
350+
assert result.exit_code == 1
351+
assert any("Failed to delete" in m for m in loguru_messages)
352+
353+
def test_status_corrupt_lock_exits_one(self, tmp_path):
354+
"""Status exits 1 when template.lock is present but unparseable."""
355+
rhiza_dir = tmp_path / ".rhiza"
356+
rhiza_dir.mkdir()
357+
(rhiza_dir / "template.lock").write_text("key: [unterminated\n")
358+
result = self.runner.invoke(app, ["status", str(tmp_path)])
359+
assert result.exit_code == 1
360+
361+
def test_status_missing_lock_reports_actionable_warning(self, tmp_path, loguru_messages):
362+
"""Status with no lock file guides the user to run sync (exit 0, actionable message)."""
363+
result = self.runner.invoke(app, ["status", str(tmp_path)])
364+
assert result.exit_code == 0
365+
assert any("run `rhiza sync` first" in m for m in loguru_messages)
366+
367+
def test_tree_corrupt_lock_exits_one(self, tmp_path):
368+
"""Tree exits 1 when template.lock is present but unparseable."""
369+
rhiza_dir = tmp_path / ".rhiza"
370+
rhiza_dir.mkdir()
371+
(rhiza_dir / "template.lock").write_text("key: [unterminated\n")
372+
result = self.runner.invoke(app, ["tree", str(tmp_path)])
373+
assert result.exit_code == 1
374+
375+
def test_tree_missing_lock_reports_actionable_warning(self, tmp_path, loguru_messages):
376+
"""Tree with no lock file guides the user to run sync (exit 0, actionable message)."""
377+
result = self.runner.invoke(app, ["tree", str(tmp_path)])
378+
assert result.exit_code == 0
379+
assert any("run `rhiza sync` first" in m for m in loguru_messages)
380+
381+
def test_migrate_has_no_failure_exit_path(self, tmp_path):
382+
"""Migrate is best-effort and idempotent: it exits 0 even with nothing to migrate.
383+
384+
Unlike the other commands, ``rhiza migrate`` deliberately has no
385+
non-zero exit path — it skips files that already exist and reports what
386+
it did. This test documents that contract so a future change that adds
387+
a failure exit is a conscious decision, not an accident.
388+
"""
389+
result = self.runner.invoke(app, ["migrate", str(_init_git_repo(tmp_path / "repo"))])
390+
assert result.exit_code == 0

0 commit comments

Comments
 (0)