Files
cleveragents-core/features/steps/namespaced_project_service_steps.py
T
HAL9000 e17a6ddec7
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / push-validation (push) Successful in 23s
CI / helm (push) Successful in 43s
CI / build (push) Successful in 3m49s
CI / lint (push) Successful in 3m56s
CI / quality (push) Successful in 4m24s
CI / typecheck (push) Successful in 4m53s
CI / security (push) Successful in 4m55s
CI / e2e_tests (push) Successful in 7m0s
CI / integration_tests (push) Successful in 7m44s
CI / unit_tests (push) Successful in 8m37s
CI / docker (push) Successful in 1m37s
CI / coverage (push) Successful in 15m4s
CI / status-check (push) Successful in 3s
CI / docker (pull_request) Successful in 1m38s
CI / coverage (pull_request) Successful in 14m58s
CI / typecheck (pull_request) Successful in 4m24s
CI / push-validation (pull_request) Successful in 23s
CI / integration_tests (pull_request) Successful in 11m54s
CI / build (pull_request) Successful in 3m35s
CI / lint (pull_request) Successful in 3m49s
CI / helm (pull_request) Successful in 29s
CI / quality (pull_request) Successful in 4m13s
CI / security (pull_request) Successful in 4m38s
CI / e2e_tests (pull_request) Successful in 6m54s
CI / unit_tests (pull_request) Successful in 8m56s
CI / status-check (pull_request) Successful in 3s
fix(arch): route CLI project create through NamespacedProjectService
Introduce NamespacedProjectService in the application layer to encapsulate
all NamespacedProject domain model construction. The agents project create
CLI command previously imported NamespacedProject and parse_namespaced_name
directly from cleveragents.domain.models.core.project, violating
Architectural Invariant #3 (CLI layer must only call application services).

Changes:
- Add NamespacedProjectService with create_project(), get_project(),
  list_projects(), delete_project(), parse_project_name(),
  validate_project_name(), and project_to_dict() methods
- Wire NamespacedProjectService into the DI container as
  namespaced_project_service provider
- Refactor cli/commands/project.py to use NamespacedProjectService for
  all project operations (create, list, show, delete, link-resource,
  unlink-resource)
- Add BDD feature file and step definitions for NamespacedProjectService
  (25 scenarios covering all service methods and edge cases)
- Narrow exception handling in get_project() to catch only
  ProjectNotFoundError instead of bare Exception, preventing
  infrastructure errors from being masked as NotFoundError
- Update CHANGELOG.md with both #7464 and #8232 entries
- Update CONTRIBUTORS.md with PR #8297 contribution entry

ISSUES CLOSED: #7464
2026-04-21 03:38:05 +00:00

450 lines
15 KiB
Python

