Files
temp/features/steps/action_repository_error_handling_steps.py

278 lines
10 KiB
Python

"""Step definitions for ActionRepository transient database error coverage.
Targets the ``except (OperationalError, SQLAlchemyDatabaseError)`` handlers in
every public method of ``ActionRepository`` (lines 771-774, 793-794, 811-812,
840-841, 864-865, 924-925, 951-952, 996-998) plus the non-unique
``IntegrityError`` branch at line 769→771.
Strategy: replace the session factory with one that returns a mock session
whose ``.query()`` or ``.flush()`` raises ``OperationalError``, forcing each
error handler to execute. The ``@database_retry`` decorator retries 3 times
before re-raising the final ``DatabaseError``.
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import sessionmaker
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import NamespacedName
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import ActionRepository
# ── ULID helpers (same scheme as sibling step file) ────────────────────────
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_ULID_CTR = 2000 # offset to avoid collisions with other step files
def _next_ulid() -> str:
global _ULID_CTR
_ULID_CTR += 1
n = _ULID_CTR
suffix = ""
for _ in range(16):
suffix = _CB32[n % 32] + suffix
n //= 32
return f"01HGZ6FE0A{suffix}"
def _make_action(name: str = "local/test-action", state: str = "available") -> Action:
"""Create a minimal valid Action domain object."""
parts = name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
short_name = parts[1] if len(parts) == 2 else parts[0]
return Action(
namespaced_name=NamespacedName(namespace=namespace, name=short_name),
description=f"Test action {short_name}",
long_description=None,
definition_of_done=f"Verify {short_name} completes",
strategy_actor="local/strategist",
execution_actor="local/executor",
estimation_actor=None,
review_actor=None,
arguments=[],
reusable=True,
read_only=False,
state=ActionState(state),
created_at=datetime.now(),
updated_at=datetime.now(),
created_by=None,
tags=[],
)
# ── Broken session helpers ─────────────────────────────────────────────────
def _make_broken_session_on_query() -> MagicMock:
"""Return a mock Session whose .query() always raises OperationalError."""
mock = MagicMock()
mock.query.side_effect = OperationalError("connection lost", {}, None)
mock.rollback.return_value = None
return mock
def _make_broken_session_on_flush() -> MagicMock:
"""Return a mock Session whose .flush() raises OperationalError."""
mock = MagicMock()
mock.add.return_value = None
mock.flush.side_effect = OperationalError("disk I/O error", {}, None)
mock.rollback.return_value = None
return mock
def _make_session_with_non_unique_integrity_error() -> MagicMock:
"""Return a mock Session whose .flush() raises a non-unique IntegrityError."""
mock = MagicMock()
mock.add.return_value = None
mock.flush.side_effect = IntegrityError(
"CHECK constraint failed: some_check", {}, None
)
mock.rollback.return_value = None
return mock
# ── Background steps ───────────────────────────────────────────────────────
@given("an action repository whose session raises OperationalError on query")
def step_repo_with_broken_query(context: Context) -> None:
broken = _make_broken_session_on_query()
context.action_error_repo = ActionRepository(session_factory=lambda: broken)
context.error = None
context.saved_action_for_error = None
# ── Create: non-unique IntegrityError (line 769→771) ──────────────────────
@given("an action repository whose session raises a non-unique IntegrityError on flush")
def step_repo_non_unique_integrity(context: Context) -> None:
broken = _make_session_with_non_unique_integrity_error()
context.action_error_repo = ActionRepository(session_factory=lambda: broken)
context.error = None
@when("the action is saved and a database error is expected")
def step_save_expecting_error(context: Context) -> None:
try:
context.action_error_repo.create(context.action)
except DatabaseError as exc:
context.error = exc
except Exception as exc:
# tenacity may wrap in RetryError; unwrap to the root cause
cause = getattr(exc, "__cause__", None) or exc
context.error = cause
@then('a DatabaseError should be raised with message containing "{fragment}"')
def step_verify_db_error_message(context: Context, fragment: str) -> None:
assert context.error is not None, "Expected a DatabaseError but no error was raised"
assert isinstance(context.error, DatabaseError), (
f"Expected DatabaseError, got {type(context.error).__name__}: {context.error}"
)
assert fragment in str(context.error), (
f"Expected '{fragment}' in error message: {context.error}"
)
# ── Create: OperationalError (lines 772-774) ──────────────────────────────
@given("an action repository whose session raises OperationalError on flush")
def step_repo_op_error_on_flush(context: Context) -> None:
broken = _make_broken_session_on_flush()
context.action_error_repo = ActionRepository(session_factory=lambda: broken)
context.error = None
# ── get_by_id: OperationalError (lines 793-794) ───────────────────────────
@when("an action is retrieved by identifier and a database error is expected")
def step_get_by_id_error(context: Context) -> None:
try:
context.action_error_repo.get_by_id("01ZZZZZZZZZZZZZZZZZZZZZZZZ")
except DatabaseError as exc:
context.error = exc
except Exception as exc:
cause = getattr(exc, "__cause__", None) or exc
context.error = cause
# ── get_by_name: OperationalError (lines 811-812) ─────────────────────────
@when('an action is retrieved by name "{name}" and a database error is expected')
def step_get_by_name_error(context: Context, name: str) -> None:
try:
context.action_error_repo.get_by_name(name)
except DatabaseError as exc:
context.error = exc
except Exception as exc:
cause = getattr(exc, "__cause__", None) or exc
context.error = cause
# ── get_by_namespace: OperationalError (lines 840-841) ────────────────────
@when('actions in namespace "{ns}" are listed and a database error is expected')
def step_get_by_namespace_error(context: Context, ns: str) -> None:
try:
context.action_error_repo.get_by_namespace(ns)
except DatabaseError as exc:
context.error = exc
except Exception as exc:
cause = getattr(exc, "__cause__", None) or exc
context.error = cause
# ── get_by_state: OperationalError (lines 864-865) ────────────────────────
@when('actions in state "{state}" are listed and a database error is expected')
def step_get_by_state_error(context: Context, state: str) -> None:
try:
context.action_error_repo.get_by_state(state)
except DatabaseError as exc:
context.error = exc
except Exception as exc:
cause = getattr(exc, "__cause__", None) or exc
context.error = cause
# ── update: OperationalError (lines 924-925) ──────────────────────────────
@given('a saved action named "{action_name}" in a healthy repository')
def step_save_action_healthy(context: Context, action_name: str) -> None:
"""Persist an action in the real in-memory database for later update."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
context._healthy_session = session
repo = ActionRepository(session_factory=lambda: session)
action = _make_action(name=action_name)
repo.create(action)
session.commit()
context.saved_action_for_error = action
context._healthy_repo = repo
@given(
"the repository session is replaced with one that raises OperationalError on query"
)
def step_replace_session_with_broken(context: Context) -> None:
broken = _make_broken_session_on_query()
context.action_error_repo = ActionRepository(session_factory=lambda: broken)
context.error = None
@when("the saved action is updated and a database error is expected")
def step_update_error(context: Context) -> None:
try:
context.action_error_repo.update(context.saved_action_for_error)
except DatabaseError as exc:
context.error = exc
except Exception as exc:
cause = getattr(exc, "__cause__", None) or exc
context.error = cause
# ── list_available: OperationalError (lines 951-952) ──────────────────────
@when("available actions are listed and a database error is expected")
def step_list_available_error(context: Context) -> None:
try:
context.action_error_repo.list_available()
except DatabaseError as exc:
context.error = exc
except Exception as exc:
cause = getattr(exc, "__cause__", None) or exc
context.error = cause
# ── delete: OperationalError (lines 996-998) ──────────────────────────────
@when("an action is deleted by identifier and a database error is expected")
def step_delete_error(context: Context) -> None:
try:
context.action_error_repo.delete("01ZZZZZZZZZZZZZZZZZZZZZZZZ")
except DatabaseError as exc:
context.error = exc
except Exception as exc:
cause = getattr(exc, "__cause__", None) or exc
context.error = cause