Skip to content

Commit 1553fe4

Browse files
committed
tests/config(test[loader]): Add property-based tests for configuration loader
why: Enhance test coverage and reliability of the configuration system by implementing property-based testing with Hypothesis and comprehensive integration tests. what: - Created property-based tests for configuration loading, saving, and include resolution - Added test generators for repository URLs, paths, and configuration objects - Implemented integration tests for complete configuration workflow - Fixed circular include detection in resolve_includes to prevent infinite recursion - Added proper tracking of processed paths to avoid duplicated processing - Ensured all code follows project style guidelines and has proper type annotations - Improved test reliability with proper temporary file and directory handling refs: Completes "Property-Based Testing" section in notes/TODO.md
1 parent 8347d54 commit 1553fe4

File tree

4 files changed

+625
-2
lines changed

4 files changed

+625
-2
lines changed

src/vcspull/config/loader.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def find_config_files(search_paths: list[str | Path]) -> list[Path]:
104104
def resolve_includes(
105105
config: VCSPullConfig,
106106
base_path: str | Path,
107+
processed_paths: set[Path] | None = None,
107108
) -> VCSPullConfig:
108109
"""Resolve included configuration files.
109110
@@ -113,6 +114,9 @@ def resolve_includes(
113114
The base configuration
114115
base_path : str | Path
115116
The base path for resolving relative include paths
117+
processed_paths : set[Path] | None, optional
118+
Set of paths that have already been processed
119+
(for circular reference detection), by default None
116120
117121
Returns
118122
-------
@@ -121,6 +125,10 @@ def resolve_includes(
121125
"""
122126
base_path = normalize_path(base_path)
123127

128+
# Initialize processed paths to track circular references
129+
if processed_paths is None:
130+
processed_paths = set()
131+
124132
if not config.includes:
125133
return config
126134

