test(int): cover the Friday open-slot notice
Notice-day gating in Eastern time across DST, upcoming-Friday range, stable dedup token, slot-open semantics (drafts hold the slot, cancelled do not, other days do not count), and claimant resolution (superuser exclusion, earliest-created wins, cancel reverts the claim).
This commit is contained in:
parent
4fad7cd259
commit
fd9f36fc46
1 changed files with 297 additions and 0 deletions
297
tests/int/friday-ops-notice.int.spec.ts
Normal file
297
tests/int/friday-ops-notice.int.spec.ts
Normal file
|
|
@ -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<Mission> => {
|
||||||
|
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(`<t:${upcomingFridayRange(WEDNESDAY).epoch}:D>`);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue