import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest"; import type { OperationReservation, User } from "@/payload-types"; import { getReservationList } from "@/app/(frontend)/operations/reservations/page"; import { getReservationDetail } from "@/app/(frontend)/operations/reservations/[id]/page"; import { createReservation, cancelReservation } from "@/app/(frontend)/operations/reservations/actions"; // Mock next/headers so the action's authenticate() can run outside a request. vi.mock("next/headers", () => ({ headers: async () => new Headers(), })); let payload: Payload; let authSpy: MockInstance; const RUN = `res-surfaces-${Date.now().toString(36)}`; const reservationIds: number[] = []; const userIds: number[] = []; const roleIds: number[] = []; let superUser: User; let plainUser: User; let soldierOne: User; function relId(value: unknown): number { return typeof value === "object" && value !== null ? (value as { id: number }).id : (value as number); } async function makeUser(label: string, roleDocIds?: number[]): Promise { const user = (await payload.create({ collection: "users", data: { username: `${RUN}-${label}`, discordUsername: `${RUN}-${label}-discord`, displayName: `${RUN} ${label}`, steamId: `${RUN}-steam-${label}`, password: "Test1234", roles: ["user"], ...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}), }, overrideAccess: true, depth: 0, })) as unknown as User; userIds.push(user.id); return user; } /** Create a minimal (personnel-only) reservation that succeeds without fixtures. */ async function createReservedReservation(key: string, operationId: string): Promise { const result = await createReservation({ reservationKey: key, operationId, actorId: superUser.id, personnel: [{ userId: soldierOne.id, slot: "Team Lead" }], expiresAt: new Date(Date.now() + 3_600_000).toISOString(), }); expect(result.success).toBe(true); const id = result.data!.id; reservationIds.push(id); return id; } beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); authSpy = vi.spyOn(payload, "auth"); const superRole = await payload.create({ collection: "roles", data: { name: `${RUN} Command`, slug: `${RUN}-command`, isSuperuser: true }, overrideAccess: true, depth: 0, }); roleIds.push(superRole.id); superUser = await makeUser("command", [superRole.id]); plainUser = await makeUser("plain"); soldierOne = await makeUser("soldier1"); }); afterAll(async () => { if (!payload) return; for (const id of reservationIds) { await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {}); } for (const id of userIds) { await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } for (const id of roleIds) { await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); } }); describe("reservation surfaces: getReservationList", () => { it("returns rows and capability true for a permitted viewer", async () => { authSpy.mockResolvedValue({ user: superUser }); await createReservedReservation(`${RUN}-list-1`, `${RUN}-op-a`); const data = await getReservationList(payload, { id: superUser.id }, {}, 1); expect(data.capability).toBe(true); expect(data.rows.length).toBeGreaterThan(0); expect(data.rows.some((r) => r.reservationKey === `${RUN}-list-1`)).toBe(true); const listed = data.rows.find((r) => r.reservationKey === `${RUN}-list-1`); expect(listed?.status).toBe("reserved"); expect(listed?.expiresAt).toBeTruthy(); }); it("returns empty rows and capability false for a plain user (permission-denied shape)", async () => { authSpy.mockResolvedValue({ user: plainUser }); const data = await getReservationList(payload, { id: plainUser.id }, {}, 1); expect(data.capability).toBe(false); expect(data.rows).toHaveLength(0); }); it("narrows rows by status", async () => { authSpy.mockResolvedValue({ user: superUser }); await createReservedReservation(`${RUN}-filter-r`, `${RUN}-op-filter-r`); const cancelledId = await createReservedReservation(`${RUN}-filter-c`, `${RUN}-op-filter-c`); await cancelReservation({ reservationId: cancelledId }); const reserved = await getReservationList(payload, { id: superUser.id }, { status: "reserved" }, 1); const reservedRows = reserved.rows.filter((r) => r.reservationKey === `${RUN}-filter-r`); expect(reservedRows).toHaveLength(1); expect(reservedRows[0].status).toBe("reserved"); const cancelled = await getReservationList(payload, { id: superUser.id }, { status: "cancelled" }, 1); const cancelledRows = cancelled.rows.filter((r) => r.reservationKey === `${RUN}-filter-c`); expect(cancelledRows).toHaveLength(1); expect(cancelledRows[0].status).toBe("cancelled"); }); it("narrows rows by operationId", async () => { authSpy.mockResolvedValue({ user: superUser }); await createReservedReservation(`${RUN}-opid-1`, `${RUN}-op-specific`); const data = await getReservationList(payload, { id: superUser.id }, { operationId: `${RUN}-op-specific` }, 1); expect(data.rows).toHaveLength(1); expect(data.rows[0].operationId).toBe(`${RUN}-op-specific`); }); }); describe("reservation surfaces: getReservationDetail", () => { it("returns the full reservation and canCancel when reserved and permitted", async () => { authSpy.mockResolvedValue({ user: superUser }); const id = await createReservedReservation(`${RUN}-detail-1`, `${RUN}-op-detail`); const detail = await getReservationDetail(payload, { id: superUser.id }, String(id)); expect(detail.reservation).not.toBeNull(); expect(detail.reservation?.id).toBe(id); expect(detail.reservation?.status).toBe("reserved"); expect(detail.reservation?.personnel).toHaveLength(1); expect(relId(detail.reservation?.personnel?.[0]?.user)).toBe(soldierOne.id); expect(detail.canCancel).toBe(true); }); it("reports a not-found reservation for an unknown id", async () => { authSpy.mockResolvedValue({ user: superUser }); const detail = await getReservationDetail(payload, { id: superUser.id }, "999999999"); expect(detail.reservation).toBeNull(); expect(detail.canCancel).toBe(false); }); it("does not allow cancel for a plain user even on a reserved row", async () => { authSpy.mockResolvedValue({ user: superUser }); const id = await createReservedReservation(`${RUN}-detail-2`, `${RUN}-op-detail-2`); const detail = await getReservationDetail(payload, { id: plainUser.id }, String(id)); expect(detail.reservation).not.toBeNull(); expect(detail.canCancel).toBe(false); }); }); describe("reservation surfaces: cancelReservation integration", () => { it("cancels a reserved reservation exactly once; second cancel is a clean no-op", async () => { authSpy.mockResolvedValue({ user: superUser }); const id = await createReservedReservation(`${RUN}-cancel-int`, `${RUN}-op-cancel-int`); const first = await cancelReservation({ reservationId: id }, { reason: "Cancelled by user" }); expect(first.success).toBe(true); expect(first.data?.status).toBe("cancelled"); expect(first.data?.settledAt).toBeTruthy(); const second = await cancelReservation({ reservationId: id }); expect(second.success).toBe(true); expect(second.data?.status).toBe("cancelled"); expect(second.data?.settledAt).toBe(first.data?.settledAt); }); it("denies cancel for a user without operation-reservations:update", async () => { authSpy.mockResolvedValue({ user: superUser }); const id = await createReservedReservation(`${RUN}-cancel-denied`, `${RUN}-op-cancel-denied`); authSpy.mockResolvedValue({ user: plainUser }); const denied = await cancelReservation({ reservationId: id }); expect(denied.success).toBe(false); expect(denied.error).toContain("forbidden"); }); });