The KPI cards and on-screen summary totals (computeUserTotals) are computed client-side from one month fetch. The hook requested limit=1000 but parsePagination silently clamped it to 100, so once a month exceeded 100 attendance rows (~5 employees x 21 shifts) the newest-first list dropped the EARLIEST days of the month — screen totals undercounted while the print path (complete server fetch) stayed correct. This is the data-volume cliff that made the summary counting look like it changed without any formula change: git archaeology confirms every counting formula is bit-identical from v1.9.1 through HEAD. - routes/admin/attendance.ts: list endpoint passes maxLimit 2000 to parsePagination (complete-month view; 2000 ~ 64 employees x 31 days) - useAttendanceAdmin: month fetch requests limit=2000 with the constraint documented - regression test: a 120-row month returns all 120 records Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
346 lines
11 KiB
TypeScript
346 lines
11 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 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<ReturnType<typeof buildApp>>;
|
|
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("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();
|
|
});
|
|
});
|