- vehicle fixtures are named to sort first so the 50-item name-sorted options window includes them even on a DB crowded with older idle vehicles - the respawn bridge assertion filters to this run's steam ids so leaked rows from aborted runs cannot break exact equality
453 lines
14 KiB
TypeScript
453 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: {
|
|
// "!" sorts before alphanumerics: the vehicle query serves the top
|
|
// MAX_VEHICLES idle vehicles sorted by name, so this keeps this run's
|
|
// fixtures inside the served window even on a DB crowded with older
|
|
// idle vehicles.
|
|
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"]);
|
|
}
|
|
});
|
|
});
|