"""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