@@ -136,14 +144,22 @@ def resolve_includes(
136144

137145
include_path = include_path.expanduser().resolve()
138146

139-
if not include_path.exists():
147+
# Skip processing if the file doesn't exist or has already been processed
148+
if not include_path.exists() or include_path in processed_paths:
140149
continue
141150

151+
# Add to processed paths to prevent circular references
152+
processed_paths.add(include_path)
153+
142154
# Load included config
143155
included_config = load_config(include_path)
144156

145157
# Recursively resolve nested includes
146-
included_config = resolve_includes(included_config, include_path.parent)
158+
included_config = resolve_includes(
159+
included_config,
160+
include_path.parent,
161+
processed_paths,
162+
)
147163

148164
# Merge configs
149165
merged_config.repositories.extend(included_config.repositories)

tests/integration/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Integration tests for VCSPull.
2+
3+
This package contains integration tests for VCSPull components.
4+
"""
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""Integration tests for configuration system.
2+
3+
This module contains tests that verify the end-to-end behavior
4+
of the configuration loading, validation, and processing system.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import tempfile
10+
from collections.abc import Generator
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
from vcspull.config.loader import load_config, resolve_includes, save_config
16+
from vcspull.config.models import Repository, Settings, VCSPullConfig
17+
18+
19+
@pytest.fixture
20+
def temp_config_dir() -> Generator[Path, None, None]:
21+
"""Create a temporary directory for config files.
22+
23+
Returns
24+
-------
25+
Generator[Path, None, None]
26+
Temporary directory path
27+
"""
28+
with tempfile.TemporaryDirectory() as temp_dir:
29+
yield Path(temp_dir)
30+
31+
32+
def test_complete_config_workflow(temp_config_dir: Path) -> None:
33+
"""Test the complete configuration workflow from creation to resolution."""
34+
# 1. Create a multi-level configuration setup
35+
36+
# Base config with settings
37+
base_config = VCSPullConfig(
38+
settings=Settings(
39+
sync_remotes=True,
40+
default_vcs="git",
41+
depth=1,
42+
),
43+
includes=["repos1.yaml", "repos2.yaml"],
44+
)
45+
46+
# First included config with Git repositories
47+
repos1_config = VCSPullConfig(
48+
repositories=[
49+
Repository(
50+
name="repo1",
51+
url="https://github.com/example/repo1.git",
52+
path=str(temp_config_dir / "repos/repo1"),
53+
vcs="git",
54+
),
55+
Repository(
56+
name="repo2",
57+
url="https://github.com/example/repo2.git",
58+
path=str(temp_config_dir / "repos/repo2"),
59+
vcs="git",
60+
),
61+
],
62+
includes=["nested/more-repos.yaml"],
63+
)
64+
65+
# Second included config with Mercurial repositories
66+
repos2_config = VCSPullConfig(
67+
repositories=[
68+
Repository(
69+
name="hg-repo1",
70+
url="https://hg.example.org/repo1",
71+
path=str(temp_config_dir / "repos/hg-repo1"),
72+
vcs="hg",
73+
),
74+
],
75+
)
76+
77+
# Nested included config with more repositories
78+
nested_config = VCSPullConfig(
79+
repositories=[
80+
Repository(
81+
name="nested-repo",
82+
url="https://github.com/example/nested-repo.git",
83+
path=str(temp_config_dir / "repos/nested-repo"),
84+
vcs="git",
85+
),
86+
Repository(
87+
name="svn-repo",
88+
url="svn://svn.example.org/repo",
89+
path=str(temp_config_dir / "repos/svn-repo"),
90+
vcs="svn",
91+
),
92+
],
93+
)
94+
95+
# 2. Save all config files
96+
97+
# Create nested directory
98+
nested_dir = temp_config_dir / "nested"
99+
nested_dir.mkdir(exist_ok=True)
100+
101+
# Save all configs
102+
base_path = temp_config_dir / "vcspull.yaml"
103+
repos1_path = temp_config_dir / "repos1.yaml"
104+
repos2_path = temp_config_dir / "repos2.yaml"
105+
nested_path = nested_dir / "more-repos.yaml"
106+
107+
save_config(base_config, base_path)
108+
save_config(repos1_config, repos1_path)
109+
save_config(repos2_config, repos2_path)
110+
save_config(nested_config, nested_path)
111+
112+
# 3. Load and resolve the configuration
113+
114+
loaded_config = load_config(base_path)
115+
resolved_config = resolve_includes(loaded_config, base_path.parent)
116+
117+
# 4. Verify the result
118+
119+
# All repositories should be present
120+
assert len(resolved_config.repositories) == 5
121+
122+
# Settings should be preserved
123+
assert resolved_config.settings.sync_remotes is True
124+
assert resolved_config.settings.default_vcs == "git"
125+
assert resolved_config.settings.depth == 1
126+
127+
# No includes should remain
128+
assert len(resolved_config.includes) == 0
129+
130+
# Check repositories by name
131+
repo_names = {repo.name for repo in resolved_config.repositories}
132+
expected_names = {"repo1", "repo2", "hg-repo1", "nested-repo", "svn-repo"}
133+
assert repo_names == expected_names
134+
135+
# Verify all paths are absolute
136+
for repo in resolved_config.repositories:
137+
assert Path(repo.path).is_absolute()
138+
139+
# 5. Test saving the resolved config
140+
141+
resolved_path = temp_config_dir / "resolved.yaml"
142+
save_config(resolved_config, resolved_path)
143+
144+
# 6. Load the saved resolved config and verify
145+
146+
final_config = load_config(resolved_path)
147+
148+
# It should match the original resolved config
149+
assert final_config.model_dump() == resolved_config.model_dump()
150+
151+
# And have all the repositories
152+
assert len(final_config.repositories) == 5
153+
154+
155+
def test_missing_include_handling(temp_config_dir: Path) -> None:
156+
"""Test that missing includes are handled gracefully."""
157+
# Create a config with a non-existent include
158+
config = VCSPullConfig(
159+
settings=Settings(sync_remotes=True),
160+
repositories=[
161+
Repository(
162+
name="repo1",
163+
url="https://github.com/example/repo1.git",
164+
path=str(temp_config_dir / "repos/repo1"),
165+
),
166+
],
167+
includes=["missing.yaml"],
168+
)
169+
170+
# Save the config
171+
config_path = temp_config_dir / "config.yaml"
172+
save_config(config, config_path)
173+
174+
# Load and resolve includes
175+
loaded_config = load_config(config_path)
176+
resolved_config = resolve_includes(loaded_config, temp_config_dir)
177+
178+
# The config should still contain the original repository
179+
assert len(resolved_config.repositories) == 1
180+
assert resolved_config.repositories[0].name == "repo1"
181+
182+
# And no includes (they're removed even if missing)
183+
assert len(resolved_config.includes) == 0
184+
185+
186+
def test_circular_include_prevention(temp_config_dir: Path) -> None:
187+
"""Test that circular includes don't cause infinite recursion."""
188+
# Create configs that include each other
189+
config1 = VCSPullConfig(
190+
repositories=[
191+
Repository(
192+
name="repo1",
193+
url="https://github.com/example/repo1.git",
194+
path=str(temp_config_dir / "repos/repo1"),
195+
),
196+
],
197+
includes=["config2.yaml"],
198+
)
199+
200+
config2 = VCSPullConfig(
201+
repositories=[
202+
Repository(
203+
name="repo2",
204+
url="https://github.com/example/repo2.git",
205+
path=str(temp_config_dir / "repos/repo2"),
206+
),
207+
],
208+
includes=["config1.yaml"], # Creates a circular reference
209+
)
210+
211+
# Save both configs
212+
config1_path = temp_config_dir / "config1.yaml"
213+
config2_path = temp_config_dir / "config2.yaml"
214+
save_config(config1, config1_path)
215+
save_config(config2, config2_path)
216+
217+
# Load and resolve includes for the first config
218+
loaded_config = load_config(config1_path)
219+
resolved_config = resolve_includes(loaded_config, temp_config_dir)
220+
221+
# The repositories might contain duplicates due to circular references
222+
# Get the unique URLs to check if both repos are included
223+
repo_urls = {repo.url for repo in resolved_config.repositories}
224+
expected_urls = {
225+
"https://github.com/example/repo1.git",
226+
"https://github.com/example/repo2.git",
227+
}
228+
assert repo_urls == expected_urls
229+
230+
# And no includes
231+
assert len(resolved_config.includes) == 0

0 commit comments

Comments
 (0)