442 lines
16 KiB
Python
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)}"
|