c9abb45adf
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 31s
CI / typecheck (pull_request) Successful in 55s
CI / integration_tests (pull_request) Successful in 3m28s
CI / unit_tests (pull_request) Successful in 10m38s
CI / docker (pull_request) Successful in 16s
CI / benchmark-regression (pull_request) Successful in 20m39s
CI / coverage (pull_request) Successful in 41m22s
CI / lint (push) Successful in 13s
CI / quality (push) Successful in 17s
CI / build (push) Successful in 22s
CI / typecheck (push) Successful in 29s
CI / security (push) Successful in 29s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 2m40s
CI / unit_tests (push) Successful in 11m4s
CI / docker (push) Successful in 1m5s
CI / benchmark-publish (push) Successful in 12m10s
CI / coverage (push) Successful in 1h7m46s
Added 246 new BDD scenarios across 9 feature files to improve unit test coverage for modules that were either entirely untested or had significant coverage gaps: - lock_service_coverage.feature (27 scenarios): validation branches, TTL boundaries, re-entrant acquisition, rollback on exceptions - plan_apply_service_coverage.feature (54 scenarios): operation labels, diff rendering (plain/rich/json), artifact building, validation gate, changeset resolution and cleanup - plan_executor_coverage.feature (51 scenarios): step parsing, execute actor integration, strategize/execute guards, stub retry/recovery, decision tree construction - skill_cli_coverage_r3.feature (22 scenarios): tools refresh, list/show JSON fallback, capability summary errors, remove confirmation - changeset_repository_coverage.feature (39 scenarios): entry/tool repos validation, database error wrapping, domain conversion, SQLite store CRUD operations - repositories_coverage.feature (20 scenarios): get_by_name/namespace errors, list_available filters, delete with ActionInUseError, plan update with invariants/processing_state/error_details - sandbox_copy_on_write_coverage.feature (12 scenarios): create OSError wrapping, get_path state transitions, commit edge cases, rollback errors, cleanup with missing paths - bridge_coverage.feature (8 scenarios): __del__ suppression, async task cancellation, execute_graph message type handling, stream config, state checkpointer - plan_cli_coverage.feature (13 scenarios): legacy apply/list/cd paths, use-action with estimation/invariant actors, lifecycle-apply guards, status errors, error recovery display All 246 scenarios (1105 steps) pass. Step definitions use unique prefixes to prevent ambiguous step conflicts with existing tests. ISSUES CLOSED: #467
570 lines
18 KiB
Python
570 lines
18 KiB
Python
"""Step definitions for lock_service_coverage.feature - uncovered branches."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from cleveragents.application.services.lock_service import LockService
|
|
from cleveragents.core.exceptions import (
|
|
LockConflictError,
|
|
LockExpiredError,
|
|
ValidationError,
|
|
)
|
|
from cleveragents.infrastructure.database.models import Base, LockModel
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helper: build a real in-memory LockService
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def _make_lock_service(context: Context) -> LockService:
|
|
"""Create a LockService backed by an in-memory SQLite database."""
|
|
engine = create_engine(
|
|
"sqlite:///:memory:",
|
|
echo=False,
|
|
future=True,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
|
|
@event.listens_for(engine, "connect")
|
|
def _fk(dbapi_conn: Any, _rec: Any) -> None:
|
|
cursor = dbapi_conn.cursor()
|
|
cursor.execute("PRAGMA foreign_keys=ON")
|
|
cursor.close()
|
|
|
|
Base.metadata.create_all(engine)
|
|
factory: sessionmaker[Session] = sessionmaker(
|
|
bind=engine,
|
|
expire_on_commit=False,
|
|
autoflush=False,
|
|
autocommit=False,
|
|
class_=Session,
|
|
)
|
|
context.cov_engine = engine
|
|
context.cov_session_factory = factory
|
|
return LockService(session_factory=factory)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Stub session that blows up on execute (for rollback tests)
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
class _StubResult:
|
|
"""Minimal stand-in for a SQLAlchemy result object."""
|
|
|
|
rowcount: int = 0
|
|
|
|
def scalar_one_or_none(self) -> None:
|
|
"""Return None."""
|
|
return None
|
|
|
|
def scalar_one(self) -> int:
|
|
"""Return 0."""
|
|
return 0
|
|
|
|
|
|
class _ExplodingSession:
|
|
"""Session stub that raises RuntimeError on execute."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialise tracking flags."""
|
|
self.rolled_back: bool = False
|
|
self.committed: bool = False
|
|
|
|
def execute(self, stmt: Any) -> Any:
|
|
"""Raise RuntimeError unconditionally."""
|
|
raise RuntimeError("boom")
|
|
|
|
def add(self, obj: Any) -> None:
|
|
"""No-op."""
|
|
|
|
def commit(self) -> None:
|
|
"""Record commit."""
|
|
self.committed = True
|
|
|
|
def rollback(self) -> None:
|
|
"""Record rollback."""
|
|
self.rolled_back = True
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Background
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@given("I have a fresh lock service with in-memory database")
|
|
def step_fresh_lock_service(context: Context) -> None:
|
|
"""Create a fresh in-memory LockService for coverage scenarios."""
|
|
context.cov_svc = _make_lock_service(context)
|
|
context.cov_error = None
|
|
context.cov_result = None
|
|
context.cov_session = None
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Validation: release
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when(
|
|
'I try to release with resource_type "{rtype}" owner "{owner}" resource_id "{rid}"'
|
|
)
|
|
def step_try_release_validation(
|
|
context: Context, rtype: str, owner: str, rid: str
|
|
) -> None:
|
|
"""Attempt release with potentially invalid params."""
|
|
try:
|
|
context.cov_result = context.cov_svc.release(
|
|
owner_id=owner,
|
|
resource_type=rtype,
|
|
resource_id=rid,
|
|
)
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to release with an empty resource_type")
|
|
def step_try_release_empty_rtype(context: Context) -> None:
|
|
"""Attempt release with empty resource_type."""
|
|
try:
|
|
context.cov_svc.release(owner_id="o", resource_type="", resource_id="r1")
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to release with an empty resource_id")
|
|
def step_try_release_empty_rid(context: Context) -> None:
|
|
"""Attempt release with empty resource_id."""
|
|
try:
|
|
context.cov_svc.release(owner_id="o", resource_type="plan", resource_id="")
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Validation: renew
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when('I try to renew with resource_type "{rtype}" owner "{owner}" resource_id "{rid}"')
|
|
def step_try_renew_validation(
|
|
context: Context, rtype: str, owner: str, rid: str
|
|
) -> None:
|
|
"""Attempt renew with potentially invalid params."""
|
|
try:
|
|
context.cov_svc.renew(
|
|
owner_id=owner,
|
|
resource_type=rtype,
|
|
resource_id=rid,
|
|
)
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when(
|
|
'I try to renew with TTL {ttl:d} owner "{owner}" resource_type "{rtype}" resource_id "{rid}"'
|
|
)
|
|
def step_try_renew_bad_ttl(
|
|
context: Context, ttl: int, owner: str, rtype: str, rid: str
|
|
) -> None:
|
|
"""Attempt renew with out-of-range TTL."""
|
|
try:
|
|
context.cov_svc.renew(
|
|
owner_id=owner,
|
|
resource_type=rtype,
|
|
resource_id=rid,
|
|
ttl_seconds=ttl,
|
|
)
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to renew with an empty resource_type")
|
|
def step_try_renew_empty_rtype(context: Context) -> None:
|
|
"""Attempt renew with empty resource_type."""
|
|
try:
|
|
context.cov_svc.renew(owner_id="o", resource_type="", resource_id="r1")
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to renew with an empty resource_id")
|
|
def step_try_renew_empty_rid(context: Context) -> None:
|
|
"""Attempt renew with empty resource_id."""
|
|
try:
|
|
context.cov_svc.renew(owner_id="o", resource_type="plan", resource_id="")
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Validation: is_locked
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when('I try to check is_locked with resource_type "{rtype}" resource_id "{rid}"')
|
|
def step_try_is_locked_validation(context: Context, rtype: str, rid: str) -> None:
|
|
"""Attempt is_locked with potentially invalid params."""
|
|
try:
|
|
context.cov_result = context.cov_svc.is_locked(
|
|
resource_type=rtype,
|
|
resource_id=rid,
|
|
)
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to check is_locked with an empty resource_type")
|
|
def step_try_is_locked_empty_rtype(context: Context) -> None:
|
|
"""Attempt is_locked with empty resource_type."""
|
|
try:
|
|
context.cov_svc.is_locked(resource_type="", resource_id="r1")
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to check is_locked with an empty resource_id")
|
|
def step_try_is_locked_empty_rid(context: Context) -> None:
|
|
"""Attempt is_locked with empty resource_id."""
|
|
try:
|
|
context.cov_svc.is_locked(resource_type="plan", resource_id="")
|
|
except ValidationError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Validation assertion
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@then(
|
|
'a lock_service validation error should be raised with message containing "{fragment}"'
|
|
)
|
|
def step_assert_validation_error_message(context: Context, fragment: str) -> None:
|
|
"""Assert a ValidationError was raised whose message contains fragment."""
|
|
assert isinstance(context.cov_error, ValidationError), (
|
|
f"Expected ValidationError, got {type(context.cov_error).__name__}: {context.cov_error}"
|
|
)
|
|
assert fragment in str(context.cov_error), (
|
|
f"Expected '{fragment}' in '{context.cov_error}'"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# TTL boundary / custom TTL acquire
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when(
|
|
'I acquire a lock with TTL {ttl:d} owner "{owner}" resource_type "{rtype}" resource_id "{rid}"'
|
|
)
|
|
def step_acquire_with_ttl(
|
|
context: Context, ttl: int, owner: str, rtype: str, rid: str
|
|
) -> None:
|
|
"""Acquire a lock with an explicit TTL."""
|
|
context.cov_result = context.cov_svc.acquire(
|
|
owner_id=owner,
|
|
resource_type=rtype,
|
|
resource_id=rid,
|
|
ttl_seconds=ttl,
|
|
)
|
|
|
|
|
|
@then("the lock_service result should be true")
|
|
def step_result_true(context: Context) -> None:
|
|
"""Assert the result is True."""
|
|
assert context.cov_result is True, f"Expected True, got {context.cov_result!r}"
|
|
|
|
|
|
@then("the lock_service result should be false")
|
|
def step_result_false(context: Context) -> None:
|
|
"""Assert the result is False."""
|
|
assert context.cov_result is False, f"Expected False, got {context.cov_result!r}"
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Re-entrant project lock
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@given('owner "{owner}" already holds a project lock on "{rid}"')
|
|
def step_owner_holds_project_lock(context: Context, owner: str, rid: str) -> None:
|
|
"""Pre-acquire a project lock."""
|
|
context.cov_svc.acquire(
|
|
owner_id=owner,
|
|
resource_type="project",
|
|
resource_id=rid,
|
|
)
|
|
|
|
|
|
@given('owner "{owner}" already holds a plan lock on "{rid}"')
|
|
def step_owner_holds_plan_lock(context: Context, owner: str, rid: str) -> None:
|
|
"""Pre-acquire a plan lock."""
|
|
context.cov_svc.acquire(
|
|
owner_id=owner,
|
|
resource_type="plan",
|
|
resource_id=rid,
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# is_locked on expired lock
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@given('an expired lock exists for owner "{owner}" on plan "{plan_id}"')
|
|
def step_insert_expired_lock(context: Context, owner: str, plan_id: str) -> None:
|
|
"""Insert an already-expired lock directly into the database."""
|
|
now = datetime.now(tz=UTC)
|
|
expired_at = (now - timedelta(seconds=60)).isoformat()
|
|
acquired_at = (now - timedelta(seconds=120)).isoformat()
|
|
|
|
session: Session = context.cov_session_factory()
|
|
lock = LockModel(
|
|
owner_id=owner,
|
|
resource_type="plan",
|
|
resource_id=plan_id,
|
|
acquired_at=acquired_at,
|
|
expires_at=expired_at,
|
|
)
|
|
session.add(lock)
|
|
session.commit()
|
|
session.close()
|
|
|
|
|
|
@when('I check is_locked for resource_type "{rtype}" resource_id "{rid}"')
|
|
def step_check_is_locked_cov(context: Context, rtype: str, rid: str) -> None:
|
|
"""Check is_locked on a resource."""
|
|
context.cov_result = context.cov_svc.is_locked(
|
|
resource_type=rtype,
|
|
resource_id=rid,
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Zero-count paths
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when('I release all locks for owner_id "{owner}"')
|
|
def step_release_all_cov(context: Context, owner: str) -> None:
|
|
"""Release all locks for an owner via the coverage service."""
|
|
context.cov_result = context.cov_svc.release_all_for_owner(owner_id=owner)
|
|
|
|
|
|
@when("I run cleanup of expired locks")
|
|
def step_cleanup_expired_cov(context: Context) -> None:
|
|
"""Run cleanup_expired via the coverage service."""
|
|
context.cov_result = context.cov_svc.cleanup_expired()
|
|
|
|
|
|
@when("I count stale locks via the service")
|
|
def step_count_stale_cov(context: Context) -> None:
|
|
"""Count stale locks via the coverage service."""
|
|
context.cov_result = context.cov_svc.count_stale_locks()
|
|
|
|
|
|
@then("the lock_service integer result should be {expected:d}")
|
|
def step_assert_int_result(context: Context, expected: int) -> None:
|
|
"""Assert the result equals an expected integer."""
|
|
assert context.cov_result == expected, (
|
|
f"Expected {expected}, got {context.cov_result!r}"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Exception rollback paths (mocked session)
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@given("a lock service whose session raises an unexpected error on execute")
|
|
def step_exploding_session_service(context: Context) -> None:
|
|
"""Build a LockService whose session factory returns an exploding session."""
|
|
exploding = _ExplodingSession()
|
|
context.cov_session = exploding
|
|
context.cov_svc = LockService(session_factory=lambda: exploding) # type: ignore[arg-type]
|
|
context.cov_error = None
|
|
context.cov_result = None
|
|
|
|
|
|
@when("I try to acquire and expect a RuntimeError")
|
|
def step_try_acquire_runtime_error(context: Context) -> None:
|
|
"""Attempt acquire expecting a RuntimeError from the stub session."""
|
|
try:
|
|
context.cov_svc.acquire(
|
|
owner_id="owner-x",
|
|
resource_type="plan",
|
|
resource_id="res-x",
|
|
)
|
|
except RuntimeError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to release and expect a RuntimeError")
|
|
def step_try_release_runtime_error(context: Context) -> None:
|
|
"""Attempt release expecting a RuntimeError from the stub session."""
|
|
try:
|
|
context.cov_svc.release(
|
|
owner_id="owner-x",
|
|
resource_type="plan",
|
|
resource_id="res-x",
|
|
)
|
|
except RuntimeError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to renew and expect a RuntimeError")
|
|
def step_try_renew_runtime_error(context: Context) -> None:
|
|
"""Attempt renew expecting a RuntimeError from the stub session."""
|
|
try:
|
|
context.cov_svc.renew(
|
|
owner_id="owner-x",
|
|
resource_type="plan",
|
|
resource_id="res-x",
|
|
)
|
|
except RuntimeError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to release_all_for_owner and expect a RuntimeError")
|
|
def step_try_release_all_runtime_error(context: Context) -> None:
|
|
"""Attempt release_all_for_owner expecting RuntimeError."""
|
|
try:
|
|
context.cov_svc.release_all_for_owner(owner_id="owner-x")
|
|
except RuntimeError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@when("I try to cleanup_expired and expect a RuntimeError")
|
|
def step_try_cleanup_runtime_error(context: Context) -> None:
|
|
"""Attempt cleanup_expired expecting RuntimeError."""
|
|
try:
|
|
context.cov_svc.cleanup_expired()
|
|
except RuntimeError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@then("a RuntimeError should have been raised")
|
|
def step_assert_runtime_error(context: Context) -> None:
|
|
"""Assert that a RuntimeError was captured."""
|
|
assert isinstance(context.cov_error, RuntimeError), (
|
|
f"Expected RuntimeError, got {type(context.cov_error).__name__}: {context.cov_error}"
|
|
)
|
|
|
|
|
|
@then("the session should have been rolled back")
|
|
def step_assert_session_rolled_back(context: Context) -> None:
|
|
"""Assert the stub session's rollback method was called."""
|
|
assert context.cov_session is not None, "No stub session on context"
|
|
assert context.cov_session.rolled_back is True, (
|
|
"Expected session.rollback() to have been called"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Acquire conflict rollback path (real DB)
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@given('a lock service with a real database and owner "{owner}" holds plan "{plan_id}"')
|
|
def step_real_db_with_holder(context: Context, owner: str, plan_id: str) -> None:
|
|
"""Build a real-DB lock service and pre-acquire a lock."""
|
|
context.cov_svc = _make_lock_service(context)
|
|
context.cov_error = None
|
|
context.cov_result = None
|
|
context.cov_svc.acquire(
|
|
owner_id=owner,
|
|
resource_type="plan",
|
|
resource_id=plan_id,
|
|
)
|
|
|
|
|
|
@when('I try to acquire plan "{plan_id}" as "{owner}" and capture the conflict error')
|
|
def step_try_acquire_conflict(context: Context, plan_id: str, owner: str) -> None:
|
|
"""Attempt acquire expecting a LockConflictError."""
|
|
try:
|
|
context.cov_svc.acquire(
|
|
owner_id=owner,
|
|
resource_type="plan",
|
|
resource_id=plan_id,
|
|
)
|
|
except LockConflictError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@then("a LockConflictError should have been raised")
|
|
def step_assert_lock_conflict(context: Context) -> None:
|
|
"""Assert that a LockConflictError was captured."""
|
|
assert isinstance(context.cov_error, LockConflictError), (
|
|
f"Expected LockConflictError, got {type(context.cov_error).__name__}"
|
|
)
|
|
|
|
|
|
@then('the conflict owner should be "{expected}"')
|
|
def step_assert_conflict_owner(context: Context, expected: str) -> None:
|
|
"""Assert the conflict error reports the expected owner."""
|
|
err = context.cov_error
|
|
assert isinstance(err, LockConflictError), "Not a LockConflictError"
|
|
assert err.owner_id == expected, (
|
|
f"Expected conflict owner '{expected}', got '{err.owner_id}'"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Renew LockExpiredError rollback path (real DB)
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@given(
|
|
'a lock service with a real database and an expired lock for "{owner}" on plan "{plan_id}"'
|
|
)
|
|
def step_real_db_with_expired_lock(context: Context, owner: str, plan_id: str) -> None:
|
|
"""Build a real-DB lock service and insert an expired lock."""
|
|
context.cov_svc = _make_lock_service(context)
|
|
context.cov_error = None
|
|
context.cov_result = None
|
|
|
|
now = datetime.now(tz=UTC)
|
|
expired_at = (now - timedelta(seconds=60)).isoformat()
|
|
acquired_at = (now - timedelta(seconds=120)).isoformat()
|
|
|
|
session: Session = context.cov_session_factory()
|
|
lock = LockModel(
|
|
owner_id=owner,
|
|
resource_type="plan",
|
|
resource_id=plan_id,
|
|
acquired_at=acquired_at,
|
|
expires_at=expired_at,
|
|
)
|
|
session.add(lock)
|
|
session.commit()
|
|
session.close()
|
|
|
|
|
|
@when('I try to renew plan "{plan_id}" as "{owner}" and capture the expired error')
|
|
def step_try_renew_expired_cov(context: Context, plan_id: str, owner: str) -> None:
|
|
"""Attempt renew expecting a LockExpiredError."""
|
|
try:
|
|
context.cov_svc.renew(
|
|
owner_id=owner,
|
|
resource_type="plan",
|
|
resource_id=plan_id,
|
|
)
|
|
except LockExpiredError as exc:
|
|
context.cov_error = exc
|
|
|
|
|
|
@then("a LockExpiredError should have been raised")
|
|
def step_assert_lock_expired(context: Context) -> None:
|
|
"""Assert that a LockExpiredError was captured."""
|
|
assert isinstance(context.cov_error, LockExpiredError), (
|
|
f"Expected LockExpiredError, got {type(context.cov_error).__name__}"
|
|
)
|