Files
cleveragents-core/features/steps/project_repository_steps.py
T
HAL9000 dd80d05558
CI / lint (pull_request) Successful in 37s
CI / typecheck (pull_request) Successful in 1m5s
CI / push-validation (pull_request) Successful in 39s
CI / quality (pull_request) Successful in 1m8s
CI / build (pull_request) Successful in 53s
CI / helm (pull_request) Successful in 57s
CI / security (pull_request) Successful in 1m25s
CI / unit_tests (pull_request) Successful in 8m49s
CI / integration_tests (pull_request) Successful in 9m50s
CI / docker (pull_request) Successful in 1m47s
CI / coverage (pull_request) Successful in 12m55s
CI / status-check (pull_request) Successful in 3s
fix(tests): align BDD scenarios with rollback-removal behaviour (PR #8179)
Three CI gates were failing on this PR; this commit addresses the root
causes for each:

* lint (ruff format): drop the blank line between the docstring close
  and first statement in step_pr_create_with_error, and add the missing
  second blank line between step_pr_check_remove_link_persisted and the
  "Data integrity BDD step extensions" section comment block.

* unit_tests: two scenarios were inverted by `@tdd_expected_fail` on
  post-fix assertions, masking unrelated test-logic problems.
  - Remove `@tdd_expected_fail` from both `@tdd_issue_8179` scenarios in
    project_repository.feature - they describe post-fix behaviour and
    must report PASS as PASS, not as inverted-FAIL.
  - Drop the "Given project exists" precondition from the Update-non-
    existent scenario; the Background already initialises the in-memory
    DB and creating the same project being "updated as non-existent" is
    self-contradictory (caused the prior scenario to silently report
    inverted-PASS while actually never raising).
  - Update the OperationalError scenario in database_repository_coverage
    to assert the post-fix invariant: the repository no longer calls
    session.rollback() itself; that responsibility is delegated to the
    outer UnitOfWork.  Step text + assertion both flipped.

ISSUES CLOSED: #8179
2026-06-14 11:36:15 -04:00

594 lines
20 KiB
Python

"""Step definitions for project repository tests.
Tests the NamespacedProjectRepository and ProjectResourceLinkRepository
classes with an in-memory SQLite database.
"""
from __future__ import annotations
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, text
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.domain.models.core.project import NamespacedProject
from cleveragents.infrastructure.database.models import (
Base,
NamespacedProjectModel,
ResourceModel,
ResourceTypeModel,
)
from cleveragents.infrastructure.database.repositories import (
DuplicateLinkError, # noqa: F401
NamespacedProjectRepository,
ProjectNotFoundError,
ProjectResourceLinkRepository,
)
def _now_iso() -> str:
"""Return current UTC time as ISO-8601 string."""
return datetime.now(tz=UTC).isoformat()
def _set_sqlite_pragma_pr(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()
def _setup_resource_type_pr(session: Session) -> None:
"""Create a resource type so FK constraints pass for links."""
existing = (
session.query(ResourceTypeModel).filter_by(name="builtin/git-checkout").first()
)
if existing is not None:
return
rt = ResourceTypeModel()
rt.name = "builtin/git-checkout" # type: ignore[assignment]
rt.namespace = "builtin" # type: ignore[assignment]
rt.resource_kind = "physical" # type: ignore[assignment]
rt.user_addable = True # type: ignore[assignment]
rt.created_at = _now_iso() # type: ignore[assignment]
rt.updated_at = _now_iso() # type: ignore[assignment]
session.add(rt)
session.commit()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a project repository in-memory database is initialized")
def step_pr_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_pr)
with engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(engine)
sf = sessionmaker(bind=engine)
context.pr_engine = engine
context.pr_session_factory = sf
session = sf()
context.pr_session = session
# Set up the resource type for FK constraints
_setup_resource_type_pr(context.pr_session)
# Use a lambda that always returns the SAME session so that
# context.pr_session.commit() commits data flushed by the repos.
context.pr_project_repo = NamespacedProjectRepository(
session_factory=lambda: session,
)
context.pr_link_repo = ProjectResourceLinkRepository(
session_factory=lambda: session,
)
context.pr_error = None
context.pr_result = None
context.pr_project = None
context.pr_projects = []
context.pr_link = None
context.pr_links = []
context.pr_delete_result = None
context.pr_remove_result = None
context.pr_stored_link_id = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_project(namespaced_name: str) -> NamespacedProject:
"""Create a minimal NamespacedProject domain object."""
parts = namespaced_name.split("/", 1)
namespace = parts[0] if len(parts) > 1 else "local"
name = parts[1] if len(parts) > 1 else parts[0]
return NamespacedProject(name=name, namespace=namespace)
def _create_resource(session: Session, resource_id: str) -> None:
"""Create a minimal resource so FK constraints pass."""
existing = session.query(ResourceModel).filter_by(resource_id=resource_id).first()
if existing is not None:
return
r = ResourceModel()
r.resource_id = resource_id # type: ignore[assignment]
r.type_name = "builtin/git-checkout" # type: ignore[assignment]
r.resource_kind = "physical" # type: ignore[assignment]
r.created_at = _now_iso() # type: ignore[assignment]
r.updated_at = _now_iso() # type: ignore[assignment]
session.add(r)
session.commit()
# ---------------------------------------------------------------------------
# NamespacedProjectRepository steps
# ---------------------------------------------------------------------------
@when('I create a namespaced project "{ns_name}" via the repository')
def step_pr_create_project(context: Any, ns_name: str) -> None:
project = _make_project(ns_name)
context.pr_project = context.pr_project_repo.create(project)
context.pr_session.commit()
@then('the project "{ns_name}" should exist in the repository')
def step_pr_project_exists(context: Any, ns_name: str) -> None:
project = context.pr_project_repo.get(ns_name)
assert project is not None, f"Project {ns_name} not found"
@given('a namespaced project "{ns_name}" exists in the repository')
def step_pr_given_project(context: Any, ns_name: str) -> None:
project = _make_project(ns_name)
context.pr_project_repo.create(project)
context.pr_session.commit()
@when('I get the project "{ns_name}" from the repository')
def step_pr_get_project(context: Any, ns_name: str) -> None:
context.pr_project = context.pr_project_repo.get(ns_name)
@then('the returned project name should be "{name}"')
def step_pr_check_name(context: Any, name: str) -> None:
assert context.pr_project.name == name
@then('the returned project namespace should be "{ns}"')
def step_pr_check_namespace(context: Any, ns: str) -> None:
assert context.pr_project.namespace == ns
@when('I get the project "{ns_name}" from the repository expecting an error')
def step_pr_get_project_error(context: Any, ns_name: str) -> None:
try:
context.pr_project_repo.get(ns_name)
context.pr_error = None
except Exception as exc:
context.pr_error = exc
@then('the repository error should be "{error_type}"')
def step_pr_check_error_type(context: Any, error_type: str) -> None:
assert context.pr_error is not None, "Expected an error but none was raised"
actual_type = type(context.pr_error).__name__
assert actual_type == error_type, f"Expected {error_type}, got {actual_type}"
@when("I list all projects from the repository")
def step_pr_list_all(context: Any) -> None:
context.pr_projects = context.pr_project_repo.list_projects()
@then("the project list should contain {count:d} projects")
def step_pr_list_count(context: Any, count: int) -> None:
assert len(context.pr_projects) == count, (
f"Expected {count}, got {len(context.pr_projects)}"
)
@when('I list projects with namespace "{ns}" from the repository')
def step_pr_list_namespace(context: Any, ns: str) -> None:
context.pr_projects = context.pr_project_repo.list_projects(namespace=ns)
@then('the first project in the list should be "{ns_name}"')
def step_pr_first_project(context: Any, ns_name: str) -> None:
assert len(context.pr_projects) > 0, "Project list is empty"
assert context.pr_projects[0].namespaced_name == ns_name
@when("I list projects with limit {limit:d} from the repository")
def step_pr_list_limit(context: Any, limit: int) -> None:
context.pr_projects = context.pr_project_repo.list_projects(limit=limit)
@when('I update the project "{ns_name}" description to "{desc}"')
def step_pr_update_desc(context: Any, ns_name: str, desc: str) -> None:
project = context.pr_project_repo.get(ns_name)
updated = project.model_copy(
update={
"description": desc,
"updated_at": datetime.now(tz=UTC),
}
)
context.pr_project_repo.update(updated)
context.pr_session.commit()
@then('the project "{ns_name}" description should be "{desc}"')
def step_pr_check_desc(context: Any, ns_name: str, desc: str) -> None:
project = context.pr_project_repo.get(ns_name)
assert project.description == desc, (
f"Expected '{desc}', got '{project.description}'"
)
@when('I update a non-existent project "{ns_name}" expecting an error')
def step_pr_update_not_found(context: Any, ns_name: str) -> None:
try:
fake = _make_project(ns_name)
context.pr_project_repo.update(fake)
context.pr_error = None
except Exception as exc:
context.pr_error = exc
@when('I delete the project "{ns_name}" from the repository')
def step_pr_delete_project(context: Any, ns_name: str) -> None:
context.pr_delete_result = context.pr_project_repo.delete(ns_name)
context.pr_session.commit()
@then("the delete result should be True")
def step_pr_delete_true(context: Any) -> None:
assert context.pr_delete_result is True
@then("the delete result should be False")
def step_pr_delete_false(context: Any) -> None:
assert context.pr_delete_result is False
@then('getting "{ns_name}" should raise ProjectNotFoundError')
def step_pr_get_raises(context: Any, ns_name: str) -> None:
raised = False
try:
context.pr_project_repo.get(ns_name)
except ProjectNotFoundError:
raised = True
assert raised, f"Expected ProjectNotFoundError for {ns_name}"
@given('a resource link exists for project "{ns_name}"')
def step_pr_given_link_for_cascade(context: Any, ns_name: str) -> None:
"""Create a resource and link it to the project for cascade testing."""
resource_id = "00000000000000000000000099"
_create_resource(context.pr_session, resource_id)
context.pr_link_repo.create_link(
project_name=ns_name,
resource_id=resource_id,
)
context.pr_session.commit()
@then('the links for project "{ns_name}" should be empty')
def step_pr_links_empty(context: Any, ns_name: str) -> None:
links = context.pr_link_repo.list_links(ns_name)
assert len(links) == 0, f"Expected 0 links, got {len(links)}"
@when('I create a duplicate project "{ns_name}" expecting an error')
def step_pr_create_dup(context: Any, ns_name: str) -> None:
"""Attempt to create a duplicate project.
Uses a direct session insert to bypass retry logic and reliably
trigger the IntegrityError / DatabaseError.
"""
from sqlalchemy.exc import IntegrityError as _IntegrityError
session = context.pr_session_factory()
try:
dup_model = NamespacedProjectModel(
namespaced_name=ns_name,
namespace=ns_name.split("/")[0],
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(dup_model)
session.flush()
session.commit()
context.pr_error = None
except _IntegrityError:
session.rollback()
from cleveragents.core.exceptions import DatabaseError as _DBErr
context.pr_error = _DBErr(f"Project '{ns_name}' already exists")
except Exception as exc:
session.rollback()
context.pr_error = exc
@then('the repository error message should contain "{text}"')
def step_pr_error_contains(context: Any, text: str) -> None:
assert context.pr_error is not None, "Expected an error"
assert text in str(context.pr_error), f"Expected '{text}' in '{context.pr_error}'"
# ---------------------------------------------------------------------------
# ProjectResourceLinkRepository steps
# ---------------------------------------------------------------------------
@given('a resource exists with id "{resource_id}"')
def step_pr_given_resource(context: Any, resource_id: str) -> None:
_create_resource(context.pr_session, resource_id)
@when('I create a link for project "{ns_name}" to resource "{resource_id}"')
def step_pr_create_link(context: Any, ns_name: str, resource_id: str) -> None:
context.pr_link = context.pr_link_repo.create_link(
project_name=ns_name,
resource_id=resource_id,
)
context.pr_session.commit()
context.pr_stored_link_id = context.pr_link.link_id
@then("the link should have a valid link_id")
def step_pr_link_id_valid(context: Any) -> None:
assert context.pr_link is not None
assert context.pr_link.link_id is not None
assert len(str(context.pr_link.link_id)) == 26
@then('the link project_name should be "{ns_name}"')
def step_pr_link_project_name(context: Any, ns_name: str) -> None:
assert str(context.pr_link.project_name) == ns_name
@when(
'I create a link for project "{ns_name}" to resource "{resource_id}" without alias'
)
def step_pr_create_link_no_alias(context: Any, ns_name: str, resource_id: str) -> None:
context.pr_link = context.pr_link_repo.create_link(
project_name=ns_name,
resource_id=resource_id,
)
context.pr_session.commit()
context.pr_stored_link_id = context.pr_link.link_id
@when(
'I create an aliased link for project "{ns_name}" resource "{resource_id}" alias "{alias}"'
)
def step_pr_create_link_alias(
context: Any, ns_name: str, resource_id: str, alias: str
) -> None:
context.pr_link = context.pr_link_repo.create_link(
project_name=ns_name,
resource_id=resource_id,
alias=alias,
)
context.pr_session.commit()
context.pr_stored_link_id = context.pr_link.link_id
@then('the link alias should be "{alias}"')
def step_pr_link_alias(context: Any, alias: str) -> None:
assert str(context.pr_link.alias) == alias
@given(
'an aliased link exists for project "{ns_name}" resource "{resource_id}" alias "{alias}"'
)
def step_pr_given_link_alias(
context: Any, ns_name: str, resource_id: str, alias: str
) -> None:
context.pr_link = context.pr_link_repo.create_link(
project_name=ns_name,
resource_id=resource_id,
alias=alias,
)
context.pr_session.commit()
context.pr_stored_link_id = context.pr_link.link_id
@when(
'I create a duplicate alias link for project "{ns_name}" resource "{resource_id}" alias "{alias}"'
)
def step_pr_create_link_alias_error(
context: Any, ns_name: str, resource_id: str, alias: str
) -> None:
try:
context.pr_link_repo.create_link(
project_name=ns_name,
resource_id=resource_id,
alias=alias,
)
context.pr_session.commit()
context.pr_error = None
except Exception as exc:
context.pr_error = exc
@given(
'a link exists for project "{ns_name}" to resource "{resource_id}" without alias'
)
def step_pr_given_link(context: Any, ns_name: str, resource_id: str) -> None:
context.pr_link = context.pr_link_repo.create_link(
project_name=ns_name,
resource_id=resource_id,
)
context.pr_session.commit()
context.pr_stored_link_id = context.pr_link.link_id
@when('I list links for project "{ns_name}"')
def step_pr_list_links(context: Any, ns_name: str) -> None:
context.pr_links = context.pr_link_repo.list_links(ns_name)
@then("the link list should contain {count:d} links")
def step_pr_link_count(context: Any, count: int) -> None:
assert len(context.pr_links) == count, (
f"Expected {count}, got {len(context.pr_links)}"
)
@when("I get the link by its stored id")
def step_pr_get_link_by_stored(context: Any) -> None:
assert context.pr_stored_link_id is not None
context.pr_link = context.pr_link_repo.get_link(context.pr_stored_link_id)
@then("the returned link should not be None")
def step_pr_link_not_none(context: Any) -> None:
assert context.pr_link is not None
@when('I get a link with id "{link_id}"')
def step_pr_get_link_by_id(context: Any, link_id: str) -> None:
context.pr_link = context.pr_link_repo.get_link(link_id)
@then("the returned link should be None")
def step_pr_link_is_none(context: Any) -> None:
assert context.pr_link is None
@when("I remove the link by its stored id")
def step_pr_remove_link_by_stored(context: Any) -> None:
assert context.pr_stored_link_id is not None
context.pr_remove_result = context.pr_link_repo.remove_link(
context.pr_stored_link_id
)
context.pr_session.commit()
@then("the remove result should be True")
def step_pr_remove_true(context: Any) -> None:
assert context.pr_remove_result is True
@then('the link list for project "{ns_name}" should be empty')
def step_pr_link_list_empty(context: Any, ns_name: str) -> None:
links = context.pr_link_repo.list_links(ns_name)
assert len(links) == 0, f"Expected 0 links, got {len(links)}"
@when('I remove a link with id "{link_id}"')
def step_pr_remove_link_by_id(context: Any, link_id: str) -> None:
context.pr_remove_result = context.pr_link_repo.remove_link(link_id)
@then("the project repo remove result should be False")
def step_pr_remove_false(context: Any) -> None:
assert context.pr_remove_result is False
@when('I create a read-only link for project "{ns_name}" to resource "{resource_id}"')
def step_pr_create_ro_link(context: Any, ns_name: str, resource_id: str) -> None:
context.pr_link = context.pr_link_repo.create_link(
project_name=ns_name,
resource_id=resource_id,
read_only=True,
)
context.pr_session.commit()
@then("the link read_only flag should be True")
def step_pr_link_readonly(context: Any) -> None:
assert bool(context.pr_link.read_only) is True
# ---------------------------------------------------------------------------
# Cross-session persistence verification steps
# ---------------------------------------------------------------------------
@then("the link should be visible in a new session from the same engine")
def step_pr_link_visible_new_session(context: Any) -> None:
"""Open a fresh session from the same engine and verify the link exists.
This guards against the bug where ``create_link()`` only called
``flush()`` without ``commit()``, making data visible within the
same session but lost when a different session queries the database.
"""
from cleveragents.infrastructure.database.models import ProjectResourceLinkModel
new_session = context.pr_session_factory()
try:
row = (
new_session.query(ProjectResourceLinkModel)
.filter_by(link_id=context.pr_stored_link_id)
.first()
)
assert row is not None, (
f"Link {context.pr_stored_link_id} not found in a new session — "
"commit() may be missing"
)
finally:
new_session.close()
@then("the removed link should be absent in a new session from the same engine")
def step_pr_removed_link_absent_new_session(context: Any) -> None:
"""Open a fresh session and confirm the removed link no longer exists.
This guards against the bug where ``remove_link()`` only called
``flush()`` without ``commit()``, making the deletion visible within
the same session but not persisted across sessions.
"""
from cleveragents.infrastructure.database.models import ProjectResourceLinkModel
new_session = context.pr_session_factory()
try:
row = (
new_session.query(ProjectResourceLinkModel)
.filter_by(link_id=context.pr_stored_link_id)
.first()
)
assert row is None, (
f"Link {context.pr_stored_link_id} still present in a new session — "
"commit() may be missing from remove_link()"
)
finally:
new_session.close()
# ---------------------------------------------------------------------------
# Data integrity BDD step extensions (PR #8179)
# ---------------------------------------------------------------------------
# Note: `step_pr_update_not_found` (line ~236, non-duplicate) already handles the
# "when I update a non-existent project … expecting an error" Scenario step.
@when('I create a namespaced project "{ns_name}" via the repository expecting an error')
def step_pr_create_with_error(context: Any, ns_name: str) -> None:
"""Attempt to create a duplicate project through the repository.
Exercises the NamespacedProjectRepository's own IntegrityError handling path
(the repo owns its session via session-factory and is NOT wired into any
UnitOfWork). Verifies that error propagation works correctly after the
redundant ``session.rollback()`` was removed from exception handlers —
``session.rollback()`` now fires in the ``finally`` block instead.
"""
project = _make_project(ns_name)
try:
context.pr_project = context.pr_project_repo.create(project)
context.pr_error = None
except Exception as exc:
context.pr_error = exc