585 lines
20 KiB
TypeScript
585 lines
20 KiB
TypeScript
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<OperationReservation | null> {
|
|
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<User> {
|
|
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<number> {
|
|
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<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 makeDestination(): Promise<number> {
|
|
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<number> {
|
|
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<BankAccount> {
|
|
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");
|
|
});
|
|
});
|
|
});
|