- 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
296 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|