Skip to content

Commit 44cce98

Browse files
committed
test: add 23 tests for /repos/analyze endpoint + IndexConfig validation
Test coverage: - URL regex: standard, trailing slash, http, rejects non-github/subpaths/no-repo - AnalyzeRepoRequest: valid, whitespace strip, rejects empty/non-github - IndexConfig: valid paths, slash normalization, backslash normalization, rejects empty/traversal/nested-traversal/non-string, allows None - _fetch_directory_tree: flat repo grouping, monorepo package-level grouping, node_modules skipping, large_repo suggestion, small repo no suggestion Also manually verified on Effect-TS/effect (1,767 files, 33 packages, correct monorepo grouping at packages/* level). 23/23 pass in 2.8s.
1 parent 7a7bdd5 commit 44cce98

1 file changed

Lines changed: 208 additions & 0 deletions

File tree

backend/tests/test_analyze_repo.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Tests for POST /repos/analyze -- pre-clone directory analysis (OPE-109)."""
2+
import pytest
3+
from unittest.mock import AsyncMock, patch, MagicMock
4+
5+
# Import after conftest patches external services
6+
from routes.repos import (
7+
_fetch_directory_tree,
8+
_GITHUB_URL_RE,
9+
AnalyzeRepoRequest,
10+
IndexConfig,
11+
)
12+
13+
14+
# -- URL regex tests ----------------------------------------------------------
15+
16+
class TestGitHubUrlRegex:
17+
def test_standard_url(self):
18+
match = _GITHUB_URL_RE.match("https://github.com/Effect-TS/effect")
19+
assert match is not None
20+
assert match.group("owner") == "Effect-TS"
21+
assert match.group("repo") == "effect"
22+
23+
def test_url_with_trailing_slash(self):
24+
match = _GITHUB_URL_RE.match("https://github.com/owner/repo/")
25+
assert match is not None
26+
27+
def test_http_url(self):
28+
match = _GITHUB_URL_RE.match("http://github.com/owner/repo")
29+
assert match is not None
30+
31+
def test_rejects_non_github(self):
32+
assert _GITHUB_URL_RE.match("https://gitlab.com/owner/repo") is None
33+
34+
def test_rejects_subpath(self):
35+
assert _GITHUB_URL_RE.match("https://github.com/owner/repo/tree/main") is None
36+
37+
def test_rejects_no_repo(self):
38+
assert _GITHUB_URL_RE.match("https://github.com/justowner") is None
39+
40+
41+
# -- AnalyzeRepoRequest validation --------------------------------------------
42+
43+
class TestAnalyzeRepoRequest:
44+
def test_valid_url(self):
45+
req = AnalyzeRepoRequest(github_url="https://github.com/owner/repo")
46+
assert req.github_url == "https://github.com/owner/repo"
47+
48+
def test_strips_whitespace_and_slash(self):
49+
req = AnalyzeRepoRequest(github_url=" https://github.com/owner/repo/ ")
50+
assert req.github_url == "https://github.com/owner/repo"
51+
52+
def test_rejects_empty(self):
53+
with pytest.raises(Exception):
54+
AnalyzeRepoRequest(github_url="")
55+
56+
def test_rejects_non_github(self):
57+
with pytest.raises(Exception):
58+
AnalyzeRepoRequest(github_url="https://gitlab.com/owner/repo")
59+
60+
61+
# -- IndexConfig validation (from PR #266) ------------------------------------
62+
63+
class TestIndexConfig:
64+
def test_valid_paths(self):
65+
cfg = IndexConfig(include_paths=["packages/effect", "packages/schema"])
66+
assert cfg.include_paths == ["packages/effect", "packages/schema"]
67+
68+
def test_normalizes_slashes(self):
69+
cfg = IndexConfig(include_paths=["/packages/effect/", " src "])
70+
assert cfg.include_paths == ["packages/effect", "src"]
71+
72+
def test_normalizes_backslashes(self):
73+
cfg = IndexConfig(include_paths=["packages\\effect"])
74+
assert cfg.include_paths == ["packages/effect"]
75+
76+
def test_rejects_empty_string(self):
77+
with pytest.raises(Exception):
78+
IndexConfig(include_paths=["packages/effect", " "])
79+
80+
def test_rejects_path_traversal(self):
81+
with pytest.raises(Exception):
82+
IndexConfig(include_paths=["../etc/passwd"])
83+
84+
def test_rejects_nested_traversal(self):
85+
with pytest.raises(Exception):
86+
IndexConfig(include_paths=["packages/../../etc"])
87+
88+
def test_rejects_non_string(self):
89+
with pytest.raises(Exception):
90+
IndexConfig(include_paths=[123])
91+
92+
def test_none_is_valid(self):
93+
cfg = IndexConfig()
94+
assert cfg.include_paths is None
95+
96+
97+
# -- _fetch_directory_tree grouping logic -------------------------------------
98+
99+
# Build a fake GitHub tree response for testing grouping
100+
def _make_tree(paths: list[str]) -> dict:
101+
"""Build a mock GitHub Tree API response from file paths."""
102+
return {
103+
"truncated": False,
104+
"tree": [{"path": p, "type": "blob"} for p in paths],
105+
}
106+
107+
108+
class TestFetchDirectoryTree:
109+
"""Test directory grouping logic with mocked GitHub API."""
110+
111+
@pytest.mark.asyncio
112+
async def test_flat_repo_groups_by_top_dir(self):
113+
tree = _make_tree([
114+
"src/main.py",
115+
"src/utils.py",
116+
"tests/test_main.py",
117+
"README.md",
118+
])
119+
with patch("routes.repos.httpx.AsyncClient") as mock_client:
120+
mock_resp = MagicMock()
121+
mock_resp.status_code = 200
122+
mock_resp.json.return_value = tree
123+
mock_client.return_value.__aenter__ = AsyncMock(return_value=MagicMock(
124+
get=AsyncMock(return_value=mock_resp)
125+
))
126+
127+
result = await _fetch_directory_tree("owner", "repo", "main")
128+
129+
dir_names = [d["name"] for d in result["directories"]]
130+
assert "src" in dir_names
131+
assert result["total_files"] == 3 # README.md has no code ext
132+
133+
@pytest.mark.asyncio
134+
async def test_monorepo_groups_at_package_level(self):
135+
tree = _make_tree([
136+
"packages/core/src/index.ts",
137+
"packages/core/src/utils.ts",
138+
"packages/cli/src/main.ts",
139+
"scripts/build.ts",
140+
])
141+
with patch("routes.repos.httpx.AsyncClient") as mock_client:
142+
mock_resp = MagicMock()
143+
mock_resp.status_code = 200
144+
mock_resp.json.return_value = tree
145+
mock_client.return_value.__aenter__ = AsyncMock(return_value=MagicMock(
146+
get=AsyncMock(return_value=mock_resp)
147+
))
148+
149+
result = await _fetch_directory_tree("owner", "repo", "main")
150+
151+
dir_names = [d["name"] for d in result["directories"]]
152+
# Should group at packages/core level, not just packages
153+
assert "packages/core" in dir_names
154+
assert "packages/cli" in dir_names
155+
assert "scripts" in dir_names
156+
157+
@pytest.mark.asyncio
158+
async def test_skips_node_modules(self):
159+
tree = _make_tree([
160+
"src/index.ts",
161+
"node_modules/lodash/index.js",
162+
])
163+
with patch("routes.repos.httpx.AsyncClient") as mock_client:
164+
mock_resp = MagicMock()
165+
mock_resp.status_code = 200
166+
mock_resp.json.return_value = tree
167+
mock_client.return_value.__aenter__ = AsyncMock(return_value=MagicMock(
168+
get=AsyncMock(return_value=mock_resp)
169+
))
170+
171+
result = await _fetch_directory_tree("owner", "repo", "main")
172+
173+
assert result["total_files"] == 1
174+
175+
@pytest.mark.asyncio
176+
async def test_large_repo_suggestion(self):
177+
# 600 TypeScript files across 15 packages
178+
paths = [f"packages/pkg{i}/src/file{j}.ts" for i in range(15) for j in range(40)]
179+
tree = _make_tree(paths)
180+
181+
with patch("routes.repos.httpx.AsyncClient") as mock_client:
182+
mock_resp = MagicMock()
183+
mock_resp.status_code = 200
184+
mock_resp.json.return_value = tree
185+
mock_client.return_value.__aenter__ = AsyncMock(return_value=MagicMock(
186+
get=AsyncMock(return_value=mock_resp)
187+
))
188+
189+
result = await _fetch_directory_tree("owner", "repo", "main")
190+
191+
assert result["suggestion"] == "large_repo"
192+
assert result["total_files"] == 600
193+
194+
@pytest.mark.asyncio
195+
async def test_small_repo_no_suggestion(self):
196+
tree = _make_tree(["src/main.py", "src/utils.py"])
197+
198+
with patch("routes.repos.httpx.AsyncClient") as mock_client:
199+
mock_resp = MagicMock()
200+
mock_resp.status_code = 200
201+
mock_resp.json.return_value = tree
202+
mock_client.return_value.__aenter__ = AsyncMock(return_value=MagicMock(
203+
get=AsyncMock(return_value=mock_resp)
204+
))
205+
206+
result = await _fetch_directory_tree("owner", "repo", "main")
207+
208+
assert result["suggestion"] is None

0 commit comments

Comments
 (0)