Files
temp/features/steps/repo_indexing_persistence_coverage_steps.py
freemo 051ee7c290 test(coverage): add Behave BDD tests to improve coverage across 52 source files
Added 52 new .feature files and corresponding _steps.py files targeting
previously uncovered code paths in the following areas:

- TUI layer: app, commands, persona (state/schema/registry), widgets,
  input (shell_exec, reference_parser)
- Application services: plan lifecycle/service/executor, session,
  project, repo indexing, correction, checkpoint, actor, llm_actors,
  strategy coordinator, resource file watcher, service retry wiring
- CLI commands: session, resource, repl, plan, db, automation_profile
- Domain models: retry_policy, resource_type, cost_budget,
  docker_compose_analyzer, detail_level, _sql_string_aware,
  _postgresql_helpers
- Core: circuit_breaker, retry_service_patterns
- Infrastructure: repositories, transaction_sandbox, strategy_registry,
  plugins/loader, container
- Config: settings
- Agents: plan_generation, context_analysis, auto_debug
- A2A: facade

All new tests follow the Behave/Gherkin BDD standard. Resolved step
definition collisions with unique prefixes. Fixed Alembic fileConfig
logger disabling issue (disable_existing_loggers=False).

ISSUES CLOSED: #1068
2026-03-20 21:22:10 +00:00

432 lines
16 KiB
Python

