fix(resource): align guard enforcement with service layer

ISSUES CLOSED: #6329
This commit is contained in:
2026-04-12 19:15:30 +00:00
parent 9c60ed1065
commit 39416fc225
3 changed files with 34 additions and 66 deletions
+10 -7
View File
@@ -50,18 +50,21 @@ Feature: Resource CLI coverage boost for remaining uncovered lines
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Error:"
# ---- resource_remove edge_count > 0 (lines 653-658) ----
# ---- resource_remove ValidationError bubble-up (service guard) ----
Scenario: resource_remove aborts when resource has edges
Given a mock resource service whose session reports edges on the resource
@resource_cli_guard
Scenario: resource_remove aborts when service rejects removal with ValidationError
Given a mock resource service whose remove_resource raises ValidationError for 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) ----
# ---- resource_remove unexpected exception surfaced to CLI ----
Scenario: resource_remove rolls back session on unexpected exception
Given a mock resource service whose session delete raises a generic exception for resource
@resource_cli_guard
Scenario: resource_remove aborts when service remove_resource raises an unexpected exception
Given a mock resource service whose remove_resource raises a generic exception for the 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
And the CliRunner output should contain "Unexpected error:"
And the mock service should have attempted to remove the resource
@@ -15,7 +15,6 @@ Covers the remaining uncovered lines and partial branches in
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]
@@ -72,20 +71,6 @@ def _make_mock_resource() -> MagicMock:
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
# ---------------------------------------------------------------------------
@@ -279,28 +264,29 @@ def step_invoke_type_remove_validation_error(context: Context) -> None:
# ---------------------------------------------------------------------------
# Scenario: resource_remove aborts when resource has edges
# Scenario: resource_remove aborts when service rejects removal with ValidationError
# ---------------------------------------------------------------------------
@given("a mock resource service whose session reports edges on the resource")
@given(
"a mock resource service whose remove_resource raises ValidationError for 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
context.rcb_mock_resource = 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.remove_resource.side_effect = ValidationError(
message=(
"Cannot remove resource 'local/mock-res': 3 edge(s) still reference it."
),
details={
"resource_id": mock_res.resource_id,
"link_count": 0,
"edge_count": 3,
},
)
svc._session.return_value = mock_session
context.rcb_mock_service = svc
@@ -321,42 +307,22 @@ def step_invoke_resource_remove_edges(context: Context) -> None:
# ---------------------------------------------------------------------------
# Scenario: resource_remove rolls back session on unexpected exception
# Scenario: resource_remove aborts when service remove_resource raises an unexpected exception
# ---------------------------------------------------------------------------
@given(
"a mock resource service whose session delete raises a generic exception for resource"
"a mock resource service whose remove_resource raises a generic exception for the 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
context.rcb_mock_resource = 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
svc.remove_resource.side_effect = RuntimeError("unexpected DB write failure")
context.rcb_mock_service = svc
context.rcb_mock_session = mock_session
@when("I invoke resource remove with --yes via CliRunner for the failing resource")
@@ -374,9 +340,10 @@ def step_invoke_resource_remove_exception(context: Context) -> None:
)
@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()
@then("the mock service should have attempted to remove the resource")
def step_verify_service_remove_called(context: Context) -> None:
expected_id = getattr(context.rcb_mock_resource, "resource_id", None)
context.rcb_mock_service.remove_resource.assert_called_once_with(expected_id)
# ---------------------------------------------------------------------------
+2 -4
View File
@@ -53,6 +53,7 @@ Based on ``implementation_plan.md`` -- Tasks B0.cli.resources, B1.cli.
from __future__ import annotations
import contextlib
import json
import logging
import re
@@ -713,11 +714,8 @@ def resource_add(
# --update: remove existing resource before re-registering
if update:
try:
with contextlib.suppress(NotFoundError):
service.remove_resource(name, force=True)
except NotFoundError:
# Resource doesn't exist yet — proceed with normal add
pass
# Build properties from type-specific flags
properties: dict[str, Any] = {}