119 lines
4.1 KiB
Python
119 lines
4.1 KiB
Python
|
|
"""Tests for the read-only dashboard FastAPI app (``agent_team.dashboard``).
|
||
|
|
|
||
|
|
Exercises the three JSON endpoints against a seeded temp ledger via FastAPI's
|
||
|
|
TestClient: /api/state (contract preserved + per-node live state), /api/topology,
|
||
|
|
and /api/task/{id} (timeline assembly, thread_id validation, partial fallback,
|
||
|
|
cost join). Guarded so the suite still runs where FastAPI is absent.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import importlib.util
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from agent_team.db import TransitionRecorder, connect, init_db
|
||
|
|
from agent_team.dashboard import task_detail
|
||
|
|
|
||
|
|
# Reuse the seeded-ledger helper from the status-page tests (sibling module,
|
||
|
|
# importable by bare name under pytest's default prepend import mode).
|
||
|
|
from test_status_page import seed_ledger
|
||
|
|
|
||
|
|
_HAS_FASTAPI = importlib.util.find_spec("fastapi") is not None
|
||
|
|
pytestmark = pytest.mark.skipif(_HAS_FASTAPI is False, reason="fastapi not installed")
|
||
|
|
|
||
|
|
|
||
|
|
def _client(db: Path):
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
|
||
|
|
from agent_team.dashboard import make_dashboard_app
|
||
|
|
|
||
|
|
return TestClient(make_dashboard_app(db_path=db))
|
||
|
|
|
||
|
|
|
||
|
|
def _seed_budget(db: Path, thread_id: str, stage: str, usd: float) -> None:
|
||
|
|
conn = connect(db)
|
||
|
|
try:
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO budget_ledger (thread_id, stage, model, billing_mode, "
|
||
|
|
"usd_cost, recorded_at, day_bucket) VALUES (?,?,?,?,?,?,?)",
|
||
|
|
(
|
||
|
|
thread_id,
|
||
|
|
stage,
|
||
|
|
"claude",
|
||
|
|
"subscription",
|
||
|
|
usd,
|
||
|
|
"2026-06-23T00:00:00+00:00",
|
||
|
|
"2026-06-23",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_state_contract_and_nodes(tmp_path: Path) -> None:
|
||
|
|
db = tmp_path / "agent_team.sqlite"
|
||
|
|
seed_ledger(db)
|
||
|
|
r = _client(db).get("/api/state")
|
||
|
|
assert r.status_code == 200
|
||
|
|
payload = r.json()
|
||
|
|
# Existing contract preserved.
|
||
|
|
assert payload["ok"] is True
|
||
|
|
assert {"stages", "tasks", "summary", "budget"} <= set(payload)
|
||
|
|
# New per-node live state, keyed by topology node id.
|
||
|
|
assert "nodes" in payload
|
||
|
|
# thread-waiting is at clarify and awaiting human; thread-active at plan.
|
||
|
|
assert payload["nodes"]["clarify"]["state"] == "awaiting_human"
|
||
|
|
assert payload["nodes"]["plan"]["state"] == "active"
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_topology(tmp_path: Path) -> None:
|
||
|
|
db = tmp_path / "agent_team.sqlite"
|
||
|
|
init_db(db)
|
||
|
|
payload = _client(db).get("/api/topology").json()
|
||
|
|
ids = {n["id"] for n in payload["nodes"]}
|
||
|
|
assert {"intake", "clarify", "plan", "review"} <= ids
|
||
|
|
assert payload["edges"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_task_timeline_and_cost(tmp_path: Path) -> None:
|
||
|
|
db = tmp_path / "agent_team.sqlite"
|
||
|
|
seed_ledger(db)
|
||
|
|
rec = TransitionRecorder(db)
|
||
|
|
rec.record_entry(thread_id="thread-active", to_phase="intake", status="active")
|
||
|
|
rec.record_entry(thread_id="thread-active", to_phase="plan", status="active")
|
||
|
|
_seed_budget(db, "thread-active", "plan", 0.5)
|
||
|
|
|
||
|
|
payload = _client(db).get("/api/task/thread-active").json()
|
||
|
|
assert payload["ok"] is True
|
||
|
|
assert payload["partial"] is False
|
||
|
|
steps = {s["to_phase"]: s for s in payload["timeline"]}
|
||
|
|
assert "intake" in steps and "plan" in steps
|
||
|
|
assert steps["plan"]["cost_usd"] == pytest.approx(0.5)
|
||
|
|
assert payload["total_usd"] == pytest.approx(0.5)
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_task_partial_when_no_transitions(tmp_path: Path) -> None:
|
||
|
|
db = tmp_path / "agent_team.sqlite"
|
||
|
|
seed_ledger(db) # checkpoints exist, but no task_transitions rows
|
||
|
|
payload = _client(db).get("/api/task/thread-active").json()
|
||
|
|
assert payload["ok"] is True
|
||
|
|
assert payload["partial"] is True
|
||
|
|
# Best-effort single step reconstructed from the checkpoint phase.
|
||
|
|
assert payload["timeline"]
|
||
|
|
assert payload["timeline"][0]["note"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_task_rejects_bad_thread_id(tmp_path: Path) -> None:
|
||
|
|
db = tmp_path / "agent_team.sqlite"
|
||
|
|
init_db(db)
|
||
|
|
cli = _client(db)
|
||
|
|
assert cli.get("/api/task/has space").status_code == 400
|
||
|
|
assert cli.get("/api/task/" + "x" * 65).status_code == 400
|
||
|
|
|
||
|
|
|
||
|
|
def test_task_detail_missing_ledger_is_failsafe(tmp_path: Path) -> None:
|
||
|
|
out = task_detail(tmp_path / "nope.sqlite", "abc")
|
||
|
|
assert out["ok"] is False
|