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>; 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); }); });