"""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