mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63a8ccc5f9 | |||
| bb364dcf04 | |||
| cdc28eeefa |
@@ -0,0 +1,100 @@
|
||||
# Issue #487 Remaining Work
|
||||
|
||||
This document outlines the remaining work for Issue #487 after the quick wins have been addressed.
|
||||
|
||||
## ✅ Completed (Quick Wins)
|
||||
|
||||
1. **Fixed mutable default args** in `search_notes()` MCP tool
|
||||
2. **Fixed Pydantic defaults** in `WatchServiceState` classes
|
||||
3. **Fixed list mutation during iteration** in `WatchService.handle_changes()`
|
||||
4. **Added regression tests** for all three bugs
|
||||
|
||||
## 🔄 Remaining Work
|
||||
|
||||
### 1. Coverage Baseline and Restoration
|
||||
|
||||
**Goal**: Restore test coverage to ~100% (practical: minimal, justified excludes)
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Run `just coverage` to establish current baseline
|
||||
- [ ] Identify files/functions with missing coverage
|
||||
- [ ] Add tests for uncovered code paths
|
||||
- [ ] Document any justified coverage excludes (e.g., CLI entry points, debug code)
|
||||
|
||||
**Priority**: High - This is the main goal of the issue
|
||||
|
||||
### 2. Reduce Mock Usage
|
||||
|
||||
**Goal**: Replace mocks with integration tests where practical
|
||||
|
||||
**Tasks**:
|
||||
- [ ] **High-value targets** (most mocked files):
|
||||
- `tests/test_rclone_commands.py` (56 mocks) - Consider using real rclone or temp dirs
|
||||
- `tests/sync/test_sync_service.py` (11 mocks) - Many can use real filesystem
|
||||
- `tests/services/test_search_service.py` (8 mocks) - Already mostly integration-style
|
||||
|
||||
- [ ] **Cloud/external service mocks**:
|
||||
- `tests/cli/test_cloud_authentication.py` - Consider mock OAuth server or test fixtures
|
||||
- `tests/cli/test_upload.py` - Consider using MinIO (local S3-compatible storage)
|
||||
- `tests/test_telemetry.py` - Mock external telemetry endpoint is acceptable
|
||||
|
||||
- [ ] **Move appropriate tests to `test-int/`**:
|
||||
- Tests that use real DB + real filesystem + real in-process API client belong in `test-int/`
|
||||
- Keep unit tests in `tests/` for isolated component testing
|
||||
|
||||
**Priority**: Medium - Important for test reliability but not critical
|
||||
|
||||
### 3. Architecture/Quality Follow-ups
|
||||
|
||||
**Tasks**:
|
||||
- [ ] **Cloud-mode DB initialization**: Ensure cloud mode runs required DB init/migrations reliably
|
||||
- Even if project reconciliation is skipped
|
||||
- Add integration test for cloud mode startup
|
||||
|
||||
- [ ] **Telemetry credential optics**: Review client secret in repo
|
||||
- Currently marked as "write-only" but still in version control
|
||||
- Consider environment variable or config file approach
|
||||
- Document security model if keeping in repo
|
||||
|
||||
**Priority**: Medium - Quality improvements, not bugs
|
||||
|
||||
### 4. Documentation
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Document coverage expectations in CLAUDE.md or CONTRIBUTING.md
|
||||
- [ ] Document when to use mocks vs integration tests
|
||||
- [ ] Add examples of good integration tests to test guidelines
|
||||
|
||||
**Priority**: Low - Nice to have
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (`tests/`)
|
||||
- **Use when**: Testing isolated components with clear boundaries
|
||||
- **Mock**: External services, network calls, filesystem (when appropriate)
|
||||
- **Examples**: Schema validation, utility functions, parsers
|
||||
|
||||
### Integration Tests (`test-int/`)
|
||||
- **Use when**: Testing user flows, API endpoints, sync operations
|
||||
- **Real components**: Database (SQLite/Postgres), filesystem (tmp_path), in-process API client
|
||||
- **Examples**: MCP tool workflows, sync operations, search functionality
|
||||
|
||||
### Coverage Goals
|
||||
- **Target**: ~100% practical coverage
|
||||
- **Acceptable excludes**: CLI entry points, debug code, unreachable error paths
|
||||
- **Document**: All coverage excludes with justification
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Run coverage baseline**: `just coverage` (requires approval)
|
||||
2. **Triage uncovered code**: Identify what needs tests vs what can be excluded
|
||||
3. **Prioritize high-value tests**: Focus on user-facing functionality first
|
||||
4. **Reduce strategic mocks**: Start with `test_rclone_commands.py`
|
||||
5. **Document patterns**: Add testing guidelines to help future contributions
|
||||
|
||||
## References
|
||||
|
||||
- Issue: #487
|
||||
- Quick wins commits: [cdc28ee](https://github.com/basicmachines-co/basic-memory/commit/cdc28ee), [bb364dc](https://github.com/basicmachines-co/basic-memory/commit/bb364dc)
|
||||
- Test structure: `tests/` (unit), `test-int/` (integration)
|
||||
- Coverage tool: `just coverage` → `coverage/index.html`
|
||||
@@ -206,8 +206,8 @@ async def search_notes(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: str = "text",
|
||||
types: List[str] = [],
|
||||
entity_types: List[str] = [],
|
||||
types: Optional[List[str]] = None,
|
||||
entity_types: Optional[List[str]] = None,
|
||||
after_date: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> SearchResponse | str:
|
||||
@@ -348,9 +348,9 @@ async def search_notes(
|
||||
search_query.text = query # Default to text search
|
||||
|
||||
# Add optional filters if provided (empty lists are treated as no filter)
|
||||
if entity_types:
|
||||
if entity_types is not None and entity_types:
|
||||
search_query.entity_types = [SearchItemType(t) for t in entity_types]
|
||||
if types:
|
||||
if types is not None and types:
|
||||
search_query.types = types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
|
||||
@@ -120,8 +120,8 @@ class WatchEvent(BaseModel):
|
||||
class WatchServiceState(BaseModel):
|
||||
# Service status
|
||||
running: bool = False
|
||||
start_time: datetime = datetime.now() # Use directly with Pydantic model
|
||||
pid: int = os.getpid() # Use directly with Pydantic model
|
||||
start_time: Optional[datetime] = None
|
||||
pid: Optional[int] = None
|
||||
|
||||
# Stats
|
||||
error_count: int = 0
|
||||
@@ -132,7 +132,14 @@ class WatchServiceState(BaseModel):
|
||||
synced_files: int = 0
|
||||
|
||||
# Recent activity
|
||||
recent_events: List[WatchEvent] = [] # Use directly with Pydantic model
|
||||
recent_events: List[WatchEvent] = []
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
"""Initialize dynamic defaults after model creation."""
|
||||
if self.start_time is None:
|
||||
self.start_time = datetime.now()
|
||||
if self.pid is None:
|
||||
self.pid = os.getpid()
|
||||
|
||||
def add_event(
|
||||
self,
|
||||
|
||||
@@ -34,8 +34,8 @@ class WatchEvent(BaseModel):
|
||||
class WatchServiceState(BaseModel):
|
||||
# Service status
|
||||
running: bool = False
|
||||
start_time: datetime = datetime.now() # Use directly with Pydantic model
|
||||
pid: int = os.getpid() # Use directly with Pydantic model
|
||||
start_time: Optional[datetime] = None
|
||||
pid: Optional[int] = None
|
||||
|
||||
# Stats
|
||||
error_count: int = 0
|
||||
@@ -46,7 +46,14 @@ class WatchServiceState(BaseModel):
|
||||
synced_files: int = 0
|
||||
|
||||
# Recent activity
|
||||
recent_events: List[WatchEvent] = [] # Use directly with Pydantic model
|
||||
recent_events: List[WatchEvent] = []
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
"""Initialize dynamic defaults after model creation."""
|
||||
if self.start_time is None:
|
||||
self.start_time = datetime.now()
|
||||
if self.pid is None:
|
||||
self.pid = os.getpid()
|
||||
|
||||
def add_event(
|
||||
self,
|
||||
@@ -299,7 +306,8 @@ class WatchService:
|
||||
)
|
||||
|
||||
# because of our atomic writes on updates, an add may be an existing file
|
||||
for added_path in adds: # pragma: no cover TODO add test
|
||||
# Iterate over a copy to avoid mutation during iteration
|
||||
for added_path in list(adds): # pragma: no cover TODO add test
|
||||
entity = await sync_service.entity_repository.get_by_file_path(added_path)
|
||||
if entity is not None:
|
||||
logger.debug(f"Existing file will be processed as modified, path={added_path}")
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Regression tests for Issue #487 - Correctness footguns.
|
||||
|
||||
This module contains tests to ensure the three correctness bugs identified in Issue #487
|
||||
are fixed and don't regress:
|
||||
|
||||
1. Mutable default args in MCP tools (search_notes)
|
||||
2. Pydantic defaults evaluated at import time (WatchServiceState)
|
||||
3. List mutation during iteration (WatchService.handle_changes)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.sync.watch_service import WatchServiceState
|
||||
from basic_memory.schemas.project_info import WatchServiceState as ProjectWatchServiceState
|
||||
|
||||
|
||||
class TestMutableDefaultArgs:
|
||||
"""Test that mutable default arguments don't cause shared state."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_types_not_shared(self, client, test_project):
|
||||
"""Verify that types parameter doesn't share state across calls."""
|
||||
# This test would fail if types=[] was used as default
|
||||
# because the list would be shared across all calls
|
||||
|
||||
# First call with no types parameter (should use None, not [])
|
||||
# We're testing that internal state doesn't get modified
|
||||
result1 = await search_notes.fn(
|
||||
project=test_project.name,
|
||||
query="test"
|
||||
)
|
||||
|
||||
# Second call with types parameter
|
||||
result2 = await search_notes.fn(
|
||||
project=test_project.name,
|
||||
query="test",
|
||||
types=["note"]
|
||||
)
|
||||
|
||||
# Third call with no types parameter again
|
||||
result3 = await search_notes.fn(
|
||||
project=test_project.name,
|
||||
query="test"
|
||||
)
|
||||
|
||||
# All three calls should complete without sharing state
|
||||
# The bug would cause the third call to use types=["note"] from the second call
|
||||
assert result1 is not None
|
||||
assert result2 is not None
|
||||
assert result3 is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_entity_types_not_shared(self, client, test_project):
|
||||
"""Verify that entity_types parameter doesn't share state across calls."""
|
||||
# First call with no entity_types parameter
|
||||
result1 = await search_notes.fn(
|
||||
project=test_project.name,
|
||||
query="test"
|
||||
)
|
||||
|
||||
# Second call with entity_types parameter
|
||||
result2 = await search_notes.fn(
|
||||
project=test_project.name,
|
||||
query="test",
|
||||
entity_types=["entity"]
|
||||
)
|
||||
|
||||
# Third call with no entity_types parameter
|
||||
result3 = await search_notes.fn(
|
||||
project=test_project.name,
|
||||
query="test"
|
||||
)
|
||||
|
||||
# All three calls should complete without sharing state
|
||||
assert result1 is not None
|
||||
assert result2 is not None
|
||||
assert result3 is not None
|
||||
|
||||
|
||||
class TestPydanticDynamicDefaults:
|
||||
"""Test that Pydantic models don't evaluate defaults at import time."""
|
||||
|
||||
def test_watch_service_state_start_time_unique(self):
|
||||
"""Verify that each WatchServiceState instance gets a unique start_time."""
|
||||
# Create first instance
|
||||
state1 = WatchServiceState()
|
||||
|
||||
# Small delay to ensure different timestamps
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
# Create second instance
|
||||
state2 = WatchServiceState()
|
||||
|
||||
# Each instance should have its own start_time (not shared from class definition)
|
||||
assert state1.start_time is not None
|
||||
assert state2.start_time is not None
|
||||
assert state1.start_time != state2.start_time, \
|
||||
"start_time should be unique per instance, not shared from class definition"
|
||||
|
||||
def test_watch_service_state_pid_set(self):
|
||||
"""Verify that WatchServiceState sets pid correctly."""
|
||||
import os
|
||||
|
||||
state = WatchServiceState()
|
||||
|
||||
# PID should be set to current process ID
|
||||
assert state.pid is not None
|
||||
assert state.pid == os.getpid()
|
||||
|
||||
def test_watch_service_state_recent_events_not_shared(self):
|
||||
"""Verify that recent_events list is not shared between instances."""
|
||||
state1 = WatchServiceState()
|
||||
state2 = WatchServiceState()
|
||||
|
||||
# Add event to first instance
|
||||
state1.add_event(
|
||||
path="test.md",
|
||||
action="new",
|
||||
status="success",
|
||||
checksum="abc123"
|
||||
)
|
||||
|
||||
# Second instance should have empty events (not shared with first)
|
||||
assert len(state1.recent_events) == 1
|
||||
assert len(state2.recent_events) == 0, \
|
||||
"recent_events should not be shared between instances"
|
||||
|
||||
def test_project_watch_service_state_defaults(self):
|
||||
"""Verify that schemas.project_info.WatchServiceState also has correct defaults."""
|
||||
state1 = ProjectWatchServiceState()
|
||||
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
state2 = ProjectWatchServiceState()
|
||||
|
||||
# Each instance should have unique timestamps
|
||||
assert state1.start_time is not None
|
||||
assert state2.start_time is not None
|
||||
assert state1.start_time != state2.start_time
|
||||
|
||||
|
||||
class TestListMutationDuringIteration:
|
||||
"""Test that lists aren't mutated while being iterated."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_changes_adds_mutation_safety(self, tmp_path, test_project):
|
||||
"""Verify that the adds list is iterated safely without skipping items.
|
||||
|
||||
This is a simplified test that verifies the fix works correctly.
|
||||
The actual bug would cause items to be skipped when removed during iteration.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from basic_memory.sync.watch_service import WatchService
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.repository import ProjectRepository
|
||||
from basic_memory.database import get_session
|
||||
|
||||
# Create a test list similar to what handle_changes uses
|
||||
test_adds = ["file1.md", "file2.md", "file3.md", "file4.md"]
|
||||
|
||||
# Simulate the buggy code (for comparison)
|
||||
# This would skip items
|
||||
buggy_result = []
|
||||
buggy_adds = test_adds.copy()
|
||||
for item in buggy_adds: # BUG: iterating while mutating
|
||||
if item in ["file2.md", "file3.md"]:
|
||||
buggy_adds.remove(item) # This causes items to be skipped
|
||||
else:
|
||||
buggy_result.append(item)
|
||||
|
||||
# With the bug, file3.md or file4.md might be skipped
|
||||
assert len(buggy_result) < len([f for f in test_adds if f not in ["file2.md", "file3.md"]])
|
||||
|
||||
# Simulate the fixed code
|
||||
# This processes all items correctly
|
||||
fixed_result = []
|
||||
fixed_adds = test_adds.copy()
|
||||
for item in list(fixed_adds): # FIX: iterate over a copy
|
||||
if item in ["file2.md", "file3.md"]:
|
||||
fixed_adds.remove(item)
|
||||
else:
|
||||
fixed_result.append(item)
|
||||
|
||||
# With the fix, all non-removed items are processed
|
||||
assert len(fixed_result) == len([f for f in test_adds if f not in ["file2.md", "file3.md"]])
|
||||
assert "file1.md" in fixed_result
|
||||
assert "file4.md" in fixed_result
|
||||
assert "file2.md" not in fixed_result
|
||||
assert "file3.md" not in fixed_result
|
||||
Reference in New Issue
Block a user