Files
resolutionflow/backend/tests/test_l1_api_ai_build.py
Michael Chihlas ac89e7b2fa fix(l1): resolve PR #193 backend review findings (1,4,5,6,7,8,9,10)
Server-assigns a uuid4 id to every AI-generated node (Finding 1 showstopper:
nodes had no id but the advance protocol keys on node_id, so ai_build walks
never advanced past question 1). Replaces the hidden {"node_type":"meta"}
walked_path convention with real category/problem_text/pending_node columns on
l1_walk_sessions (migration 61dda4f615c6) — fixes junk proposals + off-by-one
depth cap (Findings 8,9), and pending_node replays the served node on re-mount
(no duplicate paid LLM call). Intake honors explicit flow_id and adhoc=True
(Findings 4,5); flow_proposals.l1_session_id FK -> CASCADE (Finding 6 time
bomb); L1 category GET is owner+admin like PATCH and require_account_owner_or_admin
delegates to User.can_manage_account (Finding 7); escalate falls back to default
recipients + filters deleted_at + warns when empty (Finding 10). Cleanups: dead
ticket_ref removed, IntakeResponse per-outcome validator, unused acknowledged
dropped, escalations partial index, restored a deleted audit assertion.

Full Phase 2A backend set: 110 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:55:45 -04:00

228 lines
9.9 KiB
Python

