mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
256 lines
8.7 KiB
Python
256 lines
8.7 KiB
Python
import pytest
|
|
import pytest_asyncio
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
import tempfile
|
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from basic_memory.models import Base, Entity as DbEntity
|
|
from basic_memory.repository import EntityRepository
|
|
from basic_memory.services import EntityService, FileOperationError, DatabaseSyncError, EntityNotFoundError
|
|
from basic_memory.schemas import Entity, Observation
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def engine():
|
|
"""Create an async engine using in-memory SQLite database"""
|
|
engine = create_async_engine(
|
|
"sqlite+aiosqlite:///:memory:", # In-memory database
|
|
echo=False, # Set to True for SQL logging
|
|
poolclass=StaticPool, # Ensure single connection for in-memory db
|
|
connect_args={"check_same_thread": False} # Allow multi-threaded access
|
|
)
|
|
|
|
# Create all tables
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
try:
|
|
yield engine
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def session(engine):
|
|
"""Create an async session factory and yield a session"""
|
|
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
|
async with async_session() as session:
|
|
try:
|
|
yield session
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
@pytest_asyncio.fixture
|
|
async def entity_repo(session):
|
|
"""Create an EntityRepository instance."""
|
|
return EntityRepository(session, DbEntity)
|
|
|
|
@pytest_asyncio.fixture
|
|
async def entity_service(session, entity_repo):
|
|
"""Fixture providing initialized EntityService with temp directories."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
project_path = Path(temp_dir) / "test-project"
|
|
entities_path = project_path / "entities"
|
|
entities_path.mkdir(parents=True)
|
|
|
|
service = EntityService(project_path, entity_repo)
|
|
yield service
|
|
|
|
# Happy Path Tests
|
|
|
|
async def test_create_entity_success(entity_service):
|
|
"""Test successful entity creation."""
|
|
observations = ["First observation", "Second observation"]
|
|
|
|
# Act
|
|
entity = await entity_service.create_entity(
|
|
name="Test Entity",
|
|
entity_type="test",
|
|
observations=observations
|
|
)
|
|
|
|
# Assert Entity
|
|
assert isinstance(entity, Entity)
|
|
assert entity.name == "Test Entity"
|
|
assert entity.entity_type == "test"
|
|
assert len(entity.observations) == 2
|
|
assert entity.observations[0].content == "First observation"
|
|
assert entity.observations[1].content == "Second observation"
|
|
|
|
# Verify file was created
|
|
entity_file = entity_service.entities_path / f"{entity.id}.md"
|
|
assert entity_file.exists()
|
|
content = entity_file.read_text()
|
|
assert "# Test Entity" in content
|
|
assert "type: test" in content
|
|
assert "First observation" in content
|
|
assert "Second observation" in content
|
|
|
|
async def test_get_entity_success(entity_service):
|
|
"""Test successful entity retrieval."""
|
|
# Arrange
|
|
observations = ["Test observation"]
|
|
created = await entity_service.create_entity(
|
|
name="Test Entity",
|
|
entity_type="test",
|
|
observations=observations
|
|
)
|
|
|
|
# Act
|
|
retrieved = await entity_service.get_entity(created.id)
|
|
|
|
# Assert
|
|
assert isinstance(retrieved, Entity)
|
|
assert retrieved.id == created.id
|
|
assert retrieved.name == created.name
|
|
assert retrieved.entity_type == created.entity_type
|
|
assert len(retrieved.observations) == 1
|
|
assert retrieved.observations[0].content == "Test observation"
|
|
|
|
async def test_delete_entity_success(entity_service):
|
|
"""Test successful entity deletion."""
|
|
# Arrange
|
|
entity = await entity_service.create_entity(
|
|
name="Test Entity",
|
|
entity_type="test",
|
|
observations=["Test observation"]
|
|
)
|
|
entity_file = entity_service.entities_path / f"{entity.id}.md"
|
|
assert entity_file.exists()
|
|
|
|
# Act
|
|
result = await entity_service.delete_entity(entity.id)
|
|
|
|
# Assert
|
|
assert result is True
|
|
assert not entity_file.exists()
|
|
with pytest.raises(EntityNotFoundError):
|
|
await entity_service.get_entity(entity.id)
|
|
|
|
# Error Path Tests
|
|
|
|
async def test_get_entity_not_found(entity_service):
|
|
"""Test handling of non-existent entity retrieval."""
|
|
with pytest.raises(EntityNotFoundError):
|
|
await entity_service.get_entity("nonexistent-id")
|
|
|
|
async def test_create_entity_db_error(entity_service, monkeypatch):
|
|
"""Test handling of database errors during creation."""
|
|
# Arrange - make db operations fail
|
|
async def mock_create(*args, **kwargs):
|
|
raise Exception("Mock DB error")
|
|
monkeypatch.setattr(entity_service.entity_repo, "create", mock_create)
|
|
|
|
# Act/Assert - both file and DB operations should fail
|
|
with pytest.raises(Exception, match="Mock DB error"):
|
|
await entity_service.create_entity(
|
|
name="Test Entity",
|
|
entity_type="test",
|
|
observations=["Test observation"]
|
|
)
|
|
|
|
async def test_delete_nonexistent_entity(entity_service):
|
|
"""Test deleting an entity that doesn't exist."""
|
|
await entity_service.delete_entity("nonexistent-id")
|
|
# If we get here, the deletion succeeded or failed silently as expected
|
|
|
|
# Edge Cases
|
|
|
|
async def test_create_entity_with_special_chars(entity_service):
|
|
"""Test entity creation with special characters in name."""
|
|
name = "Test & Entity! With @ Special #Chars"
|
|
entity = await entity_service.create_entity(
|
|
name=name,
|
|
entity_type="test",
|
|
observations=["Test observation"]
|
|
)
|
|
|
|
assert entity.name == name
|
|
entity_file = entity_service.entities_path / f"{entity.id}.md"
|
|
assert entity_file.exists()
|
|
|
|
async def test_create_entity_atomic_file_write(entity_service):
|
|
"""Test that file writing is atomic (uses temp file)."""
|
|
# Act
|
|
entity = await entity_service.create_entity(
|
|
name="Test Entity",
|
|
entity_type="test",
|
|
observations=["Test observation"]
|
|
)
|
|
|
|
# Assert
|
|
temp_file = entity_service.entities_path / f"{entity.id}.md.tmp"
|
|
assert not temp_file.exists() # Temp file should be cleaned up
|
|
|
|
entity_file = entity_service.entities_path / f"{entity.id}.md"
|
|
assert entity_file.exists() # Final file should exist
|
|
|
|
async def test_rebuild_index(entity_service):
|
|
"""Test rebuilding database index from filesystem."""
|
|
# Arrange - Create some entities
|
|
entity1 = await entity_service.create_entity(
|
|
name="Test 1",
|
|
entity_type="test",
|
|
observations=["Test observation 1"]
|
|
)
|
|
entity2 = await entity_service.create_entity(
|
|
name="Test 2",
|
|
entity_type="test",
|
|
observations=["Test observation 2"]
|
|
)
|
|
|
|
# Act - Rebuild index
|
|
await entity_service.rebuild_index()
|
|
|
|
# Assert - Entities should be retrievable
|
|
retrieved1 = await entity_service.get_entity(entity1.id)
|
|
retrieved2 = await entity_service.get_entity(entity2.id)
|
|
|
|
assert isinstance(retrieved1, Entity)
|
|
assert isinstance(retrieved2, Entity)
|
|
assert retrieved1.name == entity1.name
|
|
assert retrieved2.name == entity2.name
|
|
assert retrieved1.observations[0].content == "Test observation 1"
|
|
assert retrieved2.observations[0].content == "Test observation 2"
|
|
|
|
# Tests for Pydantic Model Behavior
|
|
|
|
async def test_entity_id_generation(entity_service):
|
|
"""Test that entities get unique IDs generated correctly."""
|
|
entity = await entity_service.create_entity(
|
|
name="Test Entity",
|
|
entity_type="test"
|
|
)
|
|
|
|
assert entity.id # ID should be generated
|
|
assert "-test-entity-" in entity.id # Should contain normalized name
|
|
assert len(entity.id.split("-")[-1]) == 8 # UUID part should be 8 chars
|
|
|
|
async def test_entity_with_no_observations(entity_service):
|
|
"""Test entity creation with no observations."""
|
|
entity = await entity_service.create_entity(
|
|
name="Test Entity",
|
|
entity_type="test"
|
|
)
|
|
|
|
assert isinstance(entity, Entity)
|
|
assert entity.observations == []
|
|
|
|
# Check file format
|
|
entity_file = entity_service.entities_path / f"{entity.id}.md"
|
|
content = entity_file.read_text()
|
|
assert "## Observations" in content
|
|
assert content.strip().endswith("## Observations") # No observations after header
|
|
|
|
# TODO: Add tests for:
|
|
# - Concurrent operations (using asyncio.gather)
|
|
# - Filesystem permissions issues
|
|
# - System crash simulation
|
|
# - Markdown formatting edge cases
|
|
# - Very long entity names/content
|
|
# - Unicode/special character handling
|
|
# - File system space issues |