Tickets collection, settlement service, arma-bridge API route, one-shot ticketsSettledAt marker on missions, migration, and tests. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
290 lines
11 KiB
TypeScript
290 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` },
|
|
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);
|
|
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<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);
|
|
});
|
|
});
|