"""Step definitions for repo_indexing_persistence_coverage.feature.
These steps target specific uncovered lines in repo_indexing_persistence.py:
- Lines 51-56: _safe_fromisoformat exception path (corrupt timestamp)
- Line 58: _safe_fromisoformat naive datetime → UTC attachment
- Line 81: persist_status rejects error_message when status != ERROR
- Lines 112-114: persist_status exception rollback path
- Lines 181-183: persist_index exception rollback path
- Lines 219-222: load_index skips corrupt file records
- Line 259: load_index_status returns None for missing resource
"""
import logging
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
from behave import given, then, when
from cleveragents.application.services import (
repo_indexing_persistence as persistence_mod,
)
from cleveragents.application.services.repo_indexing_persistence import (
_safe_fromisoformat,
load_index,
load_index_status,
persist_index,
persist_status,
)
from cleveragents.domain.models.core.repo_index import (
IndexMetadata,
IndexStatus,
RepoIndex,
)
# ---------------------------------------------------------------------------
# Logging capture helper
# ---------------------------------------------------------------------------
class _LogCapture(logging.Handler):
"""Simple handler that captures log records for assertion."""
def __init__(self):
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record):
self.records.append(record)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the repo indexing persistence module is imported")
def step_module_imported(context):
"""Ensure the persistence module is importable."""
assert persist_status is not None
assert persist_index is not None
assert load_index is not None
assert load_index_status is not None
assert _safe_fromisoformat is not None
# Re-enable the logger in case Alembic's fileConfig() disabled it
persistence_mod.logger.disabled = False
# Attach a log capture handler for assertions later
context.log_capture = _LogCapture()
context.log_capture.setLevel(logging.DEBUG)
persistence_mod.logger.addHandler(context.log_capture)
# Also monkey-patch the logger.warning method to directly capture records,
# bypassing any structlog/logging pipeline issues in the full test suite.
_original_warning = persistence_mod.logger.warning
def _capturing_warning(msg, *args, **kwargs):
record = logging.LogRecord(
name=persistence_mod.logger.name,
level=logging.WARNING,
pathname="",
lineno=0,
msg=msg,
args=args,
exc_info=None,
)
context.log_capture.emit(record)
return _original_warning(msg, *args, **kwargs)
persistence_mod.logger.warning = _capturing_warning
def cleanup():
persistence_mod.logger.warning = _original_warning
persistence_mod.logger.removeHandler(context.log_capture)
context.add_cleanup(cleanup)
# ---------------------------------------------------------------------------
# _safe_fromisoformat — corrupt timestamp (lines 51-56)
# ---------------------------------------------------------------------------
@when('I parse a corrupt timestamp value "{value}"')
def step_parse_corrupt_timestamp(context, value):
"""Call _safe_fromisoformat with a string that cannot be parsed."""
before = datetime.now(tz=UTC)
context.parsed_dt = _safe_fromisoformat(value)
context.before_parse = before
context.after_parse = datetime.now(tz=UTC)
@when("I parse an empty string as a timestamp")
def step_parse_empty_timestamp(context):
"""Call _safe_fromisoformat with an empty string."""
before = datetime.now(tz=UTC)
context.parsed_dt = _safe_fromisoformat("")
context.before_parse = before
context.after_parse = datetime.now(tz=UTC)
@then("ripcov the result should be a UTC-aware datetime close to now")
def step_verify_utc_now_fallback(context):
"""Verify the fallback returned a UTC datetime near the current time."""
dt = context.parsed_dt
assert dt.tzinfo is not None, "Expected UTC-aware datetime"
assert context.before_parse <= dt <= context.after_parse, (
f"Expected {dt} to be between {context.before_parse} and {context.after_parse}"
)
@then("a warning about corrupt timestamp should be logged")
def step_verify_corrupt_timestamp_warning(context):
"""Verify a warning about corrupt timestamp was logged."""
# Guard: force logger to be enabled for diagnostic clarity
persistence_mod.logger.disabled = False
warnings = [
r
for r in context.log_capture.records
if r.levelno == logging.WARNING and "Corrupt timestamp" in r.getMessage()
]
assert len(warnings) >= 1, (
f"Expected at least 1 corrupt timestamp warning, got {len(warnings)}"
)
# ---------------------------------------------------------------------------
# _safe_fromisoformat — naive datetime (line 58)
# ---------------------------------------------------------------------------
@when('I parse a naive ISO timestamp "{value}"')
def step_parse_naive_timestamp(context, value):
"""Call _safe_fromisoformat with a valid naive ISO timestamp."""
context.parsed_dt = _safe_fromisoformat(value)
@then("ripcov the result should be a UTC-aware datetime with the same wall-clock time")
def step_verify_utc_attached(context):
"""Verify UTC was attached to the naive datetime without conversion."""
dt = context.parsed_dt
assert dt.tzinfo is not None, "Expected UTC-aware datetime"
assert dt.year == 2024
assert dt.month == 6
assert dt.day == 15
assert dt.hour == 12
assert dt.minute == 30
assert dt.second == 0
# ---------------------------------------------------------------------------
# persist_status — error_message validation (line 81)
# ---------------------------------------------------------------------------
@given("a mock session factory for persistence tests")
def step_mock_session_factory(context):
"""Create a mock session factory for validation tests."""
context.mock_session = MagicMock()
context.mock_session_factory = MagicMock(return_value=context.mock_session)
@when("I call persist_status with status READY and an error_message")
def step_call_persist_status_invalid_error_msg(context):
"""Call persist_status with error_message set but status != ERROR."""
context.caught_error = None
try:
persist_status(
context.mock_session_factory,
index_id="01JTEST00000000000000AAAAA",
resource_id="01JTEST00000000000000BBBBB",
status=IndexStatus.READY,
error_message="should not be allowed",
)
except ValueError as exc:
context.caught_error = exc
@then("a ValueError should be raised about error_message")
def step_verify_value_error(context):
"""Verify the ValueError was raised."""
assert context.caught_error is not None, "Expected ValueError to be raised"
assert "error_message" in str(context.caught_error)
# ---------------------------------------------------------------------------
# persist_status — exception rollback (lines 112-114)
# ---------------------------------------------------------------------------
@given("a mock session factory that raises on commit")
def step_mock_session_factory_commit_failure(context):
"""Create a mock session factory whose session raises on commit."""
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = None
mock_session.commit.side_effect = RuntimeError("db write failure")
context.mock_session_commit_fail = mock_session
context.mock_session_factory_commit_fail = MagicMock(return_value=mock_session)
@when("I call persist_status and the session commit fails")
def step_call_persist_status_commit_fail(context):
"""Call persist_status expecting the commit to fail."""
context.persist_status_error = None
try:
persist_status(
context.mock_session_factory_commit_fail,
index_id="01JTEST00000000000000CCCCC",
resource_id="01JTEST00000000000000DDDDD",
status=IndexStatus.INDEXING,
)
except RuntimeError as exc:
context.persist_status_error = exc
@then("ripcov the session should have been rolled back")
def step_verify_rollback(context):
"""Verify session.rollback() was called."""
context.mock_session_commit_fail.rollback.assert_called_once()
@then("the original exception should be re-raised")
def step_verify_exception_reraised(context):
"""Verify the RuntimeError was re-raised."""
assert context.persist_status_error is not None, "Expected RuntimeError"
assert "db write failure" in str(context.persist_status_error)
# ---------------------------------------------------------------------------
# persist_index — exception rollback (lines 181-183)
# ---------------------------------------------------------------------------
@given("a mock session factory that raises on commit for persist_index")
def step_mock_session_factory_persist_index_fail(context):
"""Create a mock session factory whose session raises on commit for persist_index."""
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = None
mock_session.commit.side_effect = RuntimeError("index write failure")
context.pi_mock_session = mock_session
context.pi_mock_factory = MagicMock(return_value=mock_session)
# Build a valid RepoIndex to pass to persist_index
now = datetime.now(tz=UTC)
meta = IndexMetadata(
index_id="01JTEST00000000000000EEEEE",
resource_id="01JTEST00000000000000FFFFF",
indexed_at=now,
file_count=0,
token_estimate=0,
primary_language="python",
status=IndexStatus.READY,
created_at=now,
)
context.pi_repo_index = RepoIndex(metadata=meta, files=())
@when("I call persist_index and the session commit fails")
def step_call_persist_index_commit_fail(context):
"""Call persist_index expecting the commit to fail."""
context.persist_index_error = None
try:
persist_index(context.pi_mock_factory, context.pi_repo_index)
except RuntimeError as exc:
context.persist_index_error = exc
@then("the persist_index session should have been rolled back")
def step_verify_persist_index_rollback(context):
"""Verify session.rollback() was called on the persist_index session."""
context.pi_mock_session.rollback.assert_called_once()
@then("the persist_index original exception should be re-raised")
def step_verify_persist_index_reraised(context):
"""Verify the RuntimeError was re-raised from persist_index."""
assert context.persist_index_error is not None, "Expected RuntimeError"
assert "index write failure" in str(context.persist_index_error)
# ---------------------------------------------------------------------------
# load_index — corrupt file record (lines 219-222)
# ---------------------------------------------------------------------------
@given("a mock session factory with an index row and a corrupt file record")
def step_mock_session_factory_corrupt_file(context):
"""Create a mock session factory returning an index row with a corrupt file record.
The corrupt file record has a None path which will cause FileRecord
validation to raise a ValueError when the path validator rejects it.
"""
# Build a fake index row (RepoIndexModel-like)
now_iso = datetime.now(tz=UTC).isoformat()
fake_index_row = SimpleNamespace(
index_id="01JTEST00000000000000GGGGG",
resource_id="01JTEST00000000000000HHHHH",
file_count=1,
token_estimate=100,
primary_language="python",
status="ready",
error_message=None,
indexed_at=now_iso,
created_at=now_iso,
)
# Build a corrupt file record — None values cause TypeError/ValueError
# in FileRecord construction. We use None for token_count to trigger
# a validation error in pydantic.
corrupt_file_row = SimpleNamespace(
path=None,
content_hash=None,
token_count=None,
language=None,
size_bytes=None,
last_modified=None,
)
mock_session = MagicMock()
# First query().filter_by().first() returns the index row
# Second query().filter_by().all() returns the corrupt file rows
def query_side_effect(model_cls):
q = MagicMock()
# Distinguish between RepoIndexModel and IndexedFileModel queries
# by tracking call order
return q
# We need a more sophisticated mock: separate query chains
mock_index_query = MagicMock()
mock_index_query.filter_by.return_value.first.return_value = fake_index_row
mock_file_query = MagicMock()
mock_file_query.filter_by.return_value.all.return_value = [corrupt_file_row]
# Track which model is being queried
call_count = {"n": 0}
def query_dispatch(model_cls):
call_count["n"] += 1
if call_count["n"] == 1:
return mock_index_query
return mock_file_query
mock_session.query.side_effect = query_dispatch
context.corrupt_file_factory = MagicMock(return_value=mock_session)
context.corrupt_file_resource_id = "01JTEST00000000000000HHHHH"
@when("I call load_index for the resource")
def step_call_load_index_corrupt(context):
"""Call load_index which should skip the corrupt file record."""
context.load_index_result = load_index(
context.corrupt_file_factory,
context.corrupt_file_resource_id,
)
@then("ripcov the result should be a RepoIndex with zero files")
def step_verify_zero_files(context):
"""Verify the RepoIndex was returned but with no files (corrupt ones skipped)."""
result = context.load_index_result
assert result is not None, "Expected a RepoIndex, got None"
assert len(result.files) == 0, f"Expected 0 files, got {len(result.files)}"
@then("a warning about skipping corrupt file record should be logged")
def step_verify_corrupt_file_warning(context):
"""Verify a warning about skipping corrupt file record was logged."""
warnings = [
r
for r in context.log_capture.records
if r.levelno == logging.WARNING
and "Skipping corrupt file record" in r.getMessage()
]
assert len(warnings) >= 1, (
f"Expected at least 1 'Skipping corrupt file record' warning, "
f"got {len(warnings)}. All records: "
f"{[(r.levelno, r.getMessage()) for r in context.log_capture.records]}"
)
# ---------------------------------------------------------------------------
# load_index_status — returns None for missing resource (line 259)
# ---------------------------------------------------------------------------
@given("a mock session factory that returns no index row")
def step_mock_session_factory_no_row(context):
"""Create a mock session factory whose query returns None."""
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = None
context.no_row_factory = MagicMock(return_value=mock_session)
@when("I call load_index_status for a missing resource")
def step_call_load_index_status_missing(context):
"""Call load_index_status for a resource that does not exist."""
context.load_index_status_result = load_index_status(
context.no_row_factory,
"01JTEST00000000000000ZZZZZ",
)
@then("ripcov the result should be None")
def step_verify_none_result(context):
"""Verify the result is None."""
assert context.load_index_status_result is None, (
f"Expected None, got {context.load_index_status_result!r}"
)