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 tripsRoutes from "../routes/admin/trips"; // --------------------------------------------------------------------------- // Route-level tests for the trips domain: // // 1. PUT /trips/:id with vehicle_id actually moves the trip to the new vehicle // (UpdateTripSchema previously had no vehicle_id, so the edit modal's // vehicle change was silently dropped while the UI reported success) and // recomputes actual_km on BOTH vehicles. // 2. GET /trips/stats aggregates count/total/business/private km over the // WHOLE filtered set (not one 25-row page), honors the month filter in // both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes // non-managers to their own trips exactly like the list does. // --------------------------------------------------------------------------- /** Note-prefix for test-created rows so cleanup never touches real data. */ const N = "trips_test_"; let app: Awaited>; let adminUserId: number; let adminToken: string; let scopeUserId: number; let scopeRoleId: number; let scopeToken: string; let vehicleAId: number; let vehicleBId: number; 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(tripsRoutes, { prefix: "/api/admin/trips" }); 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 authGet(path: string, token: string) { return app.inject({ method: "GET", url: path, headers: { Authorization: `Bearer ${token}` }, }); } 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, }); } function tripRow(params: { userId: number; vehicleId: number; date: string; startKm: number; dist: number; isBusiness: boolean; }) { return { user_id: params.userId, vehicle_id: params.vehicleId, trip_date: new Date(params.date), start_km: params.startKm, end_km: params.startKm + params.dist, route_from: "Praha", route_to: "Brno", is_business: params.isBusiness, notes: `${N}fixture`, }; } async function cleanupFixtureTrips() { await prisma.trips.deleteMany({ where: { notes: { contains: N } } }); } 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, }); // Dedicated non-admin user with trips.record + trips.history (but NOT // trips.manage) for the own-trips scoping tests. const stamp = Date.now().toString(36); const role = await prisma.roles.create({ data: { name: `${N}role_${stamp}`, display_name: "Trips Scope Test", }, }); scopeRoleId = role.id; const perms = await prisma.permissions.findMany({ where: { name: { in: ["trips.record", "trips.history"] } }, select: { id: true }, }); if (perms.length !== 2) throw new Error( "Test setup: trips.record/trips.history permissions missing", ); await prisma.role_permissions.createMany({ data: perms.map((p) => ({ role_id: role.id, permission_id: p.id })), }); const scopeUser = await prisma.users.create({ data: { username: `${N}user_${stamp}`, first_name: "Trips", last_name: "Scope", email: `${N}${stamp}@test.local`, password_hash: "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali", role_id: role.id, is_active: true, }, }); scopeUserId = scopeUser.id; if (scopeUserId === adminUserId) throw new Error("scope user must be distinct from admin"); scopeToken = generateToken({ id: scopeUser.id, username: scopeUser.username, roleName: role.name, }); // Two dedicated vehicles (spz is unique, VarChar(20)). const vehicleA = await prisma.vehicles.create({ data: { spz: `TTA-${stamp}`, name: "Trips Test A", initial_km: 500 }, }); const vehicleB = await prisma.vehicles.create({ data: { spz: `TTB-${stamp}`, name: "Trips Test B", initial_km: 200 }, }); vehicleAId = vehicleA.id; vehicleBId = vehicleB.id; await cleanupFixtureTrips(); }); beforeEach(async () => { await cleanupFixtureTrips(); }); afterAll(async () => { await cleanupFixtureTrips(); await prisma.vehicles .deleteMany({ where: { id: { in: [vehicleAId, vehicleBId] } } }) .catch(() => {}); if (scopeUserId) await prisma.users .deleteMany({ where: { id: scopeUserId } }) .catch(() => {}); if (scopeRoleId) await prisma.roles .deleteMany({ where: { id: scopeRoleId } }) .catch(() => {}); if (app) await app.close(); }); describe("PUT /api/admin/trips/:id — vehicle_id", () => { it("changes the trip's vehicle and recomputes actual_km on both vehicles", async () => { const trip = await prisma.trips.create({ data: tripRow({ userId: adminUserId, vehicleId: vehicleAId, date: "2098-04-10", startKm: 1000, dist: 500, isBusiness: true, }), }); // The edit form sends the id as a string (Select value) — intIdFromForm // must coerce it. const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, { vehicle_id: String(vehicleBId), }); expect(res.statusCode).toBe(200); expect(res.json().success).toBe(true); const updated = await prisma.trips.findUnique({ where: { id: trip.id } }); expect(updated!.vehicle_id).toBe(vehicleBId); // New vehicle picks up the trip's odometer reading… const vehB = await prisma.vehicles.findUnique({ where: { id: vehicleBId }, }); expect(vehB!.actual_km).toBe(1500); // …and the old vehicle falls back to initial_km (no trips left on it). const vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId }, }); expect(vehA!.actual_km).toBe(500); }); it("recomputes actual_km when end_km changes and the vehicle stays the same", async () => { // Two trips on vehicle A — the recompute must take MAX(end_km) over ALL // of the vehicle's trips, not just the edited one. const trip1 = await prisma.trips.create({ data: tripRow({ userId: adminUserId, vehicleId: vehicleAId, date: "2098-09-10", startKm: 1000, dist: 500, // end_km 1500 isBusiness: true, }), }); const trip2 = await prisma.trips.create({ data: tripRow({ userId: adminUserId, vehicleId: vehicleAId, date: "2098-09-12", startKm: 1800, dist: 200, // end_km 2000 isBusiness: true, }), }); // Raising the MAX trip's end_km (vehicle UNCHANGED) moves the odometer up… const raise = await authPut(`/api/admin/trips/${trip2.id}`, adminToken, { end_km: 2200, }); expect(raise.statusCode).toBe(200); let vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } }); expect(vehA!.actual_km).toBe(2200); // …while editing the non-MAX trip recomputes but keeps MAX(end_km). const lower = await authPut(`/api/admin/trips/${trip1.id}`, adminToken, { end_km: 1600, }); expect(lower.statusCode).toBe(200); const updated = await prisma.trips.findUnique({ where: { id: trip1.id } }); expect(updated!.end_km).toBe(1600); expect(updated!.vehicle_id).toBe(vehicleAId); vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } }); expect(vehA!.actual_km).toBe(2200); }); it("writes an audit row with oldValues/newValues on update", async () => { const trip = await prisma.trips.create({ data: tripRow({ userId: adminUserId, vehicleId: vehicleAId, date: "2098-10-10", startKm: 100, dist: 50, // end_km 150 isBusiness: true, }), }); const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, { end_km: 180, route_to: "Ostrava", }); expect(res.statusCode).toBe(200); const audit = await prisma.audit_logs.findFirst({ where: { action: "update", entity_type: "trip", entity_id: trip.id }, orderBy: { id: "desc" }, }); expect(audit).not.toBeNull(); expect(audit!.user_id).toBe(adminUserId); // oldValues = the full pre-update row, newValues = the changed fields. expect(audit!.old_values).toBeTruthy(); expect(audit!.new_values).toBeTruthy(); const oldValues = JSON.parse(audit!.old_values!); const newValues = JSON.parse(audit!.new_values!); expect(oldValues.end_km).toBe(150); expect(oldValues.route_to).toBe("Brno"); expect(oldValues.vehicle_id).toBe(vehicleAId); expect(newValues).toEqual({ end_km: 180, route_to: "Ostrava" }); }); it("rejects an invalid vehicle_id with 400", async () => { const trip = await prisma.trips.create({ data: tripRow({ userId: adminUserId, vehicleId: vehicleAId, date: "2098-04-11", startKm: 100, dist: 50, isBusiness: true, }), }); const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, { vehicle_id: "abc", }); expect(res.statusCode).toBe(400); const unchanged = await prisma.trips.findUnique({ where: { id: trip.id } }); expect(unchanged!.vehicle_id).toBe(vehicleAId); }); }); describe("GET /api/admin/trips/stats", () => { it("aggregates over the WHOLE filtered set, beyond one 25-row page", async () => { // 20 business trips of 10 km + 10 private trips of 7 km in 2098-05. const rows = []; for (let i = 0; i < 20; i++) { rows.push( tripRow({ userId: adminUserId, vehicleId: vehicleAId, date: "2098-05-10", startKm: 1000 + i * 10, dist: 10, isBusiness: true, }), ); } for (let i = 0; i < 10; i++) { rows.push( tripRow({ userId: adminUserId, vehicleId: vehicleBId, date: "2098-05-15", startKm: 2000 + i * 7, dist: 7, isBusiness: false, }), ); } await prisma.trips.createMany({ data: rows }); // The list truncates to the default 25-row page but reports the real total… const listRes = await authGet( "/api/admin/trips?month=5&year=2098", adminToken, ); expect(listRes.statusCode).toBe(200); const listBody = listRes.json(); expect(listBody.data).toHaveLength(25); expect(listBody.pagination.total).toBe(30); expect(listBody.pagination.total_pages).toBe(2); // …while the stats endpoint covers all 30 rows. const res = await authGet( "/api/admin/trips/stats?month=5&year=2098", adminToken, ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data).toEqual({ count: 30, total_km: 20 * 10 + 10 * 7, business_km: 200, private_km: 70, }); }); it("honors the month filter in both wire formats", async () => { await prisma.trips.createMany({ data: [ tripRow({ userId: adminUserId, vehicleId: vehicleAId, date: "2098-06-10", startKm: 100, dist: 10, isBusiness: true, }), tripRow({ userId: adminUserId, vehicleId: vehicleAId, date: "2098-06-12", startKm: 110, dist: 20, isBusiness: false, }), tripRow({ userId: adminUserId, vehicleId: vehicleAId, date: "2098-07-05", startKm: 130, dist: 40, isBusiness: true, }), ], }); // Combined "YYYY-MM" format (TripsHistory) const june = await authGet( "/api/admin/trips/stats?month=2098-06", adminToken, ); expect(june.statusCode).toBe(200); expect(june.json().data).toEqual({ count: 2, total_km: 30, business_km: 10, private_km: 20, }); // Split month+year format (TripsAdmin) const july = await authGet( "/api/admin/trips/stats?month=7&year=2098", adminToken, ); expect(july.statusCode).toBe(200); expect(july.json().data).toEqual({ count: 1, total_km: 40, business_km: 40, private_km: 0, }); }); it("scopes non-managers to their own trips (user_id param ignored)", async () => { await prisma.trips.createMany({ data: [ // 2 trips for the scope user, 3 for the admin — same month. tripRow({ userId: scopeUserId, vehicleId: vehicleAId, date: "2098-08-05", startKm: 100, dist: 10, isBusiness: true, }), tripRow({ userId: scopeUserId, vehicleId: vehicleAId, date: "2098-08-06", startKm: 110, dist: 15, isBusiness: false, }), tripRow({ userId: adminUserId, vehicleId: vehicleBId, date: "2098-08-07", startKm: 200, dist: 100, isBusiness: true, }), tripRow({ userId: adminUserId, vehicleId: vehicleBId, date: "2098-08-08", startKm: 300, dist: 100, isBusiness: true, }), tripRow({ userId: adminUserId, vehicleId: vehicleBId, date: "2098-08-09", startKm: 400, dist: 100, isBusiness: true, }), ], }); // Non-manager: only own trips, even when explicitly requesting another user. const own = await authGet( "/api/admin/trips/stats?month=8&year=2098", scopeToken, ); expect(own.statusCode).toBe(200); expect(own.json().data).toEqual({ count: 2, total_km: 25, business_km: 10, private_km: 15, }); const spoofed = await authGet( `/api/admin/trips/stats?month=8&year=2098&user_id=${adminUserId}`, scopeToken, ); expect(spoofed.statusCode).toBe(200); expect(spoofed.json().data.count).toBe(2); // The list applies the exact same scoping. const list = await authGet( "/api/admin/trips?month=8&year=2098", scopeToken, ); expect(list.statusCode).toBe(200); expect(list.json().data).toHaveLength(2); // Manager (admin) sees everything in the month. const all = await authGet( "/api/admin/trips/stats?month=8&year=2098", adminToken, ); expect(all.statusCode).toBe(200); expect(all.json().data.count).toBe(5); expect(all.json().data.total_km).toBe(325); // Manager can filter to a single user. const filtered = await authGet( `/api/admin/trips/stats?month=8&year=2098&user_id=${scopeUserId}`, adminToken, ); expect(filtered.statusCode).toBe(200); expect(filtered.json().data.count).toBe(2); }); });