Files
turnstone/tests/test_db.py
T
Patrick Buckley 2b58c127b1 Add pluggable storage backend (SQLite + PostgreSQL) and deployment packaging (#20)
* Add pluggable storage backend (SQLite + PostgreSQL) and deployment packaging

Database abstraction: StorageBackend protocol with 21 methods, SQLAlchemy Core
schema, SQLite backend (FTS5), PostgreSQL backend (tsvector/ILIKE), Alembic
migrations, singleton registry. memory.py reduced to thin facade. Session.py
open_db() calls replaced with generic KV methods. [database] config section
with env var support.

Deployment: Docker Compose production profile with PostgreSQL, Dockerfile with
postgres extras and migration entrypoint, Helm chart with bitnami subcharts,
Terraform AWS ECS/Fargate module with RDS + ElastiCache + ALB.

39 new storage tests (934 total). mypy strict clean. Docs and diagrams updated.

* Address PR #20 review feedback (16 items)

- Backends only call create_all() when Alembic migrations are disabled
- Helm configmap uses correct TURNSTONE_DB_BACKEND env var; DB URL
  constructed via env expansion with secret reference instead of ConfigMap
- Migration errors fail fast for PostgreSQL (only non-fatal for SQLite)
- save_memory/delete_memory wrapped in exception handling like other facade fns
- pool_size passed through from config/env to init_storage() in cli + server
- Terraform: DB URL moved to Secrets Manager, auth enabled flag set,
  optional TLS listeners with certificate_arn, Redis transit encryption on
- Docker entrypoint no longer suppresses migration output
- Diagram fixes: removed StaticPool claim, removed non-existent migration ref
- compose.yaml/README: clarified production profile requires DB env vars
2026-03-03 22:57:34 -08:00

78 lines
2.5 KiB
Python

"""Tests for turnstone.core.memory — database operations."""
import sqlalchemy as sa
from turnstone.core.memory import (
normalize_key,
save_message,
search_history,
search_history_recent,
)
from turnstone.core.storage import get_storage
class TestSchemaCreation:
def test_creates_tables(self, tmp_db):
engine = get_storage()._engine # noqa: SLF001
with engine.connect() as conn:
rows = conn.execute(
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='memories'")
).fetchall()
assert len(rows) == 1
rows = conn.execute(
sa.text(
"SELECT name FROM sqlite_master WHERE type='table' AND name='conversations'"
)
).fetchall()
assert len(rows) == 1
class TestSaveAndSearchHistory:
def test_save_and_search_roundtrip(self, tmp_db):
save_message("sess1", "user", "hello world test message")
results = search_history("hello")
assert len(results) >= 1
found = any(r[3] == "hello world test message" for r in results)
assert found
def test_search_empty_query_returns_empty(self, tmp_db):
save_message("sess1", "user", "something")
assert search_history("") == []
assert search_history(" ") == []
def test_search_no_match(self, tmp_db):
save_message("sess1", "user", "hello world")
results = search_history("zzzznotfound")
assert results == []
class TestSearchHistoryRecent:
def test_returns_recent_messages(self, tmp_db):
save_message("sess1", "user", "first message")
save_message("sess1", "assistant", "second message")
results = search_history_recent(limit=10)
assert len(results) == 2
def test_respects_limit(self, tmp_db):
for i in range(5):
save_message("sess1", "user", f"message {i}")
results = search_history_recent(limit=3)
assert len(results) == 3
class TestNormalizeKey:
def test_lowercase(self):
assert normalize_key("Hello") == "hello"
def test_hyphens_to_underscores(self):
assert normalize_key("my-key") == "my_key"
def test_spaces_to_underscores(self):
assert normalize_key("my key") == "my_key"
def test_combined(self):
assert normalize_key("My-Key Name") == "my_key_name"
def test_already_normalized(self):
assert normalize_key("my_key") == "my_key"