Files
ss-tools/backend/tests/services/test_agent_lifecycle_service.py
busya 488a8f349b test(backend): raise coverage to 95%+ statements and branches (97.8%/95.0%)
- ~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
2026-08-19 17:14:32 +03:00

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