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 { DEFAULT_MISSION_DURATION_MINUTES, autoCompleteMissions, autoStartMissions, hasMissionStarted, isMissionWindowComplete, missionEndTime, } from "@/lib/intelligence/missionLifecycle"; let payload: Payload; const RUN = `mlc-${Date.now().toString(36)}`; describe("Mission lifecycle (auto-start + auto-complete)", () => { let mapId: number; let campaignId: number; let authorId: number; const missionIds: number[] = []; // Ids completed by the window sweep, so the idempotency check can scope to // exactly those (the suite deliberately leaves other due missions behind). const completedBySweep: 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: "Auto-complete 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, status: Mission["ownershipAndStatus"]["status"], start: Date, estimatedDuration = 60, ): Promise => { const mission = (await payload.create({ collection: "missions", data: { name: `${RUN} ${label}`, codeName: `${RUN}-${label}`, summary: "Mission lifecycle boundary test mission", operationType: "main", classification: { map: mapId, missionType: "PvE", campaign: campaignId, startDateTime: start.toISOString(), estimatedDuration, }, 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("auto-starts only Scheduled missions whose start has arrived and whose window is still open", async () => { const now = new Date(); now.setHours(12, 0, 0, 0); const at = (hourOffset: number): Date => { const d = new Date(now); d.setHours(now.getHours() + hourOffset); return d; }; const inWindow = await makeMission("start-in-window", "Scheduled", at(-1), 240); const future = await makeMission("start-future", "Scheduled", at(1), 240); const elapsed = await makeMission("start-elapsed", "Scheduled", at(-5), 60); const alreadyActive = await makeMission("start-already-active", "Active", at(-1), 240); const cancelled = await makeMission("start-cancelled", "Cancelled", at(-1), 240); const ids = [inWindow.id, future.id, elapsed.id, alreadyActive.id, cancelled.id]; const result = await autoStartMissions(payload, now, { missionIds: ids }); expect(result.started).toBe(1); expect(result.missions.map((m) => m.id)).toEqual([inWindow.id]); expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active"); expect((await fetchMission(future.id)).ownershipAndStatus.status).toBe("Scheduled"); // Fully elapsed windows are auto-complete's job, not a spurious start. expect((await fetchMission(elapsed.id)).ownershipAndStatus.status).toBe("Scheduled"); expect((await fetchMission(alreadyActive.id)).ownershipAndStatus.status).toBe("Active"); expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled"); // The partial group update must not wipe sibling fields. const updated = await fetchMission(inWindow.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 auto-start sweep finds nothing left to start", async () => { const now = new Date(); now.setHours(12, 0, 0, 0); const result = await autoStartMissions(payload, now, { missionIds }); expect(result.started).toBe(0); expect(result.missions).toEqual([]); }); it("auto-completes Scheduled and Active missions once start + estimatedDuration has passed", async () => { const now = new Date(); now.setHours(12, 0, 0, 0); const at = (hourOffset: number): Date => { const d = new Date(now); d.setHours(now.getHours() + hourOffset); return d; }; const fromScheduled = await makeMission("done-from-scheduled", "Scheduled", at(-6), 240); const fromActive = await makeMission("done-from-active", "Active", at(-5), 60); const inWindow = await makeMission("done-in-window", "Active", at(-1), 240); const readyPast = await makeMission("done-ready", "Ready", at(-5), 60); const cancelledPast = await makeMission("done-cancelled", "Cancelled", at(-5), 60); const ids = [fromScheduled.id, fromActive.id, inWindow.id, readyPast.id, cancelledPast.id]; const result = await autoCompleteMissions(payload, now, { missionIds: ids }); expect(result.completed).toBe(2); expect(result.missions.map((m) => m.id).sort()).toEqual([fromScheduled.id, fromActive.id].sort()); completedBySweep.push(...result.missions.map((m) => m.id)); expect((await fetchMission(fromScheduled.id)).ownershipAndStatus.status).toBe("Completed"); expect((await fetchMission(fromActive.id)).ownershipAndStatus.status).toBe("Completed"); expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active"); // Draft and terminal statuses are never auto-completed. expect((await fetchMission(readyPast.id)).ownershipAndStatus.status).toBe("Ready"); expect((await fetchMission(cancelledPast.id)).ownershipAndStatus.status).toBe("Cancelled"); }); it("is idempotent — a second auto-complete sweep finds nothing left to complete", async () => { const now = new Date(); now.setHours(12, 0, 0, 0); const result = await autoCompleteMissions(payload, now, { missionIds: completedBySweep }); expect(result.completed).toBe(0); expect(result.missions).toEqual([]); }); it("stamps the exact sweep time as completedAt when auto-completing", async () => { const now = new Date(); now.setHours(15, 0, 0, 0); const start = new Date(now); start.setDate(now.getDate() - 2); start.setHours(20, 0, 0, 0); const due = await makeMission("timestamp-due", "Scheduled", start, 60); const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] }); expect(result.completed).toBe(1); const updated = await fetchMission(due.id); expect(updated.ownershipAndStatus.status).toBe("Completed"); expect(updated.ownershipAndStatus.completedAt).toBe(now.toISOString()); }); it("stamps completedAt once on manual transition to Completed and never overwrites it", async () => { const start = new Date(); // Future start date — neither sweep must ever touch this mission. start.setDate(start.getDate() + 1); start.setHours(20, 0, 0, 0); const mission = await makeMission("manual-complete", "Scheduled", start, 60); expect(mission.ownershipAndStatus.completedAt).toBeNull(); // Non-terminal transitions must not stamp a completion time. await payload.update({ collection: "missions", id: mission.id, data: { ownershipAndStatus: { status: "Active" } }, overrideAccess: true, }); expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBeNull(); const before = Date.now(); await payload.update({ collection: "missions", id: mission.id, data: { ownershipAndStatus: { status: "Completed" } }, overrideAccess: true, }); const after = Date.now(); const completed = await fetchMission(mission.id); expect(completed.ownershipAndStatus.completedAt).toBeTruthy(); // timestamptz(3) truncates sub-millisecond precision, hence the 1ms tolerance. const stamped = new Date(completed.ownershipAndStatus.completedAt!).getTime(); expect(stamped).toBeGreaterThanOrEqual(before - 1); expect(stamped).toBeLessThanOrEqual(after); // Cancelling and re-completing keeps the original stamp. await payload.update({ collection: "missions", id: mission.id, data: { ownershipAndStatus: { status: "Cancelled" } }, overrideAccess: true, }); expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBe( completed.ownershipAndStatus.completedAt, ); await payload.update({ collection: "missions", id: mission.id, data: { ownershipAndStatus: { status: "Completed" } }, overrideAccess: true, }); const recompleted = await fetchMission(mission.id); expect(recompleted.ownershipAndStatus.completedAt).toBe( completed.ownershipAndStatus.completedAt, ); }); it("classifies start and window boundaries deterministically", () => { // missionEndTime adds the duration in minutes to the start. expect(missionEndTime("2026-08-24T20:00:00.000Z", 240)?.toISOString()).toBe( "2026-08-25T00:00:00.000Z", ); // Missing / invalid durations fall back to the default. expect(missionEndTime("2026-08-24T20:00:00.000Z", null)?.toISOString()).toBe( new Date( new Date("2026-08-24T20:00:00.000Z").getTime() + DEFAULT_MISSION_DURATION_MINUTES * 60_000, ).toISOString(), ); expect(missionEndTime("not-a-date", 60)).toBeNull(); const now = new Date("2026-08-24T20:00:00.000Z"); expect(hasMissionStarted("2026-08-24T19:59:59.000Z", now)).toBe(true); expect(hasMissionStarted("2026-08-24T20:00:00.000Z", now)).toBe(true); expect(hasMissionStarted("2026-08-24T20:00:01.000Z", now)).toBe(false); expect(hasMissionStarted("not-a-date", now)).toBe(false); expect(isMissionWindowComplete("2026-08-24T20:00:00.000Z", 240, now)).toBe(false); expect( isMissionWindowComplete( "2026-08-24T20:00:00.000Z", 240, new Date("2026-08-25T00:00:00.000Z"), ), ).toBe(true); expect( isMissionWindowComplete( "2026-08-24T20:00:00.000Z", 240, new Date("2026-08-24T23:59:59.999Z"), ), ).toBe(false); expect(isMissionWindowComplete("not-a-date", 240, now)).toBe(false); }); });