- SPZ matching normalized on both sides (4SY7039 finds '4SY 7039') in list_vehicles and the list_trips vehicle filter; JS-side name matching diacritic-folded to keep utf8mb4_unicode_ci parity; ICO typed with spaces matches the space-less stored value (suppliers + customers) - get_top_customers: whole-table per-customer counts of invoices (sans drafts) / orders / projects, top 20 + customers_with_records, optional year, per-type permission re-checks — answers 'pro koho pracujeme nejvic' exactly instead of refusing - find_employee, find_supplier and stock search now return total_matching (stock search also gained its missing deterministic orderBy); system prompt teaches the model to answer counts from total_matching and use aggregation tools instead of summing 20-row list pages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1080 lines
35 KiB
TypeScript
1080 lines
35 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||
import prisma from "../config/database";
|
||
import {
|
||
toolDefinitionsFor,
|
||
executeTool,
|
||
ctxCan,
|
||
TOOL_LABELS,
|
||
type AiAuthCtx,
|
||
} from "../services/ai-tools";
|
||
|
||
// The agentic loop talks to a true external (Anthropic API) — mock the SDK
|
||
// module; everything below it (tools, services, DB) runs for real.
|
||
const { createMock } = vi.hoisted(() => ({ createMock: vi.fn() }));
|
||
vi.mock("@anthropic-ai/sdk", () => ({
|
||
default: class MockAnthropic {
|
||
messages = { create: createMock };
|
||
},
|
||
}));
|
||
|
||
// Imported AFTER the mock so ai.service's `new Anthropic()` gets the mock.
|
||
import { agenticChat } from "../services/ai.service";
|
||
|
||
const ADMIN: AiAuthCtx = {
|
||
userId: 999999993,
|
||
roleName: "admin",
|
||
permissions: [],
|
||
};
|
||
const VIEWER_INVOICES: AiAuthCtx = {
|
||
userId: 999999994,
|
||
roleName: "viewer",
|
||
permissions: ["invoices.view"],
|
||
};
|
||
const NOBODY: AiAuthCtx = {
|
||
userId: 999999995,
|
||
roleName: "viewer",
|
||
permissions: [],
|
||
};
|
||
|
||
// People-centric fixtures (far-future dates per fixture hygiene; unique
|
||
// names/spz so re-runs can't collide with real rows). 07:00–15:30 with a
|
||
// 30min break = 8.00 worked hours; trip 2 has a stored distance (10) that
|
||
// deliberately differs from end-start (8) to pin the stats-coalesce rule.
|
||
const FIX = {
|
||
username: "aitest.dominik",
|
||
email: "aitest.dominik@test.local",
|
||
first: "Dominik",
|
||
last: "AiToolsTest",
|
||
spz: "AITEST999",
|
||
note: "AiTools fixture",
|
||
role: "aitest-role",
|
||
username2: "aitest.other",
|
||
email2: "aitest.other@test.local",
|
||
};
|
||
let fixUserId = 0;
|
||
let fixUser2Id = 0;
|
||
let fixRoleId = 0;
|
||
let fixVehicleId = 0;
|
||
|
||
beforeAll(async () => {
|
||
// Defensive pre-clean of a previously failed run (Restrict FK on
|
||
// work_plan_entries.created_by means entries go before the user).
|
||
await prisma.work_plan_entries.deleteMany({ where: { note: FIX.note } });
|
||
await prisma.users.deleteMany({
|
||
where: { username: { in: [FIX.username, FIX.username2] } },
|
||
});
|
||
await prisma.roles.deleteMany({ where: { name: FIX.role } });
|
||
await prisma.vehicles.deleteMany({ where: { spz: FIX.spz } });
|
||
await prisma.ai_usage.deleteMany({
|
||
where: { user_id: { in: [ADMIN.userId, NOBODY.userId] }, kind: "agent" },
|
||
});
|
||
|
||
// find_employee scopes its population to role-permission holders (route
|
||
// parity), so the fixture user needs a role carrying attendance.record +
|
||
// trips.record (both permissions exist from migrations/seed).
|
||
const role = await prisma.roles.create({
|
||
data: { name: FIX.role, display_name: "AiTools test role" },
|
||
});
|
||
fixRoleId = role.id;
|
||
const perms = await prisma.permissions.findMany({
|
||
where: { name: { in: ["attendance.record", "trips.record"] } },
|
||
select: { id: true },
|
||
});
|
||
if (perms.length !== 2) {
|
||
throw new Error("Test setup: attendance.record/trips.record missing");
|
||
}
|
||
await prisma.role_permissions.createMany({
|
||
data: perms.map((p) => ({ role_id: fixRoleId, permission_id: p.id })),
|
||
});
|
||
|
||
const u = await prisma.users.create({
|
||
data: {
|
||
username: FIX.username,
|
||
email: FIX.email,
|
||
password_hash: "x",
|
||
first_name: FIX.first,
|
||
last_name: FIX.last,
|
||
is_active: true,
|
||
role_id: fixRoleId,
|
||
},
|
||
});
|
||
fixUserId = u.id;
|
||
// Role-less second user: invisible to scoped find_employee populations,
|
||
// off the plan grid, and the foreign-trip fixture for scoping tests.
|
||
const u2 = await prisma.users.create({
|
||
data: {
|
||
username: FIX.username2,
|
||
email: FIX.email2,
|
||
password_hash: "x",
|
||
first_name: "Otto",
|
||
last_name: "AiToolsTest",
|
||
is_active: true,
|
||
},
|
||
});
|
||
fixUser2Id = u2.id;
|
||
// Leave balance for the HR tools (far-future year per fixture hygiene).
|
||
await prisma.leave_balances.create({
|
||
data: {
|
||
user_id: fixUserId,
|
||
year: 2098,
|
||
vacation_total: 25,
|
||
vacation_used: 5,
|
||
sick_used: 2,
|
||
},
|
||
});
|
||
await prisma.attendance.create({
|
||
data: {
|
||
user_id: fixUserId,
|
||
shift_date: new Date(Date.UTC(2098, 5, 15)),
|
||
arrival_time: new Date(2098, 5, 15, 7, 0, 0),
|
||
departure_time: new Date(2098, 5, 15, 15, 30, 0),
|
||
break_start: new Date(2098, 5, 15, 11, 0, 0),
|
||
break_end: new Date(2098, 5, 15, 11, 30, 0),
|
||
leave_type: "work",
|
||
},
|
||
});
|
||
const v = await prisma.vehicles.create({
|
||
data: { spz: FIX.spz, name: "AiTest Van" },
|
||
});
|
||
fixVehicleId = v.id;
|
||
await prisma.trips.createMany({
|
||
data: [
|
||
{
|
||
vehicle_id: fixVehicleId,
|
||
user_id: fixUserId,
|
||
trip_date: new Date(Date.UTC(2098, 5, 15)),
|
||
start_km: 1000,
|
||
end_km: 1042,
|
||
route_from: "Brno",
|
||
route_to: "Praha",
|
||
is_business: true,
|
||
},
|
||
{
|
||
vehicle_id: fixVehicleId,
|
||
user_id: fixUserId,
|
||
trip_date: new Date(Date.UTC(2098, 5, 16)),
|
||
start_km: 1042,
|
||
end_km: 1050,
|
||
distance: 10,
|
||
route_from: "Praha",
|
||
route_to: "Brno",
|
||
is_business: false,
|
||
},
|
||
{
|
||
// Second user's trip in the SAME window — must be excluded by the
|
||
// non-manager self-scoping (an unscoped query would return 3 rows).
|
||
vehicle_id: fixVehicleId,
|
||
user_id: fixUser2Id,
|
||
trip_date: new Date(Date.UTC(2098, 5, 15)),
|
||
start_km: 500,
|
||
end_km: 600,
|
||
route_from: "Ostrava",
|
||
route_to: "Brno",
|
||
is_business: true,
|
||
},
|
||
],
|
||
});
|
||
await prisma.work_plan_entries.create({
|
||
data: {
|
||
user_id: fixUserId,
|
||
created_by: fixUserId,
|
||
date_from: new Date(Date.UTC(2098, 5, 15)),
|
||
date_to: new Date(Date.UTC(2098, 5, 16)),
|
||
category: "aitest-cat",
|
||
note: FIX.note,
|
||
},
|
||
});
|
||
});
|
||
|
||
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
||
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
||
const FIX_RI_SUPPLIER = "AiTest Supplier s.r.o.";
|
||
const FIX_CUSTOMER = "AiTest Zákazník s.r.o.";
|
||
let fixOfferId = 0;
|
||
let fixSupplierId = 0;
|
||
let fixRiId = 0;
|
||
let fixCustomerId = 0;
|
||
|
||
beforeAll(async () => {
|
||
await prisma.quotations.deleteMany({
|
||
where: { quotation_number: FIX_OFFER_NUMBER },
|
||
});
|
||
await prisma.sklad_suppliers.deleteMany({ where: { name: FIX_SUPPLIER } });
|
||
await prisma.received_invoices.deleteMany({
|
||
where: { supplier_name: FIX_RI_SUPPLIER },
|
||
});
|
||
const sup = await prisma.sklad_suppliers.create({
|
||
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||
});
|
||
fixSupplierId = sup.id;
|
||
// Customer + two projects for the per-customer aggregation tool.
|
||
await prisma.projects.deleteMany({
|
||
where: { name: { startsWith: "AiTest Projekt" } },
|
||
});
|
||
await prisma.customers.deleteMany({ where: { name: FIX_CUSTOMER } });
|
||
const cust = await prisma.customers.create({
|
||
data: { name: FIX_CUSTOMER },
|
||
});
|
||
fixCustomerId = cust.id;
|
||
await prisma.projects.createMany({
|
||
data: [
|
||
{ name: "AiTest Projekt 1", customer_id: fixCustomerId },
|
||
{ name: "AiTest Projekt 2", customer_id: fixCustomerId },
|
||
],
|
||
});
|
||
const ri = await prisma.received_invoices.create({
|
||
data: {
|
||
month: 6,
|
||
year: 2098,
|
||
supplier_name: FIX_RI_SUPPLIER,
|
||
invoice_number: "AITEST-RI-1",
|
||
amount: 1210,
|
||
vat_rate: 21,
|
||
vat_amount: 210,
|
||
currency: "CZK",
|
||
issue_date: new Date(Date.UTC(2098, 5, 1)),
|
||
due_date: new Date(Date.UTC(2098, 5, 15)),
|
||
status: "unpaid",
|
||
},
|
||
});
|
||
fixRiId = ri.id;
|
||
const q = await prisma.quotations.create({
|
||
data: {
|
||
quotation_number: FIX_OFFER_NUMBER,
|
||
status: "active",
|
||
currency: "EUR",
|
||
quotation_items: {
|
||
create: [
|
||
{
|
||
position: 1,
|
||
description: "Rozvaděč RVO-1",
|
||
item_description: "<p>Hlavní <b>rozvaděč</b> včetně montáže</p>",
|
||
quantity: 2,
|
||
unit: "ks",
|
||
unit_price: 1000,
|
||
},
|
||
{
|
||
position: 2,
|
||
description: "Doprava (informativní)",
|
||
quantity: 1,
|
||
unit: "ks",
|
||
unit_price: 500,
|
||
is_included_in_total: false,
|
||
},
|
||
],
|
||
},
|
||
scope_sections: {
|
||
create: [
|
||
{
|
||
position: 1,
|
||
title_cz: "Rozsah projektu",
|
||
content: "<p>Dodávka & montáž <i>na klíč</i>.</p>",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
});
|
||
fixOfferId = q.id;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
// Items/sections cascade with the quotation.
|
||
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
||
// Projects before the customer (Restrict FK on projects.customer_id).
|
||
await prisma.projects.deleteMany({ where: { customer_id: fixCustomerId } });
|
||
await prisma.customers.deleteMany({ where: { id: fixCustomerId } });
|
||
});
|
||
|
||
afterAll(async () => {
|
||
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
||
const fixUserIds = [fixUserId, fixUser2Id];
|
||
await prisma.work_plan_entries.deleteMany({
|
||
where: { user_id: { in: fixUserIds } },
|
||
});
|
||
await prisma.trips.deleteMany({ where: { user_id: { in: fixUserIds } } });
|
||
await prisma.attendance.deleteMany({
|
||
where: { user_id: { in: fixUserIds } },
|
||
});
|
||
await prisma.users.deleteMany({ where: { id: { in: fixUserIds } } });
|
||
await prisma.roles.deleteMany({ where: { id: fixRoleId } });
|
||
await prisma.vehicles.deleteMany({ where: { id: fixVehicleId } });
|
||
await prisma.ai_usage.deleteMany({
|
||
where: { user_id: { in: [ADMIN.userId, NOBODY.userId] }, kind: "agent" },
|
||
});
|
||
});
|
||
|
||
describe("ai-tools — permission delegation", () => {
|
||
it("ctxCan: admin bypasses, others need the exact permission", () => {
|
||
expect(ctxCan(ADMIN, "invoices.view")).toBe(true);
|
||
expect(ctxCan(VIEWER_INVOICES, "invoices.view")).toBe(true);
|
||
expect(ctxCan(VIEWER_INVOICES, "orders.view")).toBe(false);
|
||
expect(ctxCan(NOBODY, "invoices.view")).toBe(false);
|
||
});
|
||
|
||
it("toolDefinitionsFor filters the tool list by the user's permissions", () => {
|
||
const adminTools = toolDefinitionsFor(ADMIN).map((t) => t.name);
|
||
// All registered tools (the labels map is the authoritative name list).
|
||
expect(adminTools.sort()).toEqual(Object.keys(TOOL_LABELS).sort());
|
||
|
||
const viewerTools = toolDefinitionsFor(VIEWER_INVOICES).map((t) => t.name);
|
||
expect(viewerTools).toContain("list_invoices");
|
||
expect(viewerTools).toContain("get_invoice_stats");
|
||
expect(viewerTools).not.toContain("list_orders");
|
||
expect(viewerTools).not.toContain("get_stock_overview");
|
||
|
||
expect(toolDefinitionsFor(NOBODY)).toHaveLength(0);
|
||
});
|
||
|
||
it("executeTool DENIES a tool the user lacks the permission for", async () => {
|
||
const res = await executeTool("list_orders", {}, VIEWER_INVOICES);
|
||
expect(res.ok).toBe(false);
|
||
expect(JSON.stringify(res.result)).toContain("oprávnění");
|
||
});
|
||
|
||
it("executeTool rejects an unknown (hallucinated) tool name", async () => {
|
||
const res = await executeTool("drop_all_tables", {}, ADMIN);
|
||
expect(res.ok).toBe(false);
|
||
expect(JSON.stringify(res.result)).toContain("Neznámý nástroj");
|
||
});
|
||
|
||
it("get_attendance_summary: another user's data needs attendance.manage", async () => {
|
||
const self: AiAuthCtx = {
|
||
userId: 999999996,
|
||
roleName: "viewer",
|
||
permissions: ["attendance.record"],
|
||
};
|
||
// Own data — allowed (empty month is fine, shape matters).
|
||
const own = await executeTool("get_attendance_summary", {}, self);
|
||
expect(own.ok).toBe(true);
|
||
expect(own.result).toMatchObject({ user_id: self.userId });
|
||
|
||
// Someone else's data without attendance.manage — denied in-result, and
|
||
// executeTool flags handler-level { error } results as not-ok so denial
|
||
// chips render consistently.
|
||
const other = await executeTool(
|
||
"get_attendance_summary",
|
||
{ user_id: 1 },
|
||
self,
|
||
);
|
||
expect(other.ok).toBe(false);
|
||
expect(JSON.stringify(other.result)).toContain("oprávnění");
|
||
});
|
||
});
|
||
|
||
describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||
const RECORDER: AiAuthCtx = {
|
||
userId: 999999997,
|
||
roleName: "viewer",
|
||
permissions: ["attendance.record"],
|
||
};
|
||
|
||
it("find_employee resolves a name (and full name) to user_id", async () => {
|
||
const res = await executeTool(
|
||
"find_employee",
|
||
{ search: FIX.last },
|
||
RECORDER,
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
const r = res.result as {
|
||
employees: {
|
||
user_id: number;
|
||
name: string;
|
||
username?: string;
|
||
active: boolean;
|
||
}[];
|
||
};
|
||
const hit = r.employees.find((e) => e.user_id === fixUserId);
|
||
// Attendance callers see usernames (GET /plan/users parity).
|
||
expect(hit).toMatchObject({
|
||
name: `${FIX.first} ${FIX.last}`,
|
||
username: FIX.username,
|
||
active: true,
|
||
});
|
||
// The role-less second user is OUTSIDE the attendance.record population
|
||
// (route parity with listPlanUsers) — must not be returned.
|
||
expect(r.employees.some((e) => e.user_id === fixUser2Id)).toBe(false);
|
||
|
||
const full = await executeTool(
|
||
"find_employee",
|
||
{ search: `${FIX.first} ${FIX.last}` },
|
||
RECORDER,
|
||
);
|
||
expect(
|
||
(full.result as { employees: { user_id: number }[] }).employees.some(
|
||
(e) => e.user_id === fixUserId,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("find_employee: trips callers get no usernames; trips.history alone is denied", async () => {
|
||
// GET /trips/users parity: drivers' picker has no usernames.
|
||
const trips = await executeTool(
|
||
"find_employee",
|
||
{ search: FIX.last },
|
||
{ userId: 999999998, roleName: "viewer", permissions: ["trips.record"] },
|
||
);
|
||
expect(trips.ok).toBe(true);
|
||
const t = trips.result as {
|
||
employees: { user_id: number; username?: string }[];
|
||
};
|
||
const hit = t.employees.find((e) => e.user_id === fixUserId);
|
||
expect(hit).toBeDefined();
|
||
expect(hit).not.toHaveProperty("username");
|
||
|
||
// trips.history grants no picker route → no find_employee either.
|
||
const hist = await executeTool(
|
||
"find_employee",
|
||
{ search: FIX.last },
|
||
{ userId: 999999998, roleName: "viewer", permissions: ["trips.history"] },
|
||
);
|
||
expect(hist.ok).toBe(false);
|
||
});
|
||
|
||
it("find_employee is denied without any people-related permission", async () => {
|
||
const res = await executeTool("find_employee", { search: "x" }, NOBODY);
|
||
expect(res.ok).toBe(false);
|
||
expect(toolDefinitionsFor(NOBODY).map((t) => t.name)).not.toContain(
|
||
"find_employee",
|
||
);
|
||
});
|
||
|
||
it("find_employee: only users.view sees the full directory (role-less users)", async () => {
|
||
// ADMIN bypasses ctxCan → full-directory branch finds the role-less user.
|
||
const admin = await executeTool("find_employee", { search: "Otto" }, ADMIN);
|
||
expect(
|
||
(admin.result as { employees: { user_id: number }[] }).employees.some(
|
||
(e) => e.user_id === fixUser2Id,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("get_attendance_summary returns day detail with worked hours for a date range", async () => {
|
||
const res = await executeTool(
|
||
"get_attendance_summary",
|
||
{ user_id: fixUserId, date_from: "2098-06-15", date_to: "2098-06-15" },
|
||
ADMIN,
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
const r = res.result as {
|
||
days: {
|
||
date: string;
|
||
arrival: string | null;
|
||
departure: string | null;
|
||
worked_hours: number | null;
|
||
}[];
|
||
worked_hours_total: number;
|
||
days_by_type: Record<string, number>;
|
||
};
|
||
expect(r.days).toHaveLength(1);
|
||
expect(r.days[0]).toMatchObject({
|
||
date: "2098-06-15",
|
||
arrival: "07:00",
|
||
departure: "15:30",
|
||
worked_hours: 8, // 8.5 h minus 30 min break
|
||
});
|
||
expect(r.worked_hours_total).toBe(8);
|
||
expect(r.days_by_type).toEqual({ work: 1 });
|
||
});
|
||
|
||
it("list_trips: manager filter + km totals with the stats coalesce; non-managers scoped to self", async () => {
|
||
const mgr = await executeTool(
|
||
"list_trips",
|
||
{ user_id: fixUserId, date_from: "2098-06-15", date_to: "2098-06-16" },
|
||
ADMIN,
|
||
);
|
||
expect(mgr.ok).toBe(true);
|
||
const m = mgr.result as {
|
||
total_matching: number;
|
||
total_km: number;
|
||
business_km: number;
|
||
private_km: number;
|
||
trips: { km: number; date: string }[];
|
||
};
|
||
expect(m.total_matching).toBe(2);
|
||
expect(m.business_km).toBe(42);
|
||
expect(m.private_km).toBe(10); // stored distance wins over end-start (8)
|
||
expect(m.total_km).toBe(52);
|
||
expect(m.trips[0].date).toBe("2098-06-16"); // newest first
|
||
|
||
// Non-manager asking for someone else's trips → denied.
|
||
const denied = await executeTool(
|
||
"list_trips",
|
||
{ user_id: fixUserId },
|
||
{ userId: 999999998, roleName: "viewer", permissions: ["trips.record"] },
|
||
);
|
||
expect(denied.ok).toBe(false);
|
||
expect(JSON.stringify(denied.result)).toContain("oprávnění");
|
||
|
||
// Non-manager without user_id is hard-scoped to their own trips: the
|
||
// second user's trip sits in the same window, so an unscoped query
|
||
// would return 3 — the scoping must yield exactly the caller's 2.
|
||
const own = await executeTool(
|
||
"list_trips",
|
||
{ date_from: "2098-06-15", date_to: "2098-06-16" },
|
||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||
);
|
||
expect(own.ok).toBe(true);
|
||
const o = own.result as { total_matching: number; total_km: number };
|
||
expect(o.total_matching).toBe(2);
|
||
expect(o.total_km).toBe(52); // foreign 100km trip excluded from totals too
|
||
});
|
||
|
||
it("get_offer_detail returns line items, totals and stripped sections", async () => {
|
||
const res = await executeTool(
|
||
"get_offer_detail",
|
||
{ number: "99999" }, // partial number lookup
|
||
ADMIN,
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
const r = res.result as {
|
||
number: string;
|
||
currency: string;
|
||
item_count: number;
|
||
items_total: number;
|
||
items: {
|
||
name: string;
|
||
detail?: string;
|
||
line_total: number;
|
||
excluded_from_total?: boolean;
|
||
}[];
|
||
sections: { title: string | null; text: string }[];
|
||
};
|
||
expect(r.number).toBe(FIX_OFFER_NUMBER);
|
||
expect(r.currency).toBe("EUR");
|
||
expect(r.item_count).toBe(2);
|
||
expect(r.items[0]).toMatchObject({
|
||
name: "Rozvaděč RVO-1",
|
||
detail: "Hlavní rozvaděč včetně montáže", // HTML stripped
|
||
line_total: 2000,
|
||
});
|
||
expect(r.items[1].excluded_from_total).toBe(true);
|
||
expect(r.items_total).toBe(2000); // excluded row not counted
|
||
expect(r.sections[0]).toMatchObject({ title: "Rozsah projektu" });
|
||
expect(r.sections[0].text).toContain("Dodávka & montáž na klíč");
|
||
|
||
// Unknown number → explicit Czech error, flagged not-ok.
|
||
const miss = await executeTool(
|
||
"get_offer_detail",
|
||
{ number: "NEEXISTUJE-123" },
|
||
ADMIN,
|
||
);
|
||
expect(miss.ok).toBe(false);
|
||
|
||
// No offers.view → denied.
|
||
const denied = await executeTool(
|
||
"get_offer_detail",
|
||
{ number: "99999" },
|
||
NOBODY,
|
||
);
|
||
expect(denied.ok).toBe(false);
|
||
});
|
||
|
||
it("get_leave_balance: own for a recorder; foreign needs balances/manage", async () => {
|
||
const own = await executeTool(
|
||
"get_leave_balance",
|
||
{ year: 2098 },
|
||
{
|
||
userId: fixUserId,
|
||
roleName: "viewer",
|
||
permissions: ["attendance.record"],
|
||
},
|
||
);
|
||
expect(own.ok).toBe(true);
|
||
expect(own.result).toMatchObject({
|
||
user_id: fixUserId,
|
||
year: 2098,
|
||
vacation_total: 25,
|
||
vacation_used: 5,
|
||
vacation_remaining: 20,
|
||
sick_used: 2,
|
||
});
|
||
|
||
// Bare attendance.record asking about someone else → denied.
|
||
const denied = await executeTool(
|
||
"get_leave_balance",
|
||
{ user_id: fixUserId, year: 2098 },
|
||
RECORDER,
|
||
);
|
||
expect(denied.ok).toBe(false);
|
||
|
||
// attendance.balances (the overview route's permission) unlocks others.
|
||
const hr = await executeTool(
|
||
"get_leave_balance",
|
||
{ user_id: fixUserId, year: 2098 },
|
||
{
|
||
userId: 999999998,
|
||
roleName: "viewer",
|
||
permissions: ["attendance.balances"],
|
||
},
|
||
);
|
||
expect(hr.ok).toBe(true);
|
||
});
|
||
|
||
it("get_work_fund returns the current-month fund; foreign needs manage", async () => {
|
||
const res = await executeTool(
|
||
"get_work_fund",
|
||
{ user_id: fixUserId },
|
||
ADMIN,
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
const r = res.result as {
|
||
fund_hours: number;
|
||
worked_hours: number;
|
||
business_days: number;
|
||
};
|
||
expect(r.fund_hours).toBeGreaterThan(0);
|
||
expect(r.business_days).toBeGreaterThan(0);
|
||
expect(r.worked_hours).toBe(0); // fixture attendance lives in 2098
|
||
|
||
const denied = await executeTool(
|
||
"get_work_fund",
|
||
{ user_id: fixUserId },
|
||
RECORDER,
|
||
);
|
||
expect(denied.ok).toBe(false);
|
||
});
|
||
|
||
it("get_project_hours_report is manage-gated and aggregates the fixture shift", async () => {
|
||
const denied = await executeTool("get_project_hours_report", {}, RECORDER);
|
||
expect(denied.ok).toBe(false);
|
||
|
||
// The 2098 fixture shift (8.00 h, no project) lands in the labeled
|
||
// no-project bucket with per-user hours.
|
||
const res = await executeTool(
|
||
"get_project_hours_report",
|
||
{ year: 2098 },
|
||
ADMIN,
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
const r = res.result as {
|
||
year: number;
|
||
projects: {
|
||
label: string;
|
||
hours: number;
|
||
by_user: Record<string, number>;
|
||
}[];
|
||
};
|
||
expect(r.year).toBe(2098);
|
||
const bucket = r.projects.find((p) => p.label === "(bez projektu)");
|
||
expect(bucket).toBeDefined();
|
||
expect(bucket!.hours).toBe(8);
|
||
expect(bucket!.by_user[`${FIX.first} ${FIX.last}`]).toBe(8);
|
||
});
|
||
|
||
it("find_supplier + list_vehicles return their compact shapes", async () => {
|
||
const sup = await executeTool(
|
||
"find_supplier",
|
||
{ search: "AiTest Dodavatel" },
|
||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||
);
|
||
expect(sup.ok).toBe(true);
|
||
const s = (
|
||
sup.result as { suppliers: { name: string; ico: string | null }[] }
|
||
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||
expect(s).toMatchObject({ ico: "99999998" });
|
||
|
||
// IČO typed with spaces finds the space-less stored value.
|
||
const spacedIco = await executeTool(
|
||
"find_supplier",
|
||
{ search: "999 99 998" },
|
||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||
);
|
||
expect(
|
||
(spacedIco.result as { suppliers: { name: string }[] }).suppliers.some(
|
||
(x) => x.name === FIX_SUPPLIER,
|
||
),
|
||
).toBe(true);
|
||
|
||
const veh = await executeTool(
|
||
"list_vehicles",
|
||
{ search: FIX.spz },
|
||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||
);
|
||
expect(veh.ok).toBe(true);
|
||
const v = (
|
||
veh.result as {
|
||
vehicles: { spz: string; current_km: number; trip_count: number }[];
|
||
}
|
||
).vehicles[0];
|
||
// Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips.
|
||
expect(v).toMatchObject({ spz: FIX.spz, current_km: 1050, trip_count: 3 });
|
||
|
||
// SPZ spacing is normalized: "AITEST 999" finds the stored "AITEST999",
|
||
// in list_vehicles AND in the list_trips vehicle filter.
|
||
const spaced = await executeTool(
|
||
"list_vehicles",
|
||
{ search: "aitest 999" },
|
||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||
);
|
||
expect(
|
||
(spaced.result as { vehicles: { spz: string }[] }).vehicles[0]?.spz,
|
||
).toBe(FIX.spz);
|
||
const tripsByPlate = await executeTool(
|
||
"list_trips",
|
||
{ vehicle: "AITEST 999", date_from: "2098-06-15", date_to: "2098-06-16" },
|
||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||
);
|
||
expect(
|
||
(tripsByPlate.result as { total_matching: number }).total_matching,
|
||
).toBe(2);
|
||
|
||
expect(
|
||
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||
).toBe(false);
|
||
});
|
||
|
||
it("get_document_totals sums the WHOLE filtered set per currency", async () => {
|
||
const res = await executeTool(
|
||
"get_document_totals",
|
||
{ type: "offers", search: FIX_OFFER_NUMBER },
|
||
ADMIN,
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
expect(
|
||
(res.result as { totals: { currency: string; amount: number }[] }).totals,
|
||
).toEqual([{ currency: "EUR", amount: 2000 }]); // excluded row not counted
|
||
|
||
// orders.view alone cannot read offer totals (per-type re-check).
|
||
const denied = await executeTool(
|
||
"get_document_totals",
|
||
{ type: "offers" },
|
||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||
);
|
||
expect(denied.ok).toBe(false);
|
||
|
||
// Offers have no period filter — month/year must error, not be ignored.
|
||
const offersMonth = await executeTool(
|
||
"get_document_totals",
|
||
{ type: "offers", month: 6, year: 2026 },
|
||
ADMIN,
|
||
);
|
||
expect(offersMonth.ok).toBe(false);
|
||
|
||
// A bare month defaults the year (never an unmarked all-time sum) and
|
||
// the applied period is echoed back.
|
||
const bareMonth = await executeTool(
|
||
"get_document_totals",
|
||
{ type: "orders", month: 6 },
|
||
ADMIN,
|
||
);
|
||
expect(bareMonth.ok).toBe(true);
|
||
expect((bareMonth.result as { period: string }).period).toBe(
|
||
`6/${new Date().getFullYear()}`,
|
||
);
|
||
});
|
||
|
||
it("get_top_customers aggregates per customer across the whole table", async () => {
|
||
const res = await executeTool(
|
||
"get_top_customers",
|
||
{ type: "projects" },
|
||
ADMIN,
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
const r = res.result as {
|
||
customers_with_records: number;
|
||
top: { customer: string; count: number }[];
|
||
};
|
||
const mine = r.top.find((t) => t.customer === FIX_CUSTOMER);
|
||
expect(mine).toBeDefined();
|
||
expect(mine!.count).toBe(2);
|
||
expect(r.customers_with_records).toBeGreaterThanOrEqual(1);
|
||
|
||
// invoices.view alone cannot aggregate projects (per-type re-check).
|
||
const denied = await executeTool(
|
||
"get_top_customers",
|
||
{ type: "projects" },
|
||
VIEWER_INVOICES,
|
||
);
|
||
expect(denied.ok).toBe(false);
|
||
});
|
||
|
||
it("find_employee and find_supplier report total_matching", async () => {
|
||
const emp = await executeTool(
|
||
"find_employee",
|
||
{ search: FIX.last },
|
||
RECORDER,
|
||
);
|
||
expect(
|
||
(emp.result as { total_matching: number }).total_matching,
|
||
).toBeGreaterThanOrEqual(1);
|
||
const sup = await executeTool(
|
||
"find_supplier",
|
||
{ search: FIX_SUPPLIER },
|
||
ADMIN,
|
||
);
|
||
expect(
|
||
(sup.result as { total_matching: number }).total_matching,
|
||
).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||
const res = await executeTool(
|
||
"get_received_invoice_detail",
|
||
{ search: "AiTest Supplier" },
|
||
VIEWER_INVOICES,
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
expect(res.result).toMatchObject({
|
||
supplier: FIX_RI_SUPPLIER,
|
||
number: "AITEST-RI-1",
|
||
amount_with_vat: 1210,
|
||
vat_amount: 210,
|
||
status: "unpaid",
|
||
});
|
||
|
||
expect(
|
||
(
|
||
await executeTool(
|
||
"get_received_invoice_detail",
|
||
{ search: "x" },
|
||
NOBODY,
|
||
)
|
||
).ok,
|
||
).toBe(false);
|
||
});
|
||
|
||
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
||
const res = await executeTool(
|
||
"get_work_plan",
|
||
{ user_id: fixUserId, date_from: "2098-06-15", date_to: "2098-06-17" },
|
||
RECORDER,
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
const r = res.result as {
|
||
plan: { date: string; user: string; plan: string; note: string }[];
|
||
records: number;
|
||
};
|
||
// The entry spans 15.–16. → exactly two resolved days, none on the 17th.
|
||
expect(r.records).toBe(2);
|
||
expect(r.plan[0]).toMatchObject({
|
||
date: "2098-06-15",
|
||
user: `${FIX.first} ${FIX.last}`,
|
||
plan: "aitest-cat", // no plan_categories row → raw key fallback
|
||
note: FIX.note,
|
||
});
|
||
expect(r.plan[1].date).toBe("2098-06-16");
|
||
});
|
||
|
||
it("get_work_plan: off-grid users need attendance.manage (grid-route parity)", async () => {
|
||
// The role-less second user is not a plan-grid user → a bare
|
||
// attendance.record caller is denied...
|
||
const denied = await executeTool(
|
||
"get_work_plan",
|
||
{ user_id: fixUser2Id, date_from: "2098-06-15", date_to: "2098-06-16" },
|
||
RECORDER,
|
||
);
|
||
expect(denied.ok).toBe(false);
|
||
expect(JSON.stringify(denied.result)).toContain("oprávnění");
|
||
|
||
// ...while a manager/admin may read them (empty plan, no error).
|
||
const admin = await executeTool(
|
||
"get_work_plan",
|
||
{ user_id: fixUser2Id, date_from: "2098-06-15", date_to: "2098-06-16" },
|
||
ADMIN,
|
||
);
|
||
expect(admin.ok).toBe(true);
|
||
expect((admin.result as { records: number }).records).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe("ai-tools — real reads", () => {
|
||
it("list_invoices returns the compact shape from the real DB", async () => {
|
||
const res = await executeTool("list_invoices", {}, ADMIN);
|
||
expect(res.ok).toBe(true);
|
||
const r = res.result as {
|
||
total_matching: number;
|
||
shown: number;
|
||
invoices: unknown[];
|
||
};
|
||
expect(typeof r.total_matching).toBe("number");
|
||
expect(Array.isArray(r.invoices)).toBe(true);
|
||
expect(r.shown).toBe(r.invoices.length);
|
||
expect(r.shown).toBeLessThanOrEqual(20);
|
||
});
|
||
|
||
it("list_projects + get_stock_overview return their shapes", async () => {
|
||
const projects = await executeTool("list_projects", {}, ADMIN);
|
||
expect(projects.ok).toBe(true);
|
||
expect(
|
||
Array.isArray((projects.result as { projects: unknown[] }).projects),
|
||
).toBe(true);
|
||
|
||
const stock = await executeTool("get_stock_overview", {}, ADMIN);
|
||
expect(stock.ok).toBe(true);
|
||
expect(
|
||
typeof (stock.result as { below_minimum_count: number })
|
||
.below_minimum_count,
|
||
).toBe("number");
|
||
});
|
||
});
|
||
|
||
describe("agenticChat loop (SDK mocked)", () => {
|
||
it("executes a requested tool, feeds the result back, returns final text + trace", async () => {
|
||
createMock.mockReset();
|
||
createMock
|
||
.mockResolvedValueOnce({
|
||
stop_reason: "tool_use",
|
||
content: [
|
||
{ type: "text", text: "Podívám se." },
|
||
{
|
||
type: "tool_use",
|
||
id: "toolu_1",
|
||
name: "list_projects",
|
||
input: {},
|
||
},
|
||
],
|
||
usage: { input_tokens: 100, output_tokens: 20 },
|
||
})
|
||
.mockResolvedValueOnce({
|
||
stop_reason: "end_turn",
|
||
content: [{ type: "text", text: "Máte 2 projekty." }],
|
||
usage: { input_tokens: 200, output_tokens: 30 },
|
||
});
|
||
|
||
const res = await agenticChat(
|
||
[{ role: "user", content: "Projekty?" }],
|
||
ADMIN,
|
||
);
|
||
expect(res.reply).toBe("Máte 2 projekty.");
|
||
expect(res.toolTrace).toEqual([
|
||
{ name: "list_projects", label: "Projekty", ok: true },
|
||
]);
|
||
expect(createMock).toHaveBeenCalledTimes(2);
|
||
|
||
// Second call must carry the tool_result back to the model.
|
||
const secondCall = createMock.mock.calls[1][0];
|
||
const lastMsg = secondCall.messages[secondCall.messages.length - 1];
|
||
expect(lastMsg.role).toBe("user");
|
||
expect(lastMsg.content[0]).toMatchObject({
|
||
type: "tool_result",
|
||
tool_use_id: "toolu_1",
|
||
});
|
||
|
||
// Usage was recorded per round-trip.
|
||
const usageRows = await prisma.ai_usage.findMany({
|
||
where: { user_id: ADMIN.userId, kind: "agent" },
|
||
});
|
||
expect(usageRows.length).toBe(2);
|
||
});
|
||
|
||
it("dedupes the tool trace: failed attempt + successful retry = one ok chip", async () => {
|
||
const self: AiAuthCtx = {
|
||
userId: 999999996,
|
||
roleName: "viewer",
|
||
permissions: ["attendance.record"],
|
||
};
|
||
createMock.mockReset();
|
||
createMock
|
||
.mockResolvedValueOnce({
|
||
stop_reason: "tool_use",
|
||
content: [
|
||
{
|
||
type: "tool_use",
|
||
id: "toolu_a",
|
||
name: "get_attendance_summary",
|
||
input: { user_id: 1 }, // denied: someone else without manage
|
||
},
|
||
],
|
||
usage: { input_tokens: 10, output_tokens: 5 },
|
||
})
|
||
.mockResolvedValueOnce({
|
||
stop_reason: "tool_use",
|
||
content: [
|
||
{
|
||
type: "tool_use",
|
||
id: "toolu_b",
|
||
name: "get_attendance_summary",
|
||
input: {}, // retry: own data, succeeds
|
||
},
|
||
],
|
||
usage: { input_tokens: 10, output_tokens: 5 },
|
||
})
|
||
.mockResolvedValueOnce({
|
||
stop_reason: "end_turn",
|
||
content: [{ type: "text", text: "Hotovo." }],
|
||
usage: { input_tokens: 10, output_tokens: 5 },
|
||
});
|
||
const res = await agenticChat(
|
||
[{ role: "user", content: "Moje docházka?" }],
|
||
self,
|
||
);
|
||
expect(res.toolTrace).toEqual([
|
||
{ name: "get_attendance_summary", label: "Docházka", ok: true },
|
||
]);
|
||
await prisma.ai_usage.deleteMany({
|
||
where: { user_id: self.userId, kind: "agent" },
|
||
});
|
||
});
|
||
|
||
it("injects the caller's identity into the system prompt", async () => {
|
||
createMock.mockReset();
|
||
createMock.mockResolvedValueOnce({
|
||
stop_reason: "end_turn",
|
||
content: [{ type: "text", text: "Ahoj." }],
|
||
usage: { input_tokens: 10, output_tokens: 5 },
|
||
});
|
||
await agenticChat([{ role: "user", content: "Kdo jsem?" }], {
|
||
userId: fixUserId,
|
||
roleName: "viewer",
|
||
permissions: ["attendance.record"],
|
||
});
|
||
const system: string = createMock.mock.calls[0][0].system;
|
||
expect(system).toContain(`${FIX.first} ${FIX.last}`);
|
||
expect(system).toContain(`user_id ${fixUserId}`);
|
||
// Cleanup the usage row this extra fixture-user turn recorded.
|
||
await prisma.ai_usage.deleteMany({
|
||
where: { user_id: fixUserId, kind: "agent" },
|
||
});
|
||
});
|
||
|
||
it("names the denied areas in the system prompt for a limited user", async () => {
|
||
createMock.mockReset();
|
||
createMock.mockResolvedValueOnce({
|
||
stop_reason: "end_turn",
|
||
content: [{ type: "text", text: "Ok." }],
|
||
usage: { input_tokens: 10, output_tokens: 5 },
|
||
});
|
||
await agenticChat(
|
||
[{ role: "user", content: "Projekty?" }],
|
||
VIEWER_INVOICES,
|
||
);
|
||
const system: string = createMock.mock.calls[0][0].system;
|
||
// invoices.view grants the three invoice tools — everything else is
|
||
// listed as explicitly denied so the model says "no permission".
|
||
expect(system).toContain("NEMÁ v systému oprávnění");
|
||
expect(system).toContain("Projekty");
|
||
expect(system).toContain("Kniha jízd");
|
||
expect(system).not.toContain("Faktury vydané");
|
||
await prisma.ai_usage.deleteMany({
|
||
where: { user_id: VIEWER_INVOICES.userId, kind: "agent" },
|
||
});
|
||
});
|
||
|
||
it("a user without permissions gets NO tools passed to the model", async () => {
|
||
createMock.mockReset();
|
||
createMock.mockResolvedValueOnce({
|
||
stop_reason: "end_turn",
|
||
content: [{ type: "text", text: "Ahoj." }],
|
||
usage: { input_tokens: 10, output_tokens: 5 },
|
||
});
|
||
await agenticChat([{ role: "user", content: "Ahoj" }], NOBODY);
|
||
expect(createMock.mock.calls[0][0].tools).toBeUndefined();
|
||
});
|
||
|
||
it("stops at the iteration cap with an explanatory note", async () => {
|
||
createMock.mockReset();
|
||
createMock.mockResolvedValue({
|
||
stop_reason: "tool_use",
|
||
content: [
|
||
{ type: "tool_use", id: "toolu_x", name: "list_projects", input: {} },
|
||
],
|
||
usage: { input_tokens: 10, output_tokens: 5 },
|
||
});
|
||
const res = await agenticChat([{ role: "user", content: "smyčka" }], ADMIN);
|
||
expect(createMock.mock.calls.length).toBe(6); // MAX_AGENT_ITERATIONS
|
||
expect(res.reply).toContain("příliš složitý");
|
||
});
|
||
});
|