- ~60 new/extended test files across api, core, plugins, services, schemas: routes, superset clients, task_manager, lineage, git, translate, dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl - .coveragerc: enable branch coverage; exclude src/__tests__ (test files) and src/scripts (CLI/ops tools) from the denominator - bug fixes found while testing: * settings: PUT /settings/reports registered under duplicated prefix * schemas/lineage: FleetReportDTO missing run_status (route always 500) * dashboard_testing/baseline_inheritance: visual entry read wrong field * superset_client/_databases: logger extra name shadowed LogRecord attr * routes/datasets: _yaml_string_paths recursion without yield from * translate/sql_generator: restore explicit-type timestamp contract * baseline_catalog: remove unreachable dashboard_id fallback - conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test filename collision, TMPDIR-safe integration fixtures
310 lines
14 KiB
Python
310 lines
14 KiB
Python
# #region Test.AgentLifecycleService [C:3] [TYPE Module] [SEMANTICS testing,agent,lifecycle,service,crud]
|
|
# @BRIEF Direct service-level tests for agent_lifecycle_service — write_event + list_events against real SQLite.
|
|
# @RELATION BINDS_TO -> [Services.AgentLifecycleService]
|
|
# @TEST_CONTRACT: write_event(EventWriteRequest) -> EventWriteResponse + persisted AgentLifecycleEvent row
|
|
# @TEST_CONTRACT: list_events(filters, pagination) -> EventListResponse with user-scoped items + total
|
|
# @TEST_EDGE: failed_suffix -> _FAILED event_type routes to EXPLORE log with error_code surfaced
|
|
# @TEST_EDGE: missing_payload -> None payload/error_code merge into response without error
|
|
# @TEST_EDGE: user_scope -> non-admin only sees own events; admin user_id filter; admin sees all
|
|
# @TEST_EDGE: filter_combination -> event_type/conversation_id/status/tool_name filters narrow results
|
|
# @TEST_EDGE: pagination_edges -> has_next true/false boundaries, empty result, newest-first ordering
|
|
# @TEST_INVARIANT: Non-admin users only see their own events -> VERIFIED_BY: user_scope
|
|
# @RATIONALE API route tests patch the service functions; these exercise the service itself so the
|
|
# server-side user_id scope filter and immutable write path are verified for real.
|
|
# Hardcoded fixtures only — no expected values computed from the module under test.
|
|
# #endregion
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from src.models.agent import AgentLifecycleEvent
|
|
from src.schemas.agent_lifecycle import EventWriteRequest
|
|
from src.services.agent_lifecycle_service import list_events, write_event
|
|
|
|
|
|
@pytest.fixture
|
|
def _engine():
|
|
"""Module-isolated SQLite engine with all tables created."""
|
|
engine = create_engine(
|
|
"sqlite:///file::memory:?cache=shared&uri=true",
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON"))
|
|
from src.models.mapping import Base
|
|
Base.metadata.create_all(bind=engine)
|
|
try:
|
|
yield engine
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def db_session(_engine) -> Session:
|
|
"""Fresh per-test session; rollback+close on teardown."""
|
|
db = sessionmaker(bind=_engine)()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.rollback()
|
|
db.close()
|
|
|
|
|
|
def _insert_event(
|
|
db: Session,
|
|
*,
|
|
user_id: str,
|
|
event_type: str = "run_started",
|
|
conversation_id: str = "conv-1",
|
|
environment_id: str | None = "env-1",
|
|
tool_name: str | None = None,
|
|
status: str | None = None,
|
|
elapsed_ms: float | None = None,
|
|
payload: dict | None = None,
|
|
error_code: str | None = None,
|
|
created_at: datetime | None = None,
|
|
) -> AgentLifecycleEvent:
|
|
"""Insert a hardcoded lifecycle event row directly (DB boundary, not the SUT)."""
|
|
ev = AgentLifecycleEvent(
|
|
trace_id="trace-fixture",
|
|
conversation_id=conversation_id,
|
|
user_id=user_id,
|
|
environment_id=environment_id,
|
|
event_type=event_type,
|
|
tool_name=tool_name,
|
|
status=status,
|
|
elapsed_ms=elapsed_ms,
|
|
payload=payload,
|
|
error_code=error_code,
|
|
created_at=created_at or datetime.now(timezone.utc),
|
|
)
|
|
db.add(ev)
|
|
db.commit()
|
|
return ev
|
|
|
|
|
|
# ── _now helper ──────────────────────────────────────────────
|
|
|
|
|
|
# #region Test.AgentLifecycleService.Now [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,time]
|
|
# @BRIEF _now returns a timezone-aware UTC datetime.
|
|
def test_now_returns_utc_aware_datetime():
|
|
from src.services.agent_lifecycle_service import _now
|
|
result = _now()
|
|
assert result.tzinfo is not None
|
|
assert result.utcoffset().total_seconds() == 0 # UTC offset
|
|
# #endregion Test.AgentLifecycleService.Now
|
|
|
|
|
|
# ── write_event ───────────────────────────────────────────────
|
|
|
|
|
|
# #region Test.AgentLifecycleService.WriteEvent.Failed [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,write,failure]
|
|
# @BRIEF _FAILED event_type is persisted with error_code and routed to the EXPLORE log branch.
|
|
def test_write_event_persists_failed_event(db_session: Session):
|
|
"""write_event persists a _FAILED event; response id matches the flushed row."""
|
|
req = EventWriteRequest(
|
|
trace_id="trace-1",
|
|
conversation_id="conv-1",
|
|
environment_id="env-1",
|
|
event_type="tool_execution_FAILED",
|
|
tool_name="query",
|
|
status="failed",
|
|
elapsed_ms=123.4,
|
|
payload={"action": "run", "attempt": 2},
|
|
error_code="E_TIMEOUT",
|
|
)
|
|
resp = write_event(db_session, req, authenticated_user_id="auth-user")
|
|
|
|
row = db_session.query(AgentLifecycleEvent).filter(AgentLifecycleEvent.id == resp.id).one()
|
|
assert resp.id == row.id
|
|
assert resp.written is True
|
|
assert row.user_id == "auth-user"
|
|
assert row.trace_id == "trace-1"
|
|
assert row.conversation_id == "conv-1"
|
|
assert row.environment_id == "env-1"
|
|
assert row.event_type == "tool_execution_FAILED"
|
|
assert row.tool_name == "query"
|
|
assert row.status == "failed"
|
|
assert row.elapsed_ms == 123.4
|
|
assert row.error_code == "E_TIMEOUT"
|
|
assert row.payload == {"action": "run", "attempt": 2}
|
|
# #endregion Test.AgentLifecycleService.WriteEvent.Failed
|
|
|
|
|
|
# #region Test.AgentLifecycleService.WriteEvent.Success [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,write,success]
|
|
# @BRIEF Non-_FAILED event routes to the REFLECT log branch with optional fields left unset.
|
|
def test_write_event_persists_success_event(db_session: Session):
|
|
"""write_event with optional fields None — payload merge must tolerate None."""
|
|
req = EventWriteRequest(
|
|
trace_id="trace-2",
|
|
conversation_id="conv-2",
|
|
environment_id=None,
|
|
event_type="run_completed",
|
|
tool_name=None,
|
|
status=None,
|
|
elapsed_ms=None,
|
|
payload=None,
|
|
error_code=None,
|
|
)
|
|
resp = write_event(db_session, req, authenticated_user_id="u2")
|
|
|
|
row = db_session.query(AgentLifecycleEvent).filter(AgentLifecycleEvent.id == resp.id).one()
|
|
assert row.environment_id is None
|
|
assert row.tool_name is None
|
|
assert row.status is None
|
|
assert row.elapsed_ms is None
|
|
assert row.payload is None
|
|
assert row.error_code is None
|
|
# #endregion Test.AgentLifecycleService.WriteEvent.Success
|
|
|
|
|
|
# #region Test.AgentLifecycleService.WriteEvent.UserOverride [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,write,scope]
|
|
# @BRIEF authenticated_user_id is authoritative — stored user_id never comes from the request.
|
|
def test_write_event_authenticated_user_id_is_authoritative(db_session: Session):
|
|
"""Two writes for different authenticated users land under their own user_id."""
|
|
for idx, auth_user in enumerate(("alice", "bob")):
|
|
req = EventWriteRequest(
|
|
trace_id=f"trace-{idx}",
|
|
conversation_id=f"conv-{idx}",
|
|
environment_id="env-1",
|
|
event_type="run_started",
|
|
)
|
|
resp = write_event(db_session, req, authenticated_user_id=auth_user)
|
|
row = db_session.query(AgentLifecycleEvent).filter(AgentLifecycleEvent.id == resp.id).one()
|
|
assert row.user_id == auth_user
|
|
# #endregion Test.AgentLifecycleService.WriteEvent.UserOverride
|
|
|
|
|
|
# ── list_events: user scope ──────────────────────────────────
|
|
|
|
|
|
# #region Test.AgentLifecycleService.ListEvents.NonAdminScope [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,list,scope]
|
|
# @BRIEF Non-admin users only see their own events (server-side user_id filter).
|
|
def test_list_events_non_admin_scoped_to_own_user(db_session: Session):
|
|
"""3 events for user-a, 2 for user-b — user-a sees exactly its own 3."""
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
for i in range(3):
|
|
_insert_event(db_session, user_id="user-a", created_at=base + timedelta(minutes=i))
|
|
for i in range(2):
|
|
_insert_event(db_session, user_id="user-b", created_at=base + timedelta(hours=1, minutes=i))
|
|
|
|
resp = list_events(db_session, authenticated_user_id="user-a")
|
|
|
|
assert resp.total == 3
|
|
assert {item.user_id for item in resp.items} == {"user-a"}
|
|
assert resp.page == 1
|
|
assert resp.page_size == 50
|
|
assert resp.has_next is False
|
|
# #endregion Test.AgentLifecycleService.ListEvents.NonAdminScope
|
|
|
|
|
|
# #region Test.AgentLifecycleService.ListEvents.AdminFilter [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,list,admin]
|
|
# @BRIEF Admin with user_id filters to that user; admin without user_id sees all events.
|
|
def test_list_events_admin_user_id_filter(db_session: Session):
|
|
_insert_event(db_session, user_id="user-a")
|
|
_insert_event(db_session, user_id="user-b")
|
|
|
|
scoped = list_events(db_session, authenticated_user_id="admin", is_admin=True, user_id="user-b")
|
|
assert scoped.total == 1
|
|
assert [item.user_id for item in scoped.items] == ["user-b"]
|
|
|
|
all_events = list_events(db_session, authenticated_user_id="admin", is_admin=True)
|
|
assert all_events.total == 2
|
|
assert {item.user_id for item in all_events.items} == {"user-a", "user-b"}
|
|
# #endregion Test.AgentLifecycleService.ListEvents.AdminFilter
|
|
|
|
|
|
# #region Test.AgentLifecycleService.ListEvents.Empty [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,list,empty]
|
|
# @BRIEF No events at all — empty items, zero total, has_next False.
|
|
def test_list_events_empty_result(db_session: Session):
|
|
resp = list_events(db_session, authenticated_user_id="nobody")
|
|
assert resp.items == []
|
|
assert resp.total == 0
|
|
assert resp.has_next is False
|
|
# #endregion Test.AgentLifecycleService.ListEvents.Empty
|
|
|
|
|
|
# ── list_events: optional filters ────────────────────────────
|
|
|
|
|
|
# #region Test.AgentLifecycleService.ListEvents.Filters [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,list,filters]
|
|
# @BRIEF Each optional filter narrows the result set independently and in combination.
|
|
def test_list_events_optional_filters(db_session: Session):
|
|
_insert_event(db_session, user_id="u1", event_type="tool_call", conversation_id="c1", status="ok", tool_name="query")
|
|
_insert_event(db_session, user_id="u1", event_type="tool_call", conversation_id="c2", status="failed", tool_name="query")
|
|
_insert_event(db_session, user_id="u1", event_type="llm_call", conversation_id="c1", status="ok", tool_name="llm")
|
|
|
|
by_type = list_events(db_session, authenticated_user_id="u1", event_type="tool_call")
|
|
assert by_type.total == 2
|
|
|
|
by_conversation = list_events(db_session, authenticated_user_id="u1", conversation_id="c1")
|
|
assert by_conversation.total == 2
|
|
|
|
by_status = list_events(db_session, authenticated_user_id="u1", status="failed")
|
|
assert by_status.total == 1
|
|
assert by_status.items[0].event_type == "tool_call"
|
|
|
|
by_tool = list_events(db_session, authenticated_user_id="u1", tool_name="llm")
|
|
assert by_tool.total == 1
|
|
assert by_tool.items[0].event_type == "llm_call"
|
|
|
|
combined = list_events(
|
|
db_session, authenticated_user_id="u1",
|
|
event_type="tool_call", conversation_id="c1", status="ok", tool_name="query",
|
|
)
|
|
assert combined.total == 1
|
|
# #endregion Test.AgentLifecycleService.ListEvents.Filters
|
|
|
|
|
|
# ── list_events: pagination ──────────────────────────────────
|
|
|
|
|
|
# #region Test.AgentLifecycleService.ListEvents.Pagination [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,list,pagination]
|
|
# @BRIEF Pagination boundaries: newest-first ordering, has_next flips exactly at the last page.
|
|
def test_list_events_pagination_newest_first_and_has_next(db_session: Session):
|
|
base = datetime(2026, 2, 1, tzinfo=timezone.utc)
|
|
for i in range(5):
|
|
_insert_event(db_session, user_id="u1", event_type=f"step_{i}", created_at=base + timedelta(minutes=i))
|
|
|
|
page1 = list_events(db_session, authenticated_user_id="u1", page=1, page_size=2)
|
|
assert page1.total == 5
|
|
assert len(page1.items) == 2
|
|
assert page1.has_next is True
|
|
# Newest first: step_4 (created last) leads page 1
|
|
assert [item.event_type for item in page1.items] == ["step_4", "step_3"]
|
|
|
|
page3 = list_events(db_session, authenticated_user_id="u1", page=3, page_size=2)
|
|
assert len(page3.items) == 1
|
|
assert [item.event_type for item in page3.items] == ["step_0"]
|
|
assert page3.has_next is False
|
|
# #endregion Test.AgentLifecycleService.ListEvents.Pagination
|
|
|
|
|
|
# #region Test.AgentLifecycleService.ListEvents.DefaultPageSize [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,list,defaults]
|
|
# @BRIEF Default page_size (50) and page (1) are applied when not supplied.
|
|
def test_list_events_default_pagination_params(db_session: Session):
|
|
_insert_event(db_session, user_id="u1", event_type="step_0")
|
|
resp = list_events(db_session, authenticated_user_id="u1")
|
|
assert resp.page == 1
|
|
assert resp.page_size == 50
|
|
assert resp.total == 1
|
|
# #endregion Test.AgentLifecycleService.ListEvents.DefaultPageSize
|
|
|
|
|
|
# #region Test.AgentLifecycleService.ListEvents.PageBeyond [C:2] [TYPE Function] [SEMANTICS test,agent,lifecycle,list,overflow]
|
|
# @BRIEF Page beyond the last one returns empty items without error.
|
|
def test_list_events_page_beyond_total(db_session: Session):
|
|
_insert_event(db_session, user_id="u1", event_type="step_0")
|
|
resp = list_events(db_session, authenticated_user_id="u1", page=99, page_size=10)
|
|
assert resp.total == 1
|
|
assert resp.items == []
|
|
assert resp.has_next is False
|
|
# #endregion Test.AgentLifecycleService.ListEvents.PageBeyond
|
|
|
|
# #endregion Test.AgentLifecycleService
|