#
tokens: 44567/50000 2/348 files (page 21/23)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 21/23FirstPrevNextLast