1247 lines
37 KiB
TypeScript
1247 lines
37 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 {
|
|
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;
|
|
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;
|
|
|
|
// For list-scoping tests: any non-admin user that is NOT the admin we use
|
|
// for create tests. Just pick the second user.
|
|
const allUsers = await prisma.users.findMany({ take: 2 });
|
|
noPermUserId =
|
|
allUsers.find((u) => u.id !== adminUserId)?.id ?? allUsers[0].id;
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.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.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" },
|
|
noPermUserId,
|
|
false, // not admin
|
|
);
|
|
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" },
|
|
noPermUserId,
|
|
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 () => {
|
|
const res = await bulkCreateEntries(
|
|
{
|
|
user_ids: [adminUserId, noPermUserId],
|
|
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);
|
|
});
|
|
});
|