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 attendanceRoutes from "../routes/admin/attendance"; // --------------------------------------------------------------------------- // Regression tests for the attendance create/edit wire format. // // The admin create/edit forms send COMBINED local datetimes // ("YYYY-MM-DDTHH:MM:00", built by combineDatetime from a date + time input) // for arrival_time / departure_time / break_start / break_end, and the // service parses them with `new Date(...)`. Commit 519edce validated these // fields with `timeString` (bare HH:MM), which rejected every create/edit // containing a time, and CreateAttendanceSchema lacked break_start/break_end // entirely, so breaks were silently stripped on create. These tests pin the // combined-datetime contract at the HTTP route level. // --------------------------------------------------------------------------- // All fixtures live on far-future dates so they can never collide with real // rows in the throwaway test DB; cleanup deletes exactly this range. const RANGE_START = new Date("2098-01-01T00:00:00"); const RANGE_END = new Date("2099-01-01T00:00:00"); let app: Awaited>; let adminUserId: number; let adminToken: string; 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(attendanceRoutes, { prefix: "/api/admin/attendance" }); return a; } /** * Generate a JWT access token. `requireAuth` re-loads auth data from the DB * via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here. */ 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 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 authPut(path: string, token: string, body: unknown) { return app.inject({ method: "PUT", url: path, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, payload: body as object, }); } async function cleanupFixtureRows() { await prisma.attendance.deleteMany({ where: { user_id: adminUserId, shift_date: { gte: RANGE_START, lt: RANGE_END }, }, }); } beforeAll(async () => { app = await buildApp(); const admin = await prisma.users.findFirst({ where: { roles: { name: "admin" } }, include: { roles: true }, }); if (!admin) throw new Error("Test setup: admin user not found"); adminUserId = admin.id; adminToken = generateToken({ id: admin.id, username: admin.username, roleName: admin.roles?.name ?? null, }); await cleanupFixtureRows(); }); beforeEach(async () => { await cleanupFixtureRows(); }); afterAll(async () => { await cleanupFixtureRows(); if (app) await app.close(); }); describe("POST /api/admin/attendance (combined datetimes)", () => { it("creates a work record with arrival+departure datetimes and persists them", async () => { const arrival = "2098-03-10T08:00:00"; const departure = "2098-03-10T16:30:00"; const res = await authPost("/api/admin/attendance", adminToken, { user_id: adminUserId, shift_date: "2098-03-10", leave_type: "work", arrival_time: arrival, departure_time: departure, notes: "attendance_wire_test work", }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.success).toBe(true); const rec = await prisma.attendance.findUnique({ where: { id: body.data.id }, }); expect(rec).toBeTruthy(); expect(rec!.leave_type).toBe("work"); expect(rec!.arrival_time?.getTime()).toBe(new Date(arrival).getTime()); expect(rec!.departure_time?.getTime()).toBe(new Date(departure).getTime()); }); it("persists break_start/break_end on create (previously silently stripped)", async () => { const res = await authPost("/api/admin/attendance", adminToken, { user_id: adminUserId, shift_date: "2098-03-11", leave_type: "work", arrival_time: "2098-03-11T08:00:00", departure_time: "2098-03-11T16:30:00", break_start: "2098-03-11T12:00:00", break_end: "2098-03-11T12:30:00", notes: "attendance_wire_test break", }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.success).toBe(true); const rec = await prisma.attendance.findUnique({ where: { id: body.data.id }, }); expect(rec!.break_start?.getTime()).toBe( new Date("2098-03-11T12:00:00").getTime(), ); expect(rec!.break_end?.getTime()).toBe( new Date("2098-03-11T12:30:00").getTime(), ); }); it("creates a leave record with NO time fields", async () => { const res = await authPost("/api/admin/attendance", adminToken, { user_id: adminUserId, shift_date: "2098-03-12", leave_type: "vacation", leave_hours: 8, notes: "attendance_wire_test leave", }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.success).toBe(true); const rec = await prisma.attendance.findUnique({ where: { id: body.data.id }, }); expect(rec!.leave_type).toBe("vacation"); expect(Number(rec!.leave_hours)).toBe(8); expect(rec!.arrival_time).toBeNull(); expect(rec!.departure_time).toBeNull(); }); it('tolerates "" for unset datetime fields (old form payloads) → null', async () => { // The AttendanceCreate page historically always sent empty strings for // the unfilled time fields — even for leave records — which 400'd EVERY // submit after 519edce. The schema must treat "" as "not set". const res = await authPost("/api/admin/attendance", adminToken, { user_id: adminUserId, shift_date: "2098-03-13", leave_type: "sick", leave_hours: 8, arrival_time: "", departure_time: "", break_start: "", break_end: "", notes: "attendance_wire_test empty strings", }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.success).toBe(true); const rec = await prisma.attendance.findUnique({ where: { id: body.data.id }, }); expect(rec!.leave_type).toBe("sick"); expect(rec!.arrival_time).toBeNull(); expect(rec!.departure_time).toBeNull(); expect(rec!.break_start).toBeNull(); expect(rec!.break_end).toBeNull(); }); }); describe("GET /api/admin/attendance (complete month, no truncation)", () => { it("returns every record of a month with more than 100 rows", async () => { // The admin month view computes the KPI cards and summary totals from // this single fetch, so it must cover the COMPLETE month. With the old // 100-row pagination cap, months with >100 records were silently // truncated (newest-first) and the screen totals dropped the earliest // days while the print stayed complete. const rows = []; for (let day = 1; day <= 30; day++) { for (let s = 0; s < 4; s++) { rows.push({ user_id: adminUserId, shift_date: new Date(2098, 4, day, 12, 0, 0), leave_type: "work" as const, arrival_time: new Date(2098, 4, day, 6 + s, 0, 0), departure_time: new Date(2098, 4, day, 10 + s, 0, 0), notes: "attendance_wire_test bulk month", }); } } await prisma.attendance.createMany({ data: rows }); const res = await app.inject({ method: "GET", url: "/api/admin/attendance?year=2098&month=5&limit=2000", headers: { Authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data).toHaveLength(120); expect(body.pagination.total).toBe(120); }); }); describe("GET /api/admin/attendance (month window boundaries, @db.Date)", () => { it("includes the month's own LAST day and excludes the neighbor month's first day", async () => { // shift_date is @db.Date — Prisma compares the filter Dates by their UTC // date part. The old LOCAL-midnight month bounds (22:00/23:00 UTC of the // previous day) shifted the whole window a day back: the June list // included May 31 and DROPPED June 30. Fixture rows sit exactly on the // 2098-06 / 2098-07 boundary. const juneLast = await prisma.attendance.create({ data: { user_id: adminUserId, shift_date: new Date(2098, 5, 30, 12, 0, 0), // 2098-06-30, local noon leave_type: "work", arrival_time: new Date(2098, 5, 30, 8, 0, 0), departure_time: new Date(2098, 5, 30, 16, 0, 0), notes: "attendance_wire_test june-last-day", }, }); const julyFirst = await prisma.attendance.create({ data: { user_id: adminUserId, shift_date: new Date(2098, 6, 1, 12, 0, 0), // 2098-07-01, local noon leave_type: "work", arrival_time: new Date(2098, 6, 1, 8, 0, 0), departure_time: new Date(2098, 6, 1, 16, 0, 0), notes: "attendance_wire_test july-first-day", }, }); const june = await app.inject({ method: "GET", url: "/api/admin/attendance?year=2098&month=6&limit=100", headers: { Authorization: `Bearer ${adminToken}` }, }); expect(june.statusCode).toBe(200); const juneIds = june.json().data.map((r: { id: number }) => r.id); expect(juneIds).toContain(juneLast.id); expect(juneIds).not.toContain(julyFirst.id); const july = await app.inject({ method: "GET", url: "/api/admin/attendance?year=2098&month=7&limit=100", headers: { Authorization: `Bearer ${adminToken}` }, }); expect(july.statusCode).toBe(200); const julyIds = july.json().data.map((r: { id: number }) => r.id); expect(julyIds).toContain(julyFirst.id); expect(julyIds).not.toContain(juneLast.id); }); }); describe("POST /api/admin/attendance (same-day duplicate window, @db.Date)", () => { it("rejects a second leave record on the SAME day", async () => { const first = await authPost("/api/admin/attendance", adminToken, { user_id: adminUserId, shift_date: "2098-08-10", leave_type: "vacation", leave_hours: 8, notes: "attendance_wire_test dup-leave-first", }); expect(first.statusCode).toBe(201); // Old bug: the duplicate/overlap window was built from LOCAL midnights of // the UTC-midnight shiftDate, so the validation queried ONLY the previous // day — a same-day duplicate leave sailed through undetected. const second = await authPost("/api/admin/attendance", adminToken, { user_id: adminUserId, shift_date: "2098-08-10", leave_type: "sick", leave_hours: 8, notes: "attendance_wire_test dup-leave-second", }); expect(second.statusCode).toBe(400); expect(second.json().success).toBe(false); }); it("does NOT falsely reject a leave on the day AFTER an existing record", async () => { const first = await authPost("/api/admin/attendance", adminToken, { user_id: adminUserId, shift_date: "2098-08-10", leave_type: "vacation", leave_hours: 8, notes: "attendance_wire_test neighbor-day-first", }); expect(first.statusCode).toBe(201); // Old bug (the other half): creating a record for the NEXT day queried // the window [Aug 10, Aug 11) — i.e. yesterday's records — and bounced // with a false "záznam o nepřítomnosti již existuje". const nextDay = await authPost("/api/admin/attendance", adminToken, { user_id: adminUserId, shift_date: "2098-08-11", leave_type: "vacation", leave_hours: 8, notes: "attendance_wire_test neighbor-day-next", }); expect(nextDay.statusCode).toBe(201); expect(nextDay.json().success).toBe(true); }); }); describe("PUT /api/admin/attendance/:id (combined datetimes)", () => { it("updates a record with combined datetimes (incl. overnight departure)", async () => { const created = await prisma.attendance.create({ data: { user_id: adminUserId, shift_date: new Date(2098, 2, 14, 12, 0, 0), leave_type: "work", arrival_time: new Date("2098-03-14T08:00:00"), departure_time: new Date("2098-03-14T16:30:00"), notes: "attendance_wire_test update", }, }); const res = await authPut( `/api/admin/attendance/${created.id}`, adminToken, { leave_type: "work", arrival_time: "2098-03-14T22:00:00", departure_time: "2098-03-15T06:00:00", break_start: "2098-03-15T02:00:00", break_end: "2098-03-15T02:30:00", notes: "attendance_wire_test updated", }, ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); const rec = await prisma.attendance.findUnique({ where: { id: created.id }, }); expect(rec!.arrival_time?.getTime()).toBe( new Date("2098-03-14T22:00:00").getTime(), ); expect(rec!.departure_time?.getTime()).toBe( new Date("2098-03-15T06:00:00").getTime(), ); expect(rec!.break_start?.getTime()).toBe( new Date("2098-03-15T02:00:00").getTime(), ); expect(rec!.break_end?.getTime()).toBe( new Date("2098-03-15T02:30:00").getTime(), ); expect(rec!.notes).toBe("attendance_wire_test updated"); }); it("clears times when null is sent (leave-type edit)", async () => { const created = await prisma.attendance.create({ data: { user_id: adminUserId, shift_date: new Date(2098, 2, 16, 12, 0, 0), leave_type: "work", arrival_time: new Date("2098-03-16T08:00:00"), departure_time: new Date("2098-03-16T16:30:00"), notes: "attendance_wire_test to-leave", }, }); const res = await authPut( `/api/admin/attendance/${created.id}`, adminToken, { leave_type: "vacation", leave_hours: 8, arrival_time: null, departure_time: null, break_start: null, break_end: null, notes: "attendance_wire_test to-leave", }, ); expect(res.statusCode).toBe(200); expect(res.json().success).toBe(true); const rec = await prisma.attendance.findUnique({ where: { id: created.id }, }); expect(rec!.leave_type).toBe("vacation"); expect(rec!.arrival_time).toBeNull(); expect(rec!.departure_time).toBeNull(); }); });