forked from cleveragents/cleveragents-core
Tests: Added coverage to get us to 97%
This commit is contained in:
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user