diff --git a/src/__tests__/dashboard.test.ts b/src/__tests__/dashboard.test.ts new file mode 100644 index 0000000..65b10ab --- /dev/null +++ b/src/__tests__/dashboard.test.ts @@ -0,0 +1,100 @@ +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 dashboardRoutes from "../routes/admin/dashboard"; + +// --------------------------------------------------------------------------- +// Regression test for the "Docházka dnes" / "Přítomní dnes" dashboard window. +// +// attendance.shift_date is @db.Date and Prisma truncates a filter Date to its +// UTC date part. The dashboard used local-midnight boundaries +// (new Date(y, m, d) = 22:00/23:00 UTC of the PREVIOUS day under +// TZ=Europe/Prague), which truncated to [yesterday, today) — today's punches +// matched zero rows and everyone showed as absent. The boundaries must be +// UTC-midnight instants of the LOCAL calendar day. +// +// Unlike the other suites this one has to use the REAL current date (the +// route computes "today" internally), so fixtures are tracked by id and +// deleted in afterAll. +// --------------------------------------------------------------------------- + +let app: Awaited>; +let adminUserId: number; +let adminToken: string; +const createdIds: number[] = []; + +async function buildApp() { + const a = Fastify({ logger: false }); + await a.register(dashboardRoutes, { prefix: "/api/admin/dashboard" }); + return a; +} + +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 = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +}); + +afterAll(async () => { + if (createdIds.length) { + await prisma.attendance.deleteMany({ where: { id: { in: createdIds } } }); + } + if (app) await app.close(); +}); + +describe("GET /api/admin/dashboard — today's attendance window", () => { + it("counts a punched-in user as present (local-noon @db.Date row)", async () => { + const now = new Date(); + // The attendance regime stores shift_date at LOCAL NOON (so the UTC date + // part equals the Prague calendar date) — same as punchAction does. + const row = await prisma.attendance.create({ + data: { + user_id: adminUserId, + shift_date: new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 12, + 0, + 0, + ), + leave_type: "work", + arrival_time: now, + departure_time: null, + notes: "dashboard_window_test", + }, + }); + createdIds.push(row.id); + + const res = await app.inject({ + method: "GET", + url: "/api/admin/dashboard", + headers: { Authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + + const attendance = body.data.attendance; + expect(attendance).toBeTruthy(); + const me = attendance.users.find( + (u: { user_id: number }) => u.user_id === adminUserId, + ); + expect(me).toBeTruthy(); + expect(me.status).toBe("in"); + expect(attendance.present_today).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/admin/context/AuthContext.tsx b/src/admin/context/AuthContext.tsx index d2fc486..24be892 100644 --- a/src/admin/context/AuthContext.tsx +++ b/src/admin/context/AuthContext.tsx @@ -9,6 +9,7 @@ import { type ReactNode, } from "react"; import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api"; +import { queryClient } from "../lib/queryClient"; const API_BASE = "/api/admin"; @@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { remember, }; } + // Query keys are user-agnostic (["dashboard"], ["attendance"], …), + // so anything cached from a previously logged-in user would be + // served to this one — drop the whole cache on every login. + queryClient.clear(); setAccessTokenFn(data.data.access_token, data.data.expires_in); setUser(mapUser(data.data.user)); cachedUserRef.current = mapUser(data.data.user); @@ -288,6 +293,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const data = await response.json(); if (data.success) { + // Same reason as in login(): the cache may hold the previous + // user's data under user-agnostic keys. + queryClient.clear(); setAccessTokenFn(data.data.access_token, data.data.expires_in); setUser(mapUser(data.data.user)); cachedUserRef.current = mapUser(data.data.user); @@ -327,6 +335,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null; } + // The React Query cache outlives the session — without this, the next + // user to log in on this browser sees the previous user's cached + // dashboards/lists until each query refetches. + queryClient.clear(); } }, [getAccessTokenFn]); diff --git a/src/routes/admin/dashboard.ts b/src/routes/admin/dashboard.ts index 85d143c..6a2fcf6 100644 --- a/src/routes/admin/dashboard.ts +++ b/src/routes/admin/dashboard.ts @@ -11,15 +11,16 @@ export default async function dashboardRoutes( ): Promise { fastify.get("/", { preHandler: requireAuth }, async (request, reply) => { const now = new Date(); + // shift_date is @db.Date: Prisma truncates a filter Date to its UTC date + // part, so these boundaries MUST be UTC-midnight instants of the LOCAL + // (Prague) calendar day. A local-midnight Date (new Date(y, m, d)) is + // 22:00/23:00 UTC of the PREVIOUS day and silently shifts the window to + // [yesterday, today) — the "Docházka dnes" card then matches zero rows. const todayStart = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), + Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()), ); const todayEnd = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate() + 1, + Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1), ); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);