"""Step definitions for resource_dag.feature.""" from __future__ import annotations import json from datetime import UTC, datetime from typing import Any from behave import given, then, when from behave.runner import Context from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker from cleveragents.domain.models.core.resource import ( PhysVirt, Resource, ResourceCapabilities, ) from cleveragents.domain.models.core.resource_type import ( ResourceKind, ResourceTypeSpec, SandboxStrategy, ) from cleveragents.infrastructure.database.models import Base from cleveragents.infrastructure.database.repositories import ( CycleDetectedError, DuplicateResourceLinkError, LinkNotFoundError, ResourceNotFoundRepoError, ResourceRepository, ResourceTypeRepository, TypeIncompatibleError, ) # Crockford base32 alphabet for generating ULIDs _CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" _DAG_CTR = 0 def _next_ulid() -> str: """Return a unique, valid ULID string.""" global _DAG_CTR _DAG_CTR += 1 n = _DAG_CTR suffix = "" for _ in range(16): suffix = _CB32[n % 32] + suffix n //= 32 return f"01HDAGT0AD{suffix}"[:26] def _make_type_spec( name: str, child_types: list[str] | None = None, auto_discovery: dict[str, Any] | None = None, ) -> ResourceTypeSpec: """Create a ResourceTypeSpec.""" return ResourceTypeSpec( name=name, description=f"Test type {name}", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=SandboxStrategy.NONE, user_addable=True, cli_args=[], parent_types=[], child_types=child_types or [], auto_discovery=auto_discovery, equivalence=None, handler=None, capabilities={ "read": True, "write": True, "sandbox": True, "checkpoint": False, }, built_in=False, ) def _make_resource( resource_id: str, type_name: str, ) -> Resource: """Create a Resource domain object.""" return Resource( resource_id=resource_id, name=None, resource_type_name=type_name, classification=PhysVirt.PHYSICAL, description=f"Test resource {resource_id}", properties={}, location=None, content_hash=None, sandbox_strategy=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC), ) # ── Background ───────────────────────────────────────── @given("a clean resource DAG database") def step_clean_dag_db(context: Context) -> None: engine = create_engine("sqlite:///:memory:") @event.listens_for(engine, "connect") def _fk(conn: Any, _rec: Any) -> None: conn.cursor().execute("PRAGMA foreign_keys=ON") Base.metadata.create_all(engine) context.dag_engine = engine # type: ignore[attr-defined] context.dag_factory = sessionmaker( # type: ignore[attr-defined] bind=engine ) context.dag_resources = {} # type: ignore[attr-defined] context.dag_error = None # type: ignore[attr-defined] context.dag_result = None # type: ignore[attr-defined] @given("a resource type repository for DAG tests") def step_rt_repo(context: Context) -> None: context.dag_rt_repo = ResourceTypeRepository( # type: ignore[attr-defined] context.dag_factory # type: ignore[attr-defined] ) @given("a resource repository for DAG tests") def step_res_repo(context: Context) -> None: context.dag_res_repo = ResourceRepository( # type: ignore[attr-defined] context.dag_factory # type: ignore[attr-defined] ) # ── Type setup ────────────────────────────────────────── @given("a DAG parent type \"{name}\" allowing children '{child_json}'") def step_dag_parent_type(context: Context, name: str, child_json: str) -> None: child_list: list[str] = json.loads(child_json) spec = _make_type_spec(name, child_types=child_list) context.dag_rt_repo.create(spec) # type: ignore[attr-defined] @given('a DAG child type "{name}"') def step_dag_child_type(context: Context, name: str) -> None: spec = _make_type_spec(name) context.dag_rt_repo.create(spec) # type: ignore[attr-defined] @given('a DAG parent type "{name}" with auto-discovery for "{child_type}"') def step_dag_parent_auto(context: Context, name: str, child_type: str) -> None: auto_disc = { "enabled": True, "rules": [{"type": child_type, "pattern": "*"}], } spec = _make_type_spec( name, child_types=[child_type], auto_discovery=auto_disc, ) context.dag_rt_repo.create(spec) # type: ignore[attr-defined] @given('a DAG parent type "{name}" without auto-discovery') def step_dag_parent_no_disc(context: Context, name: str) -> None: spec = _make_type_spec(name) context.dag_rt_repo.create(spec) # type: ignore[attr-defined] # ── Resource setup ────────────────────────────────────── @given('a DAG resource "{label}" typed "{type_name}"') def step_dag_resource(context: Context, label: str, type_name: str) -> None: rid = _next_ulid() res = _make_resource(rid, type_name) context.dag_res_repo.create(res) # type: ignore[attr-defined] context.dag_resources[label] = rid # type: ignore[attr-defined] # ── Link setup ────────────────────────────────────────── @given('DAG child "{child}" is already linked to parent "{parent}"') def step_dag_already_linked(context: Context, child: str, parent: str) -> None: pid = context.dag_resources[parent] # type: ignore[attr-defined] cid = context.dag_resources[child] # type: ignore[attr-defined] context.dag_res_repo.link_child(pid, cid) # type: ignore[attr-defined] # ── When: link / unlink ──────────────────────────────── @when('DAG child "{child}" is linked to parent "{parent}"') def step_dag_link_child(context: Context, child: str, parent: str) -> None: pid = context.dag_resources[parent] # type: ignore[attr-defined] cid = context.dag_resources[child] # type: ignore[attr-defined] try: context.dag_res_repo.link_child(pid, cid) # type: ignore[attr-defined] context.dag_error = None # type: ignore[attr-defined] except ( CycleDetectedError, TypeIncompatibleError, DuplicateResourceLinkError, ResourceNotFoundRepoError, ) as exc: context.dag_error = exc # type: ignore[attr-defined] @when('DAG child "{child}" is linked to parent "{parent}" again') def step_dag_link_again(context: Context, child: str, parent: str) -> None: pid = context.dag_resources[parent] # type: ignore[attr-defined] cid = context.dag_resources[child] # type: ignore[attr-defined] try: context.dag_res_repo.link_child(pid, cid) # type: ignore[attr-defined] context.dag_error = None # type: ignore[attr-defined] except DuplicateResourceLinkError as exc: context.dag_error = exc # type: ignore[attr-defined] @when('DAG child "{child}" is unlinked from parent "{parent}"') def step_dag_unlink(context: Context, child: str, parent: str) -> None: pid = context.dag_resources[parent] # type: ignore[attr-defined] cid = context.dag_resources[child] # type: ignore[attr-defined] try: context.dag_res_repo.unlink_child(pid, cid) # type: ignore[attr-defined] context.dag_error = None # type: ignore[attr-defined] except ( ResourceNotFoundRepoError, LinkNotFoundError, ) as exc: context.dag_error = exc # type: ignore[attr-defined] @when('DAG linking child "{child}" to missing parent "{parent_id}"') def step_dag_link_missing_parent(context: Context, child: str, parent_id: str) -> None: cid = context.dag_resources[child] # type: ignore[attr-defined] try: context.dag_res_repo.link_child(parent_id, cid) # type: ignore[attr-defined] context.dag_error = None # type: ignore[attr-defined] except ResourceNotFoundRepoError as exc: context.dag_error = exc # type: ignore[attr-defined] @when('DAG linking missing child "{child_id}" to parent "{parent}"') def step_dag_link_missing_child(context: Context, child_id: str, parent: str) -> None: pid = context.dag_resources[parent] # type: ignore[attr-defined] try: context.dag_res_repo.link_child(pid, child_id) # type: ignore[attr-defined] context.dag_error = None # type: ignore[attr-defined] except ResourceNotFoundRepoError as exc: context.dag_error = exc # type: ignore[attr-defined] @when('DAG resource "{res}" is linked to itself') def step_dag_self_link(context: Context, res: str) -> None: rid = context.dag_resources[res] # type: ignore[attr-defined] try: context.dag_res_repo.link_child(rid, rid) # type: ignore[attr-defined] context.dag_error = None # type: ignore[attr-defined] except CycleDetectedError as exc: context.dag_error = exc # type: ignore[attr-defined] # ── When: auto_discover ───────────────────────────────── @when('DAG auto_discover_children is called for "{label}"') def step_dag_auto_discover(context: Context, label: str) -> None: rid = context.dag_resources[label] # type: ignore[attr-defined] try: context.dag_result = ( # type: ignore[attr-defined] context.dag_res_repo.auto_discover_children( # type: ignore[attr-defined] rid ) ) context.dag_error = None # type: ignore[attr-defined] except ResourceNotFoundRepoError as exc: context.dag_error = exc # type: ignore[attr-defined] context.dag_result = [] # type: ignore[attr-defined] @when('DAG auto_discover_children is called for missing "{resource_id}"') def step_dag_auto_discover_missing(context: Context, resource_id: str) -> None: try: context.dag_result = ( # type: ignore[attr-defined] context.dag_res_repo.auto_discover_children( # type: ignore[attr-defined] resource_id ) ) context.dag_error = None # type: ignore[attr-defined] except ResourceNotFoundRepoError as exc: context.dag_error = exc # type: ignore[attr-defined] context.dag_result = [] # type: ignore[attr-defined] # ── When: traversal ───────────────────────────────────── @when('DAG children of "{label}" are retrieved') def step_dag_get_children(context: Context, label: str) -> None: rid = context.dag_resources[label] # type: ignore[attr-defined] context.dag_result = ( # type: ignore[attr-defined] context.dag_res_repo.get_children(rid) # type: ignore[attr-defined] ) @when('DAG parents of "{label}" are retrieved') def step_dag_get_parents(context: Context, label: str) -> None: rid = context.dag_resources[label] # type: ignore[attr-defined] context.dag_result = ( # type: ignore[attr-defined] context.dag_res_repo.get_parents(rid) # type: ignore[attr-defined] ) # ── Then: assertions ──────────────────────────────────── @then("the DAG link should succeed without error") def step_dag_link_ok(context: Context) -> None: assert context.dag_error is None, ( # type: ignore[attr-defined] f"Expected no error, got: {context.dag_error}" # type: ignore[attr-defined] ) @then("the DAG unlink should succeed without error") def step_dag_unlink_ok(context: Context) -> None: assert context.dag_error is None, ( # type: ignore[attr-defined] f"Expected no error, got: {context.dag_error}" # type: ignore[attr-defined] ) @then('DAG children of "{label}" should include "{child}"') def step_dag_children_include(context: Context, label: str, child: str) -> None: pid = context.dag_resources[label] # type: ignore[attr-defined] cid = context.dag_resources[child] # type: ignore[attr-defined] children = context.dag_res_repo.get_children(pid) # type: ignore[attr-defined] child_ids = [c.resource_id for c in children] assert cid in child_ids, f"Child {cid} not in children {child_ids}" @then('DAG children of "{label}" should be empty') def step_dag_children_empty(context: Context, label: str) -> None: pid = context.dag_resources[label] # type: ignore[attr-defined] children = context.dag_res_repo.get_children(pid) # type: ignore[attr-defined] assert len(children) == 0, f"Expected 0 children, got {len(children)}" @then("a DAG ResourceNotFoundRepoError should be raised") def step_dag_not_found(context: Context) -> None: assert isinstance( context.dag_error, # type: ignore[attr-defined] ResourceNotFoundRepoError, ), ( f"Expected ResourceNotFoundRepoError, got " f"{type(context.dag_error).__name__}: " # type: ignore[attr-defined] f"{context.dag_error}" # type: ignore[attr-defined] ) @then("a DAG CycleDetectedError should be raised") def step_dag_cycle_error(context: Context) -> None: assert isinstance( context.dag_error, # type: ignore[attr-defined] CycleDetectedError, ), ( f"Expected CycleDetectedError, got " f"{type(context.dag_error).__name__}: " # type: ignore[attr-defined] f"{context.dag_error}" # type: ignore[attr-defined] ) @then("a DAG TypeIncompatibleError should be raised") def step_dag_type_error(context: Context) -> None: assert isinstance( context.dag_error, # type: ignore[attr-defined] TypeIncompatibleError, ), ( f"Expected TypeIncompatibleError, got " f"{type(context.dag_error).__name__}: " # type: ignore[attr-defined] f"{context.dag_error}" # type: ignore[attr-defined] ) @then("a DAG DuplicateResourceLinkError should be raised") def step_dag_dup_link(context: Context) -> None: assert isinstance( context.dag_error, # type: ignore[attr-defined] DuplicateResourceLinkError, ), ( f"Expected DuplicateResourceLinkError, got " f"{type(context.dag_error).__name__}: " # type: ignore[attr-defined] f"{context.dag_error}" # type: ignore[attr-defined] ) @then("at least {count:d} DAG child should be created") def step_dag_at_least_n(context: Context, count: int) -> None: result = context.dag_result or [] # type: ignore[attr-defined] assert len(result) >= count, f"Expected >= {count} children, got {len(result)}" @then("{count:d} DAG children should be created") def step_dag_exact_n(context: Context, count: int) -> None: result = context.dag_result or [] # type: ignore[attr-defined] assert len(result) == count, f"Expected {count} children, got {len(result)}" @then('DAG created children should be typed "{type_name}"') def step_dag_children_typed(context: Context, type_name: str) -> None: result = context.dag_result or [] # type: ignore[attr-defined] for child in result: assert child.resource_type_name == type_name, ( f"Expected type {type_name}, got {child.resource_type_name}" ) @then('DAG created children should be linked to "{label}"') def step_dag_children_linked(context: Context, label: str) -> None: pid = context.dag_resources[label] # type: ignore[attr-defined] children = context.dag_res_repo.get_children(pid) # type: ignore[attr-defined] result = context.dag_result or [] # type: ignore[attr-defined] child_ids = {c.resource_id for c in children} for created in result: assert created.resource_id in child_ids, ( f"Created child {created.resource_id} not linked to parent {pid}" ) @then("{count:d} DAG children should be returned") def step_dag_n_children(context: Context, count: int) -> None: result = context.dag_result or [] # type: ignore[attr-defined] assert len(result) == count, f"Expected {count} children, got {len(result)}" @then("{count:d} DAG parents should be returned") def step_dag_n_parents(context: Context, count: int) -> None: result = context.dag_result or [] # type: ignore[attr-defined] assert len(result) == count, f"Expected {count} parents, got {len(result)}"