Files
cleveragents-core/features/steps/uow_coverage_boost_steps.py
T
HAL9000 8dde6c81ef feat(server): implement PostgreSQL storage backend for server mode
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
2026-05-29 01:04:59 -04:00

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)