Files
cleveragents-core/features/steps/resource_project_services_steps.py
T
freemo 64cfdc782a
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 51s
CI / unit_tests (pull_request) Successful in 2m20s
CI / docker (pull_request) Successful in 38s
CI / integration_tests (pull_request) Successful in 3m12s
CI / coverage (pull_request) Successful in 4m30s
CI / lint (push) Successful in 13s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 15s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 34s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m45s
CI / docker (push) Successful in 41s
CI / integration_tests (push) Successful in 4m34s
CI / coverage (push) Successful in 4m36s
CI / benchmark-publish (push) Successful in 16m13s
CI / benchmark-regression (pull_request) Successful in 28m59s
fix(resource): call bootstrap_builtin_types during initialization
Add a call to bootstrap_builtin_types() in init_command() (project.py)
immediately after initialize_project() returns.  This seeds the built-in
resource types (fs-directory, git-checkout, etc.) into the database so
that "resource add" commands succeed without "Resource type not found"
errors.

The call is idempotent — invoking it multiple times will not create
duplicate types.

Also fix the TDD robot test (resource_type_bootstrap_git.robot) to
initialize a project before running "resource add", and fix a
pre-existing parallel test failure in plan_commands_new_coverage where
unittest.mock.patch could not reliably intercept PlanApplyService under
behave-parallel fork() workers.

ISSUES CLOSED: #523, #524
2026-03-07 19:36:09 +00:00

571 lines
20 KiB
Python

