forked from HAL9000/cleveragents-core
278 lines
10 KiB
Python
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
|