1
0
Fork 0
polaris-task-force/tests/int/respawn-tickets.int.spec.ts
Z8MB1E c177f2486f test(int): scope reservation vehicle and respawn balance assertions to the run
- 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
2026-09-21 20:48:02 -04:00

296 lines
11 KiB
TypeScript

import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
import { getTicketBalances, settleDueMissions } from "@/lib/respawn-tickets";
let payload: Payload;
const RUN = `rst-${Date.now().toString(36)}`;
const HOUR = 3_600_000;
// Fixed past dates so settlement windows are deterministic and already due.
const T0 = new Date("2026-09-01T12:00:00.000Z");
const NOW = new Date(T0.getTime() + 51 * HOUR);
describe("Respawn ticket settlement", () => {
let mapId: number;
let campaignId: number;
let serverId: number;
let uYes: User;
let uTent: User;
let uNo: User;
let uNone: User;
let uGhost: User;
let m1: Mission;
let m2: Mission;
let m3: Mission;
const missionIds: number[] = [];
const userIds: number[] = [];
const attendanceIds: number[] = [];
const eventIds: number[] = [];
const ticketIds: number[] = [];
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
const map = (await payload.create({
collection: "maps",
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
overrideAccess: true,
depth: 0,
})) as unknown as MissionMap;
mapId = map.id;
const campaign = (await payload.create({
collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Respawn tickets test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
campaignId = campaign.id;
const server = (await payload.create({
collection: "game-servers",
data: { serverId: `${RUN}-host`, name: `${RUN} Server`, status: "online" },
overrideAccess: true,
depth: 0,
})) as unknown as { id: number };
serverId = server.id;
const makeUser = async (label: string): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName: label.toUpperCase(),
steamId: `${RUN}-sid-${label}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
uYes = await makeUser("yes");
uTent = await makeUser("tent");
uNo = await makeUser("no");
uNone = await makeUser("none");
uGhost = await makeUser("ghost");
const makeMission = async (label: string, status: Mission["ownershipAndStatus"]["status"], start: Date): Promise<Mission> => {
const mission = (await payload.create({
collection: "missions",
data: {
name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`,
summary: "Respawn ticket settlement test mission",
operationType: "main",
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: start.toISOString(),
estimatedDuration: 60,
},
ownershipAndStatus: {
authors: [uYes.id],
status,
visibility: "unit",
},
missionRoles: { maxPlayers: 16 },
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
briefing: [],
},
overrideAccess: true,
depth: 0,
})) as unknown as Mission;
missionIds.push(mission.id);
return mission;
};
// M1 window: [T0 - 1h, min(T0 + 24h, M2 start = T0 + 26h)] -> closes T0 + 24h.
m1 = await makeMission("op-one", "Scheduled", T0);
// M2 window: [T0 + 25h, min(T0 + 50h, M3 start (far future))] -> closes T0 + 50h.
m2 = await makeMission("op-two", "Completed", new Date(T0.getTime() + 26 * HOUR));
// Far future: never inside any settled window at NOW.
m3 = await makeMission("op-three", "Scheduled", new Date(T0.getTime() + 30 * 24 * HOUR));
const makeAttendance = async (missionId: number, userId: number, response: "yes" | "tentative" | "no"): Promise<void> => {
const doc = (await payload.create({
collection: "mission-attendances",
data: { mission: missionId, user: userId, response },
overrideAccess: true,
depth: 0,
})) as unknown as { id: number };
attendanceIds.push(doc.id);
};
await makeAttendance(m1.id, uYes.id, "yes");
await makeAttendance(m1.id, uTent.id, "tentative");
await makeAttendance(m1.id, uNo.id, "no");
await makeAttendance(m1.id, uGhost.id, "yes");
await makeAttendance(m2.id, uYes.id, "yes");
const makeConnection = async (occurredAt: Date, steamId: string): Promise<void> => {
const event = (await payload.create({
collection: "arma-sync-events",
data: {
eventId: `${RUN}-${steamId}-${occurredAt.getTime()}`,
server: serverId,
type: "player.connected",
occurredAt: occurredAt.toISOString(),
payload: ["1", steamId, "Player", false, "2", "3"],
},
overrideAccess: true,
depth: 0,
})) as unknown as { id: number };
eventIds.push(event.id);
};
// M1 attendance: yes, tent, no, none connect; ghost never does.
await makeConnection(new Date(T0.getTime() - 10 * 60_000), `${RUN}-sid-yes`);
await makeConnection(new Date(T0.getTime() + 5 * 60_000), `${RUN}-sid-tent`);
await makeConnection(new Date(T0.getTime() + 10 * 60_000), `${RUN}-sid-no`);
await makeConnection(new Date(T0.getTime() + 15 * 60_000), `${RUN}-sid-none`);
// M2 attendance: only yes returns for the next operation.
await makeConnection(new Date(T0.getTime() + 27 * HOUR), `${RUN}-sid-yes`);
});
afterAll(async () => {
const cleanup = async (fn: () => Promise<unknown>): Promise<void> => {
await fn().catch(() => {});
};
for (const id of attendanceIds) {
await cleanup(() => payload.delete({ collection: "mission-attendances", id, overrideAccess: true }));
}
for (const id of eventIds) {
await cleanup(() => payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }));
}
for (const id of ticketIds) {
await cleanup(() => payload.delete({ collection: "respawn-tickets", id, overrideAccess: true }));
}
const settledEvents = await payload
.find({
collection: "game-event-logs",
where: {
and: [
{ type: { in: ["respawn:tickets-settled", "mission:attendance-change"] } },
{ targetId: { in: missionIds } },
],
},
limit: 100,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const event of settledEvents?.docs ?? []) {
await cleanup(() => payload.delete({ collection: "game-event-logs", id: event.id, overrideAccess: true }));
}
for (const id of missionIds) {
await cleanup(() => payload.delete({ collection: "missions", id, overrideAccess: true }));
}
await cleanup(() => payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }));
await cleanup(() => payload.delete({ collection: "campaigns", id: campaignId, overrideAccess: true }));
await cleanup(() => payload.delete({ collection: "maps", id: mapId, overrideAccess: true }));
for (const id of userIds) {
const accounts = await payload
.find({ collection: "bank-accounts", where: { ownerUser: { equals: id } }, limit: 5, depth: 0, overrideAccess: true })
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await cleanup(() => payload.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true }));
}
const profiles = await payload
.find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true })
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await cleanup(() => payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true }));
}
await cleanup(() => payload.delete({ collection: "users", id, overrideAccess: true }));
}
});
const balanceOf = async (userId: number): Promise<number | null> => {
const res = await payload.find({
collection: "respawn-tickets",
where: { user: { equals: userId } },
depth: 0,
limit: 1,
overrideAccess: true,
});
if (!res.docs[0]) return null;
ticketIds.push(res.docs[0].id);
return res.docs[0].balance ?? null;
};
it("awards tickets by RSVP x actual attendance and accumulates across missions", async () => {
const result = await settleDueMissions(payload, { now: NOW, missionIds: missionIds });
expect(result.settled.sort()).toEqual([m1.id, m2.id].sort());
// yes + attended on both ops: 1 + 1 = 2.
expect(await balanceOf(uYes.id)).toBe(2);
// tentative + attended: 0.5.
expect(await balanceOf(uTent.id)).toBe(0.5);
// no + attended: 0.25.
expect(await balanceOf(uNo.id)).toBe(0.25);
// Attended without an RSVP: no doc at all.
expect(await balanceOf(uNone.id)).toBeNull();
// RSVPed yes but never connected: no doc at all.
expect(await balanceOf(uGhost.id)).toBeNull();
});
it("serves steamId-keyed balances above zero to the bridge", async () => {
const tickets = await getTicketBalances(payload);
// The bridge map covers every member with a positive balance; scope the
// assertion to this run's steam ids so leaked rows from other sessions
// cannot break exact equality.
const runTickets = Object.fromEntries(
Object.entries(tickets).filter(([steamId]) => steamId.startsWith(`${RUN}-`)),
);
expect(runTickets).toEqual({
[`${RUN}-sid-yes`]: 2,
[`${RUN}-sid-tent`]: 0.5,
[`${RUN}-sid-no`]: 0.25,
});
});
it("never settles missions whose window has not closed", async () => {
const fresh = await payload.findByID({ collection: "missions", id: m3.id, depth: 0, overrideAccess: true }) as unknown as Mission;
expect(fresh.ticketsSettledAt ?? null).toBeNull();
});
it("is idempotent — a second settlement awards nothing new", async () => {
const before = await getTicketBalances(payload);
const result = await settleDueMissions(payload, { now: NOW, missionIds: missionIds });
expect(result.settled).toEqual([]);
expect(await getTicketBalances(payload)).toEqual(before);
});
it("emits a respawn:tickets-settled event per settled mission", async () => {
const poll = async (): Promise<number> => {
const res = await payload.find({
collection: "game-event-logs",
where: {
and: [{ type: { equals: "respawn:tickets-settled" } }, { targetId: { in: [m1.id, m2.id] } }],
},
limit: 10,
depth: 0,
overrideAccess: true,
});
return res.docs.length;
};
let count = await poll();
for (let attempt = 0; attempt < 20 && count < 2; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 250));
count = await poll();
}
expect(count).toBe(2);
});
});