/** * TASK-0121 — AI capability suite, part 2: the APP (budget + override + chat). * * The gateway-level capability set (echo / streaming order / tool roundtrip / * embed dimension / error mapping, across every provider) is in * `gateway.capability.test.ts`. This spec runs the parts that need the real * app stack — the Postgres `ai_messages` ledger, the Redis rate window + * 24 h notification slot, the authorization layer — against the deterministic * `MockProvider` (env `AI_PROVIDER=mock`). * * Covered acceptance criteria (TASK-0121): * - budget exhaustion: `usageLimits.aiMonthlyTokens = 100` → a call returns * `402 AI_BUDGET_EXCEEDED` with `used`/`limit` details and writes an admin * `Notification` (asserted on the `Notification` table) + the * `ai.budget_exceeded` audit event. * - per-workspace `settings.ai` override (a different `chatModel`) is honored * for that workspace's calls while a second workspace keeps env defaults. * - a chat turn returns the deterministic mock echo and persists both * messages with token counts (the usage ledger). * * The provider is selected by CONFIG ONLY (`AI_PROVIDER=mock` set below before * the app boots); nothing in this file branches on provider — the same * assertions would hold for any provider that satisfies the capability set. */ // Config must be fixed BEFORE the app boots (loadEnv + the workspace cache are // both lazily primed on first use). process.env.AI_PROVIDER = "mock"; process.env.SEARCH_VECTOR_DIMENSION = "8"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "../helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "../helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; const ping = async (headers: Record, workspaceId: string) => (await (await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/ping`, { headers })).json()) as { data: { provider: string; model: string; embeddingDimension: number; budget: { limit: number; used: number } }; }; describe("TASK-0121 AI capability — app (mock provider, config-only)", () => { it("ping: returns the resolved provider/model/dimension, no tokens spent", async () => { const owner = await createUser(app.request, { name: "AI Ping Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Ping WS"); const body = await ping(authHeaders(owner.accessToken!), workspaceId); expect(body.data.provider).toBe("custom"); // mock resolves into the custom family expect(body.data.model).toBeTruthy(); expect(body.data.embeddingDimension).toBe(8); // SEARCH_VECTOR_DIMENSION expect(body.data.budget.limit).toBe(0); // unset = unlimited expect(body.data.budget.used).toBe(0); }); it("ping: 403 for a non-member (server-side authz)", async () => { const owner = await createUser(app.request, { name: "AI Ping Owner2" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Ping WS2"); const stranger = await createUser(app.request, { name: "AI Stranger" }); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/ping`, { headers: authHeaders(stranger.accessToken!) }); expect(res.status).toBe(403); }); it("chat: returns the deterministic mock echo and persists both messages with tokens", async () => { const owner = await createUser(app.request, { name: "AI Chat Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Chat WS"); const msg = "the quick brown fox"; const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/chat`, { headers: authHeaders(owner.accessToken!), body: { message: msg } }); expect(res.status).toBe(201); const body = await json(res); expect(body.role).toBe("assistant"); expect(body.content).toBe(msg); // deterministic echo expect(Number(body.tokensIn)).toBeGreaterThan(0); expect(Number(body.tokensOut)).toBeGreaterThan(0); const convId = body.conversationId as string; const rows = await app.prisma.aiMessage.findMany({ where: { conversationId: convId }, orderBy: { createdAt: "asc" } }); expect(rows.length).toBe(2); expect(rows[0]?.role).toBe("user"); expect(rows[0]?.content).toBe(msg); expect(rows[1]?.role).toBe("assistant"); expect(Number(rows[1]?.tokensIn ?? 0)).toBeGreaterThan(0); expect(Number(rows[1]?.tokensOut ?? 0)).toBeGreaterThan(0); }); it("budget: usageLimits.aiMonthlyTokens=100 → 402 AI_BUDGET_EXCEEDED + admin Notification + audit", async () => { const owner = await createUser(app.request, { name: "AI Budget Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Budget WS"); // Add an ADMIN (a second OWNER/ADMIN to prove the notification fans out). const admin = await createUser(app.request, { name: "AI Budget Admin" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, admin.email, "ADMIN"); // Set the monthly cap. (Direct Prisma write: usageLimits has no public // mutation endpoint — permitted by the seed-helper rules.) await app.prisma.workspace.update({ where: { id: workspaceId }, data: { usageLimits: { aiMonthlyTokens: 100 } } }); const notifyBefore = await app.prisma.notification.count({ where: { workspaceId, title: "AI budget exceeded" } }); const auditBefore = await countAuditEvents(app.prisma, { action: "ai.budget_exceeded" }); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/chat`, { headers: authHeaders(owner.accessToken!), body: { message: "the quick brown fox" } }); expect(res.status).toBe(402); const body = await json(res); const err = (body.error as { code: string; details?: Array<{ reason: string; used: number; limit: number }> }) ?? {}; expect(err.code).toBe("AI_BUDGET_EXCEEDED"); const details = err.details ?? []; expect(details[0]?.reason).toBe("monthly_token_budget_exceeded"); expect(details[0]?.limit).toBe(100); expect(details[0]?.used).toBe(0); // the guard rejects before any provider call // Admin Notification rows written for every OWNER/ADMIN. const notifyAfter = await app.prisma.notification.count({ where: { workspaceId, title: "AI budget exceeded" } }); expect(notifyAfter).toBe(notifyBefore + 2); // owner + admin const notif = await app.prisma.notification.findFirst({ where: { workspaceId, title: "AI budget exceeded" }, orderBy: { createdAt: "desc" } }); expect(notif?.channel).toBe("IN_APP"); expect(notif?.type).toBe("AUTOMATION"); // The security-relevant audit event. const auditAfter = await countAuditEvents(app.prisma, { action: "ai.budget_exceeded" }); expect(auditAfter).toBe(auditBefore + 1); }); it("budget: a workspace with no cap (limit=0) is unlimited and succeeds", async () => { const owner = await createUser(app.request, { name: "AI Unlimited Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Unlimited WS"); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/chat`, { headers: authHeaders(owner.accessToken!), body: { message: "the quick brown fox" } }); expect(res.status).toBe(201); const body = await json(res); expect(body.content).toBe("the quick brown fox"); }); it("override: a workspace's settings.ai.chatModel is honored; a second keeps the default", async () => { const owner = await createUser(app.request, { name: "AI Override Owner" }); const h = authHeaders(owner.accessToken!); const { workspaceId: wsA } = await createWorkspace(app.request, h, "AI Override A"); const { workspaceId: wsB } = await createWorkspace(app.request, h, "AI Override B"); // Set a per-workspace override on wsA (the documented AiConfigSchema shape). await app.prisma.workspace.update({ where: { id: wsA }, data: { settings: { ai: { enabled: true, provider: "custom", chatModel: "my-custom-model-xyz", embedModel: "mock-embed" } } } }); const { resetWorkspaceCache } = await import("@factory/api/src/modules/ai/gateway-provider"); resetWorkspaceCache(); const a = await ping(h, wsA); const b = await ping(h, wsB); expect(a.data.model).toBe("my-custom-model-xyz"); expect(b.data.model).not.toBe("my-custom-model-xyz"); // keeps the env default }); });