Files
cleveragents-core/features/steps/skill_repo_coverage_steps.py
2026-02-21 16:30:40 +00:00

714 lines
26 KiB
Python

"""Step definitions for skill_repo_coverage.feature.
Targets the remaining ~32 uncovered lines in repositories.py, focusing on
SkillRepository error handlers, SkillInUseError, ToolRepository edge cases,
ValidationAttachmentRepository branches, and ResourceRepository auto-discover.
All step patterns are prefixed with "srcover" to avoid AmbiguousStep collisions.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine, event, text
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.skill import Skill
from cleveragents.infrastructure.database.models import (
Base,
ResourceModel,
ResourceTypeModel,
)
from cleveragents.infrastructure.database.repositories import (
DuplicateResourceTypeError,
DuplicateSkillError,
ResourceRepository,
ResourceTypeRepository,
SkillInUseError,
SkillRepository,
ToolRepository,
ValidationAttachmentRepository,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _enable_fk_pragma(dbapi_conn: Any, _connection_record: Any) -> None:
"""Enable SQLite foreign key enforcement."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def _capture_srcover_error(
context: Context, fn: Any, *args: Any, **kwargs: Any
) -> None:
"""Call *fn* and capture any error to ``context.srcover_error``."""
try:
context.srcover_result = fn(*args, **kwargs)
except (DatabaseError, DuplicateSkillError, DuplicateResourceTypeError) as exc:
context.srcover_error = exc
except Exception as exc:
# tenacity RetryError wraps the real cause
cause = getattr(exc, "__cause__", None) or exc
context.srcover_error = cause
def _make_skill(
name: str = "local/test-skill",
description: str = "Test skill",
tool_refs: list[str] | None = None,
overrides: dict[str, dict[str, Any]] | None = None,
) -> Skill:
"""Create a minimal Skill domain object."""
return Skill(
name=name,
description=description,
tool_refs=tool_refs or [],
includes=[],
anonymous_tools=[],
mcp_servers=[],
agent_skills=[],
overrides=overrides or {},
)
def _mock_session_op_error_on_flush() -> MagicMock:
"""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
query_mock = MagicMock()
query_mock.filter_by.return_value.first.return_value = None
query_mock.filter.return_value.first.return_value = None
mock.query.return_value = query_mock
return mock
def _mock_session_op_error_on_query() -> MagicMock:
"""Session whose ``.query()`` always raises OperationalError."""
mock = MagicMock()
mock.query.side_effect = OperationalError("connection lost", {}, None)
mock.rollback.return_value = None
return mock
def _now_iso() -> str:
from datetime import UTC, datetime
return datetime.now(tz=UTC).isoformat()
# ===========================================================================
# SkillInUseError (lines 4027-4031)
# ===========================================================================
@when('a SkillInUseError is created for skill "{name}" without detail')
def step_skill_in_use_no_detail(context: Context, name: str) -> None:
context.srcover_error = SkillInUseError(name)
@when('a SkillInUseError is created for skill "{name}" with detail "{detail}"')
def step_skill_in_use_with_detail(context: Context, name: str, detail: str) -> None:
context.srcover_error = SkillInUseError(name, detail=detail)
@then('the SkillInUseError message should contain "{fragment}"')
def step_skill_in_use_message(context: Context, fragment: str) -> None:
msg = str(context.srcover_error)
assert fragment in msg, f"Expected '{fragment}' in message: {msg}"
@then('the SkillInUseError skill_name should be "{name}"')
def step_skill_in_use_skill_name(context: Context, name: str) -> None:
assert context.srcover_error.skill_name == name, (
f"Expected skill_name '{name}', got '{context.srcover_error.skill_name}'"
)
# ===========================================================================
# ToolRepository.__init__ TypeError (line 3387)
# ===========================================================================
@when("a ToolRepository is created without any session factory")
def step_tool_repo_no_factory(context: Context) -> None:
context.srcover_error = None
try:
ToolRepository() # type: ignore[call-arg]
except TypeError as exc:
context.srcover_error = exc
@then("a TypeError should be raised about session_factory requirement")
def step_tool_repo_type_error(context: Context) -> None:
assert context.srcover_error is not None, "Expected TypeError but none raised"
assert isinstance(context.srcover_error, TypeError), (
f"Expected TypeError, got {type(context.srcover_error).__name__}"
)
assert (
"session_factory" in str(context.srcover_error).lower()
or "factory" in str(context.srcover_error).lower()
), f"Expected 'session_factory' in message: {context.srcover_error}"
# ===========================================================================
# ToolRepository._extract_value enum unwrapping (line 3410)
# ===========================================================================
@when('_extract_value is called with an enum-like attribute having value "{val}"')
def step_extract_value_enum(context: Context, val: str) -> None:
# Line 3409 checks ``"value" in raw.__class__.__dict__``.
# Python stdlib Enum puts ``value`` in the *base* Enum class, not the
# concrete subclass, so we need a custom class whose ``__class__.__dict__``
# contains ``"value"`` directly (a class attribute, not instance attribute).
class _EnumLike:
value: str = val # class-level attribute -> in __class__.__dict__
obj = SimpleNamespace(status=_EnumLike())
context.srcover_result = ToolRepository._extract_value(obj, "status")
@then('the extracted value should be "{val}"')
def step_extracted_value(context: Context, val: str) -> None:
assert context.srcover_result == val, (
f"Expected '{val}', got '{context.srcover_result}'"
)
# ===========================================================================
# ToolRepository.remove success (line 3514)
# ===========================================================================
@given("a tool repository with a real in-memory database for srcover")
def step_tool_repo_real_db(context: Context) -> None:
engine = create_engine("sqlite:///:memory:", echo=False)
event.listen(engine, "connect", _enable_fk_pragma)
Base.metadata.create_all(engine)
factory: sessionmaker[Session] = sessionmaker(bind=engine)
context.srcover_tool_repo = ToolRepository(session_factory=factory)
context.srcover_tool_factory = factory
context.srcover_error = None
@given('a tool "{name}" exists in the srcover tool repository')
def step_tool_exists(context: Context, name: str) -> None:
tool = SimpleNamespace(
name=name,
description="A removable tool",
tool_type=SimpleNamespace(value="tool"),
source=SimpleNamespace(value="builtin"),
input_schema=None,
output_schema=None,
capability=SimpleNamespace(
read_only=False, writes=False, checkpointable=False, side_effects=[]
),
config_yaml=None,
resource_slots=[],
tool_id=None,
)
context.srcover_tool_repo.add(tool)
@when('the tool "{name}" is removed via srcover repository')
def step_tool_remove(context: Context, name: str) -> None:
context.srcover_removal_result = context.srcover_tool_repo.remove(name)
@then("the srcover tool removal result should be True")
def step_tool_removal_true(context: Context) -> None:
assert context.srcover_removal_result is True, (
f"Expected True, got {context.srcover_removal_result}"
)
# ===========================================================================
# ValidationAttachmentRepository.create with args (line 3589)
# ===========================================================================
def _ensure_va_resource_srcover(factory: sessionmaker[Session]) -> None:
"""Create a resource type + resource for VA tests."""
session = factory()
now = _now_iso()
existing = (
session.query(ResourceTypeModel).filter_by(name="test/va-srcover").first()
)
if existing is None:
rt = ResourceTypeModel(
name="test/va-srcover",
namespace="test",
description="for srcover VA tests",
resource_kind="physical",
sandbox_strategy="none",
user_addable=True,
handler_ref=None,
args_schema_json=None,
allowed_parent_types_json=None,
allowed_child_types_json="[]",
auto_discover_json=None,
equivalence_json=None,
capabilities_json='{"read": true, "write": true, "sandbox": true, "checkpoint": false}',
source=None,
created_at=now,
updated_at=now,
)
session.add(rt)
session.flush()
existing_res = (
session.query(ResourceModel).filter_by(resource_id="srcover-res-1").first()
)
if existing_res is None:
res = ResourceModel(
resource_id="srcover-res-1",
namespaced_name=None,
namespace=None,
type_name="test/va-srcover",
resource_kind="physical",
location=None,
description="srcover test resource",
read_only=False,
auto_discovered=False,
sandbox_strategy=None,
content_hash=None,
properties_json=None,
metadata_json=None,
created_at=now,
updated_at=now,
)
session.add(res)
session.commit()
@given("a validation attachment repository with real database for srcover")
def step_va_repo_real(context: Context) -> None:
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory: sessionmaker[Session] = sessionmaker(bind=engine)
_ensure_va_resource_srcover(factory)
context.srcover_va_repo = ValidationAttachmentRepository(factory)
context.srcover_va_factory = factory
context.srcover_error = None
@when("a validation is attached with args {args_json} for srcover")
def step_va_attach_with_args(context: Context, args_json: str) -> None:
args = json.loads(args_json)
context.srcover_va_result = context.srcover_va_repo.attach(
"local/test-validation",
"srcover-res-1",
"required",
args=args,
)
@then('the srcover attachment should have args_json containing "{fragment}"')
def step_va_args_json(context: Context, fragment: str) -> None:
result = context.srcover_va_result
assert result is not None, "Attachment result was None"
args_json_val = (
result.get("args_json")
if isinstance(result, dict)
else getattr(result, "args_json", None)
)
assert args_json_val is not None, "args_json should not be None"
assert fragment in args_json_val, (
f"Expected '{fragment}' in args_json: {args_json_val}"
)
# ===========================================================================
# ValidationAttachmentRepository.get_by_id returns None (line 3714)
# ===========================================================================
@when("get_by_id is called with a non-existent attachment ID for srcover")
def step_va_get_by_id_none(context: Context) -> None:
context.srcover_va_get_result = context.srcover_va_repo.get_by_id(
"01NONEXISTENT00000000000000"
)
@then("the srcover get_by_id result should be None")
def step_va_get_by_id_is_none(context: Context) -> None:
assert context.srcover_va_get_result is None, (
f"Expected None, got {context.srcover_va_get_result}"
)
# ===========================================================================
# ValidationAttachmentRepository.get_by_id OperationalError (lines 3725-3726)
# ===========================================================================
@given(
"a validation attachment repository with mock session raising OperationalError for srcover"
)
def step_va_repo_mock_op_error(context: Context) -> None:
mock = _mock_session_op_error_on_query()
context.srcover_va_repo = ValidationAttachmentRepository(
session_factory=lambda: mock
)
context.srcover_error = None
@when("get_by_id is called and an OperationalError occurs for srcover")
def step_va_get_by_id_error(context: Context) -> None:
_capture_srcover_error(
context, context.srcover_va_repo.get_by_id, "01FAKE0000000000000000000000"
)
# ===========================================================================
# ResourceRepository.auto_discover_children - empty type (line 2555)
# ===========================================================================
@given("a resource repository with in-memory database for srcover auto-discover")
def step_res_repo_real(context: Context) -> None:
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory: sessionmaker[Session] = sessionmaker(bind=engine)
context.srcover_res_factory = factory
context.srcover_res_repo = ResourceRepository(session_factory=factory)
context.srcover_error = None
@given("a resource type with auto_discover rules containing an empty type for srcover")
def step_res_type_empty_child(context: Context) -> None:
session = context.srcover_res_factory()
now = _now_iso()
rt = ResourceTypeModel(
name="test/empty-child-rule",
namespace="test",
description="has rule with empty type",
resource_kind="physical",
sandbox_strategy="none",
user_addable=True,
handler_ref=None,
args_schema_json=None,
allowed_parent_types_json=None,
allowed_child_types_json="[]",
auto_discover_json=json.dumps({"enabled": True, "rules": [{"type": ""}, {}]}),
equivalence_json=None,
capabilities_json='{"read": true, "write": true, "sandbox": true, "checkpoint": false}',
source=None,
created_at=now,
updated_at=now,
)
session.add(rt)
session.commit()
@given("a resource of that type exists for srcover auto-discover")
def step_res_for_auto_discover(context: Context) -> None:
session = context.srcover_res_factory()
now = _now_iso()
res = ResourceModel(
resource_id="srcover-auto-disc-001",
namespaced_name=None,
namespace=None,
type_name="test/empty-child-rule",
resource_kind="physical",
location=None,
description="srcover auto-disc test",
read_only=False,
auto_discovered=False,
sandbox_strategy=None,
content_hash=None,
properties_json=None,
metadata_json=None,
created_at=now,
updated_at=now,
)
session.add(res)
session.commit()
@when("auto_discover_children is called for the srcover resource")
def step_auto_discover_empty_type(context: Context) -> None:
context.srcover_auto_disc_result = context.srcover_res_repo.auto_discover_children(
"srcover-auto-disc-001"
)
@then("the srcover auto-discover result should be an empty list")
def step_auto_discover_empty(context: Context) -> None:
assert context.srcover_auto_disc_result == [], (
f"Expected empty list, got {context.srcover_auto_disc_result}"
)
# ===========================================================================
# SkillRepository.create - OperationalError (lines 4093-4095)
# ===========================================================================
@given(
"a skill repository with mock session raising OperationalError on flush for srcover"
)
def step_skill_repo_op_error_flush(context: Context) -> None:
mock = _mock_session_op_error_on_flush()
context.srcover_skill_repo = SkillRepository(session_factory=lambda: mock)
context.srcover_error = None
@when("a skill is created through the srcover repository and an error is expected")
def step_skill_create_error(context: Context) -> None:
skill = _make_skill(name="local/error-skill")
_capture_srcover_error(context, context.srcover_skill_repo.create, skill)
# ===========================================================================
# SkillRepository.create - non-UNIQUE IntegrityError (line 4090)
# ===========================================================================
@given(
"a skill repository with mock session raising non-UNIQUE IntegrityError for srcover"
)
def step_skill_repo_non_unique_integrity(context: Context) -> None:
mock = MagicMock()
mock.add.return_value = None
mock.flush.side_effect = IntegrityError(
"CHECK constraint failed: some_check", {}, None
)
mock.rollback.return_value = None
context.srcover_skill_repo = SkillRepository(session_factory=lambda: mock)
context.srcover_error = None
# ===========================================================================
# SkillRepository.create - DuplicateSkillError re-raise (lines 4091-4092)
# ===========================================================================
@given(
"a skill repository with mock session raising DuplicateSkillError on flush for srcover"
)
def step_skill_repo_dup_on_flush(context: Context) -> None:
mock = MagicMock()
mock.add.return_value = None
mock.flush.side_effect = DuplicateSkillError("local/dup-skill")
mock.rollback.return_value = None
context.srcover_skill_repo = SkillRepository(session_factory=lambda: mock)
context.srcover_error = None
@when(
"a skill is created through the srcover repository and a duplicate error is expected"
)
def step_skill_create_dup_error(context: Context) -> None:
skill = _make_skill(name="local/dup-skill")
_capture_srcover_error(context, context.srcover_skill_repo.create, skill)
@then("a srcover DuplicateSkillError should be raised")
def step_check_dup_skill_error(context: Context) -> None:
assert context.srcover_error is not None, (
"Expected DuplicateSkillError but none raised"
)
assert isinstance(context.srcover_error, DuplicateSkillError), (
f"Expected DuplicateSkillError, got {type(context.srcover_error).__name__}: "
f"{context.srcover_error}"
)
# ===========================================================================
# SkillRepository.get_by_name / list_all / update / delete - OperationalError
# (lines 4110-4111, 4133-4134, 4192-4194, 4219-4221)
# ===========================================================================
@given(
"a skill repository with mock session raising OperationalError on query for srcover"
)
def step_skill_repo_op_error_query(context: Context) -> None:
mock = _mock_session_op_error_on_query()
context.srcover_skill_repo = SkillRepository(session_factory=lambda: mock)
context.srcover_error = None
@when(
"a skill is fetched by name through the srcover repository and an error is expected"
)
def step_skill_get_error(context: Context) -> None:
_capture_srcover_error(context, context.srcover_skill_repo.get_by_name, "local/any")
@when("skills are listed through the srcover repository and an error is expected")
def step_skill_list_error(context: Context) -> None:
_capture_srcover_error(context, context.srcover_skill_repo.list_all)
@when("a skill is updated through the srcover repository and an error is expected")
def step_skill_update_error(context: Context) -> None:
skill = _make_skill(name="local/update-err")
_capture_srcover_error(context, context.srcover_skill_repo.update, skill)
@when("a skill is deleted through the srcover repository and an error is expected")
def step_skill_delete_error(context: Context) -> None:
_capture_srcover_error(context, context.srcover_skill_repo.delete, "local/del-err")
# ===========================================================================
# SkillRepository.update - overrides + child items (lines 4168, 4178)
# ===========================================================================
@given("a clean in-memory skill repository for srcover update tests")
def step_clean_skill_repo(context: Context) -> None:
engine = create_engine("sqlite:///:memory:", echo=False)
event.listen(engine, "connect", _enable_fk_pragma)
Base.metadata.create_all(engine)
factory: sessionmaker[Session] = sessionmaker(bind=engine)
context.srcover_skill_repo = SkillRepository(session_factory=factory)
context.srcover_skill_factory = factory
context.srcover_error = None
def _commit_srcover(context: Context) -> None:
"""Commit pending transaction on the shared in-memory connection."""
session = context.srcover_skill_factory()
try:
session.execute(text("SELECT 1"))
session.commit()
finally:
session.close()
@given('a skill "{name}" is registered with tool refs for srcover')
def step_register_skill_with_refs(context: Context, name: str) -> None:
skill = _make_skill(
name=name,
description="Original skill",
tool_refs=["local/tool-a", "local/tool-b"],
)
context.srcover_skill_repo.create(skill)
_commit_srcover(context)
@when('the skill "{name}" is updated with overrides and new items for srcover')
def step_update_with_overrides(context: Context, name: str) -> None:
updated_skill = _make_skill(
name=name,
description="Updated skill",
tool_refs=["local/tool-c", "local/tool-d", "local/tool-e"],
overrides={"local/tool-c": {"timeout": 30}},
)
context.srcover_skill_repo.update(updated_skill)
_commit_srcover(context)
# Re-fetch to verify
context.srcover_updated_skill = context.srcover_skill_repo.get_by_name(name)
@then("the srcover updated skill should have overrides metadata")
def step_check_overrides_metadata(context: Context) -> None:
skill = context.srcover_updated_skill
assert skill is not None, "Updated skill was None"
# The overrides are stored in metadata_json and reconstructed
assert skill.overrides is not None, "Overrides should not be None"
assert "local/tool-c" in skill.overrides, (
f"Expected 'local/tool-c' in overrides, got: {skill.overrides}"
)
@then("the srcover updated skill should have the new items")
def step_check_new_items(context: Context) -> None:
skill = context.srcover_updated_skill
assert skill is not None, "Updated skill was None"
assert len(skill.tool_refs) == 3, (
f"Expected 3 tool refs, got {len(skill.tool_refs)}"
)
assert "local/tool-c" in skill.tool_refs
# ===========================================================================
# Common assertion steps
# ===========================================================================
@then('a srcover DatabaseError should be raised containing "{fragment}"')
def step_check_db_error(context: Context, fragment: str) -> None:
assert context.srcover_error is not None, (
f"Expected a DatabaseError containing '{fragment}' but no error was raised"
)
assert isinstance(context.srcover_error, DatabaseError), (
f"Expected DatabaseError, got {type(context.srcover_error).__name__}: "
f"{context.srcover_error}"
)
assert fragment in str(context.srcover_error), (
f"Expected '{fragment}' in error message, got: {context.srcover_error}"
)
# ===========================================================================
# ResourceTypeRepository.create - DuplicateResourceTypeError (line 1711)
# ===========================================================================
@given(
"a resource type repository with mock session raising UNIQUE IntegrityError for srcover"
)
def step_rt_repo_unique_integrity(context: Context) -> None:
mock = MagicMock()
mock.add.return_value = None
mock.flush.side_effect = IntegrityError(
"UNIQUE constraint failed: resource_types.name", {}, None
)
mock.rollback.return_value = None
query_mock = MagicMock()
query_mock.filter_by.return_value.first.return_value = None
mock.query.return_value = query_mock
context.srcover_rt_repo = ResourceTypeRepository(session_factory=lambda: mock)
context.srcover_error = None
@when(
"a resource type is created through the srcover repository and an error is expected"
)
def step_rt_create_error(context: Context) -> None:
fake_rt = SimpleNamespace(
name="test/dup-type",
description="A duplicate type",
resource_kind=SimpleNamespace(value="physical"),
sandbox_strategy=SimpleNamespace(value="none"),
user_addable=True,
handler=None,
cli_args=[],
parent_types=[],
child_types=[],
auto_discovery=None,
equivalence=None,
capabilities=None,
source=None,
)
_capture_srcover_error(context, context.srcover_rt_repo.create, fake_rt)
@then("a srcover DuplicateResourceTypeError should be raised")
def step_check_dup_rt_error(context: Context) -> None:
assert context.srcover_error is not None, (
"Expected DuplicateResourceTypeError but none raised"
)
assert isinstance(context.srcover_error, DuplicateResourceTypeError), (
f"Expected DuplicateResourceTypeError, got "
f"{type(context.srcover_error).__name__}: {context.srcover_error}"
)