Files
app/src/__tests__/plan.test.ts
BOHA 519edce373 fix: 2026-06-09 full-codebase audit hardening
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>
2026-06-09 06:45:26 +02:00

1313 lines
40 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach } 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 } from "../config/env";
import { securityHeaders } from "../middleware/security";
import planRoutes from "../routes/admin/plan";
import { localDateStr } from "../utils/date";
import {
resolveCell,
resolveGrid,
listPlanUsers,
assertNotPastDate,
createEntry,
updateEntry,
deleteEntry,
createOverride,
updateOverride,
deleteOverride,
listEntries,
listOverrides,
bulkCreateEntries,
} from "../services/plan.service";
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
/** Note-prefix for test-created rows so we can clean them up safely. */
const N = "wh_plan_";
let adminUserId: number;
// A DEDICATED, distinct non-admin user for the service-level employee-scoping
// tests (and as the second employee in the multi-user bulk test). Creating our
// own user — instead of grabbing "the second user in the DB" — guarantees it is
// never accidentally the admin (which would make `toEqual([])` pass for the
// wrong reason on a single-user DB), and that it is genuinely distinct so the
// 2-employee aggregate assertion can't collapse to 1.
let scopeUserId: number;
let scopeRoleId: number;
let noPermUserId: number;
beforeAll(async () => {
// Pick the first admin user from the test database as our fixture.
// The users -> roles relation is named `roles` in the Prisma schema
// (users.role_id references roles.id).
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
// Dedicated non-admin user + role for scoping/multi-user tests.
const stamp = Date.now();
const role = await prisma.roles.create({
data: {
name: `scope_plan_${stamp}`,
display_name: "Plan Scope Test",
},
});
scopeRoleId = role.id;
const user = await prisma.users.create({
data: {
username: `scope_plan_${stamp}`,
first_name: "Scope",
last_name: "User",
email: `scope_plan_${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
role_id: role.id,
is_active: true,
},
});
scopeUserId = user.id;
if (scopeUserId === adminUserId)
throw new Error("scope user must be distinct from admin");
});
afterAll(async () => {
// Clean up the dedicated scope user/role created above. Wrapped in catch so a
// leftover FK (none expected — its rows are note-prefixed and removed) can't
// mask real failures.
if (scopeUserId)
await prisma.users
.deleteMany({ where: { id: scopeUserId } })
.catch(() => {});
if (scopeRoleId)
await prisma.roles
.deleteMany({ where: { id: scopeRoleId } })
.catch(() => {});
});
beforeEach(async () => {
// Clean up any leftover test data from previous runs. We only touch rows
// whose `note` contains our test prefix, so we never disturb real data.
await prisma.work_plan_entries.deleteMany({
where: { note: { contains: N } },
});
await prisma.work_plan_overrides.deleteMany({
where: { note: { contains: N } },
});
// Also clean up empty-note rows on the specific test dates used by the
// "allows an empty note" tests. These rows are not caught by the prefix
// filter above, and would cause the per-cell cap check to reject them
// once three accumulate across runs.
await prisma.work_plan_entries.deleteMany({
where: {
date_from: {
in: [
new Date("2099-07-15T00:00:00.000Z"),
new Date("2097-08-05T00:00:00.000Z"),
],
},
},
});
});
afterAll(async () => {
// Final cleanup so we never leave soft-deleted test rows behind.
await prisma.work_plan_entries.deleteMany({
where: { note: { contains: N } },
});
await prisma.work_plan_overrides.deleteMany({
where: { note: { contains: N } },
});
});
// ---------------------------------------------------------------------------
// resolveCell
// ---------------------------------------------------------------------------
describe("plan.service.resolveCell", () => {
it("returns [] when nothing covers the date", async () => {
const result = await resolveCell(adminUserId, "2099-01-01");
expect(result).toEqual([]);
});
it("returns the entry that covers the date", async () => {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-01"),
date_to: new Date("2099-06-10"),
category: "work",
note: `${N}PLC upgrade`,
created_by: adminUserId,
},
});
const result = await resolveCell(adminUserId, "2099-06-05");
expect(result.length).toBe(1);
expect(result[0].source).toBe("entry");
expect(result[0].note).toBe(`${N}PLC upgrade`);
expect(result[0].rangeFrom).toBe("2099-06-01");
expect(result[0].rangeTo).toBe("2099-06-10");
});
it("returns overrides (entries hidden) when an override exists", async () => {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-01"),
date_to: new Date("2099-06-10"),
category: "work",
note: `${N}PLC upgrade`,
created_by: adminUserId,
},
});
await prisma.work_plan_overrides.create({
data: {
user_id: adminUserId,
shift_date: new Date("2099-06-05"),
category: "leave",
note: `${N}Volno po noční`,
created_by: adminUserId,
},
});
const result = await resolveCell(adminUserId, "2099-06-05");
expect(result.length).toBe(1);
expect(result[0].source).toBe("override");
expect(result[0].note).toBe(`${N}Volno po noční`);
});
it("returns multiple additive entries newest-first", async () => {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-01"),
date_to: new Date("2099-06-10"),
category: "work",
note: `${N}first`,
created_by: adminUserId,
},
});
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-05"),
date_to: new Date("2099-06-05"),
category: "work",
note: `${N}second`,
created_by: adminUserId,
},
});
const result = await resolveCell(adminUserId, "2099-06-05");
expect(result.length).toBe(2);
expect(result[0].note).toBe(`${N}second`); // newest first
});
it("caps the returned records at MAX_RECORDS_PER_CELL", async () => {
for (let i = 0; i < 4; i++) {
await prisma.work_plan_overrides.create({
data: {
user_id: adminUserId,
shift_date: new Date("2099-06-06"),
category: "leave",
note: `${N}cap${i}`,
created_by: adminUserId,
},
});
}
const result = await resolveCell(adminUserId, "2099-06-06");
expect(result.length).toBe(3);
});
it("ignores soft-deleted entries and overrides", async () => {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-01"),
date_to: new Date("2099-06-10"),
category: "work",
note: `${N}deleted entry`,
is_deleted: true,
created_by: adminUserId,
},
});
const result = await resolveCell(adminUserId, "2099-06-05");
expect(result).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// resolveGrid
// ---------------------------------------------------------------------------
describe("plan.service.resolveGrid", () => {
it("returns a 2D map keyed by userId then date", async () => {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-01"),
date_to: new Date("2099-06-03"),
category: "work",
note: `${N}A`,
created_by: adminUserId,
},
});
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05");
expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-04"]).toEqual([]);
expect(cells[adminUserId]["2099-06-05"]).toEqual([]);
});
it("applies override on a covered day", async () => {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-01"),
date_to: new Date("2099-06-03"),
category: "work",
note: `${N}A`,
created_by: adminUserId,
},
});
await prisma.work_plan_overrides.create({
data: {
user_id: adminUserId,
shift_date: new Date("2099-06-02"),
category: "leave",
note: `${N}B`,
created_by: adminUserId,
},
});
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03");
expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`);
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
});
it("stacks additive entries newest-first and caps the day at MAX_RECORDS_PER_CELL", async () => {
// Two entries cover 2099-06-12 → both appear, newest-first. A 3rd entry
// covering the same day and a 4th overlapping one push past the cap, so the
// day must still return exactly 3 (mirrors resolveCell's cap behaviour).
//
// created_at is @db.Timestamp(0) (second precision, MySQL TIMESTAMP range
// tops out at 2038), so rows created in the same second tie on the
// `created_at desc` sort. We set explicit, distinct in-range created_at
// values so the newest-first assertion is deterministic.
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-11"),
date_to: new Date("2099-06-12"),
category: "work",
note: `${N}g1`,
created_by: adminUserId,
created_at: new Date("2037-06-01T08:00:00.000Z"),
},
});
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-12"),
date_to: new Date("2099-06-12"),
category: "work",
note: `${N}g2`,
created_by: adminUserId,
created_at: new Date("2037-06-01T09:00:00.000Z"), // newer than g1
},
});
// Two-entry day: assert length 2 and newest-first ordering.
const twoDay = await resolveGrid([adminUserId], "2099-06-11", "2099-06-12");
expect(twoDay[adminUserId]["2099-06-12"].length).toBe(2);
expect(twoDay[adminUserId]["2099-06-12"][0].note).toBe(`${N}g2`); // newest first
// Day with no second entry stays single.
expect(twoDay[adminUserId]["2099-06-11"].length).toBe(1);
// Add a 3rd and a 4th overlapping entry on 2099-06-12 to exceed the cap.
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-12"),
date_to: new Date("2099-06-12"),
category: "work",
note: `${N}g3`,
created_by: adminUserId,
created_at: new Date("2037-06-01T10:00:00.000Z"),
},
});
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-12"),
date_to: new Date("2099-06-12"),
category: "work",
note: `${N}g4`,
created_by: adminUserId,
created_at: new Date("2037-06-01T11:00:00.000Z"),
},
});
const cappedDay = await resolveGrid(
[adminUserId],
"2099-06-12",
"2099-06-12",
);
expect(cappedDay[adminUserId]["2099-06-12"].length).toBe(3);
});
});
// ---------------------------------------------------------------------------
// listPlanUsers
// ---------------------------------------------------------------------------
describe("plan.service.listPlanUsers", () => {
it("returns at least the admin user", async () => {
const users = await listPlanUsers();
const found = users.find((u) => u.id === adminUserId);
expect(found).toBeDefined();
expect(found?.has_attendance_record).toBe(true);
});
});
// ---------------------------------------------------------------------------
// assertNotPastDate
// ---------------------------------------------------------------------------
describe("plan.service.assertNotPastDate", () => {
it("returns error for a past date", () => {
const result = assertNotPastDate("2000-01-01", false);
expect(result).toEqual({
error:
"Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.",
status: 403,
});
});
it("returns null for a future date", () => {
const result = assertNotPastDate("2099-01-01", false);
expect(result).toBeNull();
});
it("returns null for a past date with force=true", () => {
const result = assertNotPastDate("2000-01-01", true);
expect(result).toBeNull();
});
it("treats TODAY (local Prague date) as allowed — the boundary is inclusive", () => {
// Deterministic without mocking the clock: compute "today" exactly the way
// the service does (local-time YYYY-MM-DD; TZ is pinned to Europe/Prague in
// config/env.ts). The boundary is the bug-prone path — an off-by-one here
// would wrongly reject editing today's plan.
const today = localDateStr(new Date());
expect(assertNotPastDate(today, false)).toBeNull();
});
it("rejects YESTERDAY (local Prague date) as a past date", () => {
const d = new Date();
d.setDate(d.getDate() - 1); // local-time yesterday
const yesterday = localDateStr(d);
const result = assertNotPastDate(yesterday, false);
expect(result).not.toBeNull();
expect(result?.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// createEntry
// ---------------------------------------------------------------------------
describe("plan.service.createEntry", () => {
it("creates an entry and returns { data, oldData: null }", async () => {
const result = await createEntry(
{
user_id: adminUserId,
date_from: "2099-07-01",
date_to: "2099-07-05",
category: "work",
note: `${N}new entry`,
},
adminUserId,
false,
);
expect("data" in result).toBe(true);
if ("data" in result) {
expect(result.data.note).toBe(`${N}new entry`);
expect(result.oldData).toBeNull();
}
});
it("rejects a past date without force", async () => {
const result = await createEntry(
{
user_id: adminUserId,
date_from: "2000-01-01",
date_to: "2000-01-02",
category: "work",
note: `${N}past`,
},
adminUserId,
false,
);
expect("error" in result).toBe(true);
if ("error" in result) expect(result.status).toBe(403);
});
it("allows a past date with force", async () => {
const result = await createEntry(
{
user_id: adminUserId,
date_from: "2000-01-01",
date_to: "2000-01-02",
category: "work",
note: `${N}past forced`,
},
adminUserId,
true,
);
expect("data" in result).toBe(true);
});
it("rejects when date_to < date_from", async () => {
const result = await createEntry(
{
user_id: adminUserId,
date_from: "2099-07-10",
date_to: "2099-07-05",
category: "work",
note: `${N}invalid range`,
},
adminUserId,
false,
);
expect("error" in result).toBe(true);
});
it("allows an empty note (text is optional)", async () => {
const result = await createEntry(
{
user_id: adminUserId,
date_from: "2099-07-15",
date_to: "2099-07-15",
category: "work",
note: "",
},
adminUserId,
false,
);
expect("data" in result).toBe(true);
});
it("rejects a range whose date_to is in the past, even if date_from is today", async () => {
// The range check `date_to >= date_from` fires before the past-date
// check, so a backwards range returns 400 (correct UX). The to-endpoint
// past-date check matters when updating an existing entry — see the
// matching test in updateEntry below. Here we just confirm that a
// range whose start is in the past is rejected as 403.
const result = await createEntry(
{
user_id: adminUserId,
date_from: "2000-01-01",
date_to: "2000-01-05",
category: "work",
note: `${N}past-both`,
},
adminUserId,
false,
);
expect("error" in result).toBe(true);
if ("error" in result) expect(result.status).toBe(403);
});
it("rejects a 4th entry covering a day already at the cap", async () => {
for (let i = 0; i < 3; i++) {
const r = await createEntry(
{
user_id: adminUserId,
date_from: "2099-07-20",
date_to: "2099-07-20",
category: "work",
note: `${N}e${i}`,
},
adminUserId,
false,
);
expect("data" in r).toBe(true);
}
const fourth = await createEntry(
{
user_id: adminUserId,
date_from: "2099-07-20",
date_to: "2099-07-20",
category: "work",
note: `${N}e3`,
},
adminUserId,
false,
);
expect("error" in fourth).toBe(true);
if ("error" in fourth) expect(fourth.status).toBe(400);
});
});
// ---------------------------------------------------------------------------
// updateEntry
// ---------------------------------------------------------------------------
describe("plan.service.updateEntry", () => {
it("updates the note and returns the old data", async () => {
const created = await createEntry(
{
user_id: adminUserId,
date_from: "2099-08-01",
date_to: "2099-08-03",
category: "work",
note: `${N}original`,
},
adminUserId,
false,
);
if (!("data" in created)) throw new Error("setup failed");
const updated = await updateEntry(
created.data.id,
{ note: `${N}updated` },
adminUserId,
false,
);
expect("data" in updated).toBe(true);
if ("data" in updated) {
expect((updated.data as any).note).toBe(`${N}updated`);
expect((updated.oldData as any).note).toBe(`${N}original`);
}
});
it("rejects an update that pushes date_to into the past", async () => {
// Future entry, but the user tries to extend its end backwards into
// the past. The to-endpoint past-date check should fire.
const created = await createEntry(
{
user_id: adminUserId,
date_from: "2099-08-10",
date_to: "2099-08-15",
category: "work",
note: `${N}to-shorten`,
},
adminUserId,
false,
);
if (!("data" in created)) throw new Error("setup failed");
const updated = await updateEntry(
created.data.id,
{ date_to: "2000-01-05" },
adminUserId,
false,
);
expect("error" in updated).toBe(true);
if ("error" in updated) expect(updated.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// deleteEntry
// ---------------------------------------------------------------------------
describe("plan.service.deleteEntry", () => {
it("soft-deletes the row and returns the old data", async () => {
const created = await createEntry(
{
user_id: adminUserId,
date_from: "2099-09-01",
date_to: "2099-09-01",
category: "work",
note: `${N}to delete`,
},
adminUserId,
false,
);
if (!("data" in created)) throw new Error("setup failed");
const result = await deleteEntry(created.data.id, adminUserId, false);
expect("data" in result).toBe(true);
if ("data" in result) {
expect(result.data).toEqual({ ok: true });
expect((result.oldData as any).note).toBe(`${N}to delete`);
}
const stillThere = await prisma.work_plan_entries.findUnique({
where: { id: created.data.id },
});
expect(stillThere?.is_deleted).toBe(true);
});
});
// ---------------------------------------------------------------------------
// createOverride
// ---------------------------------------------------------------------------
describe("plan.service.createOverride", () => {
it("creates an override and returns { data, oldData: null }", async () => {
const result = await createOverride(
{
user_id: adminUserId,
shift_date: "2099-10-01",
category: "leave",
note: `${N}day off`,
},
adminUserId,
false,
);
expect("data" in result).toBe(true);
if ("data" in result) {
expect(result.data.note).toBe(`${N}day off`);
expect(result.oldData).toBeNull();
}
});
it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => {
for (let i = 0; i < 3; i++) {
const r = await createOverride(
{
user_id: adminUserId,
shift_date: "2099-10-02",
category: "leave",
note: `${N}o${i}`,
},
adminUserId,
false,
);
expect("data" in r).toBe(true);
}
// A 4th record on the same day is rejected by the cap.
const fourth = await createOverride(
{
user_id: adminUserId,
shift_date: "2099-10-02",
category: "leave",
note: `${N}o3`,
},
adminUserId,
false,
);
expect("error" in fourth).toBe(true);
if ("error" in fourth) expect(fourth.status).toBe(400);
// All three earlier overrides remain active — no replace happened.
const active = await prisma.work_plan_overrides.findMany({
where: {
user_id: adminUserId,
shift_date: new Date("2099-10-02"),
is_deleted: false,
},
});
expect(active.length).toBe(3);
});
it("rejects a past date without force", async () => {
const result = await createOverride(
{
user_id: adminUserId,
shift_date: "2000-01-01",
category: "leave",
note: `${N}past`,
},
adminUserId,
false,
);
expect("error" in result).toBe(true);
});
});
// ---------------------------------------------------------------------------
// updateOverride
// ---------------------------------------------------------------------------
describe("plan.service.updateOverride", () => {
it("updates the note and returns the old data", async () => {
const created = await createOverride(
{
user_id: adminUserId,
shift_date: "2099-11-01",
category: "leave",
note: `${N}original`,
},
adminUserId,
false,
);
if (!("data" in created)) throw new Error("setup");
const updated = await updateOverride(
created.data.id,
{ note: `${N}updated` },
adminUserId,
false,
);
expect("data" in updated).toBe(true);
if ("data" in updated) {
expect((updated.data as any).note).toBe(`${N}updated`);
expect((updated.oldData as any).note).toBe(`${N}original`);
}
});
});
// ---------------------------------------------------------------------------
// deleteOverride
// ---------------------------------------------------------------------------
describe("plan.service.deleteOverride", () => {
it("soft-deletes the override and returns the old data", async () => {
const created = await createOverride(
{
user_id: adminUserId,
shift_date: "2099-12-01",
category: "leave",
note: `${N}to delete`,
},
adminUserId,
false,
);
if (!("data" in created)) throw new Error("setup");
const result = await deleteOverride(created.data.id, adminUserId, false);
expect("data" in result).toBe(true);
if ("data" in result) {
expect(result.data).toEqual({ ok: true });
expect((result.oldData as any).note).toBe(`${N}to delete`);
}
const still = await prisma.work_plan_overrides.findUnique({
where: { id: created.data.id },
});
expect(still?.is_deleted).toBe(true);
});
});
// ---------------------------------------------------------------------------
// listEntries / listOverrides (employee scoping)
// ---------------------------------------------------------------------------
describe("plan.service.listEntries (employee scoping)", () => {
it("returns entries for the actor user when scoped", async () => {
await createEntry(
{
user_id: adminUserId,
date_from: "2098-01-01",
date_to: "2098-01-05",
category: "work",
note: `${N}scope test`,
},
adminUserId,
false,
);
const rows = await listEntries(
{ user_id: adminUserId, date_from: "2098-01-01", date_to: "2098-01-31" },
adminUserId,
true, // isAdmin
);
expect(rows.length).toBeGreaterThan(0);
});
it("scopes non-admin to actor user_id when querying for another user", async () => {
await createEntry(
{
user_id: adminUserId,
date_from: "2098-02-01",
date_to: "2098-02-05",
category: "work",
note: `${N}admin entry`,
},
adminUserId,
false,
);
const rows = await listEntries(
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
scopeUserId, // dedicated distinct non-admin actor (never the admin)
false, // not admin
);
// A non-admin actor querying ANOTHER user's data is scoped to its own
// user_id, so it sees none of the admin's entries. This can only be
// [] for the right reason because scopeUserId !== adminUserId (asserted
// in beforeAll).
expect(rows).toEqual([]);
});
});
describe("plan.service.listOverrides (employee scoping)", () => {
it("scopes non-admin to actor user_id", async () => {
await createOverride(
{
user_id: adminUserId,
shift_date: "2098-03-01",
category: "leave",
note: `${N}admin override`,
},
adminUserId,
false,
);
const rows = await listOverrides(
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
scopeUserId, // dedicated distinct non-admin actor (never the admin)
false,
);
expect(rows).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// bulkCreateEntries
// ---------------------------------------------------------------------------
describe("plan.service.bulkCreateEntries", () => {
it("creates one continuous range per employee when weekends are included", async () => {
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
// range 06-01..06-03 covering all 3 days.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-wknd`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(1);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
expect(res.data.users).toBe(1);
});
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-05",
include_weekends: false,
project_id: null,
category: "work",
note: `${N}bulk-week`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
});
it("skips days already at the cap and splits the range around them", async () => {
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
for (let i = 0; i < 3; i++) {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2096-07-02"),
date_to: new Date("2096-07-02"),
category: "work",
note: `${N}cap${i}`,
created_by: adminUserId,
},
});
}
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-07-01",
date_to: "2096-07-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-cap`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
expect(res.data.skipped_days).toBe(1);
});
it("aggregates across multiple employees", async () => {
// Two genuinely distinct employees (admin + the dedicated scope user) so
// `users` can't collapse to 1 if the DB happened to have a single user.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId, scopeUserId],
date_from: "2096-08-03", // Friday
date_to: "2096-08-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-multi`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.users).toBe(2);
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
});
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
const empty = await bulkCreateEntries(
{
user_ids: [],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in empty && empty.status).toBe(400);
const tooLong = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-01-01",
date_to: "2096-12-31", // > 92 days
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in tooLong && tooLong.status).toBe(400);
const past = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2000-01-01",
date_to: "2000-01-02",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in past && past.status).toBe(403);
const badRange = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-10",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badRange && badRange.status).toBe(400);
const badCat = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "__nope__",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badCat && badCat.status).toBe(400);
});
});
// ---------------------------------------------------------------------------
// HTTP route tests (Fastify inject)
// ---------------------------------------------------------------------------
// Build a Fastify app with only the plan routes registered under
// /api/admin/plan. rateLimit max=1000 keeps the 7 tests from tripping it.
let app: Awaited<ReturnType<typeof buildApp>>;
let adminToken: string;
let noPermToken: string;
let noPermRoleId: number;
async function buildApp() {
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(planRoutes, { prefix: "/api/admin/plan" });
return a;
}
/**
* Generate a JWT access token. `requireAuth` re-loads auth data from the
* DB via `loadAuthData(payload.sub)`, so the `role` claim is ignored —
* only the `sub` (user id) matters for these tests.
*/
function generateToken(user: {
id: number;
username: string;
roleName: string | null;
}): string {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
async function authGet(path: string, token: string) {
return app.inject({
method: "GET",
url: path,
headers: { Authorization: `Bearer ${token}` },
});
}
async function authPost(path: string, token: string, body: unknown) {
return app.inject({
method: "POST",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function authPatch(path: string, token: string, body: unknown) {
return app.inject({
method: "PATCH",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function authDelete(path: string, token: string) {
return app.inject({
method: "DELETE",
url: path,
headers: { Authorization: `Bearer ${token}` },
});
}
beforeAll(async () => {
app = await buildApp();
// Re-fetch the admin user with the roles relation so we can build a token.
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("admin user not found in test setup");
adminUserId = admin.id;
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
// Create a no-permission role + user; the role has zero permissions so
// any permission check returns 403.
const stamp = Date.now();
const noPermRole = await prisma.roles.create({
data: {
name: `noperm_plan_${stamp}`,
display_name: "No Perm Plan Test",
},
});
noPermRoleId = noPermRole.id;
const noPermUser = await prisma.users.create({
data: {
username: `noperm_plan_${stamp}`,
first_name: "No",
last_name: "Perm",
email: `noperm_plan_${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
role_id: noPermRole.id,
is_active: true,
},
});
noPermUserId = noPermUser.id;
noPermToken = generateToken({
id: noPermUser.id,
username: noPermUser.username,
roleName: noPermRole.name,
});
});
afterAll(async () => {
if (app) await app.close();
if (noPermUserId) {
await prisma.users
.deleteMany({ where: { id: noPermUserId } })
.catch(() => {});
}
if (noPermRoleId) {
await prisma.roles
.deleteMany({ where: { id: noPermRoleId } })
.catch(() => {});
}
});
describe("HTTP /api/admin/plan", () => {
it("GET /users requires auth", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/plan/users",
});
expect(res.statusCode).toBe(401);
});
it("GET /users returns plan users for an admin", async () => {
const res = await authGet("/api/admin/plan/users", adminToken);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
});
it("GET /grid returns days, users, cells", async () => {
// Seed an entry so the cell on 2097-06-01 has a known note.
await createEntry(
{
user_id: adminUserId,
date_from: "2097-06-01",
date_to: "2097-06-05",
category: "work",
note: `${N}grid test`,
},
adminUserId,
false,
);
const res = await authGet(
"/api/admin/plan/grid?date_from=2097-06-01&date_to=2097-06-05",
adminToken,
);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.users).toBeDefined();
expect(body.data.cells[adminUserId]).toBeDefined();
expect(body.data.cells[adminUserId]["2097-06-01"][0].note).toBe(
`${N}grid test`,
);
});
it("GET /grid rejects ranges > 92 days", async () => {
const res = await authGet(
"/api/admin/plan/grid?date_from=2097-01-01&date_to=2097-12-31",
adminToken,
);
expect(res.statusCode).toBe(400);
});
it("POST /entries requires attendance.manage", async () => {
const res = await authPost("/api/admin/plan/entries", noPermToken, {
user_id: adminUserId,
date_from: "2097-07-01",
date_to: "2097-07-01",
category: "work",
note: `${N}forbidden`,
});
expect(res.statusCode).toBe(403);
});
it("POST /entries works for admin", async () => {
const res = await authPost("/api/admin/plan/entries", adminToken, {
user_id: adminUserId,
date_from: "2097-08-01",
date_to: "2097-08-01",
category: "work",
note: `${N}http create`,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.note).toBe(`${N}http create`);
});
it("POST /entries accepts an empty note (note is optional)", async () => {
const res = await authPost("/api/admin/plan/entries", adminToken, {
user_id: adminUserId,
date_from: "2097-08-05",
date_to: "2097-08-05",
category: "work",
note: "",
});
expect(res.statusCode).toBe(201);
});
it("POST /entries rejects a past date without ?force=1", async () => {
const res = await authPost("/api/admin/plan/entries", adminToken, {
user_id: adminUserId,
date_from: "2000-02-01",
date_to: "2000-02-01",
category: "work",
note: `${N}past http`,
});
expect(res.statusCode).toBe(403);
});
it("POST /overrides with force=1 allows past date", async () => {
const res = await authPost(
"/api/admin/plan/overrides?force=1",
adminToken,
{
user_id: adminUserId,
shift_date: "2000-01-15",
category: "leave",
note: `${N}forced past`,
},
);
expect(res.statusCode).toBe(201);
});
it("POST /entries/bulk requires attendance.manage", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
user_ids: [adminUserId],
date_from: "2095-06-01",
date_to: "2095-06-01",
include_weekends: true,
category: "work",
note: `${N}bulk-forbidden`,
});
expect(res.statusCode).toBe(403);
});
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
user_ids: [adminUserId],
date_from: "2095-06-05", // Sunday; weekends on → 1 day
date_to: "2095-06-05",
include_weekends: true,
category: "work",
note: `${N}bulk-http`,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.created_days).toBe(1);
expect(body.data.users).toBe(1);
});
});