Skip to content

Commit 895a799

Browse files
committed
test: add tests for force_full parameter and fix Windows path normalization
- Add API endpoint tests for force_full query parameter - Add sync service test validating force_full bypasses watermark optimization - Fix normalize_project_path() to handle Windows absolute paths correctly - Windows paths (e.g., C:\Users\...) now returned unchanged instead of prepending slash Fixes #407 - ensures force_full parameter is properly tested Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 6c720da commit 895a799

6 files changed

Lines changed: 106 additions & 7 deletions

File tree

src/basic_memory/utils.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ def normalize_project_path(path: str) -> str:
2020
prefix from project paths to avoid leaking implementation details and to
2121
ensure paths match the actual S3 bucket structure.
2222
23+
For local paths (including Windows paths), returns the path unchanged.
24+
2325
Args:
24-
path: Project path (e.g., "/app/data/basic-memory-llc")
26+
path: Project path (e.g., "/app/data/basic-memory-llc" or "C:\\Users\\...")
2527
2628
Returns:
27-
Normalized path (e.g., "/basic-memory-llc")
29+
Normalized path (e.g., "/basic-memory-llc" or "C:\\Users\\...")
2830
2931
Examples:
3032
>>> normalize_project_path("/app/data/my-project")
@@ -33,13 +35,21 @@ def normalize_project_path(path: str) -> str:
3335
'/my-project'
3436
>>> normalize_project_path("app/data/my-project")
3537
'/my-project'
38+
>>> normalize_project_path("C:\\\\Users\\\\project")
39+
'C:\\\\Users\\\\project'
3640
"""
37-
# Handle both absolute and relative paths
41+
# Check if this is a Windows absolute path (e.g., C:\Users\...)
42+
# Windows paths have a drive letter followed by a colon
43+
if len(path) >= 2 and path[1] == ":":
44+
# Windows absolute path - return unchanged
45+
return path
46+
47+
# Handle both absolute and relative Unix paths
3848
normalized = path.lstrip("/")
3949
if normalized.startswith("app/data/"):
4050
normalized = normalized.removeprefix("app/data/")
4151

42-
# Ensure leading slash for absolute paths
52+
# Ensure leading slash for Unix absolute paths
4353
if not normalized.startswith("/"):
4454
normalized = "/" + normalized
4555

test2/foo.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

test2/foot.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

test3/test.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

tests/api/test_project_router.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,40 @@ async def test_sync_project_endpoint(test_graph, client, project_url):
466466
assert "Filesystem sync initiated" in data["message"]
467467

468468

469+
@pytest.mark.asyncio
470+
async def test_sync_project_endpoint_with_force_full(test_graph, client, project_url):
471+
"""Test the project sync endpoint with force_full parameter."""
472+
# Call the sync endpoint with force_full=true
473+
response = await client.post(f"{project_url}/project/sync?force_full=true")
474+
475+
# Verify response
476+
assert response.status_code == 200
477+
data = response.json()
478+
479+
# Check response structure
480+
assert "status" in data
481+
assert "message" in data
482+
assert data["status"] == "sync_started"
483+
assert "Filesystem sync initiated" in data["message"]
484+
485+
486+
@pytest.mark.asyncio
487+
async def test_sync_project_endpoint_with_force_full_false(test_graph, client, project_url):
488+
"""Test the project sync endpoint with force_full=false."""
489+
# Call the sync endpoint with force_full=false
490+
response = await client.post(f"{project_url}/project/sync?force_full=false")
491+
492+
# Verify response
493+
assert response.status_code == 200
494+
data = response.json()
495+
496+
# Check response structure
497+
assert "status" in data
498+
assert "message" in data
499+
assert data["status"] == "sync_started"
500+
assert "Filesystem sync initiated" in data["message"]
501+
502+
469503
@pytest.mark.asyncio
470504
async def test_sync_project_endpoint_not_found(client):
471505
"""Test the project sync endpoint with nonexistent project."""

tests/sync/test_sync_service_incremental.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,64 @@ async def test_file_count_increased_uses_incremental_scan(
147147
assert "file3.md" in report.new
148148

149149

150+
@pytest.mark.asyncio
151+
async def test_force_full_bypasses_watermark_optimization(
152+
sync_service: SyncService, project_config: ProjectConfig
153+
):
154+
"""Test that force_full=True bypasses watermark optimization and scans all files.
155+
156+
This is critical for detecting changes made by external tools like rclone bisync
157+
that don't update mtimes detectably. See issue #407.
158+
"""
159+
project_dir = project_config.home
160+
161+
# Create initial files
162+
await create_test_file(project_dir / "file1.md", "# File 1\nOriginal")
163+
await create_test_file(project_dir / "file2.md", "# File 2\nOriginal")
164+
165+
# First sync - establishes watermark
166+
report = await sync_service.sync(project_dir)
167+
assert len(report.new) == 2
168+
169+
# Verify watermark was set
170+
project = await sync_service.project_repository.find_by_id(
171+
sync_service.entity_repository.project_id
172+
)
173+
assert project.last_scan_timestamp is not None
174+
initial_timestamp = project.last_scan_timestamp
175+
176+
# Sleep to ensure time passes
177+
await sleep_past_watermark()
178+
179+
# Modify a file WITHOUT updating mtime (simulates external tool like rclone)
180+
# We do this by reading the current mtime, modifying the file, then restoring the mtime
181+
file_path = project_dir / "file1.md"
182+
original_stat = file_path.stat()
183+
await create_test_file(file_path, "# File 1\nModified by external tool")
184+
# Restore original mtime to simulate external tool behavior
185+
import os
186+
187+
os.utime(file_path, (original_stat.st_atime, original_stat.st_mtime))
188+
189+
# Normal incremental sync should NOT detect the change (mtime unchanged)
190+
report = await sync_service.sync(project_dir)
191+
assert len(report.modified) == 0, (
192+
"Incremental scan should not detect changes with unchanged mtime"
193+
)
194+
195+
# Force full scan should detect the change via checksum comparison
196+
report = await sync_service.sync(project_dir, force_full=True)
197+
assert len(report.modified) == 1, "Force full scan should detect changes via checksum"
198+
assert "file1.md" in report.modified
199+
200+
# Verify watermark was still updated after force_full
201+
project = await sync_service.project_repository.find_by_id(
202+
sync_service.entity_repository.project_id
203+
)
204+
assert project.last_scan_timestamp is not None
205+
assert project.last_scan_timestamp > initial_timestamp
206+
207+
150208
# ==============================================================================
151209
# Incremental Scan Base Cases
152210
# ==============================================================================

0 commit comments

Comments
 (0)