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:
BOHA
2026-06-11 00:03:39 +02:00
parent 3a3485d029
commit 608ebb02bd
4 changed files with 855 additions and 33 deletions

View File

@@ -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:0015: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);