- The mission tick now runs two idempotent sweeps: auto-start flips Scheduled missions whose start time has arrived to Active, and auto-complete sets missions whose scheduled window (start plus duration) has fully passed to Completed with a stamped completedAt. - Side ops are created as quiet Ready drafts and committed with a "Mark as Scheduled" control, which is what posts the Discord roll-call and hands the op to the lifecycle above. - Daily schedule reminders nudge the creator to set a draft live (web notification plus Discord DM). - Adds the mission lifecycle/commit event types and the matching notification labels, and updates the bot docs.
215 lines
7.1 KiB
TypeScript
215 lines
7.1 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 {
|
|
IMMINENT_REMINDER_GRACE_MS,
|
|
IMMINENT_REMINDER_WINDOW_MS,
|
|
getImminentReminderMissions,
|
|
} from "@/lib/intelligence/imminentReminders";
|
|
|
|
let payload: Payload;
|
|
|
|
const RUN = `imr-${Date.now().toString(36)}`;
|
|
|
|
describe("Imminent-op reminder query", () => {
|
|
let mapId: number;
|
|
let campaignId: number;
|
|
let authorId: number;
|
|
const missionIds: 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: "Imminent reminder 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;
|
|
});
|
|
|
|
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.
|
|
const accounts = await payload
|
|
.find({
|
|
collection: "bank-accounts",
|
|
where: { ownerUser: { equals: authorId } },
|
|
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: authorId } },
|
|
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: authorId, overrideAccess: true })
|
|
.catch(() => {});
|
|
});
|
|
|
|
const makeMission = async (
|
|
label: string,
|
|
overrides: {
|
|
status?: Mission["ownershipAndStatus"]["status"];
|
|
visibility?: Mission["ownershipAndStatus"]["visibility"];
|
|
start: Date;
|
|
withRollCall?: boolean;
|
|
marked?: boolean;
|
|
},
|
|
): Promise<Mission> => {
|
|
const mission = (await payload.create({
|
|
collection: "missions",
|
|
data: {
|
|
name: `${RUN} ${label}`,
|
|
codeName: `${RUN}-${label}`,
|
|
summary: "Imminent reminder boundary test mission",
|
|
operationType: "main",
|
|
classification: {
|
|
map: mapId,
|
|
missionType: "PvE",
|
|
campaign: campaignId,
|
|
startDateTime: overrides.start.toISOString(),
|
|
estimatedDuration: 60,
|
|
},
|
|
ownershipAndStatus: {
|
|
authors: [authorId],
|
|
status: overrides.status ?? "Scheduled",
|
|
visibility: overrides.visibility ?? "unit",
|
|
},
|
|
missionRoles: {
|
|
maxPlayers: 16,
|
|
},
|
|
gameDetails: {
|
|
serverDetails: {
|
|
serverIp: "127.0.0.1",
|
|
serverPort: 2302,
|
|
},
|
|
},
|
|
briefing: [],
|
|
...(overrides.withRollCall === false
|
|
? {}
|
|
: { discordMessageId: `${RUN}-${label}-message` }),
|
|
...(overrides.marked ? { imminentReminderSentAt: new Date().toISOString() } : {}),
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Mission;
|
|
missionIds.push(mission.id);
|
|
return mission;
|
|
};
|
|
|
|
it("finds posted Scheduled ops starting inside the 15 minute window, not Ready drafts", async () => {
|
|
const now = new Date();
|
|
const start = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS - 60_000);
|
|
const ready = await makeMission("in-window-ready", { status: "Ready", start });
|
|
const scheduled = await makeMission("in-window-scheduled", { status: "Scheduled", start });
|
|
|
|
const found = await getImminentReminderMissions(payload, now);
|
|
expect(found.map((m) => m.id)).toContain(scheduled.id);
|
|
// Ready is now a quiet draft (no roll-call), so it is never imminent-reminded.
|
|
expect(found.map((m) => m.id)).not.toContain(ready.id);
|
|
});
|
|
|
|
it("includes the exact window edge and the grace zone just past the start", async () => {
|
|
const now = new Date();
|
|
const edge = await makeMission("window-edge", {
|
|
start: new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS),
|
|
});
|
|
const justStarted = await makeMission("grace-zone", {
|
|
start: new Date(now.getTime() - 5 * 60_000),
|
|
});
|
|
|
|
const found = await getImminentReminderMissions(payload, now);
|
|
expect(found.map((m) => m.id)).toContain(edge.id);
|
|
expect(found.map((m) => m.id)).toContain(justStarted.id);
|
|
});
|
|
|
|
it("excludes ops outside the window, past the grace bound, marked, without a roll-call, or not unit-visible", async () => {
|
|
const now = new Date();
|
|
const tooEarly = await makeMission("too-early", {
|
|
start: new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS + 60_000),
|
|
});
|
|
const tooLate = await makeMission("too-late", {
|
|
start: new Date(now.getTime() - IMMINENT_REMINDER_GRACE_MS - 60_000),
|
|
});
|
|
const alreadyReminded = await makeMission("already-reminded", {
|
|
start: new Date(now.getTime() + 10 * 60_000),
|
|
marked: true,
|
|
});
|
|
const noRollCall = await makeMission("no-roll-call", {
|
|
start: new Date(now.getTime() + 10 * 60_000),
|
|
withRollCall: false,
|
|
});
|
|
const notUnitVisible = await makeMission("not-unit-visible", {
|
|
start: new Date(now.getTime() + 10 * 60_000),
|
|
visibility: "leadership",
|
|
});
|
|
const draftStatus = await makeMission("draft-status", {
|
|
status: "Planning",
|
|
start: new Date(now.getTime() + 10 * 60_000),
|
|
});
|
|
|
|
const foundIds = (await getImminentReminderMissions(payload, now)).map((m) => m.id);
|
|
for (const excluded of [
|
|
tooEarly,
|
|
tooLate,
|
|
alreadyReminded,
|
|
noRollCall,
|
|
notUnitVisible,
|
|
draftStatus,
|
|
]) {
|
|
expect(foundIds).not.toContain(excluded.id);
|
|
}
|
|
});
|
|
});
|