import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest"; import type { BankAccount, OperationReservation, Structure, User } from "@/payload-types"; import { createReservation, cancelReservation } from "@/app/(frontend)/operations/reservations/actions"; import { applyTransaction, createAccount } from "@/lib/banking"; // 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-actions-${Date.now().toString(36)}`; // --------------------------------------------------------------------------- // Fixture tracking for cleanup // --------------------------------------------------------------------------- const reservationIds: number[] = []; const userIds: number[] = []; const roleIds: number[] = []; const accountIds: number[] = []; const gameStructureIds: number[] = []; const blueprintIds: number[] = []; const gameVehicleIds: number[] = []; const vehicleBlueprintIds: number[] = []; const resourceIds: number[] = []; const missionIds: number[] = []; const campaignIds: number[] = []; const mapIds: number[] = []; let serverId: number; let commandUser: User; let plainUser: User; let spoofedUser: User; let soldierOne: User; let mapId: number; let campaignId: number; let missionId: number; let resourceAId: number; let normalBlueprintId: number; let vehicleBlueprintId: number; const LEXICAL_EMPTY = { root: { children: [{ text: "" }], direction: null, format: "" as const, indent: 0, type: "text", version: 1, }, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function relId(value: unknown): number { return typeof value === "object" && value !== null ? (value as { id: number }).id : (value as number); } async function findByKey(key: string): Promise { const res = await payload.find({ collection: "operation-reservations", where: { reservationKey: { equals: key } }, limit: 10, depth: 0, overrideAccess: true, }); return (res.docs[0] as OperationReservation | undefined) ?? null; } 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; } async function makeMission(): Promise { const mission = await payload.create({ collection: "missions", data: { name: `${RUN} Main`, codeName: `${RUN}-main`, summary: "Reservation action test mission", operationType: "main", classification: { map: mapId, missionType: "PvE", campaign: campaignId, startDateTime: new Date(Date.now() + 86_400_000).toISOString(), estimatedDuration: 60, }, ownershipAndStatus: { authors: [commandUser.id], status: "Scheduled", visibility: "unit", }, missionRoles: { maxPlayers: 16 }, gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } }, briefing: [], }, overrideAccess: true, depth: 0, }); missionIds.push(mission.id); return mission.id; } async function makeOrigin(stock: { resourceId: number; amount: number }[]): Promise { const site = await payload.create({ collection: "game-structures", data: { name: `${RUN} origin ${gameStructureIds.length}`, type: normalBlueprintId, map: mapId, coordinates: [100 + gameStructureIds.length, 100], constructionStatus: "complete", storedResources: stock.map((s, i) => ({ resource: s.resourceId, amount: s.amount, gridX: 0, gridY: i, rotated: false, })), }, overrideAccess: true, depth: 0, }); gameStructureIds.push(site.id); return site.id; } async function makeDestination(): Promise { const site = await payload.create({ collection: "game-structures", data: { name: `${RUN} dest ${gameStructureIds.length}`, type: normalBlueprintId, map: mapId, coordinates: [500 + gameStructureIds.length, 500], constructionStatus: "complete", }, overrideAccess: true, depth: 0, }); gameStructureIds.push(site.id); return site.id; } async function makeVehicle(atId: number): Promise { const vehicle = await payload.create({ collection: "game-vehicles", data: { name: `${RUN} vic ${gameVehicleIds.length}`, type: vehicleBlueprintId, deployedAt: atId, status: "idle", currentFuel: 1000, currentHealth: 100, }, overrideAccess: true, depth: 0, }); gameVehicleIds.push(vehicle.id); return vehicle.id; } async function makeFundedAccount(amount: number): Promise { const account = await createAccount(payload, { name: `${RUN} Treasury ${accountIds.length}`, accountType: "treasury", }); accountIds.push(account.id); if (amount > 0) { await applyTransaction(payload, { type: "deposit", toAccountId: account.id, amount, memo: `${RUN} test funding`, }); } return account; } // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- 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); commandUser = await makeUser("command", [superRole.id]); plainUser = await makeUser("plain"); spoofedUser = await makeUser("spoofed"); soldierOne = await makeUser("soldier1"); const map = await payload.create({ collection: "maps", data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, overrideAccess: true, depth: 0, }); mapId = map.id; mapIds.push(map.id); const campaign = await payload.create({ collection: "campaigns", data: { name: `${RUN} Campaign`, summary: "Reservation action test campaign", status: "concept", campaignMode: "custom", }, overrideAccess: true, depth: 0, }); campaignId = campaign.id; campaignIds.push(campaign.id); missionId = await makeMission(); const server = await payload.create({ collection: "game-servers", data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" }, overrideAccess: true, depth: 0, }); serverId = server.id; const resource = await payload.create({ collection: "resources", data: { name: `${RUN} Alpha`, codeName: `${RUN}-alpha`, approvalStatus: "in_progress", type: "physical", baseValue: 1, rarity: "common", unitOfMeasure: "kg", massPerUnit: 1, gridWidth: 1, gridHeight: 1, }, overrideAccess: true, depth: 0, }); resourceIds.push(resource.id); resourceAId = resource.id; const normalBlueprint = await payload.create({ collection: "structures", data: { name: `${RUN} Depot`, codeName: `${RUN}-depot`, approvalStatus: "in_progress", description: LEXICAL_EMPTY as unknown as Structure["description"], category: "logistics", materials: [{ resource: resourceAId, amount: 1 }], constructionDurationMinutes: 1, terrainType: "land", maxHealth: 100, }, overrideAccess: true, depth: 0, }); normalBlueprintId = normalBlueprint.id; blueprintIds.push(normalBlueprint.id); const vehicleBlueprint = await payload.create({ collection: "vehicles", data: { name: `${RUN} Truck`, approvalStatus: "in_progress", transportMode: "ground", maxSpeedOnRoad: 60, fuel: { fuelType: resourceAId, fuelCapacity: 1000, fuelConsumptionRate: 0.01 }, }, overrideAccess: true, depth: 0, }); vehicleBlueprintId = vehicleBlueprint.id; vehicleBlueprintIds.push(vehicleBlueprint.id); }); afterAll(async () => { if (!payload) return; for (const id of reservationIds) { await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {}); } for (const id of gameVehicleIds) { await payload.delete({ collection: "game-vehicles", id, overrideAccess: true }).catch(() => {}); } for (const id of gameStructureIds) { await payload.delete({ collection: "game-structures", id, overrideAccess: true }).catch(() => {}); } for (const id of vehicleBlueprintIds) { await payload.delete({ collection: "vehicles", id, overrideAccess: true }).catch(() => {}); } for (const id of blueprintIds) { await payload.delete({ collection: "structures", id, overrideAccess: true }).catch(() => {}); } for (const id of resourceIds) { await payload.delete({ collection: "resources", id, overrideAccess: true }).catch(() => {}); } for (const id of missionIds) { await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {}); } for (const id of campaignIds) { await payload.delete({ collection: "campaigns", id, overrideAccess: true }).catch(() => {}); } for (const id of mapIds) { await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {}); } const deleteAccountChain = async (accountId: number) => { const txns = await payload.find({ collection: "bank-transactions", where: { or: [{ fromAccount: { equals: accountId } }, { toAccount: { equals: accountId } }] }, limit: 100, depth: 0, overrideAccess: true, }); for (const txn of txns.docs) { const entries = await payload.find({ collection: "ledger-entries", where: { transaction: { equals: txn.id } }, limit: 100, depth: 0, overrideAccess: true, }); for (const entry of entries.docs) { await payload.delete({ collection: "ledger-entries", id: entry.id, overrideAccess: true }).catch(() => {}); } await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true }).catch(() => {}); } await payload.delete({ collection: "bank-accounts", id: accountId, overrideAccess: true }).catch(() => {}); }; for (const id of accountIds) { await deleteAccountChain(id); } 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(() => {}); } if (serverId) { await payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }).catch(() => {}); } }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("reservation server actions", () => { describe("createReservation", () => { it("persists a reserved row with all lines and attributes it to the session user", async () => { authSpy.mockResolvedValue({ user: commandUser }); const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]); const destinationId = await makeDestination(); const vehicleId = await makeVehicle(originId); const account = await makeFundedAccount(1000); const result = await createReservation({ reservationKey: `${RUN}-happy`, operationId: `${RUN}-op-happy`, actorId: commandUser.id, missionId, personnel: [{ userId: soldierOne.id, slot: "Team Lead" }], vehicleIds: [vehicleId], cargo: [{ resourceId: resourceAId, amount: 40 }], budget: { accountId: account.id, amount: 200 }, originId, destinationId, expiresAt: new Date(Date.now() + 3_600_000).toISOString(), }); expect(result.success).toBe(true); expect(result.data?.status).toBe("reserved"); const row = await findByKey(`${RUN}-happy`); expect(row).not.toBeNull(); expect(row?.reservationKey).toBe(`${RUN}-happy`); expect(row?.status).toBe("reserved"); expect(relId(row?.createdBy)).toBe(commandUser.id); expect(relId(row?.mission)).toBe(missionId); expect(row?.personnel).toHaveLength(1); expect(relId(row?.personnel?.[0]?.user)).toBe(soldierOne.id); expect(row?.vehicles).toHaveLength(1); expect(relId(row?.vehicles?.[0]?.vehicle)).toBe(vehicleId); expect(row?.cargo).toHaveLength(1); expect(relId(row?.cargo?.[0]?.resource)).toBe(resourceAId); expect(row?.cargo?.[0]?.amount).toBe(40); expect(relId(row?.budget?.account)).toBe(account.id); expect(row?.budget?.amount).toBe(200); }); it("overwrites a client-supplied actorId with the session user (anti-spoofing)", async () => { // The session user is the privileged command user; the input tries to // attribute the reservation to an unrelated, unprivileged account. authSpy.mockResolvedValue({ user: commandUser }); const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]); const result = await createReservation({ reservationKey: `${RUN}-spoof`, operationId: `${RUN}-op-spoof`, actorId: spoofedUser.id, // must be ignored server-side personnel: [{ userId: soldierOne.id }], }); expect(result.success).toBe(true); const row = await findByKey(`${RUN}-spoof`); expect(row).not.toBeNull(); expect(relId(row?.createdBy)).toBe(commandUser.id); expect(relId(row?.createdBy)).not.toBe(spoofedUser.id); }); it("rejects a user without operation-reservations:create with a forbidden error", async () => { authSpy.mockResolvedValue({ user: plainUser }); const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]); const result = await createReservation({ reservationKey: `${RUN}-forbidden`, operationId: `${RUN}-op-forbidden`, actorId: plainUser.id, cargo: [{ resourceId: resourceAId, amount: 5 }], originId, }); expect(result.success).toBe(false); expect(result.error).toContain("forbidden"); expect(await findByKey(`${RUN}-forbidden`)).toBeNull(); }); it("returns the engine capacity error verbatim without writing a row", async () => { authSpy.mockResolvedValue({ user: commandUser }); const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]); const result = await createReservation({ reservationKey: `${RUN}-insufficient`, operationId: `${RUN}-op-insufficient`, actorId: commandUser.id, cargo: [{ resourceId: resourceAId, amount: 101 }], originId, }); expect(result.success).toBe(false); expect(result.error).toContain("Insufficient cargo"); expect(result.error).toContain("100 available at the origin"); expect(result.error).toContain("101 requested"); expect(await findByKey(`${RUN}-insufficient`)).toBeNull(); }); it("is idempotent for a retry with the same reservation key", async () => { authSpy.mockResolvedValue({ user: commandUser }); const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]); const key = `${RUN}-retry`; const input = { reservationKey: key, operationId: `${RUN}-op-retry`, actorId: commandUser.id, cargo: [{ resourceId: resourceAId, amount: 10 }], originId, }; const first = await createReservation(input); const second = await createReservation(input); expect(first.success).toBe(true); expect(second.success).toBe(true); expect(second.data?.id).toBe(first.data?.id); const all = await payload.find({ collection: "operation-reservations", where: { reservationKey: { equals: key } }, limit: 10, depth: 0, overrideAccess: true, }); expect(all.docs).toHaveLength(1); }); }); describe("cancelReservation", () => { it("cancels a reserved reservation exactly once; a second cancel is a clean no-op", async () => { authSpy.mockResolvedValue({ user: commandUser }); const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]); const created = await createReservation({ reservationKey: `${RUN}-cancel`, operationId: `${RUN}-op-cancel`, actorId: commandUser.id, cargo: [{ resourceId: resourceAId, amount: 25 }], originId, }); reservationIds.push(created.data!.id); expect(created.success).toBe(true); const reservationId = created.data!.id; const first = await cancelReservation({ reservationId }, { reason: "plan changed" }); expect(first.success).toBe(true); expect(first.data?.status).toBe("cancelled"); expect(first.data?.settledAt).toBeTruthy(); // Second settle is a no-op: same terminal status and timestamp. const second = await cancelReservation({ reservationId }); expect(second.success).toBe(true); expect(second.data?.status).toBe("cancelled"); expect(second.data?.settledAt).toBe(first.data?.settledAt); }); it("cancels by reservation key", async () => { authSpy.mockResolvedValue({ user: commandUser }); const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]); const key = `${RUN}-cancel-key`; const created = await createReservation({ reservationKey: key, operationId: `${RUN}-op-cancel-key`, actorId: commandUser.id, cargo: [{ resourceId: resourceAId, amount: 5 }], originId, }); reservationIds.push(created.data!.id); const cancelled = await cancelReservation({ reservationKey: key }); expect(cancelled.success).toBe(true); expect(cancelled.data?.status).toBe("cancelled"); expect((await findByKey(key))?.status).toBe("cancelled"); }); it("maps an unknown reservation id to a not-found error", async () => { authSpy.mockResolvedValue({ user: commandUser }); const missing = await cancelReservation({ reservationId: 999_999_999 }); expect(missing.success).toBe(false); expect(missing.error).toContain("not found"); }); it("denies cancel for a user without operation-reservations:update", async () => { authSpy.mockResolvedValue({ user: commandUser }); const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]); const created = await createReservation({ reservationKey: `${RUN}-cancel-denied`, operationId: `${RUN}-op-cancel-denied`, actorId: commandUser.id, cargo: [{ resourceId: resourceAId, amount: 10 }], originId, }); reservationIds.push(created.data!.id); authSpy.mockResolvedValue({ user: plainUser }); const denied = await cancelReservation({ reservationId: created.data!.id }); expect(denied.success).toBe(false); expect(denied.error).toContain("forbidden"); }); }); });