"""Step definitions for namespaced_project_service.feature.
Tests the NamespacedProjectService application service which provides
a clean facade over the domain layer for the CLI layer, enforcing
Architectural Invariant #3: CLI → AppService → Domain.
"""
from __future__ import annotations
import inspect
from typing import Any
from behave import given, then, use_step_matcher, when
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
# ---------------------------------------------------------------------------
# Shared session wrapper (prevents premature session close)
# ---------------------------------------------------------------------------
class _UnclosableSession:
"""Wraps a SQLAlchemy Session but makes ``close()`` a no-op."""
def __init__(self, real_session: Session) -> None:
object.__setattr__(self, "_real", real_session)
def close(self) -> None:
"""No-op so the shared session stays usable across calls."""
def __getattr__(self, name: str) -> Any:
return getattr(object.__getattribute__(self, "_real"), name)
def __setattr__(self, name: str, value: Any) -> None:
setattr(object.__getattribute__(self, "_real"), name, value)
def _make_nps_session_factory(context: Any) -> Any:
"""Create an in-memory SQLite database and return a session factory."""
from cleveragents.infrastructure.database.models import Base
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,
)()
wrapper = _UnclosableSession(real_session)
def _factory() -> Any:
return wrapper
return _factory
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a NamespacedProjectService with an in-memory database")
def step_init_nps(context: Any) -> None:
from cleveragents.application.services.namespaced_project_service import (
NamespacedProjectService,
)
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
)
session_factory = _make_nps_session_factory(context)
repo = NamespacedProjectRepository(session_factory=session_factory)
context.nps = NamespacedProjectService(project_repo=repo)
context.nps_repo = repo
context.nps_parsed = None
context.nps_project = None
context.nps_project_list = []
context.nps_dict = {}
context.nps_delete_result = None
context.nps_raised_exc = None
# ---------------------------------------------------------------------------
# Given helpers
# ---------------------------------------------------------------------------
@given('a project "{name}" already exists in the service')
def step_nps_project_exists(context: Any, name: str) -> None:
context.nps.create_project(name=name)
# ---------------------------------------------------------------------------
# Parse / validate steps
# ---------------------------------------------------------------------------
@when('I parse the project name "{name}"')
def step_nps_parse_name(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_parsed = context.nps.parse_project_name(name)
except Exception as exc:
context.nps_raised_exc = exc
@when('I parse the invalid project name "{name}"')
def step_nps_parse_invalid_name(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_parsed = context.nps.parse_project_name(name)
except ValueError as exc:
context.nps_raised_exc = exc
@when('I validate the project name "{name}"')
def step_nps_validate_name(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_parsed = context.nps.validate_project_name(name)
except Exception as exc:
context.nps_raised_exc = exc
@when('I validate the invalid project name "{name}"')
def step_nps_validate_invalid_name(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_parsed = context.nps.validate_project_name(name)
except ValueError as exc:
context.nps_raised_exc = exc
# ---------------------------------------------------------------------------
# Create steps
# ---------------------------------------------------------------------------
use_step_matcher("re")
@when(r'I create a project named "(?P<name>[^"]+)" via the service')
def step_nps_create_project(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_project = context.nps.create_project(name=name)
except Exception as exc:
context.nps_raised_exc = exc
@when(
r'I create a project named "(?P<name>[^"]+)"'
r' with description "(?P<desc>[^"]+)" via the service'
)
def step_nps_create_project_with_desc(context: Any, name: str, desc: str) -> None:
context.nps_raised_exc = None
try:
context.nps_project = context.nps.create_project(name=name, description=desc)
except Exception as exc:
context.nps_raised_exc = exc
@when(r'I attempt to create a project named "(?P<name>[^"]+)" via the service')
def step_nps_attempt_create_project(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_project = context.nps.create_project(name=name)
except Exception as exc:
context.nps_raised_exc = exc
@when(
r'I attempt to create a duplicate project named "(?P<name>[^"]+)" via the service'
)
def step_nps_attempt_create_duplicate(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_project = context.nps.create_project(name=name)
except Exception as exc:
context.nps_raised_exc = exc
use_step_matcher("parse")
# ---------------------------------------------------------------------------
# Get steps
# ---------------------------------------------------------------------------
@when('I get the project "{name}" via the service')
def step_nps_get_project(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_project = context.nps.get_project(name)
except Exception as exc:
context.nps_raised_exc = exc
@when('I attempt to get the project "{name}" via the service')
def step_nps_attempt_get_project(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_project = context.nps.get_project(name)
except Exception as exc:
context.nps_raised_exc = exc
# ---------------------------------------------------------------------------
# List steps
# ---------------------------------------------------------------------------
@when("I list all projects via the service")
def step_nps_list_all_projects(context: Any) -> None:
context.nps_raised_exc = None
try:
context.nps_project_list = context.nps.list_projects()
except Exception as exc:
context.nps_raised_exc = exc
@when('I list projects with namespace "{ns}" via the service')
def step_nps_list_projects_ns(context: Any, ns: str) -> None:
context.nps_raised_exc = None
try:
context.nps_project_list = context.nps.list_projects(namespace=ns)
except Exception as exc:
context.nps_raised_exc = exc
# ---------------------------------------------------------------------------
# Delete steps
# ---------------------------------------------------------------------------
@when('I delete the project "{name}" via the service')
def step_nps_delete_project(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
context.nps_delete_result = context.nps.delete_project(name)
except Exception as exc:
context.nps_raised_exc = exc
# ---------------------------------------------------------------------------
# project_to_dict steps
# ---------------------------------------------------------------------------
@when('I convert the project "{name}" to a dict via the service')
def step_nps_project_to_dict(context: Any, name: str) -> None:
context.nps_raised_exc = None
try:
project = context.nps.get_project(name)
context.nps_dict = context.nps.project_to_dict(project)
except Exception as exc:
context.nps_raised_exc = exc
# ---------------------------------------------------------------------------
# Architectural invariant step
# ---------------------------------------------------------------------------
@when("I inspect the project CLI create command source")
def step_nps_inspect_cli_source(context: Any) -> None:
import cleveragents.cli.commands.project as project_module
context.nps_cli_source = inspect.getsource(project_module)
# ---------------------------------------------------------------------------
# Then assertions
# ---------------------------------------------------------------------------
@then('the NPS parsed namespace should be "{ns}"')
def step_nps_assert_parsed_ns(context: Any, ns: str) -> None:
assert context.nps_parsed is not None, "No parsed result available"
assert context.nps_parsed.namespace == ns, (
f"Expected namespace '{ns}', got '{context.nps_parsed.namespace}'"
)
@then('the NPS parsed name should be "{name}"')
def step_nps_assert_parsed_name(context: Any, name: str) -> None:
assert context.nps_parsed is not None, "No parsed result available"
assert context.nps_parsed.name == name, (
f"Expected name '{name}', got '{context.nps_parsed.name}'"
)
@then("the NPS parsed server should be None")
def step_nps_assert_parsed_server_none(context: Any) -> None:
assert context.nps_parsed is not None, "No parsed result available"
assert context.nps_parsed.server is None, (
f"Expected server to be None, got '{context.nps_parsed.server}'"
)
@then('the NPS parsed server should be "{server}"')
def step_nps_assert_parsed_server(context: Any, server: str) -> None:
assert context.nps_parsed is not None, "No parsed result available"
assert context.nps_parsed.server == server, (
f"Expected server '{server}', got '{context.nps_parsed.server}'"
)
@then("the NPS should raise a ValueError")
def step_nps_assert_value_error(context: Any) -> None:
assert context.nps_raised_exc is not None, (
"Expected a ValueError but none was raised"
)
assert isinstance(context.nps_raised_exc, ValueError), (
f"Expected ValueError, got {type(context.nps_raised_exc).__name__}: "
f"{context.nps_raised_exc}"
)
@then("a database error should be raised")
def step_nps_assert_db_error(context: Any) -> None:
assert context.nps_raised_exc is not None, (
"Expected a database error but none was raised"
)
@then("a NotFoundError should be raised")
def step_nps_assert_not_found_error(context: Any) -> None:
from cleveragents.core.exceptions import NotFoundError
assert context.nps_raised_exc is not None, (
"Expected a NotFoundError but none was raised"
)
assert isinstance(context.nps_raised_exc, NotFoundError), (
f"Expected NotFoundError, got {type(context.nps_raised_exc).__name__}: "
f"{context.nps_raised_exc}"
)
@then("the validation should succeed")
def step_nps_assert_validation_success(context: Any) -> None:
assert context.nps_raised_exc is None, (
f"Expected validation to succeed but got: {context.nps_raised_exc}"
)
assert context.nps_parsed is not None, "Expected a parsed result"
@then('the service should return a project with namespaced name "{name}"')
def step_nps_assert_project_namespaced_name(context: Any, name: str) -> None:
assert context.nps_project is not None, "No project returned from service"
assert context.nps_project.namespaced_name == name, (
f"Expected namespaced_name '{name}', "
f"got '{context.nps_project.namespaced_name}'"
)
@then("the project should be persisted in the database")
def step_nps_assert_project_persisted(context: Any) -> None:
assert context.nps_project is not None, "No project to check"
fetched = context.nps_repo.get(context.nps_project.namespaced_name)
assert fetched is not None, (
f"Project '{context.nps_project.namespaced_name}' not found in database"
)
@then('the NPS project description should be "{desc}"')
def step_nps_assert_project_desc(context: Any, desc: str) -> None:
assert context.nps_project is not None, "No project returned from service"
assert context.nps_project.description == desc, (
f"Expected description '{desc}', got '{context.nps_project.description}'"
)
@then('the service project list should contain "{name}"')
def step_nps_assert_list_contains(context: Any, name: str) -> None:
names = [p.namespaced_name for p in context.nps_project_list]
assert name in names, f"Expected project list to contain '{name}', got: {names}"
@then('the service project list should not contain "{name}"')
def step_nps_assert_list_not_contains(context: Any, name: str) -> None:
names = [p.namespaced_name for p in context.nps_project_list]
assert name not in names, (
f"Expected project list NOT to contain '{name}', got: {names}"
)
@then("the service project list should be empty")
def step_nps_assert_list_empty(context: Any) -> None:
assert len(context.nps_project_list) == 0, (
f"Expected empty project list, got: {context.nps_project_list}"
)
@then("the delete should return True")
def step_nps_assert_delete_true(context: Any) -> None:
assert context.nps_delete_result is True, (
f"Expected delete to return True, got: {context.nps_delete_result}"
)
@then("the delete should return False")
def step_nps_assert_delete_false(context: Any) -> None:
assert context.nps_delete_result is False, (
f"Expected delete to return False, got: {context.nps_delete_result}"
)
@then('the project "{name}" should not exist in the service')
def step_nps_assert_project_not_exists(context: Any, name: str) -> None:
from cleveragents.core.exceptions import NotFoundError
try:
context.nps.get_project(name)
raise AssertionError(f"Project '{name}' should not exist but was found")
except NotFoundError:
pass
@then('the dict should have key "{key}"')
def step_nps_assert_dict_has_key(context: Any, key: str) -> None:
assert key in context.nps_dict, (
f"Expected dict to have key '{key}', keys: {list(context.nps_dict.keys())}"
)
@then('the dict value for "{key}" should be "{value}"')
def step_nps_assert_dict_value(context: Any, key: str, value: str) -> None:
assert key in context.nps_dict, f"Key '{key}' not found in dict"
assert str(context.nps_dict[key]) == value, (
f"Expected dict['{key}'] == '{value}', got '{context.nps_dict[key]}'"
)
@then('it should not contain a direct import of "{module_path}"')
def step_nps_assert_no_direct_import(context: Any, module_path: str) -> None:
source = context.nps_cli_source
# Check for direct import patterns like:
# "from cleveragents.domain.models.core.project import"
import_pattern = f"from {module_path} import"
assert import_pattern not in source, (
f"CLI source still contains direct domain import: '{import_pattern}'"
)