Reserve personnel, vehicles, cargo, and budget with idempotent keys and exactly-once settlement; extraction effects preflight before any write so validated HQ deposits and locker credits are final and survive mission failure. Failure sweeps touch only unextracted reservations. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1071 lines
37 KiB
TypeScript
1071 lines
37 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
expireOperationReservations,
|
|
failOperationReservations,
|
|
reserveOperationAssets,
|
|
settleOperationReservation,
|
|
type SettlementRecord,
|
|
} from "@/lib/operations/allocation";
|
|
import { applyTransaction, createAccount, getMainCurrencyId } from "@/lib/banking";
|
|
import type { BankAccount, GameStructure, OperationReservation, Structure } from "@/payload-types";
|
|
|
|
let payload: Payload;
|
|
const RUN = `alloc-${Date.now().toString(36)}`;
|
|
|
|
const LEXICAL_EMPTY = {
|
|
root: {
|
|
children: [{ text: "" }],
|
|
direction: null,
|
|
format: "" as const,
|
|
indent: 0,
|
|
type: "text",
|
|
version: 1,
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture tracking for cleanup
|
|
// ---------------------------------------------------------------------------
|
|
const reservationIds: number[] = [];
|
|
const syncEventIds: number[] = [];
|
|
const opEventIds: number[] = [];
|
|
const opEffectIds: 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 commandUserId: number;
|
|
let plainUserId: number;
|
|
let soldierOneId: number;
|
|
let soldierTwoId: number;
|
|
let mapId: number;
|
|
let campaignId: number;
|
|
let missionId: number;
|
|
let resourceAId: number;
|
|
let resourceBId: number;
|
|
let normalBlueprintId: number;
|
|
let restrictedBlueprintId: number;
|
|
let vehicleBlueprintId: number;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
function relId(value: unknown): number {
|
|
return typeof value === "object" && value !== null
|
|
? (value as { id: number }).id
|
|
: (value as number);
|
|
}
|
|
|
|
function settlementOf(doc: OperationReservation): SettlementRecord {
|
|
return doc.settlement as unknown as SettlementRecord;
|
|
}
|
|
|
|
async function makeUser(label: string, roleDocIds?: number[]): Promise<number> {
|
|
const user = await payload.create({
|
|
collection: "users",
|
|
data: {
|
|
username: `${RUN}-${label}`,
|
|
discordUsername: `${RUN}-${label}`,
|
|
displayName: `${RUN} ${label}`,
|
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
|
password: "Test1234",
|
|
roles: ["user"],
|
|
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
userIds.push(user.id);
|
|
return user.id;
|
|
}
|
|
|
|
async function makeMission(label: string): Promise<number> {
|
|
const mission = await payload.create({
|
|
collection: "missions",
|
|
data: {
|
|
name: `${RUN} ${label}`,
|
|
codeName: `${RUN}-${label}`,
|
|
summary: "Allocation test mission",
|
|
operationType: "main",
|
|
classification: {
|
|
map: mapId,
|
|
missionType: "PvE",
|
|
campaign: campaignId,
|
|
startDateTime: new Date(Date.now() + 86_400_000).toISOString(),
|
|
estimatedDuration: 60,
|
|
},
|
|
ownershipAndStatus: {
|
|
authors: [commandUserId],
|
|
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(restricted = false): Promise<number> {
|
|
const site = await payload.create({
|
|
collection: "game-structures",
|
|
data: {
|
|
name: `${RUN} dest ${gameStructureIds.length}`,
|
|
type: restricted ? restrictedBlueprintId : 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, status: "idle" | "damaged" = "idle"): Promise<number> {
|
|
const vehicle = await payload.create({
|
|
collection: "game-vehicles",
|
|
data: {
|
|
name: `${RUN} vic ${gameVehicleIds.length}`,
|
|
type: vehicleBlueprintId,
|
|
deployedAt: atId,
|
|
status,
|
|
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;
|
|
}
|
|
|
|
async function findByKey(key: string): Promise<OperationReservation | null> {
|
|
const res = await payload.find({
|
|
collection: "operation-reservations",
|
|
where: { reservationKey: { equals: key } },
|
|
limit: 1,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
return (res.docs[0] as OperationReservation | undefined) ?? null;
|
|
}
|
|
|
|
async function storedAmountAt(siteId: number, resourceId: number): Promise<number> {
|
|
const doc = (await payload.findByID({
|
|
collection: "game-structures",
|
|
id: siteId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as unknown as GameStructure;
|
|
return (doc.storedResources ?? [])
|
|
.filter((r) => relId(r.resource) === resourceId)
|
|
.reduce((sum, r) => sum + r.amount, 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup / teardown
|
|
// ---------------------------------------------------------------------------
|
|
beforeAll(async () => {
|
|
const payloadConfig = await config;
|
|
payload = await getPayload({ config: payloadConfig });
|
|
|
|
const currencyId = await getMainCurrencyId(payload);
|
|
if (!currencyId) throw new Error("Test requires a main currency in Game Rules.");
|
|
|
|
const superRole = await payload.create({
|
|
collection: "roles",
|
|
data: { name: `${RUN} Command`, slug: `${RUN}-command`, isSuperuser: true },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
roleIds.push(superRole.id);
|
|
|
|
commandUserId = await makeUser("command", [superRole.id]);
|
|
plainUserId = await makeUser("plain");
|
|
soldierOneId = await makeUser("soldier1");
|
|
soldierTwoId = await makeUser("soldier2");
|
|
|
|
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: "Allocation test campaign",
|
|
status: "concept",
|
|
campaignMode: "custom",
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
campaignId = campaign.id;
|
|
campaignIds.push(campaign.id);
|
|
|
|
missionId = await makeMission("Main");
|
|
|
|
const server = await payload.create({
|
|
collection: "game-servers",
|
|
data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
serverId = server.id;
|
|
|
|
for (const label of ["alpha", "bravo"] as const) {
|
|
const resource = await payload.create({
|
|
collection: "resources",
|
|
data: {
|
|
name: `${RUN} ${label}`,
|
|
codeName: `${RUN}-${label}`,
|
|
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);
|
|
if (label === "alpha") resourceAId = resource.id;
|
|
else resourceBId = 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 restrictedBlueprint = await payload.create({
|
|
collection: "structures",
|
|
data: {
|
|
name: `${RUN} Vault`,
|
|
codeName: `${RUN}-vault`,
|
|
approvalStatus: "in_progress",
|
|
description: LEXICAL_EMPTY as unknown as Structure["description"],
|
|
category: "logistics",
|
|
materials: [{ resource: resourceAId, amount: 1 }],
|
|
constructionDurationMinutes: 1,
|
|
terrainType: "land",
|
|
maxHealth: 100,
|
|
restrictToAllowed: true,
|
|
allowedStorage: [],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
restrictedBlueprintId = restrictedBlueprint.id;
|
|
blueprintIds.push(restrictedBlueprint.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 opEffectIds) {
|
|
await payload.delete({ collection: "operation-effects", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of opEventIds) {
|
|
await payload.delete({ collection: "operation-events", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of syncEventIds) {
|
|
await payload.delete({ collection: "arma-sync-events", 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 userId of userIds) {
|
|
const profiles = await payload.find({
|
|
collection: "profiles",
|
|
where: { user: { equals: userId } },
|
|
limit: 5,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
for (const profile of profiles.docs) {
|
|
await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
const accounts = await payload.find({
|
|
collection: "bank-accounts",
|
|
where: { ownerUser: { equals: userId } },
|
|
limit: 5,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
for (const account of accounts.docs) {
|
|
await deleteAccountChain(account.id);
|
|
}
|
|
await payload.delete({ collection: "users", id: userId, 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("Operation allocation", () => {
|
|
describe("reserveOperationAssets", () => {
|
|
it("reserves personnel, vehicles, cargo, and budget in one record", async () => {
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
|
const destinationId = await makeDestination();
|
|
const vehicleId = await makeVehicle(originId);
|
|
const account = await makeFundedAccount(1000);
|
|
const key = `${RUN}-happy`;
|
|
|
|
const doc = await reserveOperationAssets(payload, {
|
|
reservationKey: key,
|
|
operationId: `${RUN}-op-happy`,
|
|
actorId: commandUserId,
|
|
missionId,
|
|
personnel: [{ userId: soldierOneId, 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(),
|
|
});
|
|
reservationIds.push(doc.id);
|
|
|
|
expect(doc.reservationKey).toBe(key);
|
|
expect(doc.status).toBe("reserved");
|
|
expect(doc.operationId).toBe(`${RUN}-op-happy`);
|
|
expect(relId(doc.mission)).toBe(missionId);
|
|
expect(doc.personnel).toHaveLength(1);
|
|
expect(relId(doc.personnel?.[0]?.user)).toBe(soldierOneId);
|
|
expect(doc.personnel?.[0]?.slot).toBe("Team Lead");
|
|
expect(doc.vehicles).toHaveLength(1);
|
|
expect(relId(doc.vehicles?.[0]?.vehicle)).toBe(vehicleId);
|
|
expect(doc.cargo).toHaveLength(1);
|
|
expect(relId(doc.cargo?.[0]?.resource)).toBe(resourceAId);
|
|
expect(doc.cargo?.[0]?.amount).toBe(40);
|
|
expect(relId(doc.budget?.account)).toBe(account.id);
|
|
expect(doc.budget?.amount).toBe(200);
|
|
expect(relId(doc.origin)).toBe(originId);
|
|
expect(relId(doc.destination)).toBe(destinationId);
|
|
expect(relId(doc.createdBy)).toBe(commandUserId);
|
|
});
|
|
|
|
it("is idempotent for a retry with the same reservation key", async () => {
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 50 }]);
|
|
const key = `${RUN}-retry`;
|
|
const input = {
|
|
reservationKey: key,
|
|
operationId: `${RUN}-op-retry`,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 10 }],
|
|
originId,
|
|
};
|
|
|
|
const first = await reserveOperationAssets(payload, input);
|
|
const second = await reserveOperationAssets(payload, input);
|
|
reservationIds.push(first.id);
|
|
|
|
expect(second.id).toBe(first.id);
|
|
const all = await payload.find({
|
|
collection: "operation-reservations",
|
|
where: { reservationKey: { equals: key } },
|
|
limit: 10,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
expect(all.docs).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects an unauthorized actor without writing anything", async () => {
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
|
const key = `${RUN}-unauthorized`;
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: key,
|
|
operationId: `${RUN}-op-unauthorized`,
|
|
actorId: plainUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 10 }],
|
|
originId,
|
|
}),
|
|
).rejects.toThrow(/not authorized/i);
|
|
|
|
expect(await findByKey(key)).toBeNull();
|
|
});
|
|
|
|
it("fails insufficient cargo without partial writes", async () => {
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
|
const key = `${RUN}-insufficient`;
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: key,
|
|
operationId: `${RUN}-op-insufficient`,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 101 }],
|
|
originId,
|
|
}),
|
|
).rejects.toThrow(/insufficient cargo/i);
|
|
|
|
expect(await findByKey(key)).toBeNull();
|
|
expect(await storedAmountAt(originId, resourceAId)).toBe(100);
|
|
});
|
|
|
|
it("requires an origin structure when cargo is reserved", async () => {
|
|
const key = `${RUN}-no-origin`;
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: key,
|
|
operationId: `${RUN}-op-no-origin`,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 1 }],
|
|
}),
|
|
).rejects.toThrow(/origin/i);
|
|
expect(await findByKey(key)).toBeNull();
|
|
});
|
|
|
|
it("accounts for cargo already reserved by other active reservations", async () => {
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
|
|
|
const first = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-spend-1`,
|
|
operationId: `${RUN}-op-spend`,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 60 }],
|
|
originId,
|
|
});
|
|
reservationIds.push(first.id);
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-spend-2`,
|
|
operationId: `${RUN}-op-spend`,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 50 }],
|
|
originId,
|
|
}),
|
|
).rejects.toThrow(/insufficient cargo/i);
|
|
expect(await findByKey(`${RUN}-spend-2`)).toBeNull();
|
|
|
|
const third = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-spend-3`,
|
|
operationId: `${RUN}-op-spend`,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 40 }],
|
|
originId,
|
|
});
|
|
reservationIds.push(third.id);
|
|
expect(third.status).toBe("reserved");
|
|
});
|
|
|
|
it("enforces destination storage rules at reserve time", async () => {
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
|
const restrictedDestinationId = await makeDestination(true);
|
|
const key = `${RUN}-dest-rules`;
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: key,
|
|
operationId: `${RUN}-op-dest-rules`,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 10 }],
|
|
originId,
|
|
destinationId: restrictedDestinationId,
|
|
}),
|
|
).rejects.toThrow(/allowed storage list/i);
|
|
expect(await findByKey(key)).toBeNull();
|
|
});
|
|
|
|
it("rejects unavailable vehicles", async () => {
|
|
const originId = await makeOrigin([]);
|
|
const otherOriginId = await makeOrigin([]);
|
|
const reservedVehicleId = await makeVehicle(originId);
|
|
const damagedVehicleId = await makeVehicle(originId, "damaged");
|
|
const elsewhereVehicleId = await makeVehicle(otherOriginId);
|
|
|
|
const first = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-vic-1`,
|
|
operationId: `${RUN}-op-vic`,
|
|
actorId: commandUserId,
|
|
vehicleIds: [reservedVehicleId],
|
|
originId,
|
|
});
|
|
reservationIds.push(first.id);
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-vic-2`,
|
|
operationId: `${RUN}-op-vic`,
|
|
actorId: commandUserId,
|
|
vehicleIds: [reservedVehicleId],
|
|
originId,
|
|
}),
|
|
).rejects.toThrow(/already reserved/i);
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-vic-3`,
|
|
operationId: `${RUN}-op-vic`,
|
|
actorId: commandUserId,
|
|
vehicleIds: [damagedVehicleId],
|
|
originId,
|
|
}),
|
|
).rejects.toThrow(/not available/i);
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-vic-4`,
|
|
operationId: `${RUN}-op-vic`,
|
|
actorId: commandUserId,
|
|
vehicleIds: [elsewhereVehicleId],
|
|
originId,
|
|
}),
|
|
).rejects.toThrow(/not stationed/i);
|
|
|
|
expect(await findByKey(`${RUN}-vic-2`)).toBeNull();
|
|
expect(await findByKey(`${RUN}-vic-3`)).toBeNull();
|
|
expect(await findByKey(`${RUN}-vic-4`)).toBeNull();
|
|
});
|
|
|
|
it("rejects an overdrawn treasury budget, including other reservations' earmarks", async () => {
|
|
const account = await makeFundedAccount(500);
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-budget-1`,
|
|
operationId: `${RUN}-op-budget`,
|
|
actorId: commandUserId,
|
|
budget: { accountId: account.id, amount: 600 },
|
|
}),
|
|
).rejects.toThrow(/insufficient budget/i);
|
|
|
|
const ok = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-budget-2`,
|
|
operationId: `${RUN}-op-budget`,
|
|
actorId: commandUserId,
|
|
budget: { accountId: account.id, amount: 500 },
|
|
});
|
|
reservationIds.push(ok.id);
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-budget-3`,
|
|
operationId: `${RUN}-op-budget`,
|
|
actorId: commandUserId,
|
|
budget: { accountId: account.id, amount: 1 },
|
|
}),
|
|
).rejects.toThrow(/insufficient budget/i);
|
|
|
|
expect(await findByKey(`${RUN}-budget-1`)).toBeNull();
|
|
expect(await findByKey(`${RUN}-budget-3`)).toBeNull();
|
|
|
|
const fresh = (await payload.findByID({
|
|
collection: "bank-accounts",
|
|
id: account.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as unknown as BankAccount;
|
|
expect(fresh.balance).toBe(500);
|
|
});
|
|
|
|
it("rejects personnel already booked on the same mission", async () => {
|
|
const personnelMissionId = await makeMission("Personnel");
|
|
|
|
const first = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-pers-1`,
|
|
operationId: `${RUN}-op-pers`,
|
|
actorId: commandUserId,
|
|
missionId: personnelMissionId,
|
|
personnel: [{ userId: soldierTwoId }],
|
|
});
|
|
reservationIds.push(first.id);
|
|
|
|
await expect(
|
|
reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-pers-2`,
|
|
operationId: `${RUN}-op-pers`,
|
|
actorId: commandUserId,
|
|
missionId: personnelMissionId,
|
|
personnel: [{ userId: soldierTwoId }],
|
|
}),
|
|
).rejects.toThrow(/already reserved/i);
|
|
|
|
const otherMission = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-pers-3`,
|
|
operationId: `${RUN}-op-pers`,
|
|
actorId: commandUserId,
|
|
missionId,
|
|
personnel: [{ userId: soldierTwoId }],
|
|
});
|
|
reservationIds.push(otherMission.id);
|
|
expect(otherMission.status).toBe("reserved");
|
|
});
|
|
});
|
|
|
|
describe("settleOperationReservation", () => {
|
|
it("cancels: returns everything, exactly once", async () => {
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
|
const vehicleId = await makeVehicle(originId);
|
|
const account = await makeFundedAccount(300);
|
|
const doc = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-cancel`,
|
|
operationId: `${RUN}-op-cancel`,
|
|
actorId: commandUserId,
|
|
personnel: [{ userId: soldierOneId }],
|
|
vehicleIds: [vehicleId],
|
|
cargo: [{ resourceId: resourceAId, amount: 25 }],
|
|
budget: { accountId: account.id, amount: 120 },
|
|
originId,
|
|
});
|
|
reservationIds.push(doc.id);
|
|
|
|
const settled = await settleOperationReservation(
|
|
payload,
|
|
{ reservationId: doc.id },
|
|
{ outcome: "cancelled", actorId: commandUserId },
|
|
);
|
|
expect(settled.status).toBe("cancelled");
|
|
expect(settled.settledAt).toBeTruthy();
|
|
|
|
const record = settlementOf(settled);
|
|
expect(record.outcome).toBe("cancelled");
|
|
expect(record.reason).toBe("cancelled");
|
|
expect(record.consumed).toEqual({ personnel: [], vehicles: [], cargo: [], budget: 0 });
|
|
expect(record.returned.personnel).toEqual([soldierOneId]);
|
|
expect(record.returned.vehicles).toEqual([vehicleId]);
|
|
expect(record.returned.cargo).toEqual([{ resource: resourceAId, amount: 25 }]);
|
|
expect(record.returned.budget).toBe(120);
|
|
|
|
// Second settle is a no-op: same status, same settlement timestamp.
|
|
const again = await settleOperationReservation(
|
|
payload,
|
|
{ reservationId: doc.id },
|
|
{ outcome: "cancelled", actorId: commandUserId },
|
|
);
|
|
expect(again.status).toBe("cancelled");
|
|
expect(again.settledAt).toBe(settled.settledAt);
|
|
|
|
// A different outcome cannot re-settle either.
|
|
const third = await settleOperationReservation(
|
|
payload,
|
|
{ reservationId: doc.id },
|
|
{ outcome: "settled", consumed: { budgetAmount: 120 }, actorId: commandUserId },
|
|
);
|
|
expect(third.status).toBe("cancelled");
|
|
expect(third.settledAt).toBe(settled.settledAt);
|
|
});
|
|
|
|
it("settles a partial success with exact consumed/returned math, exactly once", async () => {
|
|
const originId = await makeOrigin([
|
|
{ resourceId: resourceAId, amount: 100 },
|
|
{ resourceId: resourceBId, amount: 10 },
|
|
]);
|
|
const vehicleId = await makeVehicle(originId);
|
|
const account = await makeFundedAccount(400);
|
|
const doc = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-partial`,
|
|
operationId: `${RUN}-op-partial`,
|
|
actorId: commandUserId,
|
|
personnel: [{ userId: soldierOneId }, { userId: soldierTwoId }],
|
|
vehicleIds: [vehicleId],
|
|
cargo: [
|
|
{ resourceId: resourceAId, amount: 50 },
|
|
{ resourceId: resourceBId, amount: 10 },
|
|
],
|
|
budget: { accountId: account.id, amount: 300 },
|
|
originId,
|
|
});
|
|
reservationIds.push(doc.id);
|
|
|
|
// Over-consumption is rejected before any write.
|
|
await expect(
|
|
settleOperationReservation(
|
|
payload,
|
|
{ reservationId: doc.id },
|
|
{ outcome: "settled", consumed: { cargo: [{ resourceId: resourceAId, amount: 51 }] } },
|
|
),
|
|
).rejects.toThrow(/exceeds the reserved amount/i);
|
|
expect((await findByKey(`${RUN}-partial`))?.status).toBe("reserved");
|
|
|
|
const settled = await settleOperationReservation(
|
|
payload,
|
|
{ reservationId: doc.id },
|
|
{
|
|
outcome: "settled",
|
|
reason: "partial-success",
|
|
consumed: {
|
|
personnel: [soldierOneId],
|
|
vehicleIds: [vehicleId],
|
|
cargo: [{ resourceId: resourceAId, amount: 20 }],
|
|
budgetAmount: 100,
|
|
},
|
|
actorId: commandUserId,
|
|
},
|
|
);
|
|
expect(settled.status).toBe("settled");
|
|
|
|
const record = settlementOf(settled);
|
|
expect(record.outcome).toBe("settled");
|
|
expect(record.reason).toBe("partial-success");
|
|
expect(record.consumed.personnel).toEqual([soldierOneId]);
|
|
expect(record.consumed.vehicles).toEqual([vehicleId]);
|
|
expect(record.consumed.cargo).toEqual([{ resource: resourceAId, amount: 20 }]);
|
|
expect(record.consumed.budget).toBe(100);
|
|
expect(record.returned.personnel).toEqual([soldierTwoId]);
|
|
expect(record.returned.vehicles).toEqual([]);
|
|
expect(record.returned.cargo).toEqual([
|
|
{ resource: resourceAId, amount: 30 },
|
|
{ resource: resourceBId, amount: 10 },
|
|
]);
|
|
expect(record.returned.budget).toBe(200);
|
|
|
|
const again = await settleOperationReservation(
|
|
payload,
|
|
{ reservationId: doc.id },
|
|
{ outcome: "settled", reason: "partial-success", consumed: { budgetAmount: 100 } },
|
|
);
|
|
expect(again.settledAt).toBe(settled.settledAt);
|
|
expect(settlementOf(again).consumed.budget).toBe(100);
|
|
});
|
|
|
|
it("records a no-show as a distinct, auditable outcome", async () => {
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 50 }]);
|
|
const doc = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-noshow`,
|
|
operationId: `${RUN}-op-noshow`,
|
|
actorId: commandUserId,
|
|
personnel: [{ userId: soldierOneId }],
|
|
cargo: [{ resourceId: resourceAId, amount: 10 }],
|
|
originId,
|
|
});
|
|
reservationIds.push(doc.id);
|
|
|
|
const settled = await settleOperationReservation(
|
|
payload,
|
|
{ reservationKey: `${RUN}-noshow` },
|
|
{ outcome: "no-show", actorId: commandUserId },
|
|
);
|
|
expect(settled.status).toBe("no-show");
|
|
|
|
const record = settlementOf(settled);
|
|
expect(record.outcome).toBe("no-show");
|
|
expect(record.reason).toBe("no-show");
|
|
expect(record.consumed).toEqual({ personnel: [], vehicles: [], cargo: [], budget: 0 });
|
|
expect(record.returned.personnel).toEqual([soldierOneId]);
|
|
expect(record.returned.cargo).toEqual([{ resource: resourceAId, amount: 10 }]);
|
|
});
|
|
});
|
|
|
|
describe("expireOperationReservations", () => {
|
|
it("settles overdue reservations as expired and leaves future ones reserved", async () => {
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
|
|
|
const overdue = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-exp-overdue`,
|
|
operationId: `${RUN}-op-exp`,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 5 }],
|
|
originId,
|
|
expiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
});
|
|
reservationIds.push(overdue.id);
|
|
|
|
const future = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-exp-future`,
|
|
operationId: `${RUN}-op-exp`,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 5 }],
|
|
originId,
|
|
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
});
|
|
reservationIds.push(future.id);
|
|
|
|
const settledCount = await expireOperationReservations(payload);
|
|
expect(settledCount).toBeGreaterThanOrEqual(1);
|
|
|
|
const overdueDoc = await findByKey(`${RUN}-exp-overdue`);
|
|
expect(overdueDoc?.status).toBe("expired");
|
|
expect(settlementOf(overdueDoc as OperationReservation).returned.cargo).toEqual([
|
|
{ resource: resourceAId, amount: 5 },
|
|
]);
|
|
|
|
const futureDoc = await findByKey(`${RUN}-exp-future`);
|
|
expect(futureDoc?.status).toBe("reserved");
|
|
|
|
// Sweeping again is a no-op for the already-expired row.
|
|
const secondCount = await expireOperationReservations(payload);
|
|
const overdueAgain = await findByKey(`${RUN}-exp-overdue`);
|
|
expect(overdueAgain?.settledAt).toBe(overdueDoc?.settledAt);
|
|
expect(secondCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("failOperationReservations", () => {
|
|
it("settles only unextracted reservations and never reverses a recorded extraction", async () => {
|
|
const operationId = `${RUN}-op-fail`;
|
|
|
|
// Validated extraction evidence through the real hook path.
|
|
const syncEvent = await payload.create({
|
|
collection: "arma-sync-events",
|
|
data: {
|
|
eventId: `${RUN}-extract-1`,
|
|
server: serverId,
|
|
type: "operation.extraction",
|
|
occurredAt: new Date().toISOString(),
|
|
payload: { operationId, version: 1, player: `${RUN} soldier1` },
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
syncEventIds.push(syncEvent.id);
|
|
|
|
const opEvents = await payload.find({
|
|
collection: "operation-events",
|
|
where: { sourceEvent: { equals: syncEvent.id } },
|
|
limit: 1,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
expect(opEvents.docs).toHaveLength(1);
|
|
opEventIds.push(opEvents.docs[0].id);
|
|
|
|
const effects = await payload.find({
|
|
collection: "operation-effects",
|
|
where: { operationEvent: { equals: opEvents.docs[0].id } },
|
|
limit: 10,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
expect(effects.docs).toHaveLength(1);
|
|
const extractionEffect = effects.docs[0];
|
|
expect(extractionEffect.effectType).toBe("operation.extraction-recorded");
|
|
expect(extractionEffect.status).toBe("applied");
|
|
opEffectIds.push(extractionEffect.id);
|
|
|
|
// One reservation already settled by the extraction (partial success);
|
|
// one still reserved (unextracted).
|
|
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
|
const extracted = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-fail-extracted`,
|
|
operationId,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 10 }],
|
|
originId,
|
|
});
|
|
reservationIds.push(extracted.id);
|
|
const preSettled = await settleOperationReservation(
|
|
payload,
|
|
{ reservationId: extracted.id },
|
|
{
|
|
outcome: "settled",
|
|
reason: "partial-success",
|
|
consumed: { cargo: [{ resourceId: resourceAId, amount: 4 }] },
|
|
effectId: extractionEffect.id,
|
|
actorId: commandUserId,
|
|
},
|
|
);
|
|
expect(preSettled.status).toBe("settled");
|
|
|
|
const pending = await reserveOperationAssets(payload, {
|
|
reservationKey: `${RUN}-fail-pending`,
|
|
operationId,
|
|
actorId: commandUserId,
|
|
cargo: [{ resourceId: resourceAId, amount: 10 }],
|
|
originId,
|
|
});
|
|
reservationIds.push(pending.id);
|
|
|
|
const settledCount = await failOperationReservations(payload, operationId, {
|
|
effectId: extractionEffect.id,
|
|
actorId: commandUserId,
|
|
});
|
|
expect(settledCount).toBe(1);
|
|
|
|
// The still-reserved row settles as mission-failed with everything returned.
|
|
const failedDoc = await findByKey(`${RUN}-fail-pending`);
|
|
expect(failedDoc?.status).toBe("settled");
|
|
const failedRecord = settlementOf(failedDoc as OperationReservation);
|
|
expect(failedRecord.reason).toBe("mission-failed");
|
|
expect(failedRecord.consumed).toEqual({ personnel: [], vehicles: [], cargo: [], budget: 0 });
|
|
expect(failedRecord.returned.cargo).toEqual([{ resource: resourceAId, amount: 10 }]);
|
|
expect(failedRecord.effectId).toBe(extractionEffect.id);
|
|
expect(relId(failedDoc?.settledByEffect)).toBe(extractionEffect.id);
|
|
|
|
// The previously settled (extracted) row is untouched.
|
|
const extractedDoc = await findByKey(`${RUN}-fail-extracted`);
|
|
expect(extractedDoc?.status).toBe("settled");
|
|
expect(extractedDoc?.settledAt).toBe(preSettled.settledAt);
|
|
const extractedRecord = settlementOf(extractedDoc as OperationReservation);
|
|
expect(extractedRecord.reason).toBe("partial-success");
|
|
expect(extractedRecord.consumed.cargo).toEqual([{ resource: resourceAId, amount: 4 }]);
|
|
|
|
// The extraction effect is final: still applied, never reversed, and
|
|
// failure settlement wrote no new effects for the operation.
|
|
const freshEffect = await payload.findByID({
|
|
collection: "operation-effects",
|
|
id: extractionEffect.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
expect(freshEffect.status).toBe("applied");
|
|
expect(freshEffect.reversedBy).toBeNull();
|
|
|
|
const effectsAfter = await payload.find({
|
|
collection: "operation-effects",
|
|
where: { operationId: { equals: operationId } },
|
|
limit: 10,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
expect(effectsAfter.docs).toHaveLength(1);
|
|
});
|
|
});
|
|
});
|