diff --git a/tests/int/friday-ops-notice.int.spec.ts b/tests/int/friday-ops-notice.int.spec.ts new file mode 100644 index 0000000..440fe8b --- /dev/null +++ b/tests/int/friday-ops-notice.int.spec.ts @@ -0,0 +1,297 @@ +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 { + getFridayClaimant, + hasMissionOnFriday, + isNoticeDue, + noticeToken, + upcomingFridayRange, +} from "@/lib/intelligence/fridayOpsNotice"; + +let payload: Payload; + +const RUN = `fon-${Date.now().toString(36)}`; + +// Hermetic window: 2027-01-13 is a Wednesday far enough out that the shared +// dev database holds no real missions on its Friday (2027-01-15). +const WEDNESDAY = new Date(2027, 0, 13, 12, 0, 0, 0); +const TUESDAY = new Date(2027, 0, 12, 12, 0, 0, 0); +const FRIDAY = new Date(2027, 0, 15, 12, 0, 0, 0); +const FRIDAY_EVENING = new Date(2027, 0, 15, 20, 0, 0, 0); +const SATURDAY = new Date(2027, 0, 16, 20, 0, 0, 0); + +describe("Friday open-slot notice", () => { + let mapId: number; + let campaignId: number; + let authorId: number; + let zeusId: number; + let superuserId: number; + const missionIds: number[] = []; + const userIds: number[] = []; + const roleIds: number[] = []; + + beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + + // Sweep leftovers from crashed earlier runs: a stray Concept mission in + // the fixture window would flip the first hasMissionOnFriday assertion. + const stale = await payload.find({ + collection: "missions", + overrideAccess: true, + depth: 0, + limit: 100, + where: { codeName: { like: "fon-" } }, + }); + for (const doc of stale.docs) { + await payload.delete({ collection: "missions", id: doc.id, overrideAccess: true }).catch(() => {}); + } + + 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: "Friday notice test campaign", status: "concept", campaignMode: "custom" }, + overrideAccess: true, + depth: 0, + })) as unknown as Campaign; + campaignId = campaign.id; + + const author = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-author`, + discordUsername: `${RUN}-author`, + displayName: "AUTHOR", + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + roles: ["user"], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + authorId = author.id; + userIds.push(authorId); + + const zeus = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-zeus`, + discordUsername: `${RUN}-zeus`, + displayName: "ZEUS", + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + roles: ["user"], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + zeusId = zeus.id; + userIds.push(zeusId); + + const superRole = (await payload.create({ + collection: "roles", + data: { name: `FON Superuser ${RUN}`, slug: "", isSuperuser: true }, + overrideAccess: true, + depth: 0, + })) as unknown as { id: number }; + roleIds.push(superRole.id); + + const superuser = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-su`, + discordUsername: `${RUN}-su`, + displayName: "SU", + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + roles: ["user"], + roleDocs: [superRole.id], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + superuserId = superuser.id; + userIds.push(superuserId); + }); + + afterAll(async () => { + for (const id of missionIds) { + await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {}); + } + await payload + .delete({ collection: "campaigns", id: campaignId, overrideAccess: true }) + .catch(() => {}); + await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {}); + // User deletion trips FK constraints unless the hook-provisioned + // personal bank account and profile are removed first. + for (const uid of userIds) { + const accounts = await payload + .find({ + collection: "bank-accounts", + where: { ownerUser: { equals: uid } }, + limit: 5, + depth: 0, + overrideAccess: true, + }) + .catch(() => null); + for (const account of accounts?.docs ?? []) { + await payload + .delete({ collection: "bank-accounts", id: account.id, overrideAccess: true }) + .catch(() => {}); + } + const profiles = await payload + .find({ + collection: "profiles", + where: { user: { equals: uid } }, + limit: 5, + depth: 0, + overrideAccess: true, + }) + .catch(() => null); + for (const profile of profiles?.docs ?? []) { + await payload + .delete({ collection: "profiles", id: profile.id, overrideAccess: true }) + .catch(() => {}); + } + await payload.delete({ collection: "users", id: uid, overrideAccess: true }).catch(() => {}); + } + for (const rid of roleIds) { + await payload.delete({ collection: "roles", id: rid, overrideAccess: true }).catch(() => {}); + } + }); + + const makeMission = async ( + label: string, + start: Date, + status: Mission["ownershipAndStatus"]["status"] = "Scheduled", + creatorId?: number, + ): Promise => { + const mission = (await payload.create({ + collection: "missions", + data: { + name: `${RUN} ${label}`, + codeName: `${RUN}-${label}`, + summary: "Friday notice test mission", + operationType: "side", + classification: { + map: mapId, + missionType: "PvE", + campaign: campaignId, + startDateTime: start.toISOString(), + estimatedDuration: 60, + }, + ownershipAndStatus: { + authors: [authorId], + status, + visibility: "unit", + }, + missionRoles: { + maxPlayers: 16, + }, + gameDetails: { + serverDetails: { + serverIp: "127.0.0.1", + serverPort: 2302, + }, + }, + briefing: [], + ...(creatorId ? { createdBy: creatorId } : {}), + }, + overrideAccess: true, + depth: 0, + })) as unknown as Mission; + missionIds.push(mission.id); + return mission; + }; + + it("is due on Wednesdays at 8 PM Eastern, until the ET day rolls over", () => { + // 2026-09-16 is a Wednesday in EDT (UTC-4); January is EST (UTC-5). + expect(isNoticeDue(new Date("2026-09-17T00:30:00Z"))).toBe(true); // Wed 20:30 ET + expect(isNoticeDue(new Date("2026-09-17T03:59:00Z"))).toBe(true); // Wed 23:59 ET + expect(isNoticeDue(new Date("2026-09-15T20:30:00Z"))).toBe(false); // Tue 16:30 ET + expect(isNoticeDue(new Date("2026-09-16T16:00:00Z"))).toBe(false); // Wed 12:00 ET + expect(isNoticeDue(new Date("2026-09-16T23:59:00Z"))).toBe(false); // Wed 19:59 ET + expect(isNoticeDue(new Date("2026-09-17T00:00:00Z"))).toBe(true); // Wed 20:00 ET sharp + expect(isNoticeDue(new Date("2026-09-17T04:00:00Z"))).toBe(false); // Thu 00:00 ET + expect(isNoticeDue(new Date("2026-01-15T01:00:00Z"))).toBe(true); // Wed 20:00 ET (EST) + }); + + it("targets the upcoming Friday, strictly in the future", () => { + const fromWednesday = upcomingFridayRange(WEDNESDAY); + expect(fromWednesday.start).toEqual(new Date(2027, 0, 15, 0, 0, 0, 0)); + expect(fromWednesday.end).toEqual(new Date(2027, 0, 15, 23, 59, 59, 999)); + expect(fromWednesday.epoch).toBe(Math.floor(fromWednesday.start.getTime() / 1000)); + + // Earlier in the week still lands on the same Friday. + expect(upcomingFridayRange(new Date(2027, 0, 11, 8, 0, 0, 0)).start).toEqual( + fromWednesday.start, + ); + // On a Friday itself, the next Friday is targeted instead. + expect(upcomingFridayRange(FRIDAY).start).toEqual(new Date(2027, 0, 22, 0, 0, 0, 0)); + }); + + it("builds a stable per-Friday dedup token", () => { + expect(noticeToken(WEDNESDAY)).toBe(noticeToken(TUESDAY)); + expect(noticeToken(WEDNESDAY)).toBe(``); + expect(noticeToken(FRIDAY)).not.toBe(noticeToken(WEDNESDAY)); + }); + + it("treats the Friday slot as open only when a non-cancelled mission starts that Friday", async () => { + await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(false); + + const concept = await makeMission("concept-friday", FRIDAY_EVENING, "Concept"); + await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(true); + + await makeMission("cancelled-friday", FRIDAY_EVENING, "Cancelled"); + await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(true); + + await payload.delete({ collection: "missions", id: concept.id, overrideAccess: true }); + await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(false); + + const saturday = await makeMission("saturday-mission", SATURDAY); + await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(false); + + await payload.delete({ collection: "missions", id: saturday.id, overrideAccess: true }); + }); + + it("credits the earliest non-superuser creator as the claimant", async () => { + await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toBeNull(); + + const zeusMission = await makeMission("zeus-claim", FRIDAY_EVENING, "Scheduled", zeusId); + await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toEqual({ userName: "ZEUS" }); + + await makeMission("su-claim", FRIDAY_EVENING, "Scheduled", superuserId); + await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toEqual({ userName: "ZEUS" }); + + await payload.delete({ collection: "missions", id: zeusMission.id, overrideAccess: true }); + await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toBeNull(); + + const first = await makeMission("zeus-first", FRIDAY_EVENING, "Scheduled", zeusId); + const second = await makeMission("zeus-second", FRIDAY_EVENING, "Scheduled", zeusId); + await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toEqual({ userName: "ZEUS" }); + expect(first.createdAt <= second.createdAt).toBe(true); + + await payload.delete({ collection: "missions", id: first.id, overrideAccess: true }); + await payload.delete({ collection: "missions", id: second.id, overrideAccess: true }); + + const claimer = await makeMission("zeus-cancelled", FRIDAY_EVENING, "Scheduled", zeusId); + await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toEqual({ userName: "ZEUS" }); + await payload.update({ + collection: "missions", + id: claimer.id, + data: { ownershipAndStatus: { status: "Cancelled" } }, + overrideAccess: true, + }); + await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toBeNull(); + }); +});