201 lines
8 KiB
TypeScript
201 lines
8 KiB
TypeScript
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<User> {
|
|
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<number> {
|
|
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");
|
|
});
|
|
});
|