Compare commits

...

7 Commits

Author SHA1 Message Date
phernandez fb2fd62ed9 fix: resolve type error and prepare for v0.13.0b6 release
- Add type ignore comment for MCP prompt function call
- Function works correctly at runtime despite false positive type error
- All quality checks now passing
2025-06-09 15:38:59 -05:00
phernandez 126d1655e6 fix: simplify versioning for release workflow
- Use static API version 'v0' instead of dynamic package version
- Remove version verification step in release workflow
- Dynamic versioning handled by uv-dynamic-versioning at build time
2025-06-09 15:25:20 -05:00
phernandez 2abf626c46 fix: resolve unused variable lint warnings in tests
- Remove unused variables in test mock functions
- Clean up test code per ruff linting rules
2025-06-09 15:15:05 -05:00
phernandez ba8e3d112d chore: update dependencies for beta release
- fastmcp 2.7.0 -> 2.7.1
- automated dependency updates
2025-06-09 00:48:48 -05:00
phernandez 7108a7baf1 fix: resolve sync race conditions and search errors
- Add IntegrityError handling in entity_service.create_entity_from_markdown for file_path/permalink constraint violations
- Add IntegrityError handling in sync_service.sync_regular_file for concurrent sync race conditions
- Fix FTS "unknown special query" error when searching for wildcard "*" patterns
- Add comprehensive test coverage for race condition edge cases and error handling
- Gracefully handle concurrent sync processes with fallback to update operations

Fixes sync errors from beta testing including:
- "UNIQUE constraint failed: entity.file_path"
- "UNIQUE constraint failed: entity.permalink"
- "unknown special query" FTS errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-08 15:20:27 -05:00
phernandez 35884ef3a7 fix: update MCP tool/prompt/resource calls to use .fn attribute
FastMCP library changes now require calling decorated functions via the .fn attribute:
- Tools: @mcp.tool() functions return FunctionTool, call with tool.fn()
- Prompts: @mcp.prompt() functions return FunctionPrompt, call with prompt.fn()
- Resources: @mcp.resource() functions return FunctionResource, call with resource.fn()

Updated core files:
- view_note.py: read_note() → read_note.fn()
- read_note.py: search_notes() → search_notes.fn() (2 locations)
- tool.py: 6 MCP tool calls updated to use .fn
- recent_activity.py: recent_activity() → recent_activity.fn()
- project.py: project_info() → project_info.fn() with type ignore

Updated 100+ test files systematically to use .fn attribute and fixed mock targets.

