Tests: Added coverage to get us to 97%

This commit is contained in:
2026-02-17 18:33:08 -05:00
parent 4cda00abf6
commit 77a0a95dc3
16 changed files with 5946 additions and 0 deletions
@@ -0,0 +1,89 @@
Feature: Automation Profile CRUD operations with repository
As a developer
I want to create, read, update, and delete custom profiles via a repository
So that custom automation profiles are persisted correctly
# ---- get_profile from repository (lines 160-162) ----
Scenario: Get custom profile from repository
Given a profile service with a mock repository
And the mock repository contains a profile named "acme/custom"
When I fetch profile "acme/custom"
Then the fetched profile name should be "acme/custom"
# ---- list_profiles with repository (lines 221-222) ----
Scenario: List profiles includes custom profiles from repository
Given a profile service with a mock repository
And the mock repository contains a profile named "acme/custom"
When I list all available profiles
Then the available profiles should include "manual"
And the available profiles should include "full-auto"
And the available profiles should include "acme/custom"
# ---- create_profile valid (lines 239-248) ----
Scenario: Create a custom profile with valid config
Given a profile service with a mock repository
When I create a profile with name "team/deploy" and description "Deploy profile"
Then the created profile name should be "team/deploy"
And the created profile description should be "Deploy profile"
And the mock repository should contain "team/deploy"
# ---- create_profile builtin name raises ValidationError (lines 240-241) ----
Scenario: Create profile with builtin name raises ValidationError
Given a profile service with a mock repository
When I try to create a profile with builtin name "manual"
Then a crud validation error should be raised
And the crud validation error message should mention "manual"
# ---- update_profile valid (lines 269-282) ----
Scenario: Update a custom profile with valid config
Given a profile service with a mock repository
And the mock repository contains a profile named "acme/custom"
When I update profile "acme/custom" with description "Updated description"
Then the updated profile name should be "acme/custom"
And the updated profile description should be "Updated description"
And the mock repository should contain "acme/custom"
# ---- update_profile builtin name raises ValidationError (line 269-270) ----
Scenario: Update builtin profile raises ValidationError
Given a profile service with a mock repository
When I try to update builtin profile "auto"
Then a crud validation error should be raised
And the crud validation error message should mention "auto"
# ---- update_profile without repo raises NotFoundError (lines 275-276) ----
Scenario: Update profile without repository raises NotFoundError
Given a profile service without a repository
When I try to update profile "acme/custom" without repo
Then a crud not found error should be raised
And the crud not found error message should mention "acme/custom"
# ---- delete_profile valid (lines 294-304) ----
Scenario: Delete a custom profile with valid name
Given a profile service with a mock repository
And the mock repository contains a profile named "acme/custom"
When I delete profile "acme/custom"
Then the mock repository should not contain "acme/custom"
# ---- delete_profile builtin name raises ValidationError (lines 294-295) ----
Scenario: Delete builtin profile raises ValidationError
Given a profile service with a mock repository
When I try to delete builtin profile "supervised"
Then a crud validation error should be raised
And the crud validation error message should mention "supervised"
# ---- delete_profile without repo raises NotFoundError (lines 298-299) ----
Scenario: Delete profile without repository raises NotFoundError
Given a profile service without a repository
When I try to delete profile "acme/custom" without repo
Then a crud not found error should be raised
And the crud not found error message should mention "acme/custom"
@@ -0,0 +1,122 @@
@phase1 @domain @tool_binding @coverage
Feature: Binding Resolution Service - Coverage Gaps
As a developer ensuring binding resolution correctness
I want to cover edge cases in static, parameter, contextual,
disambiguation, and type-compatibility paths
So that every code path is exercised
Background:
Given a mock resource registry
And a binding resolution service using the registry
# ---------------------------------------------------------------------------
# Static binding -- unknown resource (lines 147-148)
# ---------------------------------------------------------------------------
@static @error_handling
Scenario: Static binding raises ValidationError for unknown resource
Given a tool "local/deployer" with a static slot "cfg" bound to "local/nonexistent" of type "fs-directory"
And a project "local/proj" with no linked resources
When the binding resolution is attempted
Then a binding ValidationError should be raised mentioning "Static binding for slot"
# ---------------------------------------------------------------------------
# Parameter binding -- provided ref not found (lines 198-201)
# ---------------------------------------------------------------------------
@parameter @error_handling
Scenario: Parameter binding raises ValidationError for unknown ref
Given a tool "local/builder" with a parameter slot "target" of type "fs-directory"
And a project "local/proj" with no linked resources
When the bindings are resolved with invocation params:
| slot | ref |
| target | local/does-not-exist |
Then a binding ValidationError should be raised mentioning "Parameter binding for slot"
# ---------------------------------------------------------------------------
# Parameter binding -- valid ref resolves (lines 209, 214)
# ---------------------------------------------------------------------------
@parameter @resolve
Scenario: Parameter binding resolves successfully with valid ref
Given a named resource "01HGZ6FE0AQDYTR4BX00000060" called "local/my-dir" of type "fs-directory" in the registry
And a tool "local/builder" with a parameter slot "target" of type "fs-directory"
And a project "local/proj" with no linked resources
When the bindings are resolved with invocation params:
| slot | ref |
| target | local/my-dir |
Then the binding for slot "target" should have mode "parameter"
And the binding for slot "target" should have resource_id "01HGZ6FE0AQDYTR4BX00000060"
And the binding for slot "target" should not be deferred
# ---------------------------------------------------------------------------
# Contextual binding -- no match, slot NOT required (line 274)
# ---------------------------------------------------------------------------
@contextual @optional
Scenario: Contextual binding returns None resource when slot is not required
Given a project "local/empty" with no linked resources
And a tool "local/reader" with an optional contextual slot "extras" of type "git-checkout"
When the bindings are resolved
Then the binding for slot "extras" should have mode "contextual"
And the binding for slot "extras" should have no resource_id
And the binding for slot "extras" should not be deferred
# ---------------------------------------------------------------------------
# _find_matching_resources -- NotFoundError skipped (lines 298-299)
# ---------------------------------------------------------------------------
@contextual @not_found_skip
Scenario: Contextual binding skips linked resources that raise NotFoundError
Given a project "local/mixed" with linked resources including a missing one:
| resource_id | type_name | alias | exists |
| 01HGZ6FE0AQDYTR4BX00000070 | git-checkout | gone | false |
| 01HGZ6FE0AQDYTR4BX00000071 | git-checkout | repo | true |
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
When the bindings are resolved
Then the binding for slot "repo" should have resource_id "01HGZ6FE0AQDYTR4BX00000071"
And the binding for slot "repo" should not be deferred
# ---------------------------------------------------------------------------
# _disambiguate -- name suffix matching (lines 325-329)
# ---------------------------------------------------------------------------
@contextual @disambiguate @name_suffix
Scenario: Disambiguation matches slot name against resource name suffix
Given a project "local/multi" with named linked resources:
| resource_id | type_name | alias | resource_name |
| 01HGZ6FE0AQDYTR4BX00000080 | git-checkout | alpha | local/frontend/repo |
| 01HGZ6FE0AQDYTR4BX00000081 | git-checkout | beta | local/backend/other |
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
When the bindings are resolved
Then the binding for slot "repo" should have resource_id "01HGZ6FE0AQDYTR4BX00000080"
And the binding for slot "repo" should have resource_name "local/frontend/repo"
# ---------------------------------------------------------------------------
# _disambiguate -- ambiguous (lines 337-338)
# ---------------------------------------------------------------------------
@contextual @disambiguate @ambiguous @error_handling
Scenario: Disambiguation raises ValidationError for ambiguous candidates
Given a project "local/multi" with named linked resources:
| resource_id | type_name | alias | resource_name |
| 01HGZ6FE0AQDYTR4BX00000090 | git-checkout | alpha | local/first |
| 01HGZ6FE0AQDYTR4BX00000091 | git-checkout | beta | local/second |
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
When the binding resolution is attempted
Then a binding ValidationError should be raised mentioning "Ambiguous contextual binding"
# ---------------------------------------------------------------------------
# _is_type_compatible -- show_type raises NotFoundError (lines 369-370)
# ---------------------------------------------------------------------------
@type_compat @not_found
Scenario: Type compatibility returns False when show_type raises NotFoundError
Given a project "local/typed" with a linked resource:
| resource_id | type_name | alias |
| 01HGZ6FE0AQDYTR4BX00000095 | unknown-type | repo |
And the resource type "unknown-type" is removed from the registry
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
And the slot "repo" is required
When the binding resolution is attempted
Then a binding ValidationError should be raised mentioning "No resource of type"
+106
View File
@@ -0,0 +1,106 @@
Feature: Project CLI coverage boost for uncovered error paths
As a developer
I want to exercise the uncovered error-handling branches in project.py
So that line and branch coverage improves for cli/commands/project.py
Background:
Given the project CLI coverage mocks are prepared
# ── _get_resource_link_repo / _get_resource_registry_service helpers ──
Scenario: _get_resource_link_repo returns a link repo from the container
When I call the real _get_resource_link_repo helper
Then the coverage link repo helper should return successfully
Scenario: _get_resource_registry_service returns a registry service from the container
When I call the real _get_resource_registry_service helper
Then the coverage registry service helper should return successfully
# ── create command: resource linking raises NotFoundError ──
Scenario: create command warns when resource linking raises NotFoundError
Given the project repo mock is configured for successful create
And the link repo mock raises NotFoundError on create_link
When I invoke the CLI create command with name "notfound-proj" and resource "bad-res"
Then the coverage CLI result should contain "Warning"
And the coverage CLI exit code should be 0
# ── create command: resource linking raises DatabaseError ──
Scenario: create command warns when resource linking raises DatabaseError
Given the project repo mock is configured for successful create
And the link repo mock raises DatabaseError on create_link
When I invoke the CLI create command with name "dblink-proj" and resource "fail-res"
Then the coverage CLI result should contain "Warning"
And the coverage CLI exit code should be 0
# ── create command: re-fetch project raises generic Exception ──
Scenario: create command falls back when re-fetching project raises Exception
Given the project repo mock is configured for create but get raises Exception
When I invoke the CLI create command with name "refetch-proj" without resources
Then the coverage CLI result should contain "created"
And the coverage CLI exit code should be 0
# ── link-resource command: create_link raises DatabaseError ──
Scenario: link-resource command fails when create_link raises DatabaseError
Given the project repo mock returns a valid project for get
And the registry mock returns a valid resource
And the link repo mock raises DatabaseError on create_link for link-resource
When I invoke the CLI link-resource command for project "local/linkdb-proj" resource "some-res"
Then the coverage CLI result should contain "Error linking resource"
And the coverage CLI exit code should be 1
# ── unlink-resource command: user declines confirmation ──
Scenario: unlink-resource command aborts when user declines confirmation
Given the project repo mock returns a valid project for get
And the registry mock returns a valid resource
And the link repo mock returns a matching link for unlink
When I invoke the CLI unlink-resource command without yes and user declines
Then the coverage CLI exit code should be 1
# ── unlink-resource command: remove_link raises DatabaseError ──
Scenario: unlink-resource command fails when remove_link raises DatabaseError
Given the project repo mock returns a valid project for get
And the registry mock returns a valid resource
And the link repo mock returns a matching link for unlink
And the link repo mock raises DatabaseError on remove_link
When I invoke the CLI unlink-resource command with yes
Then the coverage CLI result should contain "Error unlinking resource"
And the coverage CLI exit code should be 1
# ── list command: list_projects raises DatabaseError ──
Scenario: list command fails when list_projects raises DatabaseError
Given the project repo mock raises DatabaseError on list_projects
When I invoke the CLI list command
Then the coverage CLI result should contain "Error listing projects"
And the coverage CLI exit code should be 1
# ── delete command: user declines confirmation ──
Scenario: delete command aborts when user declines confirmation
Given the project repo mock returns a project with no linked resources
When I invoke the CLI delete command without yes and user declines
Then the coverage CLI exit code should be 1
# ── delete command: repo.delete raises DatabaseError ──
Scenario: delete command fails when repo.delete raises DatabaseError
Given the project repo mock returns a project with no linked resources
And the project repo mock raises DatabaseError on delete
When I invoke the CLI delete command with yes
Then the coverage CLI result should contain "Error deleting project"
And the coverage CLI exit code should be 1
# ── delete command: repo.delete returns False ──
Scenario: delete command fails when repo.delete returns False
Given the project repo mock returns a project with no linked resources
And the project repo mock returns False on delete
When I invoke the CLI delete command with yes
Then the coverage CLI result should contain "could not be deleted"
And the coverage CLI exit code should be 1
@@ -0,0 +1,505 @@
@phase1 @domain @repository @error_handling_coverage
Feature: Repository error handling coverage for major repository classes
As a system operating under unstable database conditions
I want all repository error-handling paths to be covered
So that OperationalError, IntegrityError, and DatabaseError paths are exercised
# ==========================================================================
# NamespacedProjectRepository error handling
# ==========================================================================
# --- create: IntegrityError (UNIQUE) -> DatabaseError "already exists" ----
@project_repo @transient_error
Scenario: NamespacedProjectRepository create raises DatabaseError on UNIQUE IntegrityError
Given a namespaced project repository with session raising UNIQUE IntegrityError on flush
When a namespaced project is created and a repo error is expected
Then a repo DatabaseError should be raised containing "already exists"
# --- create: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository create raises DatabaseError on non-UNIQUE IntegrityError
Given a namespaced project repository with session raising non-UNIQUE IntegrityError on flush
When a namespaced project is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create project"
# --- create: OperationalError -> DatabaseError ----------------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository create raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on flush
When a namespaced project is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create project"
# --- get: OperationalError -> DatabaseError -------------------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository get raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on query
When a namespaced project is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get project"
# --- list_projects: OperationalError -> DatabaseError ---------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository list_projects raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on query
When namespaced projects are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list projects"
# --- update: OperationalError -> DatabaseError ----------------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository update raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on query
When a namespaced project is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update project"
# --- delete: OperationalError -> DatabaseError ----------------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository delete raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on query
When a namespaced project is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete project"
# ==========================================================================
# ToolRepository error handling
# ==========================================================================
# --- add: IntegrityError (UNIQUE) -> DuplicateToolError -------------------
@tool_repo @transient_error
Scenario: ToolRepository add raises DuplicateToolError on UNIQUE IntegrityError
Given a tool repository with session raising UNIQUE IntegrityError on flush
When a tool is added and a repo error is expected
Then a repo DuplicateToolError should be raised
# --- add: IntegrityError (non-UNIQUE) -> DatabaseError --------------------
@tool_repo @transient_error
Scenario: ToolRepository add raises DatabaseError on non-UNIQUE IntegrityError
Given a tool repository with session raising non-UNIQUE IntegrityError on flush
When a tool is added and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to add tool"
# --- add: OperationalError -> DatabaseError -------------------------------
@tool_repo @transient_error
Scenario: ToolRepository add raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on flush
When a tool is added and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to add tool"
# --- get_by_name: OperationalError -> DatabaseError -----------------------
@tool_repo @transient_error
Scenario: ToolRepository get_by_name raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on query
When a tool is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get tool by name"
# --- list_all: OperationalError -> DatabaseError --------------------------
@tool_repo @transient_error
Scenario: ToolRepository list_all raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on query
When all tools are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list tools"
# --- update: ToolNotFoundError (not found) --------------------------------
@tool_repo @not_found
Scenario: ToolRepository update raises ToolNotFoundError when tool not found
Given a tool repository backed by a clean in-memory database
When a non-existent tool is updated and a repo error is expected
Then a repo ToolNotFoundError should be raised
# --- update: OperationalError -> DatabaseError ----------------------------
@tool_repo @transient_error
Scenario: ToolRepository update raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on query
When a non-existent tool is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update tool"
# --- remove: ToolNotFoundError (not found) --------------------------------
@tool_repo @not_found
Scenario: ToolRepository remove raises ToolNotFoundError when tool not found
Given a tool repository backed by a clean in-memory database
When a non-existent tool is removed and a repo error is expected
Then a repo ToolNotFoundError should be raised
# --- remove: OperationalError -> DatabaseError ----------------------------
@tool_repo @transient_error
Scenario: ToolRepository remove raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on query
When a non-existent tool is removed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to remove tool"
# ==========================================================================
# AutomationProfileRepository error handling
# ==========================================================================
# --- get_by_name: OperationalError -> DatabaseError -----------------------
@profile_repo @transient_error
Scenario: AutomationProfileRepository get_by_name raises DatabaseError on OperationalError
Given an automation profile repository with session raising OperationalError on query
When an automation profile is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get profile"
# --- list_all: OperationalError -> DatabaseError --------------------------
@profile_repo @transient_error
Scenario: AutomationProfileRepository list_all raises DatabaseError on OperationalError
Given an automation profile repository with session raising OperationalError on query
When all automation profiles are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list profiles"
# --- upsert: OperationalError -> DatabaseError ----------------------------
@profile_repo @transient_error
Scenario: AutomationProfileRepository upsert raises DatabaseError on OperationalError
Given an automation profile repository with session raising OperationalError on query
When an automation profile is upserted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to upsert profile"
# --- delete: OperationalError -> DatabaseError ----------------------------
@profile_repo @transient_error
Scenario: AutomationProfileRepository delete raises DatabaseError on OperationalError
Given an automation profile repository with session raising OperationalError on query
When an automation profile is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete profile"
# ==========================================================================
# ResourceTypeRepository error handling
# ==========================================================================
# --- create: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository create raises DatabaseError on non-UNIQUE IntegrityError
Given a resource type repository with session that passes query but raises non-UNIQUE IntegrityError on flush
When a resource type is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create resource type"
# --- create: OperationalError -> DatabaseError ----------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository create raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on flush
When a resource type is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create resource type"
# --- get: OperationalError -> DatabaseError -------------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository get raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on query
When a resource type is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get resource type"
# --- list_types: OperationalError -> DatabaseError ------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository list_types raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on query
When resource types are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list resource types"
# --- update: OperationalError -> DatabaseError ----------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository update raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on query
When a resource type is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update resource type"
# --- delete: OperationalError -> DatabaseError ----------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository delete raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on query
When a resource type is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete resource type"
# ==========================================================================
# ResourceRepository error handling
# ==========================================================================
# --- create: IntegrityError (UNIQUE) -> DuplicateResourceError ------------
@resource_repo @transient_error
Scenario: ResourceRepository create raises DuplicateResourceError on UNIQUE IntegrityError
Given a resource repository with session that passes initial queries but raises UNIQUE IntegrityError on flush
When a resource is created and a repo error is expected
Then a repo DuplicateResourceError should be raised
# --- create: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@resource_repo @transient_error
Scenario: ResourceRepository create raises DatabaseError on non-UNIQUE IntegrityError
Given a resource repository with session that passes initial queries but raises non-UNIQUE IntegrityError on flush
When a resource is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create resource"
# --- create: OperationalError -> DatabaseError ----------------------------
@resource_repo @transient_error
Scenario: ResourceRepository create raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on flush
When a resource is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create resource"
# --- get: OperationalError -> DatabaseError -------------------------------
@resource_repo @transient_error
Scenario: ResourceRepository get raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is fetched by ID and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get resource"
# --- get_by_name: OperationalError -> DatabaseError -----------------------
@resource_repo @transient_error
Scenario: ResourceRepository get_by_name raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get resource by name"
# --- list_resources: OperationalError -> DatabaseError --------------------
@resource_repo @transient_error
Scenario: ResourceRepository list_resources raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When resources are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list resources"
# --- update: OperationalError -> DatabaseError ----------------------------
@resource_repo @transient_error
Scenario: ResourceRepository update raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update resource"
# --- delete: OperationalError -> DatabaseError ----------------------------
@resource_repo @transient_error
Scenario: ResourceRepository delete raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete resource"
# --- resolve_namespaced_name: OperationalError -> DatabaseError -----------
@resource_repo @transient_error
Scenario: ResourceRepository resolve_namespaced_name raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is resolved by name-or-id and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to resolve resource"
# ==========================================================================
# ProjectResourceLinkRepository error handling
# ==========================================================================
# --- create_link: IntegrityError -> DatabaseError -------------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository create_link raises DatabaseError on IntegrityError
Given a project resource link repository with session that passes query but raises IntegrityError on flush
When a project resource link is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create link"
# --- create_link: OperationalError -> DatabaseError -----------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository create_link raises DatabaseError on OperationalError
Given a project resource link repository with session raising OperationalError on flush
When a project resource link is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create link"
# --- list_links: OperationalError -> DatabaseError ------------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository list_links raises DatabaseError on OperationalError
Given a project resource link repository with session raising OperationalError on query
When project resource links are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list links"
# --- get_link: OperationalError -> DatabaseError --------------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository get_link raises DatabaseError on OperationalError
Given a project resource link repository with session raising OperationalError on query
When a project resource link is fetched and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get link"
# --- remove_link: OperationalError -> DatabaseError -----------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository remove_link raises DatabaseError on OperationalError
Given a project resource link repository with session raising OperationalError on query
When a project resource link is removed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to remove link"
# ==========================================================================
# ValidationAttachmentRepository error handling
# ==========================================================================
# --- attach: IntegrityError (UNIQUE) -> DuplicateValidationAttachmentError
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository attach raises DuplicateValidationAttachmentError on UNIQUE IntegrityError
Given a validation attachment repository with session that passes query but raises UNIQUE IntegrityError on flush
When a validation is attached and a repo error is expected
Then a repo DuplicateValidationAttachmentError should be raised
# --- attach: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository attach raises DatabaseError on non-UNIQUE IntegrityError
Given a validation attachment repository with session that passes query but raises non-UNIQUE IntegrityError on flush
When a validation is attached and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to attach validation"
# --- attach: OperationalError -> DatabaseError ----------------------------
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository attach raises DatabaseError on OperationalError
Given a validation attachment repository with session raising OperationalError on flush
When a validation is attached and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to attach validation"
# --- detach: OperationalError -> DatabaseError ----------------------------
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository detach raises DatabaseError on OperationalError
Given a validation attachment repository with session raising OperationalError on query
When a validation is detached and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to detach validation"
# --- list_for_resource: OperationalError -> DatabaseError -----------------
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository list_for_resource raises DatabaseError on OperationalError
Given a validation attachment repository with session raising OperationalError on query
When validation attachments are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list attachments"
# ==========================================================================
# LifecyclePlanRepository error handling
# ==========================================================================
# --- create: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository create raises DatabaseError on non-UNIQUE IntegrityError
Given a lifecycle plan repository with session raising non-UNIQUE IntegrityError on flush
When a lifecycle plan is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create plan"
# --- create: OperationalError -> DatabaseError ----------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository create raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on flush
When a lifecycle plan is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create plan"
# --- get: OperationalError -> DatabaseError -------------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository get raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When a lifecycle plan is fetched by ID and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get plan"
# --- get_by_name: OperationalError -> DatabaseError -----------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository get_by_name raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When a lifecycle plan is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get plan by name"
# --- update: OperationalError -> DatabaseError ----------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository update raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When a lifecycle plan is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update plan"
# --- delete: OperationalError -> DatabaseError ----------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository delete raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When a lifecycle plan is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete plan"
# --- list_plans: OperationalError -> DatabaseError ------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository list_plans raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When lifecycle plans are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list plans"
# --- count: OperationalError -> DatabaseError -----------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository count raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When lifecycle plans are counted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to count plans"
# ==========================================================================
# ResourceRepository DAG operations error handling
# ==========================================================================
# --- link_child: IntegrityError -> DatabaseError --------------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository link_child raises DatabaseError on IntegrityError
Given a resource repository with session that has resources but raises IntegrityError on link flush
When a resource child is linked and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to link"
# --- link_child: OperationalError -> DatabaseError ------------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository link_child raises DatabaseError on OperationalError during link
Given a resource repository with session that has resources but raises OperationalError on link flush
When a resource child is linked and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to link"
# --- unlink_child: OperationalError -> DatabaseError ----------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository unlink_child raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource child is unlinked and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to unlink"
# --- get_children: OperationalError -> DatabaseError ----------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository get_children raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When resource children are fetched and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get children"
# --- get_parents: OperationalError -> DatabaseError -----------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository get_parents raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When resource parents are fetched and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get parents"
@@ -0,0 +1,349 @@
@unit
Feature: Repository uncovered lines coverage
Target the remaining uncovered lines in repositories.py to boost
line and branch coverage.
# -------------------------------------------------------------------
# LinkNotFoundError (lines 1595-1598)
# -------------------------------------------------------------------
Scenario: LinkNotFoundError stores parent and child IDs
Given a LinkNotFoundError for parent "P1" and child "C1"
Then the LinkNotFoundError message should contain "P1" and "C1"
And the LinkNotFoundError parent_id should be "P1"
And the LinkNotFoundError child_id should be "C1"
# -------------------------------------------------------------------
# ResourceTypeRepository DuplicateResourceTypeError from UNIQUE IntegrityError (line 1708)
# -------------------------------------------------------------------
Scenario: ResourceTypeRepository create raises DuplicateResourceTypeError on UNIQUE IntegrityError
Given a resource type repository backed by an in-memory database for uncovered lines
And a resource type "test/dup-type" exists in the database for uncovered lines
When the same resource type "test/dup-type" is created again for uncovered lines
Then a DuplicateResourceTypeError should be raised for uncovered lines
# -------------------------------------------------------------------
# ResourceTypeRepository._to_domain equivalence_json not None (line 1923)
# -------------------------------------------------------------------
Scenario: ResourceTypeRepository _to_domain parses equivalence_json when present
Given a resource type repository backed by an in-memory database for uncovered lines
And a resource type row with equivalence_json set to '{"field": "name"}'
When the resource type is retrieved by name "test/equiv-type" for uncovered lines
Then the resource type equivalence should contain key "field"
# -------------------------------------------------------------------
# ResourceRepository.list_resources namespace filter (line 2114)
# -------------------------------------------------------------------
Scenario: ResourceRepository list_resources filters by namespace
Given a resource repository backed by an in-memory database for uncovered lines
And resources exist in namespace "ns-alpha" and "ns-beta" for uncovered lines
When resources are listed with namespace "ns-alpha" for uncovered lines
Then only resources from namespace "ns-alpha" should be returned
# -------------------------------------------------------------------
# ResourceRepository.unlink_child parent not found (line 2385)
# -------------------------------------------------------------------
Scenario: ResourceRepository unlink_child raises error when parent not found
Given a resource repository backed by an in-memory database for uncovered lines
When unlink_child is called with non-existent parent "NOPARENT" and child "NOCHILD" for uncovered lines
Then a ResourceNotFoundRepoError should be raised for the unlink parent
# -------------------------------------------------------------------
# ResourceRepository.unlink_child child not found (line 2391)
# -------------------------------------------------------------------
Scenario: ResourceRepository unlink_child raises error when child not found
Given a resource repository backed by an in-memory database for uncovered lines
And a resource "parent-res" exists for uncovered unlink tests
When unlink_child is called with existing parent and non-existent child "NOCHILD" for uncovered lines
Then a ResourceNotFoundRepoError should be raised for the unlink child
# -------------------------------------------------------------------
# ResourceRepository.unlink_child link not found (line 2399)
# -------------------------------------------------------------------
Scenario: ResourceRepository unlink_child raises LinkNotFoundError when no link exists
Given a resource repository backed by an in-memory database for uncovered lines
And two resources "parent-ul" and "child-ul" exist but are not linked for uncovered lines
When unlink_child is called for "parent-ul" and "child-ul" for uncovered lines
Then a LinkNotFoundError should be raised for uncovered lines
# -------------------------------------------------------------------
# ResourceRepository.unlink_child re-raise domain errors (line 2407)
# -------------------------------------------------------------------
Scenario: ResourceRepository unlink_child re-raises domain errors unchanged
Given a resource repository backed by an in-memory database for uncovered lines
When unlink_child is called with non-existent parent "GHOST" and child "GHOST2" for uncovered lines re-raise
Then a ResourceNotFoundRepoError should be raised and re-raised for uncovered lines
# -------------------------------------------------------------------
# ResourceRepository.auto_discover_children type_row is None (line 2524)
# -------------------------------------------------------------------
Scenario: auto_discover_children returns empty when parent type not in DB
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with type "missing-type" that has no type row in DB for uncovered lines
When auto_discover_children is called for that resource for uncovered lines
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children auto_disc not enabled (lines 2528-2533)
# -------------------------------------------------------------------
Scenario: auto_discover_children returns empty when auto_discover_json is null
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with type that has null auto_discover_json for uncovered lines
When auto_discover_children is called for that typed resource for uncovered lines null
Then an empty list should be returned from auto_discover for uncovered lines
Scenario: auto_discover_children returns empty when auto_discover is disabled
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with type that has auto_discover disabled for uncovered lines
When auto_discover_children is called for that typed resource for uncovered lines disabled
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children rules empty (lines 2535-2537)
# -------------------------------------------------------------------
Scenario: auto_discover_children returns empty when rules list is empty
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with type that has auto_discover enabled but empty rules for uncovered lines
When auto_discover_children is called for that typed resource for uncovered lines empty rules
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children child type not in DB (line 2560)
# -------------------------------------------------------------------
Scenario: auto_discover_children skips rule when child type not in DB
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with auto_discover rules referencing non-existent child type for uncovered lines
When auto_discover_children is called for that resource with missing child type for uncovered lines
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children child type not in allowed list (line 2564)
# -------------------------------------------------------------------
Scenario: auto_discover_children skips rule when child type not in allowed list
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with auto_discover rules where child type is not in allowed list for uncovered lines
When auto_discover_children is called for that resource with disallowed child type for uncovered lines
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children successful discovery (lines 2567-2616)
# -------------------------------------------------------------------
Scenario: auto_discover_children creates child resources and links
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with auto_discover rules that match an existing child type for uncovered lines
When auto_discover_children is called for that resource for full discovery for uncovered lines
Then the discovered children list should not be empty for uncovered lines
And the child resource should be linked to the parent for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children OperationalError (lines 2619-2624)
# -------------------------------------------------------------------
Scenario: auto_discover_children wraps OperationalError in DatabaseError
Given a resource repository with session raising OperationalError on auto_discover for uncovered lines
When auto_discover_children is called and an OperationalError occurs for uncovered lines
Then a DatabaseError should be raised mentioning auto-discover for uncovered lines
# -------------------------------------------------------------------
# _get_ancestors current in visited branch (line 2641)
# -------------------------------------------------------------------
Scenario: _get_ancestors handles cycles in visited set
Given a resource repository backed by an in-memory database for uncovered lines
And resources forming a diamond graph for ancestor traversal for uncovered lines
When _get_ancestors is called on the bottom resource for uncovered lines
Then all ancestor IDs should be returned including the bottom resource for uncovered lines
# -------------------------------------------------------------------
# ResourceRepository._to_domain sandbox_strategy not None (line 2707)
# -------------------------------------------------------------------
Scenario: ResourceRepository _to_domain converts sandbox_strategy when present
Given a resource repository backed by an in-memory database for uncovered lines
And a resource row with sandbox_strategy set to "git_worktree" for uncovered lines
When the resource is retrieved by ID for uncovered lines sandbox
Then the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE
# -------------------------------------------------------------------
# ToolRepository.add invalid tool_type (line 3174)
# -------------------------------------------------------------------
Scenario: ToolRepository add raises InvalidToolTypeError for bad tool_type
Given a tool repository backed by an in-memory database for uncovered lines
And a tool domain object with tool_type "bogus" for uncovered lines
When the tool is added to the repository for uncovered lines invalid type
Then an InvalidToolTypeError should be raised for uncovered lines
# -------------------------------------------------------------------
# ToolRepository.add schema serialization + resource slots (lines 3196-3261)
# -------------------------------------------------------------------
Scenario: ToolRepository add persists input/output schemas and resource slots
Given a tool repository backed by an in-memory database for uncovered lines
And a tool domain object with schemas and resource slots for uncovered lines
When the tool is added to the repository for uncovered lines with schemas
Then the tool should be retrievable and have schemas and bindings for uncovered lines
# -------------------------------------------------------------------
# ToolRepository.get fetch by ULID (lines 3286-3293)
# -------------------------------------------------------------------
Scenario: ToolRepository get returns tool by ULID
Given a tool repository backed by an in-memory database for uncovered lines
And a tool has been persisted and its ULID captured for uncovered lines
When the tool is fetched by ULID for uncovered lines
Then the tool domain object should be returned for uncovered lines
Scenario: ToolRepository get returns None for unknown ULID
Given a tool repository backed by an in-memory database for uncovered lines
When a tool is fetched by ULID "00000000000000000000000000" for uncovered lines
Then None should be returned for the tool get for uncovered lines
# -------------------------------------------------------------------
# ToolRepository._to_domain input/output schema JSON parse (lines 3433, 3438)
# -------------------------------------------------------------------
Scenario: ToolRepository _to_domain parses input and output schemas
Given a tool repository backed by an in-memory database for uncovered lines
And a tool with input_schema and output_schema stored as JSON for uncovered lines
When the tool is retrieved by name for uncovered lines schemas
Then the tool should have parsed input_schema and output_schema for uncovered lines
# -------------------------------------------------------------------
# ValidationAttachment.attach duplicate detection (lines 3517-3520)
# -------------------------------------------------------------------
Scenario: ValidationAttachment attach raises DuplicateValidationAttachmentError on duplicate
Given a validation attachment repository backed by an in-memory database for uncovered lines
And a validation "val-check" is already attached to resource "res-1" for uncovered lines
When the same validation "val-check" is attached again to resource "res-1" for uncovered lines
Then a DuplicateValidationAttachmentError should be raised for uncovered lines attach
# -------------------------------------------------------------------
# ValidationAttachment.attach re-raise (line 3538)
# -------------------------------------------------------------------
Scenario: ValidationAttachment attach re-raises DuplicateValidationAttachmentError
Given a validation attachment repository backed by an in-memory database for uncovered lines
And a validation "val-dup" is already attached to resource "res-2" for uncovered lines
When the same validation "val-dup" is attached again to resource "res-2" for uncovered lines re-raise
Then the DuplicateValidationAttachmentError should propagate for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.get_by_name row is None (line 3663)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository get_by_name returns None when not found
Given an automation profile repository backed by an in-memory database for uncovered lines
When a profile is fetched by name "nonexistent-profile" for uncovered lines
Then None should be returned for the profile get for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.get_by_name _to_domain (line 3665)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository get_by_name returns domain object
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "test-profile" has been upserted for uncovered lines
When a profile is fetched by name "test-profile" for uncovered lines
Then the profile domain object should be returned for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.list_all _to_domain mapping (line 3686)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository list_all returns domain objects
Given an automation profile repository backed by an in-memory database for uncovered lines
And profiles "prof-a" and "prof-b" have been upserted for uncovered lines
When all profiles are listed for uncovered lines
Then two profile domain objects should be returned for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.upsert existing row update (lines 3722-3732)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository upsert updates existing profile
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "update-prof" has been upserted for uncovered lines
When the profile "update-prof" is upserted again with changed description for uncovered lines
Then the profile should have the updated description for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.upsert schema version mismatch (lines 3723-3731)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository upsert raises on schema version mismatch
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "versioned-prof" has been upserted with schema_version "1.0" for uncovered lines
When the profile "versioned-prof" is upserted with expected_schema_version "2.0" for uncovered lines
Then an AutomationProfileSchemaVersionError should be raised for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.upsert new row insert (lines 3733-3735)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository upsert inserts new profile
Given an automation profile repository backed by an in-memory database for uncovered lines
When a new profile "brand-new" is upserted for uncovered lines
Then the profile "brand-new" should be retrievable for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.upsert IntegrityError (lines 3739-3743)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository upsert wraps IntegrityError in DuplicateAutomationProfileError
Given an automation profile repository with session raising IntegrityError on flush for uncovered lines
When a profile is upserted and IntegrityError occurs for uncovered lines
Then a DuplicateAutomationProfileError should be raised for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.delete row not found (lines 3766-3767)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository delete raises NotFoundError when missing
Given an automation profile repository backed by an in-memory database for uncovered lines
When a profile "ghost" is deleted for uncovered lines
Then an AutomationProfileNotFoundError should be raised for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.delete success path (lines 3768-3769)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository delete removes existing profile
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "delete-me" has been upserted for uncovered lines
When the profile "delete-me" is deleted for uncovered lines
Then the profile "delete-me" should no longer exist for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository._to_domain full field coverage (lines 3784-3806)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository _to_domain maps all fields correctly
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "full-fields" with all fields set has been upserted for uncovered lines
When a profile is fetched by name "full-fields" for uncovered lines
Then all profile fields should match the original values for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository._from_domain + _update_row (lines 3808-3855)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository _from_domain and _update_row cover all fields
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "roundtrip" with specific field values has been upserted for uncovered lines
When the profile "roundtrip" is upserted again with different field values for uncovered lines
Then the profile "roundtrip" should have the new field values for uncovered lines
@@ -0,0 +1,67 @@
Feature: Resource CLI coverage boost for remaining uncovered lines
As a developer
I want edge-case paths in resource.py thoroughly tested
So that the line-rate and branch-rate improve beyond 0.93 / 0.88
# ---- _get_registry_service (lines 79-81) ----
Scenario: _get_registry_service delegates to the DI container
Given the DI container is mocked for resource registry
When _get_registry_service is called directly
Then the returned service should be the mock registry service
# ---- type_add --update re-raises ValidationError (line 181) ----
Scenario: type_add --update re-raises ValidationError whose message lacks "already exists"
Given a mock resource service that raises a non-already-exists ValidationError on register_type
When I invoke type add with update flag via CliRunner
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Validation error"
# ---- type_add FileNotFoundError (lines 193-195) ----
Scenario: type_add catches FileNotFoundError from service
Given a mock resource service that raises FileNotFoundError on register_type
When I invoke type add with a dummy config via CliRunner
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Config file not found"
# ---- type_remove confirm declined (lines 234-236) ----
Scenario: type_remove aborts when user declines confirmation prompt
Given a mock resource service with a removable custom type
When I invoke type remove without --yes and answer no via CliRunner
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Aborted"
# ---- type_remove row is None (lines 260-262) ----
Scenario: type_remove aborts when DB row is None after query
Given a mock resource service whose session returns no row for the type
When I invoke type remove with --yes via CliRunner for the phantom type
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Resource type not found"
# ---- type_remove generic exception triggers rollback (lines 267-269) ----
Scenario: type_remove rolls back session on unexpected exception
Given a mock resource service whose session delete raises a generic exception
When I invoke type remove with --yes via CliRunner for the failing type
Then the CliRunner exit code should be non-zero
And the mock session rollback should have been called for type remove
# ---- resource_remove edge_count > 0 (lines 653-658) ----
Scenario: resource_remove aborts when resource has edges
Given a mock resource service whose session reports edges on the resource
When I invoke resource remove with --yes via CliRunner for the edged resource
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "edge(s) still reference it"
# ---- resource_remove generic Exception rollback (lines 668-672) ----
Scenario: resource_remove rolls back session on unexpected exception
Given a mock resource service whose session delete raises a generic exception for resource
When I invoke resource remove with --yes via CliRunner for the failing resource
Then the CliRunner exit code should be non-zero
And the mock session rollback should have been called for resource remove
@@ -0,0 +1,59 @@
Feature: Resource registry service coverage gaps
Cover exception-handling rollback paths and auto_discovery/equivalence
serialisation round-trips in ResourceRegistryService, _spec_to_db, and
_db_to_spec.
# Exception rollback paths
Scenario: bootstrap_builtin_types rolls back on unexpected exception
Given a resource registry service with a faulty session for bootstrap
When I call bootstrap_builtin_types and it fails
Then the faulty session should have been rolled back
And the original exception should propagate from bootstrap
Scenario: register_type rolls back on non-ValidationError exception
Given a resource registry service with a faulty session for register_type
And a temporary valid resource type YAML file exists
When I call register_type and a non-validation exception occurs
Then the register_type faulty session should have been rolled back
And the original non-validation exception should propagate from register_type
Scenario: register_resource rolls back on generic exception
Given a resource registry service with a faulty session for register_resource
When I call register_resource and a generic exception occurs
Then the register_resource faulty session should have been rolled back
And the original generic exception should propagate from register_resource
# ── _spec_to_db auto_discovery and equivalence ────────────────────
Scenario: _spec_to_db serialises auto_discovery to JSON
Given a ResourceTypeSpec with auto_discovery set
When I convert the spec to a database model via _spec_to_db
Then the database model auto_discover_json should contain the auto_discovery data
Scenario: _spec_to_db serialises equivalence to JSON
Given a ResourceTypeSpec with equivalence set
When I convert the equivalence spec to a database model via _spec_to_db
Then the database model equivalence_json should contain the equivalence data
# ── _db_to_spec auto_discovery and equivalence ────────────────────
Scenario: _db_to_spec parses auto_discover_json from a database row
Given an in-memory database with a resource type row containing auto_discover_json
When I convert the database row back to a spec via _db_to_spec
Then the resulting spec auto_discovery should match the original data
Scenario: _db_to_spec parses equivalence_json from a database row
Given an in-memory database with a resource type row containing equivalence_json
When I convert the equivalence database row back to a spec via _db_to_spec
Then the resulting spec equivalence should match the original equivalence data
# ── Round-trip through real DB ────────────────────────────────────
Scenario: auto_discovery and equivalence survive a full register-and-show round-trip
Given a real in-memory resource registry service is initialised
And a YAML config with auto_discovery and equivalence fields exists
When I register the type with auto_discovery and equivalence via the service
And I show the registered type with auto_discovery and equivalence
Then the shown type should have the correct auto_discovery
And the shown type should have the correct equivalence
@@ -0,0 +1,270 @@
"""Step definitions for AutomationProfileService CRUD coverage tests."""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.automation_profile_service import (
AutomationProfileService,
)
from cleveragents.core.exceptions import (
NotFoundError,
ValidationError,
)
from cleveragents.domain.models.core.automation_profile import AutomationProfile
# -------------------------------------------------------------------
# Mock repository
# -------------------------------------------------------------------
class MockAutomationProfileRepository:
"""In-memory mock that implements the repository interface."""
def __init__(self) -> None:
self._store: dict[str, AutomationProfile] = {}
def get_by_name(self, name: str) -> AutomationProfile | None:
return self._store.get(name)
def list_all(self) -> list[AutomationProfile]:
return list(self._store.values())
def upsert(self, profile: AutomationProfile) -> None:
self._store[profile.name] = profile
def delete(self, name: str) -> None:
self._store.pop(name, None)
# -------------------------------------------------------------------
# Given steps
# -------------------------------------------------------------------
@given("a profile service with a mock repository")
def step_given_service_with_mock_repo(context: Context) -> None:
"""Create a service backed by an in-memory mock repository."""
context.mock_repo = MockAutomationProfileRepository()
context.profile_service = AutomationProfileService(
repo=context.mock_repo,
global_default="manual",
)
@given('the mock repository contains a profile named "{name}"')
def step_given_repo_contains_profile(context: Context, name: str) -> None:
"""Seed the mock repository with a custom profile."""
profile = AutomationProfile(
name=name,
description=f"Custom profile {name}",
)
context.mock_repo.upsert(profile)
@given("a profile service without a repository")
def step_given_service_without_repo(context: Context) -> None:
"""Create a service with no repository (repo=None)."""
context.profile_service = AutomationProfileService(
repo=None,
global_default="manual",
)
# -------------------------------------------------------------------
# When steps: get_profile from repo
# -------------------------------------------------------------------
@when('I fetch profile "{name}"')
def step_when_fetch_profile(context: Context, name: str) -> None:
"""Fetch a profile by name (may come from repo)."""
context.fetched_profile = context.profile_service.get_profile(name)
# -------------------------------------------------------------------
# When steps: list_profiles with repo
# -------------------------------------------------------------------
@when("I list all available profiles")
def step_when_list_available_profiles(context: Context) -> None:
"""List all profiles including custom ones from the repo."""
context.available_profiles = context.profile_service.list_profiles()
# -------------------------------------------------------------------
# When steps: create_profile
# -------------------------------------------------------------------
@when('I create a profile with name "{name}" and description "{description}"')
def step_when_create_profile(context: Context, name: str, description: str) -> None:
"""Create a custom profile with the given config."""
config = {"name": name, "description": description}
context.created_profile = context.profile_service.create_profile(config)
@when('I try to create a profile with builtin name "{name}"')
def step_when_try_create_builtin(context: Context, name: str) -> None:
"""Attempt to create a profile with a built-in name."""
context.crud_validation_error = None
try:
context.profile_service.create_profile({"name": name})
except ValidationError as exc:
context.crud_validation_error = exc
# -------------------------------------------------------------------
# When steps: update_profile
# -------------------------------------------------------------------
@when('I update profile "{name}" with description "{description}"')
def step_when_update_profile(context: Context, name: str, description: str) -> None:
"""Update an existing custom profile."""
config = {"description": description}
context.updated_profile = context.profile_service.update_profile(name, config)
@when('I try to update builtin profile "{name}"')
def step_when_try_update_builtin(context: Context, name: str) -> None:
"""Attempt to update a built-in profile."""
context.crud_validation_error = None
try:
context.profile_service.update_profile(name, {"description": "changed"})
except ValidationError as exc:
context.crud_validation_error = exc
@when('I try to update profile "{name}" without repo')
def step_when_try_update_no_repo(context: Context, name: str) -> None:
"""Attempt to update a profile when no repository is configured."""
context.crud_not_found_error = None
try:
context.profile_service.update_profile(name, {"description": "changed"})
except NotFoundError as exc:
context.crud_not_found_error = exc
# -------------------------------------------------------------------
# When steps: delete_profile
# -------------------------------------------------------------------
@when('I delete profile "{name}"')
def step_when_delete_profile(context: Context, name: str) -> None:
"""Delete a custom profile."""
context.profile_service.delete_profile(name)
@when('I try to delete builtin profile "{name}"')
def step_when_try_delete_builtin(context: Context, name: str) -> None:
"""Attempt to delete a built-in profile."""
context.crud_validation_error = None
try:
context.profile_service.delete_profile(name)
except ValidationError as exc:
context.crud_validation_error = exc
@when('I try to delete profile "{name}" without repo')
def step_when_try_delete_no_repo(context: Context, name: str) -> None:
"""Attempt to delete a profile when no repository is configured."""
context.crud_not_found_error = None
try:
context.profile_service.delete_profile(name)
except NotFoundError as exc:
context.crud_not_found_error = exc
# -------------------------------------------------------------------
# Then steps
# -------------------------------------------------------------------
@then('the fetched profile name should be "{expected}"')
def step_then_fetched_name(context: Context, expected: str) -> None:
"""Verify the fetched profile has the expected name."""
actual = context.fetched_profile.name
assert actual == expected, f"Expected '{expected}', got '{actual}'"
@then('the available profiles should include "{name}"')
def step_then_available_includes(context: Context, name: str) -> None:
"""Verify the available profiles list contains a given name."""
names = [p.name for p in context.available_profiles]
assert name in names, f"Expected '{name}' in list, got: {names}"
@then('the created profile name should be "{expected}"')
def step_then_created_name(context: Context, expected: str) -> None:
"""Verify the created profile has the expected name."""
actual = context.created_profile.name
assert actual == expected, f"Expected '{expected}', got '{actual}'"
@then('the created profile description should be "{expected}"')
def step_then_created_description(context: Context, expected: str) -> None:
"""Verify the created profile has the expected description."""
actual = context.created_profile.description
assert actual == expected, f"Expected '{expected}', got '{actual}'"
@then('the mock repository should contain "{name}"')
def step_then_repo_contains(context: Context, name: str) -> None:
"""Verify the mock repository has the profile stored."""
profile = context.mock_repo.get_by_name(name)
assert profile is not None, f"Expected '{name}' in repo, but not found"
@then('the mock repository should not contain "{name}"')
def step_then_repo_not_contains(context: Context, name: str) -> None:
"""Verify the mock repository does not have the profile."""
profile = context.mock_repo.get_by_name(name)
assert profile is None, f"Expected '{name}' not in repo, but found it"
@then('the updated profile name should be "{expected}"')
def step_then_updated_name(context: Context, expected: str) -> None:
"""Verify the updated profile has the expected name."""
actual = context.updated_profile.name
assert actual == expected, f"Expected '{expected}', got '{actual}'"
@then('the updated profile description should be "{expected}"')
def step_then_updated_description(context: Context, expected: str) -> None:
"""Verify the updated profile has the expected description."""
actual = context.updated_profile.description
assert actual == expected, f"Expected '{expected}', got '{actual}'"
@then("a crud validation error should be raised")
def step_then_crud_validation_error(context: Context) -> None:
"""Verify a ValidationError was raised."""
assert context.crud_validation_error is not None, (
"Expected ValidationError but none was raised"
)
@then('the crud validation error message should mention "{text}"')
def step_then_crud_validation_error_mentions(context: Context, text: str) -> None:
"""Check the ValidationError message contains the expected text."""
err = str(context.crud_validation_error)
assert text in err, f"Expected '{text}' in error, got: {err}"
@then("a crud not found error should be raised")
def step_then_crud_not_found_error(context: Context) -> None:
"""Verify a NotFoundError was raised."""
assert context.crud_not_found_error is not None, (
"Expected NotFoundError but none was raised"
)
@then('the crud not found error message should mention "{text}"')
def step_then_crud_not_found_error_mentions(context: Context, text: str) -> None:
"""Check the NotFoundError message contains the expected text."""
err = str(context.crud_not_found_error)
assert text in err, f"Expected '{text}' in error, got: {err}"
@@ -0,0 +1,263 @@
"""Step definitions for binding_resolution_coverage.feature.
Covers the uncovered lines in binding_resolution_service.py:
- Static binding with unknown resource (lines 147-148)
- Parameter binding with unknown ref (lines 198-201)
- Parameter binding with valid ref (lines 209, 214)
- Contextual binding, slot not required, no match (line 274)
- _find_matching_resources skips NotFoundError (lines 298-299)
- _disambiguate via resource name suffix (lines 325-329)
- _disambiguate ambiguous raises ValidationError (lines 337-338)
- _is_type_compatible returns False on NotFoundError (lines 369-370)
All Background / shared steps are inherited from binding_resolution_steps.py.
Only NEW step patterns are defined here to avoid duplication.
"""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.project import (
LinkedResource,
NamespacedProject,
)
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
)
from cleveragents.domain.models.core.tool import (
BindingMode,
ResourceAccessMode,
ResourceSlot,
Tool,
ToolSource,
ToolType,
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _make_resource(
resource_id: str,
type_name: str,
name: str | None = None,
) -> Resource:
return Resource(
resource_id=resource_id,
name=name,
resource_type_name=type_name,
classification=PhysVirt.PHYSICAL,
description=f"Test resource {resource_id}",
)
# ------------------------------------------------------------------
# Given: named resource in registry (with both id and name keys)
# ------------------------------------------------------------------
@given(
'a named resource "{rid}" called "{res_name}" of type "{type_name}" in the registry'
)
def step_named_resource_in_registry(
context: Context,
rid: str,
res_name: str,
type_name: str,
) -> None:
resource = _make_resource(rid, type_name, name=res_name)
context.mock_registry._resources[res_name] = resource
context.mock_registry._resources[rid] = resource
# ------------------------------------------------------------------
# Given: optional contextual slot
# ------------------------------------------------------------------
@given(
'a tool "{tool_name}" with an optional contextual slot "{slot}" of type "{rtype}"'
)
def step_tool_optional_contextual(
context: Context,
tool_name: str,
slot: str,
rtype: str,
) -> None:
context.tool = Tool(
name=tool_name,
description=f"Tool {tool_name}",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
resource_slots=[
ResourceSlot(
name=slot,
resource_type=rtype,
access=ResourceAccessMode.READ_WRITE,
binding=BindingMode.CONTEXTUAL,
required=False,
),
],
)
# ------------------------------------------------------------------
# Given: project with linked resources including missing ones
# ------------------------------------------------------------------
@given('a project "{name}" with linked resources including a missing one:')
def step_project_with_missing_resource(
context: Context,
name: str,
) -> None:
parts = name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
proj_name = parts[1] if len(parts) == 2 else parts[0]
links: list[LinkedResource] = []
for row in context.table:
rid = row["resource_id"]
tname = row["type_name"]
alias = row["alias"] if row["alias"] else None
exists = row["exists"].lower() == "true"
if exists:
resource = _make_resource(rid, tname, name=None)
context.mock_registry._resources[rid] = resource
# Always create the link, even for non-existent resources
links.append(
LinkedResource(
resource_id=rid,
alias=alias,
)
)
context.project = NamespacedProject(
name=proj_name,
namespace=namespace,
linked_resources=links,
)
# ------------------------------------------------------------------
# Given: project with named linked resources (resources have names)
# ------------------------------------------------------------------
@given('a project "{name}" with named linked resources:')
def step_project_named_resources(
context: Context,
name: str,
) -> None:
parts = name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
proj_name = parts[1] if len(parts) == 2 else parts[0]
links: list[LinkedResource] = []
for row in context.table:
rid = row["resource_id"]
tname = row["type_name"]
alias = row["alias"] if row["alias"] else None
res_name = row["resource_name"] if row["resource_name"] else None
resource = _make_resource(rid, tname, name=res_name)
context.mock_registry._resources[rid] = resource
links.append(
LinkedResource(
resource_id=rid,
alias=alias,
)
)
context.project = NamespacedProject(
name=proj_name,
namespace=namespace,
linked_resources=links,
)
# ------------------------------------------------------------------
# Given: remove resource type from registry
# ------------------------------------------------------------------
@given('the resource type "{tname}" is removed from the registry')
def step_remove_resource_type(context: Context, tname: str) -> None:
context.mock_registry._types.pop(tname, None)
# ------------------------------------------------------------------
# Given: mark slot as required (for post-hoc override)
# ------------------------------------------------------------------
@given('the slot "{slot_name}" is required')
def step_slot_is_required(context: Context, slot_name: str) -> None:
# The tool already has required=True by default; this is a
# documentation step that confirms the slot is required.
for slot in context.tool.resource_slots:
if slot.name == slot_name:
assert slot.required, f"Expected slot '{slot_name}' to be required"
return
raise AssertionError(f"No slot named '{slot_name}' on tool")
# ------------------------------------------------------------------
# When: resolve with invocation params
# ------------------------------------------------------------------
@when("the bindings are resolved with invocation params:")
def step_resolve_with_params(context: Context) -> None:
params: dict[str, str] = {}
for row in context.table:
params[row["slot"]] = row["ref"]
try:
context.binding_results = context.binding_service.resolve(
context.tool,
context.project,
invocation_params=params,
)
context.binding_error = None
except ValidationError as exc:
context.binding_error = exc
context.binding_results = None
# ------------------------------------------------------------------
# Then: check resource_name on binding result
# ------------------------------------------------------------------
@then('the binding for slot "{slot}" should have resource_name "{rname}"')
def step_check_resource_name(
context: Context,
slot: str,
rname: str,
) -> None:
result = _find_result(context, slot)
assert result.resource_name == rname, (
f"Expected resource_name '{rname}', got '{result.resource_name}'"
)
# ------------------------------------------------------------------
# Internal helper
# ------------------------------------------------------------------
def _find_result(context: Context, slot_name: str):
"""Find a binding result by slot name."""
for result in context.binding_results:
if result.slot_name == slot_name:
return result
raise AssertionError(f"No binding result for slot '{slot_name}'")
@@ -0,0 +1,373 @@
"""Step definitions for project_cli_coverage_boost.feature.
Exercises uncovered error-handling branches in
``cleveragents.cli.commands.project`` using ``typer.testing.CliRunner``
with ``unittest.mock.patch`` to mock DI container helpers.
Targets lines: 81, 84, 89, 91-92, 565-566, 574-575, 643-645,
722, 725-726, 730-732, 772-774, 908-910, 914-916, 919-920.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.project import app
from cleveragents.core.exceptions import DatabaseError, NotFoundError
# mix_stderr defaults to True in typer CliRunner, which merges stderr into
# result.output so we can assert on err_console output.
runner = CliRunner()
# Patch targets - these replace the module-level helper functions
_PATCH_PROJECT_REPO = "cleveragents.cli.commands.project._get_namespaced_project_repo"
_PATCH_LINK_REPO = "cleveragents.cli.commands.project._get_resource_link_repo"
_PATCH_REGISTRY_SVC = "cleveragents.cli.commands.project._get_resource_registry_service"
_PATCH_STORE_EXTRAS = "cleveragents.cli.commands.project._store_project_extras"
# For patching get_container inside the lazy-import helpers
_PATCH_CONTAINER = "cleveragents.application.container.get_container"
# ---------------------------------------------------------------------------
# Mock factory helpers
# ---------------------------------------------------------------------------
def _make_mock_project(
namespaced_name: str = "local/test-proj",
namespace: str = "local",
name: str = "test-proj",
description: str | None = "A test project",
linked_resources: list | None = None,
) -> MagicMock:
"""Create a mock project with the required attributes."""
proj = MagicMock()
proj.namespaced_name = namespaced_name
proj.namespace = namespace
proj.name = name
proj.description = description
proj.linked_resources = linked_resources or []
proj.created_at = datetime(2025, 1, 1, tzinfo=UTC)
proj.updated_at = datetime(2025, 1, 2, tzinfo=UTC)
return proj
def _make_mock_resource(
resource_id: str = "res-001", name: str = "local/some-res"
) -> MagicMock:
"""Create a mock resource with required attributes."""
res = MagicMock()
res.resource_id = resource_id
res.name = name
return res
def _make_mock_link(
link_id: str = "link-001", resource_id: str = "res-001"
) -> MagicMock:
"""Create a mock link with required attributes."""
link = MagicMock()
link.link_id = link_id
link.resource_id = resource_id
link.project_read_only = False
link.alias = None
link.linked_at = datetime(2025, 1, 3, tzinfo=UTC)
return link
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the project CLI coverage mocks are prepared")
def step_prepare_coverage_mocks(context: Any) -> None:
"""Initialize mock holders on context for each scenario."""
context.cov_project_repo = MagicMock()
context.cov_link_repo = MagicMock()
context.cov_registry_svc = MagicMock()
context.cov_result = None
# ---------------------------------------------------------------------------
# Helper function coverage steps (lines 79-84, 89, 91-92)
#
# These call the actual _get_resource_link_repo / _get_resource_registry_service
# functions which do a lazy ``from cleveragents.application.container import
# get_container`` inside their body, so we patch get_container at its source.
# ---------------------------------------------------------------------------
@when("I call the real _get_resource_link_repo helper")
def step_call_real_get_link_repo(context: Any) -> None:
"""Call _get_resource_link_repo with the container patched."""
mock_container = MagicMock()
mock_container.project_resource_link_repo.return_value = MagicMock()
with patch(_PATCH_CONTAINER, return_value=mock_container):
from cleveragents.cli.commands.project import _get_resource_link_repo
context.cov_helper_result = _get_resource_link_repo()
@then("the coverage link repo helper should return successfully")
def step_check_link_repo_result(context: Any) -> None:
assert context.cov_helper_result is not None
@when("I call the real _get_resource_registry_service helper")
def step_call_real_get_registry_service(context: Any) -> None:
"""Call _get_resource_registry_service with the container patched."""
mock_container = MagicMock()
mock_container.resource_registry_service.return_value = MagicMock()
with patch(_PATCH_CONTAINER, return_value=mock_container):
from cleveragents.cli.commands.project import _get_resource_registry_service
context.cov_helper_result = _get_resource_registry_service()
@then("the coverage registry service helper should return successfully")
def step_check_registry_service_result(context: Any) -> None:
assert context.cov_helper_result is not None
# ---------------------------------------------------------------------------
# create command: resource linking raises NotFoundError (lines 565-566)
# ---------------------------------------------------------------------------
@given("the project repo mock is configured for successful create")
def step_project_repo_successful_create(context: Any) -> None:
"""Configure project repo so create succeeds and get returns a project."""
mock_proj = _make_mock_project()
context.cov_project_repo.create.return_value = None
context.cov_project_repo.get.return_value = mock_proj
@given("the link repo mock raises NotFoundError on create_link")
def step_link_repo_raises_not_found(context: Any) -> None:
context.cov_registry_svc.show_resource.side_effect = NotFoundError(
"Resource not found"
)
@when('I invoke the CLI create command with name "{name}" and resource "{res}"')
def step_invoke_create_with_resource(context: Any, name: str, res: str) -> None:
with (
patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo),
patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo),
patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc),
patch(_PATCH_STORE_EXTRAS),
):
context.cov_result = runner.invoke(app, ["create", name, "--resource", res])
# ---------------------------------------------------------------------------
# create command: resource linking raises DatabaseError (lines 565-566)
# ---------------------------------------------------------------------------
@given("the link repo mock raises DatabaseError on create_link")
def step_link_repo_raises_db_error_on_create_link(context: Any) -> None:
mock_res = _make_mock_resource()
context.cov_registry_svc.show_resource.return_value = mock_res
context.cov_link_repo.create_link.side_effect = DatabaseError("DB link failure")
# ---------------------------------------------------------------------------
# create command: re-fetch raises generic Exception (lines 574-575)
# ---------------------------------------------------------------------------
@given("the project repo mock is configured for create but get raises Exception")
def step_project_repo_create_ok_get_fails(context: Any) -> None:
context.cov_project_repo.create.return_value = None
context.cov_project_repo.get.side_effect = Exception("re-fetch failed")
@when('I invoke the CLI create command with name "{name}" without resources')
def step_invoke_create_no_resources(context: Any, name: str) -> None:
with (
patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo),
patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo),
patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc),
patch(_PATCH_STORE_EXTRAS),
):
context.cov_result = runner.invoke(app, ["create", name])
# ---------------------------------------------------------------------------
# link-resource command: create_link raises DatabaseError (lines 643-645)
# ---------------------------------------------------------------------------
@given("the project repo mock returns a valid project for get")
def step_project_repo_get_returns_project(context: Any) -> None:
mock_proj = _make_mock_project()
context.cov_project_repo.get.return_value = mock_proj
@given("the registry mock returns a valid resource")
def step_registry_returns_valid_resource(context: Any) -> None:
mock_res = _make_mock_resource()
context.cov_registry_svc.show_resource.return_value = mock_res
@given("the link repo mock raises DatabaseError on create_link for link-resource")
def step_link_repo_raises_db_error_for_link_cmd(context: Any) -> None:
context.cov_link_repo.create_link.side_effect = DatabaseError(
"DB create_link failure"
)
@when('I invoke the CLI link-resource command for project "{proj}" resource "{res}"')
def step_invoke_link_resource_cmd(context: Any, proj: str, res: str) -> None:
with (
patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo),
patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo),
patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc),
):
context.cov_result = runner.invoke(app, ["link-resource", proj, res])
# ---------------------------------------------------------------------------
# unlink-resource command: user declines (lines 722, 725-726)
# ---------------------------------------------------------------------------
@given("the link repo mock returns a matching link for unlink")
def step_link_repo_returns_matching_link(context: Any) -> None:
mock_link = _make_mock_link(resource_id="res-001")
context.cov_link_repo.list_links.return_value = [mock_link]
@when("I invoke the CLI unlink-resource command without yes and user declines")
def step_invoke_unlink_user_declines(context: Any) -> None:
with (
patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo),
patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo),
patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc),
):
context.cov_result = runner.invoke(
app,
["unlink-resource", "local/test-proj", "local/some-res"],
input="n\n",
)
# ---------------------------------------------------------------------------
# unlink-resource command: remove_link raises DatabaseError (lines 730-732)
# ---------------------------------------------------------------------------
@given("the link repo mock raises DatabaseError on remove_link")
def step_link_repo_raises_db_error_on_remove(context: Any) -> None:
context.cov_link_repo.remove_link.side_effect = DatabaseError(
"DB remove_link failure"
)
@when("I invoke the CLI unlink-resource command with yes")
def step_invoke_unlink_with_yes(context: Any) -> None:
with (
patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo),
patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo),
patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc),
):
context.cov_result = runner.invoke(
app,
["unlink-resource", "local/test-proj", "local/some-res", "--yes"],
)
# ---------------------------------------------------------------------------
# list command: list_projects raises DatabaseError (lines 772-774)
# ---------------------------------------------------------------------------
@given("the project repo mock raises DatabaseError on list_projects")
def step_project_repo_raises_db_error_on_list(context: Any) -> None:
context.cov_project_repo.list_projects.side_effect = DatabaseError(
"DB list failure"
)
@when("I invoke the CLI list command")
def step_invoke_list_cmd(context: Any) -> None:
with patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo):
context.cov_result = runner.invoke(app, ["list"])
# ---------------------------------------------------------------------------
# delete command: user declines confirmation (lines 908-910)
# ---------------------------------------------------------------------------
@given("the project repo mock returns a project with no linked resources")
def step_project_repo_returns_no_linked(context: Any) -> None:
mock_proj = _make_mock_project(linked_resources=[])
context.cov_project_repo.get.return_value = mock_proj
@when("I invoke the CLI delete command without yes and user declines")
def step_invoke_delete_user_declines(context: Any) -> None:
with patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo):
context.cov_result = runner.invoke(
app,
["delete", "local/test-proj"],
input="n\n",
)
# ---------------------------------------------------------------------------
# delete command: repo.delete raises DatabaseError (lines 914-916)
# ---------------------------------------------------------------------------
@given("the project repo mock raises DatabaseError on delete")
def step_project_repo_raises_db_error_on_delete(context: Any) -> None:
context.cov_project_repo.delete.side_effect = DatabaseError("DB delete failure")
@when("I invoke the CLI delete command with yes")
def step_invoke_delete_with_yes(context: Any) -> None:
with patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo):
context.cov_result = runner.invoke(
app,
["delete", "local/test-proj", "--yes"],
)
# ---------------------------------------------------------------------------
# delete command: repo.delete returns False (lines 919-920)
# ---------------------------------------------------------------------------
@given("the project repo mock returns False on delete")
def step_project_repo_delete_returns_false(context: Any) -> None:
context.cov_project_repo.delete.return_value = False
# ---------------------------------------------------------------------------
# Common then assertions
# ---------------------------------------------------------------------------
@then('the coverage CLI result should contain "{text}"')
def step_cov_cli_result_contains(context: Any, text: str) -> None:
output = context.cov_result.output
assert text.lower() in output.lower(), (
f"Expected CLI output to contain '{text}', got:\n{output}"
)
@then("the coverage CLI exit code should be {code:d}")
def step_cov_cli_exit_code(context: Any, code: int) -> None:
actual = context.cov_result.exit_code
assert actual == code, (
f"Expected exit code {code}, got {actual}. Output:\n{context.cov_result.output}"
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
"""Step definitions for resource_cli_coverage_boost.feature.
Covers the remaining uncovered lines and partial branches in
``src/cleveragents/cli/commands/resource.py``:
- Lines 79-81: ``_get_registry_service()``
- Lines 194-195: ``type_add`` FileNotFoundError handler
- Lines 234-236: ``type_remove`` user declines confirmation
- Lines 260-262: ``type_remove`` DB row is None
- Lines 267-269: ``type_remove`` generic Exception rollback
- Lines 653-658: ``resource_remove`` edge_count > 0
- Lines 668-672: ``resource_remove`` generic Exception rollback
"""
from __future__ import annotations
import tempfile
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from typer.testing import CliRunner
from cleveragents.core.exceptions import ValidationError
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_PATCH_TARGET = "cleveragents.cli.commands.resource._get_registry_service"
_PATCH_CONTAINER = "cleveragents.cli.commands.resource.get_container"
def _make_mock_type_spec(*, built_in: bool = False) -> MagicMock:
"""Return a mock ResourceTypeSpec-like object."""
spec = MagicMock()
spec.name = "test/mock-type"
spec.built_in = built_in
spec.description = "mock type"
spec.resource_kind = "physical"
spec.sandbox_strategy = "copy_on_write"
spec.user_addable = True
spec.cli_args = []
spec.parent_types = []
spec.child_types = []
spec.handler = None
spec.capabilities = []
return spec
def _make_mock_resource() -> MagicMock:
"""Return a mock Resource domain object."""
res = MagicMock()
res.resource_id = "01HXYZ1234567890ABCDEFGHIJ"
res.name = "local/mock-res"
res.resource_type_name = "git-checkout"
res.classification = "physical"
res.description = "A mock resource"
res.location = "/tmp/mock"
res.properties = {"path": "/tmp/mock"}
res.created_at = "2025-01-01T00:00:00"
res.updated_at = "2025-01-01T00:00:00"
return res
def _smart_query_side_effect(model_map: dict[str, MagicMock]) -> Any:
"""Return a side_effect for session.query() that dispatches by model class name."""
def _side_effect(model_cls: Any) -> MagicMock:
name = getattr(model_cls, "__name__", None) or str(model_cls)
if name in model_map:
return model_map[name]
# Fallback: return a generic mock chain
m = MagicMock()
return m
return _side_effect
# ---------------------------------------------------------------------------
# Scenario: _get_registry_service delegates to the DI container
# ---------------------------------------------------------------------------
@given("the DI container is mocked for resource registry")
def step_mock_di_container(context: Context) -> None:
"""Prepare a mock container whose resource_registry_service returns a mock."""
context.rcb_mock_service = MagicMock()
context.rcb_mock_container = MagicMock()
context.rcb_mock_container.resource_registry_service.return_value = (
context.rcb_mock_service
)
@when("_get_registry_service is called directly")
def step_call_get_registry_service(context: Context) -> None:
"""Call the real _get_registry_service with a patched get_container."""
from cleveragents.cli.commands.resource import _get_registry_service
with patch(_PATCH_CONTAINER, return_value=context.rcb_mock_container):
context.rcb_returned_service = _get_registry_service()
@then("the returned service should be the mock registry service")
def step_verify_returned_service(context: Context) -> None:
assert context.rcb_returned_service is context.rcb_mock_service, (
"Expected _get_registry_service to return the container's service"
)
# ---------------------------------------------------------------------------
# Scenario: type_add --update re-raises non-"already exists" ValidationError
# ---------------------------------------------------------------------------
@given(
"a mock resource service that raises a non-already-exists ValidationError on register_type"
)
def step_mock_service_reraise_validation(context: Context) -> None:
svc = MagicMock()
svc.register_type.side_effect = ValidationError(
message="schema mismatch: bad field"
)
context.rcb_mock_service = svc
@when("I invoke type add with update flag via CliRunner")
def step_invoke_type_add_update(context: Context) -> None:
from cleveragents.cli.commands.resource import app
runner = CliRunner()
# Create a minimal temp YAML so the --config path exists on disk
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write("name: dummy\n")
tmp.flush()
config_path = tmp.name
with patch(_PATCH_TARGET, return_value=context.rcb_mock_service):
context.rcb_result = runner.invoke(
app,
["type", "add", "--config", config_path, "--update"],
)
# ---------------------------------------------------------------------------
# Scenario: type_add catches FileNotFoundError from service
# ---------------------------------------------------------------------------
@given("a mock resource service that raises FileNotFoundError on register_type")
def step_mock_service_fnf(context: Context) -> None:
svc = MagicMock()
svc.register_type.side_effect = FileNotFoundError("no such file: /fake.yaml")
context.rcb_mock_service = svc
@when("I invoke type add with a dummy config via CliRunner")
def step_invoke_type_add_fnf(context: Context) -> None:
from cleveragents.cli.commands.resource import app
runner = CliRunner()
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write("name: dummy\n")
tmp.flush()
config_path = tmp.name
with patch(_PATCH_TARGET, return_value=context.rcb_mock_service):
context.rcb_result = runner.invoke(
app,
["type", "add", "--config", config_path],
)
# ---------------------------------------------------------------------------
# Scenario: type_remove aborts when user declines confirmation prompt
# ---------------------------------------------------------------------------
@given("a mock resource service with a removable custom type")
def step_mock_service_removable_type(context: Context) -> None:
svc = MagicMock()
svc.show_type.return_value = _make_mock_type_spec(built_in=False)
context.rcb_mock_service = svc
@when("I invoke type remove without --yes and answer no via CliRunner")
def step_invoke_type_remove_no(context: Context) -> None:
from cleveragents.cli.commands.resource import app
runner = CliRunner()
with patch(_PATCH_TARGET, return_value=context.rcb_mock_service):
context.rcb_result = runner.invoke(
app,
["type", "remove", "test/mock-type"],
input="n\n",
)
# ---------------------------------------------------------------------------
# Scenario: type_remove aborts when DB row is None after query
# ---------------------------------------------------------------------------
@given("a mock resource service whose session returns no row for the type")
def step_mock_service_type_row_none(context: Context) -> None:
svc = MagicMock()
svc.show_type.return_value = _make_mock_type_spec(built_in=False)
# Build the mock session with smart query dispatch
mock_session = MagicMock()
# ResourceModel query → count returns 0 (no resources reference this type)
resource_model_query = MagicMock()
resource_model_query.filter.return_value.count.return_value = 0
# ResourceTypeModel query → first returns None (row not found)
type_model_query = MagicMock()
type_model_query.filter_by.return_value.first.return_value = None
mock_session.query.side_effect = _smart_query_side_effect(
{
"ResourceModel": resource_model_query,
"ResourceTypeModel": type_model_query,
}
)
svc._session.return_value = mock_session
context.rcb_mock_service = svc
@when("I invoke type remove with --yes via CliRunner for the phantom type")
def step_invoke_type_remove_row_none(context: Context) -> None:
from cleveragents.cli.commands.resource import app
runner = CliRunner()
with patch(_PATCH_TARGET, return_value=context.rcb_mock_service):
context.rcb_result = runner.invoke(
app,
["type", "remove", "test/mock-type", "--yes"],
)
# ---------------------------------------------------------------------------
# Scenario: type_remove rolls back session on unexpected exception
# ---------------------------------------------------------------------------
@given("a mock resource service whose session delete raises a generic exception")
def step_mock_service_type_delete_exception(context: Context) -> None:
svc = MagicMock()
svc.show_type.return_value = _make_mock_type_spec(built_in=False)
mock_session = MagicMock()
# ResourceModel query → count returns 0
resource_model_query = MagicMock()
resource_model_query.filter.return_value.count.return_value = 0
# ResourceTypeModel query → first returns a mock row
mock_row = MagicMock()
type_model_query = MagicMock()
type_model_query.filter_by.return_value.first.return_value = mock_row
mock_session.query.side_effect = _smart_query_side_effect(
{
"ResourceModel": resource_model_query,
"ResourceTypeModel": type_model_query,
}
)
# session.delete raises a generic exception
mock_session.delete.side_effect = RuntimeError("unexpected DB failure")
svc._session.return_value = mock_session
context.rcb_mock_service = svc
context.rcb_mock_session = mock_session
@when("I invoke type remove with --yes via CliRunner for the failing type")
def step_invoke_type_remove_exception(context: Context) -> None:
from cleveragents.cli.commands.resource import app
runner = CliRunner()
with patch(_PATCH_TARGET, return_value=context.rcb_mock_service):
context.rcb_result = runner.invoke(
app,
["type", "remove", "test/mock-type", "--yes"],
)
@then("the mock session rollback should have been called for type remove")
def step_verify_type_rollback(context: Context) -> None:
context.rcb_mock_session.rollback.assert_called()
# ---------------------------------------------------------------------------
# Scenario: resource_remove aborts when resource has edges
# ---------------------------------------------------------------------------
@given("a mock resource service whose session reports edges on the resource")
def step_mock_service_resource_edges(context: Context) -> None:
svc = MagicMock()
mock_res = _make_mock_resource()
svc.show_resource.return_value = mock_res
mock_session = MagicMock()
# ResourceEdgeModel query → count returns 3 (edges exist)
edge_query = MagicMock()
edge_query.filter.return_value.count.return_value = 3
mock_session.query.side_effect = _smart_query_side_effect(
{
"ResourceEdgeModel": edge_query,
}
)
svc._session.return_value = mock_session
context.rcb_mock_service = svc
@when("I invoke resource remove with --yes via CliRunner for the edged resource")
def step_invoke_resource_remove_edges(context: Context) -> None:
from cleveragents.cli.commands.resource import app
runner = CliRunner()
with patch(_PATCH_TARGET, return_value=context.rcb_mock_service):
context.rcb_result = runner.invoke(
app,
["remove", "local/mock-res", "--yes"],
)
# ---------------------------------------------------------------------------
# Scenario: resource_remove rolls back session on unexpected exception
# ---------------------------------------------------------------------------
@given(
"a mock resource service whose session delete raises a generic exception for resource"
)
def step_mock_service_resource_delete_exception(context: Context) -> None:
svc = MagicMock()
mock_res = _make_mock_resource()
svc.show_resource.return_value = mock_res
mock_session = MagicMock()
# ResourceEdgeModel query → count returns 0 (no edges)
edge_query = MagicMock()
edge_query.filter.return_value.count.return_value = 0
# ResourceModel query → first returns a mock row
mock_row = MagicMock()
resource_query = MagicMock()
resource_query.filter_by.return_value.first.return_value = mock_row
mock_session.query.side_effect = _smart_query_side_effect(
{
"ResourceEdgeModel": edge_query,
"ResourceModel": resource_query,
}
)
# session.delete raises a generic exception
mock_session.delete.side_effect = RuntimeError("unexpected DB write failure")
svc._session.return_value = mock_session
context.rcb_mock_service = svc
context.rcb_mock_session = mock_session
@when("I invoke resource remove with --yes via CliRunner for the failing resource")
def step_invoke_resource_remove_exception(context: Context) -> None:
from cleveragents.cli.commands.resource import app
runner = CliRunner()
with patch(_PATCH_TARGET, return_value=context.rcb_mock_service):
context.rcb_result = runner.invoke(
app,
["remove", "local/mock-res", "--yes"],
)
@then("the mock session rollback should have been called for resource remove")
def step_verify_resource_rollback(context: Context) -> None:
context.rcb_mock_session.rollback.assert_called()
# ---------------------------------------------------------------------------
# Shared assertion steps
# ---------------------------------------------------------------------------
@then("the CliRunner exit code should be non-zero")
def step_exit_code_nonzero(context: Context) -> None:
assert context.rcb_result.exit_code != 0, (
f"Expected non-zero exit code, got {context.rcb_result.exit_code}.\n"
f"Output: {context.rcb_result.output}"
)
@then('the CliRunner output should contain "{text}"')
def step_cli_output_contains(context: Context, text: str) -> None:
output = context.rcb_result.output
assert text.lower() in output.lower(), (
f"Expected '{text}' in CliRunner output, got:\n{output}"
)
@@ -0,0 +1,451 @@
"""Step definitions for resource_registry_service_coverage.feature.
Covers:
- bootstrap_builtin_types / register_type / register_resource exception rollback
- _spec_to_db serialisation of auto_discovery and equivalence
- _db_to_spec parsing of auto_discover_json and equivalence_json
- Full round-trip through a real in-memory database
"""
from __future__ import annotations
import json
import tempfile
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when # type: ignore[import-untyped]
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
_db_to_spec,
_spec_to_db,
)
from cleveragents.domain.models.core.resource_type import (
ResourceKind,
ResourceTypeSpec,
SandboxStrategy,
)
from cleveragents.infrastructure.database.models import (
Base,
ResourceTypeModel,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _in_memory_session_factory() -> tuple[Any, Any]:
"""Create an in-memory SQLite engine + unclosable session factory."""
engine = create_engine(
"sqlite:///:memory:",
echo=False,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
real_session = sessionmaker(
bind=engine,
expire_on_commit=False,
autoflush=True,
autocommit=False,
)()
class _Unclosable:
"""Wraps a real session but makes close() a no-op."""
def __init__(self, s: Session) -> None:
object.__setattr__(self, "_s", s)
def close(self) -> None:
pass
def __getattr__(self, name: str) -> Any:
return getattr(object.__getattribute__(self, "_s"), name)
def __setattr__(self, name: str, value: Any) -> None:
setattr(object.__getattribute__(self, "_s"), name, value)
wrapper = _Unclosable(real_session)
return engine, lambda: wrapper
# ---------------------------------------------------------------------------
# Scenario: bootstrap_builtin_types rolls back on unexpected exception
# ---------------------------------------------------------------------------
@given("a resource registry service with a faulty session for bootstrap")
def step_faulty_session_bootstrap(context: Any) -> None:
mock_session = MagicMock()
# Make query().filter_by().first() succeed the first time, then blow up
# on session.add() to trigger the generic Exception branch.
mock_session.query.return_value.filter_by.return_value.first.return_value = None
mock_session.add.side_effect = RuntimeError("unexpected DB error")
context.faulty_session = mock_session
context.cov_svc = ResourceRegistryService(
session_factory=lambda: mock_session,
)
@when("I call bootstrap_builtin_types and it fails")
def step_call_bootstrap_fails(context: Any) -> None:
context.bootstrap_exception = None
try:
context.cov_svc.bootstrap_builtin_types()
except RuntimeError as exc:
context.bootstrap_exception = exc
@then("the faulty session should have been rolled back")
def step_faulty_session_rolled_back(context: Any) -> None:
context.faulty_session.rollback.assert_called()
@then("the original exception should propagate from bootstrap")
def step_bootstrap_exception_propagates(context: Any) -> None:
assert context.bootstrap_exception is not None
assert "unexpected DB error" in str(context.bootstrap_exception)
# ---------------------------------------------------------------------------
# Scenario: register_type rolls back on non-ValidationError exception
# ---------------------------------------------------------------------------
@given("a resource registry service with a faulty session for register_type")
def step_faulty_session_register_type(context: Any) -> None:
mock_session = MagicMock()
# Make the duplicate-check query return None (no existing type)
mock_session.query.return_value.filter_by.return_value.first.return_value = None
# Blow up on session.add() with a non-ValidationError
mock_session.add.side_effect = RuntimeError("disk full")
context.regtype_faulty_session = mock_session
context.cov_regtype_svc = ResourceRegistryService(
session_factory=lambda: mock_session,
)
@given("a temporary valid resource type YAML file exists")
def step_create_temp_yaml(context: Any) -> None:
yaml_content = (
"name: testns/cov-type\n"
"description: Coverage test type\n"
"resource_kind: physical\n"
"sandbox_strategy: none\n"
"user_addable: true\n"
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write(yaml_content)
tmp.flush()
context.cov_yaml_path = tmp.name
@when("I call register_type and a non-validation exception occurs")
def step_call_register_type_fails(context: Any) -> None:
context.regtype_exception = None
try:
context.cov_regtype_svc.register_type(context.cov_yaml_path)
except RuntimeError as exc:
context.regtype_exception = exc
@then("the register_type faulty session should have been rolled back")
def step_regtype_session_rolled_back(context: Any) -> None:
context.regtype_faulty_session.rollback.assert_called()
@then("the original non-validation exception should propagate from register_type")
def step_regtype_exception_propagates(context: Any) -> None:
assert context.regtype_exception is not None
assert "disk full" in str(context.regtype_exception)
# ---------------------------------------------------------------------------
# Scenario: register_resource rolls back on generic exception
# ---------------------------------------------------------------------------
@given("a resource registry service with a faulty session for register_resource")
def step_faulty_session_register_resource(context: Any) -> None:
mock_session = MagicMock()
# type_row lookup succeeds (returns a mock row with resource_kind)
type_row = MagicMock()
type_row.resource_kind = "physical"
mock_session.query.return_value.filter_by.return_value.first.return_value = type_row
# Blow up on session.add() with a generic Exception
mock_session.add.side_effect = OSError("filesystem gone")
context.regrsc_faulty_session = mock_session
context.cov_regrsc_svc = ResourceRegistryService(
session_factory=lambda: mock_session,
)
@when("I call register_resource and a generic exception occurs")
def step_call_register_resource_fails(context: Any) -> None:
context.regrsc_exception = None
try:
context.cov_regrsc_svc.register_resource(
type_name="git-checkout",
name="test/res",
location="/tmp/test",
)
except OSError as exc:
context.regrsc_exception = exc
@then("the register_resource faulty session should have been rolled back")
def step_regrsc_session_rolled_back(context: Any) -> None:
context.regrsc_faulty_session.rollback.assert_called()
@then("the original generic exception should propagate from register_resource")
def step_regrsc_exception_propagates(context: Any) -> None:
assert context.regrsc_exception is not None
assert "filesystem gone" in str(context.regrsc_exception)
# ---------------------------------------------------------------------------
# Scenario: _spec_to_db serialises auto_discovery to JSON
# ---------------------------------------------------------------------------
@given("a ResourceTypeSpec with auto_discovery set")
def step_spec_with_auto_discovery(context: Any) -> None:
context.auto_discovery_data = {
"glob_pattern": "*.py",
"recursive": True,
}
context.cov_spec_ad = ResourceTypeSpec(
name="git-checkout",
description="Test type with auto-discovery",
resource_kind=ResourceKind.PHYSICAL,
sandbox_strategy=SandboxStrategy.GIT_WORKTREE,
user_addable=True,
auto_discovery=context.auto_discovery_data,
built_in=True,
)
@when("I convert the spec to a database model via _spec_to_db")
def step_convert_spec_to_db_ad(context: Any) -> None:
context.cov_db_model_ad = _spec_to_db(context.cov_spec_ad, source="test")
@then("the database model auto_discover_json should contain the auto_discovery data")
def step_verify_ad_json(context: Any) -> None:
raw = context.cov_db_model_ad.auto_discover_json
assert raw is not None, "auto_discover_json should not be None"
parsed = json.loads(raw)
assert parsed == context.auto_discovery_data
# ---------------------------------------------------------------------------
# Scenario: _spec_to_db serialises equivalence to JSON
# ---------------------------------------------------------------------------
@given("a ResourceTypeSpec with equivalence set")
def step_spec_with_equivalence(context: Any) -> None:
context.equivalence_data = {
"fields": ["content_hash", "location"],
"strategy": "hash_compare",
}
# Virtual types require equivalence, so this is consistent
context.cov_spec_eq = ResourceTypeSpec(
name="testns/virt-type",
description="Virtual type with equivalence",
resource_kind=ResourceKind.VIRTUAL,
sandbox_strategy=SandboxStrategy.NONE,
user_addable=False,
equivalence=context.equivalence_data,
built_in=False,
)
@when("I convert the equivalence spec to a database model via _spec_to_db")
def step_convert_spec_to_db_eq(context: Any) -> None:
context.cov_db_model_eq = _spec_to_db(context.cov_spec_eq, source="test")
@then("the database model equivalence_json should contain the equivalence data")
def step_verify_eq_json(context: Any) -> None:
raw = context.cov_db_model_eq.equivalence_json
assert raw is not None, "equivalence_json should not be None"
parsed = json.loads(raw)
assert parsed == context.equivalence_data
# ---------------------------------------------------------------------------
# Scenario: _db_to_spec parses auto_discover_json from a database row
# ---------------------------------------------------------------------------
@given("an in-memory database with a resource type row containing auto_discover_json")
def step_db_row_with_auto_discover(context: Any) -> None:
engine, factory = _in_memory_session_factory()
context.cov_ad_engine = engine
context.cov_ad_factory = factory
context.ad_original = {"glob": "**/*.py", "depth": 3}
session = factory()
from datetime import UTC, datetime
now = datetime.now(tz=UTC).isoformat()
row = ResourceTypeModel(
name="git-checkout",
namespace="builtin",
description="Test row with auto_discover_json",
resource_kind="physical",
sandbox_strategy="git_worktree",
user_addable=True,
handler_ref=None,
args_schema_json=None,
allowed_parent_types_json=None,
allowed_child_types_json=None,
auto_discover_json=json.dumps(context.ad_original),
capabilities_json=json.dumps(
{"read": True, "write": True, "sandbox": True, "checkpoint": False}
),
equivalence_json=None,
source="test",
created_at=now,
updated_at=now,
)
session.add(row)
session.commit()
context.cov_ad_row = (
session.query(ResourceTypeModel).filter_by(name="git-checkout").first()
)
@when("I convert the database row back to a spec via _db_to_spec")
def step_convert_db_to_spec_ad(context: Any) -> None:
context.cov_spec_from_db_ad = _db_to_spec(context.cov_ad_row)
@then("the resulting spec auto_discovery should match the original data")
def step_verify_spec_ad(context: Any) -> None:
assert context.cov_spec_from_db_ad.auto_discovery is not None
assert context.cov_spec_from_db_ad.auto_discovery == context.ad_original
# ---------------------------------------------------------------------------
# Scenario: _db_to_spec parses equivalence_json from a database row
# ---------------------------------------------------------------------------
@given("an in-memory database with a resource type row containing equivalence_json")
def step_db_row_with_equivalence(context: Any) -> None:
engine, factory = _in_memory_session_factory()
context.cov_eq_engine = engine
context.cov_eq_factory = factory
context.eq_original = {"fields": ["hash"], "strategy": "exact"}
session = factory()
from datetime import UTC, datetime
now = datetime.now(tz=UTC).isoformat()
row = ResourceTypeModel(
name="testns/virt-eq",
namespace="testns",
description="Virtual type with equivalence in DB",
resource_kind="virtual",
sandbox_strategy="none",
user_addable=False,
handler_ref=None,
args_schema_json=None,
allowed_parent_types_json=None,
allowed_child_types_json=None,
auto_discover_json=None,
capabilities_json=json.dumps(
{"read": True, "write": True, "sandbox": True, "checkpoint": False}
),
equivalence_json=json.dumps(context.eq_original),
source="test",
created_at=now,
updated_at=now,
)
session.add(row)
session.commit()
context.cov_eq_row = (
session.query(ResourceTypeModel).filter_by(name="testns/virt-eq").first()
)
@when("I convert the equivalence database row back to a spec via _db_to_spec")
def step_convert_db_to_spec_eq(context: Any) -> None:
context.cov_spec_from_db_eq = _db_to_spec(context.cov_eq_row)
@then("the resulting spec equivalence should match the original equivalence data")
def step_verify_spec_eq(context: Any) -> None:
assert context.cov_spec_from_db_eq.equivalence is not None
assert context.cov_spec_from_db_eq.equivalence == context.eq_original
# ---------------------------------------------------------------------------
# Scenario: auto_discovery and equivalence survive a full round-trip
# ---------------------------------------------------------------------------
@given("a real in-memory resource registry service is initialised")
def step_real_inmem_service(context: Any) -> None:
engine, factory = _in_memory_session_factory()
context.cov_rt_engine = engine
context.cov_rt_factory = factory
context.cov_rt_svc = ResourceRegistryService(session_factory=factory)
@given("a YAML config with auto_discovery and equivalence fields exists")
def step_yaml_with_ad_and_eq(context: Any) -> None:
# Virtual type requires equivalence
yaml_content = (
"name: covns/round-trip\n"
"description: Round-trip test type\n"
"resource_kind: virtual\n"
"sandbox_strategy: none\n"
"user_addable: false\n"
"auto_discovery:\n"
" glob_pattern: '*.md'\n"
" max_depth: 5\n"
"equivalence:\n"
" fields:\n"
" - content_hash\n"
" strategy: hash_compare\n"
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write(yaml_content)
tmp.flush()
context.cov_rt_yaml_path = tmp.name
@when("I register the type with auto_discovery and equivalence via the service")
def step_register_rt_type(context: Any) -> None:
context.cov_rt_spec = context.cov_rt_svc.register_type(context.cov_rt_yaml_path)
@when("I show the registered type with auto_discovery and equivalence")
def step_show_rt_type(context: Any) -> None:
context.cov_rt_shown = context.cov_rt_svc.show_type("covns/round-trip")
@then("the shown type should have the correct auto_discovery")
def step_verify_rt_ad(context: Any) -> None:
ad = context.cov_rt_shown.auto_discovery
assert ad is not None, "auto_discovery should be preserved"
assert ad["glob_pattern"] == "*.md"
assert ad["max_depth"] == 5
@then("the shown type should have the correct equivalence")
def step_verify_rt_eq(context: Any) -> None:
eq = context.cov_rt_shown.equivalence
assert eq is not None, "equivalence should be preserved"
assert eq["fields"] == ["content_hash"]
assert eq["strategy"] == "hash_compare"
@@ -0,0 +1,254 @@
"""Step definitions for tool_registry_service_coverage.feature.
Exercises the uncovered error-handling branches in ToolRegistryService
(lines 103-104, 123, 134-135, 139-140, 159, 174-175, 185-186, 236).
All repository dependencies are lightweight mocks no database needed.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.tool_registry_service import (
ToolRegistryService,
)
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.infrastructure.database.repositories import ToolNotFoundError
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _MockToolRepo:
"""Minimal mock that satisfies ToolRegistryService's expectations."""
def __init__(self) -> None:
self._get_by_name_return: Any = None
self._update_side_effect: Exception | None = None
self._remove_side_effect: Exception | None = None
self._session_mock: Any = None
def add(self, tool: Any) -> str:
return "mock-tool-id-001"
def get_by_name(self, name: str) -> Any:
return self._get_by_name_return
def update(self, tool: Any) -> None:
if self._update_side_effect is not None:
raise self._update_side_effect
def remove(self, tool_id: str) -> None:
if self._remove_side_effect is not None:
raise self._remove_side_effect
def _session(self) -> Any:
return self._session_mock
class _MockAttachmentRepo:
"""No-op attachment repository mock."""
def attach(self, **kwargs: Any) -> str:
return "mock-attachment-id-001"
def detach(self, **kwargs: Any) -> None:
pass
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a mock-based tool registry service")
def step_mock_service(context: Context) -> None:
context.cov_tool_repo = _MockToolRepo()
context.cov_attachment_repo = _MockAttachmentRepo()
context.cov_service = ToolRegistryService(
tool_repo=context.cov_tool_repo,
attachment_repo=context.cov_attachment_repo,
)
context.cov_error: Exception | None = None
# ---------------------------------------------------------------------------
# Given: config helpers
# ---------------------------------------------------------------------------
@given("a tool config that will cause Tool.from_config to raise ValueError")
def step_config_value_error(context: Context) -> None:
# Missing required 'source' key triggers ValueError inside Tool.from_config
context.cov_tool_config: dict[str, Any] = {
"name": "local/bad-tool",
"description": "Will fail",
"tool_type": "tool",
# 'source' intentionally omitted → ValueError
}
@given("a tool config that will cause Tool.from_config to raise TypeError")
def step_config_type_error(context: Context) -> None:
# We patch Tool.from_config to raise TypeError so we exercise that branch
context.cov_tool_config = {
"name": "local/bad-tool-type",
"description": "Will fail with TypeError",
"source": "builtin",
"tool_type": "tool",
}
context.cov_patch_from_config_type_error = True
@given("the mock tool repo returns None for get_by_name")
def step_repo_returns_none(context: Context) -> None:
context.cov_tool_repo._get_by_name_return = None
@given("the mock tool repo returns a sentinel for get_by_name")
def step_repo_returns_sentinel(context: Context) -> None:
context.cov_tool_repo._get_by_name_return = object() # truthy sentinel
@given("an update config that will cause Tool.from_config to raise ValueError")
def step_update_config_value_error(context: Context) -> None:
# Missing 'source' causes ValueError
context.cov_update_config: dict[str, Any] = {
"description": "Will fail",
"tool_type": "tool",
# 'source' intentionally omitted
}
@given("Tool.from_config is patched to return a valid tool")
def step_patch_from_config(context: Context) -> None:
context.cov_patch_from_config_valid = True
context.cov_update_config = {
"description": "Patched tool",
"source": "builtin",
"tool_type": "tool",
}
@given("the mock tool repo update method raises ToolNotFoundError")
def step_repo_update_raises(context: Context) -> None:
context.cov_tool_repo._update_side_effect = ToolNotFoundError("gone")
@given("the mock session query returns no DB row")
def step_session_no_row(context: Context) -> None:
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = None
context.cov_tool_repo._session_mock = mock_session
@given('the mock session query returns a DB row with tool_id "{tid}"')
def step_session_with_row(context: Context, tid: str) -> None:
mock_row = MagicMock()
mock_row.tool_id = tid
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_row
context.cov_tool_repo._session_mock = mock_session
@given("the mock tool repo remove method raises ToolNotFoundError")
def step_repo_remove_raises(context: Context) -> None:
context.cov_tool_repo._remove_side_effect = ToolNotFoundError("gone")
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when("I attempt to register the tool via the coverage service")
def step_when_register(context: Context) -> None:
try:
if getattr(context, "cov_patch_from_config_type_error", False):
with patch(
"cleveragents.application.services.tool_registry_service.Tool.from_config",
side_effect=TypeError("unexpected keyword argument"),
):
context.cov_service.register_tool(context.cov_tool_config)
else:
context.cov_service.register_tool(context.cov_tool_config)
context.cov_error = None
except (ValidationError, NotFoundError) as exc:
context.cov_error = exc
@when('I attempt to update tool "{name}" via the coverage service')
def step_when_update(context: Context, name: str) -> None:
try:
config = getattr(context, "cov_update_config", {"tool_type": "tool"})
if getattr(context, "cov_patch_from_config_valid", False):
mock_tool = MagicMock()
with patch(
"cleveragents.application.services.tool_registry_service.Tool.from_config",
return_value=mock_tool,
):
context.cov_service.update_tool(name, config)
else:
context.cov_service.update_tool(name, config)
context.cov_error = None
except (ValidationError, NotFoundError) as exc:
context.cov_error = exc
@when('I attempt to remove tool "{name}" via the coverage service')
def step_when_remove(context: Context, name: str) -> None:
try:
context.cov_service.remove_tool(name)
context.cov_error = None
except (ValidationError, NotFoundError) as exc:
context.cov_error = exc
@when('I attempt to attach validation with mode "{mode}" via the coverage service')
def step_when_attach_invalid_mode(context: Context, mode: str) -> None:
try:
context.cov_service.attach_validation(
resource_id="res-001",
validation_name="local/some-check",
mode=mode,
)
context.cov_error = None
except (ValidationError, NotFoundError) as exc:
context.cov_error = exc
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then('a coverage ValidationError should be raised with message containing "{text}"')
def step_then_validation_error(context: Context, text: str) -> None:
assert context.cov_error is not None, (
"Expected ValidationError but no error was raised"
)
assert isinstance(context.cov_error, ValidationError), (
f"Expected ValidationError, got {type(context.cov_error).__name__}"
)
assert text in str(context.cov_error), (
f"Expected '{text}' in error message, got '{context.cov_error}'"
)
@then('a coverage NotFoundError should be raised with message containing "{text}"')
def step_then_not_found_error(context: Context, text: str) -> None:
assert context.cov_error is not None, (
"Expected NotFoundError but no error was raised"
)
assert isinstance(context.cov_error, NotFoundError), (
f"Expected NotFoundError, got {type(context.cov_error).__name__}"
)
assert text in str(context.cov_error), (
f"Expected '{text}' in error message, got '{context.cov_error}'"
)
@@ -0,0 +1,74 @@
@unit @coverage @tool_registry
Feature: Tool Registry Service uncovered error paths
As a developer maintaining the tool registry service
I want to exercise the error-handling branches that lack coverage
So that lines 103-104, 123, 134-135, 139-140, 159, 175, 185-186, 236 are tested
Background:
Given a mock-based tool registry service
# --- register_tool: ValueError/TypeError from Tool.from_config (lines 103-104) ---
Scenario: register_tool raises ValidationError when Tool.from_config raises ValueError
Given a tool config that will cause Tool.from_config to raise ValueError
When I attempt to register the tool via the coverage service
Then a coverage ValidationError should be raised with message containing "Invalid tool config"
Scenario: register_tool raises ValidationError when Tool.from_config raises TypeError
Given a tool config that will cause Tool.from_config to raise TypeError
When I attempt to register the tool via the coverage service
Then a coverage ValidationError should be raised with message containing "Invalid tool config"
# --- update_tool: tool not found (line 123) ---
Scenario: update_tool raises NotFoundError when tool does not exist
Given the mock tool repo returns None for get_by_name
When I attempt to update tool "local/nonexistent" via the coverage service
Then a coverage NotFoundError should be raised with message containing "not found"
# --- update_tool: ValueError from Tool.from_config (lines 134-135) ---
Scenario: update_tool raises ValidationError when Tool.from_config raises ValueError
Given the mock tool repo returns a sentinel for get_by_name
And an update config that will cause Tool.from_config to raise ValueError
When I attempt to update tool "local/bad-update" via the coverage service
Then a coverage ValidationError should be raised with message containing "Invalid tool config"
# --- update_tool: ToolNotFoundError from repo.update (lines 139-140) ---
Scenario: update_tool raises NotFoundError when repo.update raises ToolNotFoundError
Given the mock tool repo returns a sentinel for get_by_name
And Tool.from_config is patched to return a valid tool
And the mock tool repo update method raises ToolNotFoundError
When I attempt to update tool "local/vanished" via the coverage service
Then a coverage NotFoundError should be raised with message containing "not found"
# --- remove_tool: tool not found (line 159) ---
Scenario: remove_tool raises NotFoundError when tool does not exist
Given the mock tool repo returns None for get_by_name
When I attempt to remove tool "local/ghost" via the coverage service
Then a coverage NotFoundError should be raised with message containing "not found"
# --- remove_tool: DB row is None (lines 174-175) ---
Scenario: remove_tool raises NotFoundError when DB row is None
Given the mock tool repo returns a sentinel for get_by_name
And the mock session query returns no DB row
When I attempt to remove tool "local/no-row" via the coverage service
Then a coverage NotFoundError should be raised with message containing "not found"
# --- remove_tool: ToolNotFoundError from repo.remove (lines 185-186) ---
Scenario: remove_tool raises NotFoundError when repo.remove raises ToolNotFoundError
Given the mock tool repo returns a sentinel for get_by_name
And the mock session query returns a DB row with tool_id "tid-123"
And the mock tool repo remove method raises ToolNotFoundError
When I attempt to remove tool "local/remove-fail" via the coverage service
Then a coverage NotFoundError should be raised with message containing "not found"
# --- attach_validation: invalid mode (line 236) ---
Scenario: attach_validation raises ValidationError for an invalid mode
When I attempt to attach validation with mode "bad_mode" via the coverage service
Then a coverage ValidationError should be raised with message containing "Invalid mode"