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>
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""Integration tests for the /l1/* endpoint surface (Task 15).
|
|
|
|
All tests use the `client` + `test_db` fixtures from conftest.
|
|
"""
|
|
import uuid
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import delete
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.subscription import Subscription
|
|
from app.models.user import User
|
|
from app.models.l1_walk_session import L1WalkSession
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test-local helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _register(client: AsyncClient, *, email: str, password: str = "TestPassword123!", name: str = "Test User") -> dict:
|
|
resp = await client.post("/api/v1/auth/register", json={"email": email, "password": password, "name": name})
|
|
assert resp.status_code in (200, 201), resp.text
|
|
return resp.json()
|
|
|
|
|
|
async def _login(client: AsyncClient, *, email: str, password: str = "TestPassword123!") -> dict:
|
|
resp = await client.post("/api/v1/auth/login/json", json={"email": email, "password": password})
|
|
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:
|
|
"""Ensure account has an active Pro subscription."""
|
|
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_l1_user(
|
|
client: AsyncClient,
|
|
db: AsyncSession,
|
|
*,
|
|
email: str,
|
|
account_id: uuid.UUID | None = None,
|
|
) -> dict:
|
|
"""Register a user, set role=l1_tech, ensure subscription.
|
|
|
|
If account_id is given, inserts a second user directly into that account.
|
|
Otherwise registers a fresh user via the API (new account) and returns
|
|
both user data and login headers.
|
|
"""
|
|
if account_id is None:
|
|
user_data = await _register(client, email=email)
|
|
uid = uuid.UUID(user_data["id"])
|
|
acct_id = uuid.UUID(user_data["account_id"])
|
|
# Promote to l1_tech
|
|
from sqlalchemy import select as sa_select
|
|
result = await db.execute(sa_select(User).where(User.id == uid))
|
|
user = result.scalar_one()
|
|
user.account_role = "l1_tech"
|
|
await db.commit()
|
|
await _ensure_subscription(db, acct_id)
|
|
headers = await _login(client, email=email)
|
|
return {"user_data": user_data, "headers": headers, "account_id": acct_id}
|
|
else:
|
|
# Insert directly into an existing account
|
|
s = str(uuid.uuid4())[:8]
|
|
user = User(
|
|
id=uuid.uuid4(),
|
|
email=email,
|
|
name=f"L1 Tech {s}",
|
|
account_id=account_id,
|
|
account_role="l1_tech",
|
|
role="engineer",
|
|
is_active=True,
|
|
hashed_password="$2b$12$placeholder.placeholder.placeholder.placeholder.plac",
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
return {"user_data": {"id": str(user.id), "account_id": str(account_id)}, "headers": None}
|
|
|
|
|
|
async def _create_adhoc_session(db: AsyncSession, info: dict, *, problem: str = "setup") -> str:
|
|
"""Create an adhoc walk session (backed by a real internal ticket) via the service.
|
|
|
|
Phase 2A: POST /l1/intake dispatches through match_or_build and no longer
|
|
yields an adhoc session directly, so step/notes/resolve/escalate/cross-account
|
|
tests build their setup session here instead of through intake. The test
|
|
client shares this same DB session (conftest override_get_db), so the
|
|
committed session is visible to the API immediately.
|
|
"""
|
|
from sqlalchemy import select as sa_select
|
|
from app.services import internal_ticket_service, l1_session_service
|
|
|
|
account_id = info["account_id"]
|
|
user_id = uuid.UUID(info["user_data"]["id"])
|
|
user = (await db.execute(sa_select(User).where(User.id == user_id))).scalar_one()
|
|
ticket = await internal_ticket_service.create_ticket(
|
|
db,
|
|
account_id=account_id,
|
|
created_by_user_id=user_id,
|
|
problem_statement=problem,
|
|
customer_name=None,
|
|
customer_contact=None,
|
|
)
|
|
session = await l1_session_service.start_adhoc_session(
|
|
db,
|
|
account_id=account_id,
|
|
user=user,
|
|
ticket_id=str(ticket.id),
|
|
ticket_kind="internal",
|
|
)
|
|
await db.commit()
|
|
return str(session.id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Intake (Phase 2A): build outcome → 200 + session_kind='ai_build'
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_intake_build_creates_ai_build_session(client: AsyncClient, test_db: AsyncSession):
|
|
"""POST /l1/intake with a 'build' outcome creates an ai_build session.
|
|
|
|
Phase 2A: intake dispatches via match_or_build. An explicit adhoc=True (the
|
|
out_of_scope prompt's "Walk it ad-hoc") starts an ad-hoc session directly —
|
|
see test_l1_api_ai_build.test_intake_adhoc_starts_adhoc_session.
|
|
"""
|
|
from unittest.mock import AsyncMock, patch
|
|
info = await _make_l1_user(client, test_db, email="l1intake@example.com")
|
|
headers = info["headers"]
|
|
|
|
with patch(
|
|
"app.api.endpoints.l1.match_or_build.match_or_build",
|
|
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
|
|
"category": "printer"}),
|
|
):
|
|
resp = await client.post(
|
|
"/api/v1/l1/intake",
|
|
json={"problem_statement": "Printer won't turn on", "customer_name": "Alice"},
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["outcome"] == "build"
|
|
assert body["session_kind"] == "ai_build"
|
|
assert body["ticket_kind"] == "internal"
|
|
assert body["session_id"]
|
|
assert body["ticket_id"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Intake without auth → 401
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_intake_no_auth(client: AsyncClient, test_db: AsyncSession):
|
|
"""POST /l1/intake without token → 401."""
|
|
resp = await client.post(
|
|
"/api/v1/l1/intake",
|
|
json={"problem_statement": "Test"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Intake as viewer → 403
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_intake_viewer_forbidden(client: AsyncClient, test_db: AsyncSession):
|
|
"""POST /l1/intake as viewer role → 403."""
|
|
user_data = await _register(client, email="viewer_l1@example.com")
|
|
uid = uuid.UUID(user_data["id"])
|
|
acct_id = uuid.UUID(user_data["account_id"])
|
|
|
|
from sqlalchemy import select as sa_select
|
|
result = await test_db.execute(sa_select(User).where(User.id == uid))
|
|
user = result.scalar_one()
|
|
user.account_role = "viewer"
|
|
await test_db.commit()
|
|
await _ensure_subscription(test_db, acct_id)
|
|
|
|
headers = await _login(client, email="viewer_l1@example.com")
|
|
resp = await client.post(
|
|
"/api/v1/l1/intake",
|
|
json={"problem_statement": "Test"},
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Step on adhoc session → 400 (cannot step an adhoc)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_on_adhoc_returns_400(client: AsyncClient, test_db: AsyncSession):
|
|
"""POST /l1/sessions/{id}/step on adhoc session → 400."""
|
|
info = await _make_l1_user(client, test_db, email="l1step@example.com")
|
|
headers = info["headers"]
|
|
|
|
session_id = await _create_adhoc_session(test_db, info, problem="Adhoc issue")
|
|
|
|
resp = await client.post(
|
|
f"/api/v1/l1/sessions/{session_id}/step",
|
|
json={"node_id": "node1", "question": "Q?", "answer": "A"},
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "adhoc" in resp.json()["detail"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Notes on adhoc session → 200, walk_notes updated
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notes_on_adhoc_session(client: AsyncClient, test_db: AsyncSession):
|
|
"""POST /l1/sessions/{id}/notes → 200 and walk_notes is updated."""
|
|
info = await _make_l1_user(client, test_db, email="l1notes@example.com")
|
|
headers = info["headers"]
|
|
|
|
session_id = await _create_adhoc_session(test_db, info, problem="Notes test")
|
|
|
|
notes_payload = [{"text": "Customer called about printer", "ts": "2026-05-28T10:00:00Z"}]
|
|
resp = await client.post(
|
|
f"/api/v1/l1/sessions/{session_id}/notes",
|
|
json={"notes": notes_payload},
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["walk_notes"] == notes_payload
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Resolve with helpful=True → 200; GET shows status=resolved
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_session(client: AsyncClient, test_db: AsyncSession):
|
|
"""POST /l1/sessions/{id}/resolve → 200; subsequent GET shows resolved."""
|
|
info = await _make_l1_user(client, test_db, email="l1resolve@example.com")
|
|
headers = info["headers"]
|
|
|
|
session_id = await _create_adhoc_session(test_db, info, problem="Resolve test")
|
|
|
|
resp = await client.post(
|
|
f"/api/v1/l1/sessions/{session_id}/resolve",
|
|
json={"helpful": True, "resolution_notes": "Restarted the printer."},
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["status"] == "resolved"
|
|
|
|
# GET should also show resolved
|
|
resp = await client.get(f"/api/v1/l1/sessions/{session_id}", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "resolved"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Escalate session → 200; status=escalated
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_session(client: AsyncClient, test_db: AsyncSession):
|
|
"""POST /l1/sessions/{id}/escalate → 200; status becomes escalated."""
|
|
info = await _make_l1_user(client, test_db, email="l1escalate@example.com")
|
|
headers = info["headers"]
|
|
|
|
session_id = await _create_adhoc_session(test_db, info, problem="Escalation test")
|
|
|
|
resp = await client.post(
|
|
f"/api/v1/l1/sessions/{session_id}/escalate",
|
|
json={"reason_category": "needs_l2", "reason": "Beyond L1 scope"},
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["status"] == "escalated"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. escalate-without-walk → 200 + session in escalated status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_without_walk(client: AsyncClient, test_db: AsyncSession):
|
|
"""POST /l1/escalate-without-walk → 200 + session.status=escalated."""
|
|
info = await _make_l1_user(client, test_db, email="l1eww@example.com")
|
|
headers = info["headers"]
|
|
|
|
resp = await client.post(
|
|
"/api/v1/l1/escalate-without-walk",
|
|
json={
|
|
"problem_statement": "No KB available",
|
|
"reason_category": "no_kb",
|
|
"reason": "No knowledge base content matched",
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["status"] == "escalated"
|
|
assert body["session_kind"] == "adhoc"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. List active sessions returns L1's active sessions ordered by last_step_at DESC
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_active_sessions_ordered(client: AsyncClient, test_db: AsyncSession):
|
|
"""GET /l1/sessions/active returns active sessions ordered by last_step_at DESC."""
|
|
info = await _make_l1_user(client, test_db, email="l1active@example.com")
|
|
headers = info["headers"]
|
|
user_id = uuid.UUID(info["user_data"]["id"])
|
|
account_id = info["account_id"]
|
|
|
|
# Create two sessions with controlled timestamps directly in DB
|
|
now = datetime.now(timezone.utc)
|
|
s1 = L1WalkSession(
|
|
id=uuid.uuid4(),
|
|
account_id=account_id,
|
|
created_by_user_id=user_id,
|
|
ticket_id=str(uuid.uuid4()),
|
|
ticket_kind="internal",
|
|
session_kind="adhoc",
|
|
status="active",
|
|
started_at=now - timedelta(minutes=10),
|
|
last_step_at=now - timedelta(minutes=5),
|
|
)
|
|
s2 = L1WalkSession(
|
|
id=uuid.uuid4(),
|
|
account_id=account_id,
|
|
created_by_user_id=user_id,
|
|
ticket_id=str(uuid.uuid4()),
|
|
ticket_kind="internal",
|
|
session_kind="adhoc",
|
|
status="active",
|
|
started_at=now - timedelta(minutes=20),
|
|
last_step_at=now - timedelta(minutes=1),
|
|
)
|
|
test_db.add_all([s1, s2])
|
|
await test_db.commit()
|
|
|
|
resp = await client.get("/api/v1/l1/sessions/active", headers=headers)
|
|
assert resp.status_code == 200, resp.text
|
|
bodies = resp.json()
|
|
ids = [b["id"] for b in bodies]
|
|
# s2 has the more recent last_step_at → should come first
|
|
assert ids.index(str(s2.id)) < ids.index(str(s1.id))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. GET session from different account → 404 (tenancy)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_cross_account_returns_404(client: AsyncClient, test_db: AsyncSession):
|
|
"""GET /l1/sessions/{id} from a different account → 404."""
|
|
# Account A: creates a session
|
|
info_a = await _make_l1_user(client, test_db, email="l1tenanta@example.com")
|
|
|
|
session_id = await _create_adhoc_session(test_db, info_a, problem="Account A issue")
|
|
|
|
# Account B: different user in a different account
|
|
info_b = await _make_l1_user(client, test_db, email="l1tenantb@example.com")
|
|
headers_b = info_b["headers"]
|
|
|
|
resp = await client.get(f"/api/v1/l1/sessions/{session_id}", headers=headers_b)
|
|
assert resp.status_code == 404
|