From 679a0f787968bdaeb8dd7686c18aa070eb1d84b4 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 21 Sep 2026 08:19:33 -0400 Subject: [PATCH] feat(logistics): supply box management surface --- .../logistics/supply-boxes/[id]/page.tsx | 281 ++++++++++++++++++ .../logistics/supply-boxes/actions.ts | 124 ++++++++ .../logistics/supply-boxes/page.tsx | 212 +++++++++++++ src/components/frontend/blocks/sidebarData.ts | 5 + .../frontend/logistics/SupplyBoxActions.tsx | 207 +++++++++++++ tests/e2e/supply-boxes.e2e.spec.ts | 31 ++ tests/int/supply-box-actions.int.spec.ts | 226 ++++++++++++++ 7 files changed, 1086 insertions(+) create mode 100644 src/app/(frontend)/logistics/supply-boxes/[id]/page.tsx create mode 100644 src/app/(frontend)/logistics/supply-boxes/actions.ts create mode 100644 src/app/(frontend)/logistics/supply-boxes/page.tsx create mode 100644 src/components/frontend/logistics/SupplyBoxActions.tsx create mode 100644 tests/e2e/supply-boxes.e2e.spec.ts create mode 100644 tests/int/supply-box-actions.int.spec.ts diff --git a/src/app/(frontend)/logistics/supply-boxes/[id]/page.tsx b/src/app/(frontend)/logistics/supply-boxes/[id]/page.tsx new file mode 100644 index 0000000..02db3db --- /dev/null +++ b/src/app/(frontend)/logistics/supply-boxes/[id]/page.tsx @@ -0,0 +1,281 @@ +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 { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; +import { + LedgerRow, + NoticeState, + PageShell, + ProvenanceChip, + STATUS_TONE_CLASSES, + type ReadoutCell, + type StatusTone, +} from "@/components/frontend/operations"; +import { SupplyBoxActions } from "@/components/frontend/logistics/SupplyBoxActions"; +import type { ArmaCommand, SupplyBox } from "@/payload-types"; +import type { SurfaceUser } from "@/lib/operations/surface"; + +type PayloadType = Awaited>; + +export const metadata = { + title: "Supply Box Detail: Polaris Task Force", +}; + +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 locationFromJson(value: unknown): { x: number; y: number; z: number } | null { + if (typeof value !== "object" || value === null) return null; + const { x, y, z } = value as { x?: unknown; y?: unknown; z?: unknown }; + if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") return null; + return { x, y, z }; +} + +function locationLabel(value: unknown): string { + const loc = locationFromJson(value); + return loc === null ? "n/a" : `${loc.x}, ${loc.y}, ${loc.z}`; +} + +function commandTone(status: string): StatusTone { + if (status === "succeeded") return "success"; + if (status === "failed") return "danger"; + if (status === "delivered") return "info"; + return "neutral"; +} + +export interface SupplyBoxDetailData { + box: SupplyBox | null; + capability: boolean; + commands: ArmaCommand[]; +} + +/** + * Full detail for a single supply box plus its recent queued commands, 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. + */ +export async function getSupplyBoxDetail( + payload: PayloadType, + user: SurfaceUser, + id: string, +): Promise { + const qualified = await hasLogisticsQualification(payload, user); + if (!qualified) return { box: null, capability: false, commands: [] }; + + let box: SupplyBox | null = null; + try { + box = await payload.findByID({ + collection: "supply-boxes", + id: Number(id), + depth: 2, + user: user ?? undefined, + }); + } catch { + box = null; + } + if (box === null) return { box: null, capability: true, commands: [] }; + + const serverId = relationId(box.server); + const commands = + serverId === null + ? [] + : ( + await payload.find({ + collection: "arma-commands", + where: { server: { equals: serverId } }, + sort: "-createdAt", + limit: 50, + depth: 0, + user: user ?? undefined, + }) + ).docs.filter((cmd) => Array.isArray(cmd.payload) && cmd.payload[0] === box.boxId); + + return { box, capability: true, commands }; +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function kvRow(label: string, value: React.ReactNode) { + return ( +
+ {label} + {value} +
+ ); +} + +export default async function SupplyBoxDetailPage({ + 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 { box, capability, commands } = await getSupplyBoxDetail(payload, surfaceUser, id); + + if (capability === false) { + return ( +
+ + + +
+ ); + } + + if (box === null) { + return ( +
+ + + Back to supply boxes + + } + /> + +
+ ); + } + + const contents = Array.isArray(box.contents) ? box.contents : []; + const itemCount = contents.reduce((sum, line) => { + if (typeof line !== "object" || line === null) return sum; + const count = (line as { count?: unknown }).count; + return sum + (typeof count === "number" ? count : 0); + }, 0); + const sourceEventId = relationId(box.sourceEvent); + + const readoutCells: ReadoutCell[] = [ + { label: "Class", value: box.boxClass ?? "n/a" }, + { label: "Status", value: box.status }, + { label: "Items", value: String(itemCount) }, + { label: "Last Sync", value: box.lastSyncedAt }, + ]; + + return ( +
+ + + Back to supply boxes + + + + + {sourceEventId !== null ? ( + + ) : null} + +
+ {contents.length === 0 ? ( + + ) : ( +
    + {contents.map((line, index) => ( +
  • + + {(line as { class?: unknown }).class as string} + + + {(line as { count?: unknown }).count as number} + +
  • + ))} +
+ )} +
+ +
+
+ {kvRow("Position", locationLabel(box.location))} + {kvRow("Session", box.sessionId)} +
+
+ +
+ ({ + class: (line as { class?: unknown }).class as string, + count: (line as { count?: unknown }).count as number, + }))} + /> +
+ +
+ {commands.length === 0 ? ( + + ) : ( +
    + {commands.map((cmd) => ( +
  • + {cmd.type} + + + {cmd.status} + + {cmd.error ? ( + + {cmd.error} + + ) : null} + +
  • + ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(frontend)/logistics/supply-boxes/actions.ts b/src/app/(frontend)/logistics/supply-boxes/actions.ts new file mode 100644 index 0000000..ae1d482 --- /dev/null +++ b/src/app/(frontend)/logistics/supply-boxes/actions.ts @@ -0,0 +1,124 @@ +"use server"; + +import { randomBytes } from "crypto"; +import config from "@payload-config"; +import { getPayload } from "payload"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; + +export interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +export type CrateCommand = "spawn" | "apply" | "despawn"; + +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 }; +} + +function relId(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; +} + +/** + * Validates the crate command payload against the crate-sync contract + * (docs/arma-bridge/crate-sync.md). The command envelope is built web-side by + * the pull route; the action only stores the row, so the payload must already + * match the shape the Arma bridge expects. + */ +function validateCratePayload(command: CrateCommand, payloadArgs: unknown[]): boolean { + if (command === "spawn") { + if (payloadArgs.length !== 4) return false; + const [boxClass, x, y, z] = payloadArgs; + return ( + typeof boxClass === "string" && + typeof x === "number" && + typeof y === "number" && + typeof z === "number" + ); + } + if (command === "apply") { + if (payloadArgs.length !== 1) return false; + const contents = payloadArgs[0]; + if (!Array.isArray(contents)) return false; + return contents.every( + (line) => + Array.isArray(line) && + line.length === 2 && + typeof line[0] === "string" && + typeof line[1] === "number", + ); + } + // despawn + return payloadArgs.length === 0; +} + +/** + * Queues an outbound crate command for a supply box. The command row is + * created with overrideAccess because arma-commands denies direct writes; the + * pull route picks it up and builds the [commandId, commandType, payload] + * envelope for the game server. + */ +export async function queueCrateCommand( + boxId: string, + command: CrateCommand, + payloadArgs: unknown[], +): Promise> { + try { + const { payload, user } = await authenticate(); + if (!user) return { success: false, error: "Authentication required." }; + + const qualified = await hasLogisticsQualification(payload, user); + if (!qualified) return { success: false, error: "forbidden" }; + + if (!validateCratePayload(command, payloadArgs)) { + return { success: false, error: `Invalid payload for crate.${command}.` }; + } + + const found = await payload.find({ + collection: "supply-boxes", + where: { boxId: { equals: boxId } }, + limit: 1, + depth: 0, + user: user ?? undefined, + }); + const box = found.docs[0]; + if (!box) return { success: false, error: "Supply box not found." }; + + const serverId = relId(box.server); + if (serverId === null) { + return { success: false, error: "Supply box has no owning server." }; + } + + const commandId = `crate-${randomBytes(12).toString("hex")}`; + const created = await payload.create({ + collection: "arma-commands", + data: { + commandId, + server: serverId, + type: `crate.${command}`, + payload: [boxId, ...payloadArgs], + status: "queued", + }, + overrideAccess: true, + depth: 0, + }); + + return { success: true, data: { commandId: created.commandId, status: created.status } }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} \ No newline at end of file diff --git a/src/app/(frontend)/logistics/supply-boxes/page.tsx b/src/app/(frontend)/logistics/supply-boxes/page.tsx new file mode 100644 index 0000000..4b5aa1c --- /dev/null +++ b/src/app/(frontend)/logistics/supply-boxes/page.tsx @@ -0,0 +1,212 @@ +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 { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; +import { + LedgerRow, + NoticeState, + OperationsRealtimeRefresher, + PageShell, + STATUS_TONE_CLASSES, + type ReadoutCell, + type StatusTone, +} from "@/components/frontend/operations"; +import type { SurfaceUser } from "@/lib/operations/surface"; + +type PayloadType = Awaited>; + +export const metadata = { + title: "Supply Boxes: Polaris Task Force", +}; + +const SUPPLY_BOX_PAGE_SIZE = 50; + +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 boxStatusTone(status: string): StatusTone { + if (status === "active") return "success"; + if (status === "deleted") return "danger"; + return "neutral"; +} + +function syncTone(iso: string): StatusTone { + const ageMs = Date.now() - new Date(iso).getTime(); + if (ageMs < 2 * 60_000) return "success"; + if (ageMs < 10 * 60_000) return "warning"; + return "danger"; +} + +function syncAgeLabel(iso: string): string { + const ageMs = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(ageMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +function boxItemCount(contents: unknown): number { + if (!Array.isArray(contents)) return 0; + return contents.reduce((sum, line) => { + if (typeof line !== "object" || line === null) return sum; + const count = (line as { count?: unknown }).count; + return sum + (typeof count === "number" ? count : 0); + }, 0); +} + +function boxLocationLabel(location: unknown): string { + if (typeof location !== "object" || location === null) return "n/a"; + const { x, y, z } = location as { x?: unknown; y?: unknown; z?: unknown }; + if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") return "n/a"; + return `${x}, ${y}, ${z}`; +} + +export interface SupplyBoxRow { + id: number; + boxId: string; + boxClass: string | null; + itemCount: number; + locationLabel: string; + lastSyncedAt: string; + status: string; + serverName: string | null; +} + +export interface SupplyBoxListData { + rows: SupplyBoxRow[]; + capability: boolean; +} + +/** + * List of supply boxes, fetched through collection access control (default + * access + the explicit session user, never overrideAccess). A server restart + * mints a fresh sessionId, so rows from older generations are stale snapshots; + * only the newest sessionId per server is kept. + */ +export async function getSupplyBoxList( + payload: PayloadType, + user: SurfaceUser, +): Promise { + const qualified = await hasLogisticsQualification(payload, user); + if (!qualified) return { rows: [], capability: false }; + + const found = await payload.find({ + collection: "supply-boxes", + sort: "-lastSyncedAt", + limit: SUPPLY_BOX_PAGE_SIZE, + depth: 1, + user: user ?? undefined, + }); + + const newestSessionByServer = new Map(); + for (const doc of found.docs) { + const serverId = relationId(doc.server); + if (serverId === null) continue; + const current = newestSessionByServer.get(serverId); + if (current === undefined || doc.sessionId > current) { + newestSessionByServer.set(serverId, doc.sessionId); + } + } + + const rows: SupplyBoxRow[] = found.docs + .filter((doc) => { + const serverId = relationId(doc.server); + return serverId !== null && newestSessionByServer.get(serverId) === doc.sessionId; + }) + .map((doc) => ({ + id: doc.id, + boxId: doc.boxId, + boxClass: doc.boxClass ?? null, + itemCount: boxItemCount(doc.contents), + locationLabel: boxLocationLabel(doc.location), + lastSyncedAt: doc.lastSyncedAt, + status: doc.status, + serverName: typeof doc.server === "object" && doc.server !== null ? doc.server.name : null, + })); + + return { rows, capability: true }; +} + +export default async function SupplyBoxesPage() { + 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 data = await getSupplyBoxList(payload, surfaceUser); + + return ( +
+ +

+ Crate inventory synced from the game server through the Arma bridge. + Rows show the newest generation per server; older sessions are filtered out. +

+
+ + + + {data.capability === false ? ( + + ) : data.rows.length === 0 ? ( + + ) : ( +
+ {data.rows.map((row) => { + const cells: ReadoutCell[] = [ + { label: "Class", value: row.boxClass ?? "n/a" }, + { label: "Items", value: String(row.itemCount) }, + { label: "Location", value: row.locationLabel }, + { label: "Sync", value: syncAgeLabel(row.lastSyncedAt) }, + { label: "Server", value: row.serverName ?? "n/a" }, + ]; + return ( +
+
+ + {row.boxId} + + + {row.status} + +
+ +
+ ); + })} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/frontend/blocks/sidebarData.ts b/src/components/frontend/blocks/sidebarData.ts index d44f587..195e32d 100644 --- a/src/components/frontend/blocks/sidebarData.ts +++ b/src/components/frontend/blocks/sidebarData.ts @@ -123,6 +123,11 @@ export const sidebarData = { url: "/logistics/game-vehicles", icon: Car, }, + { + title: "Supply Boxes", + url: "/logistics/supply-boxes", + icon: Boxes, + }, ], }, { diff --git a/src/components/frontend/logistics/SupplyBoxActions.tsx b/src/components/frontend/logistics/SupplyBoxActions.tsx new file mode 100644 index 0000000..e2393a0 --- /dev/null +++ b/src/components/frontend/logistics/SupplyBoxActions.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + queueCrateCommand, + type ActionResult, + type CrateCommand, +} from "@/app/(frontend)/logistics/supply-boxes/actions"; + +export interface SupplyBoxContentLine { + class: string; + count: number; +} + +interface SupplyBoxActionsProps { + boxId: string; + boxClass: string | null; + location: { x: number; y: number; z: number } | null; + contents: SupplyBoxContentLine[]; +} + +type PendingAction = CrateCommand | null; + +export function SupplyBoxActions({ boxId, boxClass, location, contents }: SupplyBoxActionsProps) { + const [lines, setLines] = useState(contents); + const [confirm, setConfirm] = useState(null); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + + function updateLine(index: number, field: "class" | "count", value: string) { + setLines((prev) => + prev.map((line, i) => + i === index + ? field === "class" + ? { ...line, class: value } + : { ...line, count: Number.parseInt(value, 10) || 0 } + : line, + ), + ); + } + + function addLine() { + setLines((prev) => [...prev, { class: "", count: 1 }]); + } + + function removeLine(index: number) { + setLines((prev) => prev.filter((_, i) => i !== index)); + } + + function openConfirm(action: Exclude) { + setError(null); + setConfirm(action); + } + + async function dispatch() { + if (confirm === null) return; + setError(null); + let result: ActionResult<{ commandId: string; status: string }>; + if (confirm === "apply") { + const clean = lines.filter((line) => line.class.trim() !== "" && line.count > 0); + result = await queueCrateCommand( + boxId, + "apply", + [clean.map((line) => [line.class.trim(), line.count])], + ); + } else if (confirm === "despawn") { + result = await queueCrateCommand(boxId, "despawn", []); + } else { + if (boxClass === null || location === null) return; + result = await queueCrateCommand(boxId, "spawn", [boxClass, location.x, location.y, location.z]); + } + if (!result.success) { + setError(result.error ?? "Failed to queue command."); + return; + } + setConfirm(null); + router.refresh(); + } + + const applyLineCount = lines.filter((line) => line.class.trim() !== "" && line.count > 0).length; + + return ( +
+
+ +
+ {lines.map((line, index) => ( +
+ updateLine(index, "class", e.target.value)} + placeholder="itemClass" + className="min-w-0 flex-1 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80 placeholder:text-white/30" + /> + updateLine(index, "count", e.target.value)} + type="number" + min={1} + className="w-20 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80" + /> + +
+ ))} +
+ +
+ +
+ + + +
+ + {error ? ( +

+ {error} +

+ ) : null} + + { + if (!open) setConfirm(null); + }} + > + + + Confirm crate command + + {confirm === "apply" + ? `Queue crate.apply with ${applyLineCount} item line${applyLineCount === 1 ? "" : "s"} to the game server?` + : confirm === "despawn" + ? "Remove this crate from the game server? The stored row stays as a deleted snapshot." + : "Spawn this crate at its last known location?"} + + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/tests/e2e/supply-boxes.e2e.spec.ts b/tests/e2e/supply-boxes.e2e.spec.ts new file mode 100644 index 0000000..23df8b2 --- /dev/null +++ b/tests/e2e/supply-boxes.e2e.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; + +/** + * Supply box management surface. + * + * NOTE: these tests exercise the real dev server and are run in the final + * verification wave (F3) under the dev-server protocol, not on every change. + * The list is server-gated: guests get the landing page; only logged-in users + * see the supply box list (logistics-qualified users additionally get the + * crate command controls on the detail page). + */ +test.describe("Supply box management surface", () => { + test("guests never see the supply box list", async ({ page }) => { + await page.goto("/logistics/supply-boxes"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Polaris Task Force"); + }); + + test("an authed user sees the supply box list page", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("dev"); + await page.getByLabel("Password").fill("Test123"); + await page.getByRole("button", { name: "Log in" }).click(); + + await page.goto("/logistics/supply-boxes"); + await expect(page.getByText("Supply Boxes")).toBeVisible(); + // Either a registered box row or the empty state renders. + await expect( + page.getByText("No supply boxes have been registered yet."), + ).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/tests/int/supply-box-actions.int.spec.ts b/tests/int/supply-box-actions.int.spec.ts new file mode 100644 index 0000000..8bea280 --- /dev/null +++ b/tests/int/supply-box-actions.int.spec.ts @@ -0,0 +1,226 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; +import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest"; +import type { ArmaCommand, SupplyBox, User } from "@/payload-types"; +import { queueCrateCommand } from "@/app/(frontend)/logistics/supply-boxes/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 = `sbx-${Date.now().toString(36)}`; + +// --------------------------------------------------------------------------- +// Fixture tracking for cleanup +// --------------------------------------------------------------------------- +const userIds: number[] = []; +const roleIds: number[] = []; +const syncEventIds: number[] = []; +const boxIds: number[] = []; +const commandIds: number[] = []; +let serverId: number; + +let commandUser: User; +let plainUser: User; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +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; +} + +// Creates a supply box directly (the crate-sync handler is covered by +// supply-box-sync.int.spec.ts) with a real arma-sync-events row as the +// required sourceEvent provenance anchor. +async function makeBox(boxId: string): Promise { + const raw = await payload.create({ + collection: "arma-sync-events", + data: { + eventId: `${RUN}-evt-${boxId}`, + server: serverId, + type: "fixture", + occurredAt: new Date().toISOString(), + payload: [], + }, + overrideAccess: true, + depth: 0, + }); + syncEventIds.push(raw.id); + const box = (await payload.create({ + collection: "supply-boxes", + data: { + boxId, + sessionId: RUN, + server: serverId, + boxClass: "Box_NATO_Wps_F", + displayName: "Ammo Crate", + location: { x: 100, y: 200, z: 0 }, + contents: [{ class: "Item_1", count: 10 }], + lastSyncedAt: new Date().toISOString(), + status: "active", + sourceEvent: raw.id, + }, + overrideAccess: true, + depth: 0, + })) as unknown as SupplyBox; + boxIds.push(box.id); + return box; +} + +async function findCommand(commandId: string): Promise { + const found = await payload.find({ + collection: "arma-commands", + where: { commandId: { equals: commandId } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + return (found.docs[0] as ArmaCommand | undefined) ?? null; +} + +// --------------------------------------------------------------------------- +// 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"); + + const server = await payload.create({ + collection: "game-servers", + data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" }, + overrideAccess: true, + depth: 0, + }); + serverId = server.id; +}); + +afterAll(async () => { + if (!payload) return; + for (const id of commandIds) { + await payload.delete({ collection: "arma-commands", id, overrideAccess: true }).catch(() => {}); + } + for (const id of boxIds) { + await payload.delete({ collection: "supply-boxes", id, overrideAccess: true }).catch(() => {}); + } + for (const id of syncEventIds) { + await payload.delete({ collection: "arma-sync-events", 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(() => {}); + } + if (serverId) { + await payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }).catch(() => {}); + } +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe("supply box crate command actions", () => { + describe("queueCrateCommand", () => { + it("queues a crate.apply command with the contract payload for a qualified user", async () => { + authSpy.mockResolvedValue({ user: commandUser }); + const box = await makeBox(`box:${RUN}:apply`); + + const result = await queueCrateCommand(box.boxId, "apply", [ + [ + ["Item_1", 10], + ["Item_2", 5], + ], + ]); + + expect(result.success).toBe(true); + expect(result.data?.status).toBe("queued"); + expect(result.data?.commandId).toMatch(/^crate-[0-9a-f]{24}$/); + + const cmd = await findCommand(result.data!.commandId); + expect(cmd).not.toBeNull(); + expect(cmd?.type).toBe("crate.apply"); + expect(cmd?.server).toBe(serverId); + expect(cmd?.status).toBe("queued"); + expect(cmd?.payload).toEqual([ + box.boxId, + [ + ["Item_1", 10], + ["Item_2", 5], + ], + ]); + commandIds.push(cmd!.id); + }); + + it("rejects a user without the logistics qualification with a forbidden error", async () => { + authSpy.mockResolvedValue({ user: plainUser }); + const box = await makeBox(`box:${RUN}:forbidden`); + + const result = await queueCrateCommand(box.boxId, "despawn", []); + + expect(result.success).toBe(false); + expect(result.error).toContain("forbidden"); + }); + + it("maps an unknown box id to a not-found error", async () => { + authSpy.mockResolvedValue({ user: commandUser }); + + const result = await queueCrateCommand(`box:${RUN}:missing`, "despawn", []); + + expect(result.success).toBe(false); + expect(result.error).toContain("not found"); + }); + + it("mints a unique command id per queue", async () => { + authSpy.mockResolvedValue({ user: commandUser }); + const box = await makeBox(`box:${RUN}:unique`); + + const first = await queueCrateCommand(box.boxId, "despawn", []); + const second = await queueCrateCommand(box.boxId, "despawn", []); + + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(first.data?.commandId).not.toBe(second.data?.commandId); + + const firstCmd = await findCommand(first.data!.commandId); + const secondCmd = await findCommand(second.data!.commandId); + expect(firstCmd).not.toBeNull(); + expect(secondCmd).not.toBeNull(); + expect(firstCmd?.id).not.toBe(secondCmd?.id); + expect(firstCmd?.payload).toEqual([box.boxId]); + expect(secondCmd?.payload).toEqual([box.boxId]); + commandIds.push(firstCmd!.id, secondCmd!.id); + }); + }); +}); \ No newline at end of file