Add the create-reservation dialog and its option sources for todo 3 of ops-reservations-and-crate-sync. - src/lib/operations/reservationOptions.ts: server-only option pool (listReservationOptions) returning upcoming unit-visible missions, idle unreserved vehicles, operational structures, budget accounts (no balances), per-origin stock, and a capped roster. - src/components/frontend/operations/reservations/CreateReservationDialog.tsx: client dialog with core fields, personnel/vehicle multi-picks, cargo line repeater, budget + expiry; client pre-validation mirrors the server validator; engine errors render verbatim. - Mount the dialog on the reservations list page, server-gated by operation-reservations:create + hasLogisticsQualification. - Integration tests for the option scopes; e2e spec written (run in F3). tsc clean; 5/5 integration tests pass.
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type {
|
|
GameStructure,
|
|
GameVehicle,
|
|
OperationReservation,
|
|
Resource,
|
|
Structure,
|
|
User,
|
|
} from "@/payload-types";
|
|
import { listReservationOptions } from "@/lib/operations/reservationOptions";
|
|
import { createAccount } from "@/lib/banking";
|
|
import { storedAmount } from "@/lib/base/storage";
|
|
|
|
let payload: Payload;
|
|
|
|
const RUN = `res-options-${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 commandUser: User;
|
|
let plainUser: User;
|
|
let mapId: number;
|
|
let campaignId: number;
|
|
let resourceAId: number;
|
|
let resourceBId: number;
|
|
let normalBlueprintId: number;
|
|
let vehicleBlueprintId: number;
|
|
|
|
const LEXICAL_EMPTY = {
|
|
root: {
|
|
children: [{ text: "" }],
|
|
direction: null,
|
|
format: "" as const,
|
|
indent: 0,
|
|
type: "text",
|
|
version: 1,
|
|
},
|
|
};
|
|
|
|
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}-${Math.random().toString(36).slice(2, 8)}`,
|
|
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(
|
|
status: "Ready" | "Scheduled" | "Completed" | "Cancelled",
|
|
visibility: "unit" | "leadership",
|
|
): Promise<number> {
|
|
const mission = await payload.create({
|
|
collection: "missions",
|
|
data: {
|
|
name: `${RUN} ${status} ${visibility}`,
|
|
codeName: `${RUN}-${status}-${visibility}`,
|
|
summary: "Options 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,
|
|
visibility,
|
|
},
|
|
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<number> {
|
|
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 makeVehicle(status: "idle" | "assigned"): Promise<number> {
|
|
const vehicle = await payload.create({
|
|
collection: "game-vehicles",
|
|
data: {
|
|
name: `${RUN} vic ${gameVehicleIds.length}`,
|
|
type: vehicleBlueprintId,
|
|
deployedAt: gameStructureIds[0],
|
|
status,
|
|
currentFuel: 800,
|
|
currentHealth: 100,
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
gameVehicleIds.push(vehicle.id);
|
|
return vehicle.id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup / teardown
|
|
// ---------------------------------------------------------------------------
|
|
beforeAll(async () => {
|
|
const payloadConfig = await config;
|
|
payload = await getPayload({ config: payloadConfig });
|
|
|
|
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 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: "Options test campaign",
|
|
status: "concept",
|
|
campaignMode: "custom",
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
campaignId = campaign.id;
|
|
campaignIds.push(campaign.id);
|
|
|
|
const resourceA = 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,
|
|
});
|
|
resourceAId = resourceA.id;
|
|
resourceIds.push(resourceA.id);
|
|
|
|
const resourceB = await payload.create({
|
|
collection: "resources",
|
|
data: {
|
|
name: `${RUN} Beta`,
|
|
codeName: `${RUN}-beta`,
|
|
approvalStatus: "in_progress",
|
|
type: "physical",
|
|
baseValue: 1,
|
|
rarity: "common",
|
|
unitOfMeasure: "kg",
|
|
massPerUnit: 1,
|
|
gridWidth: 1,
|
|
gridHeight: 1,
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
resourceBId = resourceB.id;
|
|
resourceIds.push(resourceB.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(() => {});
|
|
}
|
|
|
|
for (const id of accountIds) {
|
|
const txns = await payload.find({
|
|
collection: "bank-transactions",
|
|
where: { or: [{ fromAccount: { equals: id } }, { toAccount: { equals: id } }] },
|
|
limit: 100,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
for (const txn of txns.docs) {
|
|
await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
await payload.delete({ collection: "bank-accounts", 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(() => {});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
describe("listReservationOptions", () => {
|
|
it("returns only Ready/Scheduled unit-visible missions, sorted by start", async () => {
|
|
const scheduledId = await makeMission("Scheduled", "unit");
|
|
const readyId = await makeMission("Ready", "unit");
|
|
const completedId = await makeMission("Completed", "unit");
|
|
const leadershipId = await makeMission("Scheduled", "leadership");
|
|
|
|
const options = await listReservationOptions(payload, commandUser);
|
|
const ids = options.upcomingMissions.map((m) => m.id);
|
|
|
|
expect(ids).toContain(scheduledId);
|
|
expect(ids).toContain(readyId);
|
|
expect(ids).not.toContain(completedId);
|
|
expect(ids).not.toContain(leadershipId);
|
|
});
|
|
|
|
it("excludes non-idle vehicles and vehicles already reserved", async () => {
|
|
const idleId = await makeVehicle("idle");
|
|
const assignedId = await makeVehicle("assigned");
|
|
const reservedId = await makeVehicle("idle");
|
|
|
|
// Reserve the second idle vehicle via an active (reserved) reservation row.
|
|
const row = await payload.create({
|
|
collection: "operation-reservations",
|
|
data: {
|
|
reservationKey: `${RUN}-reserved-veh`,
|
|
operationId: `${RUN}-op`,
|
|
status: "reserved",
|
|
vehicles: [{ vehicle: reservedId }],
|
|
createdBy: commandUser.id,
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
reservationIds.push(row.id);
|
|
|
|
const options = await listReservationOptions(payload, commandUser);
|
|
const vehicleIds = options.vehicles.map((v) => v.id);
|
|
|
|
expect(vehicleIds).toContain(idleId);
|
|
expect(vehicleIds).not.toContain(assignedId);
|
|
expect(vehicleIds).not.toContain(reservedId);
|
|
});
|
|
|
|
it("reflects storedAmount per origin in stockByOrigin", async () => {
|
|
const originId = await makeOrigin([
|
|
{ resourceId: resourceAId, amount: 100 },
|
|
{ resourceId: resourceBId, amount: 50 },
|
|
]);
|
|
|
|
const options = await listReservationOptions(payload, commandUser);
|
|
const stock = options.stockByOrigin[String(originId)];
|
|
|
|
expect(stock).toHaveLength(2);
|
|
|
|
const origin = (await payload
|
|
.findByID({ collection: "game-structures", id: originId, depth: 0, overrideAccess: true })
|
|
.catch(() => null)) as unknown as (GameStructure & {
|
|
storedResources?: unknown[];
|
|
voidStorage?: unknown[];
|
|
}) | null;
|
|
const entries = [
|
|
...(origin?.storedResources ?? []),
|
|
...(origin?.voidStorage ?? []),
|
|
] as Array<{ resource: { id: number }; amount: number }>;
|
|
|
|
const amountA = storedAmount(entries as never, resourceAId);
|
|
const amountB = storedAmount(entries as never, resourceBId);
|
|
expect(amountA).toBe(100);
|
|
expect(amountB).toBe(50);
|
|
|
|
const lineA = stock!.find((s) => s.resourceId === resourceAId);
|
|
const lineB = stock!.find((s) => s.resourceId === resourceBId);
|
|
expect(lineA?.amount).toBe(amountA);
|
|
expect(lineB?.amount).toBe(amountB);
|
|
expect(lineA?.resourceName).toBeTruthy();
|
|
expect(lineB?.resourceName).toBeTruthy();
|
|
});
|
|
|
|
it("caps the roster pool at 200 users", async () => {
|
|
for (let i = 0; i < 205; i++) {
|
|
await makeUser(`roster${i}`);
|
|
}
|
|
const options = await listReservationOptions(payload, commandUser);
|
|
expect(options.rosterUsers.length).toBe(200);
|
|
// Every entry carries a display name and id; nothing else leaks.
|
|
for (const user of options.rosterUsers) {
|
|
expect(typeof user.id).toBe("number");
|
|
expect(user.displayName.length).toBeGreaterThan(0);
|
|
}
|
|
}, 120000);
|
|
|
|
it("scopes budget accounts to treasury + this user's personal accounts, no balances", async () => {
|
|
const treasury = await createAccount(payload, { name: `${RUN} Treasury`, accountType: "treasury" });
|
|
accountIds.push(treasury.id);
|
|
const commandPersonal = await createAccount(payload, {
|
|
name: `${RUN} Command Personal`,
|
|
accountType: "personal",
|
|
ownerUserId: commandUser.id,
|
|
});
|
|
accountIds.push(commandPersonal.id);
|
|
const plainPersonal = await createAccount(payload, {
|
|
name: `${RUN} Plain Personal`,
|
|
accountType: "personal",
|
|
ownerUserId: plainUser.id,
|
|
});
|
|
accountIds.push(plainPersonal.id);
|
|
|
|
const options = await listReservationOptions(payload, commandUser);
|
|
const ids = options.budgetAccounts.map((a) => a.id);
|
|
|
|
expect(ids).toContain(treasury.id);
|
|
expect(ids).toContain(commandPersonal.id);
|
|
// Another user's personal account is out of scope.
|
|
expect(ids).not.toContain(plainPersonal.id);
|
|
|
|
// Only id, name, and type are exposed - balances never leak.
|
|
for (const account of options.budgetAccounts) {
|
|
const keys = Object.keys(account).sort();
|
|
expect(keys).toEqual(["id", "name", "type"]);
|
|
}
|
|
});
|
|
});
|