All 869 tests now pass. Fixes view_note tool error in Claude Desktop.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-08 11:52:23 -05:00
phernandez 040be05a81 fix: normalize project names in config during startup
- Fix case sensitivity bug where config had "Personal" but database expected "personal"
- Add project name normalization in synchronize_projects() to use generate_permalink()
- Update config file with normalized names and log changes for user visibility
- Use proper permalink generation instead of hardcoded name.lower().replace()
- Add comprehensive tests for project name normalization scenarios

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-08 09:28:57 -05:00
36 changed files with 863 additions and 619 deletions
+4 -10
View File
@@ -32,17 +32,11 @@ jobs:
uv sync
uv build
- name: Verify version matches tag
- name: Verify build succeeded
run: |
# Get version from built package
PACKAGE_VERSION=$(uv run python -c "import basic_memory; print(basic_memory.__version__)")
TAG_VERSION=${GITHUB_REF_NAME#v} # Remove 'v' prefix from tag
echo "Package version: $PACKAGE_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
echo "Version mismatch! Package: $PACKAGE_VERSION, Tag: $TAG_VERSION"
exit 1
fi
# Verify that build artifacts exist
ls -la dist/
echo "Build completed successfully"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
+1
View File
@@ -16,6 +16,7 @@ Basic Memory v0.13.0 is a **major release** that transforms Basic Memory into a
**Key v0.13.0 Accomplishments:**
-**Complete Project Management System** - Project switching and project-specific operations
-**Advanced Note Editing** - Incremental editing with append, prepend, find/replace, and section operations
-**View Notes as Artifacts in Claude Desktop/Web** - Use the view_note tool to view a note as an artifact
-**File Management System** - Full move operations with database consistency and rollback protection
-**Enhanced Search Capabilities** - Frontmatter tags now searchable, improved content discoverability
-**Unified Database Architecture** - Single app-level database for better performance and project management
-337
View File
@@ -1,337 +0,0 @@
# Manual Testing Suite for Basic Memory
This document outlines a comprehensive manual testing approach where an AI assistant (Claude) executes real-world usage scenarios using Basic Memory's MCP tools. The unique aspect: **Basic Memory tests itself** - all test observations and results are recorded as notes in a dedicated test project.
## Philosophy
- **Integration over Isolation**: Test the full MCP→API→DB→File stack
- **Real Usage Patterns**: Creative exploration, not just checklist validation
- **Self-Documenting**: Use Basic Memory to record all test observations
- **Living Documentation**: Test results become part of the knowledge base
## Setup Instructions
### 1. Environment Preparation
```bash
# Ensure latest basic-memory is installed
pip install --upgrade basic-memory
# Verify MCP server is available
basic-memory --version
```
### 2. MCP Integration Setup
**Option A: Claude Desktop Integration**
```json
// Add to ~/.config/claude-desktop/claude_desktop_config.json
// or
// .mcp.json
{
"mcpServers": {
"basic-memory": {
"command": "uv",
"args": [
"--directory",
"/Users/phernandez/dev/basicmachines/basic-memory",
"run",
"src/basic_memory/cli/main.py",
"mcp"
]
}
}
}
```
**Option B: Claude Code MCP**
```bash
claude mcp add basic-memory basic-memory mcp
```
### 3. Test Project Creation
During testing, create a dedicated test project:
```
- Project name: "basic-memory-testing"
- Location: ~/basic-memory-testing
- Purpose: Contains all test observations and results
```
## Testing Categories
### Phase 1: Core Functionality Validation
**Objective**: Verify all basic operations work correctly
**Test Areas:**
- [ ] **Note Creation**: Various content types, structures, frontmatter
- [ ] **Note Reading**: By title, path, memory:// URLs, non-existent notes
- [ ] **Search Operations**: Simple queries, boolean operators, tag searches
- [ ] **Context Building**: Different depths, timeframes, relation traversal
- [ ] **Recent Activity**: Various timeframes, filtering options
**Success Criteria:**
- All operations complete without errors
- Files appear correctly in filesystem
- Search returns expected results
- Context includes appropriate related content
**Observations to Record:**
```markdown
# Core Functionality Test Results
## Test Execution
- [timestamp] Test started at 2025-01-06 15:30:00
- [setup] Created test project successfully
- [environment] MCP connection established
## write_note Tests
- [success] Basic note creation works
- [success] Frontmatter tags are preserved
- [issue] Special characters in titles need investigation
## Relations
- validates [[Search Operations Test]]
- part_of [[Manual Testing Suite]]
```
### Phase 2: v0.13.0 Feature Deep Dive
**Objective**: Thoroughly test new project management and editing capabilities
**Project Management Tests:**
- [ ] Create multiple projects dynamically
- [ ] Switch between projects mid-conversation
- [ ] Cross-project operations (create notes in different projects)
- [ ] Project discovery and status checking
- [ ] Default project behavior
**Note Editing Tests:**
- [ ] Append operations (add content to end)
- [ ] Prepend operations (add content to beginning)
- [ ] Find/replace operations with validation
- [ ] Section replacement under headers
- [ ] Edit operations across different projects
**File Management Tests:**
- [ ] Move notes within same project
- [ ] Move notes between projects
- [ ] Automatic folder creation during moves
- [ ] Move operations with special characters
- [ ] Database consistency after moves
**Success Criteria:**
- Project switching preserves context correctly
- Edit operations modify files as expected
- Move operations maintain database consistency
- Search indexes update after moves and edits
### Phase 3: Edge Case Exploration
**Objective**: Discover limits and handle unusual scenarios gracefully
**Boundary Testing:**
- [ ] Very long note titles and content
- [ ] Empty notes and projects
- [ ] Special characters: unicode, emojis, symbols
- [ ] Deeply nested folder structures
- [ ] Circular relations and self-references
**Error Scenario Testing:**
- [ ] Invalid memory:// URLs
- [ ] Missing files referenced in database
- [ ] Concurrent operations (if possible)
- [ ] Invalid project names
- [ ] Disk space constraints (if applicable)
**Performance Testing:**
- [ ] Large numbers of notes (100+)
- [ ] Complex search queries
- [ ] Deep relation chains (5+ levels)
- [ ] Rapid successive operations
### Phase 4: Real-World Workflow Scenarios
**Objective**: Test realistic usage patterns that users might follow
**Scenario 1: Meeting Notes Pipeline**
1. Create meeting notes with action items
2. Extract action items into separate notes
3. Link to project planning documents
4. Update progress over time using edit operations
5. Archive completed items
**Scenario 2: Research Knowledge Building**
1. Create research topic notes
2. Build complex relation networks
3. Add incremental findings over time
4. Search and discover connections
5. Reorganize as knowledge grows
**Scenario 3: Multi-Project Workflow**
1. Work project: Technical documentation
2. Personal project: Recipe collection
3. Learning project: Course notes
4. Switch between projects during conversation
5. Cross-reference related concepts
**Scenario 4: Content Evolution**
1. Start with basic notes
2. Gradually enhance with relations
3. Reorganize file structure
4. Update existing content incrementally
5. Build comprehensive knowledge graph
### Phase 5: Creative Stress Testing
**Objective**: Push the system to discover unexpected behaviors
**Creative Exploration Areas:**
- [ ] Rapid project creation and switching
- [ ] Unusual but valid markdown structures
- [ ] Creative use of observation categories
- [ ] Novel relation types and patterns
- [ ] Combining tools in unexpected ways
**Stress Scenarios:**
- [ ] Bulk operations (create many notes quickly)
- [ ] Complex nested moves and edits
- [ ] Deep context building with large graphs
- [ ] Search with complex boolean expressions
## Test Execution Process
### Pre-Test Checklist
- [ ] MCP connection verified
- [ ] Test project created
- [ ] Baseline notes recorded
### During Testing
1. **Execute test scenarios** using actual MCP tool calls
2. **Record observations** immediately in test project
3. **Note timestamps** for performance tracking
4. **Document any errors** with reproduction steps
5. **Explore variations** when something interesting happens
### Test Observation Format
Record all observations as Basic Memory notes using this structure:
```markdown
---
title: Test Session YYYY-MM-DD HH:MM
tags: [testing, session, v0.13.0]
---
# Test Session YYYY-MM-DD HH:MM
## Test Focus
- Primary objective
- Features being tested
## Observations
- [success] Feature X worked as expected #functionality
- [performance] Operation Y took 2.3 seconds #timing
- [issue] Error with special characters #bug
- [enhancement] Could improve UX for scenario Z #improvement
## Discovered Issues
- [bug] Description of problem with reproduction steps
- [limitation] Current system boundary encountered
## Relations
- tests [[Feature X]]
- part_of [[Manual Testing Suite]]
- found_issue [[Bug Report: Special Characters]]
```
### Post-Test Analysis
- [ ] Review all test observations
- [ ] Create summary report with findings
- [ ] Identify patterns in successes/failures
- [ ] Generate improvement recommendations
## Success Metrics
**Quantitative Measures:**
- % of test scenarios completed successfully
- Number of bugs discovered and documented
- Performance benchmarks established
- Coverage of all MCP tools and operations
**Qualitative Measures:**
- Natural conversation flow maintained
- Knowledge graph quality and connections
- User experience insights captured
- System reliability under various conditions
## Expected Outcomes
**For the System:**
- Validation of v0.13.0 features in real usage
- Discovery of edge cases not covered by unit tests
- Performance baseline establishment
- Bug identification with reproduction cases
**For the Knowledge Base:**
- Comprehensive testing documentation
- Real usage examples for documentation
- Edge case scenarios for future reference
- Performance insights and optimization opportunities
**For Development:**
- Priority list for bug fixes
- Enhancement ideas from real usage
- Validation of architectural decisions
- User experience insights
## Test Reporting
All test results will be captured in the Basic Memory test project, creating a living knowledge base of:
- Test execution logs with detailed observations
- Bug reports with reproduction steps
- Performance benchmarks and timing data
- Feature enhancement ideas discovered during testing
- Knowledge graphs showing test coverage relationships
- Summary reports for development team review
This approach ensures that the testing process itself validates Basic Memory's core value proposition: effectively capturing, organizing, and connecting knowledge through natural interaction patterns.
## Things to note
### User Experience & Usability:
- are tool instructions clear with working examples?
- Do error messages provide actionable guidance for resolution?
- Are response times acceptable for interactive use?
- Do tools feel consistent in their parameter patterns and behavior?
- Can users easily discover what tools are available and their capabilities?
### System Behavior:
- Does context preservation work as expected across tool calls?
- Do memory:// URLs behave intuitively for knowledge navigation?
- How well do tools work together in multi-step workflows?
- Does the system gracefully handle edge cases and invalid inputs?
### Documentation Alignment:
- does tool output provide clear results and helpful information?
- Do actual tool behaviors match their documented descriptions?
- Are the examples in tool help accurate and useful?
- Do real-world usage patterns align with documented workflows?
### Mental Model Validation:
- Does the system work the way users would naturally expect?
- Are there surprising behaviors that break user assumptions?
- Can users easily recover from mistakes or wrong turns?
- Do the knowledge graph concepts (entities, relations, observations) feel natural?
### Performance & Reliability:
- Do operations complete in reasonable time for the data size?
- Is system behavior consistent across multiple test sessions?
- How does performance change as the knowledge base grows?
- Are there any operations that feel unexpectedly slow?
---
**Ready to begin testing?** Start by creating the test project and recording your first observation about the testing setup process itself.
+7
View File
@@ -77,6 +77,13 @@ read_note("specs/search-design") # By path
read_note("memory://specs/search") # By memory URL
```
**Viewing notes as formatted artifacts (Claude Desktop):**
```
view_note("Search Design") # Creates readable artifact
view_note("specs/search-design") # By permalink
view_note("memory://specs/search") # By memory URL
```
**Incremental editing** (v0.13.0):
```
edit_note(
+2 -1
View File
@@ -1,3 +1,4 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.13.0b5"
# API version for FastAPI - independent of package version
__version__ = "v0"
+1 -1
View File
@@ -174,7 +174,7 @@ def display_project_info(
"""Display detailed information and statistics about the current project."""
try:
# Get project info
info = asyncio.run(project_info())
info = asyncio.run(project_info.fn()) # type: ignore # pyright: ignore [reportAttributeAccessIssue]
if json_output:
# Convert to JSON and print
+6 -6
View File
@@ -90,7 +90,7 @@ def write_note(
typer.echo("Empty content provided. Please provide non-empty content.", err=True)
raise typer.Exit(1)
note = asyncio.run(mcp_write_note(title, content, folder, tags))
note = asyncio.run(mcp_write_note.fn(title, content, folder, tags))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
@@ -103,7 +103,7 @@ def write_note(
def read_note(identifier: str, page: int = 1, page_size: int = 10):
"""Read a markdown note from the knowledge base."""
try:
note = asyncio.run(mcp_read_note(identifier, page, page_size))
note = asyncio.run(mcp_read_note.fn(identifier, page, page_size))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
@@ -124,7 +124,7 @@ def build_context(
"""Get context needed to continue a discussion."""
try:
context = asyncio.run(
mcp_build_context(
mcp_build_context.fn(
url=url,
depth=depth,
timeframe=timeframe,
@@ -157,7 +157,7 @@ def recent_activity(
"""Get recent activity across the knowledge base."""
try:
context = asyncio.run(
mcp_recent_activity(
mcp_recent_activity.fn(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
@@ -210,7 +210,7 @@ def search_notes(
search_type = "text" if search_type is None else search_type
results = asyncio.run(
mcp_search(
mcp_search.fn(
query,
search_type=search_type,
page=page,
@@ -241,7 +241,7 @@ def continue_conversation(
"""Prompt to continue a previous conversation or work session."""
try:
# Prompt functions return formatted strings directly
session = asyncio.run(mcp_continue_conversation(topic=topic, timeframe=timeframe))
session = asyncio.run(mcp_continue_conversation.fn(topic=topic, timeframe=timeframe)) # type: ignore
rprint(session)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
@@ -38,7 +38,7 @@ async def recent_activity_prompt(
"""
logger.info(f"Getting recent activity, timeframe: {timeframe}")
recent = await recent_activity(timeframe=timeframe, type=[SearchItemType.ENTITY])
recent = await recent_activity.fn(timeframe=timeframe, type=[SearchItemType.ENTITY])
# Extract primary results from the hierarchical structure
primary_results = []
+2 -2
View File
@@ -81,7 +81,7 @@ async def read_note(
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await search_notes(query=identifier, search_type="title", project=project)
title_results = await search_notes.fn(query=identifier, search_type="title", project=project)
if title_results and title_results.results:
result = title_results.results[0] # Get the first/best match
@@ -105,7 +105,7 @@ async def read_note(
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
text_results = await search_notes(query=identifier, search_type="text", project=project)
text_results = await search_notes.fn(query=identifier, search_type="text", project=project)
# We didn't find a direct match, construct a helpful error message
if not text_results or not text_results.results:
+1 -1
View File
@@ -37,7 +37,7 @@ async def view_note(
logger.info(f"Viewing note: {identifier}")
# Call the existing read_note logic
content = await read_note(identifier, page, page_size, project)
content = await read_note.fn(identifier, page, page_size, project)
# Check if this is an error message (note not found)
if "# Note Not Found:" in content:
@@ -237,19 +237,24 @@ class SearchRepository:
# Handle text search for title and content
if search_text:
# Check for explicit boolean operators - only detect them in proper boolean contexts
has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
if has_boolean:
# If boolean operators are present, use the raw query
# No need to prepare it, FTS5 will understand the operators
params["text"] = search_text
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
# Skip FTS for wildcard-only queries that would cause "unknown special query" errors
if search_text.strip() == "*" or search_text.strip() == "":
# For wildcard searches, don't add any text conditions - return all results
pass
else:
# Standard search with term preparation
processed_text = self._prepare_search_term(search_text.strip())
params["text"] = processed_text
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
# Check for explicit boolean operators - only detect them in proper boolean contexts
has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
if has_boolean:
# If boolean operators are present, use the raw query
# No need to prepare it, FTS5 will understand the operators
params["text"] = search_text
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
else:
# Standard search with term preparation
processed_text = self._prepare_search_term(search_text.strip())
params["text"] = processed_text
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
# Handle title match search
if title:
+14 -1
View File
@@ -299,7 +299,20 @@ class EntityService(BaseService[EntityModel]):
# Mark as incomplete because we still need to add relations
model.checksum = None
# Repository will set project_id automatically
return await self.repository.add(model)
try:
return await self.repository.add(model)
except IntegrityError as e:
# Handle race condition where entity was created by another process
if "UNIQUE constraint failed: entity.file_path" in str(
e
) or "UNIQUE constraint failed: entity.permalink" in str(e):
logger.info(
f"Entity already exists for file_path={file_path} (file_path or permalink conflict), updating instead of creating"
)
return await self.update_entity_and_observations(file_path, markdown)
else:
# Re-raise if it's a different integrity error
raise
async def update_entity_and_observations(
self, file_path: Path, markdown: EntityMarkdown
+24 -3
View File
@@ -209,8 +209,29 @@ class ProjectService:
db_projects = await self.repository.get_active_projects()
db_projects_by_name = {p.name: p for p in db_projects}
# Get all projects from configuration
config_projects = config_manager.projects
# Get all projects from configuration and normalize names if needed
config_projects = config_manager.projects.copy()
updated_config = {}
config_updated = False
for name, path in config_projects.items():
# Generate normalized name (what the database expects)
normalized_name = generate_permalink(name)
if normalized_name != name:
logger.info(f"Normalizing project name in config: '{name}' -> '{normalized_name}'")
config_updated = True
updated_config[normalized_name] = path
# Update the configuration if any changes were made
if config_updated:
config_manager.config.projects = updated_config
config_manager.save_config(config_manager.config)
logger.info("Config updated with normalized project names")
# Use the normalized config for further processing
config_projects = updated_config
# Add projects that exist in config but not in DB
for name, path in config_projects.items():
@@ -219,7 +240,7 @@ class ProjectService:
project_data = {
"name": name,
"path": path,
"permalink": name.lower().replace(" ", "-"),
"permalink": generate_permalink(name),
"is_active": True,
# Don't set is_default here - let the enforcement logic handle it
}
+36 -11
View File
@@ -364,18 +364,43 @@ class SyncService:
content_type = self.file_service.content_type(path)
file_path = Path(path)
entity = await self.entity_repository.add(
Entity(
entity_type="file",
file_path=path,
checksum=checksum,
title=file_path.name,
created_at=created,
updated_at=modified,
content_type=content_type,
try:
entity = await self.entity_repository.add(
Entity(
entity_type="file",
file_path=path,
checksum=checksum,
title=file_path.name,
created_at=created,
updated_at=modified,
content_type=content_type,
)
)
)
return entity, checksum
return entity, checksum
except IntegrityError as e:
# Handle race condition where entity was created by another process
if "UNIQUE constraint failed: entity.file_path" in str(e):
logger.info(
f"Entity already exists for file_path={path}, updating instead of creating"
)
# Treat as update instead of create
entity = await self.entity_repository.get_by_file_path(path)
if entity is None: # pragma: no cover
logger.error(f"Entity not found after constraint violation, path={path}")
raise ValueError(f"Entity not found after constraint violation: {path}")
updated = await self.entity_repository.update(
entity.id, {"file_path": path, "checksum": checksum}
)
if updated is None: # pragma: no cover
logger.error(f"Failed to update entity, entity_id={entity.id}, path={path}")
raise ValueError(f"Failed to update entity with ID {entity.id}")
return updated, checksum
else:
# Re-raise if it's a different integrity error
raise
else:
entity = await self.entity_repository.get_by_file_path(path)
if entity is None: # pragma: no cover
+7
View File
@@ -50,6 +50,13 @@ read_note("specs/search-design") # By permalink
read_note("memory://specs/search") # By memory URL
```
**Viewing notes as formatted artifacts (Claude Desktop):**
```
view_note("Search Design") # Creates readable artifact
view_note("specs/search-design") # By permalink
view_note("memory://specs/search") # By memory URL
```
**Incremental editing (v0.13.0) - REQUIRES EXACT IDENTIFIERS:**
```
edit_note(
+2 -2
View File
@@ -48,7 +48,7 @@ def test_info_stats():
# Mock the async project_info function
with patch(
"basic_memory.cli.commands.project.project_info", new_callable=AsyncMock
"basic_memory.cli.commands.project.project_info.fn", new_callable=AsyncMock
) as mock_func:
mock_func.return_value = mock_info
@@ -97,7 +97,7 @@ def test_info_stats_json():
# Mock the async project_info function
with patch(
"basic_memory.cli.commands.project.project_info", new_callable=AsyncMock
"basic_memory.cli.commands.project.project_info.fn", new_callable=AsyncMock
) as mock_func:
mock_func.return_value = mock_info
+9 -9
View File
@@ -15,7 +15,7 @@ async def test_continue_conversation_with_topic(client, test_graph):
# We can use the test_graph fixture which already has relevant content
# Call the function with a topic that should match existing content
result = await continue_conversation(topic="Root", timeframe="1w")
result = await continue_conversation.fn(topic="Root", timeframe="1w")
# Check that the result contains expected content
assert "Continuing conversation on: Root" in result
@@ -27,7 +27,7 @@ async def test_continue_conversation_with_topic(client, test_graph):
async def test_continue_conversation_with_recent_activity(client, test_graph):
"""Test continue_conversation with no topic, using recent activity."""
# Call the function without a topic
result = await continue_conversation(timeframe="1w")
result = await continue_conversation.fn(timeframe="1w")
# Check that the result contains expected content for recent activity
assert "Continuing conversation on: Recent Activity" in result
@@ -40,7 +40,7 @@ async def test_continue_conversation_with_recent_activity(client, test_graph):
async def test_continue_conversation_no_results(client):
"""Test continue_conversation when no results are found."""
# Call with a non-existent topic
result = await continue_conversation(topic="NonExistentTopic", timeframe="1w")
result = await continue_conversation.fn(topic="NonExistentTopic", timeframe="1w")
# Check the response indicates no results found
assert "Continuing conversation on: NonExistentTopic" in result
@@ -51,7 +51,7 @@ async def test_continue_conversation_no_results(client):
async def test_continue_conversation_creates_structured_suggestions(client, test_graph):
"""Test that continue_conversation generates structured tool usage suggestions."""
# Call the function with a topic that should match existing content
result = await continue_conversation(topic="Root", timeframe="1w")
result = await continue_conversation.fn(topic="Root", timeframe="1w")
# Verify the response includes clear tool usage instructions
assert "start by executing one of the suggested commands" in result.lower()
@@ -69,7 +69,7 @@ async def test_continue_conversation_creates_structured_suggestions(client, test
async def test_search_prompt_with_results(client, test_graph):
"""Test search_prompt with a query that returns results."""
# Call the function with a query that should match existing content
result = await search_prompt("Root")
result = await search_prompt.fn("Root")
# Check the response contains expected content
assert 'Search Results for: "Root"' in result
@@ -82,7 +82,7 @@ async def test_search_prompt_with_results(client, test_graph):
async def test_search_prompt_with_timeframe(client, test_graph):
"""Test search_prompt with a timeframe."""
# Call the function with a query and timeframe
result = await search_prompt("Root", timeframe="1w")
result = await search_prompt.fn("Root", timeframe="1w")
# Check the response includes timeframe information
assert 'Search Results for: "Root" (after 7d)' in result
@@ -93,7 +93,7 @@ async def test_search_prompt_with_timeframe(client, test_graph):
async def test_search_prompt_no_results(client):
"""Test search_prompt when no results are found."""
# Call with a query that won't match anything
result = await search_prompt("XYZ123NonExistentQuery")
result = await search_prompt.fn("XYZ123NonExistentQuery")
# Check the response indicates no results found
assert 'Search Results for: "XYZ123NonExistentQuery"' in result
@@ -149,7 +149,7 @@ def test_prompt_context_with_file_path_no_permalink():
async def test_recent_activity_prompt(client, test_graph):
"""Test recent_activity_prompt."""
# Call the function
result = await recent_activity_prompt(timeframe="1w")
result = await recent_activity_prompt.fn(timeframe="1w")
# Check the response contains expected content
assert "Recent Activity" in result
@@ -161,7 +161,7 @@ async def test_recent_activity_prompt(client, test_graph):
async def test_recent_activity_prompt_with_custom_timeframe(client, test_graph):
"""Test recent_activity_prompt with custom timeframe."""
# Call the function with a custom timeframe
result = await recent_activity_prompt(timeframe="1d")
result = await recent_activity_prompt.fn(timeframe="1d")
# Check the response includes the custom timeframe
assert "Recent Activity from (1d)" in result
+2 -2
View File
@@ -97,7 +97,7 @@ async def test_project_info_tool():
"basic_memory.mcp.resources.project_info.call_get", return_value=mock_response
) as mock_call_get:
# Call the function
result = await project_info()
result = await project_info.fn()
# Verify that call_get was called with the correct URL
mock_call_get.assert_called_once()
@@ -138,7 +138,7 @@ async def test_project_info_error_handling():
):
# Verify that the exception propagates
with pytest.raises(Exception) as excinfo:
await project_info()
await project_info.fn()
# Verify error message
assert "Test error" in str(excinfo.value)
+1 -1
View File
@@ -8,7 +8,7 @@ import pytest
async def test_ai_assistant_guide_exists(app):
"""Test that the canvas spec resource exists and returns content."""
# Call the resource function
guide = ai_assistant_guide()
guide = ai_assistant_guide.fn()
# Verify basic characteristics of the content
assert guide is not None
+7 -7
View File
@@ -14,7 +14,7 @@ from basic_memory.schemas.memory import (
@pytest.mark.asyncio
async def test_get_basic_discussion_context(client, test_graph):
"""Test getting basic discussion context."""
context = await build_context(url="memory://test/root")
context = await build_context.fn(url="memory://test/root")
assert isinstance(context, GraphContext)
assert len(context.results) == 1
@@ -33,7 +33,7 @@ async def test_get_basic_discussion_context(client, test_graph):
@pytest.mark.asyncio
async def test_get_discussion_context_pattern(client, test_graph):
"""Test getting context with pattern matching."""
context = await build_context(url="memory://test/*", depth=1)
context = await build_context.fn(url="memory://test/*", depth=1)
assert isinstance(context, GraphContext)
assert len(context.results) > 1 # Should match multiple test/* paths
@@ -45,13 +45,13 @@ async def test_get_discussion_context_pattern(client, test_graph):
async def test_get_discussion_context_timeframe(client, test_graph):
"""Test timeframe parameter filtering."""
# Get recent context
recent_context = await build_context(
recent_context = await build_context.fn(
url="memory://test/root",
timeframe="1d", # Last 24 hours
)
# Get older context
older_context = await build_context(
older_context = await build_context.fn(
url="memory://test/root",
timeframe="30d", # Last 30 days
)
@@ -74,7 +74,7 @@ async def test_get_discussion_context_timeframe(client, test_graph):
@pytest.mark.asyncio
async def test_get_discussion_context_not_found(client):
"""Test handling of non-existent URIs."""
context = await build_context(url="memory://test/does-not-exist")
context = await build_context.fn(url="memory://test/does-not-exist")
assert isinstance(context, GraphContext)
assert len(context.results) == 0
@@ -103,7 +103,7 @@ async def test_build_context_timeframe_formats(client, test_graph):
# Test each valid timeframe
for timeframe in valid_timeframes:
try:
result = await build_context(
result = await build_context.fn(
url=test_url, timeframe=timeframe, page=1, page_size=10, max_related=10
)
assert result is not None
@@ -113,4 +113,4 @@ async def test_build_context_timeframe_formats(client, test_graph):
# Test invalid timeframes should raise ValidationError
for timeframe in invalid_timeframes:
with pytest.raises(ToolError):
await build_context(url=test_url, timeframe=timeframe)
await build_context.fn(url=test_url, timeframe=timeframe)
+6 -6
View File
@@ -34,7 +34,7 @@ async def test_create_canvas(app, project_config):
folder = "visualizations"
# Execute
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
result = await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify result message
assert result
@@ -71,7 +71,7 @@ async def test_create_canvas_with_extension(app, project_config):
folder = "visualizations"
# Execute
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
result = await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify
assert "Created: visualizations/extension-test.canvas" in result
@@ -105,7 +105,7 @@ async def test_update_existing_canvas(app, project_config):
folder = "visualizations"
# Create initial canvas
await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify file exists
file_path = Path(project_config.home) / folder / f"{title}.canvas"
@@ -128,7 +128,7 @@ async def test_update_existing_canvas(app, project_config):
]
# Execute update
result = await canvas(nodes=updated_nodes, edges=updated_edges, title=title, folder=folder)
result = await canvas.fn(nodes=updated_nodes, edges=updated_edges, title=title, folder=folder)
# Verify result indicates update
assert "Updated: visualizations/update-test.canvas" in result
@@ -159,7 +159,7 @@ async def test_create_canvas_with_nested_folders(app, project_config):
folder = "visualizations/nested/folders" # Deep path
# Execute
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
result = await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify
assert "Created: visualizations/nested/folders/nested-test.canvas" in result
@@ -242,7 +242,7 @@ async def test_create_canvas_complex_content(app, project_config):
test_file_path.write_text("# Test File\nThis is referenced by the canvas")
# Execute
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
result = await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
# Verify
assert "Created: visualizations/complex-test.canvas" in result
+33 -31
View File
@@ -10,14 +10,14 @@ from basic_memory.mcp.tools.write_note import write_note
async def test_edit_note_append_operation(client):
"""Test appending content to an existing note."""
# Create initial note
await write_note(
await write_note.fn(
title="Test Note",
folder="test",
content="# Test Note\nOriginal content here.",
)
# Append content
result = await edit_note(
result = await edit_note.fn(
identifier="test/test-note",
operation="append",
content="\n## New Section\nAppended content here.",
@@ -34,14 +34,14 @@ async def test_edit_note_append_operation(client):
async def test_edit_note_prepend_operation(client):
"""Test prepending content to an existing note."""
# Create initial note
await write_note(
await write_note.fn(
title="Meeting Notes",
folder="meetings",
content="# Meeting Notes\nExisting content.",
)
# Prepend content
result = await edit_note(
result = await edit_note.fn(
identifier="meetings/meeting-notes",
operation="prepend",
content="## 2025-05-25 Update\nNew meeting notes.\n",
@@ -58,14 +58,14 @@ async def test_edit_note_prepend_operation(client):
async def test_edit_note_find_replace_operation(client):
"""Test find and replace operation."""
# Create initial note with version info
await write_note(
await write_note.fn(
title="Config Document",
folder="config",
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
)
# Replace version - expecting 2 replacements
result = await edit_note(
result = await edit_note.fn(
identifier="config/config-document",
operation="find_replace",
content="v0.13.0",
@@ -83,14 +83,14 @@ async def test_edit_note_find_replace_operation(client):
async def test_edit_note_replace_section_operation(client):
"""Test replacing content under a specific section."""
# Create initial note with sections
await write_note(
await write_note.fn(
title="API Specification",
folder="specs",
content="# API Spec\n\n## Overview\nAPI overview here.\n\n## Implementation\nOld implementation details.\n\n## Testing\nTest info here.",
)
# Replace implementation section
result = await edit_note(
result = await edit_note.fn(
identifier="specs/api-specification",
operation="replace_section",
content="New implementation approach using FastAPI.\nImproved error handling.\n",
@@ -106,7 +106,7 @@ async def test_edit_note_replace_section_operation(client):
@pytest.mark.asyncio
async def test_edit_note_nonexistent_note(client):
"""Test editing a note that doesn't exist - should return helpful guidance."""
result = await edit_note(
result = await edit_note.fn(
identifier="nonexistent/note", operation="append", content="Some content"
)
@@ -120,14 +120,16 @@ async def test_edit_note_nonexistent_note(client):
async def test_edit_note_invalid_operation(client):
"""Test using an invalid operation."""
# Create a note first
await write_note(
await write_note.fn(
title="Test Note",
folder="test",
content="# Test\nContent here.",
)
with pytest.raises(ValueError) as exc_info:
await edit_note(identifier="test/test-note", operation="invalid_op", content="Some content")
await edit_note.fn(
identifier="test/test-note", operation="invalid_op", content="Some content"
)
assert "Invalid operation 'invalid_op'" in str(exc_info.value)
@@ -136,14 +138,14 @@ async def test_edit_note_invalid_operation(client):
async def test_edit_note_find_replace_missing_find_text(client):
"""Test find_replace operation without find_text parameter."""
# Create a note first
await write_note(
await write_note.fn(
title="Test Note",
folder="test",
content="# Test\nContent here.",
)
with pytest.raises(ValueError) as exc_info:
await edit_note(
await edit_note.fn(
identifier="test/test-note", operation="find_replace", content="replacement"
)
@@ -154,14 +156,14 @@ async def test_edit_note_find_replace_missing_find_text(client):
async def test_edit_note_replace_section_missing_section(client):
"""Test replace_section operation without section parameter."""
# Create a note first
await write_note(
await write_note.fn(
title="Test Note",
folder="test",
content="# Test\nContent here.",
)
with pytest.raises(ValueError) as exc_info:
await edit_note(
await edit_note.fn(
identifier="test/test-note", operation="replace_section", content="new content"
)
@@ -172,14 +174,14 @@ async def test_edit_note_replace_section_missing_section(client):
async def test_edit_note_replace_section_nonexistent_section(client):
"""Test replacing a section that doesn't exist - should append it."""
# Create initial note without the target section
await write_note(
await write_note.fn(
title="Document",
folder="docs",
content="# Document\n\n## Existing Section\nSome content here.",
)
# Try to replace non-existent section
result = await edit_note(
result = await edit_note.fn(
identifier="docs/document",
operation="replace_section",
content="New section content here.\n",
@@ -196,14 +198,14 @@ async def test_edit_note_replace_section_nonexistent_section(client):
async def test_edit_note_with_observations_and_relations(client):
"""Test editing a note that contains observations and relations."""
# Create note with semantic content
await write_note(
await write_note.fn(
title="Feature Spec",
folder="features",
content="# Feature Spec\n\n- [design] Initial design thoughts #architecture\n- implements [[Base System]]\n\nOriginal content.",
)
# Append more semantic content
result = await edit_note(
result = await edit_note.fn(
identifier="features/feature-spec",
operation="append",
content="\n## Updates\n\n- [implementation] Added new feature #development\n- relates_to [[User Guide]]",
@@ -219,7 +221,7 @@ async def test_edit_note_with_observations_and_relations(client):
async def test_edit_note_identifier_variations(client):
"""Test that various identifier formats work."""
# Create a note
await write_note(
await write_note.fn(
title="Test Document",
folder="docs",
content="# Test Document\nOriginal content.",
@@ -233,7 +235,7 @@ async def test_edit_note_identifier_variations(client):
]
for identifier in identifiers_to_test:
result = await edit_note(
result = await edit_note.fn(
identifier=identifier, operation="append", content=f"\n## Update via {identifier}"
)
@@ -246,14 +248,14 @@ async def test_edit_note_identifier_variations(client):
async def test_edit_note_find_replace_no_matches(client):
"""Test find_replace when the find_text doesn't exist - should return error."""
# Create initial note
await write_note(
await write_note.fn(
title="Test Note",
folder="test",
content="# Test Note\nSome content here.",
)
# Try to replace text that doesn't exist - should fail with default expected_replacements=1
result = await edit_note(
result = await edit_note.fn(
identifier="test/test-note",
operation="find_replace",
content="replacement",
@@ -270,14 +272,14 @@ async def test_edit_note_find_replace_no_matches(client):
async def test_edit_note_empty_content_operations(client):
"""Test operations with empty content."""
# Create initial note
await write_note(
await write_note.fn(
title="Test Note",
folder="test",
content="# Test Note\nOriginal content.",
)
# Test append with empty content
result = await edit_note(identifier="test/test-note", operation="append", content="")
result = await edit_note.fn(identifier="test/test-note", operation="append", content="")
assert isinstance(result, str)
assert "Edited note (append)" in result
@@ -288,14 +290,14 @@ async def test_edit_note_empty_content_operations(client):
async def test_edit_note_find_replace_wrong_count(client):
"""Test find_replace when replacement count doesn't match expected."""
# Create initial note with version info
await write_note(
await write_note.fn(
title="Config Document",
folder="config",
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
)
# Try to replace expecting 1 occurrence, but there are actually 2
result = await edit_note(
result = await edit_note.fn(
identifier="config/config-document",
operation="find_replace",
content="v0.13.0",
@@ -315,14 +317,14 @@ async def test_edit_note_find_replace_wrong_count(client):
async def test_edit_note_replace_section_multiple_sections(client):
"""Test replace_section with multiple sections having same header - should return helpful error."""
# Create note with duplicate section headers
await write_note(
await write_note.fn(
title="Sample Note",
folder="docs",
content="# Main Title\n\n## Section 1\nFirst instance\n\n## Section 2\nSome content\n\n## Section 1\nSecond instance",
)
# Try to replace section when multiple exist
result = await edit_note(
result = await edit_note.fn(
identifier="docs/sample-note",
operation="replace_section",
content="New content",
@@ -340,14 +342,14 @@ async def test_edit_note_replace_section_multiple_sections(client):
async def test_edit_note_find_replace_empty_find_text(client):
"""Test find_replace with empty/whitespace find_text - should return helpful error."""
# Create initial note
await write_note(
await write_note.fn(
title="Test Note",
folder="test",
content="# Test Note\nSome content here.",
)
# Try with whitespace-only find_text - this should be caught by service validation
result = await edit_note(
result = await edit_note.fn(
identifier="test/test-note",
operation="find_replace",
content="replacement",
+17 -17
View File
@@ -9,7 +9,7 @@ from basic_memory.mcp.tools.write_note import write_note
@pytest.mark.asyncio
async def test_list_directory_empty(client):
"""Test listing directory when no entities exist."""
result = await list_directory()
result = await list_directory.fn()
assert isinstance(result, str)
assert "No files found in directory '/'" in result
@@ -26,7 +26,7 @@ async def test_list_directory_with_test_graph(client, test_graph):
# /test/Root.md
# List root directory
result = await list_directory()
result = await list_directory.fn()
assert isinstance(result, str)
assert "Contents of '/' (depth 1):" in result
@@ -38,7 +38,7 @@ async def test_list_directory_with_test_graph(client, test_graph):
async def test_list_directory_specific_path(client, test_graph):
"""Test listing specific directory path."""
# List the test directory
result = await list_directory(dir_name="/test")
result = await list_directory.fn(dir_name="/test")
assert isinstance(result, str)
assert "Contents of '/test' (depth 1):" in result
@@ -54,7 +54,7 @@ async def test_list_directory_specific_path(client, test_graph):
async def test_list_directory_with_glob_filter(client, test_graph):
"""Test listing directory with glob filtering."""
# Filter for files containing "Connected"
result = await list_directory(dir_name="/test", file_name_glob="*Connected*")
result = await list_directory.fn(dir_name="/test", file_name_glob="*Connected*")
assert isinstance(result, str)
assert "Files in '/test' matching '*Connected*' (depth 1):" in result
@@ -70,7 +70,7 @@ async def test_list_directory_with_glob_filter(client, test_graph):
@pytest.mark.asyncio
async def test_list_directory_with_markdown_filter(client, test_graph):
"""Test listing directory with markdown file filter."""
result = await list_directory(dir_name="/test", file_name_glob="*.md")
result = await list_directory.fn(dir_name="/test", file_name_glob="*.md")
assert isinstance(result, str)
assert "Files in '/test' matching '*.md' (depth 1):" in result
@@ -87,7 +87,7 @@ async def test_list_directory_with_markdown_filter(client, test_graph):
async def test_list_directory_with_depth_control(client, test_graph):
"""Test listing directory with depth control."""
# Depth 1: should return only the test directory
result_depth_1 = await list_directory(dir_name="/", depth=1)
result_depth_1 = await list_directory.fn(dir_name="/", depth=1)
assert isinstance(result_depth_1, str)
assert "Contents of '/' (depth 1):" in result_depth_1
@@ -95,7 +95,7 @@ async def test_list_directory_with_depth_control(client, test_graph):
assert "Total: 1 items (1 directory)" in result_depth_1
# Depth 2: should return directory + its files
result_depth_2 = await list_directory(dir_name="/", depth=2)
result_depth_2 = await list_directory.fn(dir_name="/", depth=2)
assert isinstance(result_depth_2, str)
assert "Contents of '/' (depth 2):" in result_depth_2
@@ -111,7 +111,7 @@ async def test_list_directory_with_depth_control(client, test_graph):
@pytest.mark.asyncio
async def test_list_directory_nonexistent_path(client, test_graph):
"""Test listing nonexistent directory."""
result = await list_directory(dir_name="/nonexistent")
result = await list_directory.fn(dir_name="/nonexistent")
assert isinstance(result, str)
assert "No files found in directory '/nonexistent'" in result
@@ -120,7 +120,7 @@ async def test_list_directory_nonexistent_path(client, test_graph):
@pytest.mark.asyncio
async def test_list_directory_glob_no_matches(client, test_graph):
"""Test listing directory with glob that matches nothing."""
result = await list_directory(dir_name="/test", file_name_glob="*.xyz")
result = await list_directory.fn(dir_name="/test", file_name_glob="*.xyz")
assert isinstance(result, str)
assert "No files found in directory '/test' matching '*.xyz'" in result
@@ -130,21 +130,21 @@ async def test_list_directory_glob_no_matches(client, test_graph):
async def test_list_directory_with_created_notes(client):
"""Test listing directory with dynamically created notes."""
# Create some test notes
await write_note(
await write_note.fn(
title="Project Planning",
folder="projects",
content="# Project Planning\nThis is about planning projects.",
tags=["planning", "project"],
)
await write_note(
await write_note.fn(
title="Meeting Notes",
folder="projects",
content="# Meeting Notes\nNotes from the meeting.",
tags=["meeting", "notes"],
)
await write_note(
await write_note.fn(
title="Research Document",
folder="research",
content="# Research\nSome research findings.",
@@ -152,7 +152,7 @@ async def test_list_directory_with_created_notes(client):
)
# List root directory
result_root = await list_directory()
result_root = await list_directory.fn()
assert isinstance(result_root, str)
assert "Contents of '/' (depth 1):" in result_root
@@ -161,7 +161,7 @@ async def test_list_directory_with_created_notes(client):
assert "Total: 2 items (2 directories)" in result_root
# List projects directory
result_projects = await list_directory(dir_name="/projects")
result_projects = await list_directory.fn(dir_name="/projects")
assert isinstance(result_projects, str)
assert "Contents of '/projects' (depth 1):" in result_projects
@@ -170,7 +170,7 @@ async def test_list_directory_with_created_notes(client):
assert "Total: 2 items (2 files)" in result_projects
# Test glob filter for "Meeting"
result_meeting = await list_directory(dir_name="/projects", file_name_glob="*Meeting*")
result_meeting = await list_directory.fn(dir_name="/projects", file_name_glob="*Meeting*")
assert isinstance(result_meeting, str)
assert "Files in '/projects' matching '*Meeting*' (depth 1):" in result_meeting
@@ -186,7 +186,7 @@ async def test_list_directory_path_normalization(client, test_graph):
paths_to_test = ["/test", "test", "/test/", "test/"]
for path in paths_to_test:
result = await list_directory(dir_name=path)
result = await list_directory.fn(dir_name=path)
# All should return the same number of items
assert "Total: 5 items (5 files)" in result
assert "📄 Connected Entity 1.md" in result
@@ -195,7 +195,7 @@ async def test_list_directory_path_normalization(client, test_graph):
@pytest.mark.asyncio
async def test_list_directory_shows_file_metadata(client, test_graph):
"""Test that file metadata is displayed correctly."""
result = await list_directory(dir_name="/test")
result = await list_directory.fn(dir_name="/test")
assert isinstance(result, str)
# Should show file names
+46 -46
View File
@@ -12,14 +12,14 @@ from basic_memory.mcp.tools.read_note import read_note
async def test_move_note_success(app, client):
"""Test successfully moving a note to a new location."""
# Create initial note
await write_note(
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nOriginal content here.",
)
# Move note
result = await move_note(
result = await move_note.fn(
identifier="source/test-note",
destination_path="target/MovedNote.md",
)
@@ -29,13 +29,13 @@ async def test_move_note_success(app, client):
# Verify original location no longer exists
try:
await read_note("source/test-note")
await read_note.fn("source/test-note")
assert False, "Original note should not exist after move"
except Exception:
pass # Expected - note should not exist at original location
# Verify note exists at new location with same content
content = await read_note("target/moved-note")
content = await read_note.fn("target/moved-note")
assert "# Test Note" in content
assert "Original content here" in content
assert "permalink: target/moved-note" in content
@@ -45,14 +45,14 @@ async def test_move_note_success(app, client):
async def test_move_note_with_folder_creation(client):
"""Test moving note creates necessary folders."""
# Create initial note
await write_note(
await write_note.fn(
title="Deep Note",
folder="",
content="# Deep Note\nContent in root folder.",
)
# Move to deeply nested path
result = await move_note(
result = await move_note.fn(
identifier="deep-note",
destination_path="deeply/nested/folder/DeepNote.md",
)
@@ -61,7 +61,7 @@ async def test_move_note_with_folder_creation(client):
assert "✅ Note moved successfully" in result
# Verify note exists at new location
content = await read_note("deeply/nested/folder/deep-note")
content = await read_note.fn("deeply/nested/folder/deep-note")
assert "# Deep Note" in content
assert "Content in root folder" in content
@@ -70,7 +70,7 @@ async def test_move_note_with_folder_creation(client):
async def test_move_note_with_observations_and_relations(app, client):
"""Test moving note preserves observations and relations."""
# Create note with complex semantic content
await write_note(
await write_note.fn(
title="Complex Entity",
folder="source",
content="""# Complex Entity
@@ -88,7 +88,7 @@ Some additional content.
)
# Move note
result = await move_note(
result = await move_note.fn(
identifier="source/complex-entity",
destination_path="target/MovedComplex.md",
)
@@ -97,7 +97,7 @@ Some additional content.
assert "✅ Note moved successfully" in result
# Verify moved note preserves all content
content = await read_note("target/moved-complex")
content = await read_note.fn("target/moved-complex")
assert "Important observation #tag1" in content
assert "Key feature #feature" in content
assert "[[SomeOtherEntity]]" in content
@@ -109,14 +109,14 @@ Some additional content.
async def test_move_note_by_title(client):
"""Test moving note using title as identifier."""
# Create note with unique title
await write_note(
await write_note.fn(
title="UniqueTestTitle",
folder="source",
content="# UniqueTestTitle\nTest content.",
)
# Move using title as identifier
result = await move_note(
result = await move_note.fn(
identifier="UniqueTestTitle",
destination_path="target/MovedByTitle.md",
)
@@ -125,7 +125,7 @@ async def test_move_note_by_title(client):
assert "✅ Note moved successfully" in result
# Verify note exists at new location
content = await read_note("target/moved-by-title")
content = await read_note.fn("target/moved-by-title")
assert "# UniqueTestTitle" in content
assert "Test content" in content
@@ -134,14 +134,14 @@ async def test_move_note_by_title(client):
async def test_move_note_by_file_path(client):
"""Test moving note using file path as identifier."""
# Create initial note
await write_note(
await write_note.fn(
title="PathTest",
folder="source",
content="# PathTest\nContent for path test.",
)
# Move using file path as identifier
result = await move_note(
result = await move_note.fn(
identifier="source/PathTest.md",
destination_path="target/MovedByPath.md",
)
@@ -150,7 +150,7 @@ async def test_move_note_by_file_path(client):
assert "✅ Note moved successfully" in result
# Verify note exists at new location
content = await read_note("target/moved-by-path")
content = await read_note.fn("target/moved-by-path")
assert "# PathTest" in content
assert "Content for path test" in content
@@ -158,7 +158,7 @@ async def test_move_note_by_file_path(client):
@pytest.mark.asyncio
async def test_move_note_nonexistent_note(client):
"""Test moving a note that doesn't exist."""
result = await move_note(
result = await move_note.fn(
identifier="nonexistent/note",
destination_path="target/SomeFile.md",
)
@@ -174,14 +174,14 @@ async def test_move_note_nonexistent_note(client):
async def test_move_note_invalid_destination_path(client):
"""Test moving note with invalid destination path."""
# Create initial note
await write_note(
await write_note.fn(
title="TestNote",
folder="source",
content="# TestNote\nTest content.",
)
# Test absolute path (should be rejected by validation)
result = await move_note(
result = await move_note.fn(
identifier="source/test-note",
destination_path="/absolute/path.md",
)
@@ -196,21 +196,21 @@ async def test_move_note_invalid_destination_path(client):
async def test_move_note_destination_exists(client):
"""Test moving note to existing destination."""
# Create source note
await write_note(
await write_note.fn(
title="SourceNote",
folder="source",
content="# SourceNote\nSource content.",
)
# Create destination note
await write_note(
await write_note.fn(
title="DestinationNote",
folder="target",
content="# DestinationNote\nDestination content.",
)
# Try to move source to existing destination
result = await move_note(
result = await move_note.fn(
identifier="source/source-note",
destination_path="target/DestinationNote.md",
)
@@ -225,14 +225,14 @@ async def test_move_note_destination_exists(client):
async def test_move_note_same_location(client):
"""Test moving note to the same location."""
# Create initial note
await write_note(
await write_note.fn(
title="SameLocationTest",
folder="test",
content="# SameLocationTest\nContent here.",
)
# Try to move to same location
result = await move_note(
result = await move_note.fn(
identifier="test/same-location-test",
destination_path="test/SameLocationTest.md",
)
@@ -247,27 +247,27 @@ async def test_move_note_same_location(client):
async def test_move_note_rename_only(client):
"""Test moving note within same folder (rename operation)."""
# Create initial note
await write_note(
await write_note.fn(
title="OriginalName",
folder="test",
content="# OriginalName\nContent to rename.",
)
# Rename within same folder
await move_note(
await move_note.fn(
identifier="test/original-name",
destination_path="test/NewName.md",
)
# Verify original is gone
try:
await read_note("test/original-name")
await read_note.fn("test/original-name")
assert False, "Original note should not exist after rename"
except Exception:
pass # Expected
# Verify new name exists with same content
content = await read_note("test/new-name")
content = await read_note.fn("test/new-name")
assert "# OriginalName" in content # Title in content remains same
assert "Content to rename" in content
assert "permalink: test/new-name" in content
@@ -277,14 +277,14 @@ async def test_move_note_rename_only(client):
async def test_move_note_complex_filename(client):
"""Test moving note with spaces in filename."""
# Create note with spaces in name
await write_note(
await write_note.fn(
title="Meeting Notes 2025",
folder="meetings",
content="# Meeting Notes 2025\nMeeting content with dates.",
)
# Move to new location
result = await move_note(
result = await move_note.fn(
identifier="meetings/meeting-notes-2025",
destination_path="archive/2025/meetings/Meeting Notes 2025.md",
)
@@ -293,7 +293,7 @@ async def test_move_note_complex_filename(client):
assert "✅ Note moved successfully" in result
# Verify note exists at new location with correct content
content = await read_note("archive/2025/meetings/meeting-notes-2025")
content = await read_note.fn("archive/2025/meetings/meeting-notes-2025")
assert "# Meeting Notes 2025" in content
assert "Meeting content with dates" in content
@@ -302,7 +302,7 @@ async def test_move_note_complex_filename(client):
async def test_move_note_with_tags(app, client):
"""Test moving note with tags preserves tags."""
# Create note with tags
await write_note(
await write_note.fn(
title="Tagged Note",
folder="source",
content="# Tagged Note\nContent with tags.",
@@ -310,7 +310,7 @@ async def test_move_note_with_tags(app, client):
)
# Move note
result = await move_note(
result = await move_note.fn(
identifier="source/tagged-note",
destination_path="target/MovedTaggedNote.md",
)
@@ -319,7 +319,7 @@ async def test_move_note_with_tags(app, client):
assert "✅ Note moved successfully" in result
# Verify tags are preserved in correct YAML format
content = await read_note("target/moved-tagged-note")
content = await read_note.fn("target/moved-tagged-note")
assert "- important" in content
assert "- work" in content
assert "- project" in content
@@ -329,14 +329,14 @@ async def test_move_note_with_tags(app, client):
async def test_move_note_empty_string_destination(client):
"""Test moving note with empty destination path."""
# Create initial note
await write_note(
await write_note.fn(
title="TestNote",
folder="source",
content="# TestNote\nTest content.",
)
# Test empty destination path
result = await move_note(
result = await move_note.fn(
identifier="source/test-note",
destination_path="",
)
@@ -351,14 +351,14 @@ async def test_move_note_empty_string_destination(client):
async def test_move_note_parent_directory_path(client):
"""Test moving note with parent directory in destination path."""
# Create initial note
await write_note(
await write_note.fn(
title="TestNote",
folder="source",
content="# TestNote\nTest content.",
)
# Test parent directory path
result = await move_note(
result = await move_note.fn(
identifier="source/test-note",
destination_path="../parent/file.md",
)
@@ -373,14 +373,14 @@ async def test_move_note_parent_directory_path(client):
async def test_move_note_identifier_variations(client):
"""Test that various identifier formats work for moving."""
# Create a note to test different identifier formats
await write_note(
await write_note.fn(
title="Test Document",
folder="docs",
content="# Test Document\nContent for testing identifiers.",
)
# Test with permalink identifier
result = await move_note(
result = await move_note.fn(
identifier="docs/test-document",
destination_path="moved/TestDocument.md",
)
@@ -389,7 +389,7 @@ async def test_move_note_identifier_variations(client):
assert "✅ Note moved successfully" in result
# Verify it moved correctly
content = await read_note("moved/test-document")
content = await read_note.fn("moved/test-document")
assert "# Test Document" in content
assert "Content for testing identifiers" in content
@@ -398,14 +398,14 @@ async def test_move_note_identifier_variations(client):
async def test_move_note_preserves_frontmatter(app, client):
"""Test that moving preserves custom frontmatter."""
# Create note with custom frontmatter by first creating it normally
await write_note(
await write_note.fn(
title="Custom Frontmatter Note",
folder="source",
content="# Custom Frontmatter Note\nContent with custom metadata.",
)
# Move the note
result = await move_note(
result = await move_note.fn(
identifier="source/custom-frontmatter-note",
destination_path="target/MovedCustomNote.md",
)
@@ -414,7 +414,7 @@ async def test_move_note_preserves_frontmatter(app, client):
assert "✅ Note moved successfully" in result
# Verify the moved note has proper frontmatter structure
content = await read_note("target/moved-custom-note")
content = await read_note.fn("target/moved-custom-note")
assert "title: Custom Frontmatter Note" in content
assert "type: note" in content
assert "permalink: target/moved-custom-note" in content
@@ -475,7 +475,7 @@ class TestMoveNoteErrorHandling:
"basic_memory.mcp.tools.move_note.call_post",
side_effect=Exception("entity not found"),
):
result = await move_note("test-note", "target/file.md")
result = await move_note.fn("test-note", "target/file.md")
assert isinstance(result, str)
assert "# Move Failed - Note Not Found" in result
@@ -491,7 +491,7 @@ class TestMoveNoteErrorHandling:
"basic_memory.mcp.tools.move_note.call_post",
side_effect=Exception("permission denied"),
):
result = await move_note("test-note", "target/file.md")
result = await move_note.fn("test-note", "target/file.md")
assert isinstance(result, str)
assert "# Move Failed - Permission Error" in result
+17 -17
View File
@@ -26,7 +26,7 @@ async def mock_call_get():
@pytest_asyncio.fixture
async def mock_search():
"""Mock for search tool."""
with patch("basic_memory.mcp.tools.read_note.search_notes") as mock:
with patch("basic_memory.mcp.tools.read_note.search_notes.fn") as mock:
# Default to empty results
mock.return_value = SearchResponse(results=[], current_page=1, page_size=1)
yield mock
@@ -36,10 +36,10 @@ async def mock_search():
async def test_read_note_by_title(app):
"""Test reading a note by its title."""
# First create a note
await write_note(title="Special Note", folder="test", content="Note content here")
await write_note.fn(title="Special Note", folder="test", content="Note content here")
# Should be able to read it by title
content = await read_note("Special Note")
content = await read_note.fn("Special Note")
assert "Note content here" in content
@@ -47,7 +47,7 @@ async def test_read_note_by_title(app):
async def test_note_unicode_content(app):
"""Test handling of unicode content in"""
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
result = await write_note(title="Unicode Test", folder="test", content=content)
result = await write_note.fn(title="Unicode Test", folder="test", content=content)
assert (
dedent("""
@@ -60,7 +60,7 @@ async def test_note_unicode_content(app):
)
# Read back should preserve unicode
result = await read_note("test/unicode-test")
result = await read_note.fn("test/unicode-test")
assert content in result
@@ -75,16 +75,16 @@ async def test_multiple_notes(app):
]
for _, title, folder, content, tags in notes_data:
await write_note(title=title, folder=folder, content=content, tags=tags)
await write_note.fn(title=title, folder=folder, content=content, tags=tags)
# Should be able to read each one
for permalink, title, folder, content, _ in notes_data:
note = await read_note(permalink)
note = await read_note.fn(permalink)
assert content in note
# read multiple notes at once
result = await read_note("test/*")
result = await read_note.fn("test/*")
# note we can't compare times
assert "--- memory://test/note-1" in result
@@ -108,15 +108,15 @@ async def test_multiple_notes_pagination(app):
]
for _, title, folder, content, tags in notes_data:
await write_note(title=title, folder=folder, content=content, tags=tags)
await write_note.fn(title=title, folder=folder, content=content, tags=tags)
# Should be able to read each one
for permalink, title, folder, content, _ in notes_data:
note = await read_note(permalink)
note = await read_note.fn(permalink)
assert content in note
# read multiple notes at once with pagination
result = await read_note("test/*", page=1, page_size=2)
result = await read_note.fn("test/*", page=1, page_size=2)
# note we can't compare times
assert "--- memory://test/note-1" in result
@@ -136,7 +136,7 @@ async def test_read_note_memory_url(app):
- Return the note content
"""
# First create a note
result = await write_note(
result = await write_note.fn(
title="Memory URL Test",
folder="test",
content="Testing memory:// URL handling",
@@ -145,7 +145,7 @@ async def test_read_note_memory_url(app):
# Should be able to read it with a memory:// URL
memory_url = "memory://test/memory-url-test"
content = await read_note(memory_url)
content = await read_note.fn(memory_url)
assert "Testing memory:// URL handling" in content
@@ -159,7 +159,7 @@ async def test_read_note_direct_success(mock_call_get):
mock_call_get.return_value = mock_response
# Call the function
result = await read_note("test/test-note")
result = await read_note.fn("test/test-note")
# Verify direct lookup was used
mock_call_get.assert_called_once()
@@ -199,7 +199,7 @@ async def test_read_note_title_search_fallback(mock_call_get, mock_search):
)
# Call the function
result = await read_note("Test Note")
result = await read_note.fn("Test Note")
# Verify title search was used
mock_search.assert_called_once()
@@ -253,7 +253,7 @@ async def test_read_note_text_search_fallback(mock_call_get, mock_search):
]
# Call the function
result = await read_note("some query")
result = await read_note.fn("some query")
# Verify both search types were used
assert mock_search.call_count == 2
@@ -281,7 +281,7 @@ async def test_read_note_complete_fallback(mock_call_get, mock_search):
mock_search.return_value = SearchResponse(results=[], current_page=1, page_size=1)
# Call the function
result = await read_note("nonexistent")
result = await read_note.fn("nonexistent")
# Verify search was used
assert mock_search.call_count == 2
+10 -10
View File
@@ -31,7 +31,7 @@ async def test_recent_activity_timeframe_formats(client, test_graph):
# Test each valid timeframe
for timeframe in valid_timeframes:
try:
result = await recent_activity(
result = await recent_activity.fn(
type=["entity"], timeframe=timeframe, page=1, page_size=10, max_related=10
)
assert result is not None
@@ -41,7 +41,7 @@ async def test_recent_activity_timeframe_formats(client, test_graph):
# Test invalid timeframes should raise ValidationError
for timeframe in invalid_timeframes:
with pytest.raises(ToolError):
await recent_activity(timeframe=timeframe)
await recent_activity.fn(timeframe=timeframe)
@pytest.mark.asyncio
@@ -49,25 +49,25 @@ async def test_recent_activity_type_filters(client, test_graph):
"""Test that recent_activity correctly filters by types."""
# Test single string type
result = await recent_activity(type=SearchItemType.ENTITY)
result = await recent_activity.fn(type=SearchItemType.ENTITY)
assert result is not None
assert len(result.results) > 0
assert all(isinstance(item.primary_result, EntitySummary) for item in result.results)
# Test single string type
result = await recent_activity(type="entity")
result = await recent_activity.fn(type="entity")
assert result is not None
assert len(result.results) > 0
assert all(isinstance(item.primary_result, EntitySummary) for item in result.results)
# Test single type
result = await recent_activity(type=["entity"])
result = await recent_activity.fn(type=["entity"])
assert result is not None
assert len(result.results) > 0
assert all(isinstance(item.primary_result, EntitySummary) for item in result.results)
# Test multiple types
result = await recent_activity(type=["entity", "observation"])
result = await recent_activity.fn(type=["entity", "observation"])
assert result is not None
assert len(result.results) > 0
assert all(
@@ -77,7 +77,7 @@ async def test_recent_activity_type_filters(client, test_graph):
)
# Test multiple types
result = await recent_activity(type=[SearchItemType.ENTITY, SearchItemType.OBSERVATION])
result = await recent_activity.fn(type=[SearchItemType.ENTITY, SearchItemType.OBSERVATION])
assert result is not None
assert len(result.results) > 0
assert all(
@@ -87,7 +87,7 @@ async def test_recent_activity_type_filters(client, test_graph):
)
# Test all types
result = await recent_activity(type=["entity", "observation", "relation"])
result = await recent_activity.fn(type=["entity", "observation", "relation"])
assert result is not None
assert len(result.results) > 0
# Results can be any type
@@ -105,14 +105,14 @@ async def test_recent_activity_type_invalid(client, test_graph):
# Test single invalid string type
with pytest.raises(ValueError) as e:
await recent_activity(type="note")
await recent_activity.fn(type="note")
assert (
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
)
# Test invalid string array type
with pytest.raises(ValueError) as e:
await recent_activity(type=["note"])
await recent_activity.fn(type=["note"])
assert (
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
)
+10 -10
View File
@@ -25,7 +25,7 @@ async def test_read_file_text_file(app, synced_files):
- Include correct metadata
"""
# First create a text file via notes
result = await write_note(
result = await write_note.fn(
title="Text Resource",
folder="test",
content="This is a test text resource",
@@ -34,7 +34,7 @@ async def test_read_file_text_file(app, synced_files):
assert result is not None
# Now read it as a resource
response = await read_content("test/text-resource")
response = await read_content.fn("test/text-resource")
assert response["type"] == "text"
assert "This is a test text resource" in response["text"]
@@ -52,7 +52,7 @@ async def test_read_content_file_path(app, synced_files):
- Include correct metadata
"""
# First create a text file via notes
result = await write_note(
result = await write_note.fn(
title="Text Resource",
folder="test",
content="This is a test text resource",
@@ -61,7 +61,7 @@ async def test_read_content_file_path(app, synced_files):
assert result is not None
# Now read it as a resource
response = await read_content("test/Text Resource.md")
response = await read_content.fn("test/Text Resource.md")
assert response["type"] == "text"
assert "This is a test text resource" in response["text"]
@@ -82,7 +82,7 @@ async def test_read_file_image_file(app, synced_files):
image_path = synced_files["image"].name
# Read it as a resource
response = await read_content(image_path)
response = await read_content.fn(image_path)
assert response["type"] == "image"
assert response["source"]["type"] == "base64"
@@ -110,7 +110,7 @@ async def test_read_file_pdf_file(app, synced_files):
pdf_path = synced_files["pdf"].name
# Read it as a resource
response = await read_content(pdf_path)
response = await read_content.fn(pdf_path)
assert response["type"] == "document"
assert response["source"]["type"] == "base64"
@@ -126,14 +126,14 @@ async def test_read_file_pdf_file(app, synced_files):
async def test_read_file_not_found(app):
"""Test trying to read a non-existent"""
with pytest.raises(ToolError, match="Resource not found"):
await read_content("does-not-exist")
await read_content.fn("does-not-exist")
@pytest.mark.asyncio
async def test_read_file_memory_url(app, synced_files):
"""Test reading a resource using a memory:// URL."""
# Create a text file via notes
await write_note(
await write_note.fn(
title="Memory URL Test",
folder="test",
content="Testing memory:// URL handling for resources",
@@ -141,7 +141,7 @@ async def test_read_file_memory_url(app, synced_files):
# Read it with a memory:// URL
memory_url = "memory://test/memory-url-test"
response = await read_content(memory_url)
response = await read_content.fn(memory_url)
assert response["type"] == "text"
assert "Testing memory:// URL handling for resources" in response["text"]
@@ -205,7 +205,7 @@ async def test_image_conversion(app, synced_files):
image_path = synced_files["image"].name
# Test reading the resource
response = await read_content(image_path)
response = await read_content.fn(image_path)
assert response["type"] == "image"
assert response["source"]["media_type"] == "image/jpeg"
+18 -18
View File
@@ -12,7 +12,7 @@ from basic_memory.mcp.tools.search import search_notes, _format_search_error_res
async def test_search_text(client):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
result = await write_note.fn(
title="Test Search Note",
folder="test",
content="# Test\nThis is a searchable test note",
@@ -21,7 +21,7 @@ async def test_search_text(client):
assert result
# Search for it
response = await search_notes(query="searchable")
response = await search_notes.fn(query="searchable")
# Verify results
assert len(response.results) > 0
@@ -32,7 +32,7 @@ async def test_search_text(client):
async def test_search_title(client):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
result = await write_note.fn(
title="Test Search Note",
folder="test",
content="# Test\nThis is a searchable test note",
@@ -41,7 +41,7 @@ async def test_search_title(client):
assert result
# Search for it
response = await search_notes(query="Search Note", search_type="title")
response = await search_notes.fn(query="Search Note", search_type="title")
# Verify results
assert len(response.results) > 0
@@ -52,7 +52,7 @@ async def test_search_title(client):
async def test_search_permalink(client):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
result = await write_note.fn(
title="Test Search Note",
folder="test",
content="# Test\nThis is a searchable test note",
@@ -61,7 +61,7 @@ async def test_search_permalink(client):
assert result
# Search for it
response = await search_notes(query="test/test-search-note", search_type="permalink")
response = await search_notes.fn(query="test/test-search-note", search_type="permalink")
# Verify results
assert len(response.results) > 0
@@ -72,7 +72,7 @@ async def test_search_permalink(client):
async def test_search_permalink_match(client):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
result = await write_note.fn(
title="Test Search Note",
folder="test",
content="# Test\nThis is a searchable test note",
@@ -81,7 +81,7 @@ async def test_search_permalink_match(client):
assert result
# Search for it
response = await search_notes(query="test/test-search-*", search_type="permalink")
response = await search_notes.fn(query="test/test-search-*", search_type="permalink")
# Verify results
assert len(response.results) > 0
@@ -92,7 +92,7 @@ async def test_search_permalink_match(client):
async def test_search_pagination(client):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
result = await write_note.fn(
title="Test Search Note",
folder="test",
content="# Test\nThis is a searchable test note",
@@ -101,7 +101,7 @@ async def test_search_pagination(client):
assert result
# Search for it
response = await search_notes(query="searchable", page=1, page_size=1)
response = await search_notes.fn(query="searchable", page=1, page_size=1)
# Verify results
assert len(response.results) == 1
@@ -112,14 +112,14 @@ async def test_search_pagination(client):
async def test_search_with_type_filter(client):
"""Test search with entity type filter."""
# Create test content
await write_note(
await write_note.fn(
title="Entity Type Test",
folder="test",
content="# Test\nFiltered by type",
)
# Search with type filter
response = await search_notes(query="type", types=["note"])
response = await search_notes.fn(query="type", types=["note"])
# Verify all results are entities
assert all(r.type == "entity" for r in response.results)
@@ -129,14 +129,14 @@ async def test_search_with_type_filter(client):
async def test_search_with_entity_type_filter(client):
"""Test search with entity type filter."""
# Create test content
await write_note(
await write_note.fn(
title="Entity Type Test",
folder="test",
content="# Test\nFiltered by type",
)
# Search with entity type filter
response = await search_notes(query="type", entity_types=["entity"])
response = await search_notes.fn(query="type", entity_types=["entity"])
# Verify all results are entities
assert all(r.type == "entity" for r in response.results)
@@ -146,7 +146,7 @@ async def test_search_with_entity_type_filter(client):
async def test_search_with_date_filter(client):
"""Test search with date filter."""
# Create test content
await write_note(
await write_note.fn(
title="Recent Note",
folder="test",
content="# Test\nRecent content",
@@ -154,7 +154,7 @@ async def test_search_with_date_filter(client):
# Search with date filter
one_hour_ago = datetime.now() - timedelta(hours=1)
response = await search_notes(query="recent", after_date=one_hour_ago.isoformat())
response = await search_notes.fn(query="recent", after_date=one_hour_ago.isoformat())
# Verify we get results within timeframe
assert len(response.results) > 0
@@ -227,7 +227,7 @@ class TestSearchToolErrorHandling:
with patch(
"basic_memory.mcp.tools.search.call_post", side_effect=Exception("syntax error")
):
result = await search_notes("test query")
result = await search_notes.fn("test query")
assert isinstance(result, str)
assert "# Search Failed - Invalid Syntax" in result
@@ -242,7 +242,7 @@ class TestSearchToolErrorHandling:
"basic_memory.mcp.tools.search.call_post",
side_effect=Exception("permission denied"),
):
result = await search_notes("test query")
result = await search_notes.fn("test query")
assert isinstance(result, str)
assert "# Search Failed - Access Error" in result
+7 -7
View File
@@ -21,7 +21,7 @@ async def test_sync_status_completed():
mock_tracker.get_all_projects.return_value = {}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status()
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "System Ready**: ✅ Yes" in result
@@ -57,7 +57,7 @@ async def test_sync_status_in_progress():
mock_tracker.get_all_projects.return_value = {"project1": project1, "project2": project2}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status()
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "System Ready**: 🔄 Processing" in result
@@ -87,7 +87,7 @@ async def test_sync_status_failed():
mock_tracker.get_all_projects.return_value = {"project1": failed_project}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status()
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "System Ready**: 🔄 Processing" in result
@@ -107,7 +107,7 @@ async def test_sync_status_idle():
mock_tracker.get_all_projects.return_value = {}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status()
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "System Ready**: ✅ Yes" in result
@@ -133,7 +133,7 @@ async def test_sync_status_with_project():
mock_tracker.get_project_status.return_value = project_status
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status(project="test-project")
result = await sync_status.fn(project="test-project")
# The function should use the original logic for project-specific queries
# But since we changed the implementation, let's just verify it doesn't crash
@@ -150,7 +150,7 @@ async def test_sync_status_pending():
mock_tracker.get_all_projects.return_value = {}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status()
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "Sync operations pending" in result
@@ -165,6 +165,6 @@ async def test_sync_status_error_handling():
mock_tracker.is_ready = True
mock_tracker.get_summary.side_effect = Exception("Test error")
result = await sync_status()
result = await sync_status.fn()
assert "Unable to check sync status**: Test error" in result
+30 -26
View File
@@ -24,7 +24,7 @@ async def mock_call_get():
@pytest_asyncio.fixture
async def mock_search():
"""Mock for search tool."""
with patch("basic_memory.mcp.tools.read_note.search_notes") as mock:
with patch("basic_memory.mcp.tools.read_note.search_notes.fn") as mock:
# Default to empty results
mock.return_value = SearchResponse(results=[], current_page=1, page_size=1)
yield mock
@@ -34,14 +34,14 @@ async def mock_search():
async def test_view_note_basic_functionality(app):
"""Test viewing a note creates an artifact."""
# First create a note
await write_note(
await write_note.fn(
title="Test View Note",
folder="test",
content="# Test View Note\n\nThis is test content for viewing.",
)
# View the note
result = await view_note("Test View Note")
result = await view_note.fn("Test View Note")
# Should contain artifact XML
assert '<artifact identifier="note-' in result
@@ -72,10 +72,10 @@ async def test_view_note_with_frontmatter_title(app):
Content with frontmatter title.
""").strip()
await write_note(title="Frontmatter Title", folder="test", content=content)
await write_note.fn(title="Frontmatter Title", folder="test", content=content)
# View the note
result = await view_note("Frontmatter Title")
result = await view_note.fn("Frontmatter Title")
# Should extract title from frontmatter
assert 'title="Frontmatter Title"' in result
@@ -88,10 +88,10 @@ async def test_view_note_with_heading_title(app):
# Create note with heading but no frontmatter title
content = "# Heading Title\n\nContent with heading title."
await write_note(title="Heading Title", folder="test", content=content)
await write_note.fn(title="Heading Title", folder="test", content=content)
# View the note
result = await view_note("Heading Title")
result = await view_note.fn("Heading Title")
# Should extract title from heading
assert 'title="Heading Title"' in result
@@ -103,10 +103,10 @@ async def test_view_note_unicode_content(app):
"""Test viewing a note with Unicode content."""
content = "# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦"
await write_note(title="Unicode Test 🚀", folder="test", content=content)
await write_note.fn(title="Unicode Test 🚀", folder="test", content=content)
# View the note
result = await view_note("Unicode Test 🚀")
result = await view_note.fn("Unicode Test 🚀")
# Should handle Unicode properly
assert "🚀" in result
@@ -118,10 +118,12 @@ async def test_view_note_unicode_content(app):
@pytest.mark.asyncio
async def test_view_note_by_permalink(app):
"""Test viewing a note by its permalink."""
await write_note(title="Permalink Test", folder="test", content="Content for permalink test.")
await write_note.fn(
title="Permalink Test", folder="test", content="Content for permalink test."
)
# View by permalink
result = await view_note("test/permalink-test")
result = await view_note.fn("test/permalink-test")
# Should work with permalink
assert '<artifact identifier="note-' in result
@@ -132,14 +134,14 @@ async def test_view_note_by_permalink(app):
@pytest.mark.asyncio
async def test_view_note_with_memory_url(app):
"""Test viewing a note using a memory:// URL."""
await write_note(
await write_note.fn(
title="Memory URL Test",
folder="test",
content="Testing memory:// URL handling in view_note",
)
# View with memory:// URL
result = await view_note("memory://test/memory-url-test")
result = await view_note.fn("memory://test/memory-url-test")
# Should work with memory:// URL
assert '<artifact identifier="note-' in result
@@ -151,7 +153,7 @@ async def test_view_note_with_memory_url(app):
async def test_view_note_not_found(app):
"""Test viewing a non-existent note returns error without artifact."""
# Try to view non-existent note
result = await view_note("NonExistent Note")
result = await view_note.fn("NonExistent Note")
# Should return error message without artifact
assert "# Note Not Found:" in result
@@ -164,10 +166,12 @@ async def test_view_note_not_found(app):
@pytest.mark.asyncio
async def test_view_note_pagination(app):
"""Test viewing a note with pagination parameters."""
await write_note(title="Pagination Test", folder="test", content="Content for pagination test.")
await write_note.fn(
title="Pagination Test", folder="test", content="Content for pagination test."
)
# View with pagination
result = await view_note("Pagination Test", page=1, page_size=5)
result = await view_note.fn("Pagination Test", page=1, page_size=5)
# Should work with pagination
assert '<artifact identifier="note-' in result
@@ -178,10 +182,10 @@ async def test_view_note_pagination(app):
@pytest.mark.asyncio
async def test_view_note_project_parameter(app):
"""Test viewing a note with project parameter."""
await write_note(title="Project Test", folder="test", content="Content for project test.")
await write_note.fn(title="Project Test", folder="test", content="Content for project test.")
# View with explicit project (None uses current)
result = await view_note("Project Test", project=None)
result = await view_note.fn("Project Test", project=None)
# Should work with project parameter
assert '<artifact identifier="note-' in result
@@ -193,12 +197,12 @@ async def test_view_note_project_parameter(app):
async def test_view_note_artifact_identifier_unique(app):
"""Test that different notes get different artifact identifiers."""
# Create two notes
await write_note(title="Note One", folder="test", content="Content one")
await write_note(title="Note Two", folder="test", content="Content two")
await write_note.fn(title="Note One", folder="test", content="Content one")
await write_note.fn(title="Note Two", folder="test", content="Content two")
# View both notes
result1 = await view_note("Note One")
result2 = await view_note("Note Two")
result1 = await view_note.fn("Note One")
result2 = await view_note.fn("Note Two")
# Should have different artifact identifiers
import re
@@ -215,14 +219,14 @@ async def test_view_note_artifact_identifier_unique(app):
async def test_view_note_fallback_identifier_as_title(app):
"""Test that view_note uses identifier as title when no title is extractable."""
# Create a note with no clear title structure
await write_note(
await write_note.fn(
title="Simple Note",
folder="test",
content="Just plain content with no headings or frontmatter title",
)
# View the note
result = await view_note("Simple Note")
result = await view_note.fn("Simple Note")
# Should use identifier as fallback title
assert 'title="Simple Note"' in result
@@ -248,7 +252,7 @@ async def test_view_note_direct_success(mock_call_get):
mock_call_get.return_value = mock_response
# Call the function
result = await view_note("test/test-note")
result = await view_note.fn("test/test-note")
# Verify direct lookup was used
mock_call_get.assert_called_once()
@@ -290,7 +294,7 @@ async def test_view_note_title_search_fallback(mock_call_get, mock_search):
)
# Call the function
result = await view_note("Test Note")
result = await view_note.fn("Test Note")
# Verify title search was used
mock_search.assert_called_once()
+21 -21
View File
@@ -16,7 +16,7 @@ async def test_write_note(app):
- Handle tags correctly
- Return valid permalink
"""
result = await write_note(
result = await write_note.fn(
title="Test Note",
folder="test",
content="# Test\nThis is a test note",
@@ -31,7 +31,7 @@ async def test_write_note(app):
assert "- test, documentation" in result
# Try reading it back via permalink
content = await read_note("test/test-note")
content = await read_note.fn("test/test-note")
assert (
dedent("""
---
@@ -53,14 +53,14 @@ async def test_write_note(app):
@pytest.mark.asyncio
async def test_write_note_no_tags(app):
"""Test creating a note without tags."""
result = await write_note(title="Simple Note", folder="test", content="Just some text")
result = await write_note.fn(title="Simple Note", folder="test", content="Just some text")
assert result
assert "# Created note" in result
assert "file_path: test/Simple Note.md" in result
assert "permalink: test/simple-note" in result
# Should be able to read it back
content = await read_note("test/simple-note")
content = await read_note.fn("test/simple-note")
assert (
dedent("""
--
@@ -85,7 +85,7 @@ async def test_write_note_update_existing(app):
- Handle tags correctly
- Return valid permalink
"""
result = await write_note(
result = await write_note.fn(
title="Test Note",
folder="test",
content="# Test\nThis is a test note",
@@ -99,7 +99,7 @@ async def test_write_note_update_existing(app):
assert "## Tags" in result
assert "- test, documentation" in result
result = await write_note(
result = await write_note.fn(
title="Test Note",
folder="test",
content="# Test\nThis is an updated note",
@@ -112,7 +112,7 @@ async def test_write_note_update_existing(app):
assert "- test, documentation" in result
# Try reading it back
content = await read_note("test/test-note")
content = await read_note.fn("test/test-note")
assert (
dedent(
"""
@@ -150,7 +150,7 @@ async def test_issue_93_write_note_respects_custom_permalink_new_note(app):
- [note] Testing if custom permalink is respected
""").strip()
result = await write_note(
result = await write_note.fn(
title="My New Note",
folder="notes",
content=content_with_custom_permalink,
@@ -167,7 +167,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app):
"""Test that write_note respects custom permalinks when updating existing notes (Issue #93)"""
# Step 1: Create initial note (auto-generated permalink)
result1 = await write_note(
result1 = await write_note.fn(
title="Existing Note",
folder="test",
content="Initial content without custom permalink",
@@ -197,7 +197,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app):
- [note] Custom permalink should be respected on update
""").strip()
result2 = await write_note(
result2 = await write_note.fn(
title="Existing Note",
folder="test",
content=updated_content,
@@ -218,7 +218,7 @@ async def test_delete_note_existing(app):
- Return valid permalink
- Delete the note
"""
result = await write_note(
result = await write_note.fn(
title="Test Note",
folder="test",
content="# Test\nThis is a test note",
@@ -227,7 +227,7 @@ async def test_delete_note_existing(app):
assert result
deleted = await delete_note("test/test-note")
deleted = await delete_note.fn("test/test-note")
assert deleted is True
@@ -239,7 +239,7 @@ async def test_delete_note_doesnt_exist(app):
- Delete the note
- verify returns false
"""
deleted = await delete_note("doesnt-exist")
deleted = await delete_note.fn("doesnt-exist")
assert deleted is False
@@ -259,7 +259,7 @@ async def test_write_note_with_tag_array_from_bug_report(app):
}
# Try to call the function with this data directly
result = await write_note(**bug_payload)
result = await write_note.fn(**bug_payload)
assert result
assert "permalink: folder/title" in result
@@ -277,7 +277,7 @@ async def test_write_note_verbose(app):
- Handle tags correctly
- Return valid permalink
"""
result = await write_note(
result = await write_note.fn(
title="Test Note",
folder="test",
content="""
@@ -313,7 +313,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
- Verify custom frontmatter is preserved
"""
# First, create a note with custom metadata using write_note
await write_note(
await write_note.fn(
title="Custom Metadata Note",
folder="test",
content="# Initial content",
@@ -321,7 +321,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
)
# Read the note to get its permalink
content = await read_note("test/custom-metadata-note")
content = await read_note.fn("test/custom-metadata-note")
# Now directly update the file with custom frontmatter
# We need to use a direct file update to add custom frontmatter
@@ -340,7 +340,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
f.write(frontmatter.dumps(post))
# Now update the note using write_note
result = await write_note(
result = await write_note.fn(
title="Custom Metadata Note",
folder="test",
content="# Updated content",
@@ -351,7 +351,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
assert ("Updated note\nfile_path: test/Custom Metadata Note.md") in result
# Read the note back and check if custom frontmatter is preserved
content = await read_note("test/custom-metadata-note")
content = await read_note.fn("test/custom-metadata-note")
# Custom frontmatter should be preserved
assert "Status: In Progress" in content
@@ -371,7 +371,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
@pytest.mark.asyncio
async def test_write_note_preserves_content_frontmatter(app):
"""Test creating a new note."""
await write_note(
await write_note.fn(
title="Test Note",
folder="test",
content=dedent(
@@ -391,7 +391,7 @@ async def test_write_note_preserves_content_frontmatter(app):
)
# Try reading it back via permalink
content = await read_note("test/test-note")
content = await read_note.fn("test/test-note")
assert (
dedent(
"""
@@ -483,3 +483,37 @@ class TestSearchTermPreparation:
# Test with other problematic patterns
results3 = await search_repository.search(search_text="node.js version")
assert isinstance(results3, list) # Should not crash
@pytest.mark.asyncio
async def test_wildcard_only_search(self, search_repository, search_entity):
"""Test that wildcard-only search '*' doesn't cause FTS5 errors (line 243 coverage)."""
# Index an entity for testing
search_row = SearchIndexRow(
id=search_entity.id,
type=SearchItemType.ENTITY.value,
title="Test Entity",
content_stems="test entity content",
content_snippet="This is a test entity",
permalink=search_entity.permalink,
file_path=search_entity.file_path,
entity_id=search_entity.id,
metadata={"entity_type": search_entity.entity_type},
created_at=search_entity.created_at,
updated_at=search_entity.updated_at,
project_id=search_repository.project_id,
)
await search_repository.index_item(search_row)
# Test wildcard-only search - should not crash and should return results
results = await search_repository.search(search_text="*")
assert isinstance(results, list) # Should not crash
assert len(results) >= 1 # Should return all results, including our test entity
# Test empty string search - should also not crash
results_empty = await search_repository.search(search_text="")
assert isinstance(results_empty, list) # Should not crash
# Test whitespace-only search
results_whitespace = await search_repository.search(search_text=" ")
assert isinstance(results_whitespace, list) # Should not crash
+113
View File
@@ -869,6 +869,119 @@ async def test_edit_entity_with_observations_and_relations(
assert new_rel.relation_type == "relates to"
@pytest.mark.asyncio
async def test_create_entity_from_markdown_race_condition_handling(
entity_service: EntityService, file_service: FileService
):
"""Test that create_entity_from_markdown handles race condition with IntegrityError (lines 304-311)."""
from unittest.mock import patch
from sqlalchemy.exc import IntegrityError
file_path = Path("test/race-condition.md")
# Create a mock EntityMarkdown object
from basic_memory.markdown.schemas import (
EntityFrontmatter,
EntityMarkdown as RealEntityMarkdown,
)
from datetime import datetime, timezone
frontmatter = EntityFrontmatter(metadata={"title": "Race Condition Test", "type": "test"})
markdown = RealEntityMarkdown(
frontmatter=frontmatter,
observations=[],
relations=[],
created=datetime.now(timezone.utc),
modified=datetime.now(timezone.utc),
)
# Mock the repository.add to raise IntegrityError on first call, then succeed on second
original_add = entity_service.repository.add
call_count = 0
async def mock_add(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
# Simulate race condition - another process created the entity
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
else:
return await original_add(*args, **kwargs)
# Mock update method to return a dummy entity
async def mock_update(*args, **kwargs):
from basic_memory.models import Entity
from datetime import datetime, timezone
return Entity(
id=1,
title="Race Condition Test",
entity_type="test",
file_path=str(file_path),
permalink="test/race-condition-test",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
with (
patch.object(entity_service.repository, "add", side_effect=mock_add),
patch.object(
entity_service, "update_entity_and_observations", side_effect=mock_update
) as mock_update_call,
):
# Call the method
result = await entity_service.create_entity_from_markdown(file_path, markdown)
# Verify it handled the race condition gracefully
assert result is not None
assert result.title == "Race Condition Test"
assert result.file_path == str(file_path)
# Verify that update_entity_and_observations was called as fallback
mock_update_call.assert_called_once_with(file_path, markdown)
@pytest.mark.asyncio
async def test_create_entity_from_markdown_integrity_error_reraise(
entity_service: EntityService, file_service: FileService
):
"""Test that create_entity_from_markdown re-raises IntegrityError for non-race-condition cases."""
from unittest.mock import patch
from sqlalchemy.exc import IntegrityError
file_path = Path("test/integrity-error.md")
# Create a mock EntityMarkdown object
from basic_memory.markdown.schemas import (
EntityFrontmatter,
EntityMarkdown as RealEntityMarkdown,
)
from datetime import datetime, timezone
frontmatter = EntityFrontmatter(metadata={"title": "Integrity Error Test", "type": "test"})
markdown = RealEntityMarkdown(
frontmatter=frontmatter,
observations=[],
relations=[],
created=datetime.now(timezone.utc),
modified=datetime.now(timezone.utc),
)
# Mock the repository.add to raise a different IntegrityError (not file_path/permalink constraint)
async def mock_add(*args, **kwargs):
# Simulate a different constraint violation
raise IntegrityError("UNIQUE constraint failed: entity.some_other_field", None, None)
with patch.object(entity_service.repository, "add", side_effect=mock_add):
# Should re-raise the IntegrityError since it's not a file_path/permalink constraint
with pytest.raises(
IntegrityError, match="UNIQUE constraint failed: entity.some_other_field"
):
await entity_service.create_entity_from_markdown(file_path, markdown)
# Edge case tests for find_replace operation
@pytest.mark.asyncio
async def test_edit_entity_find_replace_not_found(entity_service: EntityService):
+132
View File
@@ -469,3 +469,135 @@ async def test_synchronize_projects_calls_ensure_single_default(
# Clean up test project
if test_project_name in project_service.projects:
await project_service.remove_project(test_project_name)
@pytest.mark.asyncio
async def test_synchronize_projects_normalizes_project_names(
project_service: ProjectService, tmp_path
):
"""Test that synchronize_projects normalizes project names in config to match database format."""
# Use a project name that needs normalization (uppercase, spaces)
unnormalized_name = "Test Project With Spaces"
expected_normalized_name = "test-project-with-spaces"
test_project_path = str(tmp_path / "test-project-spaces")
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
# Import config manager outside try block
from basic_memory.config import config_manager
try:
# Manually add the unnormalized project name to config
# Save the original config state for potential debugging
# original_projects = config_manager.projects.copy()
# Add project with unnormalized name directly to config
config_manager.config.projects[unnormalized_name] = test_project_path
config_manager.save_config(config_manager.config)
# Verify the unnormalized name is in config
assert unnormalized_name in project_service.projects
assert project_service.projects[unnormalized_name] == test_project_path
# Call synchronize_projects - this should normalize the project name
await project_service.synchronize_projects()
# Verify the config was updated with normalized name
assert expected_normalized_name in project_service.projects
assert unnormalized_name not in project_service.projects
assert project_service.projects[expected_normalized_name] == test_project_path
# Verify the project was added to database with normalized name
db_project = await project_service.repository.get_by_name(expected_normalized_name)
assert db_project is not None
assert db_project.name == expected_normalized_name
assert db_project.path == test_project_path
assert db_project.permalink == expected_normalized_name
# Verify the unnormalized name is not in database
unnormalized_db_project = await project_service.repository.get_by_name(unnormalized_name)
assert unnormalized_db_project is None
finally:
# Clean up - remove any test projects from both config and database
current_projects = project_service.projects.copy()
for name in [unnormalized_name, expected_normalized_name]:
if name in current_projects:
try:
await project_service.remove_project(name)
except Exception:
# Try to clean up manually if remove_project fails
try:
config_manager.remove_project(name)
except Exception:
pass
# Remove from database
db_project = await project_service.repository.get_by_name(name)
if db_project:
await project_service.repository.delete(db_project.id)
@pytest.mark.asyncio
async def test_synchronize_projects_handles_case_sensitivity_bug(
project_service: ProjectService, tmp_path
):
"""Test that synchronize_projects fixes the case sensitivity bug (Personal vs personal)."""
# Simulate the exact bug scenario: config has "Personal" but database expects "personal"
config_name = "Personal"
normalized_name = "personal"
test_project_path = str(tmp_path / "personal-project")
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
# Import config manager outside try block
from basic_memory.config import config_manager
try:
# Add project with uppercase name to config (simulating the bug scenario)
config_manager.config.projects[config_name] = test_project_path
config_manager.save_config(config_manager.config)
# Verify the uppercase name is in config
assert config_name in project_service.projects
assert project_service.projects[config_name] == test_project_path
# Call synchronize_projects - this should fix the case sensitivity issue
await project_service.synchronize_projects()
# Verify the config was updated to use normalized case
assert normalized_name in project_service.projects
assert config_name not in project_service.projects
assert project_service.projects[normalized_name] == test_project_path
# Verify the project exists in database with correct normalized name
db_project = await project_service.repository.get_by_name(normalized_name)
assert db_project is not None
assert db_project.name == normalized_name
assert db_project.path == test_project_path
# Verify we can now switch to this project without case sensitivity errors
# (This would have failed before the fix with "Personal" != "personal")
project_lookup = await project_service.get_project(normalized_name)
assert project_lookup is not None
assert project_lookup.name == normalized_name
finally:
# Clean up
for name in [config_name, normalized_name]:
if name in project_service.projects:
try:
await project_service.remove_project(name)
except Exception:
# Manual cleanup if needed
try:
config_manager.remove_project(name)
except Exception:
pass
db_project = await project_service.repository.get_by_name(name)
if db_project:
await project_service.repository.delete(db_project.id)
+222
View File
@@ -1089,3 +1089,225 @@ permalink: note
""".strip()
== file_one_content
)
@pytest.mark.asyncio
async def test_sync_regular_file_race_condition_handling(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that sync_regular_file handles race condition with IntegrityError (lines 380-401)."""
from unittest.mock import patch
from sqlalchemy.exc import IntegrityError
from datetime import datetime, timezone
# Create a test file
test_file = project_config.home / "test_race.md"
test_content = """
---
type: knowledge
---
# Test Race Condition
This is a test file for race condition handling.
"""
await create_test_file(test_file, test_content)
# Mock the entity_repository.add to raise IntegrityError on first call
original_add = sync_service.entity_repository.add
call_count = 0
async def mock_add(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
# Simulate race condition - another process created the entity
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
else:
return await original_add(*args, **kwargs)
# Mock get_by_file_path to return an existing entity (simulating the race condition result)
async def mock_get_by_file_path(file_path):
from basic_memory.models import Entity
return Entity(
id=1,
title="Test Race Condition",
entity_type="knowledge",
file_path=str(file_path),
permalink="test-race-condition",
content_type="text/markdown",
checksum="old_checksum",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Mock update to return the updated entity
async def mock_update(entity_id, updates):
from basic_memory.models import Entity
return Entity(
id=entity_id,
title="Test Race Condition",
entity_type="knowledge",
file_path=updates["file_path"],
permalink="test-race-condition",
content_type="text/markdown",
checksum=updates["checksum"],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
with (
patch.object(sync_service.entity_repository, "add", side_effect=mock_add),
patch.object(
sync_service.entity_repository, "get_by_file_path", side_effect=mock_get_by_file_path
) as mock_get,
patch.object(
sync_service.entity_repository, "update", side_effect=mock_update
) as mock_update_call,
):
# Call sync_regular_file
entity, checksum = await sync_service.sync_regular_file(
str(test_file.relative_to(project_config.home)), new=True
)
# Verify it handled the race condition gracefully
assert entity is not None
assert entity.title == "Test Race Condition"
assert entity.file_path == str(test_file.relative_to(project_config.home))
# Verify that get_by_file_path and update were called as fallback
assert mock_get.call_count >= 1 # May be called multiple times
mock_update_call.assert_called_once()
@pytest.mark.asyncio
async def test_sync_regular_file_integrity_error_reraise(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that sync_regular_file re-raises IntegrityError for non-race-condition cases."""
from unittest.mock import patch
from sqlalchemy.exc import IntegrityError
# Create a test file
test_file = project_config.home / "test_integrity.md"
test_content = """
---
type: knowledge
---
# Test Integrity Error
This is a test file for integrity error handling.
"""
await create_test_file(test_file, test_content)
# Mock the entity_repository.add to raise a different IntegrityError (not file_path constraint)
async def mock_add(*args, **kwargs):
# Simulate a different constraint violation
raise IntegrityError("UNIQUE constraint failed: entity.some_other_field", None, None)
with patch.object(sync_service.entity_repository, "add", side_effect=mock_add):
# Should re-raise the IntegrityError since it's not a file_path constraint
with pytest.raises(
IntegrityError, match="UNIQUE constraint failed: entity.some_other_field"
):
await sync_service.sync_regular_file(
str(test_file.relative_to(project_config.home)), new=True
)
@pytest.mark.asyncio
async def test_sync_regular_file_race_condition_entity_not_found(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test handling when entity is not found after IntegrityError (pragma: no cover case)."""
from unittest.mock import patch
from sqlalchemy.exc import IntegrityError
# Create a test file
test_file = project_config.home / "test_not_found.md"
test_content = """
---
type: knowledge
---
# Test Not Found
This is a test file for entity not found after constraint violation.
"""
await create_test_file(test_file, test_content)
# Mock the entity_repository.add to raise IntegrityError
async def mock_add(*args, **kwargs):
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
# Mock get_by_file_path to return None (entity not found)
async def mock_get_by_file_path(file_path):
return None
with (
patch.object(sync_service.entity_repository, "add", side_effect=mock_add),
patch.object(
sync_service.entity_repository, "get_by_file_path", side_effect=mock_get_by_file_path
),
):
# Should raise ValueError when entity is not found after constraint violation
with pytest.raises(ValueError, match="Entity not found after constraint violation"):
await sync_service.sync_regular_file(
str(test_file.relative_to(project_config.home)), new=True
)
@pytest.mark.asyncio
async def test_sync_regular_file_race_condition_update_failed(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test handling when update fails after IntegrityError (pragma: no cover case)."""
from unittest.mock import patch
from sqlalchemy.exc import IntegrityError
from datetime import datetime, timezone
# Create a test file
test_file = project_config.home / "test_update_fail.md"
test_content = """
---
type: knowledge
---
# Test Update Fail
This is a test file for update failure after constraint violation.
"""
await create_test_file(test_file, test_content)
# Mock the entity_repository.add to raise IntegrityError
async def mock_add(*args, **kwargs):
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
# Mock get_by_file_path to return an existing entity
async def mock_get_by_file_path(file_path):
from basic_memory.models import Entity
return Entity(
id=1,
title="Test Update Fail",
entity_type="knowledge",
file_path=str(file_path),
permalink="test-update-fail",
content_type="text/markdown",
checksum="old_checksum",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Mock update to return None (failure)
async def mock_update(entity_id, updates):
return None
with (
patch.object(sync_service.entity_repository, "add", side_effect=mock_add),
patch.object(
sync_service.entity_repository, "get_by_file_path", side_effect=mock_get_by_file_path
),
patch.object(sync_service.entity_repository, "update", side_effect=mock_update),
):
# Should raise ValueError when update fails
with pytest.raises(ValueError, match="Failed to update entity with ID"):
await sync_service.sync_regular_file(
str(test_file.relative_to(project_config.home)), new=True
)
Generated
+3 -3
View File
@@ -408,7 +408,7 @@ standard = [
[[package]]
name = "fastmcp"
version = "2.7.0"
version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
@@ -420,9 +420,9 @@ dependencies = [
{ name = "rich" },
{ name = "typer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/a5/d156051c08915c2ca7c97583dac4547f17fcbf09918727c36ca0e6cd6ea7/fastmcp-2.7.0.tar.gz", hash = "sha256:6a081400ed46e1b74fbda3f5b7f806180f4091b7bf36bd4c52d7074934767004", size = 1590831, upload-time = "2025-06-05T19:03:51.778Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/69/8820d3c0e17ed2c7baed3e322191509285fc724c60f9cac5b28037feb5c9/fastmcp-2.7.1.tar.gz", hash = "sha256:489b8480a3e3a96b9eb1847e77f0272b732ad397b2ddad3a25eb185cc99b6c9c", size = 1591616, upload-time = "2025-06-08T01:50:02.349Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/3f/88fd54bbec9a3c619f94c61b84473a49c3af24582a9cc64059fdefeef98b/fastmcp-2.7.0-py3-none-any.whl", hash = "sha256:5e0827a37bc71656edebb5f217423ce6f838d8f0e42f79c9f803349c0366fc80", size = 127452, upload-time = "2025-06-05T19:03:50.129Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b8/af0bb06d1388b680c64ec7b9767d3718e51e65d91e425c1296446f10a9fc/fastmcp-2.7.1-py3-none-any.whl", hash = "sha256:e75b4c7088338f2532d79f37a2ae654f47bfd7d3d15340233fda25bc168231b6", size = 127618, upload-time = "2025-06-08T01:50:00.945Z" },
]
[[package]]