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 { autoCompleteMissions, isPastScheduledDay, startOfToday, } from "@/lib/intelligence/missionLifecycle"; let payload: Payload; const RUN = `mlc-${Date.now().toString(36)}`; describe("Mission auto-complete", () => { 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` }, overrideAccess: true, depth: 0, })) as unknown as MissionMap; mapId = map.id; const campaign = (await payload.create({ collection: "campaigns", data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept" }, 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, status: Mission["ownershipAndStatus"]["status"], start: Date, ): Promise => { const mission = (await payload.create({ collection: "missions", data: { name: `${RUN} ${label}`, codeName: `${RUN}-${label}`, summary: "Auto-complete boundary test mission", operationType: "main", 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: [], }, overrideAccess: true, depth: 0, })) as unknown as Mission; missionIds.push(mission.id); return mission; }; const fetchMission = (id: number): Promise => payload.findByID({ collection: "missions", id, depth: 0, overrideAccess: true, }) as unknown as Promise; it("completes Scheduled/Active missions only after the day after their scheduled date starts", async () => { const now = new Date(); now.setHours(12, 0, 0, 0); const at = (dayOffset: number, hour: number): Date => { const d = new Date(now); d.setDate(now.getDate() + dayOffset); d.setHours(hour, 0, 0, 0); return d; }; const dueScheduled = await makeMission("due-scheduled", "Scheduled", at(-1, 20)); const dueActive = await makeMission("due-active", "Active", at(-3, 20)); const midnightEdge = await makeMission("midnight-edge", "Scheduled", at(-1, 23)); const sameDayEarlier = await makeMission("same-day-earlier", "Scheduled", at(0, 8)); const sameDayLater = await makeMission("same-day-later", "Scheduled", at(0, 13)); const draftReady = await makeMission("draft-ready", "Ready", at(-1, 20)); const cancelled = await makeMission("cancelled", "Cancelled", at(-1, 20)); const result = await autoCompleteMissions(payload, now, { missionIds }); expect(result.missions.map((m) => m.id).sort()).toEqual( [dueScheduled.id, dueActive.id, midnightEdge.id].sort(), ); expect((await fetchMission(dueScheduled.id)).ownershipAndStatus.status).toBe("Completed"); expect((await fetchMission(dueActive.id)).ownershipAndStatus.status).toBe("Completed"); expect((await fetchMission(midnightEdge.id)).ownershipAndStatus.status).toBe("Completed"); expect((await fetchMission(sameDayEarlier.id)).ownershipAndStatus.status).toBe("Scheduled"); expect((await fetchMission(sameDayLater.id)).ownershipAndStatus.status).toBe("Scheduled"); expect((await fetchMission(draftReady.id)).ownershipAndStatus.status).toBe("Ready"); expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled"); // The partial group update must not wipe sibling fields. const updated = await fetchMission(dueScheduled.id); expect(updated.ownershipAndStatus.visibility).toBe("unit"); const authorRefs = Array.isArray(updated.ownershipAndStatus.authors) ? updated.ownershipAndStatus.authors : [updated.ownershipAndStatus.authors]; expect(authorRefs).toContain(authorId); }); it("is idempotent — a second sweep finds nothing left to complete", async () => { const now = new Date(); now.setHours(12, 0, 0, 0); const result = await autoCompleteMissions(payload, now, { missionIds }); expect(result.completed).toBe(0); expect(result.missions).toEqual([]); }); it("classifies day boundaries deterministically", () => { const now = new Date(2026, 7, 24, 12, 0, 0); expect(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime()); expect(isPastScheduledDay("2026-08-23T23:59:59.999", now)).toBe(true); expect(isPastScheduledDay("2026-08-24T00:00:00.000", now)).toBe(false); expect(isPastScheduledDay("2026-08-24T19:30:00.000", now)).toBe(false); expect(isPastScheduledDay("2026-08-25T00:00:00.000", now)).toBe(false); expect(isPastScheduledDay("not-a-date", now)).toBe(false); }); });