Files
cleveragents-core/features/steps/project_tables_migration_steps.py
Jeffrey Phillips Freeman dbca60c98e
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 27s
CI / security (pull_request) Successful in 23s
CI / quality (pull_request) Successful in 16s
CI / integration_tests (pull_request) Failing after 3m31s
CI / build (pull_request) Successful in 15s
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 28s
CI / security (push) Successful in 23s
CI / quality (push) Successful in 15s
CI / unit_tests (pull_request) Failing after 9m45s
CI / docker (pull_request) Has been skipped
CI / integration_tests (push) Failing after 3m30s
CI / build (push) Successful in 15s
CI / unit_tests (push) Failing after 9m42s
CI / docker (push) Has been skipped
CI / coverage (pull_request) Failing after 3m29s
CI / coverage (push) Failing after 3m28s
feat(db): add projects and project links tables
2026-02-15 11:04:18 -05:00

515 lines
17 KiB
Python

"""Step definitions for project database table migration tests.
Tests the NamespacedProjectModel and ProjectResourceLinkModel ORM models
and their corresponding database tables (projects, project_resource_links).
Uses an in-memory SQLite database with schema created via
``Base.metadata.create_all``.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.infrastructure.database.models import (
Base,
NamespacedProjectModel,
ProjectResourceLinkModel,
ResourceModel,
ResourceTypeModel,
)
def _now_iso() -> str:
"""Return current UTC time as ISO-8601 string."""
return datetime.now(tz=UTC).isoformat()
def _make_ulid(suffix: str = "0") -> str:
"""Create a deterministic 26-char fake ULID for testing."""
return ("0" * (26 - len(suffix))) + suffix
def _set_sqlite_pragma_projects(dbapi_conn: Any, _connection_record: Any) -> None:
"""Enable FK enforcement on every new SQLite connection."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys = ON")
cursor.close()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a project tables in-memory database is initialized")
def step_project_tables_init_db(context: Any) -> None:
"""Create an in-memory SQLite database with all tables."""
engine = create_engine("sqlite:///:memory:")
event.listen(engine, "connect", _set_sqlite_pragma_projects)
with engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(engine)
context.pt_engine = engine
context.pt_session_factory = sessionmaker(bind=engine)
context.pt_session = context.pt_session_factory()
# Storage for cross-step references
context.pt_project = None
context.pt_link = None
context.pt_resource = None
context.pt_resource_id = None
context.pt_error = None
context.pt_domain_project = None
context.pt_model_from_domain = None
# ---------------------------------------------------------------------------
# Table existence
# ---------------------------------------------------------------------------
@then('the project database should contain table "{table_name}"')
def step_project_table_exists(context: Any, table_name: str) -> None:
inspector = inspect(context.pt_engine)
tables = inspector.get_table_names()
assert table_name in tables, f"Table {table_name} not found. Tables: {tables}"
# ---------------------------------------------------------------------------
# Index existence
# ---------------------------------------------------------------------------
@then(
'the project database should have an index "{index_name}" on table "{table_name}"'
)
def step_project_index_exists(context: Any, index_name: str, table_name: str) -> None:
inspector = inspect(context.pt_engine)
indexes = inspector.get_indexes(table_name)
index_names = [idx["name"] for idx in indexes]
assert index_name in index_names, (
f"Index {index_name} not found on {table_name}. Indexes: {index_names}"
)
# ---------------------------------------------------------------------------
# Project CRUD
# ---------------------------------------------------------------------------
@when('I insert a project "{name}" with namespace "{namespace}"')
def step_insert_project(context: Any, name: str, namespace: str) -> None:
session: Session = context.pt_session
proj = NamespacedProjectModel(
namespaced_name=name,
namespace=namespace,
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
context.pt_project = proj
@when(
'I insert a described project "{name}" with namespace "{namespace}"'
' and description "{desc}"'
)
def step_insert_project_with_desc(
context: Any, name: str, namespace: str, desc: str
) -> None:
session: Session = context.pt_session
proj = NamespacedProjectModel(
namespaced_name=name,
namespace=namespace,
description=desc,
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
context.pt_project = proj
@when('I insert a project "{name}" with JSON fields')
def step_insert_project_with_json(context: Any, name: str) -> None:
session: Session = context.pt_session
proj = NamespacedProjectModel(
namespaced_name=name,
namespace="local",
invariants_json=json.dumps(["invariant-1", "invariant-2"]),
context_policy_json=json.dumps({"max_file_size": 500000}),
tags_json=json.dumps(["tag-a", "tag-b"]),
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
context.pt_project = proj
@then('I can retrieve project "{name}"')
def step_retrieve_project(context: Any, name: str) -> None:
session: Session = context.pt_session
proj = session.get(NamespacedProjectModel, name)
assert proj is not None, f"Project {name} not found"
context.pt_project = proj
@then('the retrieved project namespace is "{expected}"')
def step_project_namespace(context: Any, expected: str) -> None:
assert context.pt_project.namespace == expected
@then("the retrieved project description is empty")
def step_project_description_empty(context: Any) -> None:
assert context.pt_project.description is None
@then('the retrieved project description is "{expected}"')
def step_project_description(context: Any, expected: str) -> None:
assert context.pt_project.description == expected
@then('the retrieved project tags_json is "{expected}"')
def step_project_tags_json(context: Any, expected: str) -> None:
assert context.pt_project.tags_json == expected
@then("the project invariants_json round-trips correctly")
def step_project_invariants_roundtrip(context: Any) -> None:
data = json.loads(context.pt_project.invariants_json)
assert data == ["invariant-1", "invariant-2"]
@then("the project context_policy_json round-trips correctly")
def step_project_context_policy_roundtrip(context: Any) -> None:
data = json.loads(context.pt_project.context_policy_json)
assert data == {"max_file_size": 500000}
@then("the project tags_json round-trips as a list")
def step_project_tags_roundtrip(context: Any) -> None:
data = json.loads(context.pt_project.tags_json)
assert data == ["tag-a", "tag-b"]
@then('inserting a duplicate project "{name}" raises an integrity error')
def step_duplicate_project_error(context: Any, name: str) -> None:
session: Session = context.pt_session
dup = NamespacedProjectModel(
namespaced_name=name,
namespace="local",
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(dup)
try:
session.commit()
raise AssertionError("Expected IntegrityError for duplicate project")
except IntegrityError:
session.rollback()
# ---------------------------------------------------------------------------
# Resource + type setup helpers for link tests
# ---------------------------------------------------------------------------
@given('a resource type "{name}" exists in project db')
def step_resource_type_exists_project(context: Any, name: str) -> None:
session: Session = context.pt_session
existing = session.get(ResourceTypeModel, name)
if existing is not None:
return
namespace = name.split("/")[0]
rt = ResourceTypeModel()
rt.name = name
rt.namespace = namespace
rt.resource_kind = "physical"
rt.user_addable = True
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
session.commit()
@given('a resource "{name}" of type "{type_name}" exists in project db')
def step_resource_exists_project(context: Any, name: str, type_name: str) -> None:
session: Session = context.pt_session
# Generate a deterministic ULID from the resource name
ulid = _make_ulid(str(abs(hash(name)) % 10**20).zfill(20))
existing = session.get(ResourceModel, ulid)
if existing is not None:
context.pt_resource_id = ulid
return
r = ResourceModel()
r.resource_id = ulid
r.namespaced_name = name
r.namespace = name.split("/")[0]
r.type_name = type_name
r.resource_kind = "physical"
r.location = f"/tmp/{name}"
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.pt_resource_id = ulid
@given('a project "{name}" exists in project db')
def step_project_exists_project(context: Any, name: str) -> None:
session: Session = context.pt_session
existing = session.get(NamespacedProjectModel, name)
if existing is not None:
return
namespace = name.split("/")[0]
proj = NamespacedProjectModel(
namespaced_name=name,
namespace=namespace,
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
# ---------------------------------------------------------------------------
# Project resource links
# ---------------------------------------------------------------------------
@when(
'I create a project resource link for project "{project}" to resource "{resource}"'
)
def step_create_link(context: Any, project: str, resource: str) -> None:
session: Session = context.pt_session
# Resolve resource_id from namespaced_name
res = (
session.query(ResourceModel)
.filter(ResourceModel.namespaced_name == resource)
.first()
)
assert res is not None, f"Resource {resource} not found"
link_id = _make_ulid(str(abs(hash(f"{project}:{resource}")) % 10**20).zfill(20))
link = ProjectResourceLinkModel(
link_id=link_id,
project_name=project,
resource_id=res.resource_id,
read_only=False,
created_at=_now_iso(),
)
session.add(link)
session.commit()
context.pt_link = link
@when('I create a project resource link with alias "{alias}" and read_only {ro}')
def step_create_link_with_alias(context: Any, alias: str, ro: str) -> None:
session: Session = context.pt_session
# Use the most recently created resource and project
res = session.query(ResourceModel).first()
proj = session.query(NamespacedProjectModel).first()
assert res is not None and proj is not None
link_id = _make_ulid(str(abs(hash(f"alias:{alias}")) % 10**20).zfill(20))
link = ProjectResourceLinkModel(
link_id=link_id,
project_name=proj.namespaced_name,
resource_id=res.resource_id,
alias=alias,
read_only=ro.lower() == "true",
created_at=_now_iso(),
)
session.add(link)
session.commit()
context.pt_link = link
@then("the project resource link can be retrieved")
def step_link_retrievable(context: Any) -> None:
session: Session = context.pt_session
link = session.get(ProjectResourceLinkModel, context.pt_link.link_id)
assert link is not None, "Link not found"
context.pt_link = link
@then('the project resource link project_name is "{expected}"')
def step_link_project_name(context: Any, expected: str) -> None:
assert context.pt_link.project_name == expected
@then("the project resource link read_only is false")
def step_link_read_only_false(context: Any) -> None:
assert context.pt_link.read_only is False
@then("the project resource link read_only is true")
def step_link_read_only_true(context: Any) -> None:
assert context.pt_link.read_only is True
@then('the project resource link alias is "{expected}"')
def step_link_alias(context: Any, expected: str) -> None:
assert context.pt_link.alias == expected
@then(
'creating a duplicate link for project "{project}" to resource "{resource}"'
" raises an integrity error"
)
def step_duplicate_link_error(context: Any, project: str, resource: str) -> None:
session: Session = context.pt_session
res = (
session.query(ResourceModel)
.filter(ResourceModel.namespaced_name == resource)
.first()
)
assert res is not None
dup_link_id = _make_ulid("99999999999999999999999999"[:26])
dup = ProjectResourceLinkModel(
link_id=dup_link_id,
project_name=project,
resource_id=res.resource_id,
read_only=False,
created_at=_now_iso(),
)
session.add(dup)
try:
session.commit()
raise AssertionError("Expected IntegrityError for duplicate link")
except IntegrityError:
session.rollback()
# ---------------------------------------------------------------------------
# Cascade / restrict delete tests
# ---------------------------------------------------------------------------
@given('a project resource link exists for "{project}" to "{resource}"')
def step_link_exists(context: Any, project: str, resource: str) -> None:
session: Session = context.pt_session
res = (
session.query(ResourceModel)
.filter(ResourceModel.namespaced_name == resource)
.first()
)
assert res is not None
link_id = _make_ulid(
str(abs(hash(f"exist:{project}:{resource}")) % 10**20).zfill(20)
)
link = ProjectResourceLinkModel(
link_id=link_id,
project_name=project,
resource_id=res.resource_id,
read_only=False,
created_at=_now_iso(),
)
session.add(link)
session.commit()
context.pt_link = link
@when('I delete the project "{name}"')
def step_delete_project(context: Any, name: str) -> None:
session: Session = context.pt_session
proj = session.get(NamespacedProjectModel, name)
assert proj is not None
session.delete(proj)
session.commit()
@then('the project resource link for "{project}" should not exist')
def step_link_should_not_exist(context: Any, project: str) -> None:
session: Session = context.pt_session
links = (
session.query(ProjectResourceLinkModel)
.filter(ProjectResourceLinkModel.project_name == project)
.all()
)
assert len(links) == 0, f"Expected 0 links, found {len(links)}"
@then('deleting the linked resource "{resource}" raises an integrity error')
def step_delete_linked_resource_restricted(context: Any, resource: str) -> None:
session: Session = context.pt_session
res = (
session.query(ResourceModel)
.filter(ResourceModel.namespaced_name == resource)
.first()
)
assert res is not None
session.delete(res)
try:
session.commit()
raise AssertionError("Expected IntegrityError for RESTRICT delete")
except IntegrityError:
session.rollback()
# ---------------------------------------------------------------------------
# Domain conversion
# ---------------------------------------------------------------------------
@then("converting the project to domain returns a NamespacedProject")
def step_to_domain(context: Any) -> None:
from cleveragents.domain.models.core.project import NamespacedProject
domain = context.pt_project.to_domain()
assert isinstance(domain, NamespacedProject)
context.pt_domain_project = domain
@then('the domain project name is "{expected}"')
def step_domain_project_name(context: Any, expected: str) -> None:
assert context.pt_domain_project.name == expected
@then('the domain project namespace is "{expected}"')
def step_domain_project_namespace(context: Any, expected: str) -> None:
assert context.pt_domain_project.namespace == expected
@then('the domain project description is "{expected}"')
def step_domain_project_description(context: Any, expected: str) -> None:
assert context.pt_domain_project.description == expected
@when("I create a NamespacedProject domain model")
def step_create_domain_project(context: Any) -> None:
from cleveragents.domain.models.core.project import NamespacedProject
context.pt_domain_project = NamespacedProject(
name="from-domain-test",
namespace="local",
description="Created from domain",
)
@when("I convert it to a NamespacedProjectModel via from_domain")
def step_convert_from_domain(context: Any) -> None:
model = NamespacedProjectModel.from_domain(context.pt_domain_project)
context.pt_model_from_domain = model
@then('the model namespaced_name is "{expected}"')
def step_model_namespaced_name(context: Any, expected: str) -> None:
assert context.pt_model_from_domain.namespaced_name == expected
@then('the model namespace is "{expected}"')
def step_model_namespace(context: Any, expected: str) -> None:
assert context.pt_model_from_domain.namespace == expected