"""Tests for EntityService.""" from pathlib import Path from textwrap import dedent import pytest import yaml from basic_memory.config import ProjectConfig, BasicMemoryConfig from basic_memory.markdown import EntityParser from basic_memory.models import Entity as EntityModel from basic_memory.repository import EntityRepository from basic_memory.schemas import Entity as EntitySchema from basic_memory.services import FileService from basic_memory.services.entity_service import EntityService from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError from basic_memory.utils import generate_permalink @pytest.mark.asyncio async def test_create_entity(entity_service: EntityService, file_service: FileService): """Test successful entity creation.""" entity_data = EntitySchema( title="Test Entity", folder="", entity_type="test", ) # Act entity = await entity_service.create_entity(entity_data) # Assert Entity assert isinstance(entity, EntityModel) assert entity.permalink == entity_data.permalink assert entity.file_path == entity_data.file_path assert entity.entity_type == "test" assert entity.created_at is not None assert len(entity.relations) == 0 # Verify we can retrieve it using permalink retrieved = await entity_service.get_by_permalink(entity_data.permalink) assert retrieved.title == "Test Entity" assert retrieved.entity_type == "test" assert retrieved.created_at is not None # Verify file was written file_path = file_service.get_entity_path(entity) assert await file_service.exists(file_path) file_content, _ = await file_service.read_file(file_path) _, frontmatter, doc_content = file_content.split("---", 2) metadata = yaml.safe_load(frontmatter) # Verify frontmatter contents assert metadata["permalink"] == entity.permalink assert metadata["type"] == entity.entity_type @pytest.mark.asyncio async def test_create_entity_file_exists(entity_service: EntityService, file_service: FileService): """Test successful entity creation.""" entity_data = EntitySchema( title="Test Entity", folder="", entity_type="test", content="first", ) # Act entity = await entity_service.create_entity(entity_data) # Verify file was written file_path = file_service.get_entity_path(entity) assert await file_service.exists(file_path) file_content, _ = await file_service.read_file(file_path) assert ( "---\ntitle: Test Entity\ntype: test\npermalink: test-entity\n---\n\nfirst" == file_content ) entity_data = EntitySchema( title="Test Entity", folder="", entity_type="test", content="second", ) with pytest.raises(EntityCreationError): await entity_service.create_entity(entity_data) @pytest.mark.asyncio async def test_create_entity_unique_permalink( project_config, entity_service: EntityService, file_service: FileService, entity_repository: EntityRepository, ): """Test successful entity creation.""" entity_data = EntitySchema( title="Test Entity", folder="test", entity_type="test", ) entity = await entity_service.create_entity(entity_data) # default permalink assert entity.permalink == generate_permalink(entity.file_path) # move file file_path = file_service.get_entity_path(entity) file_path.rename(project_config.home / "new_path.md") await entity_repository.update(entity.id, {"file_path": "new_path.md"}) # create again entity2 = await entity_service.create_entity(entity_data) assert entity2.permalink == f"{entity.permalink}-1" file_path = file_service.get_entity_path(entity2) file_content, _ = await file_service.read_file(file_path) _, frontmatter, doc_content = file_content.split("---", 2) metadata = yaml.safe_load(frontmatter) # Verify frontmatter contents assert metadata["permalink"] == entity2.permalink @pytest.mark.asyncio async def test_get_by_permalink(entity_service: EntityService): """Test finding entity by type and name combination.""" entity1_data = EntitySchema( title="TestEntity1", folder="test", entity_type="test", ) entity1 = await entity_service.create_entity(entity1_data) entity2_data = EntitySchema( title="TestEntity2", folder="test", entity_type="test", ) entity2 = await entity_service.create_entity(entity2_data) # Find by type1 and name found = await entity_service.get_by_permalink(entity1_data.permalink) assert found is not None assert found.id == entity1.id assert found.entity_type == entity1.entity_type # Find by type2 and name found = await entity_service.get_by_permalink(entity2_data.permalink) assert found is not None assert found.id == entity2.id assert found.entity_type == entity2.entity_type # Test not found case with pytest.raises(EntityNotFoundError): await entity_service.get_by_permalink("nonexistent/test_entity") @pytest.mark.asyncio async def test_get_entity_success(entity_service: EntityService): """Test successful entity retrieval.""" entity_data = EntitySchema( title="TestEntity", folder="test", entity_type="test", ) await entity_service.create_entity(entity_data) # Get by permalink retrieved = await entity_service.get_by_permalink(entity_data.permalink) assert isinstance(retrieved, EntityModel) assert retrieved.title == "TestEntity" assert retrieved.entity_type == "test" @pytest.mark.asyncio async def test_delete_entity_success(entity_service: EntityService): """Test successful entity deletion.""" entity_data = EntitySchema( title="TestEntity", folder="test", entity_type="test", ) await entity_service.create_entity(entity_data) # Act using permalink result = await entity_service.delete_entity(entity_data.permalink) # Assert assert result is True with pytest.raises(EntityNotFoundError): await entity_service.get_by_permalink(entity_data.permalink) @pytest.mark.asyncio async def test_delete_entity_by_id(entity_service: EntityService): """Test successful entity deletion.""" entity_data = EntitySchema( title="TestEntity", folder="test", entity_type="test", ) created = await entity_service.create_entity(entity_data) # Act using permalink result = await entity_service.delete_entity(created.id) # Assert assert result is True with pytest.raises(EntityNotFoundError): await entity_service.get_by_permalink(entity_data.permalink) @pytest.mark.asyncio async def test_get_entity_by_permalink_not_found(entity_service: EntityService): """Test handling of non-existent entity retrieval.""" with pytest.raises(EntityNotFoundError): await entity_service.get_by_permalink("test/non_existent") @pytest.mark.asyncio async def test_delete_nonexistent_entity(entity_service: EntityService): """Test deleting an entity that doesn't exist.""" assert await entity_service.delete_entity("test/non_existent") is True @pytest.mark.asyncio async def test_create_entity_with_special_chars(entity_service: EntityService): """Test entity creation with special characters in name and description.""" name = "TestEntity_$pecial chars & symbols!" # Note: Using valid path characters entity_data = EntitySchema( title=name, folder="test", entity_type="test", ) entity = await entity_service.create_entity(entity_data) assert entity.title == name # Verify after retrieval using permalink await entity_service.get_by_permalink(entity_data.permalink) @pytest.mark.asyncio async def test_get_entities_by_permalinks(entity_service: EntityService): """Test opening multiple entities by path IDs.""" # Create test entities entity1_data = EntitySchema( title="Entity1", folder="test", entity_type="test", ) entity2_data = EntitySchema( title="Entity2", folder="test", entity_type="test", ) await entity_service.create_entity(entity1_data) await entity_service.create_entity(entity2_data) # Open nodes by path IDs permalinks = [entity1_data.permalink, entity2_data.permalink] found = await entity_service.get_entities_by_permalinks(permalinks) assert len(found) == 2 names = {e.title for e in found} assert names == {"Entity1", "Entity2"} @pytest.mark.asyncio async def test_get_entities_empty_input(entity_service: EntityService): """Test opening nodes with empty path ID list.""" found = await entity_service.get_entities_by_permalinks([]) assert len(found) == 0 @pytest.mark.asyncio async def test_get_entities_some_not_found(entity_service: EntityService): """Test opening nodes with mix of existing and non-existent path IDs.""" # Create one test entity entity_data = EntitySchema( title="Entity1", folder="test", entity_type="test", ) await entity_service.create_entity(entity_data) # Try to open two nodes, one exists, one doesn't permalinks = [entity_data.permalink, "type1/non_existent"] found = await entity_service.get_entities_by_permalinks(permalinks) assert len(found) == 1 assert found[0].title == "Entity1" @pytest.mark.asyncio async def test_get_entity_path(entity_service: EntityService): """Should generate correct filesystem path for entity.""" entity = EntityModel( permalink="test-entity", file_path="test-entity.md", entity_type="test", ) path = entity_service.file_service.get_entity_path(entity) assert path == Path(entity_service.file_service.base_path / "test-entity.md") @pytest.mark.asyncio async def test_update_note_entity_content(entity_service: EntityService, file_service: FileService): """Should update note content directly.""" # Create test entity schema = EntitySchema( title="test", folder="test", entity_type="note", entity_metadata={"status": "draft"}, ) entity = await entity_service.create_entity(schema) assert entity.entity_metadata.get("status") == "draft" # Update content with a relation schema.content = """ # Updated [[Content]] - references [[new content]] - [note] This is new content. """ updated = await entity_service.update_entity(entity, schema) # Verify file has new content but preserved metadata file_path = file_service.get_entity_path(updated) content, _ = await file_service.read_file(file_path) assert "# Updated [[Content]]" in content assert "- references [[new content]]" in content assert "- [note] This is new content" in content # Verify metadata was preserved _, frontmatter, _ = content.split("---", 2) metadata = yaml.safe_load(frontmatter) assert metadata.get("status") == "draft" @pytest.mark.asyncio async def test_create_or_update_new(entity_service: EntityService, file_service: FileService): """Should create a new entity.""" # Create test entity entity, created = await entity_service.create_or_update_entity( EntitySchema( title="test", folder="test", entity_type="test", entity_metadata={"status": "draft"}, ) ) assert entity.title == "test" assert created is True @pytest.mark.asyncio async def test_create_or_update_existing(entity_service: EntityService, file_service: FileService): """Should update entity name in both DB and frontmatter.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="test", folder="test", entity_type="test", content="Test entity", entity_metadata={"status": "final"}, ) ) entity.content = "Updated content" # Update name updated, created = await entity_service.create_or_update_entity(entity) assert updated.title == "test" assert updated.entity_metadata["status"] == "final" assert created is False @pytest.mark.asyncio async def test_create_with_content(entity_service: EntityService, file_service: FileService): # contains frontmatter content = dedent( """ --- permalink: git-workflow-guide --- # Git Workflow Guide A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]]. ## Best Practices Use branches effectively: - [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts) - implements [[Branch Strategy]] (Our standard workflow) ## Common Commands See the [[Git Cheat Sheet]] for reference. """ ) # Create test entity entity, created = await entity_service.create_or_update_entity( EntitySchema( title="Git Workflow Guide", folder="test", entity_type="test", content=content, ) ) assert created is True assert entity.title == "Git Workflow Guide" assert entity.entity_type == "test" assert entity.permalink == "git-workflow-guide" assert entity.file_path == "test/Git Workflow Guide.md" assert len(entity.observations) == 1 assert entity.observations[0].category == "design" assert entity.observations[0].content == "Keep feature branches short-lived #git #workflow" assert set(entity.observations[0].tags) == {"git", "workflow"} assert entity.observations[0].context == "Reduces merge conflicts" assert len(entity.relations) == 4 assert entity.relations[0].relation_type == "links to" assert entity.relations[0].to_name == "Git" assert entity.relations[1].relation_type == "links to" assert entity.relations[1].to_name == "Trunk Based Development" assert entity.relations[2].relation_type == "implements" assert entity.relations[2].to_name == "Branch Strategy" assert entity.relations[2].context == "Our standard workflow" assert entity.relations[3].relation_type == "links to" assert entity.relations[3].to_name == "Git Cheat Sheet" # Verify file has new content but preserved metadata file_path = file_service.get_entity_path(entity) file_content, _ = await file_service.read_file(file_path) # assert file # note the permalink value is corrected expected = dedent(""" --- title: Git Workflow Guide type: test permalink: git-workflow-guide --- # Git Workflow Guide A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]]. ## Best Practices Use branches effectively: - [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts) - implements [[Branch Strategy]] (Our standard workflow) ## Common Commands See the [[Git Cheat Sheet]] for reference. """).strip() assert expected == file_content @pytest.mark.asyncio async def test_update_with_content(entity_service: EntityService, file_service: FileService): content = """# Git Workflow Guide""" # Create test entity entity, created = await entity_service.create_or_update_entity( EntitySchema( title="Git Workflow Guide", entity_type="test", folder="test", content=content, ) ) assert created is True assert entity.title == "Git Workflow Guide" assert len(entity.observations) == 0 assert len(entity.relations) == 0 # Verify file has new content but preserved metadata file_path = file_service.get_entity_path(entity) file_content, _ = await file_service.read_file(file_path) # assert content is in file assert ( dedent( """ --- title: Git Workflow Guide type: test permalink: test/git-workflow-guide --- # Git Workflow Guide """ ).strip() == file_content ) # now update the content update_content = dedent( """ --- title: Git Workflow Guide type: test permalink: git-workflow-guide --- # Git Workflow Guide A guide to our [[Git]] workflow. This uses some ideas from [[Trunk Based Development]]. ## Best Practices Use branches effectively: - [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts) - implements [[Branch Strategy]] (Our standard workflow) ## Common Commands See the [[Git Cheat Sheet]] for reference. """ ).strip() # update entity entity, created = await entity_service.create_or_update_entity( EntitySchema( title="Git Workflow Guide", folder="test", entity_type="test", content=update_content, ) ) assert created is False assert entity.title == "Git Workflow Guide" # assert custom permalink value assert entity.permalink == "git-workflow-guide" assert len(entity.observations) == 1 assert entity.observations[0].category == "design" assert entity.observations[0].content == "Keep feature branches short-lived #git #workflow" assert set(entity.observations[0].tags) == {"git", "workflow"} assert entity.observations[0].context == "Reduces merge conflicts" assert len(entity.relations) == 4 assert entity.relations[0].relation_type == "links to" assert entity.relations[0].to_name == "Git" assert entity.relations[1].relation_type == "links to" assert entity.relations[1].to_name == "Trunk Based Development" assert entity.relations[2].relation_type == "implements" assert entity.relations[2].to_name == "Branch Strategy" assert entity.relations[2].context == "Our standard workflow" assert entity.relations[3].relation_type == "links to" assert entity.relations[3].to_name == "Git Cheat Sheet" # Verify file has new content but preserved metadata file_path = file_service.get_entity_path(entity) file_content, _ = await file_service.read_file(file_path) # assert content is in file assert update_content.strip() == file_content @pytest.mark.asyncio async def test_create_with_no_frontmatter( project_config: ProjectConfig, entity_parser: EntityParser, entity_service: EntityService, file_service: FileService, ): # contains no frontmatter content = "# Git Workflow Guide" file_path = Path("test/Git Workflow Guide.md") full_path = project_config.home / file_path await file_service.write_file(Path(full_path), content) entity_markdown = await entity_parser.parse_file(full_path) created = await entity_service.create_entity_from_markdown(file_path, entity_markdown) file_content, _ = await file_service.read_file(created.file_path) assert str(file_path) == str(created.file_path) assert created.title == "Git Workflow Guide" assert created.entity_type == "note" assert created.permalink is None # assert file expected = dedent(""" # Git Workflow Guide """).strip() assert expected == file_content @pytest.mark.asyncio async def test_edit_entity_append(entity_service: EntityService, file_service: FileService): """Test appending content to an entity.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="Original content", ) ) # Edit entity with append operation updated = await entity_service.edit_entity( identifier=entity.permalink, operation="append", content="Appended content" ) # Verify content was appended file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "Original content" in file_content assert "Appended content" in file_content assert file_content.index("Original content") < file_content.index("Appended content") @pytest.mark.asyncio async def test_edit_entity_prepend(entity_service: EntityService, file_service: FileService): """Test prepending content to an entity.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="Original content", ) ) # Edit entity with prepend operation updated = await entity_service.edit_entity( identifier=entity.permalink, operation="prepend", content="Prepended content" ) # Verify content was prepended file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "Original content" in file_content assert "Prepended content" in file_content assert file_content.index("Prepended content") < file_content.index("Original content") @pytest.mark.asyncio async def test_edit_entity_find_replace(entity_service: EntityService, file_service: FileService): """Test find and replace operation on an entity.""" # Create test entity with specific content to replace entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="This is old content that needs updating", ) ) # Edit entity with find_replace operation updated = await entity_service.edit_entity( identifier=entity.permalink, operation="find_replace", content="new content", find_text="old content", ) # Verify content was replaced file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "old content" not in file_content assert "This is new content that needs updating" in file_content @pytest.mark.asyncio async def test_edit_entity_replace_section( entity_service: EntityService, file_service: FileService ): """Test replacing a specific section in an entity.""" # Create test entity with sections content = dedent(""" # Main Title ## Section 1 Original section 1 content ## Section 2 Original section 2 content """).strip() entity = await entity_service.create_entity( EntitySchema( title="Sample Note", folder="docs", entity_type="note", content=content, ) ) # Edit entity with replace_section operation updated = await entity_service.edit_entity( identifier=entity.permalink, operation="replace_section", content="New section 1 content", section="## Section 1", ) # Verify section was replaced file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "New section 1 content" in file_content assert "Original section 1 content" not in file_content assert "Original section 2 content" in file_content # Other sections preserved @pytest.mark.asyncio async def test_edit_entity_replace_section_create_new( entity_service: EntityService, file_service: FileService ): """Test replacing a section that doesn't exist creates it.""" # Create test entity without the section entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="# Main Title\n\nSome content", ) ) # Edit entity with replace_section operation for non-existent section updated = await entity_service.edit_entity( identifier=entity.permalink, operation="replace_section", content="New section content", section="## New Section", ) # Verify section was created file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "## New Section" in file_content assert "New section content" in file_content @pytest.mark.asyncio async def test_edit_entity_not_found(entity_service: EntityService): """Test editing a non-existent entity raises error.""" with pytest.raises(EntityNotFoundError): await entity_service.edit_entity( identifier="non-existent", operation="append", content="content" ) @pytest.mark.asyncio async def test_edit_entity_invalid_operation(entity_service: EntityService): """Test editing with invalid operation raises error.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="Original content", ) ) with pytest.raises(ValueError, match="Unsupported operation"): await entity_service.edit_entity( identifier=entity.permalink, operation="invalid_operation", content="content" ) @pytest.mark.asyncio async def test_edit_entity_find_replace_missing_find_text(entity_service: EntityService): """Test find_replace operation without find_text raises error.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="Original content", ) ) with pytest.raises(ValueError, match="find_text is required"): await entity_service.edit_entity( identifier=entity.permalink, operation="find_replace", content="new content" ) @pytest.mark.asyncio async def test_edit_entity_replace_section_missing_section(entity_service: EntityService): """Test replace_section operation without section parameter raises error.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="Original content", ) ) with pytest.raises(ValueError, match="section is required"): await entity_service.edit_entity( identifier=entity.permalink, operation="replace_section", content="new content" ) @pytest.mark.asyncio async def test_edit_entity_with_observations_and_relations( entity_service: EntityService, file_service: FileService ): """Test editing entity updates observations and relations correctly.""" # Create test entity with observations and relations content = dedent(""" # Test Note - [note] This is an observation - links to [[Other Entity]] Original content """).strip() entity = await entity_service.create_entity( EntitySchema( title="Sample Note", folder="docs", entity_type="note", content=content, ) ) # Verify initial state assert len(entity.observations) == 1 assert len(entity.relations) == 1 # Edit entity by appending content with new observations/relations updated = await entity_service.edit_entity( identifier=entity.permalink, operation="append", content="\n- [category] New observation\n- relates to [[New Entity]]", ) # Verify observations and relations were updated assert len(updated.observations) == 2 assert len(updated.relations) == 2 # Check new observation new_obs = [obs for obs in updated.observations if obs.category == "category"][0] assert new_obs.content == "New observation" # Check new relation new_rel = [rel for rel in updated.relations if rel.to_name == "New Entity"][0] assert new_rel.relation_type == "relates to" @pytest.mark.asyncio async def test_create_entity_from_markdown_with_upsert( entity_service: EntityService, file_service: FileService ): """Test that create_entity_from_markdown uses UPSERT approach for conflict resolution.""" file_path = Path("test/upsert-test.md") # Create a mock EntityMarkdown object from basic_memory.markdown.schemas import ( EntityFrontmatter, EntityMarkdown as RealEntityMarkdown, ) from datetime import datetime, timezone frontmatter = EntityFrontmatter(metadata={"title": "UPSERT Test", "type": "test"}) markdown = RealEntityMarkdown( frontmatter=frontmatter, observations=[], relations=[], created=datetime.now(timezone.utc), modified=datetime.now(timezone.utc), ) # Call the method - should succeed without complex exception handling result = await entity_service.create_entity_from_markdown(file_path, markdown) # Verify it created the entity successfully using the UPSERT approach assert result is not None assert result.title == "UPSERT Test" assert result.file_path == str(file_path) # create_entity_from_markdown sets checksum to None (incomplete sync) assert result.checksum is None @pytest.mark.asyncio async def test_create_entity_from_markdown_error_handling( entity_service: EntityService, file_service: FileService ): """Test that create_entity_from_markdown handles repository errors gracefully.""" from unittest.mock import patch from basic_memory.services.exceptions import EntityCreationError file_path = Path("test/error-test.md") # Create a mock EntityMarkdown object from basic_memory.markdown.schemas import ( EntityFrontmatter, EntityMarkdown as RealEntityMarkdown, ) from datetime import datetime, timezone frontmatter = EntityFrontmatter(metadata={"title": "Error Test", "type": "test"}) markdown = RealEntityMarkdown( frontmatter=frontmatter, observations=[], relations=[], created=datetime.now(timezone.utc), modified=datetime.now(timezone.utc), ) # Mock the repository.upsert_entity to raise a general error async def mock_upsert(*args, **kwargs): # Simulate a general database error raise Exception("Database connection failed") with patch.object(entity_service.repository, "upsert_entity", side_effect=mock_upsert): # Should wrap the error in EntityCreationError with pytest.raises(EntityCreationError, match="Failed to create entity"): await entity_service.create_entity_from_markdown(file_path, markdown) # Edge case tests for find_replace operation @pytest.mark.asyncio async def test_edit_entity_find_replace_not_found(entity_service: EntityService): """Test find_replace operation when text is not found.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="This is some content", ) ) # Try to replace text that doesn't exist with pytest.raises(ValueError, match="Text to replace not found: 'nonexistent'"): await entity_service.edit_entity( identifier=entity.permalink, operation="find_replace", content="new content", find_text="nonexistent", ) @pytest.mark.asyncio async def test_edit_entity_find_replace_multiple_occurrences_expected_one( entity_service: EntityService, ): """Test find_replace with multiple occurrences when expecting one.""" # Create entity with repeated text (avoiding "test" since it appears in frontmatter) entity = await entity_service.create_entity( EntitySchema( title="Sample Note", folder="docs", entity_type="note", content="The word banana appears here. Another banana word here.", ) ) # Try to replace with expected count of 1 when there are 2 with pytest.raises(ValueError, match="Expected 1 occurrences of 'banana', but found 2"): await entity_service.edit_entity( identifier=entity.permalink, operation="find_replace", content="replacement", find_text="banana", expected_replacements=1, ) @pytest.mark.asyncio async def test_edit_entity_find_replace_multiple_occurrences_success( entity_service: EntityService, file_service: FileService ): """Test find_replace with multiple occurrences when expected count matches.""" # Create test entity with repeated text (avoiding "test" since it appears in frontmatter) entity = await entity_service.create_entity( EntitySchema( title="Sample Note", folder="docs", entity_type="note", content="The word banana appears here. Another banana word here.", ) ) # Replace with correct expected count updated = await entity_service.edit_entity( identifier=entity.permalink, operation="find_replace", content="apple", find_text="banana", expected_replacements=2, ) # Verify both instances were replaced file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "The word apple appears here. Another apple word here." in file_content @pytest.mark.asyncio async def test_edit_entity_find_replace_empty_find_text(entity_service: EntityService): """Test find_replace with empty find_text.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="Some content", ) ) # Try with empty find_text with pytest.raises(ValueError, match="find_text cannot be empty or whitespace only"): await entity_service.edit_entity( identifier=entity.permalink, operation="find_replace", content="new content", find_text=" ", # whitespace only ) @pytest.mark.asyncio async def test_edit_entity_find_replace_multiline( entity_service: EntityService, file_service: FileService ): """Test find_replace with multiline text.""" # Create test entity with multiline content content = dedent(""" # Title This is a paragraph that spans multiple lines and needs replacement. Other content. """).strip() entity = await entity_service.create_entity( EntitySchema( title="Sample Note", folder="docs", entity_type="note", content=content, ) ) # Replace multiline text find_text = "This is a paragraph\nthat spans multiple lines\nand needs replacement." new_text = "This is new content\nthat replaces the old paragraph." updated = await entity_service.edit_entity( identifier=entity.permalink, operation="find_replace", content=new_text, find_text=find_text ) # Verify replacement worked file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "This is new content\nthat replaces the old paragraph." in file_content assert "Other content." in file_content # Make sure rest is preserved # Edge case tests for replace_section operation @pytest.mark.asyncio async def test_edit_entity_replace_section_multiple_sections_error(entity_service: EntityService): """Test replace_section with multiple sections having same header.""" # Create test entity with duplicate section headers content = dedent(""" # Main Title ## Section 1 First instance content ## Section 2 Some content ## Section 1 Second instance content """).strip() entity = await entity_service.create_entity( EntitySchema( title="Sample Note", folder="docs", entity_type="note", content=content, ) ) # Try to replace section when multiple exist with pytest.raises(ValueError, match="Multiple sections found with header '## Section 1'"): await entity_service.edit_entity( identifier=entity.permalink, operation="replace_section", content="New content", section="## Section 1", ) @pytest.mark.asyncio async def test_edit_entity_replace_section_empty_section(entity_service: EntityService): """Test replace_section with empty section parameter.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="Some content", ) ) # Try with empty section with pytest.raises(ValueError, match="section cannot be empty or whitespace only"): await entity_service.edit_entity( identifier=entity.permalink, operation="replace_section", content="new content", section=" ", # whitespace only ) @pytest.mark.asyncio async def test_edit_entity_replace_section_header_variations( entity_service: EntityService, file_service: FileService ): """Test replace_section with different header formatting.""" # Create entity with various header formats (avoiding "test" in frontmatter) content = dedent(""" # Main Title ## Section Name Original content ### Subsection Sub content """).strip() entity = await entity_service.create_entity( EntitySchema( title="Sample Note", folder="docs", entity_type="note", content=content, ) ) # Test replacing with different header format (no ##) updated = await entity_service.edit_entity( identifier=entity.permalink, operation="replace_section", content="New section content", section="Section Name", # No ## prefix ) # Verify replacement worked file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "New section content" in file_content assert "Original content" not in file_content assert "### Subsection" in file_content # Subsection preserved @pytest.mark.asyncio async def test_edit_entity_replace_section_at_end_of_document( entity_service: EntityService, file_service: FileService ): """Test replace_section when section is at the end of document.""" # Create test entity with section at end content = dedent(""" # Main Title ## First Section First content ## Last Section Last section content""").strip() # No trailing newline entity = await entity_service.create_entity( EntitySchema( title="Sample Note", folder="docs", entity_type="note", content=content, ) ) # Replace the last section updated = await entity_service.edit_entity( identifier=entity.permalink, operation="replace_section", content="New last section content", section="## Last Section", ) # Verify replacement worked file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "New last section content" in file_content assert "Last section content" not in file_content assert "First content" in file_content # Previous section preserved @pytest.mark.asyncio async def test_edit_entity_replace_section_with_subsections( entity_service: EntityService, file_service: FileService ): """Test replace_section preserves subsections (stops at any header).""" # Create test entity with nested sections content = dedent(""" # Main Title ## Parent Section Parent content ### Child Section 1 Child 1 content ### Child Section 2 Child 2 content ## Another Section Other content """).strip() entity = await entity_service.create_entity( EntitySchema( title="Sample Note", folder="docs", entity_type="note", content=content, ) ) # Replace parent section (should only replace content until first subsection) updated = await entity_service.edit_entity( identifier=entity.permalink, operation="replace_section", content="New parent content", section="## Parent Section", ) # Verify replacement worked - only immediate content replaced, subsections preserved file_path = file_service.get_entity_path(updated) file_content, _ = await file_service.read_file(file_path) assert "New parent content" in file_content assert "Parent content" not in file_content # Original content replaced assert "Child 1 content" in file_content # Child sections preserved assert "Child 2 content" in file_content # Child sections preserved assert "## Another Section" in file_content # Next section preserved assert "Other content" in file_content # Move entity tests @pytest.mark.asyncio async def test_move_entity_success( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, ): """Test successful entity move with basic settings.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="original", entity_type="note", content="Original content", ) ) # Verify original file exists original_path = file_service.get_entity_path(entity) assert await file_service.exists(original_path) # Create app config with permalinks disabled app_config = BasicMemoryConfig(update_permalinks_on_move=False) # Move entity assert entity.permalink == "original/test-note" await entity_service.move_entity( identifier=entity.permalink, destination_path="moved/test-note.md", project_config=project_config, app_config=app_config, ) # Verify original file no longer exists assert not await file_service.exists(original_path) # Verify new file exists new_path = project_config.home / "moved/test-note.md" assert new_path.exists() # Verify database was updated updated_entity = await entity_service.get_by_permalink(entity.permalink) assert updated_entity.file_path == "moved/test-note.md" # Verify file content is preserved new_content, _ = await file_service.read_file("moved/test-note.md") assert "Original content" in new_content @pytest.mark.asyncio async def test_move_entity_with_permalink_update( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, ): """Test entity move with permalink updates enabled.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="original", entity_type="note", content="Original content", ) ) original_permalink = entity.permalink # Create app config with permalinks enabled app_config = BasicMemoryConfig(update_permalinks_on_move=True) # Move entity await entity_service.move_entity( identifier=entity.permalink, destination_path="moved/test-note.md", project_config=project_config, app_config=app_config, ) # Verify entity was found by new path (since permalink changed) moved_entity = await entity_service.link_resolver.resolve_link("moved/test-note.md") assert moved_entity is not None assert moved_entity.file_path == "moved/test-note.md" assert moved_entity.permalink != original_permalink # Verify frontmatter was updated with new permalink new_content, _ = await file_service.read_file("moved/test-note.md") assert moved_entity.permalink in new_content @pytest.mark.asyncio async def test_move_entity_creates_destination_directory( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, ): """Test that moving creates destination directory if it doesn't exist.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="original", entity_type="note", content="Original content", ) ) app_config = BasicMemoryConfig(update_permalinks_on_move=False) # Move to deeply nested path that doesn't exist await entity_service.move_entity( identifier=entity.permalink, destination_path="deeply/nested/folders/test-note.md", project_config=project_config, app_config=app_config, ) # Verify directory was created new_path = project_config.home / "deeply/nested/folders/test-note.md" assert new_path.exists() assert new_path.parent.exists() @pytest.mark.asyncio async def test_move_entity_not_found( entity_service: EntityService, project_config: ProjectConfig, ): """Test moving non-existent entity raises error.""" app_config = BasicMemoryConfig(update_permalinks_on_move=False) with pytest.raises(EntityNotFoundError, match="Entity not found: non-existent"): await entity_service.move_entity( identifier="non-existent", destination_path="new/path.md", project_config=project_config, app_config=app_config, ) @pytest.mark.asyncio async def test_move_entity_source_file_missing( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, ): """Test moving when source file doesn't exist on filesystem.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="Original content", ) ) # Manually delete the file (simulating corruption/external deletion) file_path = file_service.get_entity_path(entity) file_path.unlink() app_config = BasicMemoryConfig(update_permalinks_on_move=False) with pytest.raises(ValueError, match="Source file not found:"): await entity_service.move_entity( identifier=entity.permalink, destination_path="new/path.md", project_config=project_config, app_config=app_config, ) @pytest.mark.asyncio async def test_move_entity_destination_exists( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, ): """Test moving to existing destination fails.""" # Create two test entities entity1 = await entity_service.create_entity( EntitySchema( title="Test Note 1", folder="test", entity_type="note", content="Content 1", ) ) entity2 = await entity_service.create_entity( EntitySchema( title="Test Note 2", folder="test", entity_type="note", content="Content 2", ) ) app_config = BasicMemoryConfig(update_permalinks_on_move=False) # Try to move entity1 to entity2's location with pytest.raises(ValueError, match="Destination already exists:"): await entity_service.move_entity( identifier=entity1.permalink, destination_path=entity2.file_path, project_config=project_config, app_config=app_config, ) @pytest.mark.asyncio async def test_move_entity_invalid_destination_path( entity_service: EntityService, project_config: ProjectConfig, ): """Test moving with invalid destination paths.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="test", entity_type="note", content="Original content", ) ) app_config = BasicMemoryConfig(update_permalinks_on_move=False) # Test absolute path with pytest.raises(ValueError, match="Invalid destination path:"): await entity_service.move_entity( identifier=entity.permalink, destination_path="/absolute/path.md", project_config=project_config, app_config=app_config, ) # Test empty path with pytest.raises(ValueError, match="Invalid destination path:"): await entity_service.move_entity( identifier=entity.permalink, destination_path="", project_config=project_config, app_config=app_config, ) @pytest.mark.asyncio async def test_move_entity_by_title( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, app_config: BasicMemoryConfig, ): """Test moving entity by title instead of permalink.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="original", entity_type="note", content="Original content", ) ) app_config = BasicMemoryConfig(update_permalinks_on_move=False) # Move by title await entity_service.move_entity( identifier="Test Note", # Use title instead of permalink destination_path="moved/test-note.md", project_config=project_config, app_config=app_config, ) # Verify old path no longer exists new_path = project_config.home / entity.file_path assert not new_path.exists() # Verify new file exists new_path = project_config.home / "moved/test-note.md" assert new_path.exists() @pytest.mark.asyncio async def test_move_entity_preserves_observations_and_relations( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, ): """Test that moving preserves entity observations and relations.""" # Create test entity with observations and relations content = dedent(""" # Test Note - [note] This is an observation #test - links to [[Other Entity]] Original content """).strip() entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="original", entity_type="note", content=content, ) ) # Verify initial observations and relations assert len(entity.observations) == 1 assert len(entity.relations) == 1 app_config = BasicMemoryConfig(update_permalinks_on_move=False) # Move entity await entity_service.move_entity( identifier=entity.permalink, destination_path="moved/test-note.md", project_config=project_config, app_config=app_config, ) # Get moved entity moved_entity = await entity_service.link_resolver.resolve_link("moved/test-note.md") # Verify observations and relations are preserved assert len(moved_entity.observations) == 1 assert moved_entity.observations[0].content == "This is an observation #test" assert len(moved_entity.relations) == 1 assert moved_entity.relations[0].to_name == "Other Entity" # Verify file content includes observations and relations new_content, _ = await file_service.read_file("moved/test-note.md") assert "- [note] This is an observation #test" in new_content assert "- links to [[Other Entity]]" in new_content @pytest.mark.asyncio async def test_move_entity_rollback_on_database_failure( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, entity_repository: EntityRepository, ): """Test that filesystem changes are rolled back on database failures.""" # Create test entity entity = await entity_service.create_entity( EntitySchema( title="Test Note", folder="original", entity_type="note", content="Original content", ) ) original_path = file_service.get_entity_path(entity) assert await file_service.exists(original_path) app_config = BasicMemoryConfig(update_permalinks_on_move=False) # Mock repository update to fail original_update = entity_repository.update async def failing_update(*args, **kwargs): return None # Simulate failure entity_repository.update = failing_update try: with pytest.raises(ValueError, match="Move failed:"): await entity_service.move_entity( identifier=entity.permalink, destination_path="moved/test-note.md", project_config=project_config, app_config=app_config, ) # Verify rollback - original file should still exist assert await file_service.exists(original_path) # Verify destination file was cleaned up destination_path = project_config.home / "moved/test-note.md" assert not destination_path.exists() finally: # Restore original update method entity_repository.update = original_update @pytest.mark.asyncio async def test_move_entity_with_complex_observations( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, ): """Test moving entity with complex observations (tags, context).""" content = dedent(""" # Complex Note - [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts) - [tech] Using SQLite for storage #implementation (Fast and reliable) - implements [[Branch Strategy]] (Our standard workflow) Complex content with [[Multiple]] [[Links]]. """).strip() entity = await entity_service.create_entity( EntitySchema( title="Complex Note", folder="docs", entity_type="note", content=content, ) ) # Verify complex structure assert len(entity.observations) == 2 assert len(entity.relations) == 3 # 1 explicit + 2 wikilinks app_config = BasicMemoryConfig(update_permalinks_on_move=False) # Move entity await entity_service.move_entity( identifier=entity.permalink, destination_path="moved/complex-note.md", project_config=project_config, app_config=app_config, ) # Verify moved entity maintains structure moved_entity = await entity_service.link_resolver.resolve_link("moved/complex-note.md") # Check observations with tags and context design_obs = [obs for obs in moved_entity.observations if obs.category == "design"][0] assert "git" in design_obs.tags assert "workflow" in design_obs.tags assert design_obs.context == "Reduces merge conflicts" tech_obs = [obs for obs in moved_entity.observations if obs.category == "tech"][0] assert "implementation" in tech_obs.tags assert tech_obs.context == "Fast and reliable" # Check relations relation_types = {rel.relation_type for rel in moved_entity.relations} assert "implements" in relation_types assert "links to" in relation_types relation_targets = {rel.to_name for rel in moved_entity.relations} assert "Branch Strategy" in relation_targets assert "Multiple" in relation_targets assert "Links" in relation_targets @pytest.mark.asyncio async def test_move_entity_with_null_permalink_generates_permalink( entity_service: EntityService, project_config: ProjectConfig, entity_repository: EntityRepository, ): """Test that moving entity with null permalink generates a new permalink automatically. This tests the fix for issue #155 where entities with null permalinks from the database migration would fail validation when being moved. The fix ensures that entities with null permalinks get a generated permalink during move operations, regardless of the update_permalinks_on_move setting. """ # Create entity through direct database insertion to simulate migrated entity with null permalink from datetime import datetime, timezone # Create an entity with null permalink directly in database (simulating migrated data) entity_data = { "title": "Test Entity", "file_path": "test/null-permalink-entity.md", "entity_type": "note", "content_type": "text/markdown", "permalink": None, # This is the key - null permalink from migration "created_at": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc), } # Create the entity directly in database created_entity = await entity_repository.create(entity_data) assert created_entity.permalink is None # Create the physical file file_path = project_config.home / created_entity.file_path file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text("# Test Entity\n\nContent here.") # Configure move without permalink updates (the default setting that previously triggered the bug) app_config = BasicMemoryConfig(update_permalinks_on_move=False) # Move entity - this should now succeed and generate a permalink moved_entity = await entity_service.move_entity( identifier=created_entity.title, # Use title since permalink is None destination_path="moved/test-entity.md", project_config=project_config, app_config=app_config, ) # Verify the move succeeded and a permalink was generated assert moved_entity is not None assert moved_entity.file_path == "moved/test-entity.md" assert moved_entity.permalink is not None assert moved_entity.permalink != "" # Verify the moved entity can be used to create an EntityResponse without validation errors from basic_memory.schemas.response import EntityResponse response = EntityResponse.model_validate(moved_entity) assert response.permalink == moved_entity.permalink # Verify the physical file was moved old_path = project_config.home / "test/null-permalink-entity.md" new_path = project_config.home / "moved/test-entity.md" assert not old_path.exists() assert new_path.exists()