Skip to content

Commit a09066e

Browse files
jope-bmclaudephernandez
authored
fix: Add permalink normalization to project lookups in deps.py (#348)
Signed-off-by: Joe P <joe@basicmemory.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: phernandez <paul@basicmachines.co>
1 parent be352ab commit a09066e

2 files changed

Lines changed: 213 additions & 5 deletions

File tree

src/basic_memory/deps.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from basic_memory.services.link_resolver import LinkResolver
3434
from basic_memory.services.search_service import SearchService
3535
from basic_memory.sync import SyncService
36+
from basic_memory.utils import generate_permalink
3637

3738

3839
def get_app_config() -> BasicMemoryConfig: # pragma: no cover
@@ -61,8 +62,9 @@ async def get_project_config(
6162
Raises:
6263
HTTPException: If project is not found
6364
"""
64-
65-
project_obj = await project_repository.get_by_permalink(str(project))
65+
# Convert project name to permalink for lookup
66+
project_permalink = generate_permalink(str(project))
67+
project_obj = await project_repository.get_by_permalink(project_permalink)
6668
if project_obj:
6769
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
6870

@@ -147,9 +149,9 @@ async def get_project_id(
147149
Raises:
148150
HTTPException: If project is not found
149151
"""
150-
151-
# Try by permalink first (most common case with URL paths)
152-
project_obj = await project_repository.get_by_permalink(str(project))
152+
# Convert project name to permalink for lookup
153+
project_permalink = generate_permalink(str(project))
154+
project_obj = await project_repository.get_by_permalink(project_permalink)
153155
if project_obj:
154156
return project_obj.id
155157

tests/test_deps.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""Tests for dependency injection functions in deps.py."""
2+
3+
from datetime import datetime, timezone
4+
from pathlib import Path
5+
6+
import pytest
7+
import pytest_asyncio
8+
from fastapi import HTTPException
9+
10+
from basic_memory.deps import get_project_config, get_project_id
11+
from basic_memory.models.project import Project
12+
from basic_memory.repository.project_repository import ProjectRepository
13+
14+
15+
@pytest_asyncio.fixture
16+
async def project_with_spaces(project_repository: ProjectRepository) -> Project:
17+
"""Create a project with spaces in the name for testing permalink normalization."""
18+
project_data = {
19+
"name": "My Test Project",
20+
"description": "A project with spaces in the name",
21+
"path": "/my/test/project",
22+
"is_active": True,
23+
"is_default": False,
24+
"created_at": datetime.now(timezone.utc),
25+
"updated_at": datetime.now(timezone.utc),
26+
}
27+
return await project_repository.create(project_data)
28+
29+
30+
@pytest_asyncio.fixture
31+
async def project_with_special_chars(project_repository: ProjectRepository) -> Project:
32+
"""Create a project with special characters for testing permalink normalization."""
33+
project_data = {
34+
"name": "Project: Test & Development!",
35+
"description": "A project with special characters",
36+
"path": "/project/test/dev",
37+
"is_active": True,
38+
"is_default": False,
39+
"created_at": datetime.now(timezone.utc),
40+
"updated_at": datetime.now(timezone.utc),
41+
}
42+
return await project_repository.create(project_data)
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_get_project_config_with_spaces(
47+
project_repository: ProjectRepository, project_with_spaces: Project
48+
):
49+
"""Test that get_project_config normalizes project names with spaces."""
50+
# The project name has spaces: "My Test Project"
51+
# The permalink should be: "my-test-project"
52+
assert project_with_spaces.name == "My Test Project"
53+
assert project_with_spaces.permalink == "my-test-project"
54+
55+
# Call get_project_config with the project name (not permalink)
56+
# This simulates what happens when the project name comes from URL path
57+
config = await get_project_config(
58+
project="My Test Project", project_repository=project_repository
59+
)
60+
61+
# Verify we got the correct project config
62+
assert config.name == "My Test Project"
63+
assert config.home == Path("/my/test/project")
64+
65+
66+
@pytest.mark.asyncio
67+
async def test_get_project_config_with_permalink(
68+
project_repository: ProjectRepository, project_with_spaces: Project
69+
):
70+
"""Test that get_project_config works when already given a permalink."""
71+
# Call with the permalink directly
72+
config = await get_project_config(
73+
project="my-test-project", project_repository=project_repository
74+
)
75+
76+
# Verify we got the correct project config
77+
assert config.name == "My Test Project"
78+
assert config.home == Path("/my/test/project")
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_get_project_config_with_special_chars(
83+
project_repository: ProjectRepository, project_with_special_chars: Project
84+
):
85+
"""Test that get_project_config normalizes project names with special characters."""
86+
# The project name has special chars: "Project: Test & Development!"
87+
# The permalink should be: "project-test-development"
88+
assert project_with_special_chars.name == "Project: Test & Development!"
89+
assert project_with_special_chars.permalink == "project-test-development"
90+
91+
# Call get_project_config with the project name
92+
config = await get_project_config(
93+
project="Project: Test & Development!", project_repository=project_repository
94+
)
95+
96+
# Verify we got the correct project config
97+
assert config.name == "Project: Test & Development!"
98+
assert config.home == Path("/project/test/dev")
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_get_project_config_not_found(project_repository: ProjectRepository):
103+
"""Test that get_project_config raises HTTPException when project not found."""
104+
with pytest.raises(HTTPException) as exc_info:
105+
await get_project_config(
106+
project="Nonexistent Project", project_repository=project_repository
107+
)
108+
109+
assert exc_info.value.status_code == 404
110+
assert "Project 'Nonexistent Project' not found" in exc_info.value.detail
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_get_project_id_with_spaces(
115+
project_repository: ProjectRepository, project_with_spaces: Project
116+
):
117+
"""Test that get_project_id normalizes project names with spaces."""
118+
# Call get_project_id with the project name (not permalink)
119+
project_id = await get_project_id(
120+
project_repository=project_repository, project="My Test Project"
121+
)
122+
123+
# Verify we got the correct project ID
124+
assert project_id == project_with_spaces.id
125+
126+
127+
@pytest.mark.asyncio
128+
async def test_get_project_id_with_permalink(
129+
project_repository: ProjectRepository, project_with_spaces: Project
130+
):
131+
"""Test that get_project_id works when already given a permalink."""
132+
# Call with the permalink directly
133+
project_id = await get_project_id(
134+
project_repository=project_repository, project="my-test-project"
135+
)
136+
137+
# Verify we got the correct project ID
138+
assert project_id == project_with_spaces.id
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_get_project_id_with_special_chars(
143+
project_repository: ProjectRepository, project_with_special_chars: Project
144+
):
145+
"""Test that get_project_id normalizes project names with special characters."""
146+
# Call get_project_id with the project name
147+
project_id = await get_project_id(
148+
project_repository=project_repository, project="Project: Test & Development!"
149+
)
150+
151+
# Verify we got the correct project ID
152+
assert project_id == project_with_special_chars.id
153+
154+
155+
@pytest.mark.asyncio
156+
async def test_get_project_id_not_found(project_repository: ProjectRepository):
157+
"""Test that get_project_id raises HTTPException when project not found."""
158+
with pytest.raises(HTTPException) as exc_info:
159+
await get_project_id(project_repository=project_repository, project="Nonexistent Project")
160+
161+
assert exc_info.value.status_code == 404
162+
assert "Project 'Nonexistent Project' not found" in exc_info.value.detail
163+
164+
165+
@pytest.mark.asyncio
166+
async def test_get_project_id_fallback_to_name(
167+
project_repository: ProjectRepository, test_project: Project
168+
):
169+
"""Test that get_project_id falls back to name lookup if permalink lookup fails.
170+
171+
This test verifies the fallback behavior in get_project_id where it tries
172+
get_by_name if get_by_permalink returns None.
173+
"""
174+
# The test_project fixture has name "test-project" and permalink "test-project"
175+
# Since both are the same, we can't easily test the fallback with existing fixtures
176+
# So this test just verifies the normal path works with test_project
177+
project_id = await get_project_id(project_repository=project_repository, project="test-project")
178+
179+
assert project_id == test_project.id
180+
181+
182+
@pytest.mark.asyncio
183+
async def test_get_project_config_case_sensitivity(
184+
project_repository: ProjectRepository, project_with_spaces: Project
185+
):
186+
"""Test that get_project_config handles case variations correctly.
187+
188+
Permalink normalization should convert to lowercase, so different case
189+
variations of the same name should resolve to the same project.
190+
"""
191+
# Create project with mixed case: "My Test Project" -> permalink "my-test-project"
192+
193+
# Try with different case variations
194+
config1 = await get_project_config(
195+
project="My Test Project", project_repository=project_repository
196+
)
197+
config2 = await get_project_config(
198+
project="my test project", project_repository=project_repository
199+
)
200+
config3 = await get_project_config(
201+
project="MY TEST PROJECT", project_repository=project_repository
202+
)
203+
204+
# All should resolve to the same project
205+
assert config1.name == config2.name == config3.name == "My Test Project"
206+
assert config1.home == config2.home == config3.home == Path("/my/test/project")

0 commit comments

Comments
 (0)