Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
failed deficit line rolls back the surplus corrective receipt — retries
no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
(in-transaction, clamped at 0)
High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
(Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
hours against its own balance (mirrors createLeave)
Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
(recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
@db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
/items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
settings.company|settings.system; Settings System tab no longer clobbers
Firma numbering patterns; draft invoices hide the dead PDF button;
dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
audit-log + invoice month buckets day-shift fixes
Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.
~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
6.1 KiB
TypeScript
203 lines
6.1 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import Fastify from "fastify";
|
|
import jwt from "jsonwebtoken";
|
|
import prisma from "../config/database";
|
|
import { config } from "../config/env";
|
|
import leaveRequestsRoutes from "../routes/admin/leave-requests";
|
|
|
|
/**
|
|
* Pinning tests for audit finding H6 + the year-spanning fast-follow:
|
|
* leave-request creation and approval must mirror attendance.service
|
|
* createLeave — skip Czech public holidays (a weekday holiday is already
|
|
* paid/free; charging it double-deducts) and book each day's hours against
|
|
* ITS OWN calendar year's balance (a Dec→Jan request used to charge
|
|
* everything to the start year).
|
|
*
|
|
* Fixture math (verified): 2098-05-01 (Thu) and 2098-05-08 (Thu) are both
|
|
* weekday holidays → the 05-01..05-08 range has 4 chargeable days (32h),
|
|
* not 6 (48h). 2098-12-28 (Sun)..2099-01-05 (Mon) has 3 chargeable days in
|
|
* 2098 (29/30/31 = 24h) and 2 in 2099 (Jan 2 + Jan 5 = 16h; Jan 1 is a
|
|
* holiday).
|
|
*/
|
|
|
|
const N = "leave_holiday_";
|
|
|
|
let app: ReturnType<typeof Fastify>;
|
|
let adminToken: string;
|
|
let userToken: string;
|
|
let userId: number;
|
|
|
|
async function cleanup() {
|
|
const users = await prisma.users.findMany({
|
|
where: { username: { startsWith: N } },
|
|
select: { id: true },
|
|
});
|
|
const ids = users.map((u) => u.id);
|
|
if (ids.length > 0) {
|
|
await prisma.attendance.deleteMany({ where: { user_id: { in: ids } } });
|
|
await prisma.leave_requests.deleteMany({
|
|
where: { user_id: { in: ids } },
|
|
});
|
|
await prisma.leave_balances.deleteMany({ where: { user_id: { in: ids } } });
|
|
await prisma.users.deleteMany({ where: { id: { in: ids } } });
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await cleanup();
|
|
|
|
app = Fastify({ logger: false });
|
|
await app.register(leaveRequestsRoutes, {
|
|
prefix: "/api/admin/leave-requests",
|
|
});
|
|
|
|
const admin = await prisma.users.findFirst({
|
|
where: { roles: { name: "admin" } },
|
|
});
|
|
if (!admin) throw new Error("Test setup: admin user not found");
|
|
adminToken = jwt.sign(
|
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
|
config.jwt.secret,
|
|
{ expiresIn: "15m" },
|
|
);
|
|
|
|
const role = await prisma.roles.findFirst();
|
|
const user = await prisma.users.create({
|
|
data: {
|
|
username: `${N}user`,
|
|
email: `${N}user@test.local`,
|
|
password_hash:
|
|
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
|
first_name: "Leave",
|
|
last_name: "Holiday",
|
|
is_active: true,
|
|
role_id: role!.id,
|
|
},
|
|
});
|
|
userId = user.id;
|
|
userToken = jwt.sign(
|
|
{ sub: user.id, username: user.username, role: role!.name },
|
|
config.jwt.secret,
|
|
{ expiresIn: "15m" },
|
|
);
|
|
|
|
for (const year of [2098, 2099]) {
|
|
await prisma.leave_balances.create({
|
|
data: {
|
|
user_id: userId,
|
|
year,
|
|
vacation_total: 200,
|
|
vacation_used: 0,
|
|
sick_used: 0,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (app) await app.close();
|
|
await cleanup();
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
async function balance(year: number) {
|
|
return prisma.leave_balances.findFirst({
|
|
where: { user_id: userId, year },
|
|
});
|
|
}
|
|
|
|
describe("leave requests vs Czech holidays (audit H6)", () => {
|
|
it("creation excludes weekday public holidays from the charged total", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/admin/leave-requests",
|
|
headers: {
|
|
Authorization: `Bearer ${userToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: {
|
|
leave_type: "vacation",
|
|
date_from: "2098-05-01",
|
|
date_to: "2098-05-08",
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const row = await prisma.leave_requests.findFirst({
|
|
where: { id: res.json().data.id },
|
|
});
|
|
expect(row?.total_days).toBe(4);
|
|
expect(Number(row?.total_hours)).toBe(32);
|
|
// Leave it cancelled so the approval test's range stays free.
|
|
await prisma.leave_requests.update({
|
|
where: { id: row!.id },
|
|
data: { status: "cancelled" },
|
|
});
|
|
});
|
|
|
|
it("approval charges only non-holiday business days and skips holiday attendance rows", async () => {
|
|
// Stored totals mimic a PRE-FIX request (weekend-only counting) — the
|
|
// approval must recompute, not trust them.
|
|
const req = await prisma.leave_requests.create({
|
|
data: {
|
|
user_id: userId,
|
|
leave_type: "vacation",
|
|
date_from: new Date(Date.UTC(2098, 4, 1)),
|
|
date_to: new Date(Date.UTC(2098, 4, 8)),
|
|
total_hours: 48,
|
|
total_days: 6,
|
|
status: "pending",
|
|
},
|
|
});
|
|
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: `/api/admin/leave-requests/${req.id}`,
|
|
headers: {
|
|
Authorization: `Bearer ${adminToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { status: "approved" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
expect(Number((await balance(2098))?.vacation_used)).toBe(32);
|
|
|
|
const rows = await prisma.attendance.findMany({
|
|
where: { user_id: userId, leave_type: "vacation" },
|
|
});
|
|
const days = rows.map((r) => r.shift_date.toISOString().slice(0, 10));
|
|
expect(rows).toHaveLength(4);
|
|
expect(days).not.toContain("2098-05-01");
|
|
expect(days).not.toContain("2098-05-08");
|
|
});
|
|
|
|
it("a year-spanning approval books each day against its own year's balance", async () => {
|
|
const req = await prisma.leave_requests.create({
|
|
data: {
|
|
user_id: userId,
|
|
leave_type: "sick",
|
|
date_from: new Date(Date.UTC(2098, 11, 28)),
|
|
date_to: new Date(Date.UTC(2099, 0, 5)),
|
|
total_hours: 48,
|
|
total_days: 6,
|
|
status: "pending",
|
|
},
|
|
});
|
|
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: `/api/admin/leave-requests/${req.id}`,
|
|
headers: {
|
|
Authorization: `Bearer ${adminToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: { status: "approved" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
// 29/30/31 Dec 2098 = 24h; Jan 2 + Jan 5 2099 = 16h (Jan 1 is a holiday).
|
|
expect(Number((await balance(2098))?.sick_used)).toBe(24);
|
|
expect(Number((await balance(2099))?.sick_used)).toBe(16);
|
|
});
|
|
});
|