"""Step definitions for resource_project_services.feature."""
from __future__ import annotations
import os
import tempfile
from typing import Any
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,
)
from cleveragents.domain.models.core.project import NamespacedProject
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
ProjectResourceLinkRepository,
)
class _UnclosableSession:
"""Wraps a SQLAlchemy Session but makes ``close()`` a no-op.
This lets repos and services that call ``session.close()`` in ``finally``
blocks coexist when sharing a single in-memory SQLite session. All other
calls are proxied transparently.
"""
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_session_factory(context: Any) -> tuple[Any, Any]:
"""Create an in-memory SQLite database and return (engine, session_factory).
Returns a session factory that always returns the **same** unclosable
session so that repositories using the session-factory pattern (each method
opens its own session) share transactional state within a single in-memory
database. Without this, flush-only repos would lose data across calls
since each new session gets an independent transaction on ``:memory:``.
"""
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 engine, _factory
# ─── Background ─────────────────────────────────────────────
@given("a resource service in-memory database is initialized")
def step_init_resource_svc_db(context: Any) -> None:
engine, session_factory = _make_session_factory(context)
context.svc_engine = engine
context.svc_session_factory = session_factory
context.resource_service = ResourceRegistryService(
session_factory=session_factory,
)
context.project_repo = NamespacedProjectRepository(
session_factory=session_factory,
)
context.link_repo = ProjectResourceLinkRepository(
session_factory=session_factory,
)
context.svc_error = None
context.svc_project_error = None
context.last_resource = None
context.last_type = None
context.last_bootstrap_count = 0
context.second_bootstrap_count = None
context.resource_list = []
context.type_list = []
context.project_list = []
context.linked_resources_list = []
context.last_project = None
context.bootstrap_names = []
# temp dir for YAML files
context.svc_tmpdir = tempfile.mkdtemp()
# ─── Bootstrap ──────────────────────────────────────────────
@when("I bootstrap built-in resource types")
def step_bootstrap_builtins(context: Any) -> None:
result = context.resource_service.bootstrap_builtin_types()
context.last_bootstrap_count = len(result)
# Types may have been auto-bootstrapped in __init__; fall back to
# querying the database so assertion steps see all registered types.
if result:
context.bootstrap_names = result
else:
context.bootstrap_names = [
t.name for t in context.resource_service.list_types()
]
@then('the bootstrap should register "{name}"')
def step_bootstrap_registered(context: Any, name: str) -> None:
assert name in context.bootstrap_names, (
f"Expected '{name}' in {context.bootstrap_names}"
)
@when("I bootstrap built-in resource types again")
def step_bootstrap_again(context: Any) -> None:
result = context.resource_service.bootstrap_builtin_types()
context.second_bootstrap_count = len(result)
@then("the second bootstrap should register {count:d} new types")
def step_second_bootstrap_count(context: Any, count: int) -> None:
assert context.second_bootstrap_count == count, (
f"Expected {count}, got {context.second_bootstrap_count}"
)
@given("built-in resource types are bootstrapped")
def step_given_bootstrapped(context: Any) -> None:
context.resource_service.bootstrap_builtin_types()
# ─── List / Show Resource Types ─────────────────────────────
@when("I list all resource types via the service")
def step_list_all_types(context: Any) -> None:
context.type_list = context.resource_service.list_types()
@then("the type list should contain at least {count:d} types")
def step_type_list_at_least(context: Any, count: int) -> None:
assert len(context.type_list) >= count, (
f"Expected at least {count}, got {len(context.type_list)}"
)
@then('the type list should include "{name}"')
def step_type_list_includes(context: Any, name: str) -> None:
names = [t.name for t in context.type_list]
assert name in names, f"Expected '{name}' in {names}"
@when('I show the resource type "{name}" via the service')
def step_show_type(context: Any, name: str) -> None:
context.last_type = context.resource_service.show_type(name)
@when('I show the resource type "{name}" via the service expecting an error')
def step_show_type_error(context: Any, name: str) -> None:
try:
context.resource_service.show_type(name)
except Exception as exc:
context.svc_error = exc
@then('the shown type name should be "{name}"')
def step_shown_type_name(context: Any, name: str) -> None:
assert context.last_type is not None
assert context.last_type.name == name
@then('the shown type resource kind should be "{kind}"')
def step_shown_type_kind(context: Any, kind: str) -> None:
assert context.last_type is not None
assert context.last_type.resource_kind.value == kind
@then('the shown type sandbox strategy should be "{strategy}"')
def step_shown_type_strategy(context: Any, strategy: str) -> None:
assert context.last_type is not None
assert context.last_type.sandbox_strategy.value == strategy
@then('the service error should be "{error_type}"')
def step_svc_error_type(context: Any, error_type: str) -> None:
assert context.svc_error is not None, "Expected an error but none was raised"
# Check the actual class name and all parent class names (handles aliases
# like NotFoundError -> ResourceNotFoundError)
exc = context.svc_error
type_names = [cls.__name__ for cls in type(exc).__mro__]
assert error_type in type_names, (
f"Expected {error_type}, got {type(exc).__name__} (mro: {type_names})"
)
# ─── Custom Resource Type Registration ──────────────────────
@given('a custom resource type YAML file for "{name}"')
def step_custom_yaml(context: Any, name: str) -> None:
yaml_content = f"""
name: "{name}"
resource_kind: physical
sandbox_strategy: none
user_addable: true
description: "Test custom type"
"""
path = os.path.join(context.svc_tmpdir, f"{name.replace('/', '_')}.yaml")
with open(path, "w") as f:
f.write(yaml_content)
context.custom_yaml_path = path
context.custom_type_name = name
@given("I register the custom resource type via the service")
def step_given_register_custom_type(context: Any) -> None:
context.last_type = context.resource_service.register_type(context.custom_yaml_path)
@when("I register the custom resource type via the service")
def step_register_custom_type(context: Any) -> None:
context.last_type = context.resource_service.register_type(context.custom_yaml_path)
@then('the registered type name should be "{name}"')
def step_registered_type_name(context: Any, name: str) -> None:
assert context.last_type is not None
assert context.last_type.name == name
@when("I register the same custom type again expecting an error")
def step_register_dup_type(context: Any) -> None:
try:
context.resource_service.register_type(context.custom_yaml_path)
except Exception as exc:
context.svc_error = exc
@when('I register a type from path "{path}" expecting an error')
def step_register_nonexistent_path(context: Any, path: str) -> None:
try:
context.resource_service.register_type(path)
except Exception as exc:
context.svc_error = exc
# ─── Resource Registration ──────────────────────────────────
@when(
'I register a resource of type "{type_name}" named "{name}" with location "{location}"'
)
def step_register_resource(
context: Any, type_name: str, name: str, location: str
) -> None:
context.last_resource = context.resource_service.register_resource(
type_name=type_name, name=name, location=location
)
@when('I register a resource of type "{type_name}" named "{name}" expecting an error')
def step_register_resource_error(context: Any, type_name: str, name: str) -> None:
try:
context.resource_service.register_resource(type_name=type_name, name=name)
except Exception as exc:
context.svc_error = exc
@then('the registered resource name should be "{name}"')
def step_registered_resource_name(context: Any, name: str) -> None:
assert context.last_resource is not None
assert context.last_resource.name == name
@then('the registered resource type should be "{type_name}"')
def step_registered_resource_type(context: Any, type_name: str) -> None:
assert context.last_resource is not None
assert context.last_resource.resource_type_name == type_name
@then('the registered resource classification should be "{kind}"')
def step_registered_resource_classification(context: Any, kind: str) -> None:
assert context.last_resource is not None
assert context.last_resource.classification == kind
@given('a resource of type "{type_name}" named "{name}" is registered')
def step_given_resource_registered(context: Any, type_name: str, name: str) -> None:
context.last_resource = context.resource_service.register_resource(
type_name=type_name, name=name, location="/tmp/test"
)
# Commit so the row survives any rollback in later error-path steps.
context.svc_session_factory().commit()
@when("I list all resources via the service")
def step_list_all_resources(context: Any) -> None:
context.resource_list = context.resource_service.list_resources()
@when('I list resources of type "{type_name}" via the service')
def step_list_resources_by_type(context: Any, type_name: str) -> None:
context.resource_list = context.resource_service.list_resources(type_name=type_name)
@then("the resource list should contain {count:d} resources")
def step_resource_list_count(context: Any, count: int) -> None:
assert len(context.resource_list) == count, (
f"Expected {count}, got {len(context.resource_list)}"
)
@when('I show the resource "{name}" via the service')
def step_show_resource(context: Any, name: str) -> None:
context.last_resource = context.resource_service.show_resource(name)
@when("I show the resource by its ULID via the service")
def step_show_resource_by_ulid(context: Any) -> None:
assert context.last_resource is not None
ulid = context.last_resource.resource_id
context.last_resource = context.resource_service.show_resource(ulid)
@when('I show the resource "{name}" via the service expecting an error')
def step_show_resource_error(context: Any, name: str) -> None:
try:
context.resource_service.show_resource(name)
except Exception as exc:
context.svc_error = exc
@then('the shown resource name should be "{name}"')
def step_shown_resource_name(context: Any, name: str) -> None:
assert context.last_resource is not None
assert context.last_resource.name == name
# ─── Project Service ────────────────────────────────────────
@when('I create a namespaced project "{ns_name}" via the project service')
def step_create_ns_project(context: Any, ns_name: str) -> None:
parts = ns_name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
name = parts[1] if len(parts) == 2 else parts[0]
project = NamespacedProject(name=name, namespace=namespace)
context.last_project = context.project_repo.create(project)
@then('the service project "{ns_name}" should exist')
def step_project_exists(context: Any, ns_name: str) -> None:
project = context.project_repo.get(ns_name)
assert project is not None
@given('a namespaced project "{ns_name}" exists via the service')
def step_given_ns_project_exists(context: Any, ns_name: str) -> None:
parts = ns_name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
name = parts[1] if len(parts) == 2 else parts[0]
project = NamespacedProject(name=name, namespace=namespace)
context.project_repo.create(project)
# Commit so the row survives any rollback in later error-path steps.
context.svc_session_factory().commit()
@when(
'I create a namespaced project "{ns_name}" via the project service expecting an error'
)
def step_create_dup_project(context: Any, ns_name: str) -> None:
parts = ns_name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
name = parts[1] if len(parts) == 2 else parts[0]
project = NamespacedProject(name=name, namespace=namespace)
try:
context.project_repo.create(project)
except Exception as exc:
context.svc_project_error = exc
@then("the project service error should be a database error")
def step_project_svc_db_error(context: Any) -> None:
assert context.svc_project_error is not None, (
"Expected a database error but none was raised"
)
@when("I list all projects via the project service")
def step_list_all_projects(context: Any) -> None:
context.project_list = context.project_repo.list_projects()
@then("the project service list should contain {count:d} projects")
def step_project_list_count(context: Any, count: int) -> None:
assert len(context.project_list) == count, (
f"Expected {count}, got {len(context.project_list)}"
)
@when('I get the project "{ns_name}" via the project service')
def step_get_project(context: Any, ns_name: str) -> None:
context.last_project = context.project_repo.get(ns_name)
@then('the project service returned name should be "{name}"')
def step_project_returned_name(context: Any, name: str) -> None:
assert context.last_project is not None
assert context.last_project.name == name
@when('I delete the project "{ns_name}" via the project service')
def step_delete_project(context: Any, ns_name: str) -> None:
context.project_repo.delete(ns_name)
@then('the project "{ns_name}" should not exist via the service')
def step_project_not_exist(context: Any, ns_name: str) -> None:
try:
context.project_repo.get(ns_name)
raise AssertionError(f"Project {ns_name} should not exist")
except Exception:
pass
# ─── Link / Unlink Resources ───────────────────────────────
@when(
'I link the resource "{resource_name}" to project "{project_name}" via the service'
)
def step_link_resource(context: Any, resource_name: str, project_name: str) -> None:
resource = context.resource_service.show_resource(resource_name)
context.link_repo.create_link(
project_name=project_name,
resource_id=resource.resource_id,
)
@given('the resource "{resource_name}" is linked to project "{project_name}"')
def step_given_resource_linked(
context: Any, resource_name: str, project_name: str
) -> None:
resource = context.resource_service.show_resource(resource_name)
context.link_repo.create_link(
project_name=project_name,
resource_id=resource.resource_id,
)
# Commit so the row survives any rollback in later error-path steps.
context.svc_session_factory().commit()
context.last_link_resource_id = resource.resource_id
@then('the project "{project_name}" should have {count:d} linked resource')
def step_project_link_count_singular(
context: Any, project_name: str, count: int
) -> None:
links = context.link_repo.list_links(project_name)
assert len(links) == count, f"Expected {count}, got {len(links)}"
@then('the project "{project_name}" should have {count:d} linked resources')
def step_project_link_count(context: Any, project_name: str, count: int) -> None:
links = context.link_repo.list_links(project_name)
assert len(links) == count, f"Expected {count}, got {len(links)}"
@when('I unlink the resource from project "{project_name}" via the service')
def step_unlink_resource(context: Any, project_name: str) -> None:
links = context.link_repo.list_links(project_name)
if links:
link_id: str = str(links[0].link_id) # type: ignore[union-attr]
context.link_repo.remove_link(link_id)
@when('I list linked resources for project "{project_name}" via the service')
def step_list_linked_resources(context: Any, project_name: str) -> None:
context.linked_resources_list = context.link_repo.list_links(project_name)
@then("the linked resources list should contain {count:d} items")
def step_linked_resources_count(context: Any, count: int) -> None:
assert len(context.linked_resources_list) == count, (
f"Expected {count}, got {len(context.linked_resources_list)}"
)
# ─── Additional Coverage Steps ──────────────────────────────
@when('I list resource types with namespace "{namespace}" via the service')
def step_list_types_by_namespace(context: Any, namespace: str) -> None:
context.type_list = context.resource_service.list_types(namespace=namespace)
@when(
'I register a resource of type "{type_name}" named "{name}"'
' with location "{location}" and properties'
)
def step_register_resource_with_props(
context: Any, type_name: str, name: str, location: str
) -> None:
context.last_resource = context.resource_service.register_resource(
type_name=type_name,
name=name,
location=location,
properties={"branch": "main", "depth": 1},
)
@when(
'I register a resource of type "{type_name}" with no name but location "{location}"'
)
def step_register_resource_no_name(context: Any, type_name: str, location: str) -> None:
context.last_resource = context.resource_service.register_resource(
type_name=type_name, name=None, location=location
)
@then("the registered resource should have a ULID id")
def step_resource_has_ulid(context: Any) -> None:
assert context.last_resource is not None
assert context.last_resource.resource_id is not None
assert len(context.last_resource.resource_id) == 26
# ─── DI Container ──────────────────────────────────────────
@when("I get the resource registry service from the DI container")
def step_get_di_resource_service(context: Any) -> None:
from cleveragents.application.container import Container
container = Container()
context.di_resource_service = container.resource_registry_service()
@then("the resource registry service should not be None")
def step_di_resource_service_not_none(context: Any) -> None:
assert context.di_resource_service is not None
@when("I get the namespaced project service from the DI container")
def step_get_di_project_service(context: Any) -> None:
from cleveragents.application.container import Container
container = Container()
context.di_project_service = container.namespaced_project_repo()
@then("the namespaced project service should not be None")
def step_di_project_service_not_none(context: Any) -> None:
assert context.di_project_service is not None