Skip to content

Commit 42cb7a9

Browse files
authored
test: Phase 7 — test coverage (+24 tests, 64 → 88 total)
test_backup.py (9 new): - backup_file path structure, rotation (3 keep), exact keep_count enforcement - list_backups newest-first ordering, empty when no dir - restore_backup single file, all files, unknown-id error - restore_backup path-traversal rejection (validates Phase 1.4) test_cli.py (7 new, via typer CliRunner): - push updates manifest on success; skips on errors (validates Phase 2.3) - push cleans up sanitized temp even when engine raises - pull skips merge on empty remote .claude.json; merges when non-empty - remote add rejects address without @ - diff includes per-project files (validates Phase 3.1) test_engine.py (4 new): - push with project paths calls rsync per-project - get_remote_file_hashes raises on invalid JSON (validates Phase 2.1) - _rsync_project aggregates all failures (validates Phase 3.2) - check_connection handles TimeoutExpired (validates Phase 2.2) test_sanitize.py (4 new): - strips primaryApiKey and hasCompletedOnboarding - merge raises ValueError on corrupted pulled JSON (validates Phase 1.2) - merge raises ValueError on corrupted local JSON (validates Phase 1.2) cli.py: restore Optional[str] type hint for backup_restore (typer needs it on Python 3.9 — get_type_hints doesn't evaluate str|None syntax at runtime)
2 parents 7dbb739 + 954b047 commit 42cb7a9

5 files changed

Lines changed: 388 additions & 1 deletion

File tree

src/claudesync/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import typer
88
from rich.console import Console
99
from rich.table import Table
10+
from typing import Optional
1011

1112
from .backup import list_backups, restore_backup
1213
from .config import Config, Remote, SyncSettings, load_config, save_config
@@ -314,7 +315,7 @@ def backup_list() -> None:
314315
@backup_app.command("restore")
315316
def backup_restore(
316317
backup_id: str = typer.Argument(..., help="Backup ID to restore"),
317-
original_path: str | None = typer.Argument(None, help="Specific file to restore (or all files if omitted)"),
318+
original_path: Optional[str] = typer.Argument(None, help="Specific file to restore (or all files if omitted)"),
318319
) -> None:
319320
"""Restore a backed-up file."""
320321
try:

tests/test_backup.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Tests for backup management."""
2+
import pytest
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
from claudesync.backup import backup_file, list_backups, restore_backup
7+
8+
9+
@pytest.fixture
10+
def backup_dir(tmp_path, monkeypatch):
11+
"""Redirect BACKUP_DIR to an isolated temp directory and return it."""
12+
bd = tmp_path / "backups"
13+
monkeypatch.setattr("claudesync.backup.BACKUP_DIR", bd)
14+
return bd
15+
16+
17+
def _ts_backup(src: Path, ts: str, **kwargs) -> Path:
18+
"""Call backup_file with a fixed mock timestamp."""
19+
with patch("claudesync.backup.datetime") as mock_dt:
20+
mock_dt.now.return_value.strftime.return_value = ts
21+
return backup_file(src, **kwargs)
22+
23+
24+
def _get_backup_id(dest: Path, backup_dir: Path) -> str:
25+
"""Extract the timestamp directory name (backup_id) from a dest path."""
26+
return dest.relative_to(backup_dir).parts[0]
27+
28+
29+
def test_backup_file_creates_expected_path_structure(tmp_path, backup_dir):
30+
src = tmp_path / "settings.json"
31+
src.write_text("content")
32+
33+
dest = _ts_backup(src, "20260101T000000")
34+
35+
assert dest.exists()
36+
assert dest.is_relative_to(backup_dir)
37+
assert dest.name == "settings.json"
38+
39+
40+
def test_backup_file_rotates_when_over_limit(tmp_path, backup_dir):
41+
"""Verify rotation removes oldest entries when over keep_count."""
42+
src = tmp_path / "file.txt"
43+
src.write_text("data")
44+
45+
timestamps = [f"2026010{i}T000000" for i in range(5)]
46+
for ts in timestamps:
47+
_ts_backup(src, ts, keep_count=3)
48+
49+
ts_dirs = sorted([d for d in backup_dir.iterdir() if d.is_dir()])
50+
assert len(ts_dirs) == 3
51+
# Newest 3 kept: indices 2,3,4
52+
assert ts_dirs[0].name == "20260102T000000"
53+
assert ts_dirs[-1].name == "20260104T000000"
54+
55+
56+
def test_backup_file_keeps_exactly_keep_count_entries(tmp_path, backup_dir):
57+
src = tmp_path / "f.txt"
58+
src.write_text("x")
59+
60+
for i in range(10):
61+
_ts_backup(src, f"202601{i:02d}T000000", keep_count=5)
62+
63+
ts_dirs = [d for d in backup_dir.iterdir() if d.is_dir()]
64+
assert len(ts_dirs) == 5
65+
66+
67+
def test_list_backups_returns_newest_first(tmp_path, backup_dir):
68+
src = tmp_path / "f.txt"
69+
src.write_text("x")
70+
71+
_ts_backup(src, "20260101T000000")
72+
_ts_backup(src, "20260102T000000")
73+
74+
entries = list_backups()
75+
assert len(entries) >= 2
76+
ids = [e.backup_id for e in entries]
77+
assert ids == sorted(ids, reverse=True)
78+
79+
80+
def test_list_backups_empty_when_no_backup_dir(tmp_path, monkeypatch):
81+
monkeypatch.setattr("claudesync.backup.BACKUP_DIR", tmp_path / "nonexistent")
82+
assert list_backups() == []
83+
84+
85+
def test_restore_backup_single_file(tmp_path, backup_dir, monkeypatch):
86+
# Patch Path.home() so the restore guard accepts tmp_path
87+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
88+
89+
src = tmp_path / "restore_target.txt"
90+
src.write_text("original")
91+
dest = _ts_backup(src, "20260101T120000")
92+
backup_id = _get_backup_id(dest, backup_dir)
93+
94+
src.write_text("overwritten")
95+
restored = restore_backup(backup_id, str(src))
96+
97+
assert len(restored) == 1
98+
assert src.read_text() == "original"
99+
100+
101+
def test_restore_backup_all_files(tmp_path, backup_dir):
102+
src = tmp_path / "all_files.txt"
103+
src.write_text("data")
104+
dest = _ts_backup(src, "20260101T130000")
105+
backup_id = _get_backup_id(dest, backup_dir)
106+
107+
restored = restore_backup(backup_id)
108+
assert len(restored) >= 1
109+
110+
111+
def test_restore_backup_raises_on_unknown_backup_id(backup_dir):
112+
with pytest.raises(ValueError, match="not found"):
113+
restore_backup("nonexistent_id")
114+
115+
116+
def test_restore_backup_rejects_path_traversal(tmp_path, backup_dir):
117+
"""restore_backup must reject paths that escape the backup directory."""
118+
ts_dir = backup_dir / "20260101T000000"
119+
ts_dir.mkdir(parents=True)
120+
(ts_dir / "innocent.txt").write_text("ok")
121+
122+
with pytest.raises(ValueError, match="traversal"):
123+
restore_backup("20260101T000000", "/../../../etc/passwd")

tests/test_cli.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Tests for ClaudeSync CLI commands."""
2+
import json
3+
import pytest
4+
from pathlib import Path
5+
from unittest.mock import MagicMock, patch
6+
7+
from typer.testing import CliRunner
8+
9+
from claudesync.cli import app
10+
from claudesync.config import Config, Remote, SyncSettings
11+
from claudesync.engine import SyncError, SyncSummary
12+
13+
14+
runner = CliRunner()
15+
16+
REMOTE_NAME = "home"
17+
18+
19+
@pytest.fixture
20+
def mock_config(tmp_path):
21+
"""A Config with one remote and no projects."""
22+
remote = Remote(host="192.168.1.1", user="alice", ssh_key="~/.ssh/id_ed25519",
23+
remote_home="/home/alice")
24+
config = Config(remotes={REMOTE_NAME: remote}, projects=[], sync=SyncSettings())
25+
return config
26+
27+
28+
@pytest.fixture
29+
def connected_engine():
30+
"""An Engine mock that always reports connection success."""
31+
engine = MagicMock()
32+
engine.check_connection.return_value = True
33+
engine.get_remote_file_hashes.return_value = {}
34+
engine.push.return_value = SyncSummary(files_transferred=1, errors=[])
35+
engine.pull.return_value = SyncSummary(files_transferred=0, errors=[])
36+
return engine
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# push
41+
# ---------------------------------------------------------------------------
42+
43+
def test_push_updates_manifest_after_successful_sync(mock_config, connected_engine):
44+
with patch("claudesync.cli.load_config", return_value=mock_config), \
45+
patch("claudesync.cli.Engine", return_value=connected_engine), \
46+
patch("claudesync.cli.get_global_include_paths", return_value=[]), \
47+
patch("claudesync.cli.build_local_manifest", return_value={}), \
48+
patch("claudesync.cli.get_remote_manifest", return_value={}), \
49+
patch("claudesync.cli.update_manifest_for_remote") as mock_update, \
50+
patch("claudesync.cli.write_sanitized_temp", return_value=Path("/tmp/sanitized.json")), \
51+
patch("pathlib.Path.unlink"):
52+
result = runner.invoke(app, ["push", REMOTE_NAME])
53+
54+
assert result.exit_code == 0
55+
mock_update.assert_called_once()
56+
57+
58+
def test_push_skips_manifest_update_on_errors(mock_config, connected_engine):
59+
connected_engine.push.return_value = SyncSummary(files_transferred=0, errors=["rsync failed"])
60+
61+
with patch("claudesync.cli.load_config", return_value=mock_config), \
62+
patch("claudesync.cli.Engine", return_value=connected_engine), \
63+
patch("claudesync.cli.get_global_include_paths", return_value=[]), \
64+
patch("claudesync.cli.build_local_manifest", return_value={}), \
65+
patch("claudesync.cli.get_remote_manifest", return_value={}), \
66+
patch("claudesync.cli.update_manifest_for_remote") as mock_update, \
67+
patch("claudesync.cli.write_sanitized_temp", return_value=Path("/tmp/sanitized.json")), \
68+
patch("pathlib.Path.unlink"):
69+
result = runner.invoke(app, ["push", REMOTE_NAME])
70+
71+
assert result.exit_code == 0
72+
mock_update.assert_not_called()
73+
74+
75+
def test_push_cleans_up_sanitized_temp_on_engine_exception(mock_config, connected_engine):
76+
tmp_file = MagicMock(spec=Path)
77+
connected_engine.push.side_effect = RuntimeError("rsync crashed")
78+
79+
with patch("claudesync.cli.load_config", return_value=mock_config), \
80+
patch("claudesync.cli.Engine", return_value=connected_engine), \
81+
patch("claudesync.cli.get_global_include_paths", return_value=[]), \
82+
patch("claudesync.cli.build_local_manifest", return_value={}), \
83+
patch("claudesync.cli.get_remote_manifest", return_value={}), \
84+
patch("claudesync.cli.write_sanitized_temp", return_value=tmp_file):
85+
result = runner.invoke(app, ["push", REMOTE_NAME])
86+
87+
# Even on exception, unlink should have been called
88+
tmp_file.unlink.assert_called_once()
89+
90+
91+
# ---------------------------------------------------------------------------
92+
# pull
93+
# ---------------------------------------------------------------------------
94+
95+
def test_pull_skips_merge_on_empty_remote_claude_json(mock_config, connected_engine, tmp_path):
96+
empty_tmp = tmp_path / "empty.json"
97+
empty_tmp.write_text("") # zero bytes — merge should be skipped
98+
99+
with patch("claudesync.cli.load_config", return_value=mock_config), \
100+
patch("claudesync.cli.Engine", return_value=connected_engine), \
101+
patch("claudesync.cli.get_global_include_paths", return_value=[]), \
102+
patch("claudesync.cli.build_local_manifest", return_value={}), \
103+
patch("claudesync.cli.get_remote_manifest", return_value={}), \
104+
patch("claudesync.cli.update_manifest_for_remote"), \
105+
patch("claudesync.cli.merge_pulled_claude_json") as mock_merge, \
106+
patch("tempfile.NamedTemporaryFile") as mock_ntf:
107+
mock_ntf.return_value.__enter__.return_value.name = str(empty_tmp)
108+
result = runner.invoke(app, ["pull", REMOTE_NAME])
109+
110+
mock_merge.assert_not_called()
111+
112+
113+
def test_pull_merges_when_remote_claude_json_nonempty(mock_config, connected_engine, tmp_path):
114+
nonempty_tmp = tmp_path / "nonempty.json"
115+
nonempty_tmp.write_text('{"key": "value"}')
116+
117+
with patch("claudesync.cli.load_config", return_value=mock_config), \
118+
patch("claudesync.cli.Engine", return_value=connected_engine), \
119+
patch("claudesync.cli.get_global_include_paths", return_value=[]), \
120+
patch("claudesync.cli.build_local_manifest", return_value={}), \
121+
patch("claudesync.cli.get_remote_manifest", return_value={}), \
122+
patch("claudesync.cli.update_manifest_for_remote"), \
123+
patch("claudesync.cli.merge_pulled_claude_json") as mock_merge, \
124+
patch("tempfile.NamedTemporaryFile") as mock_ntf:
125+
mock_ntf.return_value.__enter__.return_value.name = str(nonempty_tmp)
126+
result = runner.invoke(app, ["pull", REMOTE_NAME])
127+
128+
mock_merge.assert_called_once()
129+
130+
131+
# ---------------------------------------------------------------------------
132+
# remote add
133+
# ---------------------------------------------------------------------------
134+
135+
def test_remote_add_rejects_missing_at_symbol(mock_config):
136+
with patch("claudesync.cli.load_config", return_value=mock_config):
137+
result = runner.invoke(app, ["remote", "add", "work", "nousernamehost"])
138+
139+
assert result.exit_code != 0
140+
assert "user@host" in result.output
141+
142+
143+
# ---------------------------------------------------------------------------
144+
# diff
145+
# ---------------------------------------------------------------------------
146+
147+
def test_diff_includes_project_files(mock_config, connected_engine, tmp_path):
148+
"""diff should discover per-project files, not just global ones."""
149+
proj = tmp_path / "MyProject"
150+
proj.mkdir()
151+
claude_md = proj / "CLAUDE.md"
152+
claude_md.write_text("# My project")
153+
154+
mock_config.projects = [str(proj)]
155+
156+
with patch("claudesync.cli.load_config", return_value=mock_config), \
157+
patch("claudesync.cli.Engine", return_value=connected_engine), \
158+
patch("claudesync.cli.get_global_include_paths", return_value=[]), \
159+
patch("claudesync.cli.get_remote_manifest", return_value={}), \
160+
patch("claudesync.cli.build_local_manifest", return_value={}) as mock_build:
161+
result = runner.invoke(app, ["diff", REMOTE_NAME])
162+
163+
# build_local_manifest should have received CLAUDE.md in the file list
164+
call_args = mock_build.call_args[0][0]
165+
assert str(claude_md) in call_args

tests/test_engine.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,63 @@ def test_empty_result():
143143
r = _empty_result()
144144
assert r.returncode == 0
145145
assert r.stdout == ""
146+
147+
148+
def test_push_with_project_paths_calls_rsync_per_project(engine, tmp_path):
149+
"""Engine should call rsync once per PROJECT_SYNC_ITEM per project."""
150+
proj = tmp_path / "MyProject"
151+
proj.mkdir()
152+
(proj / "CLAUDE.md").write_text("# project")
153+
154+
mock_result = MagicMock()
155+
mock_result.returncode = 0
156+
mock_result.stdout = ""
157+
mock_result.stderr = ""
158+
159+
with patch("claudesync.engine.subprocess.run", return_value=mock_result) as mock_run:
160+
engine.push([proj])
161+
162+
# At minimum: 1 global call + >=1 project call
163+
assert mock_run.call_count >= 2
164+
165+
166+
def test_get_remote_file_hashes_raises_on_invalid_json(engine):
167+
"""If SSH stdout is not JSON (e.g. login banner), SyncError is raised."""
168+
mock_result = MagicMock()
169+
mock_result.returncode = 0
170+
mock_result.stdout = "Welcome to server!\nLast login: ..."
171+
172+
with patch("claudesync.engine.subprocess.run", return_value=mock_result):
173+
with pytest.raises(SyncError, match="parse"):
174+
engine.get_remote_file_hashes(["/some/file"])
175+
176+
177+
def test_rsync_project_aggregates_all_failures(engine, tmp_path):
178+
"""All per-item rsync failures are aggregated, not just the first."""
179+
proj = tmp_path / "Proj"
180+
proj.mkdir()
181+
# Create all three items so all rsync calls are attempted on push
182+
(proj / ".claude").mkdir()
183+
(proj / ".claude" / "settings.json").write_text("{}")
184+
(proj / "CLAUDE.md").write_text("# x")
185+
(proj / ".mcp.json").write_text("{}")
186+
187+
fail_result = MagicMock()
188+
fail_result.returncode = 1
189+
fail_result.stdout = ""
190+
fail_result.stderr = "error"
191+
192+
with patch("claudesync.engine.subprocess.run", return_value=fail_result):
193+
combined = engine._rsync_project(proj, direction="push", dry_run=False)
194+
195+
assert combined.returncode != 0
196+
# Combined stderr should contain errors from all 3 items
197+
assert combined.stderr.count("error") >= 2
198+
199+
200+
def test_check_connection_handles_timeout(engine):
201+
"""TimeoutExpired during SSH connection check returns False."""
202+
import subprocess
203+
with patch("claudesync.engine.subprocess.run",
204+
side_effect=subprocess.TimeoutExpired(cmd="ssh", timeout=10)):
205+
assert engine.check_connection() is False

0 commit comments

Comments
 (0)