From 77a0a95dc3894f747894954bef8a526ff27aca32 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Tue, 17 Feb 2026 18:33:08 -0500 Subject: [PATCH] Tests: Added coverage to get us to 97% --- .../automation_profile_crud_coverage.feature | 89 + features/binding_resolution_coverage.feature | 122 ++ features/project_cli_coverage_boost.feature | 106 ++ ...positories_error_handling_coverage.feature | 505 ++++++ features/repositories_uncovered_lines.feature | 349 ++++ features/resource_cli_coverage_boost.feature | 67 + ...resource_registry_service_coverage.feature | 59 + .../automation_profile_crud_coverage_steps.py | 270 +++ .../binding_resolution_coverage_steps.py | 263 +++ .../steps/project_cli_coverage_boost_steps.py | 373 +++++ ...ositories_error_handling_coverage_steps.py | 1071 ++++++++++++ .../repositories_uncovered_lines_steps.py | 1483 +++++++++++++++++ .../resource_cli_coverage_boost_steps.py | 410 +++++ ...esource_registry_service_coverage_steps.py | 451 +++++ .../tool_registry_service_coverage_steps.py | 254 +++ .../tool_registry_service_coverage.feature | 74 + 16 files changed, 5946 insertions(+) create mode 100644 features/automation_profile_crud_coverage.feature create mode 100644 features/binding_resolution_coverage.feature create mode 100644 features/project_cli_coverage_boost.feature create mode 100644 features/repositories_error_handling_coverage.feature create mode 100644 features/repositories_uncovered_lines.feature create mode 100644 features/resource_cli_coverage_boost.feature create mode 100644 features/resource_registry_service_coverage.feature create mode 100644 features/steps/automation_profile_crud_coverage_steps.py create mode 100644 features/steps/binding_resolution_coverage_steps.py create mode 100644 features/steps/project_cli_coverage_boost_steps.py create mode 100644 features/steps/repositories_error_handling_coverage_steps.py create mode 100644 features/steps/repositories_uncovered_lines_steps.py create mode 100644 features/steps/resource_cli_coverage_boost_steps.py create mode 100644 features/steps/resource_registry_service_coverage_steps.py create mode 100644 features/steps/tool_registry_service_coverage_steps.py create mode 100644 features/tool_registry_service_coverage.feature diff --git a/features/automation_profile_crud_coverage.feature b/features/automation_profile_crud_coverage.feature new file mode 100644 index 00000000..7c069715 --- /dev/null +++ b/features/automation_profile_crud_coverage.feature @@ -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" diff --git a/features/binding_resolution_coverage.feature b/features/binding_resolution_coverage.feature new file mode 100644 index 00000000..08af6367 --- /dev/null +++ b/features/binding_resolution_coverage.feature @@ -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" diff --git a/features/project_cli_coverage_boost.feature b/features/project_cli_coverage_boost.feature new file mode 100644 index 00000000..411720ea --- /dev/null +++ b/features/project_cli_coverage_boost.feature @@ -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 diff --git a/features/repositories_error_handling_coverage.feature b/features/repositories_error_handling_coverage.feature new file mode 100644 index 00000000..416c3fe9 --- /dev/null +++ b/features/repositories_error_handling_coverage.feature @@ -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" diff --git a/features/repositories_uncovered_lines.feature b/features/repositories_uncovered_lines.feature new file mode 100644 index 00000000..e2d055e6 --- /dev/null +++ b/features/repositories_uncovered_lines.feature @@ -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 diff --git a/features/resource_cli_coverage_boost.feature b/features/resource_cli_coverage_boost.feature new file mode 100644 index 00000000..da92d923 --- /dev/null +++ b/features/resource_cli_coverage_boost.feature @@ -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 diff --git a/features/resource_registry_service_coverage.feature b/features/resource_registry_service_coverage.feature new file mode 100644 index 00000000..de3e6817 --- /dev/null +++ b/features/resource_registry_service_coverage.feature @@ -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 diff --git a/features/steps/automation_profile_crud_coverage_steps.py b/features/steps/automation_profile_crud_coverage_steps.py new file mode 100644 index 00000000..d8846947 --- /dev/null +++ b/features/steps/automation_profile_crud_coverage_steps.py @@ -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}" diff --git a/features/steps/binding_resolution_coverage_steps.py b/features/steps/binding_resolution_coverage_steps.py new file mode 100644 index 00000000..27a241cd --- /dev/null +++ b/features/steps/binding_resolution_coverage_steps.py @@ -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}'") diff --git a/features/steps/project_cli_coverage_boost_steps.py b/features/steps/project_cli_coverage_boost_steps.py new file mode 100644 index 00000000..65328dc5 --- /dev/null +++ b/features/steps/project_cli_coverage_boost_steps.py @@ -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}" + ) diff --git a/features/steps/repositories_error_handling_coverage_steps.py b/features/steps/repositories_error_handling_coverage_steps.py new file mode 100644 index 00000000..f780fa59 --- /dev/null +++ b/features/steps/repositories_error_handling_coverage_steps.py @@ -0,0 +1,1071 @@ +"""Step definitions for repository error-handling coverage. + +Targets uncovered ``except (OperationalError, SQLAlchemyDatabaseError)`` and +``except IntegrityError`` handlers across all major repository classes: + +- NamespacedProjectRepository +- ToolRepository +- AutomationProfileRepository +- ResourceTypeRepository +- ResourceRepository +- ProjectResourceLinkRepository +- ValidationAttachmentRepository +- LifecyclePlanRepository + +Strategy: replace the session factory with one returning a mock session whose +``.query()`` or ``.flush()`` raises ``OperationalError`` / ``IntegrityError``, +forcing every error handler to execute. The ``@database_retry`` decorator +retries 3 times on ``DatabaseError`` before re-raising. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import MagicMock + +from behave import given, then, when +from behave.runner import Context +from sqlalchemy import create_engine +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.orm import sessionmaker + +from cleveragents.core.exceptions import DatabaseError +from cleveragents.infrastructure.database.models import Base +from cleveragents.infrastructure.database.repositories import ( + AutomationProfileRepository, + DuplicateResourceError, + DuplicateToolError, + DuplicateValidationAttachmentError, + LifecyclePlanRepository, + NamespacedProjectRepository, + ProjectResourceLinkRepository, + ResourceRepository, + ResourceTypeRepository, + ToolNotFoundError, + ToolRepository, + ValidationAttachmentRepository, +) + +# --------------------------------------------------------------------------- +# Mock session helpers +# --------------------------------------------------------------------------- + + +def _mock_session_op_error_on_query() -> MagicMock: + """Session whose .query() always raises OperationalError.""" + mock = MagicMock() + mock.query.side_effect = OperationalError("connection lost", {}, None) + mock.rollback.return_value = None + return mock + + +def _mock_session_op_error_on_flush() -> MagicMock: + """Session whose .flush() raises OperationalError but .add()/.query() work.""" + mock = MagicMock() + mock.add.return_value = None + mock.flush.side_effect = OperationalError("disk I/O error", {}, None) + mock.rollback.return_value = None + # query returns a mock that allows filter_by().first() to return None + query_mock = MagicMock() + query_mock.filter_by.return_value.first.return_value = None + query_mock.filter.return_value.first.return_value = None + mock.query.return_value = query_mock + return mock + + +def _mock_session_unique_integrity_on_flush() -> MagicMock: + """Session whose .flush() raises UNIQUE IntegrityError.""" + mock = MagicMock() + mock.add.return_value = None + mock.flush.side_effect = IntegrityError( + "UNIQUE constraint failed: table.name", {}, None + ) + mock.rollback.return_value = None + query_mock = MagicMock() + query_mock.filter_by.return_value.first.return_value = None + query_mock.filter.return_value.first.return_value = None + mock.query.return_value = query_mock + return mock + + +def _mock_session_non_unique_integrity_on_flush() -> MagicMock: + """Session whose .flush() raises a non-UNIQUE IntegrityError.""" + mock = MagicMock() + mock.add.return_value = None + mock.flush.side_effect = IntegrityError( + "CHECK constraint failed: some_check", {}, None + ) + mock.rollback.return_value = None + query_mock = MagicMock() + query_mock.filter_by.return_value.first.return_value = None + query_mock.filter.return_value.first.return_value = None + mock.query.return_value = query_mock + return mock + + +def _mock_session_integrity_on_flush() -> MagicMock: + """Session whose .flush() raises IntegrityError (generic).""" + mock = MagicMock() + mock.add.return_value = None + mock.flush.side_effect = IntegrityError("FOREIGN KEY constraint failed", {}, None) + mock.rollback.return_value = None + query_mock = MagicMock() + query_mock.filter_by.return_value.first.return_value = None + query_mock.filter.return_value.first.return_value = None + mock.query.return_value = query_mock + return mock + + +def _capture_error(context: Context, fn, *args, **kwargs) -> None: + """Call fn and capture any exception to context.repo_error.""" + try: + fn(*args, **kwargs) + except ( + DatabaseError, + DuplicateToolError, + ToolNotFoundError, + DuplicateResourceError, + DuplicateValidationAttachmentError, + ) as exc: + context.repo_error = exc + except Exception as exc: + # tenacity may wrap; unwrap to root cause + cause = getattr(exc, "__cause__", None) or exc + context.repo_error = cause + + +# --------------------------------------------------------------------------- +# Fake domain objects +# --------------------------------------------------------------------------- + + +def _make_fake_project() -> SimpleNamespace: + """Minimal fake NamespacedProject for repository calls.""" + now = datetime.now(tz=UTC) + proj = SimpleNamespace( + name="test-project", + namespace="local", + namespaced_name="local/test-project", + description="A test project", + context_config=None, + tags=[], + created_at=now, + updated_at=now, + ) + return proj + + +def _make_fake_tool() -> SimpleNamespace: + """Minimal fake Tool for repository calls.""" + return SimpleNamespace( + name="local/test-tool", + description="A test tool", + tool_type=SimpleNamespace(value="tool"), + source=SimpleNamespace(value="builtin"), + input_schema=None, + output_schema=None, + capability=SimpleNamespace( + read_only=False, writes=False, checkpointable=False, side_effects=[] + ), + config_yaml=None, + resource_slots=[], + tool_id=None, + ) + + +def _make_fake_automation_profile() -> SimpleNamespace: + """Minimal fake AutomationProfile for repository calls.""" + return SimpleNamespace( + name="local/test-profile", + description="A test profile", + schema_version="1.0", + auto_strategize=0.0, + auto_execute=0.0, + auto_apply=0.0, + auto_decisions_strategize=0.0, + auto_decisions_execute=0.0, + auto_validation_fix=0.0, + auto_strategy_revision=0.0, + auto_reversion_from_apply=0.0, + auto_child_plans=0.0, + auto_retry_transient=0.0, + auto_checkpoint_restore=0.0, + require_sandbox=True, + require_checkpoints=True, + allow_unsafe_tools=False, + ) + + +def _make_fake_resource_type() -> SimpleNamespace: + """Minimal fake ResourceTypeSpec for repository calls.""" + return SimpleNamespace( + name="test/resource-type", + description="A test resource type", + resource_kind=SimpleNamespace(value="physical"), + sandbox_strategy=SimpleNamespace(value="copy_on_write"), + user_addable=True, + handler=None, + cli_args=[], + parent_types=[], + child_types=[], + auto_discovery=None, + equivalence=None, + capabilities=None, + source=None, + ) + + +def _make_fake_resource() -> SimpleNamespace: + """Minimal fake Resource for repository calls.""" + return SimpleNamespace( + resource_id="01HGZ6FE0A0000000000000001", + name="test/my-resource", + resource_type_name="test/resource-type", + classification="physical", + description="A test resource", + location="/tmp/test", + capabilities=SimpleNamespace(writable=True), + sandbox_strategy=None, + content_hash=None, + properties=None, + ) + + +def _make_fake_plan() -> SimpleNamespace: + """Minimal fake Plan for LifecyclePlanRepository. + + Must have enough attributes for ``LifecyclePlanModel.from_domain()`` to + succeed (it accesses many nested fields). + """ + now = datetime.now(tz=UTC) + return SimpleNamespace( + identity=SimpleNamespace( + plan_id="01HGZ6FE0A0000000000000099", + parent_plan_id=None, + root_plan_id=None, + attempt=1, + ), + phase=SimpleNamespace(value="strategize"), + processing_state=SimpleNamespace(value="queued"), + namespaced_name=type( + "_NS", (), {"namespace": "local", "__str__": lambda s: "local/test-plan"} + )(), + namespace="local", + action_name="local/test-action", + description="A test plan", + definition_of_done="Done", + strategy_actor="local/strategist", + execution_actor="local/executor", + review_actor=None, + apply_actor=None, + estimation_actor=None, + invariant_actor=None, + automation_level=SimpleNamespace(value="assisted"), + automation_profile=None, + error_message=None, + error_details=None, + changeset_id=None, + sandbox_refs=[], + validation_summary=None, + decision_root_id=None, + reusable=False, + read_only=False, + created_by=None, + tags=[], + project_links=[], + arguments={}, + arguments_order=[], + invariants=[], + timestamps=SimpleNamespace( + created_at=now, + updated_at=now, + applied_at=None, + strategize_started_at=None, + strategize_completed_at=None, + execute_started_at=None, + execute_completed_at=None, + apply_started_at=None, + ), + ) + + +# =========================================================================== +# NamespacedProjectRepository steps +# =========================================================================== + + +@given( + "a namespaced project repository with session raising UNIQUE IntegrityError on flush" +) +def step_nsproject_repo_unique_integrity(context: Context) -> None: + mock = _mock_session_unique_integrity_on_flush() + context.repo_under_test = NamespacedProjectRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given( + "a namespaced project repository with session raising non-UNIQUE IntegrityError on flush" +) +def step_nsproject_repo_non_unique_integrity(context: Context) -> None: + mock = _mock_session_non_unique_integrity_on_flush() + context.repo_under_test = NamespacedProjectRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a namespaced project repository with session raising OperationalError on flush") +def step_nsproject_repo_op_error_flush(context: Context) -> None: + mock = _mock_session_op_error_on_flush() + context.repo_under_test = NamespacedProjectRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a namespaced project repository with session raising OperationalError on query") +def step_nsproject_repo_op_error_query(context: Context) -> None: + mock = _mock_session_op_error_on_query() + context.repo_under_test = NamespacedProjectRepository(session_factory=lambda: mock) + context.repo_error = None + + +@when("a namespaced project is created and a repo error is expected") +def step_nsproject_create(context: Context) -> None: + proj = _make_fake_project() + _capture_error(context, context.repo_under_test.create, proj) + + +@when("a namespaced project is fetched by name and a repo error is expected") +def step_nsproject_get(context: Context) -> None: + _capture_error(context, context.repo_under_test.get, "local/test-project") + + +@when("namespaced projects are listed and a repo error is expected") +def step_nsproject_list(context: Context) -> None: + _capture_error(context, context.repo_under_test.list_projects) + + +@when("a namespaced project is updated and a repo error is expected") +def step_nsproject_update(context: Context) -> None: + proj = _make_fake_project() + _capture_error(context, context.repo_under_test.update, proj) + + +@when("a namespaced project is deleted and a repo error is expected") +def step_nsproject_delete(context: Context) -> None: + _capture_error(context, context.repo_under_test.delete, "local/test-project") + + +# =========================================================================== +# ToolRepository steps +# =========================================================================== + + +@given("a tool repository with session raising UNIQUE IntegrityError on flush") +def step_tool_repo_unique_integrity(context: Context) -> None: + mock = _mock_session_unique_integrity_on_flush() + # .query().filter_by().first() returns None so the duplicate check passes + context.repo_under_test = ToolRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a tool repository with session raising non-UNIQUE IntegrityError on flush") +def step_tool_repo_non_unique_integrity(context: Context) -> None: + mock = _mock_session_non_unique_integrity_on_flush() + context.repo_under_test = ToolRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a tool repository with session raising OperationalError on flush") +def step_tool_repo_op_error_flush(context: Context) -> None: + mock = _mock_session_op_error_on_flush() + context.repo_under_test = ToolRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a tool repository with session raising OperationalError on query") +def step_tool_repo_op_error_query(context: Context) -> None: + mock = _mock_session_op_error_on_query() + context.repo_under_test = ToolRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a tool repository backed by a clean in-memory database") +def step_tool_repo_clean_db(context: Context) -> None: + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + session_factory = sessionmaker(bind=engine) + context.repo_under_test = ToolRepository(session_factory=session_factory) + context.repo_error = None + + +@when("a tool is added and a repo error is expected") +def step_tool_add(context: Context) -> None: + tool = _make_fake_tool() + _capture_error(context, context.repo_under_test.add, tool) + + +@when("a tool is fetched by name and a repo error is expected") +def step_tool_get_by_name(context: Context) -> None: + _capture_error(context, context.repo_under_test.get_by_name, "local/test-tool") + + +@when("all tools are listed and a repo error is expected") +def step_tool_list_all(context: Context) -> None: + _capture_error(context, context.repo_under_test.list_all) + + +@when("a non-existent tool is updated and a repo error is expected") +def step_tool_update_nonexistent(context: Context) -> None: + tool = _make_fake_tool() + _capture_error(context, context.repo_under_test.update, tool) + + +@when("a non-existent tool is removed and a repo error is expected") +def step_tool_remove_nonexistent(context: Context) -> None: + _capture_error( + context, context.repo_under_test.remove, "01HGZ6FE0AXXXXXXXXXXXXXXXXX" + ) + + +# =========================================================================== +# AutomationProfileRepository steps +# =========================================================================== + + +@given( + "an automation profile repository with session raising OperationalError on query" +) +def step_profile_repo_op_error_query(context: Context) -> None: + mock = _mock_session_op_error_on_query() + context.repo_under_test = AutomationProfileRepository(session_factory=lambda: mock) + context.repo_error = None + + +@when("an automation profile is fetched by name and a repo error is expected") +def step_profile_get_by_name(context: Context) -> None: + _capture_error(context, context.repo_under_test.get_by_name, "local/test-profile") + + +@when("all automation profiles are listed and a repo error is expected") +def step_profile_list_all(context: Context) -> None: + _capture_error(context, context.repo_under_test.list_all) + + +@when("an automation profile is upserted and a repo error is expected") +def step_profile_upsert(context: Context) -> None: + profile = _make_fake_automation_profile() + _capture_error(context, context.repo_under_test.upsert, profile) + + +@when("an automation profile is deleted and a repo error is expected") +def step_profile_delete(context: Context) -> None: + _capture_error(context, context.repo_under_test.delete, "local/test-profile") + + +# =========================================================================== +# ResourceTypeRepository steps +# =========================================================================== + + +@given("a resource type repository with session raising OperationalError on query") +def step_rt_repo_op_error_query(context: Context) -> None: + mock = _mock_session_op_error_on_query() + context.repo_under_test = ResourceTypeRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a resource type repository with session raising OperationalError on flush") +def step_rt_repo_op_error_flush(context: Context) -> None: + mock = _mock_session_op_error_on_flush() + context.repo_under_test = ResourceTypeRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given( + "a resource type repository with session that passes query but raises non-UNIQUE IntegrityError on flush" +) +def step_rt_repo_non_unique_integrity(context: Context) -> None: + mock = _mock_session_non_unique_integrity_on_flush() + context.repo_under_test = ResourceTypeRepository(session_factory=lambda: mock) + context.repo_error = None + + +@when("a resource type is created and a repo error is expected") +def step_rt_create(context: Context) -> None: + rt = _make_fake_resource_type() + _capture_error(context, context.repo_under_test.create, rt) + + +@when("a resource type is fetched by name and a repo error is expected") +def step_rt_get(context: Context) -> None: + _capture_error(context, context.repo_under_test.get, "test/resource-type") + + +@when("resource types are listed and a repo error is expected") +def step_rt_list(context: Context) -> None: + _capture_error(context, context.repo_under_test.list_types) + + +@when("a resource type is updated and a repo error is expected") +def step_rt_update(context: Context) -> None: + rt = _make_fake_resource_type() + _capture_error(context, context.repo_under_test.update, rt) + + +@when("a resource type is deleted and a repo error is expected") +def step_rt_delete(context: Context) -> None: + _capture_error(context, context.repo_under_test.delete, "test/resource-type") + + +# =========================================================================== +# ResourceRepository steps +# =========================================================================== + + +def _make_resource_create_session_factory(integrity_msg: str): + """Return a session_factory callable. + + Each call produces a fresh mock with its own counter so that retries + get the same deterministic sequence: type-row on 1st query, None on + 2nd, then IntegrityError on flush. + """ + + def _factory() -> MagicMock: + mock = MagicMock() + mock.add.return_value = None + mock.flush.side_effect = IntegrityError(integrity_msg, {}, None) + mock.rollback.return_value = None + + type_row = MagicMock() + type_row.name = "test/resource-type" + call_count = {"n": 0} + + def _query_side_effect(*args): + qm = MagicMock() + + def _filter_by_side(**kwargs): + fm = MagicMock() + call_count["n"] += 1 + if call_count["n"] == 1: + fm.first.return_value = type_row + else: + fm.first.return_value = None + return fm + + qm.filter_by = _filter_by_side + return qm + + mock.query.side_effect = _query_side_effect + return mock + + return _factory + + +@given("a resource repository with session raising OperationalError on query") +def step_resource_repo_op_error_query(context: Context) -> None: + mock = _mock_session_op_error_on_query() + context.repo_under_test = ResourceRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a resource repository with session raising OperationalError on flush") +def step_resource_repo_op_error_flush(context: Context) -> None: + # ResourceRepository.create queries type first, so we need a factory + # where queries return proper rows but flush raises OperationalError. + def _factory() -> MagicMock: + mock = MagicMock() + mock.add.return_value = None + mock.flush.side_effect = OperationalError("disk I/O error", {}, None) + mock.rollback.return_value = None + + type_row = MagicMock() + type_row.name = "test/resource-type" + call_count = {"n": 0} + + def _query_side_effect(*args): + qm = MagicMock() + + def _filter_by_side(**kwargs): + fm = MagicMock() + call_count["n"] += 1 + if call_count["n"] == 1: + fm.first.return_value = type_row + else: + fm.first.return_value = None + return fm + + qm.filter_by = _filter_by_side + return qm + + mock.query.side_effect = _query_side_effect + return mock + + context.repo_under_test = ResourceRepository(session_factory=_factory) + context.repo_error = None + + +@given( + "a resource repository with session that passes initial queries but raises UNIQUE IntegrityError on flush" +) +def step_resource_repo_unique_integrity(context: Context) -> None: + factory = _make_resource_create_session_factory( + "UNIQUE constraint failed: resources.namespaced_name" + ) + context.repo_under_test = ResourceRepository(session_factory=factory) + context.repo_error = None + + +@given( + "a resource repository with session that passes initial queries but raises non-UNIQUE IntegrityError on flush" +) +def step_resource_repo_non_unique_integrity(context: Context) -> None: + factory = _make_resource_create_session_factory( + "CHECK constraint failed: some_check" + ) + context.repo_under_test = ResourceRepository(session_factory=factory) + context.repo_error = None + + +@when("a resource is created and a repo error is expected") +def step_resource_create(context: Context) -> None: + res = _make_fake_resource() + _capture_error(context, context.repo_under_test.create, res) + + +@when("a resource is fetched by ID and a repo error is expected") +def step_resource_get(context: Context) -> None: + _capture_error(context, context.repo_under_test.get, "01HGZ6FE0A0000000000000001") + + +@when("a resource is fetched by name and a repo error is expected") +def step_resource_get_by_name(context: Context) -> None: + _capture_error(context, context.repo_under_test.get_by_name, "test/my-resource") + + +@when("resources are listed and a repo error is expected") +def step_resource_list(context: Context) -> None: + _capture_error(context, context.repo_under_test.list_resources) + + +@when("a resource is updated and a repo error is expected") +def step_resource_update(context: Context) -> None: + res = _make_fake_resource() + _capture_error(context, context.repo_under_test.update, res) + + +@when("a resource is deleted and a repo error is expected") +def step_resource_delete(context: Context) -> None: + _capture_error( + context, context.repo_under_test.delete, "01HGZ6FE0A0000000000000001" + ) + + +@when("a resource is resolved by name-or-id and a repo error is expected") +def step_resource_resolve(context: Context) -> None: + _capture_error( + context, context.repo_under_test.resolve_namespaced_name, "test/my-resource" + ) + + +# --- ResourceRepository DAG operations --- + + +def _make_dag_link_session_factory(flush_error): + """Return a session_factory for DAG link operations. + + Each call produces a fresh mock with its own counter so retries + get the same deterministic query sequence. + + ``flush_error`` should be an exception class or instance to raise + on flush. + """ + + def _factory() -> MagicMock: + mock = MagicMock() + mock.rollback.return_value = None + + parent_row = MagicMock() + parent_row.type_name = "test/parent-type" + parent_row.resource_id = "PARENT000000000000000000001" + + child_row = MagicMock() + child_row.type_name = "test/child-type" + child_row.resource_id = "CHILD0000000000000000000001" + + type_row = MagicMock() + type_row.allowed_child_types_json = None + + call_count = {"n": 0} + + def _query_side_effect(*args): + qm = MagicMock() + + def _filter_by_side(**kwargs): + fm = MagicMock() + call_count["n"] += 1 + if call_count["n"] == 1: + fm.first.return_value = parent_row + elif call_count["n"] == 2: + fm.first.return_value = child_row + elif call_count["n"] == 3: + fm.first.return_value = type_row + elif call_count["n"] == 4: + fm.first.return_value = None # no existing link + else: + fm.first.return_value = None + fm.all.return_value = [] + return fm + + qm.filter_by = _filter_by_side + qm.filter.return_value = qm + qm.all.return_value = [] + return qm + + mock.query.side_effect = _query_side_effect + mock.add.return_value = None + mock.flush.side_effect = flush_error + return mock + + return _factory + + +@given( + "a resource repository with session that has resources but raises IntegrityError on link flush" +) +def step_resource_repo_dag_integrity(context: Context) -> None: + factory = _make_dag_link_session_factory( + IntegrityError("FOREIGN KEY constraint failed", {}, None) + ) + context.repo_under_test = ResourceRepository(session_factory=factory) + context.repo_error = None + + +@given( + "a resource repository with session that has resources but raises OperationalError on link flush" +) +def step_resource_repo_dag_op_error(context: Context) -> None: + factory = _make_dag_link_session_factory( + OperationalError("disk I/O error", {}, None) + ) + context.repo_under_test = ResourceRepository(session_factory=factory) + context.repo_error = None + + +@when("a resource child is linked and a repo error is expected") +def step_resource_link_child(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.link_child, + "PARENT000000000000000000001", + "CHILD0000000000000000000001", + ) + + +@when("a resource child is unlinked and a repo error is expected") +def step_resource_unlink_child(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.unlink_child, + "PARENT000000000000000000001", + "CHILD0000000000000000000001", + ) + + +@when("resource children are fetched and a repo error is expected") +def step_resource_get_children(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.get_children, + "PARENT000000000000000000001", + ) + + +@when("resource parents are fetched and a repo error is expected") +def step_resource_get_parents(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.get_parents, + "CHILD0000000000000000000001", + ) + + +# =========================================================================== +# ProjectResourceLinkRepository steps +# =========================================================================== + + +@given( + "a project resource link repository with session that passes query but raises IntegrityError on flush" +) +def step_link_repo_integrity(context: Context) -> None: + mock = _mock_session_integrity_on_flush() + context.repo_under_test = ProjectResourceLinkRepository( + session_factory=lambda: mock + ) + context.repo_error = None + + +@given( + "a project resource link repository with session raising OperationalError on flush" +) +def step_link_repo_op_error_flush(context: Context) -> None: + mock = _mock_session_op_error_on_flush() + context.repo_under_test = ProjectResourceLinkRepository( + session_factory=lambda: mock + ) + context.repo_error = None + + +@given( + "a project resource link repository with session raising OperationalError on query" +) +def step_link_repo_op_error_query(context: Context) -> None: + mock = _mock_session_op_error_on_query() + context.repo_under_test = ProjectResourceLinkRepository( + session_factory=lambda: mock + ) + context.repo_error = None + + +@when("a project resource link is created and a repo error is expected") +def step_link_create(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.create_link, + "local/test-project", + "01HGZ6FE0A0000000000000001", + ) + + +@when("project resource links are listed and a repo error is expected") +def step_link_list(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.list_links, + "local/test-project", + ) + + +@when("a project resource link is fetched and a repo error is expected") +def step_link_get(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.get_link, + "01HGZ6FE0A0000000000000001", + ) + + +@when("a project resource link is removed and a repo error is expected") +def step_link_remove(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.remove_link, + "01HGZ6FE0A0000000000000001", + ) + + +# =========================================================================== +# ValidationAttachmentRepository steps +# =========================================================================== + + +@given( + "a validation attachment repository with session that passes query but raises UNIQUE IntegrityError on flush" +) +def step_val_repo_unique_integrity(context: Context) -> None: + mock = _mock_session_unique_integrity_on_flush() + context.repo_under_test = ValidationAttachmentRepository( + session_factory=lambda: mock + ) + context.repo_error = None + + +@given( + "a validation attachment repository with session that passes query but raises non-UNIQUE IntegrityError on flush" +) +def step_val_repo_non_unique_integrity(context: Context) -> None: + mock = _mock_session_non_unique_integrity_on_flush() + context.repo_under_test = ValidationAttachmentRepository( + session_factory=lambda: mock + ) + context.repo_error = None + + +@given( + "a validation attachment repository with session raising OperationalError on flush" +) +def step_val_repo_op_error_flush(context: Context) -> None: + mock = _mock_session_op_error_on_flush() + context.repo_under_test = ValidationAttachmentRepository( + session_factory=lambda: mock + ) + context.repo_error = None + + +@given( + "a validation attachment repository with session raising OperationalError on query" +) +def step_val_repo_op_error_query(context: Context) -> None: + mock = _mock_session_op_error_on_query() + context.repo_under_test = ValidationAttachmentRepository( + session_factory=lambda: mock + ) + context.repo_error = None + + +@when("a validation is attached and a repo error is expected") +def step_val_attach(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.attach, + "01HGZ6FE0A0000000000000001", + "local/test-validation", + ) + + +@when("a validation is detached and a repo error is expected") +def step_val_detach(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.detach, + "01HGZ6FE0A0000000000000001", + "local/test-validation", + ) + + +@when("validation attachments are listed and a repo error is expected") +def step_val_list(context: Context) -> None: + _capture_error( + context, + context.repo_under_test.list_for_resource, + "01HGZ6FE0A0000000000000001", + ) + + +# =========================================================================== +# LifecyclePlanRepository steps +# =========================================================================== + + +@given( + "a lifecycle plan repository with session raising non-UNIQUE IntegrityError on flush" +) +def step_plan_repo_non_unique_integrity(context: Context) -> None: + mock = _mock_session_non_unique_integrity_on_flush() + context.repo_under_test = LifecyclePlanRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a lifecycle plan repository with session raising OperationalError on flush") +def step_plan_repo_op_error_flush(context: Context) -> None: + mock = _mock_session_op_error_on_flush() + context.repo_under_test = LifecyclePlanRepository(session_factory=lambda: mock) + context.repo_error = None + + +@given("a lifecycle plan repository with session raising OperationalError on query") +def step_plan_repo_op_error_query(context: Context) -> None: + mock = _mock_session_op_error_on_query() + context.repo_under_test = LifecyclePlanRepository(session_factory=lambda: mock) + context.repo_error = None + + +@when("a lifecycle plan is created and a repo error is expected") +def step_plan_create(context: Context) -> None: + plan = _make_fake_plan() + _capture_error(context, context.repo_under_test.create, plan) + + +@when("a lifecycle plan is fetched by ID and a repo error is expected") +def step_plan_get(context: Context) -> None: + _capture_error(context, context.repo_under_test.get, "01HGZ6FE0A0000000000000099") + + +@when("a lifecycle plan is fetched by name and a repo error is expected") +def step_plan_get_by_name(context: Context) -> None: + _capture_error(context, context.repo_under_test.get_by_name, "local/test-plan") + + +@when("a lifecycle plan is updated and a repo error is expected") +def step_plan_update(context: Context) -> None: + plan = _make_fake_plan() + _capture_error(context, context.repo_under_test.update, plan) + + +@when("a lifecycle plan is deleted and a repo error is expected") +def step_plan_delete(context: Context) -> None: + _capture_error( + context, context.repo_under_test.delete, "01HGZ6FE0A0000000000000099" + ) + + +@when("lifecycle plans are listed and a repo error is expected") +def step_plan_list(context: Context) -> None: + _capture_error(context, context.repo_under_test.list_plans) + + +@when("lifecycle plans are counted and a repo error is expected") +def step_plan_count(context: Context) -> None: + _capture_error(context, context.repo_under_test.count) + + +# =========================================================================== +# Common assertion steps (unique to this file) +# =========================================================================== + + +@then('a repo DatabaseError should be raised containing "{fragment}"') +def step_check_repo_db_error(context: Context, fragment: str) -> None: + assert context.repo_error is not None, ( + "Expected a DatabaseError but no error was raised" + ) + assert isinstance(context.repo_error, DatabaseError), ( + f"Expected DatabaseError, got {type(context.repo_error).__name__}: " + f"{context.repo_error}" + ) + assert fragment in str(context.repo_error), ( + f"Expected '{fragment}' in error message, got: {context.repo_error}" + ) + + +@then("a repo DuplicateToolError should be raised") +def step_check_duplicate_tool_error(context: Context) -> None: + assert context.repo_error is not None, ( + "Expected a DuplicateToolError but no error was raised" + ) + assert isinstance(context.repo_error, DuplicateToolError), ( + f"Expected DuplicateToolError, got {type(context.repo_error).__name__}: " + f"{context.repo_error}" + ) + + +@then("a repo ToolNotFoundError should be raised") +def step_check_tool_not_found_error(context: Context) -> None: + assert context.repo_error is not None, ( + "Expected a ToolNotFoundError but no error was raised" + ) + assert isinstance(context.repo_error, ToolNotFoundError), ( + f"Expected ToolNotFoundError, got {type(context.repo_error).__name__}: " + f"{context.repo_error}" + ) + + +@then("a repo DuplicateResourceError should be raised") +def step_check_duplicate_resource_error(context: Context) -> None: + assert context.repo_error is not None, ( + "Expected a DuplicateResourceError but no error was raised" + ) + assert isinstance(context.repo_error, DuplicateResourceError), ( + f"Expected DuplicateResourceError, got {type(context.repo_error).__name__}: " + f"{context.repo_error}" + ) + + +@then("a repo DuplicateValidationAttachmentError should be raised") +def step_check_duplicate_val_attachment_error(context: Context) -> None: + assert context.repo_error is not None, ( + "Expected a DuplicateValidationAttachmentError but no error was raised" + ) + assert isinstance(context.repo_error, DuplicateValidationAttachmentError), ( + f"Expected DuplicateValidationAttachmentError, " + f"got {type(context.repo_error).__name__}: {context.repo_error}" + ) diff --git a/features/steps/repositories_uncovered_lines_steps.py b/features/steps/repositories_uncovered_lines_steps.py new file mode 100644 index 00000000..7087af35 --- /dev/null +++ b/features/steps/repositories_uncovered_lines_steps.py @@ -0,0 +1,1483 @@ +"""Step definitions for repositories_uncovered_lines.feature. + +Targets the remaining ~84 uncovered lines in repositories.py. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +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.exc import IntegrityError, OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from cleveragents.infrastructure.database.models import ( + Base, + ResourceLinkModel, + ResourceModel, + ResourceTypeModel, + ToolBindingModel, + ToolModel, +) +from cleveragents.infrastructure.database.repositories import ( + AutomationProfileNotFoundError, + AutomationProfileRepository, + AutomationProfileSchemaVersionError, + DatabaseError, + DuplicateAutomationProfileError, + DuplicateResourceTypeError, + DuplicateValidationAttachmentError, + InvalidToolTypeError, + LinkNotFoundError, + ResourceNotFoundRepoError, + ResourceRepository, + ResourceTypeRepository, + ToolRepository, + ValidationAttachmentRepository, +) + +# ── helpers ──────────────────────────────────────────────────────────────── + + +def _make_engine_and_session(): + """Create an in-memory SQLite engine + session factory.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + return engine, factory + + +def _now_iso() -> str: + return datetime.now(tz=UTC).isoformat() + + +def _make_rt_model_row( + name: str, + description: str = "test type", + resource_kind: str = "physical", + sandbox_strategy: str = "none", + user_addable: bool = True, + allowed_child_types_json: str = "[]", + auto_discover_json: str | None = None, + equivalence_json: str | None = None, + capabilities_json: str = '{"read": true, "write": true, "sandbox": true, "checkpoint": false}', +) -> ResourceTypeModel: + """Create a ResourceTypeModel with correct column names.""" + now = _now_iso() + ns = name.split("/")[0] if "/" in name else "builtin" + return ResourceTypeModel( + name=name, + namespace=ns, + description=description, + resource_kind=resource_kind, + sandbox_strategy=sandbox_strategy, + user_addable=user_addable, + handler_ref=None, + args_schema_json=None, + allowed_parent_types_json=None, + allowed_child_types_json=allowed_child_types_json, + auto_discover_json=auto_discover_json, + equivalence_json=equivalence_json, + capabilities_json=capabilities_json, + source=None, + created_at=now, + updated_at=now, + ) + + +def _make_automation_profile( + name: str, + description: str = "test", + schema_version: str = "1.0", + **overrides: Any, +) -> Any: + """Create an AutomationProfile domain object.""" + from cleveragents.domain.models.core.automation_profile import AutomationProfile + + defaults = dict( + name=name, + description=description, + schema_version=schema_version, + auto_strategize=0.0, + auto_execute=0.0, + auto_apply=0.0, + auto_decisions_strategize=0.0, + auto_decisions_execute=0.0, + auto_validation_fix=0.0, + auto_strategy_revision=0.0, + auto_reversion_from_apply=0.0, + auto_child_plans=0.0, + auto_retry_transient=0.0, + auto_checkpoint_restore=0.0, + require_sandbox=True, + require_checkpoints=True, + allow_unsafe_tools=False, + ) + defaults.update(overrides) + return AutomationProfile(**defaults) + + +# ── LinkNotFoundError ────────────────────────────────────────────────────── + + +@given('a LinkNotFoundError for parent "{pid}" and child "{cid}"') +def step_given_link_not_found_error(context, pid, cid): + context.link_error = LinkNotFoundError(pid, cid) + + +@then('the LinkNotFoundError message should contain "{pid}" and "{cid}"') +def step_then_link_error_message(context, pid, cid): + msg = str(context.link_error) + assert pid in msg, f"Expected '{pid}' in '{msg}'" + assert cid in msg, f"Expected '{cid}' in '{msg}'" + + +@then('the LinkNotFoundError parent_id should be "{pid}"') +def step_then_link_error_parent(context, pid): + assert context.link_error.parent_id == pid + + +@then('the LinkNotFoundError child_id should be "{cid}"') +def step_then_link_error_child(context, cid): + assert context.link_error.child_id == cid + + +# ── ResourceTypeRepository helpers ───────────────────────────────────────── + + +@given("a resource type repository backed by an in-memory database for uncovered lines") +def step_given_rt_repo_inmem(context): + engine, factory = _make_engine_and_session() + context._ucl_engine = engine + context._ucl_session_factory = factory + context._ucl_rt_repo = ResourceTypeRepository(factory) + context._ucl_res_repo = ResourceRepository(factory) + + +def _make_resource_type_spec(name: str, **kw: Any) -> Any: + """Create a ResourceTypeSpec domain object.""" + from cleveragents.domain.models.core.resource_type import ( + ResourceKind, + ResourceTypeSpec, + SandboxStrategy, + ) + + defaults = dict( + name=name, + description="test type", + resource_kind=ResourceKind.PHYSICAL, + sandbox_strategy=SandboxStrategy.NONE, + user_addable=True, + cli_args=[], + parent_types=[], + child_types=[], + auto_discovery=None, + equivalence=None, + handler=None, + capabilities={ + "read": True, + "write": True, + "sandbox": True, + "checkpoint": False, + }, + built_in=False, + ) + defaults.update(kw) + return ResourceTypeSpec(**defaults) + + +@given('a resource type "{name}" exists in the database for uncovered lines') +def step_given_rt_exists(context, name): + spec = _make_resource_type_spec(name) + context._ucl_rt_repo.create(spec) + + +@when('the same resource type "{name}" is created again for uncovered lines') +def step_when_rt_dup_create(context, name): + spec = _make_resource_type_spec(name) + try: + context._ucl_rt_repo.create(spec) + context._ucl_error = None + except DuplicateResourceTypeError as exc: + context._ucl_error = exc + except Exception as exc: + context._ucl_error = exc + + +@then("a DuplicateResourceTypeError should be raised for uncovered lines") +def step_then_dup_rt_error(context): + assert isinstance(context._ucl_error, DuplicateResourceTypeError), ( + f"Expected DuplicateResourceTypeError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── ResourceTypeRepository._to_domain equivalence_json ────────────────────── + + +@given("a resource type row with equivalence_json set to '{json_str}'") +def step_given_rt_with_equiv(context, json_str): + # Insert a resource type row directly with equivalence_json set + session = context._ucl_session_factory() + now = _now_iso() + row = ResourceTypeModel( + name="test/equiv-type", + namespace="test", + description="type with equivalence", + resource_kind="physical", + sandbox_strategy="none", + user_addable=True, + handler_ref=None, + args_schema_json=None, + allowed_parent_types_json=None, + allowed_child_types_json="[]", + auto_discover_json=None, + equivalence_json=json_str, + capabilities_json='{"read": true, "write": true, "sandbox": true, "checkpoint": false}', + source=None, + created_at=now, + updated_at=now, + ) + session.add(row) + session.commit() + + +@when('the resource type is retrieved by name "{name}" for uncovered lines') +def step_when_rt_get_by_name(context, name): + context._ucl_rt_result = context._ucl_rt_repo.get(name) + + +@then('the resource type equivalence should contain key "{key}"') +def step_then_rt_equiv_key(context, key): + result = context._ucl_rt_result + assert result is not None, "Resource type was None" + equiv = result.equivalence + assert equiv is not None, "Equivalence was None" + assert key in equiv, f"Expected key '{key}' in equivalence {equiv}" + + +# ── ResourceRepository helpers ───────────────────────────────────────────── + + +@given("a resource repository backed by an in-memory database for uncovered lines") +def step_given_res_repo_inmem(context): + if not hasattr(context, "_ucl_engine"): + engine, factory = _make_engine_and_session() + context._ucl_engine = engine + context._ucl_session_factory = factory + context._ucl_rt_repo = ResourceTypeRepository(factory) + context._ucl_res_repo = ResourceRepository(context._ucl_session_factory) + + +def _ensure_resource_type(context, type_name: str = "test/default-type"): + """Ensure a resource type exists.""" + session = context._ucl_session_factory() + existing = session.query(ResourceTypeModel).filter_by(name=type_name).first() + if not existing: + now = _now_iso() + ns = type_name.split("/")[0] if "/" in type_name else "builtin" + row = ResourceTypeModel( + name=type_name, + namespace=ns, + description="test", + resource_kind="physical", + sandbox_strategy="none", + user_addable=True, + handler_ref=None, + args_schema_json=None, + allowed_parent_types_json=None, + allowed_child_types_json=None, + auto_discover_json=None, + capabilities_json='{"read": true, "write": true, "sandbox": true, "checkpoint": false}', + equivalence_json=None, + source=None, + created_at=now, + updated_at=now, + ) + session.add(row) + session.commit() + + +def _create_resource_row( + context, + resource_id: str | None = None, + type_name: str = "test/default-type", + namespace: str | None = None, + name: str | None = None, + sandbox_strategy: str | None = None, +) -> str: + """Insert a resource row directly and return its ID.""" + from ulid import ULID as _ULID + + if resource_id is None: + resource_id = str(_ULID()) + _ensure_resource_type(context, type_name) + session = context._ucl_session_factory() + now = _now_iso() + row = ResourceModel( + resource_id=resource_id, + namespaced_name=name, + namespace=namespace, + type_name=type_name, + resource_kind="physical", + location=None, + description="test resource", + read_only=False, + auto_discovered=False, + sandbox_strategy=sandbox_strategy, + content_hash=None, + properties_json=None, + metadata_json=None, + created_at=now, + updated_at=now, + ) + session.add(row) + session.commit() + return resource_id + + +# ── list_resources namespace filter ───────────────────────────────────────── + + +@given('resources exist in namespace "{ns1}" and "{ns2}" for uncovered lines') +def step_given_resources_in_namespaces(context, ns1, ns2): + from ulid import ULID + + _create_resource_row(context, str(ULID()), namespace=ns1, name=f"{ns1}/res-a") + _create_resource_row(context, str(ULID()), namespace=ns1, name=f"{ns1}/res-b") + _create_resource_row(context, str(ULID()), namespace=ns2, name=f"{ns2}/res-a") + + +@when('resources are listed with namespace "{ns}" for uncovered lines') +def step_when_list_by_namespace(context, ns): + context._ucl_listed = context._ucl_res_repo.list_resources(namespace=ns) + + +@then('only resources from namespace "{ns}" should be returned') +def step_then_only_namespace_resources(context, ns): + resources = context._ucl_listed + assert len(resources) > 0, "Expected at least one resource" + for r in resources: + name = r.name or "" + assert name.startswith(ns), f"Resource '{name}' not in namespace '{ns}'" + + +# ── unlink_child - parent not found ──────────────────────────────────────── + + +@when( + 'unlink_child is called with non-existent parent "{pid}" and child "{cid}" for uncovered lines' +) +def step_when_unlink_parent_missing(context, pid, cid): + try: + context._ucl_res_repo.unlink_child(pid, cid) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("a ResourceNotFoundRepoError should be raised for the unlink parent") +def step_then_unlink_parent_error(context): + assert isinstance(context._ucl_error, ResourceNotFoundRepoError), ( + f"Expected ResourceNotFoundRepoError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── unlink_child - child not found ──────────────────────────────────────── + + +@given('a resource "{rid}" exists for uncovered unlink tests') +def step_given_resource_for_unlink(context, rid): + _create_resource_row(context, rid) + context._ucl_unlink_parent_id = rid + + +@when( + 'unlink_child is called with existing parent and non-existent child "{cid}" for uncovered lines' +) +def step_when_unlink_child_missing(context, cid): + try: + context._ucl_res_repo.unlink_child(context._ucl_unlink_parent_id, cid) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("a ResourceNotFoundRepoError should be raised for the unlink child") +def step_then_unlink_child_error(context): + assert isinstance(context._ucl_error, ResourceNotFoundRepoError), ( + f"Expected ResourceNotFoundRepoError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── unlink_child - link not found ───────────────────────────────────────── + + +@given('two resources "{pid}" and "{cid}" exist but are not linked for uncovered lines') +def step_given_two_unlinked_resources(context, pid, cid): + _create_resource_row(context, pid) + _create_resource_row(context, cid) + context._ucl_unlink_pid = pid + context._ucl_unlink_cid = cid + + +@when('unlink_child is called for "{pid}" and "{cid}" for uncovered lines') +def step_when_unlink_no_link(context, pid, cid): + try: + context._ucl_res_repo.unlink_child(pid, cid) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("a LinkNotFoundError should be raised for uncovered lines") +def step_then_link_not_found_error(context): + assert isinstance(context._ucl_error, LinkNotFoundError), ( + f"Expected LinkNotFoundError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── unlink_child - re-raise domain errors ───────────────────────────────── + + +@when( + 'unlink_child is called with non-existent parent "{pid}" and child "{cid}" for uncovered lines re-raise' +) +def step_when_unlink_reraise(context, pid, cid): + try: + context._ucl_res_repo.unlink_child(pid, cid) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("a ResourceNotFoundRepoError should be raised and re-raised for uncovered lines") +def step_then_unlink_reraise(context): + assert isinstance(context._ucl_error, ResourceNotFoundRepoError), ( + f"Expected ResourceNotFoundRepoError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── auto_discover_children - type_row is None ───────────────────────────── + + +@given( + 'a resource with type "missing-type" that has no type row in DB for uncovered lines' +) +def step_given_res_missing_type(context): + # Insert resource with a type_name that has no matching ResourceTypeModel + session = context._ucl_session_factory() + now = _now_iso() + row = ResourceModel( + resource_id="auto-disc-no-type", + namespaced_name=None, + namespace=None, + type_name="missing-type", + resource_kind="physical", + location=None, + description="test", + read_only=False, + auto_discovered=False, + sandbox_strategy=None, + content_hash=None, + properties_json=None, + metadata_json=None, + created_at=now, + updated_at=now, + ) + session.add(row) + session.commit() + context._ucl_auto_disc_id = "auto-disc-no-type" + + +@when("auto_discover_children is called for that resource for uncovered lines") +def step_when_auto_disc_no_type(context): + context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children( + context._ucl_auto_disc_id + ) + + +@then("an empty list should be returned from auto_discover for uncovered lines") +def step_then_auto_disc_empty(context): + assert context._ucl_auto_disc_result == [], ( + f"Expected empty list, got {context._ucl_auto_disc_result}" + ) + + +# ── auto_discover_children - null auto_discover_json ────────────────────── + + +@given("a resource with type that has null auto_discover_json for uncovered lines") +def step_given_res_null_auto_disc(context): + _ensure_resource_type(context, "test/null-auto-disc") + _create_resource_row(context, "auto-disc-null", type_name="test/null-auto-disc") + context._ucl_auto_disc_id_null = "auto-disc-null" + + +@when( + "auto_discover_children is called for that typed resource for uncovered lines null" +) +def step_when_auto_disc_null(context): + context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children( + context._ucl_auto_disc_id_null + ) + + +# ── auto_discover_children - disabled ───────────────────────────────────── + + +@given("a resource with type that has auto_discover disabled for uncovered lines") +def step_given_res_disabled_auto_disc(context): + session = context._ucl_session_factory() + now = _now_iso() + row = ResourceTypeModel( + name="test/disabled-auto-disc", + namespace="test", + description="disabled auto-disc", + resource_kind="physical", + sandbox_strategy="none", + user_addable=True, + handler_ref=None, + args_schema_json=None, + allowed_parent_types_json=None, + allowed_child_types_json="[]", + auto_discover_json=json.dumps({"enabled": False, "rules": []}), + equivalence_json=None, + capabilities_json='{"read": true, "write": true, "sandbox": true, "checkpoint": false}', + source=None, + created_at=now, + updated_at=now, + ) + session.add(row) + session.commit() + _create_resource_row( + context, "auto-disc-disabled", type_name="test/disabled-auto-disc" + ) + context._ucl_auto_disc_id_disabled = "auto-disc-disabled" + + +@when( + "auto_discover_children is called for that typed resource for uncovered lines disabled" +) +def step_when_auto_disc_disabled(context): + context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children( + context._ucl_auto_disc_id_disabled + ) + + +# ── auto_discover_children - empty rules ────────────────────────────────── + + +@given( + "a resource with type that has auto_discover enabled but empty rules for uncovered lines" +) +def step_given_res_empty_rules(context): + session = context._ucl_session_factory() + _now_iso() + row = _make_rt_model_row( + "test/empty-rules", + description="empty rules", + auto_discover_json=json.dumps({"enabled": True, "rules": []}), + ) + session.add(row) + session.commit() + _create_resource_row(context, "auto-disc-empty-rules", type_name="test/empty-rules") + context._ucl_auto_disc_id_empty = "auto-disc-empty-rules" + + +@when( + "auto_discover_children is called for that typed resource for uncovered lines empty rules" +) +def step_when_auto_disc_empty_rules(context): + context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children( + context._ucl_auto_disc_id_empty + ) + + +# ── auto_discover_children - child type not in DB ───────────────────────── + + +@given( + "a resource with auto_discover rules referencing non-existent child type for uncovered lines" +) +def step_given_res_no_child_type(context): + session = context._ucl_session_factory() + _now_iso() + row = _make_rt_model_row( + "test/no-child-type", + description="missing child type", + auto_discover_json=json.dumps( + { + "enabled": True, + "rules": [{"type": "nonexistent/child-type"}], + } + ), + ) + session.add(row) + session.commit() + _create_resource_row(context, "auto-disc-no-child", type_name="test/no-child-type") + context._ucl_auto_disc_id_no_child = "auto-disc-no-child" + + +@when( + "auto_discover_children is called for that resource with missing child type for uncovered lines" +) +def step_when_auto_disc_no_child(context): + context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children( + context._ucl_auto_disc_id_no_child + ) + + +# ── auto_discover_children - child type not in allowed list ─────────────── + + +@given( + "a resource with auto_discover rules where child type is not in allowed list for uncovered lines" +) +def step_given_res_disallowed_child(context): + session = context._ucl_session_factory() + _now_iso() + # Create the child type in DB + child_type = _make_rt_model_row("test/disallowed-child", description="child type") + session.add(child_type) + session.commit() + + # Create parent type that allows only "test/other-child", not "test/disallowed-child" + session2 = context._ucl_session_factory() + parent_type = _make_rt_model_row( + "test/disallow-parent", + description="parent type disallowing child", + allowed_child_types_json=json.dumps(["test/other-child"]), + auto_discover_json=json.dumps( + { + "enabled": True, + "rules": [{"type": "test/disallowed-child"}], + } + ), + ) + session2.add(parent_type) + session2.commit() + + _create_resource_row( + context, "auto-disc-disallowed", type_name="test/disallow-parent" + ) + context._ucl_auto_disc_id_disallowed = "auto-disc-disallowed" + + +@when( + "auto_discover_children is called for that resource with disallowed child type for uncovered lines" +) +def step_when_auto_disc_disallowed(context): + context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children( + context._ucl_auto_disc_id_disallowed + ) + + +# ── auto_discover_children - successful discovery ───────────────────────── + + +@given( + "a resource with auto_discover rules that match an existing child type for uncovered lines" +) +def step_given_res_full_discovery(context): + session = context._ucl_session_factory() + _now_iso() + # Create the child type + existing_ct = ( + session.query(ResourceTypeModel).filter_by(name="test/valid-child").first() + ) + if not existing_ct: + child_type = _make_rt_model_row( + "test/valid-child", description="valid child type" + ) + session.add(child_type) + session.commit() + + session2 = context._ucl_session_factory() + existing_pt = ( + session2.query(ResourceTypeModel).filter_by(name="test/disc-parent").first() + ) + if not existing_pt: + parent_type = _make_rt_model_row( + "test/disc-parent", + description="parent for discovery", + allowed_child_types_json=json.dumps(["test/valid-child"]), + auto_discover_json=json.dumps( + { + "enabled": True, + "rules": [{"type": "test/valid-child"}], + } + ), + ) + session2.add(parent_type) + session2.commit() + + _create_resource_row(context, "auto-disc-full", type_name="test/disc-parent") + context._ucl_auto_disc_full_id = "auto-disc-full" + + +@when( + "auto_discover_children is called for that resource for full discovery for uncovered lines" +) +def step_when_auto_disc_full(context): + context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children( + context._ucl_auto_disc_full_id + ) + + +@then("the discovered children list should not be empty for uncovered lines") +def step_then_discovered_not_empty(context): + assert len(context._ucl_auto_disc_result) > 0, ( + "Expected non-empty discovered children" + ) + + +@then("the child resource should be linked to the parent for uncovered lines") +def step_then_child_linked(context): + session = context._ucl_session_factory() + links = ( + session.query(ResourceLinkModel) + .filter_by(parent_id=context._ucl_auto_disc_full_id) + .all() + ) + assert len(links) > 0, "Expected at least one link from parent to child" + + +# ── auto_discover_children - OperationalError ───────────────────────────── + + +@given( + "a resource repository with session raising OperationalError on auto_discover for uncovered lines" +) +def step_given_res_repo_op_error_auto_disc(context): + call_count = 0 + + def mock_session_factory(): + nonlocal call_count + call_count += 1 + mock_session = MagicMock(spec=Session) + # First query returns the resource row + parent_row = MagicMock() + parent_row.type_name = "some-type" + parent_row.resource_id = "res-op-err" + + type_row = MagicMock() + type_row.auto_discover_json = json.dumps( + {"enabled": True, "rules": [{"type": "child-t"}]} + ) + type_row.allowed_child_types_json = "[]" + + # query().filter_by().first() returns parent_row first, then type_row, then raises + query_mock = MagicMock() + filter_mock = MagicMock() + filter_mock.first.side_effect = [ + parent_row, + type_row, + OperationalError("db fail", {}, None), + ] + query_mock.filter_by.return_value = filter_mock + mock_session.query.return_value = query_mock + mock_session.rollback = MagicMock() + return mock_session + + context._ucl_res_repo = ResourceRepository(mock_session_factory) + context._ucl_auto_disc_op_err_id = "res-op-err" + + +@when( + "auto_discover_children is called and an OperationalError occurs for uncovered lines" +) +def step_when_auto_disc_op_error(context): + try: + context._ucl_res_repo.auto_discover_children(context._ucl_auto_disc_op_err_id) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("a DatabaseError should be raised mentioning auto-discover for uncovered lines") +def step_then_auto_disc_db_error(context): + assert isinstance(context._ucl_error, DatabaseError), ( + f"Expected DatabaseError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + assert ( + "auto-discover" in str(context._ucl_error).lower() + or "auto_discover" in str(context._ucl_error).lower() + ), f"Expected 'auto-discover' in message: {context._ucl_error}" + + +# ── _get_ancestors - diamond graph ──────────────────────────────────────── + + +@given("resources forming a diamond graph for ancestor traversal for uncovered lines") +def step_given_diamond_graph(context): + # Create: A -> B, A -> C, B -> D, C -> D (diamond) + # _get_ancestors(D) should visit D, B, C, A + for rid in ["anc-A", "anc-B", "anc-C", "anc-D"]: + _create_resource_row(context, rid) + + session = context._ucl_session_factory() + now = _now_iso() + links = [ + ResourceLinkModel(parent_id="anc-A", child_id="anc-B", created_at=now), + ResourceLinkModel(parent_id="anc-A", child_id="anc-C", created_at=now), + ResourceLinkModel(parent_id="anc-B", child_id="anc-D", created_at=now), + ResourceLinkModel(parent_id="anc-C", child_id="anc-D", created_at=now), + ] + for link in links: + session.add(link) + session.commit() + context._ucl_ancestor_id = "anc-D" + + +@when("_get_ancestors is called on the bottom resource for uncovered lines") +def step_when_get_ancestors(context): + session = context._ucl_session_factory() + context._ucl_ancestors = ResourceRepository._get_ancestors( + session, context._ucl_ancestor_id + ) + + +@then( + "all ancestor IDs should be returned including the bottom resource for uncovered lines" +) +def step_then_ancestors_returned(context): + expected = {"anc-A", "anc-B", "anc-C", "anc-D"} + assert context._ucl_ancestors == expected, ( + f"Expected {expected}, got {context._ucl_ancestors}" + ) + + +# ── ResourceRepository._to_domain - sandbox_strategy ───────────────────── + + +@given('a resource row with sandbox_strategy set to "git_worktree" for uncovered lines') +def step_given_res_with_sandbox(context): + from ulid import ULID + + rid = str(ULID()) + _create_resource_row( + context, + rid, + sandbox_strategy="git_worktree", + ) + context._ucl_sandbox_id = rid + + +@when("the resource is retrieved by ID for uncovered lines sandbox") +def step_when_res_get_sandbox(context): + context._ucl_sandbox_result = context._ucl_res_repo.get(context._ucl_sandbox_id) + + +@then("the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE") +def step_then_sandbox_strategy(context): + from cleveragents.domain.models.core.resource import SandboxStrategy + + result = context._ucl_sandbox_result + assert result is not None, "Resource was None" + assert result.sandbox_strategy == SandboxStrategy.GIT_WORKTREE, ( + f"Expected GIT_WORKTREE, got {result.sandbox_strategy}" + ) + + +# ── ToolRepository helpers ──────────────────────────────────────────────── + + +@given("a tool repository backed by an in-memory database for uncovered lines") +def step_given_tool_repo_inmem(context): + engine, factory = _make_engine_and_session() + context._ucl_tool_engine = engine + context._ucl_tool_session_factory = factory + context._ucl_tool_repo = ToolRepository(factory) + + +def _make_tool_domain( + name: str = "test/my-tool", + tool_type: str = "tool", + source: str = "builtin", + input_schema: dict | None = None, + output_schema: dict | None = None, + resource_slots: list | None = None, +) -> Any: + """Create a mock tool domain object.""" + tool = MagicMock() + tool.name = name + tool.description = "A test tool" + + # tool_type enum-like + tt = MagicMock() + tt.value = tool_type + tool.tool_type = tt + + # source enum-like + src = MagicMock() + src.value = source + tool.source = src + + tool.input_schema = input_schema + tool.output_schema = output_schema + tool.config_yaml = None + tool.tool_id = "" + + cap = MagicMock() + cap.read_only = False + cap.writes = True + cap.checkpointable = False + cap.side_effects = False + tool.capability = cap + + tool.resource_slots = resource_slots or [] + return tool + + +# ── ToolRepository.add - invalid tool_type ──────────────────────────────── + + +@given('a tool domain object with tool_type "bogus" for uncovered lines') +def step_given_tool_bad_type(context): + context._ucl_tool = _make_tool_domain(tool_type="bogus") + + +@when("the tool is added to the repository for uncovered lines invalid type") +def step_when_tool_add_invalid(context): + try: + context._ucl_tool_repo.add(context._ucl_tool) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("an InvalidToolTypeError should be raised for uncovered lines") +def step_then_invalid_tool_type(context): + assert isinstance(context._ucl_error, InvalidToolTypeError), ( + f"Expected InvalidToolTypeError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── ToolRepository.add - schemas + resource slots ───────────────────────── + + +@given("a tool domain object with schemas and resource slots for uncovered lines") +def step_given_tool_with_schemas(context): + slot = MagicMock() + slot.name = "my_slot" + slot.resource_type = "test/resource-type" + + binding = MagicMock() + binding.value = "contextual" + slot.binding = binding + + access = MagicMock() + access.value = "read_only" + slot.access = access + + context._ucl_tool = _make_tool_domain( + name="test/schema-tool", + input_schema={"type": "object", "properties": {"x": {"type": "string"}}}, + output_schema={"type": "object", "properties": {"y": {"type": "integer"}}}, + resource_slots=[slot], + ) + + +@when("the tool is added to the repository for uncovered lines with schemas") +def step_when_tool_add_schemas(context): + context._ucl_tool_id = context._ucl_tool_repo.add(context._ucl_tool) + + +@then( + "the tool should be retrievable and have schemas and bindings for uncovered lines" +) +def step_then_tool_schemas_bindings(context): + session = context._ucl_tool_session_factory() + row = session.query(ToolModel).filter_by(tool_id=context._ucl_tool_id).first() + assert row is not None, "Tool row not found" + assert row.input_schema is not None, "input_schema should not be None" + assert row.output_schema is not None, "output_schema should not be None" + + bindings = ( + session.query(ToolBindingModel).filter_by(tool_id=context._ucl_tool_id).all() + ) + assert len(bindings) == 1, f"Expected 1 binding, got {len(bindings)}" + assert bindings[0].slot_name == "my_slot" + assert bindings[0].binding_mode == "context" # contextual -> context mapping + + +# ── ToolRepository.get - by ULID ────────────────────────────────────────── + + +@given("a tool has been persisted and its ULID captured for uncovered lines") +def step_given_tool_persisted(context): + tool = _make_tool_domain(name="test/get-tool") + context._ucl_persisted_tool_id = context._ucl_tool_repo.add(tool) + + +@when("the tool is fetched by ULID for uncovered lines") +def step_when_tool_get_by_ulid(context): + context._ucl_tool_result = context._ucl_tool_repo.get( + context._ucl_persisted_tool_id + ) + + +@then("the tool domain object should be returned for uncovered lines") +def step_then_tool_returned(context): + assert context._ucl_tool_result is not None, "Tool was None" + assert context._ucl_tool_result.name == "test/get-tool" + + +@when('a tool is fetched by ULID "{ulid}" for uncovered lines') +def step_when_tool_get_unknown(context, ulid): + context._ucl_tool_result = context._ucl_tool_repo.get(ulid) + + +@then("None should be returned for the tool get for uncovered lines") +def step_then_tool_none(context): + assert context._ucl_tool_result is None, ( + f"Expected None, got {context._ucl_tool_result}" + ) + + +# ── ToolRepository._to_domain - input/output schema ────────────────────── + + +@given("a tool with input_schema and output_schema stored as JSON for uncovered lines") +def step_given_tool_with_json_schemas(context): + tool = _make_tool_domain( + name="test/json-schema-tool", + input_schema={"type": "object"}, + output_schema={"type": "array"}, + ) + context._ucl_tool_repo.add(tool) + context._ucl_schema_tool_name = "test/json-schema-tool" + + +@when("the tool is retrieved by name for uncovered lines schemas") +def step_when_tool_get_by_name_schemas(context): + context._ucl_schema_tool = context._ucl_tool_repo.get_by_name( + context._ucl_schema_tool_name + ) + + +@then("the tool should have parsed input_schema and output_schema for uncovered lines") +def step_then_tool_parsed_schemas(context): + tool = context._ucl_schema_tool + assert tool is not None, "Tool was None" + assert tool.input_schema is not None, "input_schema should not be None" + assert tool.output_schema is not None, "output_schema should not be None" + assert isinstance(tool.input_schema, dict), ( + f"Expected dict, got {type(tool.input_schema)}" + ) + assert isinstance(tool.output_schema, dict), ( + f"Expected dict, got {type(tool.output_schema)}" + ) + + +# ── ValidationAttachment - duplicate detection ──────────────────────────── + + +@given( + "a validation attachment repository backed by an in-memory database for uncovered lines" +) +def step_given_va_repo(context): + engine, factory = _make_engine_and_session() + context._ucl_va_engine = engine + context._ucl_va_session_factory = factory + context._ucl_va_repo = ValidationAttachmentRepository(factory) + # We need a resource to attach to + _ensure_va_resource(context, factory) + + +def _ensure_va_resource(context, factory): + """Create a resource to use in validation attachment tests.""" + session = factory() + now = _now_iso() + # Resource type first + rt = _make_rt_model_row("test/va-type", description="for VA tests") + session.add(rt) + session.flush() + for rid in ["res-1", "res-2"]: + res = ResourceModel( + resource_id=rid, + namespaced_name=None, + namespace=None, + type_name="test/va-type", + resource_kind="physical", + location=None, + description="VA test resource", + read_only=False, + auto_discovered=False, + sandbox_strategy=None, + content_hash=None, + properties_json=None, + metadata_json=None, + created_at=now, + updated_at=now, + ) + session.add(res) + session.commit() + + +@given( + 'a validation "{vname}" is already attached to resource "{rid}" for uncovered lines' +) +def step_given_va_attached(context, vname, rid): + context._ucl_va_repo.attach(rid, vname) + context._ucl_va_rid = rid + context._ucl_va_vname = vname + + +@when( + 'the same validation "{vname}" is attached again to resource "{rid}" for uncovered lines' +) +def step_when_va_dup_attach(context, vname, rid): + try: + context._ucl_va_repo.attach(rid, vname) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then( + "a DuplicateValidationAttachmentError should be raised for uncovered lines attach" +) +def step_then_va_dup_error(context): + assert isinstance(context._ucl_error, DuplicateValidationAttachmentError), ( + f"Expected DuplicateValidationAttachmentError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── ValidationAttachment - re-raise ─────────────────────────────────────── + + +@when( + 'the same validation "{vname}" is attached again to resource "{rid}" for uncovered lines re-raise' +) +def step_when_va_dup_reraise(context, vname, rid): + try: + context._ucl_va_repo.attach(rid, vname) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("the DuplicateValidationAttachmentError should propagate for uncovered lines") +def step_then_va_dup_propagate(context): + assert isinstance(context._ucl_error, DuplicateValidationAttachmentError), ( + f"Expected DuplicateValidationAttachmentError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── AutomationProfileRepository ────────────────────────────────────────── + + +@given( + "an automation profile repository backed by an in-memory database for uncovered lines" +) +def step_given_ap_repo(context): + engine, factory = _make_engine_and_session() + context._ucl_ap_engine = engine + context._ucl_ap_session_factory = factory + context._ucl_ap_repo = AutomationProfileRepository(factory) + + +@when('a profile is fetched by name "{name}" for uncovered lines') +def step_when_ap_get(context, name): + context._ucl_ap_result = context._ucl_ap_repo.get_by_name(name) + + +@then("None should be returned for the profile get for uncovered lines") +def step_then_ap_none(context): + assert context._ucl_ap_result is None, ( + f"Expected None, got {context._ucl_ap_result}" + ) + + +# ── get_by_name returns domain ──────────────────────────────────────────── + + +@given('a profile "{name}" has been upserted for uncovered lines') +def step_given_ap_upserted(context, name): + profile = _make_automation_profile(name) + context._ucl_ap_repo.upsert(profile) + + +@then("the profile domain object should be returned for uncovered lines") +def step_then_ap_domain(context): + assert context._ucl_ap_result is not None, "Profile was None" + assert context._ucl_ap_result.name == "test-profile" + + +# ── list_all ────────────────────────────────────────────────────────────── + + +@given('profiles "{name1}" and "{name2}" have been upserted for uncovered lines') +def step_given_multiple_profiles(context, name1, name2): + for name in [name1, name2]: + profile = _make_automation_profile(name) + context._ucl_ap_repo.upsert(profile) + + +@when("all profiles are listed for uncovered lines") +def step_when_ap_list(context): + context._ucl_ap_list = context._ucl_ap_repo.list_all() + + +@then("two profile domain objects should be returned for uncovered lines") +def step_then_ap_list_count(context): + assert len(context._ucl_ap_list) == 2, ( + f"Expected 2, got {len(context._ucl_ap_list)}" + ) + + +# ── upsert update ──────────────────────────────────────────────────────── + + +@when( + 'the profile "{name}" is upserted again with changed description for uncovered lines' +) +def step_when_ap_update(context, name): + profile = _make_automation_profile(name, description="updated desc") + context._ucl_ap_repo.upsert(profile) + + +@then("the profile should have the updated description for uncovered lines") +def step_then_ap_updated_desc(context): + result = context._ucl_ap_repo.get_by_name("update-prof") + assert result is not None, "Profile was None" + assert result.description == "updated desc", ( + f"Expected 'updated desc', got '{result.description}'" + ) + + +# ── upsert schema version mismatch ────────────────────────────────────── + + +@given( + 'a profile "{name}" has been upserted with schema_version "{ver}" for uncovered lines' +) +def step_given_ap_versioned(context, name, ver): + profile = _make_automation_profile(name, schema_version=ver) + context._ucl_ap_repo.upsert(profile) + + +@when( + 'the profile "{name}" is upserted with expected_schema_version "{ver}" for uncovered lines' +) +def step_when_ap_version_mismatch(context, name, ver): + profile = _make_automation_profile(name, schema_version="1.0") + try: + context._ucl_ap_repo.upsert(profile, expected_schema_version=ver) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("an AutomationProfileSchemaVersionError should be raised for uncovered lines") +def step_then_ap_version_error(context): + assert isinstance(context._ucl_error, AutomationProfileSchemaVersionError), ( + f"Expected AutomationProfileSchemaVersionError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── upsert new insert ──────────────────────────────────────────────────── + + +@when('a new profile "{name}" is upserted for uncovered lines') +def step_when_ap_new_insert(context, name): + profile = _make_automation_profile(name, description="new profile") + context._ucl_ap_repo.upsert(profile) + + +@then('the profile "{name}" should be retrievable for uncovered lines') +def step_then_ap_retrievable(context, name): + result = context._ucl_ap_repo.get_by_name(name) + assert result is not None, f"Profile '{name}' was None" + assert result.name == name + + +# ── upsert IntegrityError ──────────────────────────────────────────────── + + +@given( + "an automation profile repository with session raising IntegrityError on flush for uncovered lines" +) +def step_given_ap_repo_integrity_error(context): + def mock_session_factory(): + mock_session = MagicMock(spec=Session) + query_mock = MagicMock() + filter_mock = MagicMock() + filter_mock.first.return_value = None # No existing row + query_mock.filter_by.return_value = filter_mock + mock_session.query.return_value = query_mock + mock_session.add = MagicMock() + mock_session.flush.side_effect = IntegrityError("UNIQUE constraint", {}, None) + mock_session.rollback = MagicMock() + return mock_session + + context._ucl_ap_repo = AutomationProfileRepository(mock_session_factory) + + +@when("a profile is upserted and IntegrityError occurs for uncovered lines") +def step_when_ap_integrity_error(context): + profile = _make_automation_profile("integrity-fail") + try: + context._ucl_ap_repo.upsert(profile) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("a DuplicateAutomationProfileError should be raised for uncovered lines") +def step_then_ap_dup_error(context): + assert isinstance(context._ucl_error, DuplicateAutomationProfileError), ( + f"Expected DuplicateAutomationProfileError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── delete not found ───────────────────────────────────────────────────── + + +@when('a profile "{name}" is deleted for uncovered lines') +def step_when_ap_delete(context, name): + try: + context._ucl_ap_repo.delete(name) + context._ucl_error = None + except Exception as exc: + context._ucl_error = exc + + +@then("an AutomationProfileNotFoundError should be raised for uncovered lines") +def step_then_ap_not_found(context): + assert isinstance(context._ucl_error, AutomationProfileNotFoundError), ( + f"Expected AutomationProfileNotFoundError, got {type(context._ucl_error)}: {context._ucl_error}" + ) + + +# ── delete success ──────────────────────────────────────────────────────── + + +@when('the profile "{name}" is deleted for uncovered lines') +def step_when_ap_delete_success(context, name): + context._ucl_ap_repo.delete(name) + + +@then('the profile "{name}" should no longer exist for uncovered lines') +def step_then_ap_deleted(context, name): + result = context._ucl_ap_repo.get_by_name(name) + assert result is None, f"Profile '{name}' still exists" + + +# ── _to_domain full fields ─────────────────────────────────────────────── + + +@given('a profile "{name}" with all fields set has been upserted for uncovered lines') +def step_given_ap_full_fields(context, name): + profile = _make_automation_profile( + name, + description="full fields profile", + schema_version="2.0", + auto_strategize=0.1, + auto_execute=0.2, + auto_apply=0.3, + auto_decisions_strategize=0.4, + auto_decisions_execute=0.5, + auto_validation_fix=0.6, + auto_strategy_revision=0.7, + auto_reversion_from_apply=0.8, + auto_child_plans=0.9, + auto_retry_transient=0.15, + auto_checkpoint_restore=0.25, + require_sandbox=False, + require_checkpoints=False, + allow_unsafe_tools=True, + ) + context._ucl_ap_repo.upsert(profile) + context._ucl_ap_full = profile + + +@then("all profile fields should match the original values for uncovered lines") +def step_then_ap_all_fields(context): + result = context._ucl_ap_result + orig = context._ucl_ap_full + assert result is not None, "Profile was None" + assert result.name == orig.name + assert result.description == orig.description + assert result.schema_version == orig.schema_version + assert abs(result.auto_strategize - orig.auto_strategize) < 0.001 + assert abs(result.auto_execute - orig.auto_execute) < 0.001 + assert abs(result.auto_apply - orig.auto_apply) < 0.001 + assert ( + abs(result.auto_decisions_strategize - orig.auto_decisions_strategize) < 0.001 + ) + assert abs(result.auto_decisions_execute - orig.auto_decisions_execute) < 0.001 + assert abs(result.auto_validation_fix - orig.auto_validation_fix) < 0.001 + assert abs(result.auto_strategy_revision - orig.auto_strategy_revision) < 0.001 + assert ( + abs(result.auto_reversion_from_apply - orig.auto_reversion_from_apply) < 0.001 + ) + assert abs(result.auto_child_plans - orig.auto_child_plans) < 0.001 + assert abs(result.auto_retry_transient - orig.auto_retry_transient) < 0.001 + assert abs(result.auto_checkpoint_restore - orig.auto_checkpoint_restore) < 0.001 + assert result.require_sandbox == orig.require_sandbox + assert result.require_checkpoints == orig.require_checkpoints + assert result.allow_unsafe_tools == orig.allow_unsafe_tools + + +# ── _from_domain + _update_row roundtrip ────────────────────────────────── + + +@given( + 'a profile "{name}" with specific field values has been upserted for uncovered lines' +) +def step_given_ap_specific_fields(context, name): + profile = _make_automation_profile( + name, + description="original", + auto_strategize=0.1, + auto_execute=0.2, + require_sandbox=True, + allow_unsafe_tools=False, + ) + context._ucl_ap_repo.upsert(profile) + + +@when( + 'the profile "{name}" is upserted again with different field values for uncovered lines' +) +def step_when_ap_update_fields(context, name): + profile = _make_automation_profile( + name, + description="updated roundtrip", + auto_strategize=0.9, + auto_execute=0.8, + auto_apply=0.7, + auto_decisions_strategize=0.6, + auto_decisions_execute=0.5, + auto_validation_fix=0.4, + auto_strategy_revision=0.3, + auto_reversion_from_apply=0.2, + auto_child_plans=0.1, + auto_retry_transient=0.05, + auto_checkpoint_restore=0.15, + require_sandbox=False, + require_checkpoints=False, + allow_unsafe_tools=True, + ) + context._ucl_ap_repo.upsert(profile) + context._ucl_ap_updated_vals = profile + + +@then('the profile "{name}" should have the new field values for uncovered lines') +def step_then_ap_new_values(context, name): + result = context._ucl_ap_repo.get_by_name(name) + expected = context._ucl_ap_updated_vals + assert result is not None, f"Profile '{name}' was None" + assert result.description == expected.description + assert abs(result.auto_strategize - expected.auto_strategize) < 0.001 + assert abs(result.auto_execute - expected.auto_execute) < 0.001 + assert abs(result.auto_apply - expected.auto_apply) < 0.001 + assert result.require_sandbox == expected.require_sandbox + assert result.require_checkpoints == expected.require_checkpoints + assert result.allow_unsafe_tools == expected.allow_unsafe_tools diff --git a/features/steps/resource_cli_coverage_boost_steps.py b/features/steps/resource_cli_coverage_boost_steps.py new file mode 100644 index 00000000..0bf83317 --- /dev/null +++ b/features/steps/resource_cli_coverage_boost_steps.py @@ -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}" + ) diff --git a/features/steps/resource_registry_service_coverage_steps.py b/features/steps/resource_registry_service_coverage_steps.py new file mode 100644 index 00000000..ed6aa63c --- /dev/null +++ b/features/steps/resource_registry_service_coverage_steps.py @@ -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" diff --git a/features/steps/tool_registry_service_coverage_steps.py b/features/steps/tool_registry_service_coverage_steps.py new file mode 100644 index 00000000..932d31a3 --- /dev/null +++ b/features/steps/tool_registry_service_coverage_steps.py @@ -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}'" + ) diff --git a/features/tool_registry_service_coverage.feature b/features/tool_registry_service_coverage.feature new file mode 100644 index 00000000..db429168 --- /dev/null +++ b/features/tool_registry_service_coverage.feature @@ -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"