Skip to main content
Glama

basic-memory

test_entity_service.py63.3 kB
"""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.services.search_service import SearchService 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 file_path.as_posix() == 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 == file_path.as_posix() # 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() @pytest.mark.asyncio async def test_create_or_update_entity_fuzzy_search_bug( entity_service: EntityService, file_service: FileService, project_config: ProjectConfig, search_service: SearchService, ): """Test that create_or_update_entity doesn't incorrectly match similar entities via fuzzy search. This reproduces the critical bug where creating "Node C" overwrote "Node A.md" because fuzzy search incorrectly matched the similar file paths. Root cause: link_resolver.resolve_link() uses fuzzy search fallback which matches "edge-cases/Node C.md" to existing "edge-cases/Node A.md" because they share similar words ("edge-cases", "Node"). Expected: Create new entity "Node C" with its own file Actual Bug: Updates existing "Node A" entity, overwriting its file """ # Step 1: Create first entity "Node A" entity_a = EntitySchema( title="Node A", folder="edge-cases", entity_type="note", content="# Node A\n\nOriginal content for Node A", ) created_a, is_new_a = await entity_service.create_or_update_entity(entity_a) assert is_new_a is True, "Node A should be created as new entity" assert created_a.title == "Node A" assert created_a.file_path == "edge-cases/Node A.md" # CRITICAL: Index Node A in search to enable fuzzy search fallback # This is what triggers the bug - without indexing, fuzzy search returns no results await search_service.index_entity(created_a) # Verify Node A file exists with correct content file_a = project_config.home / "edge-cases" / "Node A.md" assert file_a.exists(), "Node A.md file should exist" content_a = file_a.read_text() assert "Node A" in content_a assert "Original content for Node A" in content_a # Step 2: Create Node B to match live test scenario entity_b = EntitySchema( title="Node B", folder="edge-cases", entity_type="note", content="# Node B\n\nContent for Node B", ) created_b, is_new_b = await entity_service.create_or_update_entity(entity_b) assert is_new_b is True await search_service.index_entity(created_b) # Step 3: Create Node C - this is where the bug occurs in live testing # BUG: This will incorrectly match Node A via fuzzy search entity_c = EntitySchema( title="Node C", folder="edge-cases", entity_type="note", content="# Node C\n\nContent for Node C", ) created_c, is_new_c = await entity_service.create_or_update_entity(entity_c) # CRITICAL ASSERTIONS: Node C should be created as NEW entity, not update Node A assert is_new_c is True, "Node C should be created as NEW entity, not update existing" assert created_c.title == "Node C", "Created entity should have title 'Node C'" assert created_c.file_path == "edge-cases/Node C.md", "Should create Node C.md file" assert created_c.id != created_a.id, "Node C should have different ID than Node A" # Verify both files exist with correct content file_c = project_config.home / "edge-cases" / "Node C.md" assert file_c.exists(), "Node C.md file should exist as separate file" # Re-read Node A file to ensure it wasn't overwritten content_a_after = file_a.read_text() assert "title: Node A" in content_a_after, "Node A.md should still have Node A title" assert "Original content for Node A" in content_a_after, ( "Node A.md should NOT be overwritten with Node C content" ) assert "Content for Node C" not in content_a_after, ( "Node A.md should not contain Node C content" ) # Verify Node C file has correct content content_c = file_c.read_text() assert "title: Node C" in content_c, "Node C.md should have Node C title" assert "Content for Node C" in content_c, "Node C.md should have Node C content" assert "Original content for Node A" not in content_c, ( "Node C.md should not contain Node A content" )

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/basicmachines-co/basic-memory'

If you have feedback or need assistance with the MCP directory API, please join our Discord server