Files
cleveragents-core/features/steps/resource_dag_steps.py
T

442 lines
16 KiB
Python

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