mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
4cba7ba01c
Signed-off-by: phernandez <paul@basicmachines.co>
2550 lines
83 KiB
Python
2550 lines
83 KiB
Python
"""Tests for EntityService."""
|
|
|
|
import uuid
|
|
from pathlib import Path
|
|
from textwrap import dedent
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
import yaml
|
|
from sqlalchemy import text
|
|
|
|
from basic_memory import db
|
|
from basic_memory.config import ProjectConfig, BasicMemoryConfig, DatabaseBackend
|
|
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
|
|
|
|
|
|
def _permalink(entity: EntityModel | EntitySchema) -> str:
|
|
permalink = entity.permalink
|
|
assert permalink is not None
|
|
return permalink
|
|
|
|
|
|
class _DeleteTestEmbeddingProvider:
|
|
"""Deterministic embedding provider for entity delete cleanup tests."""
|
|
|
|
model_name = "delete-test"
|
|
dimensions = 4
|
|
|
|
async def embed_query(self, text: str) -> list[float]:
|
|
return self._vectorize(text)
|
|
|
|
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
return [self._vectorize(text) for text in texts]
|
|
|
|
@staticmethod
|
|
def _vectorize(text: str) -> list[float]:
|
|
normalized = text.lower()
|
|
if "semantic" in normalized:
|
|
return [1.0, 0.0, 0.0, 0.0]
|
|
if "cleanup" in normalized:
|
|
return [0.0, 1.0, 0.0, 0.0]
|
|
return [0.0, 0.0, 1.0, 0.0]
|
|
|
|
|
|
async def _count_entity_search_state(
|
|
session_maker,
|
|
app_config: BasicMemoryConfig,
|
|
project_id: int,
|
|
entity_id: int,
|
|
) -> tuple[int, int, int]:
|
|
"""Return counts for all derived search rows tied to one entity."""
|
|
embedding_join = (
|
|
"e.chunk_id = c.id"
|
|
if app_config.database_backend == DatabaseBackend.POSTGRES
|
|
else "e.rowid = c.id"
|
|
)
|
|
params = {"project_id": project_id, "entity_id": entity_id}
|
|
|
|
async with db.scoped_session(session_maker) as session:
|
|
search_index_rows = await session.execute(
|
|
text(
|
|
"SELECT COUNT(*) FROM search_index "
|
|
"WHERE project_id = :project_id AND entity_id = :entity_id"
|
|
),
|
|
params,
|
|
)
|
|
vector_chunk_rows = await session.execute(
|
|
text(
|
|
"SELECT COUNT(*) FROM search_vector_chunks "
|
|
"WHERE project_id = :project_id AND entity_id = :entity_id"
|
|
),
|
|
params,
|
|
)
|
|
vector_embedding_rows = await session.execute(
|
|
text(
|
|
"SELECT COUNT(*) FROM search_vector_embeddings e "
|
|
"JOIN search_vector_chunks c ON "
|
|
f"{embedding_join} "
|
|
"WHERE c.project_id = :project_id AND c.entity_id = :entity_id"
|
|
),
|
|
params,
|
|
)
|
|
|
|
return (
|
|
int(search_index_rows.scalar_one()),
|
|
int(vector_chunk_rows.scalar_one()),
|
|
int(vector_embedding_rows.scalar_one()),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def entity_service_with_search(
|
|
entity_repository: EntityRepository,
|
|
observation_repository,
|
|
relation_repository,
|
|
entity_parser: EntityParser,
|
|
file_service: FileService,
|
|
link_resolver,
|
|
search_service: SearchService,
|
|
app_config: BasicMemoryConfig,
|
|
) -> EntityService:
|
|
"""Create EntityService with a real attached search service."""
|
|
return EntityService(
|
|
entity_parser=entity_parser,
|
|
entity_repository=entity_repository,
|
|
observation_repository=observation_repository,
|
|
relation_repository=relation_repository,
|
|
file_service=file_service,
|
|
link_resolver=link_resolver,
|
|
search_service=search_service,
|
|
app_config=app_config,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_entity(
|
|
entity_service: EntityService, file_service: FileService, project_config: ProjectConfig
|
|
):
|
|
"""Test successful entity creation."""
|
|
entity_data = EntitySchema(
|
|
title="Test Entity",
|
|
directory="",
|
|
note_type="test",
|
|
)
|
|
# Save expected permalink before create_entity mutates entity_data._permalink
|
|
expected_permalink = f"{generate_permalink(project_config.name)}/{entity_data.permalink}"
|
|
|
|
# Act
|
|
entity = await entity_service.create_entity(entity_data)
|
|
|
|
# Assert Entity
|
|
assert isinstance(entity, EntityModel)
|
|
assert entity.permalink == expected_permalink
|
|
assert entity.file_path == entity_data.file_path
|
|
assert entity.note_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(_permalink(entity))
|
|
assert retrieved.title == "Test Entity"
|
|
assert retrieved.note_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.note_type
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_entity_file_exists(
|
|
entity_service: EntityService, file_service: FileService, project_config: ProjectConfig
|
|
):
|
|
"""Test successful entity creation."""
|
|
entity_data = EntitySchema(
|
|
title="Test Entity",
|
|
directory="",
|
|
note_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 (
|
|
f"---\ntitle: Test Entity\ntype: test\npermalink: {generate_permalink(project_config.name)}/test-entity\n---\n\nfirst"
|
|
== file_content
|
|
)
|
|
|
|
entity_data = EntitySchema(
|
|
title="Test Entity",
|
|
directory="",
|
|
note_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",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
|
|
entity = await entity_service.create_entity(entity_data)
|
|
|
|
# default permalink
|
|
assert entity.permalink == (
|
|
f"{generate_permalink(project_config.name)}/{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",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
entity1 = await entity_service.create_entity(entity1_data)
|
|
|
|
entity2_data = EntitySchema(
|
|
title="TestEntity2",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
entity2 = await entity_service.create_entity(entity2_data)
|
|
|
|
# Find by type1 and name
|
|
found = await entity_service.get_by_permalink(_permalink(entity1_data))
|
|
assert found is not None
|
|
assert found.id == entity1.id
|
|
assert found.note_type == entity1.note_type
|
|
|
|
# Find by type2 and name
|
|
found = await entity_service.get_by_permalink(_permalink(entity2_data))
|
|
assert found is not None
|
|
assert found.id == entity2.id
|
|
assert found.note_type == entity2.note_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",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
await entity_service.create_entity(entity_data)
|
|
|
|
# Get by permalink
|
|
retrieved = await entity_service.get_by_permalink(_permalink(entity_data))
|
|
|
|
assert isinstance(retrieved, EntityModel)
|
|
assert retrieved.title == "TestEntity"
|
|
assert retrieved.note_type == "test"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_success(entity_service: EntityService):
|
|
"""Test successful entity deletion."""
|
|
entity_data = EntitySchema(
|
|
title="TestEntity",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
await entity_service.create_entity(entity_data)
|
|
|
|
# Act using permalink
|
|
result = await entity_service.delete_entity(_permalink(entity_data))
|
|
|
|
# Assert
|
|
assert result is True
|
|
with pytest.raises(EntityNotFoundError):
|
|
await entity_service.get_by_permalink(_permalink(entity_data))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_by_id(entity_service: EntityService):
|
|
"""Test successful entity deletion."""
|
|
entity_data = EntitySchema(
|
|
title="TestEntity",
|
|
directory="test",
|
|
note_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(_permalink(entity_data))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_removes_search_and_vector_state(
|
|
entity_service_with_search: EntityService,
|
|
search_service: SearchService,
|
|
session_maker,
|
|
app_config: BasicMemoryConfig,
|
|
):
|
|
"""Deleting an entity should clear all of its full-text and semantic search state."""
|
|
if app_config.database_backend == DatabaseBackend.SQLITE:
|
|
pytest.importorskip("sqlite_vec")
|
|
|
|
repository = cast(Any, search_service.repository)
|
|
repository._semantic_enabled = True
|
|
repository._embedding_provider = _DeleteTestEmbeddingProvider()
|
|
repository._vector_dimensions = repository._embedding_provider.dimensions
|
|
repository._vector_tables_initialized = False
|
|
await search_service.init_search_index()
|
|
|
|
entity = await entity_service_with_search.create_entity(
|
|
EntitySchema(
|
|
title="Semantic Delete Target",
|
|
directory="test",
|
|
note_type="note",
|
|
content=dedent("""
|
|
# Semantic Delete Target
|
|
|
|
- [note] Semantic cleanup should remove every derived row
|
|
- references [[Cleanup Target]]
|
|
""").strip(),
|
|
)
|
|
)
|
|
|
|
await search_service.index_entity(entity)
|
|
await search_service.sync_entity_vectors(entity.id)
|
|
|
|
search_rows, chunk_rows, embedding_rows = await _count_entity_search_state(
|
|
session_maker,
|
|
app_config,
|
|
search_service.repository.project_id,
|
|
entity.id,
|
|
)
|
|
assert search_rows >= 3
|
|
assert chunk_rows > 0
|
|
assert embedding_rows > 0
|
|
|
|
assert await entity_service_with_search.delete_entity(entity.id) is True
|
|
|
|
assert await _count_entity_search_state(
|
|
session_maker,
|
|
app_config,
|
|
search_service.repository.project_id,
|
|
entity.id,
|
|
) == (0, 0, 0)
|
|
|
|
|
|
@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,
|
|
directory="test",
|
|
note_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(_permalink(entity_data))
|
|
|
|
|
|
@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",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
entity2_data = EntitySchema(
|
|
title="Entity2",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
await entity_service.create_entity(entity1_data)
|
|
await entity_service.create_entity(entity2_data)
|
|
|
|
# Open nodes by path IDs
|
|
permalinks = [_permalink(entity1_data), _permalink(entity2_data)]
|
|
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",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
await entity_service.create_entity(entity_data)
|
|
|
|
# Try to open two nodes, one exists, one doesn't
|
|
permalinks = [_permalink(entity_data), "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",
|
|
note_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",
|
|
directory="test",
|
|
note_type="note",
|
|
entity_metadata={"status": "draft"},
|
|
)
|
|
|
|
entity = await entity_service.create_entity(schema)
|
|
assert entity.entity_metadata is not None
|
|
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",
|
|
directory="test",
|
|
note_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",
|
|
directory="test",
|
|
note_type="test",
|
|
content="Test entity",
|
|
entity_metadata={"status": "final"},
|
|
)
|
|
)
|
|
|
|
entity_for_update = cast(Any, entity)
|
|
entity_for_update.content = "Updated content"
|
|
|
|
# Update name
|
|
updated, created = await entity_service.create_or_update_entity(entity_for_update)
|
|
|
|
assert updated.title == "test"
|
|
assert updated.entity_metadata is not None
|
|
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",
|
|
directory="test",
|
|
note_type="test",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
assert created is True
|
|
assert entity.title == "Git Workflow Guide"
|
|
assert entity.note_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,
|
|
project_config: ProjectConfig,
|
|
):
|
|
content = """# Git Workflow Guide"""
|
|
|
|
# Create test entity
|
|
entity, created = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Git Workflow Guide",
|
|
note_type="test",
|
|
directory="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
|
|
project_prefix = generate_permalink(project_config.name)
|
|
assert (
|
|
dedent(
|
|
f"""
|
|
---
|
|
title: Git Workflow Guide
|
|
type: test
|
|
permalink: {project_prefix}/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",
|
|
directory="test",
|
|
note_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.note_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",
|
|
directory="test",
|
|
note_type="note",
|
|
content="Original content",
|
|
)
|
|
)
|
|
|
|
# Edit entity with append operation
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity), 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",
|
|
directory="test",
|
|
note_type="note",
|
|
content="Original content",
|
|
)
|
|
)
|
|
|
|
# Edit entity with prepend operation
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity), 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",
|
|
directory="test",
|
|
note_type="note",
|
|
content="This is old content that needs updating",
|
|
)
|
|
)
|
|
|
|
# Edit entity with find_replace operation
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
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",
|
|
directory="docs",
|
|
note_type="note",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
# Edit entity with replace_section operation
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
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",
|
|
directory="test",
|
|
note_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=_permalink(entity),
|
|
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",
|
|
directory="test",
|
|
note_type="note",
|
|
content="Original content",
|
|
)
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Unsupported operation"):
|
|
await entity_service.edit_entity(
|
|
identifier=_permalink(entity), 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",
|
|
directory="test",
|
|
note_type="note",
|
|
content="Original content",
|
|
)
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="find_text is required"):
|
|
await entity_service.edit_entity(
|
|
identifier=_permalink(entity), 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",
|
|
directory="test",
|
|
note_type="note",
|
|
content="Original content",
|
|
)
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="section is required"):
|
|
await entity_service.edit_entity(
|
|
identifier=_permalink(entity), 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",
|
|
directory="docs",
|
|
note_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=_permalink(entity),
|
|
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, monkeypatch
|
|
):
|
|
"""Test that create_entity_from_markdown handles repository errors gracefully."""
|
|
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")
|
|
|
|
monkeypatch.setattr(entity_service.repository, "upsert_entity", 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",
|
|
directory="test",
|
|
note_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=_permalink(entity),
|
|
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",
|
|
directory="docs",
|
|
note_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=_permalink(entity),
|
|
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",
|
|
directory="docs",
|
|
note_type="note",
|
|
content="The word banana appears here. Another banana word here.",
|
|
)
|
|
)
|
|
|
|
# Replace with correct expected count
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
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",
|
|
directory="test",
|
|
note_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=_permalink(entity),
|
|
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",
|
|
directory="docs",
|
|
note_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=_permalink(entity),
|
|
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",
|
|
directory="docs",
|
|
note_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=_permalink(entity),
|
|
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",
|
|
directory="test",
|
|
note_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=_permalink(entity),
|
|
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",
|
|
directory="docs",
|
|
note_type="note",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
# Test replacing with different header format (no ##)
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
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",
|
|
directory="docs",
|
|
note_type="note",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
# Replace the last section
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
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",
|
|
directory="docs",
|
|
note_type="note",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
# Replace parent section (should only replace content until first subsection)
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
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",
|
|
directory="docs",
|
|
note_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=_permalink(entity),
|
|
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
|
|
|
|
|
|
# Insert before/after section tests
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_insert_before_section(
|
|
entity_service: EntityService, file_service: FileService
|
|
):
|
|
"""Test inserting content before a section heading."""
|
|
content = dedent("""
|
|
# Main Title
|
|
|
|
## Section 1
|
|
Section 1 content
|
|
|
|
## Section 2
|
|
Section 2 content
|
|
""").strip()
|
|
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Insert Before Test",
|
|
directory="docs",
|
|
note_type="note",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
operation="insert_before_section",
|
|
content="Inserted before section 2",
|
|
section="## Section 2",
|
|
)
|
|
|
|
file_path = file_service.get_entity_path(updated)
|
|
file_content, _ = await file_service.read_file(file_path)
|
|
assert "Inserted before section 2" in file_content
|
|
assert "## Section 2" in file_content
|
|
assert "Section 2 content" in file_content
|
|
# Inserted content should appear before the section heading
|
|
assert file_content.index("Inserted before section 2") < file_content.index("## Section 2")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_insert_after_section(
|
|
entity_service: EntityService, file_service: FileService
|
|
):
|
|
"""Test inserting content after a section heading."""
|
|
content = dedent("""
|
|
# Main Title
|
|
|
|
## Section 1
|
|
Section 1 content
|
|
|
|
## Section 2
|
|
Section 2 content
|
|
""").strip()
|
|
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Insert After Test",
|
|
directory="docs",
|
|
note_type="note",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
operation="insert_after_section",
|
|
content="Inserted after section 1 heading",
|
|
section="## Section 1",
|
|
)
|
|
|
|
file_path = file_service.get_entity_path(updated)
|
|
file_content, _ = await file_service.read_file(file_path)
|
|
assert "Inserted after section 1 heading" in file_content
|
|
assert "## Section 1" in file_content
|
|
assert "Section 1 content" in file_content
|
|
# Inserted content should appear after the heading but content is also preserved
|
|
assert file_content.index("## Section 1") < file_content.index(
|
|
"Inserted after section 1 heading"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_insert_before_section_not_found(entity_service: EntityService):
|
|
"""Test insert_before_section raises ValueError when section not found."""
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Test Note",
|
|
directory="test",
|
|
note_type="note",
|
|
content="# Main Title\n\nSome content",
|
|
)
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Section '## Missing' not found"):
|
|
await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
operation="insert_before_section",
|
|
content="new content",
|
|
section="## Missing",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_insert_after_section_not_found(entity_service: EntityService):
|
|
"""Test insert_after_section raises ValueError when section not found."""
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Test Note",
|
|
directory="test",
|
|
note_type="note",
|
|
content="# Main Title\n\nSome content",
|
|
)
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Section '## Missing' not found"):
|
|
await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
operation="insert_after_section",
|
|
content="new content",
|
|
section="## Missing",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_insert_before_section_multiple_sections_error(
|
|
entity_service: EntityService,
|
|
):
|
|
"""Test insert_before_section raises ValueError with duplicate sections."""
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Test Note",
|
|
directory="test",
|
|
note_type="note",
|
|
content="# Title\n\n## Dup\nFirst\n\n## Dup\nSecond",
|
|
)
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Multiple sections found"):
|
|
await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
operation="insert_before_section",
|
|
content="new content",
|
|
section="## Dup",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_insert_before_section_missing_section_param(
|
|
entity_service: EntityService,
|
|
):
|
|
"""Test insert_before_section raises ValueError when section param is missing."""
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Test Note",
|
|
directory="test",
|
|
note_type="note",
|
|
content="# Title\n\nContent",
|
|
)
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="section is required"):
|
|
await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
operation="insert_before_section",
|
|
content="new content",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_insert_before_section_empty_section(entity_service: EntityService):
|
|
"""Test insert_before_section raises ValueError when section is empty/whitespace."""
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Test Note",
|
|
directory="test",
|
|
note_type="note",
|
|
content="# Title\n\nContent",
|
|
)
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="section cannot be empty"):
|
|
await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
operation="insert_before_section",
|
|
content="new content",
|
|
section=" ",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_insert_after_section_at_end_of_document(
|
|
entity_service: EntityService, file_service: FileService
|
|
):
|
|
"""Test inserting after the last section in a document."""
|
|
content = dedent("""
|
|
# Main Title
|
|
|
|
## Only Section
|
|
Some content here
|
|
""").strip()
|
|
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Insert End Test",
|
|
directory="docs",
|
|
note_type="note",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
operation="insert_after_section",
|
|
content="Inserted after the last section heading",
|
|
section="## Only Section",
|
|
)
|
|
|
|
file_path = file_service.get_entity_path(updated)
|
|
file_content, _ = await file_service.read_file(file_path)
|
|
assert "Inserted after the last section heading" in file_content
|
|
assert "## Only Section" in file_content
|
|
assert "Some content here" in file_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_insert_after_section_preserves_paragraph_separation(
|
|
entity_service: EntityService, file_service: FileService
|
|
):
|
|
"""Test that insert_after_section adds blank line so inserted text doesn't merge
|
|
with existing section content into a single markdown paragraph."""
|
|
content = dedent("""
|
|
# Main Title
|
|
|
|
## Section
|
|
Existing paragraph text
|
|
""").strip()
|
|
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Paragraph Sep Test",
|
|
directory="docs",
|
|
note_type="note",
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
updated = await entity_service.edit_entity(
|
|
identifier=_permalink(entity),
|
|
operation="insert_after_section",
|
|
content="Inserted line",
|
|
section="## Section",
|
|
)
|
|
|
|
file_path = file_service.get_entity_path(updated)
|
|
file_content, _ = await file_service.read_file(file_path)
|
|
# The inserted line and existing content should be separated by a blank line
|
|
assert "Inserted line\n\nExisting paragraph text" 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",
|
|
directory="original",
|
|
note_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 == f"{generate_permalink(project_config.name)}/original/test-note"
|
|
await entity_service.move_entity(
|
|
identifier=_permalink(entity),
|
|
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(_permalink(entity))
|
|
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",
|
|
directory="original",
|
|
note_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=_permalink(entity),
|
|
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 _permalink(moved_entity) 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",
|
|
directory="original",
|
|
note_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=_permalink(entity),
|
|
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",
|
|
directory="test",
|
|
note_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=_permalink(entity),
|
|
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",
|
|
directory="test",
|
|
note_type="note",
|
|
content="Content 1",
|
|
)
|
|
)
|
|
|
|
entity2 = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Test Note 2",
|
|
directory="test",
|
|
note_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=_permalink(entity1),
|
|
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",
|
|
directory="test",
|
|
note_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=_permalink(entity),
|
|
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=_permalink(entity),
|
|
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",
|
|
directory="original",
|
|
note_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",
|
|
directory="original",
|
|
note_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=_permalink(entity),
|
|
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")
|
|
assert moved_entity is not None
|
|
|
|
# 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,
|
|
monkeypatch,
|
|
):
|
|
"""Test that filesystem changes are rolled back on database failures."""
|
|
# Create test entity
|
|
entity = await entity_service.create_entity(
|
|
EntitySchema(
|
|
title="Test Note",
|
|
directory="original",
|
|
note_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)
|
|
|
|
async def failing_update(*args, **kwargs):
|
|
return None # Simulate failure
|
|
|
|
monkeypatch.setattr(entity_repository, "update", failing_update)
|
|
|
|
with pytest.raises(ValueError, match="Move failed:"):
|
|
await entity_service.move_entity(
|
|
identifier=_permalink(entity),
|
|
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()
|
|
|
|
|
|
@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",
|
|
directory="docs",
|
|
note_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=_permalink(entity),
|
|
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")
|
|
assert moved_entity is not None
|
|
|
|
# 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",
|
|
"note_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",
|
|
directory="edge-cases",
|
|
note_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",
|
|
directory="edge-cases",
|
|
note_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",
|
|
directory="edge-cases",
|
|
note_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"
|
|
)
|
|
|
|
|
|
# --- User Tracking (created_by / last_updated_by) ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_created_by_null_by_default(entity_service: EntityService):
|
|
"""created_by and last_updated_by are NULL when get_user_id returns None (local/CLI usage)."""
|
|
schema = EntitySchema(
|
|
title="Local Entity",
|
|
directory="test",
|
|
entity_type="note",
|
|
)
|
|
entity = await entity_service.create_entity(schema)
|
|
assert entity.created_by is None
|
|
assert entity.last_updated_by is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_created_by_set_when_get_user_id_returns_value(entity_service: EntityService):
|
|
"""created_by and last_updated_by are set when get_user_id returns a user ID."""
|
|
user_id = str(uuid.uuid4())
|
|
entity_service.get_user_id = lambda: user_id
|
|
|
|
schema = EntitySchema(
|
|
title="Cloud Entity",
|
|
directory="test",
|
|
entity_type="note",
|
|
)
|
|
entity = await entity_service.create_entity(schema)
|
|
assert entity.created_by == user_id
|
|
assert entity.last_updated_by == user_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_preserves_created_by(entity_service: EntityService):
|
|
"""Updating an entity preserves created_by and updates last_updated_by."""
|
|
creator_id = str(uuid.uuid4())
|
|
editor_id = str(uuid.uuid4())
|
|
|
|
# Create as creator
|
|
entity_service.get_user_id = lambda: creator_id
|
|
schema = EntitySchema(
|
|
title="Owned Entity",
|
|
directory="test",
|
|
entity_type="note",
|
|
content="Original content",
|
|
)
|
|
entity = await entity_service.create_entity(schema)
|
|
assert entity.created_by == creator_id
|
|
|
|
# Update as editor
|
|
entity_service.get_user_id = lambda: editor_id
|
|
update_schema = EntitySchema(
|
|
title="Owned Entity",
|
|
directory="test",
|
|
entity_type="note",
|
|
content="Updated content",
|
|
)
|
|
updated = await entity_service.update_entity(entity, update_schema)
|
|
assert updated.created_by == creator_id # preserved
|
|
assert updated.last_updated_by == editor_id # updated
|
|
|
|
|
|
# --- Concurrent Delete Resilience ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_by_id_already_deleted(entity_service: EntityService):
|
|
"""delete_entity returns True when entity was already deleted (concurrent delete)."""
|
|
entity_data = EntitySchema(
|
|
title="ConcurrentDeleteTarget",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
created = await entity_service.create_entity(entity_data)
|
|
entity_id = created.id
|
|
|
|
# Delete once - should succeed
|
|
assert await entity_service.delete_entity(entity_id) is True
|
|
|
|
# Delete again by ID - should return True (already deleted), not raise ValueError
|
|
assert await entity_service.delete_entity(entity_id) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_by_permalink_already_deleted(entity_service: EntityService):
|
|
"""delete_entity returns True when entity was already deleted by permalink."""
|
|
entity_data = EntitySchema(
|
|
title="ConcurrentDeleteByPermalink",
|
|
directory="test",
|
|
note_type="test",
|
|
)
|
|
created = await entity_service.create_entity(entity_data)
|
|
|
|
# Delete once
|
|
assert await entity_service.delete_entity(created.id) is True
|
|
|
|
# Delete again by permalink - should return True (EntityNotFoundError caught)
|
|
assert await entity_service.delete_entity(_permalink(entity_data)) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_directory_concurrent_resilience(entity_service: EntityService):
|
|
"""delete_directory succeeds even when entities are concurrently deleted."""
|
|
# Create entities in a directory
|
|
entities = []
|
|
for i in range(5):
|
|
entity_data = EntitySchema(
|
|
title=f"DirEntity{i}",
|
|
directory="concurrent-test",
|
|
note_type="test",
|
|
)
|
|
created = await entity_service.create_entity(entity_data)
|
|
entities.append(created)
|
|
|
|
# Delete some entities directly to simulate concurrent delete
|
|
await entity_service.delete_entity(entities[0].id)
|
|
await entity_service.delete_entity(entities[2].id)
|
|
|
|
# Now delete the directory - should handle already-deleted entities gracefully
|
|
result = await entity_service.delete_directory("concurrent-test")
|
|
|
|
# Only 3 remain in DB (2 were already deleted before the directory query)
|
|
assert result.total_files == 3
|
|
assert result.successful_deletes == 3
|
|
assert result.failed_deletes == 0
|
|
assert len(result.errors) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_directory_all_already_deleted(entity_service: EntityService):
|
|
"""delete_directory handles case where all entities were concurrently deleted."""
|
|
# Create entities
|
|
created_entities = []
|
|
for i in range(3):
|
|
entity_data = EntitySchema(
|
|
title=f"AllGone{i}",
|
|
directory="all-deleted",
|
|
note_type="test",
|
|
)
|
|
created = await entity_service.create_entity(entity_data)
|
|
created_entities.append(created)
|
|
|
|
# Delete all entities directly
|
|
for e in created_entities:
|
|
await entity_service.delete_entity(e.id)
|
|
|
|
# Directory delete should report empty (entities no longer found in prefix query)
|
|
result = await entity_service.delete_directory("all-deleted")
|
|
assert result.total_files == 0
|
|
assert result.successful_deletes == 0
|
|
assert result.failed_deletes == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_directory_entity_deleted_between_query_and_delete(
|
|
entity_service: EntityService,
|
|
):
|
|
"""Simulates the real race condition: entity exists in prefix query but is deleted
|
|
by a concurrent request before delete_entity is called."""
|
|
# Create entities
|
|
entity_data = EntitySchema(title="RaceTarget", directory="race-dir", note_type="test")
|
|
created = await entity_service.create_entity(entity_data)
|
|
|
|
# Get the entities via prefix query (as delete_directory does)
|
|
entities = await entity_service.repository.find_by_directory_prefix("race-dir")
|
|
assert len(entities) == 1
|
|
|
|
# Now delete the entity behind the scenes (simulating a concurrent request)
|
|
await entity_service.delete_entity(created.id)
|
|
|
|
# Call delete_entity with the stale entity ID - should return True, not raise
|
|
result = await entity_service.delete_entity(entities[0].id)
|
|
assert result is True
|