"""Integration tests for the Phase 2A L1 AI-build API surface.
Covers intake dispatch (match_or_build outcomes), the next-node endpoint, and the
engineer escalations list. The orchestrator and node generator are mocked — this
exercises the endpoint wiring, not the AI. Auth/subscription follow the same
register → promote-role → ensure-subscription → login pattern as test_l1_endpoints.
"""
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.l1_walk_session import L1WalkSession
from app.models.subscription import Subscription
from app.models.user import User
async def _register(client: AsyncClient, *, email: str) -> dict:
resp = await client.post(
"/api/v1/auth/register",
json={"email": email, "password": "TestPassword123!", "name": "Test User"},
)
assert resp.status_code in (200, 201), resp.text
return resp.json()
async def _login(client: AsyncClient, *, email: str) -> dict:
resp = await client.post(
"/api/v1/auth/login/json",
json={"email": email, "password": "TestPassword123!"},
)
assert resp.status_code == 200, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
async def _ensure_subscription(db: AsyncSession, account_id: uuid.UUID) -> None:
await db.execute(delete(Subscription).where(Subscription.account_id == account_id))
db.add(Subscription(account_id=account_id, plan="pro", status="active"))
await db.commit()
async def _make_user(client: AsyncClient, db: AsyncSession, *, email: str, account_role: str) -> dict:
"""Register a user, promote to a role, ensure an active subscription, return headers + ids."""
data = await _register(client, email=email)
uid = uuid.UUID(data["id"])
acct_id = uuid.UUID(data["account_id"])
user = (await db.execute(select(User).where(User.id == uid))).scalar_one()
user.account_role = account_role
await db.commit()
await _ensure_subscription(db, acct_id)
headers = await _login(client, email=email) # login AFTER role change
return {"headers": headers, "account_id": acct_id, "user_id": uid}
@pytest.mark.asyncio
async def test_intake_build_outcome_creates_ai_build_session(client: AsyncClient, test_db: AsyncSession):
"""intake → match_or_build returns 'build' → an ai_build session is created."""
info = await _make_user(client, test_db, email="aib_build@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
"category": "printer"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "printer jam"}, headers=info["headers"])
assert r.status_code == 200, r.text
body = r.json()
assert body["outcome"] == "build"
assert body["session_kind"] == "ai_build"
assert body["session_id"]
@pytest.mark.asyncio
async def test_intake_out_of_scope(client: AsyncClient, test_db: AsyncSession):
"""intake → 'out_of_scope' → no session, surfaced to the caller."""
info = await _make_user(client, test_db, email="aib_oos@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "out_of_scope", "category": "unknown"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "weird"}, headers=info["headers"])
assert r.status_code == 200, r.text
body = r.json()
assert body["outcome"] == "out_of_scope"
assert body.get("session_id") is None
@pytest.mark.asyncio
async def test_intake_suggest_returns_near_miss(client: AsyncClient, test_db: AsyncSession):
"""intake → 'suggest' → near_miss prompt, no session."""
info = await _make_user(client, test_db, email="aib_sugg@example.com", account_role="l1_tech")
near = {"flow_id": str(uuid.uuid4()), "flow_name": "VPN", "score": 0.66}
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "suggest", "near_miss": near, "can_build": True}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "vpn"}, headers=info["headers"])
assert r.status_code == 200, r.text
assert r.json()["near_miss"]["flow_name"] == "VPN"
@pytest.mark.asyncio
async def test_next_node_returns_generated_node(client: AsyncClient, test_db: AsyncSession):
"""After a build intake, /next-node returns the node from advance_ai_build."""
info = await _make_user(client, test_db, email="aib_next@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
"category": "printer"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "printer jam"}, headers=info["headers"])
sid = r.json()["session_id"]
with patch(
"app.api.endpoints.l1.l1_session_service.advance_ai_build",
new=AsyncMock(return_value={"node_type": "question", "id": "n1", "text": "Powered on?"}),
):
r2 = await client.post(f"/api/v1/l1/sessions/{sid}/next-node",
json={}, headers=info["headers"])
assert r2.status_code == 200, r2.text
assert r2.json()["node"]["node_type"] == "question"
@pytest.mark.asyncio
async def test_escalations_lists_escalated_sessions_for_engineer(client: AsyncClient, test_db: AsyncSession):
"""GET /l1/escalations returns escalated L1 sessions for an engineer-or-above user."""
info = await _make_user(client, test_db, email="aib_eng@example.com", account_role="engineer")
now = datetime.now(timezone.utc)
sess = L1WalkSession(
account_id=info["account_id"],
created_by_user_id=info["user_id"],
ticket_id="t-esc",
ticket_kind="internal",
session_kind="ai_build",
status="escalated",
started_at=now,
last_step_at=now,
)
test_db.add(sess)
await test_db.commit()
r = await client.get("/api/v1/l1/escalations", headers=info["headers"])
assert r.status_code == 200, r.text
assert any(row["id"] == str(sess.id) for row in r.json())
@pytest.mark.asyncio
async def test_escalations_forbidden_for_l1_tech(client: AsyncClient, test_db: AsyncSession):
"""An l1_tech (not engineer-or-above) is rejected from the escalations queue."""
info = await _make_user(client, test_db, email="aib_l1@example.com", account_role="l1_tech")
r = await client.get("/api/v1/l1/escalations", headers=info["headers"])
assert r.status_code == 403, r.text
@pytest.mark.asyncio
async def test_intake_with_flow_id_starts_flow_directly(client: AsyncClient, test_db: AsyncSession):
"""Finding 4: an explicit flow_id bypasses the matcher and starts that flow."""
from app.models.tree import Tree
info = await _make_user(client, test_db, email="aib_flowid@example.com", account_role="l1_tech")
tree = Tree(
id=uuid.uuid4(), name="VPN Flow", account_id=info["account_id"],
author_id=info["user_id"], tree_type="troubleshooting",
tree_structure={"nodes": [], "edges": []}, visibility="team", status="published",
)
test_db.add(tree)
await test_db.commit()
# match_or_build must NOT be called when flow_id is supplied.
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(side_effect=AssertionError("matcher should be bypassed")),
):
r = await client.post(
"/api/v1/l1/intake",
json={"problem_statement": "vpn down", "flow_id": str(tree.id)},
headers=info["headers"],
)
assert r.status_code == 200, r.text
body = r.json()
assert body["outcome"] == "matched"
assert body["session_kind"] == "flow"
assert body["flow_id"] == str(tree.id)
assert body["session_id"]
@pytest.mark.asyncio
async def test_intake_adhoc_starts_adhoc_session(client: AsyncClient, test_db: AsyncSession):
"""Finding 5: adhoc=True starts a free-form ad-hoc walk (out_of_scope fallback)."""
info = await _make_user(client, test_db, email="aib_adhoc@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(side_effect=AssertionError("matcher should be bypassed")),
):
r = await client.post(
"/api/v1/l1/intake",
json={"problem_statement": "weird thing", "adhoc": True},
headers=info["headers"],
)
assert r.status_code == 200, r.text
body = r.json()
assert body["outcome"] == "adhoc"
assert body["session_kind"] == "adhoc"
assert body["session_id"]
@pytest.mark.asyncio
async def test_intake_build_persists_category_and_problem_text(client: AsyncClient, test_db: AsyncSession):
"""Root cause B: build stores category + problem_text on the session (no meta entry)."""
info = await _make_user(client, test_db, email="aib_cols@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
"category": "printer"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "printer jam"}, headers=info["headers"])
sid = r.json()["session_id"]
sess = await test_db.get(L1WalkSession, uuid.UUID(sid))
assert sess.category == "printer"
assert sess.problem_text == "printer jam"
# No hidden meta entry smuggled into walked_path.
assert sess.walked_path == []