feat(odin): people tools - employee lookup, attendance hours, trips, work plan
- find_employee: name -> user_id with route-parity population scoping
(attendance perms see plan users, trips perms see drivers sans usernames,
full directory only with users.view; trips.history unlocks nothing)
- get_attendance_summary: day-range detail with worked hours (shared
calcWorkedHours, break subtracted) + totals; attendance.manage also
unlocks the tool
- list_trips: kniha jizd with route-identical non-manager self-scoping and
the stats-endpoint km coalesce; bare month defaults to current year
- get_work_plan: resolved plan grid rows; off-grid users need
attendance.manage (grid-route parity)
- executeTool flags handler {error} results not-ok (consistent denial chips)
- system prompt: resolve employee names via find_employee first (gated on
the tool being present)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, afterAll } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
toolDefinitionsFor,
|
||||
@@ -36,9 +36,161 @@ const NOBODY: AiAuthCtx = {
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
afterAll(async () => {
|
||||
// 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] }, kind: "agent" },
|
||||
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;
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,17 +239,221 @@ describe("ai-tools — permission delegation", () => {
|
||||
expect(own.ok).toBe(true);
|
||||
expect(own.result).toMatchObject({ user_id: self.userId });
|
||||
|
||||
// Someone else's data without attendance.manage — denied in-result.
|
||||
// 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(true); // handler-level denial, not a thrown error
|
||||
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_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);
|
||||
|
||||
Reference in New Issue
Block a user