forked from HAL9000/cleveragents-core
051ee7c290
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
432 lines
16 KiB
Python
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}"
|
|
)
|