Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
|
|
import Fastify from "fastify";
|
|
import cookie from "@fastify/cookie";
|
|
import rateLimit from "@fastify/rate-limit";
|
|
import jwt from "jsonwebtoken";
|
|
import prisma from "../config/database";
|
|
import { config as appConfig } from "../config/env";
|
|
import { securityHeaders } from "../middleware/security";
|
|
import aiRoutes from "../routes/admin/ai";
|
|
import {
|
|
computeCostUsd,
|
|
recordUsage,
|
|
getMonthSpendUsd,
|
|
getBudgetUsd,
|
|
assertBudgetAvailable,
|
|
isConfigured,
|
|
listConversations,
|
|
createConversation,
|
|
getConversationMessages,
|
|
appendConversationMessages,
|
|
renameConversation,
|
|
deleteConversation,
|
|
} from "../services/ai.service";
|
|
|
|
const KIND = "test_ai"; // marker so we only clean our own rows
|
|
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
|
|
const CONV_UID_B = 999999992;
|
|
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
|
|
|
|
beforeEach(async () => {
|
|
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
|
});
|
|
afterAll(async () => {
|
|
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
|
await prisma.ai_conversations.deleteMany({
|
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
|
});
|
|
await prisma.ai_conversations.deleteMany({
|
|
where: { title: { contains: HIST_MARKER } },
|
|
});
|
|
});
|
|
|
|
describe("ai.service cost + budget", () => {
|
|
it("computes Sonnet 4.6 cost from tokens", () => {
|
|
// 1,000,000 input @ $3 + 1,000,000 output @ $15 = $18
|
|
expect(
|
|
computeCostUsd("claude-sonnet-4-6", 1_000_000, 1_000_000),
|
|
).toBeCloseTo(18, 6);
|
|
expect(computeCostUsd("claude-sonnet-4-6", 4000, 500)).toBeCloseTo(
|
|
0.0195,
|
|
6,
|
|
);
|
|
});
|
|
|
|
it("records usage with the computed cost", async () => {
|
|
await recordUsage({
|
|
userId: null,
|
|
kind: KIND,
|
|
model: "claude-sonnet-4-6",
|
|
inputTokens: 4000,
|
|
outputTokens: 500,
|
|
});
|
|
const rows = await prisma.ai_usage.findMany({ where: { kind: KIND } });
|
|
expect(rows.length).toBe(1);
|
|
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
|
|
});
|
|
|
|
it("sums only the current month's spend (excludes prior months)", async () => {
|
|
// Measure the baseline so the assertion is robust to any OTHER current-month
|
|
// rows that may already exist in the test DB (getMonthSpendUsd sums ALL
|
|
// kinds, not just ours). We assert the DELTA we introduce, not an absolute
|
|
// upper bound.
|
|
const before = await getMonthSpendUsd();
|
|
|
|
await recordUsage({
|
|
userId: null,
|
|
kind: KIND,
|
|
model: "claude-sonnet-4-6",
|
|
inputTokens: 1_000_000,
|
|
outputTokens: 0,
|
|
}); // +$3 this month
|
|
// An old row (year 2000) must NOT count toward the current month.
|
|
await prisma.ai_usage.create({
|
|
data: {
|
|
kind: KIND,
|
|
model: "claude-sonnet-4-6",
|
|
input_tokens: 1_000_000,
|
|
output_tokens: 0,
|
|
cost_usd: 3,
|
|
created_at: new Date("2000-01-01T00:00:00Z"),
|
|
},
|
|
});
|
|
|
|
const after = await getMonthSpendUsd();
|
|
// Only the $3 current-month row moved the needle; the year-2000 $3 is
|
|
// excluded. The delta is exactly $3 (not $6).
|
|
expect(after - before).toBeCloseTo(3, 6);
|
|
});
|
|
|
|
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
|
|
const budget = await getBudgetUsd();
|
|
// Under budget → null (allowed)
|
|
expect(await assertBudgetAvailable()).toBeNull();
|
|
// Push spend over budget for this month, then it must block with 402.
|
|
// Tag with a unique marker so we can delete EXACTLY this row at the end of
|
|
// the test — that keeps the inflated spend from leaking into any other
|
|
// current-month test regardless of execution order (the beforeEach kind=KIND
|
|
// cleanup would catch it too, but cleaning in-test makes it order-proof).
|
|
const overBudget = await prisma.ai_usage.create({
|
|
data: {
|
|
kind: KIND,
|
|
model: "claude-sonnet-4-6",
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
cost_usd: budget + 1,
|
|
created_at: new Date(),
|
|
},
|
|
});
|
|
try {
|
|
const blocked = await assertBudgetAvailable();
|
|
expect(blocked).not.toBeNull();
|
|
expect(blocked?.status).toBe(402);
|
|
} finally {
|
|
// Remove the over-budget row immediately so it cannot inflate
|
|
// getMonthSpendUsd for any subsequently-running test.
|
|
await prisma.ai_usage
|
|
.delete({ where: { id: overBudget.id } })
|
|
.catch(() => {});
|
|
}
|
|
});
|
|
|
|
it("isConfigured reflects the API key presence", () => {
|
|
expect(typeof isConfigured()).toBe("boolean");
|
|
});
|
|
});
|
|
|
|
describe("ai.service conversations", () => {
|
|
beforeEach(async () => {
|
|
await prisma.ai_conversations.deleteMany({
|
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
|
});
|
|
});
|
|
|
|
// Narrow a service result to its error branch with a clear failure message.
|
|
const expectError = (
|
|
r: { error: string; status: number } | { data: unknown },
|
|
status: number,
|
|
) => {
|
|
if (!("error" in r))
|
|
throw new Error(`expected an error result, got ${JSON.stringify(r)}`);
|
|
expect(r.status).toBe(status);
|
|
};
|
|
|
|
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
|
|
const conv = await createConversation(CONV_UID_A);
|
|
expect(conv.title).toBe("Nová konverzace");
|
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
|
{ role: "user", content: "Kolik je hodin v Praze?" },
|
|
{ role: "assistant", content: "Nevím, ale…" },
|
|
]);
|
|
const list = await listConversations(CONV_UID_A);
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
|
|
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
|
|
if (!("data" in msgs)) throw new Error("expected messages, got an error");
|
|
expect(msgs.data.map((m) => m.content)).toEqual([
|
|
"Kolik je hodin v Praze?",
|
|
"Nevím, ale…",
|
|
]);
|
|
});
|
|
|
|
it("does not overwrite a renamed title on later appends", async () => {
|
|
const conv = await createConversation(CONV_UID_A);
|
|
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
|
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
|
{ role: "user", content: "tohle by nemělo přepsat název" },
|
|
]);
|
|
const list = await listConversations(CONV_UID_A);
|
|
expect(list[0].title).toBe("Moje téma");
|
|
});
|
|
|
|
it("bumps updated_at on append", async () => {
|
|
const conv = await createConversation(CONV_UID_A);
|
|
// Force an old timestamp, then prove append refreshes it (TIMESTAMP(0) is
|
|
// second-precision, so compare against a year rather than ms deltas).
|
|
await prisma.ai_conversations.update({
|
|
where: { id: conv.id },
|
|
data: { updated_at: new Date("2000-01-01T00:00:00Z") },
|
|
});
|
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
|
{ role: "user", content: "ping" },
|
|
]);
|
|
const after = (await listConversations(CONV_UID_A))[0].updated_at;
|
|
expect(after.getFullYear()).toBeGreaterThan(2000);
|
|
});
|
|
|
|
it("isolates conversations per user (404 on every op across owners)", async () => {
|
|
const a = await createConversation(CONV_UID_A);
|
|
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
|
|
expectError(await getConversationMessages(CONV_UID_B, a.id), 404);
|
|
expectError(
|
|
await appendConversationMessages(CONV_UID_B, a.id, [
|
|
{ role: "user", content: "x" },
|
|
]),
|
|
404,
|
|
);
|
|
expectError(await renameConversation(CONV_UID_B, a.id, "hack"), 404);
|
|
expectError(await deleteConversation(CONV_UID_B, a.id), 404);
|
|
});
|
|
|
|
it("cascade-deletes messages with the conversation", async () => {
|
|
const conv = await createConversation(CONV_UID_A);
|
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
|
{ role: "user", content: "x" },
|
|
]);
|
|
await deleteConversation(CONV_UID_A, conv.id);
|
|
const remaining = await prisma.ai_chat_messages.count({
|
|
where: { conversation_id: conv.id },
|
|
});
|
|
expect(remaining).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("HTTP /api/admin/ai", () => {
|
|
let app: Awaited<ReturnType<typeof buildAiApp>>;
|
|
let adminToken: string;
|
|
let noPermToken: string;
|
|
let noPermRoleId: number;
|
|
let noPermUserId: number;
|
|
let savedKey: string;
|
|
|
|
async function buildAiApp() {
|
|
const a = Fastify({ logger: false });
|
|
await a.register(cookie);
|
|
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
|
a.addHook("onRequest", securityHeaders);
|
|
await a.register(aiRoutes, { prefix: "/api/admin/ai" });
|
|
return a;
|
|
}
|
|
function token(user: {
|
|
id: number;
|
|
username: string;
|
|
roleName: string | null;
|
|
}) {
|
|
return jwt.sign(
|
|
{ sub: user.id, username: user.username, role: user.roleName },
|
|
appConfig.jwt.secret,
|
|
{ expiresIn: "15m" },
|
|
);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
savedKey = appConfig.anthropic.apiKey;
|
|
app = await buildAiApp();
|
|
const admin = await prisma.users.findFirst({
|
|
where: { roles: { name: "admin" } },
|
|
include: { roles: true },
|
|
});
|
|
if (!admin) throw new Error("admin not found");
|
|
adminToken = token({
|
|
id: admin.id,
|
|
username: admin.username,
|
|
roleName: admin.roles?.name ?? null,
|
|
});
|
|
const stamp = Date.now();
|
|
const role = await prisma.roles.create({
|
|
data: { name: `noperm_ai_${stamp}`, display_name: "No Perm AI" },
|
|
});
|
|
noPermRoleId = role.id;
|
|
const u = await prisma.users.create({
|
|
data: {
|
|
username: `noperm_ai_${stamp}`,
|
|
first_name: "No",
|
|
last_name: "Perm",
|
|
email: `noperm_ai_${stamp}@test.local`,
|
|
password_hash:
|
|
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
|
role_id: role.id,
|
|
is_active: true,
|
|
},
|
|
});
|
|
noPermUserId = u.id;
|
|
noPermToken = token({
|
|
id: u.id,
|
|
username: u.username,
|
|
roleName: role.name,
|
|
});
|
|
});
|
|
afterAll(async () => {
|
|
if (app) await app.close();
|
|
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
|
if (noPermUserId)
|
|
await prisma.users
|
|
.deleteMany({ where: { id: noPermUserId } })
|
|
.catch(() => {});
|
|
if (noPermRoleId)
|
|
await prisma.roles
|
|
.deleteMany({ where: { id: noPermRoleId } })
|
|
.catch(() => {});
|
|
});
|
|
|
|
it("GET /usage requires ai.use", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/admin/ai/usage",
|
|
headers: { Authorization: `Bearer ${noPermToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("GET /usage returns spend + budget for an admin", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/admin/ai/usage",
|
|
headers: { Authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(typeof body.data.budget_usd).toBe("number");
|
|
expect(typeof body.data.month_spend_usd).toBe("number");
|
|
});
|
|
|
|
it("POST /chat returns 503 when AI is not configured", async () => {
|
|
(appConfig.anthropic as { apiKey: string }).apiKey = "";
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/admin/ai/chat",
|
|
headers: {
|
|
Authorization: `Bearer ${adminToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
|
});
|
|
expect(res.statusCode).toBe(503);
|
|
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
|
});
|
|
|
|
it("GET /conversations requires ai.use", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/admin/ai/conversations",
|
|
headers: { Authorization: `Bearer ${noPermToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
|
|
const created = await app.inject({
|
|
method: "POST",
|
|
url: "/api/admin/ai/conversations",
|
|
headers: {
|
|
Authorization: `Bearer ${adminToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { title: `${HIST_MARKER} t` },
|
|
});
|
|
expect(created.statusCode).toBe(200);
|
|
const id = created.json().data.conversation.id as number;
|
|
|
|
const appended = await app.inject({
|
|
method: "POST",
|
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
|
headers: {
|
|
Authorization: `Bearer ${adminToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
|
});
|
|
expect(appended.statusCode).toBe(200);
|
|
|
|
const msgs = await app.inject({
|
|
method: "GET",
|
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
|
headers: { Authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(
|
|
msgs.json().data.messages.map((m: { content: string }) => m.content),
|
|
).toContain("ahoj");
|
|
|
|
const renamed = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/admin/ai/conversations/${id}`,
|
|
headers: {
|
|
Authorization: `Bearer ${adminToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { title: `${HIST_MARKER} renamed` },
|
|
});
|
|
expect(renamed.json().data.conversation.title).toBe(
|
|
`${HIST_MARKER} renamed`,
|
|
);
|
|
|
|
const del = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/admin/ai/conversations/${id}`,
|
|
headers: { Authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(del.statusCode).toBe(200);
|
|
});
|
|
|
|
it("GET messages of a non-existent conversation → 404", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/admin/ai/conversations/999999000/messages",
|
|
headers: { Authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("POST /conversations requires ai.use", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/admin/ai/conversations",
|
|
headers: {
|
|
Authorization: `Bearer ${noPermToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { title: "x" },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("GET /conversations lists the user's conversations", async () => {
|
|
const created = await app.inject({
|
|
method: "POST",
|
|
url: "/api/admin/ai/conversations",
|
|
headers: {
|
|
Authorization: `Bearer ${adminToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { title: `${HIST_MARKER} listed` },
|
|
});
|
|
const id = created.json().data.conversation.id as number;
|
|
const list = await app.inject({
|
|
method: "GET",
|
|
url: "/api/admin/ai/conversations",
|
|
headers: { Authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(list.statusCode).toBe(200);
|
|
expect(
|
|
list.json().data.conversations.map((c: { id: number }) => c.id),
|
|
).toContain(id);
|
|
});
|
|
|
|
it("rejects an empty title (PATCH) and empty messages (POST) with 400", async () => {
|
|
const patch = await app.inject({
|
|
method: "PATCH",
|
|
url: "/api/admin/ai/conversations/999999000",
|
|
headers: {
|
|
Authorization: `Bearer ${adminToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { title: "" },
|
|
});
|
|
expect(patch.statusCode).toBe(400);
|
|
|
|
const append = await app.inject({
|
|
method: "POST",
|
|
url: "/api/admin/ai/conversations/999999000/messages",
|
|
headers: {
|
|
Authorization: `Bearer ${adminToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { messages: [] },
|
|
});
|
|
expect(append.statusCode).toBe(400);
|
|
});
|
|
});
|