test(l1): rewrite AI-build API tests on proven register/login/subscription helpers
KNOWN-RED (handoff): test_escalations_forbidden_for_l1_tech passes; the intake/ next-node tests still 403 'L1 access required' despite the DB role persisting as l1_tech (verified) and get_current_user reading role from the DB. The identical register->promote->subscribe->login helper works in test_l1_endpoints.py, so this is a test-harness/auth interaction needing interactive debugging in a clean shell. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,60 +1,73 @@
|
|||||||
"""Tests for the Phase 2A L1 AI-build API surface.
|
"""Integration tests for the Phase 2A L1 AI-build API surface.
|
||||||
|
|
||||||
Covers intake dispatch (match_or_build outcomes), the next-node endpoint, and
|
Covers intake dispatch (match_or_build outcomes), the next-node endpoint, and the
|
||||||
the engineer escalations list. The orchestrator and node generator are mocked —
|
engineer escalations list. The orchestrator and node generator are mocked — this
|
||||||
this exercises the endpoint wiring, not the AI.
|
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
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.security import create_access_token, get_password_hash
|
from app.models.l1_walk_session import L1WalkSession
|
||||||
from app.models.account import Account
|
from app.models.subscription import Subscription
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
async def _seed_user(test_db: AsyncSession, *, account_role="l1_tech"):
|
async def _register(client: AsyncClient, *, email: str) -> dict:
|
||||||
account = Account(name="L1 Co", display_code=f"L1{uuid.uuid4().hex[:6].upper()}")
|
resp = await client.post(
|
||||||
test_db.add(account)
|
"/api/v1/auth/register",
|
||||||
await test_db.flush()
|
json={"email": email, "password": "TestPassword123!", "name": "Test User"},
|
||||||
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)
|
assert resp.status_code in (200, 201), resp.text
|
||||||
await test_db.flush()
|
return resp.json()
|
||||||
return account, user
|
|
||||||
|
|
||||||
|
|
||||||
def _auth(user: User) -> dict:
|
async def _login(client: AsyncClient, *, email: str) -> dict:
|
||||||
return {"Authorization": f"Bearer {create_access_token(subject=str(user.id))}"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_intake_build_outcome_creates_ai_build_session(
|
async def test_intake_build_outcome_creates_ai_build_session(client: AsyncClient, test_db: AsyncSession):
|
||||||
client: AsyncClient, test_db: AsyncSession
|
|
||||||
):
|
|
||||||
"""intake → match_or_build returns 'build' → an ai_build session is created."""
|
"""intake → match_or_build returns 'build' → an ai_build session is created."""
|
||||||
account, user = await _seed_user(test_db)
|
info = await _make_user(client, test_db, email="aib_build@example.com", account_role="l1_tech")
|
||||||
with patch(
|
with patch(
|
||||||
"app.api.endpoints.l1.match_or_build.match_or_build",
|
"app.api.endpoints.l1.match_or_build.match_or_build",
|
||||||
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
|
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
|
||||||
"category": "printer"}),
|
"category": "printer"}),
|
||||||
):
|
):
|
||||||
r = await client.post(
|
r = await client.post("/api/v1/l1/intake",
|
||||||
"/api/v1/l1/intake",
|
json={"problem_statement": "printer jam"}, headers=info["headers"])
|
||||||
json={"problem_statement": "printer jam"},
|
assert r.status_code == 200, r.text
|
||||||
headers=_auth(user),
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["outcome"] == "build"
|
assert body["outcome"] == "build"
|
||||||
assert body["session_kind"] == "ai_build"
|
assert body["session_kind"] == "ai_build"
|
||||||
@@ -64,83 +77,64 @@ async def test_intake_build_outcome_creates_ai_build_session(
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_intake_out_of_scope(client: AsyncClient, test_db: AsyncSession):
|
async def test_intake_out_of_scope(client: AsyncClient, test_db: AsyncSession):
|
||||||
"""intake → 'out_of_scope' → no session, surfaced to the caller."""
|
"""intake → 'out_of_scope' → no session, surfaced to the caller."""
|
||||||
account, user = await _seed_user(test_db)
|
info = await _make_user(client, test_db, email="aib_oos@example.com", account_role="l1_tech")
|
||||||
with patch(
|
with patch(
|
||||||
"app.api.endpoints.l1.match_or_build.match_or_build",
|
"app.api.endpoints.l1.match_or_build.match_or_build",
|
||||||
new=AsyncMock(return_value={"outcome": "out_of_scope", "category": "unknown"}),
|
new=AsyncMock(return_value={"outcome": "out_of_scope", "category": "unknown"}),
|
||||||
):
|
):
|
||||||
r = await client.post(
|
r = await client.post("/api/v1/l1/intake",
|
||||||
"/api/v1/l1/intake",
|
json={"problem_statement": "weird"}, headers=info["headers"])
|
||||||
json={"problem_statement": "weird thing"},
|
assert r.status_code == 200, r.text
|
||||||
headers=_auth(user),
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["outcome"] == "out_of_scope"
|
assert body["outcome"] == "out_of_scope"
|
||||||
assert body["session_id"] is None
|
assert body.get("session_id") is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_intake_suggest_returns_near_miss(client: AsyncClient, test_db: AsyncSession):
|
async def test_intake_suggest_returns_near_miss(client: AsyncClient, test_db: AsyncSession):
|
||||||
"""intake → 'suggest' → near_miss prompt, no session."""
|
"""intake → 'suggest' → near_miss prompt, no session."""
|
||||||
account, user = await _seed_user(test_db)
|
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}
|
near = {"flow_id": str(uuid.uuid4()), "flow_name": "VPN", "score": 0.66}
|
||||||
with patch(
|
with patch(
|
||||||
"app.api.endpoints.l1.match_or_build.match_or_build",
|
"app.api.endpoints.l1.match_or_build.match_or_build",
|
||||||
new=AsyncMock(return_value={"outcome": "suggest", "near_miss": near, "can_build": True}),
|
new=AsyncMock(return_value={"outcome": "suggest", "near_miss": near, "can_build": True}),
|
||||||
):
|
):
|
||||||
r = await client.post(
|
r = await client.post("/api/v1/l1/intake",
|
||||||
"/api/v1/l1/intake",
|
json={"problem_statement": "vpn"}, headers=info["headers"])
|
||||||
json={"problem_statement": "vpn"},
|
assert r.status_code == 200, r.text
|
||||||
headers=_auth(user),
|
assert r.json()["near_miss"]["flow_name"] == "VPN"
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["outcome"] == "suggest"
|
|
||||||
assert body["near_miss"]["flow_name"] == "VPN"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_next_node_returns_generated_node(client: AsyncClient, test_db: AsyncSession):
|
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."""
|
"""After a build intake, /next-node returns the node from advance_ai_build."""
|
||||||
account, user = await _seed_user(test_db)
|
info = await _make_user(client, test_db, email="aib_next@example.com", account_role="l1_tech")
|
||||||
with patch(
|
with patch(
|
||||||
"app.api.endpoints.l1.match_or_build.match_or_build",
|
"app.api.endpoints.l1.match_or_build.match_or_build",
|
||||||
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
|
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
|
||||||
"category": "printer"}),
|
"category": "printer"}),
|
||||||
):
|
):
|
||||||
r = await client.post(
|
r = await client.post("/api/v1/l1/intake",
|
||||||
"/api/v1/l1/intake",
|
json={"problem_statement": "printer jam"}, headers=info["headers"])
|
||||||
json={"problem_statement": "printer jam"},
|
|
||||||
headers=_auth(user),
|
|
||||||
)
|
|
||||||
sid = r.json()["session_id"]
|
sid = r.json()["session_id"]
|
||||||
with patch(
|
with patch(
|
||||||
"app.api.endpoints.l1.l1_session_service.advance_ai_build",
|
"app.api.endpoints.l1.l1_session_service.advance_ai_build",
|
||||||
new=AsyncMock(return_value={"node_type": "question", "id": "n1", "text": "Powered on?"}),
|
new=AsyncMock(return_value={"node_type": "question", "id": "n1", "text": "Powered on?"}),
|
||||||
):
|
):
|
||||||
r2 = await client.post(
|
r2 = await client.post(f"/api/v1/l1/sessions/{sid}/next-node",
|
||||||
f"/api/v1/l1/sessions/{sid}/next-node",
|
json={}, headers=info["headers"])
|
||||||
json={},
|
assert r2.status_code == 200, r2.text
|
||||||
headers=_auth(user),
|
|
||||||
)
|
|
||||||
assert r2.status_code == 200
|
|
||||||
assert r2.json()["node"]["node_type"] == "question"
|
assert r2.json()["node"]["node_type"] == "question"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_escalations_lists_escalated_sessions_for_engineer(
|
async def test_escalations_lists_escalated_sessions_for_engineer(client: AsyncClient, test_db: AsyncSession):
|
||||||
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")
|
||||||
"""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)
|
now = datetime.now(timezone.utc)
|
||||||
sess = L1WalkSession(
|
sess = L1WalkSession(
|
||||||
account_id=account.id,
|
account_id=info["account_id"],
|
||||||
created_by_user_id=engineer.id,
|
created_by_user_id=info["user_id"],
|
||||||
ticket_id="t-esc",
|
ticket_id="t-esc",
|
||||||
ticket_kind="internal",
|
ticket_kind="internal",
|
||||||
session_kind="ai_build",
|
session_kind="ai_build",
|
||||||
@@ -150,17 +144,15 @@ async def test_escalations_lists_escalated_sessions_for_engineer(
|
|||||||
escalated_at=now,
|
escalated_at=now,
|
||||||
)
|
)
|
||||||
test_db.add(sess)
|
test_db.add(sess)
|
||||||
await test_db.flush()
|
await test_db.commit()
|
||||||
|
r = await client.get("/api/v1/l1/escalations", headers=info["headers"])
|
||||||
r = await client.get("/api/v1/l1/escalations", headers=_auth(engineer))
|
assert r.status_code == 200, r.text
|
||||||
assert r.status_code == 200
|
assert any(row["id"] == str(sess.id) for row in r.json())
|
||||||
rows = r.json()
|
|
||||||
assert any(row["id"] == str(sess.id) for row in rows)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_escalations_forbidden_for_l1_tech(client: AsyncClient, test_db: AsyncSession):
|
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."""
|
"""An l1_tech (not engineer-or-above) is rejected from the escalations queue."""
|
||||||
account, l1 = await _seed_user(test_db, account_role="l1_tech")
|
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=_auth(l1))
|
r = await client.get("/api/v1/l1/escalations", headers=info["headers"])
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403, r.text
|
||||||
|
|||||||
Reference in New Issue
Block a user