8dde6c81ef
Add PostgreSQL support as the server-mode storage backend alongside existing SQLite for local mode. Verify all ORM models are dialect- agnostic, configure connection pooling for multi-user access, add Docker Compose for local PG development, and wire database URL selection based on deployment mode. Changes: - Add psycopg2-binary dependency to pyproject.toml - Add server_mode, db_pool_size, db_max_overflow, db_pool_recycle settings to Settings with environment variable support - Add resolve_database_url() and is_postgresql() to Settings for mode-aware database URL resolution - Configure UnitOfWork engine creation with pool_size, max_overflow, pool_recycle, and pool_pre_ping for PostgreSQL connections - Update MigrationRunner to handle both SQLite and PostgreSQL backends - Add compare_type=True to Alembic env.py for dialect-aware migrations - Add docker-compose.yml with PostgreSQL 16-alpine for local development - Add Behave BDD feature (14 scenarios) covering settings, pool config, engine creation, ORM dialect compatibility, and migration runner - Add Robot Framework integration tests (12 test cases) for the abstraction layer with requires_postgresql tag for live PG tests Fixes: - Restore require_confirmation parameter to UnitOfWork.__init__ - Add argument validation to UnitOfWork.__init__ (fail-fast) - Add explicit PostgreSQL isolation_level (READ COMMITTED) - Add dispose() method and context manager support to UnitOfWork - Fix MigrationRunner.get_current_revision() to dispose engine - Fix Settings.is_postgresql() to handle ValueError gracefully ISSUES CLOSED: #878
324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""Step definitions for UnitOfWork coverage boost.
|
|
|
|
Targets uncovered lines in
|
|
``src/cleveragents/infrastructure/database/unit_of_work.py``:
|
|
- Lines 71-76: fresh in-memory engine creation (MEMORY_ENGINES cache miss)
|
|
- Lines 90-93: non-SQLite engine creation
|
|
- Lines 244-248: checkpoints property lazy init
|
|
- Line 312: refresh() method
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.infrastructure.database.unit_of_work import (
|
|
UnitOfWork,
|
|
UnitOfWorkContext,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_session() -> MagicMock:
|
|
"""Create a MagicMock that mimics a SQLAlchemy Session."""
|
|
session = MagicMock()
|
|
session.add = MagicMock()
|
|
session.flush = MagicMock()
|
|
session.refresh = MagicMock()
|
|
session.commit = MagicMock()
|
|
session.rollback = MagicMock()
|
|
session.close = MagicMock()
|
|
return session
|
|
|
|
|
|
# =========================================================================
|
|
# Non-SQLite engine (lines 90-93)
|
|
# =========================================================================
|
|
|
|
|
|
@given("a UnitOfWork configured with a non-SQLite database URL")
|
|
def step_uow_non_sqlite_url(context: Context) -> None:
|
|
"""Create a UnitOfWork with a PostgreSQL-style URL (never actually connects)."""
|
|
context.uow = UnitOfWork(
|
|
database_url="postgresql://user:pass@localhost:5432/testdb"
|
|
)
|
|
|
|
|
|
@when("I access the engine property with mocked create_engine")
|
|
def step_access_engine_property_mocked(context: Context) -> None:
|
|
"""Access the engine property, mocking _ensure_database_initialized and create_engine."""
|
|
import sys as _sys
|
|
|
|
from sqlalchemy.engine import Engine
|
|
|
|
mock_engine = MagicMock(spec=Engine)
|
|
|
|
# Ensure psycopg2 can be imported (it may not be installed in test env)
|
|
_needs_psycopg2_stub = "psycopg2" not in _sys.modules
|
|
if _needs_psycopg2_stub:
|
|
_sys.modules["psycopg2"] = MagicMock()
|
|
|
|
try:
|
|
with (
|
|
patch.object(context.uow, "_ensure_database_initialized"),
|
|
patch(
|
|
"cleveragents.infrastructure.database.unit_of_work.create_engine",
|
|
return_value=mock_engine,
|
|
) as mock_create,
|
|
):
|
|
engine = context.uow.engine
|
|
context.result_engine = engine
|
|
context.mock_create_engine = mock_create
|
|
finally:
|
|
if _needs_psycopg2_stub:
|
|
_sys.modules.pop("psycopg2", None)
|
|
|
|
|
|
@then("the engine should be created via the non-SQLite code path")
|
|
def step_engine_created_non_sqlite(context: Context) -> None:
|
|
"""Verify that create_engine was called (meaning the non-SQLite branch ran)."""
|
|
assert context.mock_create_engine.called, "create_engine was not called"
|
|
|
|
|
|
@then("the engine should not use SQLite-specific connect_args")
|
|
def step_engine_no_sqlite_args(context: Context) -> None:
|
|
"""Verify the call did not include connect_args or isolation_level."""
|
|
call_kwargs = context.mock_create_engine.call_args[1]
|
|
assert "connect_args" not in call_kwargs, (
|
|
f"connect_args should not be present for non-SQLite, got {call_kwargs}"
|
|
)
|
|
assert "isolation_level" not in call_kwargs, (
|
|
f"isolation_level should not be present for non-SQLite, got {call_kwargs}"
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# Fresh in-memory engine (lines 71-76)
|
|
# =========================================================================
|
|
|
|
|
|
@given("the in-memory engine cache is cleared")
|
|
def step_clear_memory_engine_cache(context: Context) -> None:
|
|
"""Clear the MEMORY_ENGINES cache so the cache-miss branch is exercised."""
|
|
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
|
|
|
|
# Save and clear so the test exercises the cache-miss path
|
|
context.saved_memory_engines = dict(MEMORY_ENGINES)
|
|
MEMORY_ENGINES.clear()
|
|
|
|
|
|
@given("a UnitOfWork configured with an in-memory SQLite URL")
|
|
def step_uow_in_memory_sqlite(context: Context) -> None:
|
|
"""Create a UnitOfWork targeting in-memory SQLite."""
|
|
context.uow = UnitOfWork(database_url="sqlite:///:memory:")
|
|
|
|
|
|
@when("I access the in-memory engine property with mocked migrations")
|
|
def step_access_in_memory_engine_mocked_migrations(context: Context) -> None:
|
|
"""Access the engine property, mocking only _ensure_database_initialized.
|
|
|
|
The real create_engine runs so that MEMORY_ENGINES is actually populated
|
|
with a real engine, exercising lines 71-76.
|
|
"""
|
|
with patch.object(context.uow, "_ensure_database_initialized"):
|
|
engine = context.uow.engine
|
|
context.result_engine = engine
|
|
|
|
|
|
@then("a new in-memory engine should be created and cached")
|
|
def step_engine_created_and_cached(context: Context) -> None:
|
|
"""Verify the engine was stored in MEMORY_ENGINES."""
|
|
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
|
|
|
|
assert "sqlite:///:memory:" in MEMORY_ENGINES, (
|
|
"Expected sqlite:///:memory: in MEMORY_ENGINES cache"
|
|
)
|
|
assert context.result_engine is MEMORY_ENGINES["sqlite:///:memory:"], (
|
|
"Engine returned should be the same as the cached one"
|
|
)
|
|
|
|
|
|
@then("the cached engine should use SERIALIZABLE isolation")
|
|
def step_cached_engine_serializable(context: Context) -> None:
|
|
"""Verify the created engine is cached and has the correct URL.
|
|
|
|
The SERIALIZABLE isolation level is set at creation time. We verify the
|
|
engine is present and has the correct URL as a proxy.
|
|
"""
|
|
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
|
|
|
|
engine = MEMORY_ENGINES.get("sqlite:///:memory:")
|
|
assert engine is not None, "Engine should be present in cache"
|
|
assert str(engine.url) == "sqlite:///:memory:", (
|
|
f"Expected sqlite:///:memory: URL, got {engine.url}"
|
|
)
|
|
# Restore saved cache state
|
|
if hasattr(context, "saved_memory_engines"):
|
|
MEMORY_ENGINES.clear()
|
|
MEMORY_ENGINES.update(context.saved_memory_engines)
|
|
|
|
|
|
# =========================================================================
|
|
# Checkpoint repository (lines 244-248)
|
|
# =========================================================================
|
|
|
|
|
|
@given("a UnitOfWorkContext backed by a mock session")
|
|
def step_uow_context_mock_session(context: Context) -> None:
|
|
"""Create a UnitOfWorkContext with a mock session."""
|
|
context.mock_session = _make_mock_session()
|
|
context.uow_ctx = UnitOfWorkContext(context.mock_session)
|
|
|
|
|
|
@when("I access the checkpoints repository from the context")
|
|
def step_access_checkpoints_repo(context: Context) -> None:
|
|
"""Access the checkpoints property."""
|
|
context.checkpoints_result = context.uow_ctx.checkpoints
|
|
|
|
|
|
@then("the checkpoints repository should be a CheckpointRepository instance")
|
|
def step_checkpoints_is_correct_type(context: Context) -> None:
|
|
"""Verify the result is a CheckpointRepository."""
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
CheckpointRepository,
|
|
)
|
|
|
|
assert isinstance(context.checkpoints_result, CheckpointRepository), (
|
|
f"Expected CheckpointRepository, got {type(context.checkpoints_result)}"
|
|
)
|
|
|
|
|
|
@when("I access the checkpoints repository from the context twice")
|
|
def step_access_checkpoints_repo_twice(context: Context) -> None:
|
|
"""Access the checkpoints property twice."""
|
|
context.checkpoints_first = context.uow_ctx.checkpoints
|
|
context.checkpoints_second = context.uow_ctx.checkpoints
|
|
|
|
|
|
@then("both accesses should return the same CheckpointRepository instance")
|
|
def step_checkpoints_cached(context: Context) -> None:
|
|
"""Verify both accesses return the same object (lazy caching)."""
|
|
assert context.checkpoints_first is context.checkpoints_second, (
|
|
"Expected same CheckpointRepository instance on repeated access"
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# refresh() method (line 312)
|
|
# =========================================================================
|
|
|
|
|
|
@when("I call refresh with a dummy entity")
|
|
def step_call_refresh(context: Context) -> None:
|
|
"""Call refresh() with a dummy entity object."""
|
|
context.dummy_entity = MagicMock(name="DummyEntity")
|
|
context.uow_ctx.refresh(context.dummy_entity)
|
|
|
|
|
|
@then("session.refresh should have been called with that entity")
|
|
def step_verify_session_refresh_called(context: Context) -> None:
|
|
"""Verify the underlying session.refresh was called with the entity."""
|
|
context.mock_session.refresh.assert_called_once_with(context.dummy_entity)
|
|
|
|
|
|
# =========================================================================
|
|
# Non-SQLite engine options (lines 90-93 deeper verification)
|
|
# =========================================================================
|
|
|
|
|
|
@given("a UnitOfWork configured with a postgresql-style database URL")
|
|
def step_uow_postgresql_url(context: Context) -> None:
|
|
"""Create a UnitOfWork with a PostgreSQL URL."""
|
|
context.uow = UnitOfWork(
|
|
database_url="postgresql+psycopg2://user:pass@db.example.com:5432/myapp"
|
|
)
|
|
|
|
|
|
@when("I trigger engine creation with mocked dependencies")
|
|
def step_trigger_engine_creation_mocked(context: Context) -> None:
|
|
"""Access engine property with mocked create_engine and migration runner."""
|
|
import sys as _sys
|
|
|
|
from sqlalchemy.engine import Engine
|
|
|
|
mock_engine = MagicMock(spec=Engine)
|
|
|
|
# Ensure psycopg2 can be imported (it may not be installed in test env)
|
|
_needs_psycopg2_stub = "psycopg2" not in _sys.modules
|
|
if _needs_psycopg2_stub:
|
|
_sys.modules["psycopg2"] = MagicMock()
|
|
|
|
try:
|
|
with (
|
|
patch.object(context.uow, "_ensure_database_initialized"),
|
|
patch(
|
|
"cleveragents.infrastructure.database.unit_of_work.create_engine",
|
|
return_value=mock_engine,
|
|
) as mock_create,
|
|
):
|
|
engine = context.uow.engine
|
|
context.result_engine = engine
|
|
context.mock_create_engine = mock_create
|
|
finally:
|
|
if _needs_psycopg2_stub:
|
|
_sys.modules.pop("psycopg2", None)
|
|
|
|
|
|
@then("create_engine should have been called without isolation_level")
|
|
def step_create_engine_no_isolation_level(context: Context) -> None:
|
|
"""Verify create_engine was called without isolation_level kwarg."""
|
|
call_kwargs = context.mock_create_engine.call_args[1]
|
|
assert "isolation_level" not in call_kwargs, (
|
|
f"isolation_level should not be set for non-SQLite, got {call_kwargs}"
|
|
)
|
|
|
|
|
|
@then("create_engine should have been called with future=True")
|
|
def step_create_engine_future_true(context: Context) -> None:
|
|
"""Verify create_engine was called with future=True."""
|
|
call_kwargs = context.mock_create_engine.call_args[1]
|
|
assert call_kwargs.get("future") is True, f"Expected future=True, got {call_kwargs}"
|
|
|
|
|
|
# =========================================================================
|
|
# In-memory engine cache hit (lines 70-78)
|
|
# =========================================================================
|
|
|
|
|
|
@given("a first UnitOfWork has already created an in-memory engine")
|
|
def step_first_uow_creates_engine(context: Context) -> None:
|
|
"""Create a UnitOfWork and access its engine, populating MEMORY_ENGINES."""
|
|
uow1 = UnitOfWork(database_url="sqlite:///:memory:")
|
|
with patch.object(uow1, "_ensure_database_initialized"):
|
|
engine1 = uow1.engine
|
|
context.first_engine = engine1
|
|
|
|
|
|
@when("a second UnitOfWork accesses its engine property")
|
|
def step_second_uow_accesses_engine(context: Context) -> None:
|
|
"""Create a second UnitOfWork and access its engine property."""
|
|
uow2 = UnitOfWork(database_url="sqlite:///:memory:")
|
|
with patch.object(uow2, "_ensure_database_initialized"):
|
|
engine2 = uow2.engine
|
|
context.second_engine = engine2
|
|
|
|
|
|
@then("the second engine should be the same object as the first")
|
|
def step_second_engine_same_as_first(context: Context) -> None:
|
|
"""Both UnitOfWork instances should share the same cached engine."""
|
|
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
|
|
|
|
assert context.second_engine is context.first_engine, (
|
|
"Expected same engine object from MEMORY_ENGINES cache"
|
|
)
|
|
# Restore cache
|
|
if hasattr(context, "saved_memory_engines"):
|
|
MEMORY_ENGINES.clear()
|
|
MEMORY_ENGINES.update(context.saved_memory_engines)
|