Files
resolutionflow/backend/tests/test_l1_api_ai_build.py
Michael Chihlas 633a208742 feat(l1): intake dispatch via match_or_build + next-node + escalations endpoints
- /intake now runs match_or_build (matched/suggest/out_of_scope/build); build
  seeds the classified category as a hidden meta walked_path entry, matched starts
  a flow session, suggest/out_of_scope return prompt data with no session.
- New POST /sessions/{id}/next-node (threads node_text to advance_ai_build) and
  GET /escalations (engineer-or-above) for the handoff queue.
- New IntakeResponse(outcome=...)/NextNodeRequest/NextNodeResponse schemas and
  require_account_owner_or_admin dep.
- Reconcile Phase-1 intake tests to the new contract (mock match_or_build); add
  test_l1_api_ai_build.py covering build/out_of_scope/suggest/next-node/escalations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 03:54:23 -04:00

167 lines
5.8 KiB
Python

"""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.
"""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import create_access_token, get_password_hash
from app.models.account import Account
from app.models.user import User
async def _seed_user(test_db: AsyncSession, *, account_role="l1_tech"):
account = Account(name="L1 Co", display_code=f"L1{uuid.uuid4().hex[:6].upper()}")
test_db.add(account)
await test_db.flush()
user = User(
account_id=account.id,
email=f"l1-{uuid.uuid4().hex[:8]}@example.com",
hashed_password=get_password_hash("pw"),
full_name="L1 Tech",
account_role=account_role,
is_active=True,
is_verified=True,
)
test_db.add(user)
await test_db.flush()
return account, user
def _auth(user: User) -> dict:
return {"Authorization": f"Bearer {create_access_token(subject=str(user.id))}"}
@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."""
account, user = await _seed_user(test_db)
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=_auth(user),
)
assert r.status_code == 200
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."""
account, user = await _seed_user(test_db)
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 thing"},
headers=_auth(user),
)
assert r.status_code == 200
body = r.json()
assert body["outcome"] == "out_of_scope"
assert body["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."""
account, user = await _seed_user(test_db)
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=_auth(user),
)
assert r.status_code == 200
body = r.json()
assert body["outcome"] == "suggest"
assert body["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."""
account, user = await _seed_user(test_db)
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=_auth(user),
)
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=_auth(user),
)
assert r2.status_code == 200
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; requires engineer-or-above."""
from datetime import datetime, timezone
from app.models.l1_walk_session import L1WalkSession
account, engineer = await _seed_user(test_db, account_role="engineer")
now = datetime.now(timezone.utc)
sess = L1WalkSession(
account_id=account.id,
created_by_user_id=engineer.id,
ticket_id="t-esc",
ticket_kind="internal",
session_kind="ai_build",
status="escalated",
started_at=now,
last_step_at=now,
escalated_at=now,
)
test_db.add(sess)
await test_db.flush()
r = await client.get("/api/v1/l1/escalations", headers=_auth(engineer))
assert r.status_code == 200
rows = r.json()
assert any(row["id"] == str(sess.id) for row in rows)
@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."""
account, l1 = await _seed_user(test_db, account_role="l1_tech")
r = await client.get("/api/v1/l1/escalations", headers=_auth(l1))
assert r.status_code == 403