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 => { 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 => { 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 => { 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 => { 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): Promise => { 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 => { 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); expect(tickets).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 => { 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); }); });