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