"""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 == []