|
8 | 8 | import shutil |
9 | 9 | import subprocess # nosec B404 |
10 | 10 | import sys |
| 11 | +import urllib.error |
| 12 | +from pathlib import Path |
11 | 13 | from unittest.mock import patch |
12 | 14 |
|
13 | 15 | import pytest |
|
18 | 20 | from rhiza.cli import app, version_callback |
19 | 21 |
|
20 | 22 |
|
| 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 | + |
21 | 61 | class TestCliApp: |
22 | 62 | """Tests for the CLI Typer app.""" |
23 | 63 |
|
@@ -237,3 +277,114 @@ def test_summarise_exits_with_code_1_on_runtime_error(self, tmp_path): |
237 | 277 | with patch("rhiza.cli.summarise_cmd", side_effect=RuntimeError("summarise failed")): |
238 | 278 | result = self.runner.invoke(app, ["summarise", str(tmp_path)]) |
239 | 279 | 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