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
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
571 lines
20 KiB
Python
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
|