This is page 21 of 23. Use http://codebase.md/basicmachines-co/basic-memory?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ ├── python-developer.md
│ │ └── system-architect.md
│ └── commands
│ ├── release
│ │ ├── beta.md
│ │ ├── changelog.md
│ │ ├── release-check.md
│ │ └── release.md
│ ├── spec.md
│ └── test-live.md
├── .dockerignore
├── .github
│ ├── dependabot.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.md
│ │ ├── config.yml
│ │ ├── documentation.md
│ │ └── feature_request.md
│ └── workflows
│ ├── claude-code-review.yml
│ ├── claude-issue-triage.yml
│ ├── claude.yml
│ ├── dev-release.yml
│ ├── docker.yml
│ ├── pr-title.yml
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .python-version
├── CHANGELOG.md
├── CITATION.cff
├── CLA.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── docker-compose.yml
├── Dockerfile
├── docs
│ ├── ai-assistant-guide-extended.md
│ ├── character-handling.md
│ ├── cloud-cli.md
│ └── Docker.md
├── justfile
├── LICENSE
├── llms-install.md
├── pyproject.toml
├── README.md
├── SECURITY.md
├── smithery.yaml
├── specs
│ ├── SPEC-1 Specification-Driven Development Process.md
│ ├── SPEC-10 Unified Deployment Workflow and Event Tracking.md
│ ├── SPEC-11 Basic Memory API Performance Optimization.md
│ ├── SPEC-12 OpenTelemetry Observability.md
│ ├── SPEC-13 CLI Authentication with Subscription Validation.md
│ ├── SPEC-14 Cloud Git Versioning & GitHub Backup.md
│ ├── SPEC-14- Cloud Git Versioning & GitHub Backup.md
│ ├── SPEC-15 Configuration Persistence via Tigris for Cloud Tenants.md
│ ├── SPEC-16 MCP Cloud Service Consolidation.md
│ ├── SPEC-17 Semantic Search with ChromaDB.md
│ ├── SPEC-18 AI Memory Management Tool.md
│ ├── SPEC-19 Sync Performance and Memory Optimization.md
│ ├── SPEC-2 Slash Commands Reference.md
│ ├── SPEC-3 Agent Definitions.md
│ ├── SPEC-4 Notes Web UI Component Architecture.md
│ ├── SPEC-5 CLI Cloud Upload via WebDAV.md
│ ├── SPEC-6 Explicit Project Parameter Architecture.md
│ ├── SPEC-7 POC to spike Tigris Turso for local access to cloud data.md
│ ├── SPEC-8 TigrisFS Integration.md
│ ├── SPEC-9 Multi-Project Bidirectional Sync Architecture.md
│ ├── SPEC-9 Signed Header Tenant Information.md
│ └── SPEC-9-1 Follow-Ups- Conflict, Sync, and Observability.md
├── src
│ └── basic_memory
│ ├── __init__.py
│ ├── alembic
│ │ ├── alembic.ini
│ │ ├── env.py
│ │ ├── migrations.py
│ │ ├── script.py.mako
│ │ └── versions
│ │ ├── 3dae7c7b1564_initial_schema.py
│ │ ├── 502b60eaa905_remove_required_from_entity_permalink.py
│ │ ├── 5fe1ab1ccebe_add_projects_table.py
│ │ ├── 647e7a75e2cd_project_constraint_fix.py
│ │ ├── 9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py
│ │ ├── a1b2c3d4e5f6_fix_project_foreign_keys.py
│ │ ├── b3c3938bacdb_relation_to_name_unique_index.py
│ │ ├── cc7172b46608_update_search_index_schema.py
│ │ └── e7e1f4367280_add_scan_watermark_tracking_to_project.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── app.py
│ │ ├── routers
│ │ │ ├── __init__.py
│ │ │ ├── directory_router.py
│ │ │ ├── importer_router.py
│ │ │ ├── knowledge_router.py
│ │ │ ├── management_router.py
│ │ │ ├── memory_router.py
│ │ │ ├── project_router.py
│ │ │ ├── prompt_router.py
│ │ │ ├── resource_router.py
│ │ │ ├── search_router.py
│ │ │ └── utils.py
│ │ └── template_loader.py
│ ├── cli
│ │ ├── __init__.py
│ │ ├── app.py
│ │ ├── auth.py
│ │ ├── commands
│ │ │ ├── __init__.py
│ │ │ ├── cloud
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api_client.py
│ │ │ │ ├── bisync_commands.py
│ │ │ │ ├── cloud_utils.py
│ │ │ │ ├── core_commands.py
│ │ │ │ ├── mount_commands.py
│ │ │ │ ├── rclone_config.py
│ │ │ │ ├── rclone_installer.py
│ │ │ │ ├── upload_command.py
│ │ │ │ └── upload.py
│ │ │ ├── command_utils.py
│ │ │ ├── db.py
│ │ │ ├── import_chatgpt.py
│ │ │ ├── import_claude_conversations.py
│ │ │ ├── import_claude_projects.py
│ │ │ ├── import_memory_json.py
│ │ │ ├── mcp.py
│ │ │ ├── project.py
│ │ │ ├── status.py
│ │ │ ├── sync.py
│ │ │ └── tool.py
│ │ └── main.py
│ ├── config.py
│ ├── db.py
│ ├── deps.py
│ ├── file_utils.py
│ ├── ignore_utils.py
│ ├── importers
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── chatgpt_importer.py
│ │ ├── claude_conversations_importer.py
│ │ ├── claude_projects_importer.py
│ │ ├── memory_json_importer.py
│ │ └── utils.py
│ ├── markdown
│ │ ├── __init__.py
│ │ ├── entity_parser.py
│ │ ├── markdown_processor.py
│ │ ├── plugins.py
│ │ ├── schemas.py
│ │ └── utils.py
│ ├── mcp
│ │ ├── __init__.py
│ │ ├── async_client.py
│ │ ├── project_context.py
│ │ ├── prompts
│ │ │ ├── __init__.py
│ │ │ ├── ai_assistant_guide.py
│ │ │ ├── continue_conversation.py
│ │ │ ├── recent_activity.py
│ │ │ ├── search.py
│ │ │ └── utils.py
│ │ ├── resources
│ │ │ ├── ai_assistant_guide.md
│ │ │ └── project_info.py
│ │ ├── server.py
│ │ └── tools
│ │ ├── __init__.py
│ │ ├── build_context.py
│ │ ├── canvas.py
│ │ ├── chatgpt_tools.py
│ │ ├── delete_note.py
│ │ ├── edit_note.py
│ │ ├── list_directory.py
│ │ ├── move_note.py
│ │ ├── project_management.py
│ │ ├── read_content.py
│ │ ├── read_note.py
│ │ ├── recent_activity.py
│ │ ├── search.py
│ │ ├── utils.py
│ │ ├── view_note.py
│ │ └── write_note.py
│ ├── models
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── knowledge.py
│ │ ├── project.py
│ │ └── search.py
│ ├── repository
│ │ ├── __init__.py
│ │ ├── entity_repository.py
│ │ ├── observation_repository.py
│ │ ├── project_info_repository.py
│ │ ├── project_repository.py
│ │ ├── relation_repository.py
│ │ ├── repository.py
│ │ └── search_repository.py
│ ├── schemas
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── cloud.py
│ │ ├── delete.py
│ │ ├── directory.py
│ │ ├── importer.py
│ │ ├── memory.py
│ │ ├── project_info.py
│ │ ├── prompt.py
│ │ ├── request.py
│ │ ├── response.py
│ │ ├── search.py
│ │ └── sync_report.py
│ ├── services
│ │ ├── __init__.py
│ │ ├── context_service.py
│ │ ├── directory_service.py
│ │ ├── entity_service.py
│ │ ├── exceptions.py
│ │ ├── file_service.py
│ │ ├── initialization.py
│ │ ├── link_resolver.py
│ │ ├── project_service.py
│ │ ├── search_service.py
│ │ └── service.py
│ ├── sync
│ │ ├── __init__.py
│ │ ├── background_sync.py
│ │ ├── sync_service.py
│ │ └── watch_service.py
│ ├── templates
│ │ └── prompts
│ │ ├── continue_conversation.hbs
│ │ └── search.hbs
│ └── utils.py
├── test-int
│ ├── BENCHMARKS.md
│ ├── cli
│ │ ├── test_project_commands_integration.py
│ │ ├── test_sync_commands_integration.py
│ │ └── test_version_integration.py
│ ├── conftest.py
│ ├── mcp
│ │ ├── test_build_context_underscore.py
│ │ ├── test_build_context_validation.py
│ │ ├── test_chatgpt_tools_integration.py
│ │ ├── test_default_project_mode_integration.py
│ │ ├── test_delete_note_integration.py
│ │ ├── test_edit_note_integration.py
│ │ ├── test_list_directory_integration.py
│ │ ├── test_move_note_integration.py
│ │ ├── test_project_management_integration.py
│ │ ├── test_project_state_sync_integration.py
│ │ ├── test_read_content_integration.py
│ │ ├── test_read_note_integration.py
│ │ ├── test_search_integration.py
│ │ ├── test_single_project_mcp_integration.py
│ │ └── test_write_note_integration.py
│ ├── test_db_wal_mode.py
│ ├── test_disable_permalinks_integration.py
│ └── test_sync_performance_benchmark.py
├── tests
│ ├── __init__.py
│ ├── api
│ │ ├── conftest.py
│ │ ├── test_async_client.py
│ │ ├── test_continue_conversation_template.py
│ │ ├── test_directory_router.py
│ │ ├── test_importer_router.py
│ │ ├── test_knowledge_router.py
│ │ ├── test_management_router.py
│ │ ├── test_memory_router.py
│ │ ├── test_project_router_operations.py
│ │ ├── test_project_router.py
│ │ ├── test_prompt_router.py
│ │ ├── test_relation_background_resolution.py
│ │ ├── test_resource_router.py
│ │ ├── test_search_router.py
│ │ ├── test_search_template.py
│ │ ├── test_template_loader_helpers.py
│ │ └── test_template_loader.py
│ ├── cli
│ │ ├── conftest.py
│ │ ├── test_bisync_commands.py
│ │ ├── test_cli_tools.py
│ │ ├── test_cloud_authentication.py
│ │ ├── test_cloud_utils.py
│ │ ├── test_ignore_utils.py
│ │ ├── test_import_chatgpt.py
│ │ ├── test_import_claude_conversations.py
│ │ ├── test_import_claude_projects.py
│ │ ├── test_import_memory_json.py
│ │ └── test_upload.py
│ ├── conftest.py
│ ├── db
│ │ └── test_issue_254_foreign_key_constraints.py
│ ├── importers
│ │ ├── test_importer_base.py
│ │ └── test_importer_utils.py
│ ├── markdown
│ │ ├── __init__.py
│ │ ├── test_date_frontmatter_parsing.py
│ │ ├── test_entity_parser_error_handling.py
│ │ ├── test_entity_parser.py
│ │ ├── test_markdown_plugins.py
│ │ ├── test_markdown_processor.py
│ │ ├── test_observation_edge_cases.py
│ │ ├── test_parser_edge_cases.py
│ │ ├── test_relation_edge_cases.py
│ │ └── test_task_detection.py
│ ├── mcp
│ │ ├── conftest.py
│ │ ├── test_obsidian_yaml_formatting.py
│ │ ├── test_permalink_collision_file_overwrite.py
│ │ ├── test_prompts.py
│ │ ├── test_resources.py
│ │ ├── test_tool_build_context.py
│ │ ├── test_tool_canvas.py
│ │ ├── test_tool_delete_note.py
│ │ ├── test_tool_edit_note.py
│ │ ├── test_tool_list_directory.py
│ │ ├── test_tool_move_note.py
│ │ ├── test_tool_read_content.py
│ │ ├── test_tool_read_note.py
│ │ ├── test_tool_recent_activity.py
│ │ ├── test_tool_resource.py
│ │ ├── test_tool_search.py
│ │ ├── test_tool_utils.py
│ │ ├── test_tool_view_note.py
│ │ ├── test_tool_write_note.py
│ │ └── tools
│ │ └── test_chatgpt_tools.py
│ ├── Non-MarkdownFileSupport.pdf
│ ├── repository
│ │ ├── test_entity_repository_upsert.py
│ │ ├── test_entity_repository.py
│ │ ├── test_entity_upsert_issue_187.py
│ │ ├── test_observation_repository.py
│ │ ├── test_project_info_repository.py
│ │ ├── test_project_repository.py
│ │ ├── test_relation_repository.py
│ │ ├── test_repository.py
│ │ ├── test_search_repository_edit_bug_fix.py
│ │ └── test_search_repository.py
│ ├── schemas
│ │ ├── test_base_timeframe_minimum.py
│ │ ├── test_memory_serialization.py
│ │ ├── test_memory_url_validation.py
│ │ ├── test_memory_url.py
│ │ ├── test_schemas.py
│ │ └── test_search.py
│ ├── Screenshot.png
│ ├── services
│ │ ├── test_context_service.py
│ │ ├── test_directory_service.py
│ │ ├── test_entity_service_disable_permalinks.py
│ │ ├── test_entity_service.py
│ │ ├── test_file_service.py
│ │ ├── test_initialization.py
│ │ ├── test_link_resolver.py
│ │ ├── test_project_removal_bug.py
│ │ ├── test_project_service_operations.py
│ │ ├── test_project_service.py
│ │ └── test_search_service.py
│ ├── sync
│ │ ├── test_character_conflicts.py
│ │ ├── test_sync_service_incremental.py
│ │ ├── test_sync_service.py
│ │ ├── test_sync_wikilink_issue.py
│ │ ├── test_tmp_files.py
│ │ ├── test_watch_service_edge_cases.py
│ │ ├── test_watch_service_reload.py
│ │ └── test_watch_service.py
│ ├── test_config.py
│ ├── test_db_migration_deduplication.py
│ ├── test_deps.py
│ ├── test_production_cascade_delete.py
│ └── utils
│ ├── test_file_utils.py
│ ├── test_frontmatter_obsidian_compatible.py
│ ├── test_parse_tags.py
│ ├── test_permalink_formatting.py
│ ├── test_utf8_handling.py
│ └── test_validate_project_path.py
├── uv.lock
├── v0.15.0-RELEASE-DOCS.md
└── v15-docs
├── api-performance.md
├── background-relations.md
├── basic-memory-home.md
├── bug-fixes.md
├── chatgpt-integration.md
├── cloud-authentication.md
├── cloud-bisync.md
├── cloud-mode-usage.md
├── cloud-mount.md
├── default-project-mode.md
├── env-file-removal.md
├── env-var-overrides.md
├── explicit-project-parameter.md
├── gitignore-integration.md
├── project-root-env-var.md
├── README.md
└── sqlite-performance.md
```
# Files
--------------------------------------------------------------------------------
/tests/services/test_entity_service.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for EntityService."""
2 |
3 | from pathlib import Path
4 | from textwrap import dedent
5 |
6 | import pytest
7 | import yaml
8 |
9 | from basic_memory.config import ProjectConfig, BasicMemoryConfig
10 | from basic_memory.markdown import EntityParser
11 | from basic_memory.models import Entity as EntityModel
12 | from basic_memory.repository import EntityRepository
13 | from basic_memory.schemas import Entity as EntitySchema
14 | from basic_memory.services import FileService
15 | from basic_memory.services.entity_service import EntityService
16 | from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
17 | from basic_memory.services.search_service import SearchService
18 | from basic_memory.utils import generate_permalink
19 |
20 |
21 | @pytest.mark.asyncio
22 | async def test_create_entity(entity_service: EntityService, file_service: FileService):
23 | """Test successful entity creation."""
24 | entity_data = EntitySchema(
25 | title="Test Entity",
26 | folder="",
27 | entity_type="test",
28 | )
29 |
30 | # Act
31 | entity = await entity_service.create_entity(entity_data)
32 |
33 | # Assert Entity
34 | assert isinstance(entity, EntityModel)
35 | assert entity.permalink == entity_data.permalink
36 | assert entity.file_path == entity_data.file_path
37 | assert entity.entity_type == "test"
38 | assert entity.created_at is not None
39 | assert len(entity.relations) == 0
40 |
41 | # Verify we can retrieve it using permalink
42 | retrieved = await entity_service.get_by_permalink(entity_data.permalink)
43 | assert retrieved.title == "Test Entity"
44 | assert retrieved.entity_type == "test"
45 | assert retrieved.created_at is not None
46 |
47 | # Verify file was written
48 | file_path = file_service.get_entity_path(entity)
49 | assert await file_service.exists(file_path)
50 |
51 | file_content, _ = await file_service.read_file(file_path)
52 | _, frontmatter, doc_content = file_content.split("---", 2)
53 | metadata = yaml.safe_load(frontmatter)
54 |
55 | # Verify frontmatter contents
56 | assert metadata["permalink"] == entity.permalink
57 | assert metadata["type"] == entity.entity_type
58 |
59 |
60 | @pytest.mark.asyncio
61 | async def test_create_entity_file_exists(entity_service: EntityService, file_service: FileService):
62 | """Test successful entity creation."""
63 | entity_data = EntitySchema(
64 | title="Test Entity",
65 | folder="",
66 | entity_type="test",
67 | content="first",
68 | )
69 |
70 | # Act
71 | entity = await entity_service.create_entity(entity_data)
72 |
73 | # Verify file was written
74 | file_path = file_service.get_entity_path(entity)
75 | assert await file_service.exists(file_path)
76 |
77 | file_content, _ = await file_service.read_file(file_path)
78 | assert (
79 | "---\ntitle: Test Entity\ntype: test\npermalink: test-entity\n---\n\nfirst" == file_content
80 | )
81 |
82 | entity_data = EntitySchema(
83 | title="Test Entity",
84 | folder="",
85 | entity_type="test",
86 | content="second",
87 | )
88 |
89 | with pytest.raises(EntityCreationError):
90 | await entity_service.create_entity(entity_data)
91 |
92 |
93 | @pytest.mark.asyncio
94 | async def test_create_entity_unique_permalink(
95 | project_config,
96 | entity_service: EntityService,
97 | file_service: FileService,
98 | entity_repository: EntityRepository,
99 | ):
100 | """Test successful entity creation."""
101 | entity_data = EntitySchema(
102 | title="Test Entity",
103 | folder="test",
104 | entity_type="test",
105 | )
106 |
107 | entity = await entity_service.create_entity(entity_data)
108 |
109 | # default permalink
110 | assert entity.permalink == generate_permalink(entity.file_path)
111 |
112 | # move file
113 | file_path = file_service.get_entity_path(entity)
114 | file_path.rename(project_config.home / "new_path.md")
115 | await entity_repository.update(entity.id, {"file_path": "new_path.md"})
116 |
117 | # create again
118 | entity2 = await entity_service.create_entity(entity_data)
119 | assert entity2.permalink == f"{entity.permalink}-1"
120 |
121 | file_path = file_service.get_entity_path(entity2)
122 | file_content, _ = await file_service.read_file(file_path)
123 | _, frontmatter, doc_content = file_content.split("---", 2)
124 | metadata = yaml.safe_load(frontmatter)
125 |
126 | # Verify frontmatter contents
127 | assert metadata["permalink"] == entity2.permalink
128 |
129 |
130 | @pytest.mark.asyncio
131 | async def test_get_by_permalink(entity_service: EntityService):
132 | """Test finding entity by type and name combination."""
133 | entity1_data = EntitySchema(
134 | title="TestEntity1",
135 | folder="test",
136 | entity_type="test",
137 | )
138 | entity1 = await entity_service.create_entity(entity1_data)
139 |
140 | entity2_data = EntitySchema(
141 | title="TestEntity2",
142 | folder="test",
143 | entity_type="test",
144 | )
145 | entity2 = await entity_service.create_entity(entity2_data)
146 |
147 | # Find by type1 and name
148 | found = await entity_service.get_by_permalink(entity1_data.permalink)
149 | assert found is not None
150 | assert found.id == entity1.id
151 | assert found.entity_type == entity1.entity_type
152 |
153 | # Find by type2 and name
154 | found = await entity_service.get_by_permalink(entity2_data.permalink)
155 | assert found is not None
156 | assert found.id == entity2.id
157 | assert found.entity_type == entity2.entity_type
158 |
159 | # Test not found case
160 | with pytest.raises(EntityNotFoundError):
161 | await entity_service.get_by_permalink("nonexistent/test_entity")
162 |
163 |
164 | @pytest.mark.asyncio
165 | async def test_get_entity_success(entity_service: EntityService):
166 | """Test successful entity retrieval."""
167 | entity_data = EntitySchema(
168 | title="TestEntity",
169 | folder="test",
170 | entity_type="test",
171 | )
172 | await entity_service.create_entity(entity_data)
173 |
174 | # Get by permalink
175 | retrieved = await entity_service.get_by_permalink(entity_data.permalink)
176 |
177 | assert isinstance(retrieved, EntityModel)
178 | assert retrieved.title == "TestEntity"
179 | assert retrieved.entity_type == "test"
180 |
181 |
182 | @pytest.mark.asyncio
183 | async def test_delete_entity_success(entity_service: EntityService):
184 | """Test successful entity deletion."""
185 | entity_data = EntitySchema(
186 | title="TestEntity",
187 | folder="test",
188 | entity_type="test",
189 | )
190 | await entity_service.create_entity(entity_data)
191 |
192 | # Act using permalink
193 | result = await entity_service.delete_entity(entity_data.permalink)
194 |
195 | # Assert
196 | assert result is True
197 | with pytest.raises(EntityNotFoundError):
198 | await entity_service.get_by_permalink(entity_data.permalink)
199 |
200 |
201 | @pytest.mark.asyncio
202 | async def test_delete_entity_by_id(entity_service: EntityService):
203 | """Test successful entity deletion."""
204 | entity_data = EntitySchema(
205 | title="TestEntity",
206 | folder="test",
207 | entity_type="test",
208 | )
209 | created = await entity_service.create_entity(entity_data)
210 |
211 | # Act using permalink
212 | result = await entity_service.delete_entity(created.id)
213 |
214 | # Assert
215 | assert result is True
216 | with pytest.raises(EntityNotFoundError):
217 | await entity_service.get_by_permalink(entity_data.permalink)
218 |
219 |
220 | @pytest.mark.asyncio
221 | async def test_get_entity_by_permalink_not_found(entity_service: EntityService):
222 | """Test handling of non-existent entity retrieval."""
223 | with pytest.raises(EntityNotFoundError):
224 | await entity_service.get_by_permalink("test/non_existent")
225 |
226 |
227 | @pytest.mark.asyncio
228 | async def test_delete_nonexistent_entity(entity_service: EntityService):
229 | """Test deleting an entity that doesn't exist."""
230 | assert await entity_service.delete_entity("test/non_existent") is True
231 |
232 |
233 | @pytest.mark.asyncio
234 | async def test_create_entity_with_special_chars(entity_service: EntityService):
235 | """Test entity creation with special characters in name and description."""
236 | name = "TestEntity_$pecial chars & symbols!" # Note: Using valid path characters
237 | entity_data = EntitySchema(
238 | title=name,
239 | folder="test",
240 | entity_type="test",
241 | )
242 | entity = await entity_service.create_entity(entity_data)
243 |
244 | assert entity.title == name
245 |
246 | # Verify after retrieval using permalink
247 | await entity_service.get_by_permalink(entity_data.permalink)
248 |
249 |
250 | @pytest.mark.asyncio
251 | async def test_get_entities_by_permalinks(entity_service: EntityService):
252 | """Test opening multiple entities by path IDs."""
253 | # Create test entities
254 | entity1_data = EntitySchema(
255 | title="Entity1",
256 | folder="test",
257 | entity_type="test",
258 | )
259 | entity2_data = EntitySchema(
260 | title="Entity2",
261 | folder="test",
262 | entity_type="test",
263 | )
264 | await entity_service.create_entity(entity1_data)
265 | await entity_service.create_entity(entity2_data)
266 |
267 | # Open nodes by path IDs
268 | permalinks = [entity1_data.permalink, entity2_data.permalink]
269 | found = await entity_service.get_entities_by_permalinks(permalinks)
270 |
271 | assert len(found) == 2
272 | names = {e.title for e in found}
273 | assert names == {"Entity1", "Entity2"}
274 |
275 |
276 | @pytest.mark.asyncio
277 | async def test_get_entities_empty_input(entity_service: EntityService):
278 | """Test opening nodes with empty path ID list."""
279 | found = await entity_service.get_entities_by_permalinks([])
280 | assert len(found) == 0
281 |
282 |
283 | @pytest.mark.asyncio
284 | async def test_get_entities_some_not_found(entity_service: EntityService):
285 | """Test opening nodes with mix of existing and non-existent path IDs."""
286 | # Create one test entity
287 | entity_data = EntitySchema(
288 | title="Entity1",
289 | folder="test",
290 | entity_type="test",
291 | )
292 | await entity_service.create_entity(entity_data)
293 |
294 | # Try to open two nodes, one exists, one doesn't
295 | permalinks = [entity_data.permalink, "type1/non_existent"]
296 | found = await entity_service.get_entities_by_permalinks(permalinks)
297 |
298 | assert len(found) == 1
299 | assert found[0].title == "Entity1"
300 |
301 |
302 | @pytest.mark.asyncio
303 | async def test_get_entity_path(entity_service: EntityService):
304 | """Should generate correct filesystem path for entity."""
305 | entity = EntityModel(
306 | permalink="test-entity",
307 | file_path="test-entity.md",
308 | entity_type="test",
309 | )
310 | path = entity_service.file_service.get_entity_path(entity)
311 | assert path == Path(entity_service.file_service.base_path / "test-entity.md")
312 |
313 |
314 | @pytest.mark.asyncio
315 | async def test_update_note_entity_content(entity_service: EntityService, file_service: FileService):
316 | """Should update note content directly."""
317 | # Create test entity
318 | schema = EntitySchema(
319 | title="test",
320 | folder="test",
321 | entity_type="note",
322 | entity_metadata={"status": "draft"},
323 | )
324 |
325 | entity = await entity_service.create_entity(schema)
326 | assert entity.entity_metadata.get("status") == "draft"
327 |
328 | # Update content with a relation
329 | schema.content = """
330 | # Updated [[Content]]
331 | - references [[new content]]
332 | - [note] This is new content.
333 | """
334 | updated = await entity_service.update_entity(entity, schema)
335 |
336 | # Verify file has new content but preserved metadata
337 | file_path = file_service.get_entity_path(updated)
338 | content, _ = await file_service.read_file(file_path)
339 |
340 | assert "# Updated [[Content]]" in content
341 | assert "- references [[new content]]" in content
342 | assert "- [note] This is new content" in content
343 |
344 | # Verify metadata was preserved
345 | _, frontmatter, _ = content.split("---", 2)
346 | metadata = yaml.safe_load(frontmatter)
347 | assert metadata.get("status") == "draft"
348 |
349 |
350 | @pytest.mark.asyncio
351 | async def test_create_or_update_new(entity_service: EntityService, file_service: FileService):
352 | """Should create a new entity."""
353 | # Create test entity
354 | entity, created = await entity_service.create_or_update_entity(
355 | EntitySchema(
356 | title="test",
357 | folder="test",
358 | entity_type="test",
359 | entity_metadata={"status": "draft"},
360 | )
361 | )
362 | assert entity.title == "test"
363 | assert created is True
364 |
365 |
366 | @pytest.mark.asyncio
367 | async def test_create_or_update_existing(entity_service: EntityService, file_service: FileService):
368 | """Should update entity name in both DB and frontmatter."""
369 | # Create test entity
370 | entity = await entity_service.create_entity(
371 | EntitySchema(
372 | title="test",
373 | folder="test",
374 | entity_type="test",
375 | content="Test entity",
376 | entity_metadata={"status": "final"},
377 | )
378 | )
379 |
380 | entity.content = "Updated content"
381 |
382 | # Update name
383 | updated, created = await entity_service.create_or_update_entity(entity)
384 |
385 | assert updated.title == "test"
386 | assert updated.entity_metadata["status"] == "final"
387 | assert created is False
388 |
389 |
390 | @pytest.mark.asyncio
391 | async def test_create_with_content(entity_service: EntityService, file_service: FileService):
392 | # contains frontmatter
393 | content = dedent(
394 | """
395 | ---
396 | permalink: git-workflow-guide
397 | ---
398 | # Git Workflow Guide
399 |
400 | A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]].
401 |
402 | ## Best Practices
403 | Use branches effectively:
404 | - [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts)
405 | - implements [[Branch Strategy]] (Our standard workflow)
406 |
407 | ## Common Commands
408 | See the [[Git Cheat Sheet]] for reference.
409 | """
410 | )
411 |
412 | # Create test entity
413 | entity, created = await entity_service.create_or_update_entity(
414 | EntitySchema(
415 | title="Git Workflow Guide",
416 | folder="test",
417 | entity_type="test",
418 | content=content,
419 | )
420 | )
421 |
422 | assert created is True
423 | assert entity.title == "Git Workflow Guide"
424 | assert entity.entity_type == "test"
425 | assert entity.permalink == "git-workflow-guide"
426 | assert entity.file_path == "test/Git Workflow Guide.md"
427 |
428 | assert len(entity.observations) == 1
429 | assert entity.observations[0].category == "design"
430 | assert entity.observations[0].content == "Keep feature branches short-lived #git #workflow"
431 | assert set(entity.observations[0].tags) == {"git", "workflow"}
432 | assert entity.observations[0].context == "Reduces merge conflicts"
433 |
434 | assert len(entity.relations) == 4
435 | assert entity.relations[0].relation_type == "links to"
436 | assert entity.relations[0].to_name == "Git"
437 | assert entity.relations[1].relation_type == "links to"
438 | assert entity.relations[1].to_name == "Trunk Based Development"
439 | assert entity.relations[2].relation_type == "implements"
440 | assert entity.relations[2].to_name == "Branch Strategy"
441 | assert entity.relations[2].context == "Our standard workflow"
442 | assert entity.relations[3].relation_type == "links to"
443 | assert entity.relations[3].to_name == "Git Cheat Sheet"
444 |
445 | # Verify file has new content but preserved metadata
446 | file_path = file_service.get_entity_path(entity)
447 | file_content, _ = await file_service.read_file(file_path)
448 |
449 | # assert file
450 | # note the permalink value is corrected
451 | expected = dedent("""
452 | ---
453 | title: Git Workflow Guide
454 | type: test
455 | permalink: git-workflow-guide
456 | ---
457 |
458 | # Git Workflow Guide
459 |
460 | A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]].
461 |
462 | ## Best Practices
463 | Use branches effectively:
464 | - [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts)
465 | - implements [[Branch Strategy]] (Our standard workflow)
466 |
467 | ## Common Commands
468 | See the [[Git Cheat Sheet]] for reference.
469 |
470 | """).strip()
471 | assert expected == file_content
472 |
473 |
474 | @pytest.mark.asyncio
475 | async def test_update_with_content(entity_service: EntityService, file_service: FileService):
476 | content = """# Git Workflow Guide"""
477 |
478 | # Create test entity
479 | entity, created = await entity_service.create_or_update_entity(
480 | EntitySchema(
481 | title="Git Workflow Guide",
482 | entity_type="test",
483 | folder="test",
484 | content=content,
485 | )
486 | )
487 |
488 | assert created is True
489 | assert entity.title == "Git Workflow Guide"
490 |
491 | assert len(entity.observations) == 0
492 | assert len(entity.relations) == 0
493 |
494 | # Verify file has new content but preserved metadata
495 | file_path = file_service.get_entity_path(entity)
496 | file_content, _ = await file_service.read_file(file_path)
497 |
498 | # assert content is in file
499 | assert (
500 | dedent(
501 | """
502 | ---
503 | title: Git Workflow Guide
504 | type: test
505 | permalink: test/git-workflow-guide
506 | ---
507 |
508 | # Git Workflow Guide
509 | """
510 | ).strip()
511 | == file_content
512 | )
513 |
514 | # now update the content
515 | update_content = dedent(
516 | """
517 | ---
518 | title: Git Workflow Guide
519 | type: test
520 | permalink: git-workflow-guide
521 | ---
522 |
523 | # Git Workflow Guide
524 |
525 | A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]].
526 |
527 | ## Best Practices
528 | Use branches effectively:
529 | - [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts)
530 | - implements [[Branch Strategy]] (Our standard workflow)
531 |
532 | ## Common Commands
533 | See the [[Git Cheat Sheet]] for reference.
534 | """
535 | ).strip()
536 |
537 | # update entity
538 | entity, created = await entity_service.create_or_update_entity(
539 | EntitySchema(
540 | title="Git Workflow Guide",
541 | folder="test",
542 | entity_type="test",
543 | content=update_content,
544 | )
545 | )
546 |
547 | assert created is False
548 | assert entity.title == "Git Workflow Guide"
549 |
550 | # assert custom permalink value
551 | assert entity.permalink == "git-workflow-guide"
552 |
553 | assert len(entity.observations) == 1
554 | assert entity.observations[0].category == "design"
555 | assert entity.observations[0].content == "Keep feature branches short-lived #git #workflow"
556 | assert set(entity.observations[0].tags) == {"git", "workflow"}
557 | assert entity.observations[0].context == "Reduces merge conflicts"
558 |
559 | assert len(entity.relations) == 4
560 | assert entity.relations[0].relation_type == "links to"
561 | assert entity.relations[0].to_name == "Git"
562 | assert entity.relations[1].relation_type == "links to"
563 | assert entity.relations[1].to_name == "Trunk Based Development"
564 | assert entity.relations[2].relation_type == "implements"
565 | assert entity.relations[2].to_name == "Branch Strategy"
566 | assert entity.relations[2].context == "Our standard workflow"
567 | assert entity.relations[3].relation_type == "links to"
568 | assert entity.relations[3].to_name == "Git Cheat Sheet"
569 |
570 | # Verify file has new content but preserved metadata
571 | file_path = file_service.get_entity_path(entity)
572 | file_content, _ = await file_service.read_file(file_path)
573 |
574 | # assert content is in file
575 | assert update_content.strip() == file_content
576 |
577 |
578 | @pytest.mark.asyncio
579 | async def test_create_with_no_frontmatter(
580 | project_config: ProjectConfig,
581 | entity_parser: EntityParser,
582 | entity_service: EntityService,
583 | file_service: FileService,
584 | ):
585 | # contains no frontmatter
586 | content = "# Git Workflow Guide"
587 | file_path = Path("test/Git Workflow Guide.md")
588 | full_path = project_config.home / file_path
589 |
590 | await file_service.write_file(Path(full_path), content)
591 |
592 | entity_markdown = await entity_parser.parse_file(full_path)
593 | created = await entity_service.create_entity_from_markdown(file_path, entity_markdown)
594 | file_content, _ = await file_service.read_file(created.file_path)
595 |
596 | assert file_path.as_posix() == created.file_path
597 | assert created.title == "Git Workflow Guide"
598 | assert created.entity_type == "note"
599 | assert created.permalink is None
600 |
601 | # assert file
602 | expected = dedent("""
603 | # Git Workflow Guide
604 | """).strip()
605 | assert expected == file_content
606 |
607 |
608 | @pytest.mark.asyncio
609 | async def test_edit_entity_append(entity_service: EntityService, file_service: FileService):
610 | """Test appending content to an entity."""
611 | # Create test entity
612 | entity = await entity_service.create_entity(
613 | EntitySchema(
614 | title="Test Note",
615 | folder="test",
616 | entity_type="note",
617 | content="Original content",
618 | )
619 | )
620 |
621 | # Edit entity with append operation
622 | updated = await entity_service.edit_entity(
623 | identifier=entity.permalink, operation="append", content="Appended content"
624 | )
625 |
626 | # Verify content was appended
627 | file_path = file_service.get_entity_path(updated)
628 | file_content, _ = await file_service.read_file(file_path)
629 | assert "Original content" in file_content
630 | assert "Appended content" in file_content
631 | assert file_content.index("Original content") < file_content.index("Appended content")
632 |
633 |
634 | @pytest.mark.asyncio
635 | async def test_edit_entity_prepend(entity_service: EntityService, file_service: FileService):
636 | """Test prepending content to an entity."""
637 | # Create test entity
638 | entity = await entity_service.create_entity(
639 | EntitySchema(
640 | title="Test Note",
641 | folder="test",
642 | entity_type="note",
643 | content="Original content",
644 | )
645 | )
646 |
647 | # Edit entity with prepend operation
648 | updated = await entity_service.edit_entity(
649 | identifier=entity.permalink, operation="prepend", content="Prepended content"
650 | )
651 |
652 | # Verify content was prepended
653 | file_path = file_service.get_entity_path(updated)
654 | file_content, _ = await file_service.read_file(file_path)
655 | assert "Original content" in file_content
656 | assert "Prepended content" in file_content
657 | assert file_content.index("Prepended content") < file_content.index("Original content")
658 |
659 |
660 | @pytest.mark.asyncio
661 | async def test_edit_entity_find_replace(entity_service: EntityService, file_service: FileService):
662 | """Test find and replace operation on an entity."""
663 | # Create test entity with specific content to replace
664 | entity = await entity_service.create_entity(
665 | EntitySchema(
666 | title="Test Note",
667 | folder="test",
668 | entity_type="note",
669 | content="This is old content that needs updating",
670 | )
671 | )
672 |
673 | # Edit entity with find_replace operation
674 | updated = await entity_service.edit_entity(
675 | identifier=entity.permalink,
676 | operation="find_replace",
677 | content="new content",
678 | find_text="old content",
679 | )
680 |
681 | # Verify content was replaced
682 | file_path = file_service.get_entity_path(updated)
683 | file_content, _ = await file_service.read_file(file_path)
684 | assert "old content" not in file_content
685 | assert "This is new content that needs updating" in file_content
686 |
687 |
688 | @pytest.mark.asyncio
689 | async def test_edit_entity_replace_section(
690 | entity_service: EntityService, file_service: FileService
691 | ):
692 | """Test replacing a specific section in an entity."""
693 | # Create test entity with sections
694 | content = dedent("""
695 | # Main Title
696 |
697 | ## Section 1
698 | Original section 1 content
699 |
700 | ## Section 2
701 | Original section 2 content
702 | """).strip()
703 |
704 | entity = await entity_service.create_entity(
705 | EntitySchema(
706 | title="Sample Note",
707 | folder="docs",
708 | entity_type="note",
709 | content=content,
710 | )
711 | )
712 |
713 | # Edit entity with replace_section operation
714 | updated = await entity_service.edit_entity(
715 | identifier=entity.permalink,
716 | operation="replace_section",
717 | content="New section 1 content",
718 | section="## Section 1",
719 | )
720 |
721 | # Verify section was replaced
722 | file_path = file_service.get_entity_path(updated)
723 | file_content, _ = await file_service.read_file(file_path)
724 | assert "New section 1 content" in file_content
725 | assert "Original section 1 content" not in file_content
726 | assert "Original section 2 content" in file_content # Other sections preserved
727 |
728 |
729 | @pytest.mark.asyncio
730 | async def test_edit_entity_replace_section_create_new(
731 | entity_service: EntityService, file_service: FileService
732 | ):
733 | """Test replacing a section that doesn't exist creates it."""
734 | # Create test entity without the section
735 | entity = await entity_service.create_entity(
736 | EntitySchema(
737 | title="Test Note",
738 | folder="test",
739 | entity_type="note",
740 | content="# Main Title\n\nSome content",
741 | )
742 | )
743 |
744 | # Edit entity with replace_section operation for non-existent section
745 | updated = await entity_service.edit_entity(
746 | identifier=entity.permalink,
747 | operation="replace_section",
748 | content="New section content",
749 | section="## New Section",
750 | )
751 |
752 | # Verify section was created
753 | file_path = file_service.get_entity_path(updated)
754 | file_content, _ = await file_service.read_file(file_path)
755 | assert "## New Section" in file_content
756 | assert "New section content" in file_content
757 |
758 |
759 | @pytest.mark.asyncio
760 | async def test_edit_entity_not_found(entity_service: EntityService):
761 | """Test editing a non-existent entity raises error."""
762 | with pytest.raises(EntityNotFoundError):
763 | await entity_service.edit_entity(
764 | identifier="non-existent", operation="append", content="content"
765 | )
766 |
767 |
768 | @pytest.mark.asyncio
769 | async def test_edit_entity_invalid_operation(entity_service: EntityService):
770 | """Test editing with invalid operation raises error."""
771 | # Create test entity
772 | entity = await entity_service.create_entity(
773 | EntitySchema(
774 | title="Test Note",
775 | folder="test",
776 | entity_type="note",
777 | content="Original content",
778 | )
779 | )
780 |
781 | with pytest.raises(ValueError, match="Unsupported operation"):
782 | await entity_service.edit_entity(
783 | identifier=entity.permalink, operation="invalid_operation", content="content"
784 | )
785 |
786 |
787 | @pytest.mark.asyncio
788 | async def test_edit_entity_find_replace_missing_find_text(entity_service: EntityService):
789 | """Test find_replace operation without find_text raises error."""
790 | # Create test entity
791 | entity = await entity_service.create_entity(
792 | EntitySchema(
793 | title="Test Note",
794 | folder="test",
795 | entity_type="note",
796 | content="Original content",
797 | )
798 | )
799 |
800 | with pytest.raises(ValueError, match="find_text is required"):
801 | await entity_service.edit_entity(
802 | identifier=entity.permalink, operation="find_replace", content="new content"
803 | )
804 |
805 |
806 | @pytest.mark.asyncio
807 | async def test_edit_entity_replace_section_missing_section(entity_service: EntityService):
808 | """Test replace_section operation without section parameter raises error."""
809 | # Create test entity
810 | entity = await entity_service.create_entity(
811 | EntitySchema(
812 | title="Test Note",
813 | folder="test",
814 | entity_type="note",
815 | content="Original content",
816 | )
817 | )
818 |
819 | with pytest.raises(ValueError, match="section is required"):
820 | await entity_service.edit_entity(
821 | identifier=entity.permalink, operation="replace_section", content="new content"
822 | )
823 |
824 |
825 | @pytest.mark.asyncio
826 | async def test_edit_entity_with_observations_and_relations(
827 | entity_service: EntityService, file_service: FileService
828 | ):
829 | """Test editing entity updates observations and relations correctly."""
830 | # Create test entity with observations and relations
831 | content = dedent("""
832 | # Test Note
833 |
834 | - [note] This is an observation
835 | - links to [[Other Entity]]
836 |
837 | Original content
838 | """).strip()
839 |
840 | entity = await entity_service.create_entity(
841 | EntitySchema(
842 | title="Sample Note",
843 | folder="docs",
844 | entity_type="note",
845 | content=content,
846 | )
847 | )
848 |
849 | # Verify initial state
850 | assert len(entity.observations) == 1
851 | assert len(entity.relations) == 1
852 |
853 | # Edit entity by appending content with new observations/relations
854 | updated = await entity_service.edit_entity(
855 | identifier=entity.permalink,
856 | operation="append",
857 | content="\n- [category] New observation\n- relates to [[New Entity]]",
858 | )
859 |
860 | # Verify observations and relations were updated
861 | assert len(updated.observations) == 2
862 | assert len(updated.relations) == 2
863 |
864 | # Check new observation
865 | new_obs = [obs for obs in updated.observations if obs.category == "category"][0]
866 | assert new_obs.content == "New observation"
867 |
868 | # Check new relation
869 | new_rel = [rel for rel in updated.relations if rel.to_name == "New Entity"][0]
870 | assert new_rel.relation_type == "relates to"
871 |
872 |
873 | @pytest.mark.asyncio
874 | async def test_create_entity_from_markdown_with_upsert(
875 | entity_service: EntityService, file_service: FileService
876 | ):
877 | """Test that create_entity_from_markdown uses UPSERT approach for conflict resolution."""
878 | file_path = Path("test/upsert-test.md")
879 |
880 | # Create a mock EntityMarkdown object
881 | from basic_memory.markdown.schemas import (
882 | EntityFrontmatter,
883 | EntityMarkdown as RealEntityMarkdown,
884 | )
885 | from datetime import datetime, timezone
886 |
887 | frontmatter = EntityFrontmatter(metadata={"title": "UPSERT Test", "type": "test"})
888 | markdown = RealEntityMarkdown(
889 | frontmatter=frontmatter,
890 | observations=[],
891 | relations=[],
892 | created=datetime.now(timezone.utc),
893 | modified=datetime.now(timezone.utc),
894 | )
895 |
896 | # Call the method - should succeed without complex exception handling
897 | result = await entity_service.create_entity_from_markdown(file_path, markdown)
898 |
899 | # Verify it created the entity successfully using the UPSERT approach
900 | assert result is not None
901 | assert result.title == "UPSERT Test"
902 | assert result.file_path == file_path.as_posix()
903 | # create_entity_from_markdown sets checksum to None (incomplete sync)
904 | assert result.checksum is None
905 |
906 |
907 | @pytest.mark.asyncio
908 | async def test_create_entity_from_markdown_error_handling(
909 | entity_service: EntityService, file_service: FileService
910 | ):
911 | """Test that create_entity_from_markdown handles repository errors gracefully."""
912 | from unittest.mock import patch
913 | from basic_memory.services.exceptions import EntityCreationError
914 |
915 | file_path = Path("test/error-test.md")
916 |
917 | # Create a mock EntityMarkdown object
918 | from basic_memory.markdown.schemas import (
919 | EntityFrontmatter,
920 | EntityMarkdown as RealEntityMarkdown,
921 | )
922 | from datetime import datetime, timezone
923 |
924 | frontmatter = EntityFrontmatter(metadata={"title": "Error Test", "type": "test"})
925 | markdown = RealEntityMarkdown(
926 | frontmatter=frontmatter,
927 | observations=[],
928 | relations=[],
929 | created=datetime.now(timezone.utc),
930 | modified=datetime.now(timezone.utc),
931 | )
932 |
933 | # Mock the repository.upsert_entity to raise a general error
934 | async def mock_upsert(*args, **kwargs):
935 | # Simulate a general database error
936 | raise Exception("Database connection failed")
937 |
938 | with patch.object(entity_service.repository, "upsert_entity", side_effect=mock_upsert):
939 | # Should wrap the error in EntityCreationError
940 | with pytest.raises(EntityCreationError, match="Failed to create entity"):
941 | await entity_service.create_entity_from_markdown(file_path, markdown)
942 |
943 |
944 | # Edge case tests for find_replace operation
945 | @pytest.mark.asyncio
946 | async def test_edit_entity_find_replace_not_found(entity_service: EntityService):
947 | """Test find_replace operation when text is not found."""
948 | # Create test entity
949 | entity = await entity_service.create_entity(
950 | EntitySchema(
951 | title="Test Note",
952 | folder="test",
953 | entity_type="note",
954 | content="This is some content",
955 | )
956 | )
957 |
958 | # Try to replace text that doesn't exist
959 | with pytest.raises(ValueError, match="Text to replace not found: 'nonexistent'"):
960 | await entity_service.edit_entity(
961 | identifier=entity.permalink,
962 | operation="find_replace",
963 | content="new content",
964 | find_text="nonexistent",
965 | )
966 |
967 |
968 | @pytest.mark.asyncio
969 | async def test_edit_entity_find_replace_multiple_occurrences_expected_one(
970 | entity_service: EntityService,
971 | ):
972 | """Test find_replace with multiple occurrences when expecting one."""
973 | # Create entity with repeated text (avoiding "test" since it appears in frontmatter)
974 | entity = await entity_service.create_entity(
975 | EntitySchema(
976 | title="Sample Note",
977 | folder="docs",
978 | entity_type="note",
979 | content="The word banana appears here. Another banana word here.",
980 | )
981 | )
982 |
983 | # Try to replace with expected count of 1 when there are 2
984 | with pytest.raises(ValueError, match="Expected 1 occurrences of 'banana', but found 2"):
985 | await entity_service.edit_entity(
986 | identifier=entity.permalink,
987 | operation="find_replace",
988 | content="replacement",
989 | find_text="banana",
990 | expected_replacements=1,
991 | )
992 |
993 |
994 | @pytest.mark.asyncio
995 | async def test_edit_entity_find_replace_multiple_occurrences_success(
996 | entity_service: EntityService, file_service: FileService
997 | ):
998 | """Test find_replace with multiple occurrences when expected count matches."""
999 | # Create test entity with repeated text (avoiding "test" since it appears in frontmatter)
1000 | entity = await entity_service.create_entity(
1001 | EntitySchema(
1002 | title="Sample Note",
1003 | folder="docs",
1004 | entity_type="note",
1005 | content="The word banana appears here. Another banana word here.",
1006 | )
1007 | )
1008 |
1009 | # Replace with correct expected count
1010 | updated = await entity_service.edit_entity(
1011 | identifier=entity.permalink,
1012 | operation="find_replace",
1013 | content="apple",
1014 | find_text="banana",
1015 | expected_replacements=2,
1016 | )
1017 |
1018 | # Verify both instances were replaced
1019 | file_path = file_service.get_entity_path(updated)
1020 | file_content, _ = await file_service.read_file(file_path)
1021 | assert "The word apple appears here. Another apple word here." in file_content
1022 |
1023 |
1024 | @pytest.mark.asyncio
1025 | async def test_edit_entity_find_replace_empty_find_text(entity_service: EntityService):
1026 | """Test find_replace with empty find_text."""
1027 | # Create test entity
1028 | entity = await entity_service.create_entity(
1029 | EntitySchema(
1030 | title="Test Note",
1031 | folder="test",
1032 | entity_type="note",
1033 | content="Some content",
1034 | )
1035 | )
1036 |
1037 | # Try with empty find_text
1038 | with pytest.raises(ValueError, match="find_text cannot be empty or whitespace only"):
1039 | await entity_service.edit_entity(
1040 | identifier=entity.permalink,
1041 | operation="find_replace",
1042 | content="new content",
1043 | find_text=" ", # whitespace only
1044 | )
1045 |
1046 |
1047 | @pytest.mark.asyncio
1048 | async def test_edit_entity_find_replace_multiline(
1049 | entity_service: EntityService, file_service: FileService
1050 | ):
1051 | """Test find_replace with multiline text."""
1052 | # Create test entity with multiline content
1053 | content = dedent("""
1054 | # Title
1055 |
1056 | This is a paragraph
1057 | that spans multiple lines
1058 | and needs replacement.
1059 |
1060 | Other content.
1061 | """).strip()
1062 |
1063 | entity = await entity_service.create_entity(
1064 | EntitySchema(
1065 | title="Sample Note",
1066 | folder="docs",
1067 | entity_type="note",
1068 | content=content,
1069 | )
1070 | )
1071 |
1072 | # Replace multiline text
1073 | find_text = "This is a paragraph\nthat spans multiple lines\nand needs replacement."
1074 | new_text = "This is new content\nthat replaces the old paragraph."
1075 |
1076 | updated = await entity_service.edit_entity(
1077 | identifier=entity.permalink, operation="find_replace", content=new_text, find_text=find_text
1078 | )
1079 |
1080 | # Verify replacement worked
1081 | file_path = file_service.get_entity_path(updated)
1082 | file_content, _ = await file_service.read_file(file_path)
1083 | assert "This is new content\nthat replaces the old paragraph." in file_content
1084 | assert "Other content." in file_content # Make sure rest is preserved
1085 |
1086 |
1087 | # Edge case tests for replace_section operation
1088 | @pytest.mark.asyncio
1089 | async def test_edit_entity_replace_section_multiple_sections_error(entity_service: EntityService):
1090 | """Test replace_section with multiple sections having same header."""
1091 | # Create test entity with duplicate section headers
1092 | content = dedent("""
1093 | # Main Title
1094 |
1095 | ## Section 1
1096 | First instance content
1097 |
1098 | ## Section 2
1099 | Some content
1100 |
1101 | ## Section 1
1102 | Second instance content
1103 | """).strip()
1104 |
1105 | entity = await entity_service.create_entity(
1106 | EntitySchema(
1107 | title="Sample Note",
1108 | folder="docs",
1109 | entity_type="note",
1110 | content=content,
1111 | )
1112 | )
1113 |
1114 | # Try to replace section when multiple exist
1115 | with pytest.raises(ValueError, match="Multiple sections found with header '## Section 1'"):
1116 | await entity_service.edit_entity(
1117 | identifier=entity.permalink,
1118 | operation="replace_section",
1119 | content="New content",
1120 | section="## Section 1",
1121 | )
1122 |
1123 |
1124 | @pytest.mark.asyncio
1125 | async def test_edit_entity_replace_section_empty_section(entity_service: EntityService):
1126 | """Test replace_section with empty section parameter."""
1127 | # Create test entity
1128 | entity = await entity_service.create_entity(
1129 | EntitySchema(
1130 | title="Test Note",
1131 | folder="test",
1132 | entity_type="note",
1133 | content="Some content",
1134 | )
1135 | )
1136 |
1137 | # Try with empty section
1138 | with pytest.raises(ValueError, match="section cannot be empty or whitespace only"):
1139 | await entity_service.edit_entity(
1140 | identifier=entity.permalink,
1141 | operation="replace_section",
1142 | content="new content",
1143 | section=" ", # whitespace only
1144 | )
1145 |
1146 |
1147 | @pytest.mark.asyncio
1148 | async def test_edit_entity_replace_section_header_variations(
1149 | entity_service: EntityService, file_service: FileService
1150 | ):
1151 | """Test replace_section with different header formatting."""
1152 | # Create entity with various header formats (avoiding "test" in frontmatter)
1153 | content = dedent("""
1154 | # Main Title
1155 |
1156 | ## Section Name
1157 | Original content
1158 |
1159 | ### Subsection
1160 | Sub content
1161 | """).strip()
1162 |
1163 | entity = await entity_service.create_entity(
1164 | EntitySchema(
1165 | title="Sample Note",
1166 | folder="docs",
1167 | entity_type="note",
1168 | content=content,
1169 | )
1170 | )
1171 |
1172 | # Test replacing with different header format (no ##)
1173 | updated = await entity_service.edit_entity(
1174 | identifier=entity.permalink,
1175 | operation="replace_section",
1176 | content="New section content",
1177 | section="Section Name", # No ## prefix
1178 | )
1179 |
1180 | # Verify replacement worked
1181 | file_path = file_service.get_entity_path(updated)
1182 | file_content, _ = await file_service.read_file(file_path)
1183 | assert "New section content" in file_content
1184 | assert "Original content" not in file_content
1185 | assert "### Subsection" in file_content # Subsection preserved
1186 |
1187 |
1188 | @pytest.mark.asyncio
1189 | async def test_edit_entity_replace_section_at_end_of_document(
1190 | entity_service: EntityService, file_service: FileService
1191 | ):
1192 | """Test replace_section when section is at the end of document."""
1193 | # Create test entity with section at end
1194 | content = dedent("""
1195 | # Main Title
1196 |
1197 | ## First Section
1198 | First content
1199 |
1200 | ## Last Section
1201 | Last section content""").strip() # No trailing newline
1202 |
1203 | entity = await entity_service.create_entity(
1204 | EntitySchema(
1205 | title="Sample Note",
1206 | folder="docs",
1207 | entity_type="note",
1208 | content=content,
1209 | )
1210 | )
1211 |
1212 | # Replace the last section
1213 | updated = await entity_service.edit_entity(
1214 | identifier=entity.permalink,
1215 | operation="replace_section",
1216 | content="New last section content",
1217 | section="## Last Section",
1218 | )
1219 |
1220 | # Verify replacement worked
1221 | file_path = file_service.get_entity_path(updated)
1222 | file_content, _ = await file_service.read_file(file_path)
1223 | assert "New last section content" in file_content
1224 | assert "Last section content" not in file_content
1225 | assert "First content" in file_content # Previous section preserved
1226 |
1227 |
1228 | @pytest.mark.asyncio
1229 | async def test_edit_entity_replace_section_with_subsections(
1230 | entity_service: EntityService, file_service: FileService
1231 | ):
1232 | """Test replace_section preserves subsections (stops at any header)."""
1233 | # Create test entity with nested sections
1234 | content = dedent("""
1235 | # Main Title
1236 |
1237 | ## Parent Section
1238 | Parent content
1239 |
1240 | ### Child Section 1
1241 | Child 1 content
1242 |
1243 | ### Child Section 2
1244 | Child 2 content
1245 |
1246 | ## Another Section
1247 | Other content
1248 | """).strip()
1249 |
1250 | entity = await entity_service.create_entity(
1251 | EntitySchema(
1252 | title="Sample Note",
1253 | folder="docs",
1254 | entity_type="note",
1255 | content=content,
1256 | )
1257 | )
1258 |
1259 | # Replace parent section (should only replace content until first subsection)
1260 | updated = await entity_service.edit_entity(
1261 | identifier=entity.permalink,
1262 | operation="replace_section",
1263 | content="New parent content",
1264 | section="## Parent Section",
1265 | )
1266 |
1267 | # Verify replacement worked - only immediate content replaced, subsections preserved
1268 | file_path = file_service.get_entity_path(updated)
1269 | file_content, _ = await file_service.read_file(file_path)
1270 | assert "New parent content" in file_content
1271 | assert "Parent content" not in file_content # Original content replaced
1272 | assert "Child 1 content" in file_content # Child sections preserved
1273 | assert "Child 2 content" in file_content # Child sections preserved
1274 | assert "## Another Section" in file_content # Next section preserved
1275 | assert "Other content" in file_content
1276 |
1277 |
1278 | # Move entity tests
1279 | @pytest.mark.asyncio
1280 | async def test_move_entity_success(
1281 | entity_service: EntityService,
1282 | file_service: FileService,
1283 | project_config: ProjectConfig,
1284 | ):
1285 | """Test successful entity move with basic settings."""
1286 | # Create test entity
1287 | entity = await entity_service.create_entity(
1288 | EntitySchema(
1289 | title="Test Note",
1290 | folder="original",
1291 | entity_type="note",
1292 | content="Original content",
1293 | )
1294 | )
1295 |
1296 | # Verify original file exists
1297 | original_path = file_service.get_entity_path(entity)
1298 | assert await file_service.exists(original_path)
1299 |
1300 | # Create app config with permalinks disabled
1301 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1302 |
1303 | # Move entity
1304 | assert entity.permalink == "original/test-note"
1305 | await entity_service.move_entity(
1306 | identifier=entity.permalink,
1307 | destination_path="moved/test-note.md",
1308 | project_config=project_config,
1309 | app_config=app_config,
1310 | )
1311 |
1312 | # Verify original file no longer exists
1313 | assert not await file_service.exists(original_path)
1314 |
1315 | # Verify new file exists
1316 | new_path = project_config.home / "moved/test-note.md"
1317 | assert new_path.exists()
1318 |
1319 | # Verify database was updated
1320 | updated_entity = await entity_service.get_by_permalink(entity.permalink)
1321 | assert updated_entity.file_path == "moved/test-note.md"
1322 |
1323 | # Verify file content is preserved
1324 | new_content, _ = await file_service.read_file("moved/test-note.md")
1325 | assert "Original content" in new_content
1326 |
1327 |
1328 | @pytest.mark.asyncio
1329 | async def test_move_entity_with_permalink_update(
1330 | entity_service: EntityService,
1331 | file_service: FileService,
1332 | project_config: ProjectConfig,
1333 | ):
1334 | """Test entity move with permalink updates enabled."""
1335 | # Create test entity
1336 | entity = await entity_service.create_entity(
1337 | EntitySchema(
1338 | title="Test Note",
1339 | folder="original",
1340 | entity_type="note",
1341 | content="Original content",
1342 | )
1343 | )
1344 |
1345 | original_permalink = entity.permalink
1346 |
1347 | # Create app config with permalinks enabled
1348 | app_config = BasicMemoryConfig(update_permalinks_on_move=True)
1349 |
1350 | # Move entity
1351 | await entity_service.move_entity(
1352 | identifier=entity.permalink,
1353 | destination_path="moved/test-note.md",
1354 | project_config=project_config,
1355 | app_config=app_config,
1356 | )
1357 |
1358 | # Verify entity was found by new path (since permalink changed)
1359 | moved_entity = await entity_service.link_resolver.resolve_link("moved/test-note.md")
1360 | assert moved_entity is not None
1361 | assert moved_entity.file_path == "moved/test-note.md"
1362 | assert moved_entity.permalink != original_permalink
1363 |
1364 | # Verify frontmatter was updated with new permalink
1365 | new_content, _ = await file_service.read_file("moved/test-note.md")
1366 | assert moved_entity.permalink in new_content
1367 |
1368 |
1369 | @pytest.mark.asyncio
1370 | async def test_move_entity_creates_destination_directory(
1371 | entity_service: EntityService,
1372 | file_service: FileService,
1373 | project_config: ProjectConfig,
1374 | ):
1375 | """Test that moving creates destination directory if it doesn't exist."""
1376 | # Create test entity
1377 | entity = await entity_service.create_entity(
1378 | EntitySchema(
1379 | title="Test Note",
1380 | folder="original",
1381 | entity_type="note",
1382 | content="Original content",
1383 | )
1384 | )
1385 |
1386 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1387 |
1388 | # Move to deeply nested path that doesn't exist
1389 | await entity_service.move_entity(
1390 | identifier=entity.permalink,
1391 | destination_path="deeply/nested/folders/test-note.md",
1392 | project_config=project_config,
1393 | app_config=app_config,
1394 | )
1395 |
1396 | # Verify directory was created
1397 | new_path = project_config.home / "deeply/nested/folders/test-note.md"
1398 | assert new_path.exists()
1399 | assert new_path.parent.exists()
1400 |
1401 |
1402 | @pytest.mark.asyncio
1403 | async def test_move_entity_not_found(
1404 | entity_service: EntityService,
1405 | project_config: ProjectConfig,
1406 | ):
1407 | """Test moving non-existent entity raises error."""
1408 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1409 |
1410 | with pytest.raises(EntityNotFoundError, match="Entity not found: non-existent"):
1411 | await entity_service.move_entity(
1412 | identifier="non-existent",
1413 | destination_path="new/path.md",
1414 | project_config=project_config,
1415 | app_config=app_config,
1416 | )
1417 |
1418 |
1419 | @pytest.mark.asyncio
1420 | async def test_move_entity_source_file_missing(
1421 | entity_service: EntityService,
1422 | file_service: FileService,
1423 | project_config: ProjectConfig,
1424 | ):
1425 | """Test moving when source file doesn't exist on filesystem."""
1426 | # Create test entity
1427 | entity = await entity_service.create_entity(
1428 | EntitySchema(
1429 | title="Test Note",
1430 | folder="test",
1431 | entity_type="note",
1432 | content="Original content",
1433 | )
1434 | )
1435 |
1436 | # Manually delete the file (simulating corruption/external deletion)
1437 | file_path = file_service.get_entity_path(entity)
1438 | file_path.unlink()
1439 |
1440 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1441 |
1442 | with pytest.raises(ValueError, match="Source file not found:"):
1443 | await entity_service.move_entity(
1444 | identifier=entity.permalink,
1445 | destination_path="new/path.md",
1446 | project_config=project_config,
1447 | app_config=app_config,
1448 | )
1449 |
1450 |
1451 | @pytest.mark.asyncio
1452 | async def test_move_entity_destination_exists(
1453 | entity_service: EntityService,
1454 | file_service: FileService,
1455 | project_config: ProjectConfig,
1456 | ):
1457 | """Test moving to existing destination fails."""
1458 | # Create two test entities
1459 | entity1 = await entity_service.create_entity(
1460 | EntitySchema(
1461 | title="Test Note 1",
1462 | folder="test",
1463 | entity_type="note",
1464 | content="Content 1",
1465 | )
1466 | )
1467 |
1468 | entity2 = await entity_service.create_entity(
1469 | EntitySchema(
1470 | title="Test Note 2",
1471 | folder="test",
1472 | entity_type="note",
1473 | content="Content 2",
1474 | )
1475 | )
1476 |
1477 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1478 |
1479 | # Try to move entity1 to entity2's location
1480 | with pytest.raises(ValueError, match="Destination already exists:"):
1481 | await entity_service.move_entity(
1482 | identifier=entity1.permalink,
1483 | destination_path=entity2.file_path,
1484 | project_config=project_config,
1485 | app_config=app_config,
1486 | )
1487 |
1488 |
1489 | @pytest.mark.asyncio
1490 | async def test_move_entity_invalid_destination_path(
1491 | entity_service: EntityService,
1492 | project_config: ProjectConfig,
1493 | ):
1494 | """Test moving with invalid destination paths."""
1495 | # Create test entity
1496 | entity = await entity_service.create_entity(
1497 | EntitySchema(
1498 | title="Test Note",
1499 | folder="test",
1500 | entity_type="note",
1501 | content="Original content",
1502 | )
1503 | )
1504 |
1505 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1506 |
1507 | # Test absolute path
1508 | with pytest.raises(ValueError, match="Invalid destination path:"):
1509 | await entity_service.move_entity(
1510 | identifier=entity.permalink,
1511 | destination_path="/absolute/path.md",
1512 | project_config=project_config,
1513 | app_config=app_config,
1514 | )
1515 |
1516 | # Test empty path
1517 | with pytest.raises(ValueError, match="Invalid destination path:"):
1518 | await entity_service.move_entity(
1519 | identifier=entity.permalink,
1520 | destination_path="",
1521 | project_config=project_config,
1522 | app_config=app_config,
1523 | )
1524 |
1525 |
1526 | @pytest.mark.asyncio
1527 | async def test_move_entity_by_title(
1528 | entity_service: EntityService,
1529 | file_service: FileService,
1530 | project_config: ProjectConfig,
1531 | app_config: BasicMemoryConfig,
1532 | ):
1533 | """Test moving entity by title instead of permalink."""
1534 | # Create test entity
1535 | entity = await entity_service.create_entity(
1536 | EntitySchema(
1537 | title="Test Note",
1538 | folder="original",
1539 | entity_type="note",
1540 | content="Original content",
1541 | )
1542 | )
1543 |
1544 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1545 |
1546 | # Move by title
1547 | await entity_service.move_entity(
1548 | identifier="Test Note", # Use title instead of permalink
1549 | destination_path="moved/test-note.md",
1550 | project_config=project_config,
1551 | app_config=app_config,
1552 | )
1553 |
1554 | # Verify old path no longer exists
1555 | new_path = project_config.home / entity.file_path
1556 | assert not new_path.exists()
1557 |
1558 | # Verify new file exists
1559 | new_path = project_config.home / "moved/test-note.md"
1560 | assert new_path.exists()
1561 |
1562 |
1563 | @pytest.mark.asyncio
1564 | async def test_move_entity_preserves_observations_and_relations(
1565 | entity_service: EntityService,
1566 | file_service: FileService,
1567 | project_config: ProjectConfig,
1568 | ):
1569 | """Test that moving preserves entity observations and relations."""
1570 | # Create test entity with observations and relations
1571 | content = dedent("""
1572 | # Test Note
1573 |
1574 | - [note] This is an observation #test
1575 | - links to [[Other Entity]]
1576 |
1577 | Original content
1578 | """).strip()
1579 |
1580 | entity = await entity_service.create_entity(
1581 | EntitySchema(
1582 | title="Test Note",
1583 | folder="original",
1584 | entity_type="note",
1585 | content=content,
1586 | )
1587 | )
1588 |
1589 | # Verify initial observations and relations
1590 | assert len(entity.observations) == 1
1591 | assert len(entity.relations) == 1
1592 |
1593 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1594 |
1595 | # Move entity
1596 | await entity_service.move_entity(
1597 | identifier=entity.permalink,
1598 | destination_path="moved/test-note.md",
1599 | project_config=project_config,
1600 | app_config=app_config,
1601 | )
1602 |
1603 | # Get moved entity
1604 | moved_entity = await entity_service.link_resolver.resolve_link("moved/test-note.md")
1605 |
1606 | # Verify observations and relations are preserved
1607 | assert len(moved_entity.observations) == 1
1608 | assert moved_entity.observations[0].content == "This is an observation #test"
1609 | assert len(moved_entity.relations) == 1
1610 | assert moved_entity.relations[0].to_name == "Other Entity"
1611 |
1612 | # Verify file content includes observations and relations
1613 | new_content, _ = await file_service.read_file("moved/test-note.md")
1614 | assert "- [note] This is an observation #test" in new_content
1615 | assert "- links to [[Other Entity]]" in new_content
1616 |
1617 |
1618 | @pytest.mark.asyncio
1619 | async def test_move_entity_rollback_on_database_failure(
1620 | entity_service: EntityService,
1621 | file_service: FileService,
1622 | project_config: ProjectConfig,
1623 | entity_repository: EntityRepository,
1624 | ):
1625 | """Test that filesystem changes are rolled back on database failures."""
1626 | # Create test entity
1627 | entity = await entity_service.create_entity(
1628 | EntitySchema(
1629 | title="Test Note",
1630 | folder="original",
1631 | entity_type="note",
1632 | content="Original content",
1633 | )
1634 | )
1635 |
1636 | original_path = file_service.get_entity_path(entity)
1637 | assert await file_service.exists(original_path)
1638 |
1639 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1640 |
1641 | # Mock repository update to fail
1642 | original_update = entity_repository.update
1643 |
1644 | async def failing_update(*args, **kwargs):
1645 | return None # Simulate failure
1646 |
1647 | entity_repository.update = failing_update
1648 |
1649 | try:
1650 | with pytest.raises(ValueError, match="Move failed:"):
1651 | await entity_service.move_entity(
1652 | identifier=entity.permalink,
1653 | destination_path="moved/test-note.md",
1654 | project_config=project_config,
1655 | app_config=app_config,
1656 | )
1657 |
1658 | # Verify rollback - original file should still exist
1659 | assert await file_service.exists(original_path)
1660 |
1661 | # Verify destination file was cleaned up
1662 | destination_path = project_config.home / "moved/test-note.md"
1663 | assert not destination_path.exists()
1664 |
1665 | finally:
1666 | # Restore original update method
1667 | entity_repository.update = original_update
1668 |
1669 |
1670 | @pytest.mark.asyncio
1671 | async def test_move_entity_with_complex_observations(
1672 | entity_service: EntityService,
1673 | file_service: FileService,
1674 | project_config: ProjectConfig,
1675 | ):
1676 | """Test moving entity with complex observations (tags, context)."""
1677 | content = dedent("""
1678 | # Complex Note
1679 |
1680 | - [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts)
1681 | - [tech] Using SQLite for storage #implementation (Fast and reliable)
1682 | - implements [[Branch Strategy]] (Our standard workflow)
1683 |
1684 | Complex content with [[Multiple]] [[Links]].
1685 | """).strip()
1686 |
1687 | entity = await entity_service.create_entity(
1688 | EntitySchema(
1689 | title="Complex Note",
1690 | folder="docs",
1691 | entity_type="note",
1692 | content=content,
1693 | )
1694 | )
1695 |
1696 | # Verify complex structure
1697 | assert len(entity.observations) == 2
1698 | assert len(entity.relations) == 3 # 1 explicit + 2 wikilinks
1699 |
1700 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1701 |
1702 | # Move entity
1703 | await entity_service.move_entity(
1704 | identifier=entity.permalink,
1705 | destination_path="moved/complex-note.md",
1706 | project_config=project_config,
1707 | app_config=app_config,
1708 | )
1709 |
1710 | # Verify moved entity maintains structure
1711 | moved_entity = await entity_service.link_resolver.resolve_link("moved/complex-note.md")
1712 |
1713 | # Check observations with tags and context
1714 | design_obs = [obs for obs in moved_entity.observations if obs.category == "design"][0]
1715 | assert "git" in design_obs.tags
1716 | assert "workflow" in design_obs.tags
1717 | assert design_obs.context == "Reduces merge conflicts"
1718 |
1719 | tech_obs = [obs for obs in moved_entity.observations if obs.category == "tech"][0]
1720 | assert "implementation" in tech_obs.tags
1721 | assert tech_obs.context == "Fast and reliable"
1722 |
1723 | # Check relations
1724 | relation_types = {rel.relation_type for rel in moved_entity.relations}
1725 | assert "implements" in relation_types
1726 | assert "links to" in relation_types
1727 |
1728 | relation_targets = {rel.to_name for rel in moved_entity.relations}
1729 | assert "Branch Strategy" in relation_targets
1730 | assert "Multiple" in relation_targets
1731 | assert "Links" in relation_targets
1732 |
1733 |
1734 | @pytest.mark.asyncio
1735 | async def test_move_entity_with_null_permalink_generates_permalink(
1736 | entity_service: EntityService,
1737 | project_config: ProjectConfig,
1738 | entity_repository: EntityRepository,
1739 | ):
1740 | """Test that moving entity with null permalink generates a new permalink automatically.
1741 |
1742 | This tests the fix for issue #155 where entities with null permalinks from the database
1743 | migration would fail validation when being moved. The fix ensures that entities with
1744 | null permalinks get a generated permalink during move operations, regardless of the
1745 | update_permalinks_on_move setting.
1746 | """
1747 | # Create entity through direct database insertion to simulate migrated entity with null permalink
1748 | from datetime import datetime, timezone
1749 |
1750 | # Create an entity with null permalink directly in database (simulating migrated data)
1751 | entity_data = {
1752 | "title": "Test Entity",
1753 | "file_path": "test/null-permalink-entity.md",
1754 | "entity_type": "note",
1755 | "content_type": "text/markdown",
1756 | "permalink": None, # This is the key - null permalink from migration
1757 | "created_at": datetime.now(timezone.utc),
1758 | "updated_at": datetime.now(timezone.utc),
1759 | }
1760 |
1761 | # Create the entity directly in database
1762 | created_entity = await entity_repository.create(entity_data)
1763 | assert created_entity.permalink is None
1764 |
1765 | # Create the physical file
1766 | file_path = project_config.home / created_entity.file_path
1767 | file_path.parent.mkdir(parents=True, exist_ok=True)
1768 | file_path.write_text("# Test Entity\n\nContent here.")
1769 |
1770 | # Configure move without permalink updates (the default setting that previously triggered the bug)
1771 | app_config = BasicMemoryConfig(update_permalinks_on_move=False)
1772 |
1773 | # Move entity - this should now succeed and generate a permalink
1774 | moved_entity = await entity_service.move_entity(
1775 | identifier=created_entity.title, # Use title since permalink is None
1776 | destination_path="moved/test-entity.md",
1777 | project_config=project_config,
1778 | app_config=app_config,
1779 | )
1780 |
1781 | # Verify the move succeeded and a permalink was generated
1782 | assert moved_entity is not None
1783 | assert moved_entity.file_path == "moved/test-entity.md"
1784 | assert moved_entity.permalink is not None
1785 | assert moved_entity.permalink != ""
1786 |
1787 | # Verify the moved entity can be used to create an EntityResponse without validation errors
1788 | from basic_memory.schemas.response import EntityResponse
1789 |
1790 | response = EntityResponse.model_validate(moved_entity)
1791 | assert response.permalink == moved_entity.permalink
1792 |
1793 | # Verify the physical file was moved
1794 | old_path = project_config.home / "test/null-permalink-entity.md"
1795 | new_path = project_config.home / "moved/test-entity.md"
1796 | assert not old_path.exists()
1797 | assert new_path.exists()
1798 |
1799 |
1800 | @pytest.mark.asyncio
1801 | async def test_create_or_update_entity_fuzzy_search_bug(
1802 | entity_service: EntityService,
1803 | file_service: FileService,
1804 | project_config: ProjectConfig,
1805 | search_service: SearchService,
1806 | ):
1807 | """Test that create_or_update_entity doesn't incorrectly match similar entities via fuzzy search.
1808 |
1809 | This reproduces the critical bug where creating "Node C" overwrote "Node A.md"
1810 | because fuzzy search incorrectly matched the similar file paths.
1811 |
1812 | Root cause: link_resolver.resolve_link() uses fuzzy search fallback which matches
1813 | "edge-cases/Node C.md" to existing "edge-cases/Node A.md" because they share
1814 | similar words ("edge-cases", "Node").
1815 |
1816 | Expected: Create new entity "Node C" with its own file
1817 | Actual Bug: Updates existing "Node A" entity, overwriting its file
1818 | """
1819 | # Step 1: Create first entity "Node A"
1820 | entity_a = EntitySchema(
1821 | title="Node A",
1822 | folder="edge-cases",
1823 | entity_type="note",
1824 | content="# Node A\n\nOriginal content for Node A",
1825 | )
1826 |
1827 | created_a, is_new_a = await entity_service.create_or_update_entity(entity_a)
1828 | assert is_new_a is True, "Node A should be created as new entity"
1829 | assert created_a.title == "Node A"
1830 | assert created_a.file_path == "edge-cases/Node A.md"
1831 |
1832 | # CRITICAL: Index Node A in search to enable fuzzy search fallback
1833 | # This is what triggers the bug - without indexing, fuzzy search returns no results
1834 | await search_service.index_entity(created_a)
1835 |
1836 | # Verify Node A file exists with correct content
1837 | file_a = project_config.home / "edge-cases" / "Node A.md"
1838 | assert file_a.exists(), "Node A.md file should exist"
1839 | content_a = file_a.read_text()
1840 | assert "Node A" in content_a
1841 | assert "Original content for Node A" in content_a
1842 |
1843 | # Step 2: Create Node B to match live test scenario
1844 | entity_b = EntitySchema(
1845 | title="Node B",
1846 | folder="edge-cases",
1847 | entity_type="note",
1848 | content="# Node B\n\nContent for Node B",
1849 | )
1850 |
1851 | created_b, is_new_b = await entity_service.create_or_update_entity(entity_b)
1852 | assert is_new_b is True
1853 | await search_service.index_entity(created_b)
1854 |
1855 | # Step 3: Create Node C - this is where the bug occurs in live testing
1856 | # BUG: This will incorrectly match Node A via fuzzy search
1857 | entity_c = EntitySchema(
1858 | title="Node C",
1859 | folder="edge-cases",
1860 | entity_type="note",
1861 | content="# Node C\n\nContent for Node C",
1862 | )
1863 |
1864 | created_c, is_new_c = await entity_service.create_or_update_entity(entity_c)
1865 |
1866 | # CRITICAL ASSERTIONS: Node C should be created as NEW entity, not update Node A
1867 | assert is_new_c is True, "Node C should be created as NEW entity, not update existing"
1868 | assert created_c.title == "Node C", "Created entity should have title 'Node C'"
1869 | assert created_c.file_path == "edge-cases/Node C.md", "Should create Node C.md file"
1870 | assert created_c.id != created_a.id, "Node C should have different ID than Node A"
1871 |
1872 | # Verify both files exist with correct content
1873 | file_c = project_config.home / "edge-cases" / "Node C.md"
1874 | assert file_c.exists(), "Node C.md file should exist as separate file"
1875 |
1876 | # Re-read Node A file to ensure it wasn't overwritten
1877 | content_a_after = file_a.read_text()
1878 | assert "title: Node A" in content_a_after, "Node A.md should still have Node A title"
1879 | assert "Original content for Node A" in content_a_after, (
1880 | "Node A.md should NOT be overwritten with Node C content"
1881 | )
1882 | assert "Content for Node C" not in content_a_after, (
1883 | "Node A.md should not contain Node C content"
1884 | )
1885 |
1886 | # Verify Node C file has correct content
1887 | content_c = file_c.read_text()
1888 | assert "title: Node C" in content_c, "Node C.md should have Node C title"
1889 | assert "Content for Node C" in content_c, "Node C.md should have Node C content"
1890 | assert "Original content for Node A" not in content_c, (
1891 | "Node C.md should not contain Node A content"
1892 | )
1893 |
```
--------------------------------------------------------------------------------
/tests/sync/test_sync_service.py:
--------------------------------------------------------------------------------
```python
1 | """Test general sync behavior."""
2 |
3 | import asyncio
4 | import os
5 | from datetime import datetime, timezone
6 | from pathlib import Path
7 | from textwrap import dedent
8 |
9 | import pytest
10 |
11 | from basic_memory.config import ProjectConfig, BasicMemoryConfig
12 | from basic_memory.models import Entity
13 | from basic_memory.repository import EntityRepository
14 | from basic_memory.schemas.search import SearchQuery
15 | from basic_memory.services import EntityService, FileService
16 | from basic_memory.services.search_service import SearchService
17 | from basic_memory.sync.sync_service import SyncService
18 |
19 |
20 | async def create_test_file(path: Path, content: str = "test content") -> None:
21 | """Create a test file with given content."""
22 | path.parent.mkdir(parents=True, exist_ok=True)
23 | path.write_text(content)
24 |
25 |
26 | async def touch_file(path: Path) -> None:
27 | """Touch a file to update its mtime (for watermark testing)."""
28 | import time
29 |
30 | # Read and rewrite to update mtime
31 | content = path.read_text()
32 | time.sleep(0.5) # Ensure mtime changes and is newer than watermark (500ms)
33 | path.write_text(content)
34 |
35 |
36 | async def force_full_scan(sync_service: SyncService) -> None:
37 | """Force next sync to do a full scan by clearing watermark (for testing moves/deletions)."""
38 | if sync_service.entity_repository.project_id is not None:
39 | project = await sync_service.project_repository.find_by_id(
40 | sync_service.entity_repository.project_id
41 | )
42 | if project:
43 | await sync_service.project_repository.update(
44 | project.id,
45 | {
46 | "last_scan_timestamp": None,
47 | "last_file_count": None,
48 | },
49 | )
50 |
51 |
52 | @pytest.mark.asyncio
53 | async def test_forward_reference_resolution(
54 | sync_service: SyncService,
55 | project_config: ProjectConfig,
56 | entity_service: EntityService,
57 | ):
58 | """Test that forward references get resolved when target file is created."""
59 | project_dir = project_config.home
60 |
61 | # First create a file with a forward reference
62 | source_content = """
63 | ---
64 | type: knowledge
65 | ---
66 | # Source Document
67 |
68 | ## Relations
69 | - depends_on [[target-doc]]
70 | - depends_on [[target-doc]] # duplicate
71 | """
72 | await create_test_file(project_dir / "source.md", source_content)
73 |
74 | # Initial sync - should create forward reference
75 | await sync_service.sync(project_config.home)
76 |
77 | # Verify forward reference
78 | source = await entity_service.get_by_permalink("source")
79 | assert len(source.relations) == 1
80 | assert source.relations[0].to_id is None
81 | assert source.relations[0].to_name == "target-doc"
82 |
83 | # Now create the target file
84 | target_content = """
85 | ---
86 | type: knowledge
87 | ---
88 | # Target Doc
89 | Target content
90 | """
91 | target_file = project_dir / "target_doc.md"
92 | await create_test_file(target_file, target_content)
93 |
94 | # Force full scan to ensure the new file is detected
95 | # Incremental scans have timing precision issues with watermarks on some filesystems
96 | await force_full_scan(sync_service)
97 |
98 | # Sync again - should resolve the reference
99 | await sync_service.sync(project_config.home)
100 |
101 | # Verify reference is now resolved
102 | source = await entity_service.get_by_permalink("source")
103 | target = await entity_service.get_by_permalink("target-doc")
104 | assert len(source.relations) == 1
105 | assert source.relations[0].to_id == target.id
106 | assert source.relations[0].to_name == target.title
107 |
108 |
109 | @pytest.mark.asyncio
110 | async def test_sync(
111 | sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
112 | ):
113 | """Test basic knowledge sync functionality."""
114 | # Create test files
115 | project_dir = project_config.home
116 |
117 | # New entity with relation
118 | new_content = """
119 | ---
120 | type: knowledge
121 | permalink: concept/test-concept
122 | created: 2023-01-01
123 | modified: 2023-01-01
124 | ---
125 | # Test Concept
126 |
127 | A test concept.
128 |
129 | ## Observations
130 | - [design] Core feature
131 |
132 | ## Relations
133 | - depends_on [[concept/other]]
134 | """
135 | await create_test_file(project_dir / "concept/test_concept.md", new_content)
136 |
137 | # Create related entity in DB that will be deleted
138 | # because file was not found
139 | other = Entity(
140 | permalink="concept/other",
141 | title="Other",
142 | entity_type="test",
143 | file_path="concept/other.md",
144 | checksum="12345678",
145 | content_type="text/markdown",
146 | created_at=datetime.now(timezone.utc),
147 | updated_at=datetime.now(timezone.utc),
148 | )
149 | await entity_service.repository.add(other)
150 |
151 | # Run sync
152 | await sync_service.sync(project_config.home)
153 |
154 | # Verify results
155 | entities = await entity_service.repository.find_all()
156 | assert len(entities) == 1
157 |
158 | # Find new entity
159 | test_concept = next(e for e in entities if e.permalink == "concept/test-concept")
160 | assert test_concept.entity_type == "knowledge"
161 |
162 | # Verify relation was created
163 | # with forward link
164 | entity = await entity_service.get_by_permalink(test_concept.permalink)
165 | relations = entity.relations
166 | assert len(relations) == 1, "Expected 1 relation for entity"
167 | assert relations[0].to_name == "concept/other"
168 |
169 |
170 | @pytest.mark.asyncio
171 | async def test_sync_hidden_file(
172 | sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
173 | ):
174 | """Test basic knowledge sync functionality."""
175 | # Create test files
176 | project_dir = project_config.home
177 |
178 | # hidden file
179 | await create_test_file(project_dir / "concept/.hidden.md", "hidden")
180 |
181 | # Run sync
182 | await sync_service.sync(project_config.home)
183 |
184 | # Verify results
185 | entities = await entity_service.repository.find_all()
186 | assert len(entities) == 0
187 |
188 |
189 | @pytest.mark.asyncio
190 | async def test_sync_entity_with_nonexistent_relations(
191 | sync_service: SyncService, project_config: ProjectConfig
192 | ):
193 | """Test syncing an entity that references nonexistent entities."""
194 | project_dir = project_config.home
195 |
196 | # Create entity that references entities we haven't created yet
197 | content = """
198 | ---
199 | type: knowledge
200 | permalink: concept/depends-on-future
201 | created: 2024-01-01
202 | modified: 2024-01-01
203 | ---
204 | # Test Dependencies
205 |
206 | ## Observations
207 | - [design] Testing future dependencies
208 |
209 | ## Relations
210 | - depends_on [[concept/not_created_yet]]
211 | - uses [[concept/also_future]]
212 | """
213 | await create_test_file(project_dir / "concept/depends_on_future.md", content)
214 |
215 | # Sync
216 | await sync_service.sync(project_config.home)
217 |
218 | # Verify entity created but no relations
219 | entity = await sync_service.entity_service.repository.get_by_permalink(
220 | "concept/depends-on-future"
221 | )
222 | assert entity is not None
223 | assert len(entity.relations) == 2
224 | assert entity.relations[0].to_name == "concept/not_created_yet"
225 | assert entity.relations[1].to_name == "concept/also_future"
226 |
227 |
228 | @pytest.mark.asyncio
229 | async def test_sync_entity_circular_relations(
230 | sync_service: SyncService, project_config: ProjectConfig
231 | ):
232 | """Test syncing entities with circular dependencies."""
233 | project_dir = project_config.home
234 |
235 | # Create entity A that depends on B
236 | content_a = """
237 | ---
238 | type: knowledge
239 | permalink: concept/entity-a
240 | created: 2024-01-01
241 | modified: 2024-01-01
242 | ---
243 | # Entity A
244 |
245 | ## Observations
246 | - First entity in circular reference
247 |
248 | ## Relations
249 | - depends_on [[concept/entity-b]]
250 | """
251 | await create_test_file(project_dir / "concept/entity_a.md", content_a)
252 |
253 | # Create entity B that depends on A
254 | content_b = """
255 | ---
256 | type: knowledge
257 | permalink: concept/entity-b
258 | created: 2024-01-01
259 | modified: 2024-01-01
260 | ---
261 | # Entity B
262 |
263 | ## Observations
264 | - Second entity in circular reference
265 |
266 | ## Relations
267 | - depends_on [[concept/entity-a]]
268 | """
269 | await create_test_file(project_dir / "concept/entity_b.md", content_b)
270 |
271 | # Sync
272 | await sync_service.sync(project_config.home)
273 |
274 | # Verify both entities and their relations
275 | entity_a = await sync_service.entity_service.repository.get_by_permalink("concept/entity-a")
276 | entity_b = await sync_service.entity_service.repository.get_by_permalink("concept/entity-b")
277 |
278 | # outgoing relations
279 | assert len(entity_a.outgoing_relations) == 1
280 | assert len(entity_b.outgoing_relations) == 1
281 |
282 | # incoming relations
283 | assert len(entity_a.incoming_relations) == 1
284 | assert len(entity_b.incoming_relations) == 1
285 |
286 | # all relations
287 | assert len(entity_a.relations) == 2
288 | assert len(entity_b.relations) == 2
289 |
290 | # Verify circular reference works
291 | a_relation = entity_a.outgoing_relations[0]
292 | assert a_relation.to_id == entity_b.id
293 |
294 | b_relation = entity_b.outgoing_relations[0]
295 | assert b_relation.to_id == entity_a.id
296 |
297 |
298 | @pytest.mark.asyncio
299 | async def test_sync_entity_duplicate_relations(
300 | sync_service: SyncService, project_config: ProjectConfig
301 | ):
302 | """Test handling of duplicate relations in an entity."""
303 | project_dir = project_config.home
304 |
305 | # Create target entity first
306 | target_content = """
307 | ---
308 | type: knowledge
309 | permalink: concept/target
310 | created: 2024-01-01
311 | modified: 2024-01-01
312 | ---
313 | # Target Entity
314 |
315 | ## Observations
316 | - something to observe
317 |
318 | """
319 | await create_test_file(project_dir / "concept/target.md", target_content)
320 |
321 | # Create entity with duplicate relations
322 | content = """
323 | ---
324 | type: knowledge
325 | permalink: concept/duplicate-relations
326 | created: 2024-01-01
327 | modified: 2024-01-01
328 | ---
329 | # Test Duplicates
330 |
331 | ## Observations
332 | - this has a lot of relations
333 |
334 | ## Relations
335 | - depends_on [[concept/target]]
336 | - depends_on [[concept/target]] # Duplicate
337 | - uses [[concept/target]] # Different relation type
338 | - uses [[concept/target]] # Duplicate of different type
339 | """
340 | await create_test_file(project_dir / "concept/duplicate_relations.md", content)
341 |
342 | # Sync
343 | await sync_service.sync(project_config.home)
344 |
345 | # Verify duplicates are handled
346 | entity = await sync_service.entity_service.repository.get_by_permalink(
347 | "concept/duplicate-relations"
348 | )
349 |
350 | # Count relations by type
351 | relation_counts = {}
352 | for rel in entity.relations:
353 | relation_counts[rel.relation_type] = relation_counts.get(rel.relation_type, 0) + 1
354 |
355 | # Should only have one of each type
356 | assert relation_counts["depends_on"] == 1
357 | assert relation_counts["uses"] == 1
358 |
359 |
360 | @pytest.mark.asyncio
361 | async def test_sync_entity_with_random_categories(
362 | sync_service: SyncService, project_config: ProjectConfig
363 | ):
364 | """Test handling of random observation categories."""
365 | project_dir = project_config.home
366 |
367 | content = """
368 | ---
369 | type: knowledge
370 | permalink: concept/invalid-category
371 | created: 2024-01-01
372 | modified: 2024-01-01
373 | ---
374 | # Test Categories
375 |
376 | ## Observations
377 | - [random category] This is fine
378 | - [ a space category] Should default to note
379 | - This one is not an observation, should be ignored
380 | - [design] This is valid
381 | """
382 | await create_test_file(project_dir / "concept/invalid_category.md", content)
383 |
384 | # Sync
385 | await sync_service.sync(project_config.home)
386 |
387 | # Verify observations
388 | entity = await sync_service.entity_service.repository.get_by_permalink(
389 | "concept/invalid-category"
390 | )
391 |
392 | assert len(entity.observations) == 3
393 | categories = [obs.category for obs in entity.observations]
394 |
395 | # Invalid categories should be converted to default
396 | assert "random category" in categories
397 | # Valid categories preserved
398 | assert "a space category" in categories
399 | assert "design" in categories
400 |
401 |
402 | @pytest.mark.skip("sometimes fails")
403 | @pytest.mark.asyncio
404 | async def test_sync_entity_with_order_dependent_relations(
405 | sync_service: SyncService, project_config: ProjectConfig
406 | ):
407 | """Test that order of entity syncing doesn't affect relation creation."""
408 | project_dir = project_config.home
409 |
410 | # Create several interrelated entities
411 | entities = {
412 | "a": """
413 | ---
414 | type: knowledge
415 | permalink: concept/entity-a
416 | created: 2024-01-01
417 | modified: 2024-01-01
418 | ---
419 | # Entity A
420 |
421 | ## Observations
422 | - depends on b
423 | - depends on c
424 |
425 | ## Relations
426 | - depends_on [[concept/entity-b]]
427 | - depends_on [[concept/entity-c]]
428 | """,
429 | "b": """
430 | ---
431 | type: knowledge
432 | permalink: concept/entity-b
433 | created: 2024-01-01
434 | modified: 2024-01-01
435 | ---
436 | # Entity B
437 |
438 | ## Observations
439 | - depends on c
440 |
441 | ## Relations
442 | - depends_on [[concept/entity-c]]
443 | """,
444 | "c": """
445 | ---
446 | type: knowledge
447 | permalink: concept/entity-c
448 | created: 2024-01-01
449 | modified: 2024-01-01
450 | ---
451 | # Entity C
452 |
453 | ## Observations
454 | - depends on a
455 |
456 | ## Relations
457 | - depends_on [[concept/entity-a]]
458 | """,
459 | }
460 |
461 | # Create files in different orders and verify results are the same
462 | for name, content in entities.items():
463 | await create_test_file(project_dir / f"concept/entity_{name}.md", content)
464 |
465 | # Sync
466 | await sync_service.sync(project_config.home)
467 |
468 | # Verify all relations are created correctly regardless of order
469 | entity_a = await sync_service.entity_service.repository.get_by_permalink("concept/entity-a")
470 | entity_b = await sync_service.entity_service.repository.get_by_permalink("concept/entity-b")
471 | entity_c = await sync_service.entity_service.repository.get_by_permalink("concept/entity-c")
472 |
473 | # Verify outgoing relations by checking actual targets
474 | a_outgoing_targets = {rel.to_id for rel in entity_a.outgoing_relations}
475 | assert entity_b.id in a_outgoing_targets, (
476 | f"A should depend on B. A's targets: {a_outgoing_targets}, B's ID: {entity_b.id}"
477 | )
478 | assert entity_c.id in a_outgoing_targets, (
479 | f"A should depend on C. A's targets: {a_outgoing_targets}, C's ID: {entity_c.id}"
480 | )
481 | assert len(entity_a.outgoing_relations) == 2, "A should have exactly 2 outgoing relations"
482 |
483 | b_outgoing_targets = {rel.to_id for rel in entity_b.outgoing_relations}
484 | assert entity_c.id in b_outgoing_targets, "B should depend on C"
485 | assert len(entity_b.outgoing_relations) == 1, "B should have exactly 1 outgoing relation"
486 |
487 | c_outgoing_targets = {rel.to_id for rel in entity_c.outgoing_relations}
488 | assert entity_a.id in c_outgoing_targets, "C should depend on A"
489 | assert len(entity_c.outgoing_relations) == 1, "C should have exactly 1 outgoing relation"
490 |
491 | # Verify incoming relations by checking actual sources
492 | a_incoming_sources = {rel.from_id for rel in entity_a.incoming_relations}
493 | assert entity_c.id in a_incoming_sources, "A should have incoming relation from C"
494 |
495 | b_incoming_sources = {rel.from_id for rel in entity_b.incoming_relations}
496 | assert entity_a.id in b_incoming_sources, "B should have incoming relation from A"
497 |
498 | c_incoming_sources = {rel.from_id for rel in entity_c.incoming_relations}
499 | assert entity_a.id in c_incoming_sources, "C should have incoming relation from A"
500 | assert entity_b.id in c_incoming_sources, "C should have incoming relation from B"
501 |
502 |
503 | @pytest.mark.asyncio
504 | async def test_sync_empty_directories(sync_service: SyncService, project_config: ProjectConfig):
505 | """Test syncing empty directories."""
506 | await sync_service.sync(project_config.home)
507 |
508 | # Should not raise exceptions for empty dirs
509 | assert project_config.home.exists()
510 |
511 |
512 | @pytest.mark.skip("flaky on Windows due to filesystem timing precision")
513 | @pytest.mark.asyncio
514 | async def test_sync_file_modified_during_sync(
515 | sync_service: SyncService, project_config: ProjectConfig
516 | ):
517 | """Test handling of files that change during sync process."""
518 | # Create initial files
519 | doc_path = project_config.home / "changing.md"
520 | await create_test_file(
521 | doc_path,
522 | """
523 | ---
524 | type: knowledge
525 | id: changing
526 | created: 2024-01-01
527 | modified: 2024-01-01
528 | ---
529 | # Knowledge File
530 |
531 | ## Observations
532 | - This is a test
533 | """,
534 | )
535 |
536 | # Setup async modification during sync
537 | async def modify_file():
538 | await asyncio.sleep(0.1) # Small delay to ensure sync has started
539 | doc_path.write_text("Modified during sync")
540 |
541 | # Run sync and modification concurrently
542 | await asyncio.gather(sync_service.sync(project_config.home), modify_file())
543 |
544 | # Verify final state
545 | doc = await sync_service.entity_service.repository.get_by_permalink("changing")
546 | assert doc is not None
547 |
548 | # if we failed in the middle of a sync, the next one should fix it.
549 | if doc.checksum is None:
550 | await sync_service.sync(project_config.home)
551 | doc = await sync_service.entity_service.repository.get_by_permalink("changing")
552 | assert doc.checksum is not None
553 |
554 |
555 | @pytest.mark.asyncio
556 | async def test_permalink_formatting(
557 | sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
558 | ):
559 | """Test that permalinks are properly formatted during sync."""
560 |
561 | # Test cases with different filename formats
562 | test_files = {
563 | # filename -> expected permalink
564 | "my_awesome_feature.md": "my-awesome-feature",
565 | "MIXED_CASE_NAME.md": "mixed-case-name",
566 | "spaces and_underscores.md": "spaces-and-underscores",
567 | "design/model_refactor.md": "design/model-refactor",
568 | "test/multiple_word_directory/feature_name.md": "test/multiple-word-directory/feature-name",
569 | }
570 |
571 | # Create test files
572 | content: str = """
573 | ---
574 | type: knowledge
575 | created: 2024-01-01
576 | modified: 2024-01-01
577 | ---
578 | # Test File
579 |
580 | Testing permalink generation.
581 | """
582 | for filename, _ in test_files.items():
583 | await create_test_file(project_config.home / filename, content)
584 |
585 | # Run sync once after all files are created
586 | await sync_service.sync(project_config.home)
587 |
588 | # Verify permalinks
589 | entities = await entity_service.repository.find_all()
590 | for filename, expected_permalink in test_files.items():
591 | # Find entity for this file
592 | entity = next(e for e in entities if e.file_path == filename)
593 | assert entity.permalink == expected_permalink, (
594 | f"File {filename} should have permalink {expected_permalink}"
595 | )
596 |
597 |
598 | @pytest.mark.asyncio
599 | async def test_handle_entity_deletion(
600 | test_graph,
601 | sync_service: SyncService,
602 | entity_repository: EntityRepository,
603 | search_service: SearchService,
604 | ):
605 | """Test deletion of entity cleans up search index."""
606 |
607 | root_entity = test_graph["root"]
608 | # Delete the entity
609 | await sync_service.handle_delete(root_entity.file_path)
610 |
611 | # Verify entity is gone from db
612 | assert await entity_repository.get_by_permalink(root_entity.permalink) is None
613 |
614 | # Verify entity is gone from search index
615 | entity_results = await search_service.search(SearchQuery(text=root_entity.title))
616 | assert len(entity_results) == 0
617 |
618 | obs_results = await search_service.search(SearchQuery(text="Root note 1"))
619 | assert len(obs_results) == 0
620 |
621 | rel_results = await search_service.search(SearchQuery(text="connects_to"))
622 | assert len(rel_results) == 0
623 |
624 |
625 | @pytest.mark.asyncio
626 | async def test_sync_preserves_timestamps(
627 | sync_service: SyncService,
628 | project_config: ProjectConfig,
629 | entity_service: EntityService,
630 | ):
631 | """Test that sync preserves file timestamps and frontmatter dates."""
632 | project_dir = project_config.home
633 |
634 | # Create a file with explicit frontmatter dates
635 | frontmatter_content = """
636 | ---
637 | type: knowledge
638 | ---
639 | # Explicit Dates
640 | Testing frontmatter dates
641 | """
642 | await create_test_file(project_dir / "explicit_dates.md", frontmatter_content)
643 |
644 | # Create a file without dates (will use file timestamps)
645 | file_dates_content = """
646 | ---
647 | type: knowledge
648 | ---
649 | # File Dates
650 | Testing file timestamps
651 | """
652 | file_path = project_dir / "file_dates3.md"
653 | await create_test_file(file_path, file_dates_content)
654 |
655 | # Run sync
656 | await sync_service.sync(project_config.home)
657 |
658 | # Check explicit frontmatter dates
659 | explicit_entity = await entity_service.get_by_permalink("explicit-dates")
660 | assert explicit_entity.created_at is not None
661 | assert explicit_entity.updated_at is not None
662 |
663 | # Check file timestamps
664 | file_entity = await entity_service.get_by_permalink("file-dates3")
665 | file_stats = file_path.stat()
666 |
667 | # Compare using epoch timestamps to handle timezone differences correctly
668 | # This ensures we're comparing the actual points in time, not display representations
669 | entity_created_epoch = file_entity.created_at.timestamp()
670 | entity_updated_epoch = file_entity.updated_at.timestamp()
671 |
672 | # Allow 2s difference on Windows due to filesystem timing precision
673 | tolerance = 2 if os.name == "nt" else 1
674 | assert abs(entity_created_epoch - file_stats.st_ctime) < tolerance
675 | assert abs(entity_updated_epoch - file_stats.st_mtime) < tolerance # Allow tolerance difference
676 |
677 |
678 | @pytest.mark.asyncio
679 | async def test_sync_updates_timestamps_on_file_modification(
680 | sync_service: SyncService,
681 | project_config: ProjectConfig,
682 | entity_service: EntityService,
683 | ):
684 | """Test that sync updates entity timestamps when files are modified.
685 |
686 | This test specifically validates that when an existing file is modified and re-synced,
687 | the updated_at timestamp in the database reflects the file's actual modification time,
688 | not the database operation time. This is critical for accurate temporal ordering in
689 | search and recent_activity queries.
690 | """
691 |
692 | project_dir = project_config.home
693 |
694 | # Create initial file
695 | initial_content = """
696 | ---
697 | type: knowledge
698 | ---
699 | # Test File
700 | Initial content for timestamp test
701 | """
702 | file_path = project_dir / "timestamp_test.md"
703 | await create_test_file(file_path, initial_content)
704 |
705 | # Initial sync
706 | await sync_service.sync(project_config.home)
707 |
708 | # Get initial entity and timestamps
709 | entity_before = await entity_service.get_by_permalink("timestamp-test")
710 | initial_updated_at = entity_before.updated_at
711 |
712 | # Modify the file content and update mtime to be newer than watermark
713 | modified_content = """
714 | ---
715 | type: knowledge
716 | ---
717 | # Test File
718 | Modified content for timestamp test
719 |
720 | ## Observations
721 | - [test] This was modified
722 | """
723 | file_path.write_text(modified_content)
724 |
725 | # Touch file to ensure mtime is newer than watermark
726 | # This uses our helper which sleeps 500ms and rewrites to guarantee mtime change
727 | await touch_file(file_path)
728 |
729 | # Get the file's modification time after our changes
730 | file_stats_after_modification = file_path.stat()
731 |
732 | # Force full scan to ensure the modified file is detected
733 | # (incremental scans have timing precision issues with watermarks on some filesystems)
734 | await force_full_scan(sync_service)
735 |
736 | # Re-sync the modified file
737 | await sync_service.sync(project_config.home)
738 |
739 | # Get entity after re-sync
740 | entity_after = await entity_service.get_by_permalink("timestamp-test")
741 |
742 | # Verify that updated_at changed
743 | assert entity_after.updated_at != initial_updated_at, (
744 | "updated_at should change when file is modified"
745 | )
746 |
747 | # Verify that updated_at matches the file's modification time, not db operation time
748 | entity_updated_epoch = entity_after.updated_at.timestamp()
749 | file_mtime = file_stats_after_modification.st_mtime
750 |
751 | # Allow 2s difference on Windows due to filesystem timing precision
752 | tolerance = 2 if os.name == "nt" else 1
753 | assert abs(entity_updated_epoch - file_mtime) < tolerance, (
754 | f"Entity updated_at ({entity_after.updated_at}) should match file mtime "
755 | f"({datetime.fromtimestamp(file_mtime)}) within {tolerance}s tolerance"
756 | )
757 |
758 | # Verify the content was actually updated
759 | assert len(entity_after.observations) == 1
760 | assert entity_after.observations[0].content == "This was modified"
761 |
762 |
763 | @pytest.mark.asyncio
764 | async def test_file_move_updates_search_index(
765 | sync_service: SyncService,
766 | project_config: ProjectConfig,
767 | search_service: SearchService,
768 | ):
769 | """Test that moving a file updates its path in the search index."""
770 | project_dir = project_config.home
771 |
772 | # Create initial file
773 | content = """
774 | ---
775 | type: knowledge
776 | ---
777 | # Test Move
778 | Content for move test
779 | """
780 | old_path = project_dir / "old" / "test_move.md"
781 | old_path.parent.mkdir(parents=True)
782 | await create_test_file(old_path, content)
783 |
784 | # Initial sync
785 | await sync_service.sync(project_config.home)
786 |
787 | # Move the file
788 | new_path = project_dir / "new" / "moved_file.md"
789 | new_path.parent.mkdir(parents=True)
790 | old_path.rename(new_path)
791 |
792 | # Force full scan to detect the move
793 | # (rename doesn't update mtime, so incremental scan won't find it)
794 | await force_full_scan(sync_service)
795 |
796 | # Second sync should detect the move
797 | await sync_service.sync(project_config.home)
798 |
799 | # Check search index has updated path
800 | results = await search_service.search(SearchQuery(text="Content for move test"))
801 | assert len(results) == 1
802 | assert results[0].file_path == new_path.relative_to(project_dir).as_posix()
803 |
804 |
805 | @pytest.mark.asyncio
806 | async def test_sync_null_checksum_cleanup(
807 | sync_service: SyncService,
808 | project_config: ProjectConfig,
809 | entity_service: EntityService,
810 | ):
811 | """Test handling of entities with null checksums from incomplete syncs."""
812 | # Create entity with null checksum (simulating incomplete sync)
813 | entity = Entity(
814 | permalink="concept/incomplete",
815 | title="Incomplete",
816 | entity_type="test",
817 | file_path="concept/incomplete.md",
818 | checksum=None, # Null checksum
819 | content_type="text/markdown",
820 | created_at=datetime.now(timezone.utc),
821 | updated_at=datetime.now(timezone.utc),
822 | )
823 | await entity_service.repository.add(entity)
824 |
825 | # Create corresponding file
826 | content = """
827 | ---
828 | type: knowledge
829 | id: concept/incomplete
830 | created: 2024-01-01
831 | modified: 2024-01-01
832 | ---
833 | # Incomplete Entity
834 |
835 | ## Observations
836 | - Testing cleanup
837 | """
838 | await create_test_file(project_config.home / "concept/incomplete.md", content)
839 |
840 | # Run sync
841 | await sync_service.sync(project_config.home)
842 |
843 | # Verify entity was properly synced
844 | updated = await entity_service.get_by_permalink("concept/incomplete")
845 | assert updated.checksum is not None
846 |
847 |
848 | @pytest.mark.asyncio
849 | async def test_sync_permalink_resolved(
850 | sync_service: SyncService, project_config: ProjectConfig, file_service: FileService, app_config
851 | ):
852 | """Test that we resolve duplicate permalinks on sync ."""
853 | project_dir = project_config.home
854 |
855 | # Create initial file
856 | content = """
857 | ---
858 | type: knowledge
859 | ---
860 | # Test Move
861 | Content for move test
862 | """
863 | old_path = project_dir / "old" / "test_move.md"
864 | old_path.parent.mkdir(parents=True)
865 | await create_test_file(old_path, content)
866 |
867 | # Initial sync
868 | await sync_service.sync(project_config.home)
869 |
870 | # Move the file
871 | new_path = project_dir / "new" / "moved_file.md"
872 | new_path.parent.mkdir(parents=True)
873 | old_path.rename(new_path)
874 |
875 | # Force full scan to detect the move
876 | # (rename doesn't update mtime, so incremental scan won't find it)
877 | await force_full_scan(sync_service)
878 |
879 | # Sync again
880 | await sync_service.sync(project_config.home)
881 |
882 | file_content, _ = await file_service.read_file(new_path)
883 | assert "permalink: new/moved-file" in file_content
884 |
885 | # Create another that has the same permalink
886 | content = """
887 | ---
888 | type: knowledge
889 | permalink: new/moved-file
890 | ---
891 | # Test Move
892 | Content for move test
893 | """
894 | old_path = project_dir / "old" / "test_move.md"
895 | old_path.parent.mkdir(parents=True, exist_ok=True)
896 | await create_test_file(old_path, content)
897 |
898 | # Force full scan to detect the new file
899 | # (file just created may not be newer than watermark due to timing precision)
900 | await force_full_scan(sync_service)
901 |
902 | # Sync new file
903 | await sync_service.sync(project_config.home)
904 |
905 | # assert permalink is unique
906 | file_content, _ = await file_service.read_file(old_path)
907 | assert "permalink: new/moved-file-1" in file_content
908 |
909 |
910 | @pytest.mark.asyncio
911 | async def test_sync_permalink_resolved_on_update(
912 | sync_service: SyncService,
913 | project_config: ProjectConfig,
914 | file_service: FileService,
915 | ):
916 | """Test that sync resolves permalink conflicts on update."""
917 | project_dir = project_config.home
918 |
919 | one_file = project_dir / "one.md"
920 | two_file = project_dir / "two.md"
921 | await create_test_file(
922 | one_file,
923 | content=dedent(
924 | """
925 | ---
926 | permalink: one
927 | ---
928 | test content
929 | """
930 | ),
931 | )
932 | await create_test_file(
933 | two_file,
934 | content=dedent(
935 | """
936 | ---
937 | permalink: two
938 | ---
939 | test content
940 | """
941 | ),
942 | )
943 |
944 | # Run sync
945 | await sync_service.sync(project_config.home)
946 |
947 | # Check permalinks
948 | file_one_content, _ = await file_service.read_file(one_file)
949 | assert "permalink: one" in file_one_content
950 |
951 | file_two_content, _ = await file_service.read_file(two_file)
952 | assert "permalink: two" in file_two_content
953 |
954 | # update the second file with a duplicate permalink
955 | updated_content = """
956 | ---
957 | title: two.md
958 | type: note
959 | permalink: one
960 | tags: []
961 | ---
962 |
963 | test content
964 | """
965 | two_file.write_text(updated_content)
966 |
967 | # Force full scan to detect the modified file
968 | # (file just modified may not be newer than watermark due to timing precision)
969 | await force_full_scan(sync_service)
970 |
971 | # Run sync
972 | await sync_service.sync(project_config.home)
973 |
974 | # Check permalinks
975 | file_two_content, _ = await file_service.read_file(two_file)
976 | assert "permalink: two" in file_two_content
977 |
978 | # new content with duplicate permalink
979 | new_content = """
980 | ---
981 | title: new.md
982 | type: note
983 | permalink: one
984 | tags: []
985 | ---
986 |
987 | test content
988 | """
989 | new_file = project_dir / "new.md"
990 | await create_test_file(new_file, new_content)
991 |
992 | # Force full scan to detect the new file
993 | # (file just created may not be newer than watermark due to timing precision)
994 | await force_full_scan(sync_service)
995 |
996 | # Run another time
997 | await sync_service.sync(project_config.home)
998 |
999 | # Should have deduplicated permalink
1000 | new_file_content, _ = await file_service.read_file(new_file)
1001 | assert "permalink: one-1" in new_file_content
1002 |
1003 |
1004 | @pytest.mark.asyncio
1005 | async def test_sync_permalink_not_created_if_no_frontmatter(
1006 | sync_service: SyncService,
1007 | project_config: ProjectConfig,
1008 | file_service: FileService,
1009 | ):
1010 | """Test that sync resolves permalink conflicts on update."""
1011 | project_dir = project_config.home
1012 |
1013 | file = project_dir / "one.md"
1014 | await create_test_file(file)
1015 |
1016 | # Run sync
1017 | await sync_service.sync(project_config.home)
1018 |
1019 | # Check permalink not created
1020 | file_content, _ = await file_service.read_file(file)
1021 | assert "permalink:" not in file_content
1022 |
1023 |
1024 | @pytest.fixture
1025 | def test_config_update_permamlinks_on_move(app_config) -> BasicMemoryConfig:
1026 | """Test configuration using in-memory DB."""
1027 | app_config.update_permalinks_on_move = True
1028 | return app_config
1029 |
1030 |
1031 | @pytest.mark.asyncio
1032 | async def test_sync_permalink_updated_on_move(
1033 | test_config_update_permamlinks_on_move: BasicMemoryConfig,
1034 | project_config: ProjectConfig,
1035 | sync_service: SyncService,
1036 | file_service: FileService,
1037 | ):
1038 | """Test that we update a permalink on a file move if set in config ."""
1039 | project_dir = project_config.home
1040 |
1041 | # Create initial file
1042 | content = dedent(
1043 | """
1044 | ---
1045 | type: knowledge
1046 | ---
1047 | # Test Move
1048 | Content for move test
1049 | """
1050 | )
1051 |
1052 | old_path = project_dir / "old" / "test_move.md"
1053 | old_path.parent.mkdir(parents=True)
1054 | await create_test_file(old_path, content)
1055 |
1056 | # Initial sync
1057 | await sync_service.sync(project_config.home)
1058 |
1059 | # verify permalink
1060 | old_content, _ = await file_service.read_file(old_path)
1061 | assert "permalink: old/test-move" in old_content
1062 |
1063 | # Move the file
1064 | new_path = project_dir / "new" / "moved_file.md"
1065 | new_path.parent.mkdir(parents=True)
1066 | old_path.rename(new_path)
1067 |
1068 | # Force full scan to detect the move
1069 | # (rename doesn't update mtime, so incremental scan won't find it)
1070 | await force_full_scan(sync_service)
1071 |
1072 | # Sync again
1073 | await sync_service.sync(project_config.home)
1074 |
1075 | file_content, _ = await file_service.read_file(new_path)
1076 | assert "permalink: new/moved-file" in file_content
1077 |
1078 |
1079 | @pytest.mark.asyncio
1080 | async def test_sync_non_markdown_files(sync_service, project_config, test_files):
1081 | """Test syncing non-markdown files."""
1082 | report = await sync_service.sync(project_config.home)
1083 | assert report.total == 2
1084 |
1085 | # Check files were detected
1086 | assert test_files["pdf"].name in [f for f in report.new]
1087 | assert test_files["image"].name in [f for f in report.new]
1088 |
1089 | # Verify entities were created
1090 | pdf_entity = await sync_service.entity_repository.get_by_file_path(str(test_files["pdf"].name))
1091 | assert pdf_entity is not None, "PDF entity should have been created"
1092 | assert pdf_entity.content_type == "application/pdf"
1093 |
1094 | image_entity = await sync_service.entity_repository.get_by_file_path(
1095 | str(test_files["image"].name)
1096 | )
1097 | assert image_entity.content_type == "image/png"
1098 |
1099 |
1100 | @pytest.mark.asyncio
1101 | async def test_sync_non_markdown_files_modified(
1102 | sync_service, project_config, test_files, file_service
1103 | ):
1104 | """Test syncing non-markdown files."""
1105 | report = await sync_service.sync(project_config.home)
1106 | assert report.total == 2
1107 |
1108 | # Check files were detected
1109 | assert test_files["pdf"].name in [f for f in report.new]
1110 | assert test_files["image"].name in [f for f in report.new]
1111 |
1112 | test_files["pdf"].write_text("New content")
1113 | test_files["image"].write_text("New content")
1114 |
1115 | # Force full scan to detect the modified files
1116 | # (files just modified may not be newer than watermark due to timing precision)
1117 | await force_full_scan(sync_service)
1118 |
1119 | report = await sync_service.sync(project_config.home)
1120 | assert len(report.modified) == 2
1121 |
1122 | pdf_file_content, pdf_checksum = await file_service.read_file(test_files["pdf"].name)
1123 | image_file_content, img_checksum = await file_service.read_file(test_files["image"].name)
1124 |
1125 | pdf_entity = await sync_service.entity_repository.get_by_file_path(str(test_files["pdf"].name))
1126 | image_entity = await sync_service.entity_repository.get_by_file_path(
1127 | str(test_files["image"].name)
1128 | )
1129 |
1130 | assert pdf_entity.checksum == pdf_checksum
1131 | assert image_entity.checksum == img_checksum
1132 |
1133 |
1134 | @pytest.mark.asyncio
1135 | async def test_sync_non_markdown_files_move(sync_service, project_config, test_files):
1136 | """Test syncing non-markdown files updates permalink"""
1137 | report = await sync_service.sync(project_config.home)
1138 | assert report.total == 2
1139 |
1140 | # Check files were detected
1141 | assert test_files["pdf"].name in [f for f in report.new]
1142 | assert test_files["image"].name in [f for f in report.new]
1143 |
1144 | test_files["pdf"].rename(project_config.home / "moved_pdf.pdf")
1145 |
1146 | # Force full scan to detect the move
1147 | # (rename doesn't update mtime, so incremental scan won't find it)
1148 | await force_full_scan(sync_service)
1149 |
1150 | report2 = await sync_service.sync(project_config.home)
1151 | assert len(report2.moves) == 1
1152 |
1153 | # Verify entity is updated
1154 | pdf_entity = await sync_service.entity_repository.get_by_file_path("moved_pdf.pdf")
1155 | assert pdf_entity is not None
1156 | assert pdf_entity.permalink is None
1157 |
1158 |
1159 | @pytest.mark.asyncio
1160 | async def test_sync_non_markdown_files_deleted(sync_service, project_config, test_files):
1161 | """Test syncing non-markdown files updates permalink"""
1162 | report = await sync_service.sync(project_config.home)
1163 | assert report.total == 2
1164 |
1165 | # Check files were detected
1166 | assert test_files["pdf"].name in [f for f in report.new]
1167 | assert test_files["image"].name in [f for f in report.new]
1168 |
1169 | test_files["pdf"].unlink()
1170 | report2 = await sync_service.sync(project_config.home)
1171 | assert len(report2.deleted) == 1
1172 |
1173 | # Verify entity is deleted
1174 | pdf_entity = await sync_service.entity_repository.get_by_file_path("moved_pdf.pdf")
1175 | assert pdf_entity is None
1176 |
1177 |
1178 | @pytest.mark.asyncio
1179 | async def test_sync_non_markdown_files_move_with_delete(
1180 | sync_service, project_config, test_files, file_service
1181 | ):
1182 | """Test syncing non-markdown files handles file deletes and renames during sync"""
1183 |
1184 | # Create initial files
1185 | await create_test_file(project_config.home / "doc.pdf", "content1")
1186 | await create_test_file(project_config.home / "other/doc-1.pdf", "content2")
1187 |
1188 | # Initial sync
1189 | await sync_service.sync(project_config.home)
1190 |
1191 | # First move/delete the original file to make way for the move
1192 | (project_config.home / "doc.pdf").unlink()
1193 | (project_config.home / "other/doc-1.pdf").rename(project_config.home / "doc.pdf")
1194 |
1195 | # Sync again
1196 | await sync_service.sync(project_config.home)
1197 |
1198 | # Verify the changes
1199 | moved_entity = await sync_service.entity_repository.get_by_file_path("doc.pdf")
1200 | assert moved_entity is not None
1201 | assert moved_entity.permalink is None
1202 |
1203 | file_content, _ = await file_service.read_file("doc.pdf")
1204 | assert "content2" in file_content
1205 |
1206 |
1207 | @pytest.mark.asyncio
1208 | async def test_sync_relation_to_non_markdown_file(
1209 | sync_service: SyncService, project_config: ProjectConfig, file_service: FileService, test_files
1210 | ):
1211 | """Test that sync resolves permalink conflicts on update."""
1212 | project_dir = project_config.home
1213 |
1214 | content = f"""
1215 | ---
1216 | title: a note
1217 | type: note
1218 | tags: []
1219 | ---
1220 |
1221 | - relates_to [[{test_files["pdf"].name}]]
1222 | """
1223 |
1224 | note_file = project_dir / "note.md"
1225 | await create_test_file(note_file, content)
1226 |
1227 | # Run sync
1228 | await sync_service.sync(project_config.home)
1229 |
1230 | # Check permalinks
1231 | file_one_content, _ = await file_service.read_file(note_file)
1232 | assert (
1233 | f"""---
1234 | title: a note
1235 | type: note
1236 | tags: []
1237 | permalink: note
1238 | ---
1239 |
1240 | - relates_to [[{test_files["pdf"].name}]]
1241 | """.strip()
1242 | == file_one_content
1243 | )
1244 |
1245 |
1246 | @pytest.mark.asyncio
1247 | async def test_sync_regular_file_race_condition_handling(
1248 | sync_service: SyncService, project_config: ProjectConfig
1249 | ):
1250 | """Test that sync_regular_file handles race condition with IntegrityError (lines 380-401)."""
1251 | from unittest.mock import patch
1252 | from sqlalchemy.exc import IntegrityError
1253 | from datetime import datetime, timezone
1254 |
1255 | # Create a test file
1256 | test_file = project_config.home / "test_race.md"
1257 | test_content = """
1258 | ---
1259 | type: knowledge
1260 | ---
1261 | # Test Race Condition
1262 | This is a test file for race condition handling.
1263 | """
1264 | await create_test_file(test_file, test_content)
1265 |
1266 | # Mock the entity_repository.add to raise IntegrityError on first call
1267 | original_add = sync_service.entity_repository.add
1268 |
1269 | call_count = 0
1270 |
1271 | async def mock_add(*args, **kwargs):
1272 | nonlocal call_count
1273 | call_count += 1
1274 | if call_count == 1:
1275 | # Simulate race condition - another process created the entity
1276 | raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None) # pyright: ignore [reportArgumentType]
1277 | else:
1278 | return await original_add(*args, **kwargs)
1279 |
1280 | # Mock get_by_file_path to return an existing entity (simulating the race condition result)
1281 | async def mock_get_by_file_path(file_path):
1282 | from basic_memory.models import Entity
1283 |
1284 | return Entity(
1285 | id=1,
1286 | title="Test Race Condition",
1287 | entity_type="knowledge",
1288 | file_path=str(file_path),
1289 | permalink="test-race-condition",
1290 | content_type="text/markdown",
1291 | checksum="old_checksum",
1292 | created_at=datetime.now(timezone.utc),
1293 | updated_at=datetime.now(timezone.utc),
1294 | )
1295 |
1296 | # Mock update to return the updated entity
1297 | async def mock_update(entity_id, updates):
1298 | from basic_memory.models import Entity
1299 |
1300 | return Entity(
1301 | id=entity_id,
1302 | title="Test Race Condition",
1303 | entity_type="knowledge",
1304 | file_path=updates["file_path"],
1305 | permalink="test-race-condition",
1306 | content_type="text/markdown",
1307 | checksum=updates["checksum"],
1308 | created_at=datetime.now(timezone.utc),
1309 | updated_at=datetime.now(timezone.utc),
1310 | )
1311 |
1312 | with (
1313 | patch.object(sync_service.entity_repository, "add", side_effect=mock_add),
1314 | patch.object(
1315 | sync_service.entity_repository, "get_by_file_path", side_effect=mock_get_by_file_path
1316 | ) as mock_get,
1317 | patch.object(
1318 | sync_service.entity_repository, "update", side_effect=mock_update
1319 | ) as mock_update_call,
1320 | ):
1321 | # Call sync_regular_file
1322 | entity, checksum = await sync_service.sync_regular_file(
1323 | str(test_file.relative_to(project_config.home)), new=True
1324 | )
1325 |
1326 | # Verify it handled the race condition gracefully
1327 | assert entity is not None
1328 | assert entity.title == "Test Race Condition"
1329 | assert entity.file_path == str(test_file.relative_to(project_config.home))
1330 |
1331 | # Verify that get_by_file_path and update were called as fallback
1332 | assert mock_get.call_count >= 1 # May be called multiple times
1333 | mock_update_call.assert_called_once()
1334 |
1335 |
1336 | @pytest.mark.asyncio
1337 | async def test_sync_regular_file_integrity_error_reraise(
1338 | sync_service: SyncService, project_config: ProjectConfig
1339 | ):
1340 | """Test that sync_regular_file re-raises IntegrityError for non-race-condition cases."""
1341 | from unittest.mock import patch
1342 | from sqlalchemy.exc import IntegrityError
1343 |
1344 | # Create a test file
1345 | test_file = project_config.home / "test_integrity.md"
1346 | test_content = """
1347 | ---
1348 | type: knowledge
1349 | ---
1350 | # Test Integrity Error
1351 | This is a test file for integrity error handling.
1352 | """
1353 | await create_test_file(test_file, test_content)
1354 |
1355 | # Mock the entity_repository.add to raise a different IntegrityError (not file_path constraint)
1356 | async def mock_add(*args, **kwargs):
1357 | # Simulate a different constraint violation
1358 | raise IntegrityError("UNIQUE constraint failed: entity.some_other_field", None, None) # pyright: ignore [reportArgumentType]
1359 |
1360 | with patch.object(sync_service.entity_repository, "add", side_effect=mock_add):
1361 | # Should re-raise the IntegrityError since it's not a file_path constraint
1362 | with pytest.raises(
1363 | IntegrityError, match="UNIQUE constraint failed: entity.some_other_field"
1364 | ):
1365 | await sync_service.sync_regular_file(
1366 | str(test_file.relative_to(project_config.home)), new=True
1367 | )
1368 |
1369 |
1370 | @pytest.mark.asyncio
1371 | async def test_sync_regular_file_race_condition_entity_not_found(
1372 | sync_service: SyncService, project_config: ProjectConfig
1373 | ):
1374 | """Test handling when entity is not found after IntegrityError (pragma: no cover case)."""
1375 | from unittest.mock import patch
1376 | from sqlalchemy.exc import IntegrityError
1377 |
1378 | # Create a test file
1379 | test_file = project_config.home / "test_not_found.md"
1380 | test_content = """
1381 | ---
1382 | type: knowledge
1383 | ---
1384 | # Test Not Found
1385 | This is a test file for entity not found after constraint violation.
1386 | """
1387 | await create_test_file(test_file, test_content)
1388 |
1389 | # Mock the entity_repository.add to raise IntegrityError
1390 | async def mock_add(*args, **kwargs):
1391 | raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None) # pyright: ignore [reportArgumentType]
1392 |
1393 | # Mock get_by_file_path to return None (entity not found)
1394 | async def mock_get_by_file_path(file_path):
1395 | return None
1396 |
1397 | with (
1398 | patch.object(sync_service.entity_repository, "add", side_effect=mock_add),
1399 | patch.object(
1400 | sync_service.entity_repository, "get_by_file_path", side_effect=mock_get_by_file_path
1401 | ),
1402 | ):
1403 | # Should raise ValueError when entity is not found after constraint violation
1404 | with pytest.raises(ValueError, match="Entity not found after constraint violation"):
1405 | await sync_service.sync_regular_file(
1406 | str(test_file.relative_to(project_config.home)), new=True
1407 | )
1408 |
1409 |
1410 | @pytest.mark.asyncio
1411 | async def test_sync_regular_file_race_condition_update_failed(
1412 | sync_service: SyncService, project_config: ProjectConfig
1413 | ):
1414 | """Test handling when update fails after IntegrityError (pragma: no cover case)."""
1415 | from unittest.mock import patch
1416 | from sqlalchemy.exc import IntegrityError
1417 | from datetime import datetime, timezone
1418 |
1419 | # Create a test file
1420 | test_file = project_config.home / "test_update_fail.md"
1421 | test_content = """
1422 | ---
1423 | type: knowledge
1424 | ---
1425 | # Test Update Fail
1426 | This is a test file for update failure after constraint violation.
1427 | """
1428 | await create_test_file(test_file, test_content)
1429 |
1430 | # Mock the entity_repository.add to raise IntegrityError
1431 | async def mock_add(*args, **kwargs):
1432 | raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None) # pyright: ignore [reportArgumentType]
1433 |
1434 | # Mock get_by_file_path to return an existing entity
1435 | async def mock_get_by_file_path(file_path):
1436 | from basic_memory.models import Entity
1437 |
1438 | return Entity(
1439 | id=1,
1440 | title="Test Update Fail",
1441 | entity_type="knowledge",
1442 | file_path=str(file_path),
1443 | permalink="test-update-fail",
1444 | content_type="text/markdown",
1445 | checksum="old_checksum",
1446 | created_at=datetime.now(timezone.utc),
1447 | updated_at=datetime.now(timezone.utc),
1448 | )
1449 |
1450 | # Mock update to return None (failure)
1451 | async def mock_update(entity_id, updates):
1452 | return None
1453 |
1454 | with (
1455 | patch.object(sync_service.entity_repository, "add", side_effect=mock_add),
1456 | patch.object(
1457 | sync_service.entity_repository, "get_by_file_path", side_effect=mock_get_by_file_path
1458 | ),
1459 | patch.object(sync_service.entity_repository, "update", side_effect=mock_update),
1460 | ):
1461 | # Should raise ValueError when update fails
1462 | with pytest.raises(ValueError, match="Failed to update entity with ID"):
1463 | await sync_service.sync_regular_file(
1464 | str(test_file.relative_to(project_config.home)), new=True
1465 | )
1466 |
1467 |
1468 | @pytest.mark.asyncio
1469 | async def test_circuit_breaker_skips_after_three_failures(
1470 | sync_service: SyncService, project_config: ProjectConfig
1471 | ):
1472 | """Test that circuit breaker skips file after 3 consecutive failures."""
1473 | from unittest.mock import patch
1474 |
1475 | project_dir = project_config.home
1476 | test_file = project_dir / "failing_file.md"
1477 |
1478 | # Create a file with malformed content that will fail to parse
1479 | await create_test_file(test_file, "invalid markdown content")
1480 |
1481 | # Mock sync_markdown_file to always fail
1482 | async def mock_sync_markdown_file(*args, **kwargs):
1483 | raise ValueError("Simulated sync failure")
1484 |
1485 | with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
1486 | # First sync - should fail and record (1/3)
1487 | report1 = await sync_service.sync(project_dir)
1488 | assert len(report1.skipped_files) == 0 # Not skipped yet
1489 |
1490 | # Touch file to trigger incremental scan
1491 | await touch_file(test_file)
1492 |
1493 | # Force full scan to ensure file is detected
1494 | # (touch may not update mtime sufficiently on all filesystems)
1495 | await force_full_scan(sync_service)
1496 |
1497 | # Second sync - should fail and record (2/3)
1498 | report2 = await sync_service.sync(project_dir)
1499 | assert len(report2.skipped_files) == 0 # Still not skipped
1500 |
1501 | # Touch file to trigger incremental scan
1502 | await touch_file(test_file)
1503 |
1504 | # Force full scan to ensure file is detected
1505 | # (touch may not update mtime sufficiently on all filesystems)
1506 | await force_full_scan(sync_service)
1507 |
1508 | # Third sync - should fail, record (3/3), and be added to skipped list
1509 | report3 = await sync_service.sync(project_dir)
1510 | assert len(report3.skipped_files) == 1
1511 | assert report3.skipped_files[0].path == "failing_file.md"
1512 | assert report3.skipped_files[0].failure_count == 3
1513 | assert "Simulated sync failure" in report3.skipped_files[0].reason
1514 |
1515 | # Touch file to trigger incremental scan
1516 | await touch_file(test_file)
1517 |
1518 | # Force full scan to ensure file is detected
1519 | # (touch may not update mtime sufficiently on all filesystems)
1520 | await force_full_scan(sync_service)
1521 |
1522 | # Fourth sync - should be skipped immediately without attempting
1523 | report4 = await sync_service.sync(project_dir)
1524 | assert len(report4.skipped_files) == 1 # Still skipped
1525 |
1526 |
1527 | @pytest.mark.asyncio
1528 | async def test_circuit_breaker_resets_on_file_change(
1529 | sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
1530 | ):
1531 | """Test that circuit breaker resets when file content changes."""
1532 | from unittest.mock import patch
1533 |
1534 | project_dir = project_config.home
1535 | test_file = project_dir / "changing_file.md"
1536 |
1537 | # Create initial failing content
1538 | await create_test_file(test_file, "initial bad content")
1539 |
1540 | # Mock sync_markdown_file to fail
1541 | call_count = 0
1542 |
1543 | async def mock_sync_markdown_file(*args, **kwargs):
1544 | nonlocal call_count
1545 | call_count += 1
1546 | raise ValueError("Simulated sync failure")
1547 |
1548 | with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
1549 | # Fail 3 times to hit circuit breaker threshold
1550 | await sync_service.sync(project_dir) # Fail 1
1551 | await touch_file(test_file) # Touch to trigger incremental scan
1552 |
1553 | # Force full scan to ensure file is detected
1554 | # (touch may not update mtime sufficiently on all filesystems)
1555 | await force_full_scan(sync_service)
1556 |
1557 | await sync_service.sync(project_dir) # Fail 2
1558 | await touch_file(test_file) # Touch to trigger incremental scan
1559 |
1560 | # Force full scan to ensure file is detected
1561 | # (touch may not update mtime sufficiently on all filesystems)
1562 | await force_full_scan(sync_service)
1563 |
1564 | report3 = await sync_service.sync(project_dir) # Fail 3 - now skipped
1565 | assert len(report3.skipped_files) == 1
1566 |
1567 | # Now change the file content
1568 | valid_content = dedent(
1569 | """
1570 | ---
1571 | title: Fixed Content
1572 | type: knowledge
1573 | ---
1574 | # Fixed Content
1575 | This should work now.
1576 | """
1577 | ).strip()
1578 | await create_test_file(test_file, valid_content)
1579 |
1580 | # Force full scan to detect the modified file
1581 | # (file just modified may not be newer than watermark due to timing precision)
1582 | await force_full_scan(sync_service)
1583 |
1584 | # Circuit breaker should reset and allow retry
1585 | report = await sync_service.sync(project_dir)
1586 | assert len(report.skipped_files) == 0 # Should not be skipped anymore
1587 |
1588 | # Verify entity was created successfully
1589 | entity = await entity_service.get_by_permalink("changing-file")
1590 | assert entity is not None
1591 | assert entity.title == "Fixed Content"
1592 |
1593 |
1594 | @pytest.mark.asyncio
1595 | async def test_circuit_breaker_clears_on_success(
1596 | sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
1597 | ):
1598 | """Test that circuit breaker clears failure history after successful sync."""
1599 | from unittest.mock import patch
1600 |
1601 | project_dir = project_config.home
1602 | test_file = project_dir / "sometimes_failing.md"
1603 |
1604 | valid_content = dedent(
1605 | """
1606 | ---
1607 | title: Test File
1608 | type: knowledge
1609 | ---
1610 | # Test File
1611 | Test content
1612 | """
1613 | ).strip()
1614 | await create_test_file(test_file, valid_content)
1615 |
1616 | # Mock to fail twice, then succeed
1617 | call_count = 0
1618 | original_sync_markdown_file = sync_service.sync_markdown_file
1619 |
1620 | async def mock_sync_markdown_file(path, new):
1621 | nonlocal call_count
1622 | call_count += 1
1623 | if call_count <= 2:
1624 | raise ValueError("Temporary failure")
1625 | # On third call, use the real implementation
1626 | return await original_sync_markdown_file(path, new)
1627 |
1628 | # Patch and fail twice
1629 | with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
1630 | await sync_service.sync(project_dir) # Fail 1
1631 | await touch_file(test_file) # Touch to trigger incremental scan
1632 |
1633 | # Force full scan to ensure file is detected
1634 | # (touch may not update mtime sufficiently on all filesystems)
1635 | await force_full_scan(sync_service)
1636 |
1637 | await sync_service.sync(project_dir) # Fail 2
1638 | await touch_file(test_file) # Touch to trigger incremental scan
1639 |
1640 | # Force full scan to ensure file is detected
1641 | # (touch may not update mtime sufficiently on all filesystems)
1642 | await force_full_scan(sync_service)
1643 |
1644 | await sync_service.sync(project_dir) # Succeed
1645 |
1646 | # Verify failure history was cleared
1647 | assert "sometimes_failing.md" not in sync_service._file_failures
1648 |
1649 | # Verify entity was created
1650 | entity = await entity_service.get_by_permalink("sometimes-failing")
1651 | assert entity is not None
1652 |
1653 |
1654 | @pytest.mark.asyncio
1655 | async def test_circuit_breaker_tracks_multiple_files(
1656 | sync_service: SyncService, project_config: ProjectConfig
1657 | ):
1658 | """Test that circuit breaker tracks multiple failing files independently."""
1659 | from unittest.mock import patch
1660 |
1661 | project_dir = project_config.home
1662 |
1663 | # Create multiple files with valid markdown
1664 | await create_test_file(
1665 | project_dir / "file1.md",
1666 | """
1667 | ---
1668 | type: knowledge
1669 | ---
1670 | # File 1
1671 | Content 1
1672 | """,
1673 | )
1674 | await create_test_file(
1675 | project_dir / "file2.md",
1676 | """
1677 | ---
1678 | type: knowledge
1679 | ---
1680 | # File 2
1681 | Content 2
1682 | """,
1683 | )
1684 | await create_test_file(
1685 | project_dir / "file3.md",
1686 | """
1687 | ---
1688 | type: knowledge
1689 | ---
1690 | # File 3
1691 | Content 3
1692 | """,
1693 | )
1694 |
1695 | # Mock to make file1 and file2 fail, but file3 succeed
1696 | original_sync_markdown_file = sync_service.sync_markdown_file
1697 |
1698 | async def mock_sync_markdown_file(path, new):
1699 | if "file1.md" in path or "file2.md" in path:
1700 | raise ValueError(f"Failure for {path}")
1701 | # file3 succeeds - use real implementation
1702 | return await original_sync_markdown_file(path, new)
1703 |
1704 | with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
1705 | # Fail 3 times for file1 and file2 (file3 succeeds each time)
1706 | await sync_service.sync(project_dir) # Fail count: file1=1, file2=1
1707 | await touch_file(project_dir / "file1.md") # Touch to trigger incremental scan
1708 | await touch_file(project_dir / "file2.md") # Touch to trigger incremental scan
1709 | await sync_service.sync(project_dir) # Fail count: file1=2, file2=2
1710 | await touch_file(project_dir / "file1.md") # Touch to trigger incremental scan
1711 | await touch_file(project_dir / "file2.md") # Touch to trigger incremental scan
1712 | report3 = await sync_service.sync(project_dir) # Fail count: file1=3, file2=3, now skipped
1713 |
1714 | # Both files should be skipped on third sync
1715 | assert len(report3.skipped_files) == 2
1716 | skipped_paths = {f.path for f in report3.skipped_files}
1717 | assert "file1.md" in skipped_paths
1718 | assert "file2.md" in skipped_paths
1719 |
1720 | # Verify file3 is not in failures dict
1721 | assert "file3.md" not in sync_service._file_failures
1722 |
1723 |
1724 | @pytest.mark.asyncio
1725 | async def test_circuit_breaker_handles_checksum_computation_failure(
1726 | sync_service: SyncService, project_config: ProjectConfig
1727 | ):
1728 | """Test circuit breaker behavior when checksum computation fails."""
1729 | from unittest.mock import patch
1730 |
1731 | project_dir = project_config.home
1732 | test_file = project_dir / "checksum_fail.md"
1733 | await create_test_file(test_file, "content")
1734 |
1735 | # Mock sync_markdown_file to fail
1736 | async def mock_sync_markdown_file(*args, **kwargs):
1737 | raise ValueError("Sync failure")
1738 |
1739 | # Mock checksum computation to fail only during _record_failure (not during scan)
1740 | original_compute_checksum = sync_service.file_service.compute_checksum
1741 | call_count = 0
1742 |
1743 | async def mock_compute_checksum(path):
1744 | nonlocal call_count
1745 | call_count += 1
1746 | # First call is during scan - let it succeed
1747 | if call_count == 1:
1748 | return await original_compute_checksum(path)
1749 | # Second call is during _record_failure - make it fail
1750 | raise IOError("Cannot read file")
1751 |
1752 | with (
1753 | patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file),
1754 | patch.object(
1755 | sync_service.file_service,
1756 | "compute_checksum",
1757 | side_effect=mock_compute_checksum,
1758 | ),
1759 | ):
1760 | # Should still record failure even if checksum fails
1761 | await sync_service.sync(project_dir)
1762 |
1763 | # Check that failure was recorded with empty checksum
1764 | assert "checksum_fail.md" in sync_service._file_failures
1765 | failure_info = sync_service._file_failures["checksum_fail.md"]
1766 | assert failure_info.count == 1
1767 | assert failure_info.last_checksum == "" # Empty when checksum fails
1768 |
1769 |
1770 | @pytest.mark.asyncio
1771 | async def test_sync_fatal_error_terminates_sync_immediately(
1772 | sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
1773 | ):
1774 | """Test that SyncFatalError terminates sync immediately without circuit breaker retry.
1775 |
1776 | This tests the fix for issue #188 where project deletion during sync should
1777 | terminate immediately rather than retrying each file 3 times.
1778 | """
1779 | from unittest.mock import patch
1780 | from basic_memory.services.exceptions import SyncFatalError
1781 |
1782 | project_dir = project_config.home
1783 |
1784 | # Create multiple test files
1785 | await create_test_file(
1786 | project_dir / "file1.md",
1787 | dedent(
1788 | """
1789 | ---
1790 | type: knowledge
1791 | ---
1792 | # File 1
1793 | Content 1
1794 | """
1795 | ),
1796 | )
1797 | await create_test_file(
1798 | project_dir / "file2.md",
1799 | dedent(
1800 | """
1801 | ---
1802 | type: knowledge
1803 | ---
1804 | # File 2
1805 | Content 2
1806 | """
1807 | ),
1808 | )
1809 | await create_test_file(
1810 | project_dir / "file3.md",
1811 | dedent(
1812 | """
1813 | ---
1814 | type: knowledge
1815 | ---
1816 | # File 3
1817 | Content 3
1818 | """
1819 | ),
1820 | )
1821 |
1822 | # Mock entity_service.create_entity_from_markdown to raise SyncFatalError on first file
1823 | # This simulates project being deleted during sync
1824 | async def mock_create_entity_from_markdown(*args, **kwargs):
1825 | raise SyncFatalError(
1826 | "Cannot sync file 'file1.md': project_id=99999 does not exist in database. "
1827 | "The project may have been deleted. This sync will be terminated."
1828 | )
1829 |
1830 | with patch.object(
1831 | entity_service, "create_entity_from_markdown", side_effect=mock_create_entity_from_markdown
1832 | ):
1833 | # Sync should raise SyncFatalError and terminate immediately
1834 | with pytest.raises(SyncFatalError, match="project_id=99999 does not exist"):
1835 | await sync_service.sync(project_dir)
1836 |
1837 | # Verify that circuit breaker did NOT record this as a file-level failure
1838 | # (SyncFatalError should bypass circuit breaker and re-raise immediately)
1839 | assert "file1.md" not in sync_service._file_failures
1840 |
1841 | # Verify that no other files were attempted (sync terminated on first error)
1842 | # If circuit breaker was used, we'd see file1 in failures
1843 | # If sync continued, we'd see attempts for file2 and file3
1844 |
1845 |
1846 | @pytest.mark.asyncio
1847 | async def test_scan_directory_basic(sync_service: SyncService, project_config: ProjectConfig):
1848 | """Test basic streaming directory scan functionality."""
1849 | project_dir = project_config.home
1850 |
1851 | # Create test files in different directories
1852 | await create_test_file(project_dir / "root.md", "root content")
1853 | await create_test_file(project_dir / "subdir/file1.md", "file 1 content")
1854 | await create_test_file(project_dir / "subdir/file2.md", "file 2 content")
1855 | await create_test_file(project_dir / "subdir/nested/file3.md", "file 3 content")
1856 |
1857 | # Collect results from streaming iterator
1858 | results = []
1859 | async for file_path, stat_info in sync_service.scan_directory(project_dir):
1860 | rel_path = Path(file_path).relative_to(project_dir).as_posix()
1861 | results.append((rel_path, stat_info))
1862 |
1863 | # Verify all files were found
1864 | file_paths = {rel_path for rel_path, _ in results}
1865 | assert "root.md" in file_paths
1866 | assert "subdir/file1.md" in file_paths
1867 | assert "subdir/file2.md" in file_paths
1868 | assert "subdir/nested/file3.md" in file_paths
1869 | assert len(file_paths) == 4
1870 |
1871 | # Verify stat info is present for each file
1872 | for rel_path, stat_info in results:
1873 | assert stat_info is not None
1874 | assert stat_info.st_size > 0 # Files have content
1875 | assert stat_info.st_mtime > 0 # Have modification time
1876 |
1877 |
1878 | @pytest.mark.asyncio
1879 | async def test_scan_directory_respects_ignore_patterns(
1880 | sync_service: SyncService, project_config: ProjectConfig
1881 | ):
1882 | """Test that streaming scan respects .gitignore patterns."""
1883 | project_dir = project_config.home
1884 |
1885 | # Create .gitignore file in project (will be used along with .bmignore)
1886 | (project_dir / ".gitignore").write_text("*.ignored\n.hidden/\n")
1887 |
1888 | # Reload ignore patterns using project's .gitignore
1889 | from basic_memory.ignore_utils import load_gitignore_patterns
1890 |
1891 | sync_service._ignore_patterns = load_gitignore_patterns(project_dir)
1892 |
1893 | # Create test files - some should be ignored
1894 | await create_test_file(project_dir / "included.md", "included")
1895 | await create_test_file(project_dir / "excluded.ignored", "excluded")
1896 | await create_test_file(project_dir / ".hidden/secret.md", "secret")
1897 | await create_test_file(project_dir / "subdir/file.md", "file")
1898 |
1899 | # Collect results
1900 | results = []
1901 | async for file_path, stat_info in sync_service.scan_directory(project_dir):
1902 | rel_path = Path(file_path).relative_to(project_dir).as_posix()
1903 | results.append(rel_path)
1904 |
1905 | # Verify ignored files were not returned
1906 | assert "included.md" in results
1907 | assert "subdir/file.md" in results
1908 | assert "excluded.ignored" not in results
1909 | assert ".hidden/secret.md" not in results
1910 | assert ".bmignore" not in results # .bmignore itself should be ignored
1911 |
1912 |
1913 | @pytest.mark.asyncio
1914 | async def test_scan_directory_cached_stat_info(
1915 | sync_service: SyncService, project_config: ProjectConfig
1916 | ):
1917 | """Test that streaming scan provides cached stat info (no redundant stat calls)."""
1918 | project_dir = project_config.home
1919 |
1920 | # Create test file
1921 | test_file = project_dir / "test.md"
1922 | await create_test_file(test_file, "test content")
1923 |
1924 | # Get stat info from streaming scan
1925 | async for file_path, stat_info in sync_service.scan_directory(project_dir):
1926 | if Path(file_path).name == "test.md":
1927 | # Get independent stat for comparison
1928 | independent_stat = test_file.stat()
1929 |
1930 | # Verify stat info matches (cached stat should be accurate)
1931 | assert stat_info.st_size == independent_stat.st_size
1932 | assert abs(stat_info.st_mtime - independent_stat.st_mtime) < 1 # Allow 1s tolerance
1933 | assert abs(stat_info.st_ctime - independent_stat.st_ctime) < 1
1934 | break
1935 |
1936 |
1937 | @pytest.mark.asyncio
1938 | async def test_scan_directory_empty_directory(
1939 | sync_service: SyncService, project_config: ProjectConfig
1940 | ):
1941 | """Test streaming scan on empty directory (ignoring hidden files)."""
1942 | project_dir = project_config.home
1943 |
1944 | # Directory exists but has no user files (may have .basic-memory config dir)
1945 | assert project_dir.exists()
1946 |
1947 | # Don't create any user files - just scan empty directory
1948 | # Scan should yield no results (hidden files are ignored by default)
1949 | results = []
1950 | async for file_path, stat_info in sync_service.scan_directory(project_dir):
1951 | results.append(file_path)
1952 |
1953 | # Should find no files (config dirs are hidden and ignored)
1954 | assert len(results) == 0
1955 |
1956 |
1957 | @pytest.mark.asyncio
1958 | async def test_scan_directory_handles_permission_error(
1959 | sync_service: SyncService, project_config: ProjectConfig
1960 | ):
1961 | """Test that streaming scan handles permission errors gracefully."""
1962 | import sys
1963 |
1964 | # Skip on Windows - permission handling is different
1965 | if sys.platform == "win32":
1966 | pytest.skip("Permission tests not reliable on Windows")
1967 |
1968 | project_dir = project_config.home
1969 |
1970 | # Create accessible file
1971 | await create_test_file(project_dir / "accessible.md", "accessible")
1972 |
1973 | # Create restricted directory
1974 | restricted_dir = project_dir / "restricted"
1975 | restricted_dir.mkdir()
1976 | await create_test_file(restricted_dir / "secret.md", "secret")
1977 |
1978 | # Remove read permission from restricted directory
1979 | restricted_dir.chmod(0o000)
1980 |
1981 | try:
1982 | # Scan should handle permission error and continue
1983 | results = []
1984 | async for file_path, stat_info in sync_service.scan_directory(project_dir):
1985 | rel_path = Path(file_path).relative_to(project_dir).as_posix()
1986 | results.append(rel_path)
1987 |
1988 | # Should have found accessible file but not restricted one
1989 | assert "accessible.md" in results
1990 | assert "restricted/secret.md" not in results
1991 |
1992 | finally:
1993 | # Restore permissions for cleanup
1994 | restricted_dir.chmod(0o755)
1995 |
1996 |
1997 | @pytest.mark.asyncio
1998 | async def test_scan_directory_non_markdown_files(
1999 | sync_service: SyncService, project_config: ProjectConfig
2000 | ):
2001 | """Test that streaming scan finds all file types, not just markdown."""
2002 | project_dir = project_config.home
2003 |
2004 | # Create various file types
2005 | await create_test_file(project_dir / "doc.md", "markdown")
2006 | (project_dir / "image.png").write_bytes(b"PNG content")
2007 | (project_dir / "data.json").write_text('{"key": "value"}')
2008 | (project_dir / "script.py").write_text("print('hello')")
2009 |
2010 | # Collect results
2011 | results = []
2012 | async for file_path, stat_info in sync_service.scan_directory(project_dir):
2013 | rel_path = Path(file_path).relative_to(project_dir).as_posix()
2014 | results.append(rel_path)
2015 |
2016 | # All files should be found
2017 | assert "doc.md" in results
2018 | assert "image.png" in results
2019 | assert "data.json" in results
2020 | assert "script.py" in results
2021 |
2022 |
2023 | @pytest.mark.asyncio
2024 | async def test_file_service_checksum_correctness(
2025 | sync_service: SyncService, project_config: ProjectConfig
2026 | ):
2027 | """Test that FileService computes correct checksums."""
2028 | import hashlib
2029 |
2030 | project_dir = project_config.home
2031 |
2032 | # Test small markdown file
2033 | small_content = "Test content for checksum validation" * 10
2034 | small_file = project_dir / "small.md"
2035 | await create_test_file(small_file, small_content)
2036 |
2037 | rel_path = small_file.relative_to(project_dir).as_posix()
2038 | checksum = await sync_service.file_service.compute_checksum(rel_path)
2039 |
2040 | # Verify checksum is correct
2041 | expected = hashlib.sha256(small_content.encode("utf-8")).hexdigest()
2042 | assert checksum == expected
2043 | assert len(checksum) == 64 # SHA256 hex digest length
2044 |
```