mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
1646572f69
Signed-off-by: phernandez <paul@basicmachines.co> Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
1942 lines
63 KiB
Python
1942 lines
63 KiB
Python
"""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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_replace_section_strips_duplicate_header(
|
|
entity_service: EntityService, file_service: FileService
|
|
):
|
|
"""Test that replace_section strips duplicate header from content (issue #390)."""
|
|
# Create test entity with a section
|
|
content = dedent("""
|
|
# Main Title
|
|
|
|
## Testing
|
|
Original content
|
|
|
|
## Another Section
|
|
Other content
|
|
""").strip()
|
|
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Sample Note",
|
|
folder="docs",
|
|
entity_type="note",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
# Replace section with content that includes the duplicate header
|
|
# (This is what LLMs sometimes do)
|
|
updated = await entity_service.edit_entity(
|
|
identifier=entity.permalink,
|
|
operation="replace_section",
|
|
content="## Testing\nNew content for testing section",
|
|
section="## Testing",
|
|
)
|
|
|
|
# Verify that we don't have duplicate headers
|
|
file_path = file_service.get_entity_path(updated)
|
|
file_content, _ = await file_service.read_file(file_path)
|
|
|
|
# Count occurrences of "## Testing" - should only be 1
|
|
testing_header_count = file_content.count("## Testing")
|
|
assert testing_header_count == 1, (
|
|
f"Expected 1 '## Testing' header, found {testing_header_count}"
|
|
)
|
|
|
|
assert "New content for testing section" in file_content
|
|
assert "Original content" not in file_content
|
|
assert "## Another Section" in file_content # Other sections preserved
|
|
|
|
|
|
# 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"
|
|
)
|