Skip to content

Commit 78075bd

Browse files
committed
tests/cli/add(feat): Add comprehensive tests for add command
why: Ensure add command functionality is properly tested what: - Test simple repository addition - Test custom base directory handling - Test duplicate detection - Test adding to existing config
1 parent 4dfa67a commit 78075bd

1 file changed

Lines changed: 310 additions & 0 deletions

File tree

tests/cli/test_add.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
"""Tests for vcspull add command functionality."""
2+
3+
from __future__ import annotations
4+
5+
import contextlib
6+
import logging
7+
import typing as t
8+
9+
import pytest
10+
import yaml
11+
12+
from vcspull.cli import cli
13+
from vcspull.cli.add import add_repo
14+
15+
if t.TYPE_CHECKING:
16+
import pathlib
17+
18+
from typing_extensions import TypeAlias
19+
20+
ExpectedOutput: TypeAlias = t.Optional[t.Union[str, list[str]]]
21+
22+
23+
@pytest.fixture(autouse=True)
24+
def reset_logging() -> t.Generator[None, None, None]:
25+
"""Reset logging configuration between tests."""
26+
# Store original handlers
27+
logger = logging.getLogger("vcspull.cli.add")
28+
original_handlers = logger.handlers[:]
29+
original_level = logger.level
30+
31+
yield
32+
33+
# Reset after test
34+
logger.handlers = original_handlers
35+
logger.setLevel(original_level)
36+
37+
38+
class AddRepoFixture(t.NamedTuple):
39+
"""Pytest fixture for vcspull add command."""
40+
41+
# pytest internal: used for naming test
42+
test_id: str
43+
44+
# test parameters
45+
cli_args: list[str]
46+
initial_config: dict[str, t.Any] | None
47+
expected_config_contains: dict[str, t.Any]
48+
expected_in_output: ExpectedOutput = None
49+
expected_not_in_output: ExpectedOutput = None
50+
expected_log_level: str = "INFO"
51+
should_create_config: bool = False
52+
53+
54+
ADD_REPO_FIXTURES: list[AddRepoFixture] = [
55+
# Simple repo addition with default base dir
56+
AddRepoFixture(
57+
test_id="simple-repo-default-dir",
58+
cli_args=["add", "myproject", "git@github.com:user/myproject.git"],
59+
initial_config=None,
60+
should_create_config=True,
61+
expected_config_contains={
62+
"./": {
63+
"myproject": {"repo": "git@github.com:user/myproject.git"},
64+
},
65+
},
66+
expected_in_output="Successfully added 'myproject'",
67+
),
68+
# Add with custom base directory
69+
AddRepoFixture(
70+
test_id="custom-base-dir",
71+
cli_args=[
72+
"add",
73+
"mylib",
74+
"https://github.com/org/mylib",
75+
"--dir",
76+
"~/projects/libs",
77+
],
78+
initial_config=None,
79+
should_create_config=True,
80+
expected_config_contains={
81+
"~/projects/libs/": {
82+
"mylib": {"repo": "https://github.com/org/mylib"},
83+
},
84+
},
85+
expected_in_output="Successfully added 'mylib'",
86+
),
87+
# Add to existing config
88+
AddRepoFixture(
89+
test_id="add-to-existing",
90+
cli_args=["add", "project2", "git@github.com:user/project2.git", "--dir", "~/work"],
91+
initial_config={
92+
"~/work/": {
93+
"project1": {"repo": "git@github.com:user/project1.git"},
94+
},
95+
},
96+
expected_config_contains={
97+
"~/work/": {
98+
"project1": {"repo": "git@github.com:user/project1.git"},
99+
"project2": {"repo": "git@github.com:user/project2.git"},
100+
},
101+
},
102+
expected_in_output="Successfully added 'project2'",
103+
),
104+
# Duplicate repo detection
105+
AddRepoFixture(
106+
test_id="duplicate-repo",
107+
cli_args=[
108+
"add",
109+
"existing",
110+
"git@github.com:other/existing.git",
111+
"--dir",
112+
"~/code",
113+
],
114+
initial_config={
115+
"~/code/": {
116+
"existing": {"repo": "git@github.com:user/existing.git"},
117+
},
118+
},
119+
expected_config_contains={
120+
"~/code/": {
121+
"existing": {"repo": "git@github.com:user/existing.git"},
122+
},
123+
},
124+
expected_in_output=[
125+
"Repository 'existing' already exists",
126+
"Current URL: git@github.com:user/existing.git",
127+
],
128+
expected_log_level="WARNING",
129+
),
130+
# Path inference
131+
AddRepoFixture(
132+
test_id="path-inference",
133+
cli_args=[
134+
"add",
135+
"inferred",
136+
"git@github.com:user/inferred.git",
137+
"--path",
138+
"~/dev/projects/inferred",
139+
],
140+
initial_config=None,
141+
should_create_config=True,
142+
expected_config_contains={
143+
"~/dev/projects/inferred/": {
144+
"inferred": {"repo": "git@github.com:user/inferred.git"},
145+
},
146+
},
147+
expected_in_output="Successfully added 'inferred'",
148+
),
149+
]
150+
151+
152+
@pytest.mark.parametrize(
153+
list(AddRepoFixture._fields),
154+
ADD_REPO_FIXTURES,
155+
ids=[test.test_id for test in ADD_REPO_FIXTURES],
156+
)
157+
def test_add_repo_cli(
158+
tmp_path: pathlib.Path,
159+
capsys: pytest.CaptureFixture[str],
160+
caplog: pytest.LogCaptureFixture,
161+
monkeypatch: pytest.MonkeyPatch,
162+
test_id: str,
163+
cli_args: list[str],
164+
initial_config: dict[str, t.Any] | None,
165+
expected_config_contains: dict[str, t.Any],
166+
expected_in_output: ExpectedOutput,
167+
expected_not_in_output: ExpectedOutput,
168+
expected_log_level: str,
169+
should_create_config: bool,
170+
) -> None:
171+
"""Test vcspull add command through CLI."""
172+
caplog.set_level(expected_log_level)
173+
174+
# Set up config file path
175+
config_file = tmp_path / ".vcspull.yaml"
176+
177+
# Create initial config if provided
178+
if initial_config:
179+
yaml_content = yaml.dump(initial_config, default_flow_style=False)
180+
config_file.write_text(yaml_content, encoding="utf-8")
181+
182+
# Add config path to CLI args if not specified
183+
if "-c" not in cli_args and "--config" not in cli_args:
184+
cli_args = cli_args[:1] + ["-c", str(config_file)] + cli_args[1:]
185+
186+
# Change to tmp directory
187+
monkeypatch.chdir(tmp_path)
188+
189+
# Run CLI command
190+
with contextlib.suppress(SystemExit):
191+
cli(cli_args)
192+
193+
# Capture output
194+
captured = capsys.readouterr()
195+
output = "".join([*caplog.messages, captured.out, captured.err])
196+
197+
# Check expected output (strip ANSI codes for comparison)
198+
import re
199+
clean_output = re.sub(r'\x1b\[[0-9;]*m', '', output) # Strip ANSI codes
200+
201+
if expected_in_output is not None:
202+
if isinstance(expected_in_output, str):
203+
expected_in_output = [expected_in_output]
204+
for needle in expected_in_output:
205+
assert needle in clean_output, f"Expected '{needle}' in output, got: {clean_output}"
206+
207+
if expected_not_in_output is not None:
208+
if isinstance(expected_not_in_output, str):
209+
expected_not_in_output = [expected_not_in_output]
210+
for needle in expected_not_in_output:
211+
assert needle not in clean_output, f"Unexpected '{needle}' in output"
212+
213+
# Verify config file
214+
if should_create_config or initial_config:
215+
assert config_file.exists(), "Config file should exist"
216+
217+
# Load and verify config
218+
with config_file.open() as f:
219+
config_data = yaml.safe_load(f)
220+
221+
# Check expected config contents
222+
for key, value in expected_config_contains.items():
223+
assert key in config_data, f"Expected key '{key}' in config"
224+
if isinstance(value, dict):
225+
for subkey, subvalue in value.items():
226+
assert subkey in config_data[key], f"Expected '{subkey}' in config['{key}']"
227+
assert config_data[key][subkey] == subvalue, (
228+
f"Config mismatch for {key}/{subkey}: "
229+
f"expected {subvalue}, got {config_data[key][subkey]}"
230+
)
231+
232+
233+
class TestAddRepoUnit:
234+
"""Unit tests for add_repo function."""
235+
236+
def test_add_repo_direct_call(
237+
self,
238+
tmp_path: pathlib.Path,
239+
caplog: pytest.LogCaptureFixture,
240+
) -> None:
241+
"""Test direct add_repo function call."""
242+
caplog.set_level("INFO")
243+
config_file = tmp_path / ".vcspull.yaml"
244+
245+
# Call add_repo directly
246+
add_repo(
247+
name="direct-test",
248+
url="git@github.com:user/direct.git",
249+
config_file_path_str=str(config_file),
250+
path=None,
251+
base_dir=None,
252+
)
253+
254+
# Verify
255+
assert config_file.exists()
256+
with config_file.open() as f:
257+
config_data = yaml.safe_load(f)
258+
259+
assert "./" in config_data
260+
assert "direct-test" in config_data["./"]
261+
assert config_data["./"]["direct-test"] == {
262+
"repo": "git@github.com:user/direct.git",
263+
}
264+
265+
def test_add_repo_invalid_config(
266+
self,
267+
tmp_path: pathlib.Path,
268+
capsys: pytest.CaptureFixture[str],
269+
monkeypatch: pytest.MonkeyPatch,
270+
) -> None:
271+
"""Test handling of invalid config file."""
272+
config_file = tmp_path / ".vcspull.yaml"
273+
274+
# Write invalid YAML
275+
config_file.write_text("invalid: yaml: content:", encoding="utf-8")
276+
277+
# Change to tmp directory
278+
monkeypatch.chdir(tmp_path)
279+
280+
# Try to add repo
281+
add_repo(
282+
name="test",
283+
url="git@github.com:user/test.git",
284+
config_file_path_str=str(config_file),
285+
path=None,
286+
base_dir=None,
287+
)
288+
289+
# Should log error to stderr
290+
captured = capsys.readouterr()
291+
assert "Error loading YAML" in captured.err
292+
293+
294+
def test_add_command_help(
295+
capsys: pytest.CaptureFixture[str],
296+
) -> None:
297+
"""Test add command help output."""
298+
with contextlib.suppress(SystemExit):
299+
cli(["add", "--help"])
300+
301+
captured = capsys.readouterr()
302+
output = captured.out + captured.err
303+
304+
# Check help content
305+
assert "Add a repository to the vcspull configuration file" in output
306+
assert "name" in output
307+
assert "url" in output
308+
assert "--path" in output
309+
assert "--dir" in output
310+
assert "--config" in output

0 commit comments

Comments
 (0)