From f895f7531709ad2ed67ff16b408571e15ab69cf4 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 21 Sep 2026 07:46:27 -0400 Subject: [PATCH] feat(operations): reservation actions, surfaces, and cancel flow --- .../operations/reservations/[id]/page.tsx | 251 ++++++++ .../operations/reservations/actions.ts | 85 +++ src/components/frontend/blocks/sidebarData.ts | 6 + .../reservations/ReservationCancelButton.tsx | 47 ++ .../operation-reservation-actions.int.spec.ts | 585 ++++++++++++++++++ ...operation-reservation-surfaces.int.spec.ts | 201 ++++++ 6 files changed, 1175 insertions(+) create mode 100644 src/app/(frontend)/operations/reservations/[id]/page.tsx create mode 100644 src/app/(frontend)/operations/reservations/actions.ts create mode 100644 src/components/frontend/operations/reservations/ReservationCancelButton.tsx create mode 100644 tests/int/operation-reservation-actions.int.spec.ts create mode 100644 tests/int/operation-reservation-surfaces.int.spec.ts diff --git a/src/app/(frontend)/operations/reservations/[id]/page.tsx b/src/app/(frontend)/operations/reservations/[id]/page.tsx new file mode 100644 index 0000000..69cce7d --- /dev/null +++ b/src/app/(frontend)/operations/reservations/[id]/page.tsx @@ -0,0 +1,251 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import Link from "next/link"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { LedgerRow, NoticeState, PageShell } from "@/components/frontend/operations"; +import type { SurfaceUser } from "@/lib/operations/surface"; +import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton"; +import type { OperationReservation } from "@/payload-types"; + +type PayloadType = Awaited>; + +function relationId(value: unknown): number | null { + if (typeof value === "number") return value; + if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) { + const id = (value as { id: unknown }).id; + return typeof id === "number" ? id : null; + } + return null; +} + +function relationLabel(value: unknown): string { + if (typeof value === "object" && value !== null && "codeName" in (value as { codeName?: unknown })) { + const codeName = (value as { codeName?: unknown }).codeName; + if (typeof codeName === "string" && codeName) return codeName; + } + return String(relationId(value) ?? "n/a"); +} + +interface ReservationDetail { + reservation: OperationReservation | null; + canCancel: boolean; +} + +/** + * Full detail for a single reservation, fetched through collection access + * control (default access + the explicit session user, never overrideAccess). + * Returns null for an unknown id so the page can render a not-found notice. + * canCancel is true only when the row is still reserved AND the viewer holds + * operation-reservations:update. + */ +export async function getReservationDetail( + payload: PayloadType, + user: SurfaceUser, + id: string, +): Promise { + let reservation: OperationReservation | null = null; + try { + reservation = await payload.findByID({ + collection: "operation-reservations", + id: Number(id), + depth: 1, + user: user ?? undefined, + }); + } catch { + reservation = null; + } + + const canCancel = + reservation !== null && + reservation.status === "reserved" && + user !== null && + (await hasPermission(payload, user, "operation-reservations:update")); + + return { reservation, canCancel }; +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function kvRow(label: string, value: React.ReactNode) { + return ( +
+ {label} + {value} +
+ ); +} + +export const metadata = { + title: "Reservation Detail: Polaris Task Force", +}; + +export default async function ReservationDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const payload = await getPayload({ config }); + const { user: authUser } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + const user = isPayloadUser(authUser) ? authUser : null; + const surfaceUser: SurfaceUser = user ? { id: user.id } : null; + + const { reservation, canCancel } = await getReservationDetail(payload, surfaceUser, id); + + if (reservation === null) { + return ( +
+ + + Back to reservations + + } + /> + +
+ ); + } + + const personnel = reservation.personnel ?? []; + const vehicles = reservation.vehicles ?? []; + const cargo = reservation.cargo ?? []; + const budget = reservation.budget; + + const readoutCells = [ + { label: "Key", value: reservation.reservationKey }, + { label: "Operation", value: reservation.operationId }, + { label: "Status", value: reservation.status }, + { label: "Expires", value: reservation.expiresAt ?? "n/a" }, + ]; + + return ( +
+ + + Back to reservations + + + + +
+ {personnel.length === 0 ? ( + + ) : ( +
    + {personnel.map((member) => ( +
  • + + {relationLabel(member.user)} · id {relationId(member.user)} + + + {member.slot ?? "slot"} + +
  • + ))} +
+ )} +
+ +
+ {vehicles.length === 0 ? ( + + ) : ( +
    + {vehicles.map((vehicle) => ( +
  • + + {relationLabel(vehicle.vehicle)} · id {relationId(vehicle.vehicle)} + +
  • + ))} +
+ )} +
+ +
+ {cargo.length === 0 ? ( + + ) : ( +
    + {cargo.map((line) => ( +
  • + + {relationLabel(line.resource)} · id {relationId(line.resource)} + + {line.amount} +
  • + ))} +
+ )} +
+ +
+ {budget?.amount != null || budget?.account != null ? ( +
+ {kvRow("Account", budget.account != null ? `${relationLabel(budget.account)} · id ${relationId(budget.account)}` : "n/a")} + {kvRow("Amount", budget.amount ?? "n/a")} +
+ ) : ( + + )} +
+ +
+
+ {kvRow("Origin", reservation.origin != null ? relationLabel(reservation.origin) : "n/a")} + {kvRow("Destination", reservation.destination != null ? relationLabel(reservation.destination) : "n/a")} +
+
+ +
+
+ {kvRow("Created by", reservation.createdBy != null ? relationLabel(reservation.createdBy) : "n/a")} + {kvRow("Created at", reservation.createdAt)} + {kvRow("Expires", reservation.expiresAt ?? "n/a")} +
+
+ + {reservation.status !== "reserved" && ( +
+
+              {JSON.stringify(reservation.settlement, null, 2)}
+            
+
+ )} + + {canCancel && reservation.status === "reserved" ? ( + + ) : null} +
+
+ ); +} diff --git a/src/app/(frontend)/operations/reservations/actions.ts b/src/app/(frontend)/operations/reservations/actions.ts new file mode 100644 index 0000000..69e7157 --- /dev/null +++ b/src/app/(frontend)/operations/reservations/actions.ts @@ -0,0 +1,85 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import type { OperationReservation } from "@/payload-types"; +import { + reserveOperationAssets, + settleOperationReservation, + type ReserveOperationAssetsInput, +} from "@/lib/operations/allocation"; + +export interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +async function authenticate() { + const { headers } = await import("next/headers"); + const payload = await getPayload({ config }); + const { user: authUser } = await payload.auth({ + headers: await headers(), + canSetHeaders: false, + }); + return { payload, user: isPayloadUser(authUser) ? authUser : null }; +} + +/** + * Reserve people, vehicles, cargo, and treasury budget for an operation. + * + * Anti-spoofing: the actor is ALWAYS the authenticated session user. The + * client-supplied `actorId` is overwritten with `user.id` before the input + * reaches the allocation engine, so a caller cannot attribute a reservation + * to (or impersonate) another account. + */ +export async function createReservation( + input: ReserveOperationAssetsInput, +): Promise> { + try { + const { payload, user } = await authenticate(); + if (!user) return { success: false, error: "Authentication required." }; + if (!(await hasPermission(payload, user, "operation-reservations:create"))) { + return { success: false, error: "forbidden" }; + } + + const sanitizedInput: ReserveOperationAssetsInput = { + ...input, + actorId: user.id, + }; + + const row = await reserveOperationAssets(payload, sanitizedInput); + return { success: true, data: row }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +/** + * Cancel a still-reserved reservation. Settlement is exactly-once: a second + * cancel (or any settle on an already-terminal row) is a clean no-op that + * returns the stored row, and an unknown ref maps to a "not found" error. + */ +export async function cancelReservation( + ref: { reservationId?: number; reservationKey?: string }, + opts?: { reason?: string }, +): Promise> { + try { + const { payload, user } = await authenticate(); + if (!user) return { success: false, error: "Authentication required." }; + if (!(await hasPermission(payload, user, "operation-reservations:update"))) { + return { success: false, error: "forbidden" }; + } + + const row = await settleOperationReservation(payload, ref, { + outcome: "cancelled", + reason: opts?.reason, + actorId: user.id, + }); + return { success: true, data: row }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} diff --git a/src/components/frontend/blocks/sidebarData.ts b/src/components/frontend/blocks/sidebarData.ts index 0c9443f..d44f587 100644 --- a/src/components/frontend/blocks/sidebarData.ts +++ b/src/components/frontend/blocks/sidebarData.ts @@ -5,6 +5,7 @@ import { BookOpen, Boxes, Building2, + ClipboardList, Car, Contact, Crosshair, @@ -78,6 +79,11 @@ export const sidebarData = { url: "/operations/ledger", icon: ScrollText, }, + { + title: "Reservations", + url: "/operations/reservations", + icon: ClipboardList, + }, ], navLogistics: [ { diff --git a/src/components/frontend/operations/reservations/ReservationCancelButton.tsx b/src/components/frontend/operations/reservations/ReservationCancelButton.tsx new file mode 100644 index 0000000..3dba238 --- /dev/null +++ b/src/components/frontend/operations/reservations/ReservationCancelButton.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { cancelReservation, type ActionResult } from "@/app/(frontend)/operations/reservations/actions"; + +/** + * Client-side cancel control for a still-reserved reservation. Dispatches + * cancelReservation (a server action) inside a transition, surfaces the + * ActionResult error verbatim inline, and refreshes the page on success so + * the terminal status renders. Disabled while the cancel is in flight. + */ +export function CancelButton({ reservationId }: { reservationId: number }) { + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const router = useRouter(); + + async function onCancel() { + setError(null); + const result: ActionResult = + await cancelReservation({ reservationId }, { reason: "Cancelled by user" }); + if (!result.success) { + setError(result.error ?? "Failed to cancel reservation."); + return; + } + router.refresh(); + } + + return ( +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/tests/int/operation-reservation-actions.int.spec.ts b/tests/int/operation-reservation-actions.int.spec.ts new file mode 100644 index 0000000..94d1209 --- /dev/null +++ b/tests/int/operation-reservation-actions.int.spec.ts @@ -0,0 +1,585 @@ +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"); + }); + }); +}); diff --git a/tests/int/operation-reservation-surfaces.int.spec.ts b/tests/int/operation-reservation-surfaces.int.spec.ts new file mode 100644 index 0000000..018f21f --- /dev/null +++ b/tests/int/operation-reservation-surfaces.int.spec.ts @@ -0,0 +1,201 @@ +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"); + }); +});