fix(dates): @db.Date filters built from local midnight queried the wrong day
Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to its UTC date part. Under TZ=Europe/Prague a local-midnight boundary (new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of every @db.Date filter in the codebase. New shared helper utcMidnightOfLocalDay() in src/utils/date.ts. Fixed (all previously off by one day): - attendance.service: getStatus today+month windows; getWorkfund (last day of each month was double-counted across months); getPrintData (monthly print included prev month's last day); listAttendance (admin month view included prev month's last day AND dropped the selected month's last day); createAttendance duplicate/overlap validation (checked only the PREVIOUS day - same-day duplicates were never caught, neighbors falsely rejected) - invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired (due==today+3 was outside the fetched window); window computation extracted as computeAlertWindow() + pure tests - invoices.service: month list/totals filter dropped invoices issued on the month's last day; getInvoiceStats month/year bounds; markOverdueInvoices boundary; auto paid_date could store yesterday during 00:00-02:00 - received-invoices auto paid_date, issued-orders default order_date: same night-window write hazard, now via the helper - attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD) +9 regression tests (month boundaries, same-day duplicate, last-day-of-month invoice in list+totals, alert window). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -258,6 +258,106 @@ describe("GET /api/admin/attendance (complete month, no truncation)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user