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
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
450 lines
15 KiB
Python
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}'"
|
|
)
|