// @vitest-environment node // jose (via payload's jwtSign) fails under jsdom: its module-level TextEncoder and // the global Uint8Array come from different realms, so signing throws a TypeError. import { getFieldsToSign, getPayload, jwtSign, Payload } from "payload"; import config from "@/payload.config"; import { randomUUID } from "node:crypto"; import { NextRequest } from "next/server"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Assignment, Campaign, Map as MissionMap, Mission, User } from "@/payload-types"; import { GET } from "@/app/api/mission-reminder/route"; import { getMissionReminders } from "@/lib/intelligence/missionReminder"; let payload: Payload; const RUN = `mrm-${Date.now().toString(36)}`; const HOUR = 60 * 60 * 1000; const DAY = 24 * HOUR; /** Reference "now" for the boundary fixtures and the service-level window test. */ let fixedNow: Date; function request(token?: string): NextRequest { const headers = new Headers(); if (token) headers.set("cookie", `payload-token=${token}`); return new NextRequest("http://localhost/api/mission-reminder", { headers }); } /** * Mint a payload-token JWT for a user, mirroring the impersonation route: * persist a session on the user doc, then sign with that session's sid. */ async function mintToken(userId: number): Promise { const collection = payload.collections.users.config; const user = await payload.findByID({ collection: "users", id: userId, depth: 0, overrideAccess: true, }); const sid = randomUUID(); const now = new Date(); const expiresAt = new Date(now.getTime() + collection.auth.tokenExpiration * 1000); const sessions = (user.sessions ?? []).filter( (session) => new Date(session.expiresAt).getTime() > now.getTime(), ); sessions.push({ id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() }); await payload.update({ collection: "users", id: userId, data: { sessions }, overrideAccess: true, }); const { token } = await jwtSign({ fieldsToSign: getFieldsToSign({ collectionConfig: collection, email: user.email ?? "", sid, user, }), secret: payload.secret, tokenExpiration: collection.auth.tokenExpiration, }); return token; } describe("Mission feedback reminder", () => { let mapId: number; let campaignId: number; const userIds: number[] = []; const missionIds: number[] = []; const attendanceIds: number[] = []; const assignmentIds: number[] = []; let memberId: number; let leaderAId: number; let leaderBId: number; let strangerId: number; let lonerId: number; let memberToken: string; let lonerToken: string; // Eligible fixtures. let eligibleMainId: number; // main, 24h ago, member + leaderA yes -> included [leaderA] let multiLeaderId: number; // main, 12h ago, member + both leaders yes -> included [leaderA, leaderB] // Ineligible fixtures. let sideOpId: number; // side operation within the window let staleMainId: number; // main, 73h ago (past the window) let noStampId: number; // main, completion stamp cleared -> fail closed let userNoId: number; // member RSVP'd "no" let leaderNoId: number; // only a non-leader (stranger) RSVP'd yes // Boundary fixtures relative to fixedNow. let boundaryExactId: number; // exactly fixedNow - 72h -> included at fixedNow let boundaryPastId: number; // fixedNow - 72h - 1ms -> excluded at fixedNow beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); fixedNow = new Date(); 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: "Reminder test campaign", status: "concept", campaignMode: "custom" }, overrideAccess: true, depth: 0, })) as unknown as Campaign; campaignId = campaign.id; const makeUser = async (label: string, displayName: string): Promise => { const user = (await payload.create({ collection: "users", data: { username: `${RUN}-${label}`, discordUsername: `${RUN}-${label}`, displayName, steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; userIds.push(user.id); return user; }; const member = await makeUser("member", "MEMBER"); const leaderA = await makeUser("leader-a", "LEADER A"); const leaderB = await makeUser("leader-b", "LEADER B"); const stranger = await makeUser("stranger", "STRANGER"); const loner = await makeUser("loner", "LONER"); memberId = member.id; leaderAId = leaderA.id; leaderBId = leaderB.id; strangerId = stranger.id; lonerId = loner.id; // The member belongs to two squads, so their direct leaders are both // leaders (same model as isDirectLeaderOf in @/lib/evaluations). const makeAssignment = async (label: string, leaderId: number): Promise => { const assignment = (await payload.create({ collection: "assignments", data: { name: `${RUN} ${label}`, type: "squad", leader: leaderId, members: [memberId] }, overrideAccess: true, depth: 0, })) as unknown as Assignment; assignmentIds.push(assignment.id); return assignment; }; await makeAssignment("Squad A", leaderAId); await makeAssignment("Squad B", leaderBId); const makeMission = async ( label: string, operationType: Mission["operationType"], completedAt?: string, ): Promise => { const mission = (await payload.create({ collection: "missions", data: { name: `${RUN} ${label}`, codeName: `${RUN}-${label}`, summary: "Reminder boundary test mission", operationType, classification: { map: mapId, missionType: "PvE", campaign: campaignId, startDateTime: new Date(fixedNow.getTime() - DAY).toISOString(), estimatedDuration: 60, }, ownershipAndStatus: { authors: [memberId], status: "Completed", visibility: "unit", ...(completedAt ? { completedAt } : {}), }, 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 addAttendance = async ( missionId: number, userId: number, response: "yes" | "no" | "tentative", ) => { const attendance = await payload.create({ collection: "mission-attendances", data: { mission: missionId, user: userId, response }, overrideAccess: true, depth: 0, }); attendanceIds.push((attendance as unknown as { id: number }).id); }; // Eligible fixtures. eligibleMainId = ( await makeMission("eligible-main", "main", new Date(fixedNow.getTime() - DAY).toISOString()) ).id; await addAttendance(eligibleMainId, memberId, "yes"); await addAttendance(eligibleMainId, leaderAId, "yes"); multiLeaderId = ( await makeMission( "multi-leader", "main", new Date(fixedNow.getTime() - 12 * HOUR).toISOString(), ) ).id; await addAttendance(multiLeaderId, memberId, "yes"); await addAttendance(multiLeaderId, leaderAId, "yes"); await addAttendance(multiLeaderId, leaderBId, "yes"); // Ineligible fixtures. sideOpId = ( await makeMission("side-op", "side", new Date(fixedNow.getTime() - DAY).toISOString()) ).id; await addAttendance(sideOpId, memberId, "yes"); await addAttendance(sideOpId, leaderAId, "yes"); staleMainId = ( await makeMission( "stale-main", "main", new Date(fixedNow.getTime() - 73 * HOUR).toISOString(), ) ).id; await addAttendance(staleMainId, memberId, "yes"); await addAttendance(staleMainId, leaderAId, "yes"); // Created as Completed (the hook stamps the completion time), then the // stamp is cleared: the reminder must fail closed on a missing timestamp. const noStamp = await makeMission("no-stamp", "main"); noStampId = noStamp.id; await payload.update({ collection: "missions", id: noStampId, data: { ownershipAndStatus: { completedAt: null } }, overrideAccess: true, }); await addAttendance(noStampId, memberId, "yes"); await addAttendance(noStampId, leaderAId, "yes"); userNoId = ( await makeMission("user-no", "main", new Date(fixedNow.getTime() - DAY).toISOString()) ).id; await addAttendance(userNoId, memberId, "no"); await addAttendance(userNoId, leaderAId, "yes"); leaderNoId = ( await makeMission("leader-no", "main", new Date(fixedNow.getTime() - DAY).toISOString()) ).id; await addAttendance(leaderNoId, memberId, "yes"); await addAttendance(leaderNoId, strangerId, "yes"); // a non-leader "yes" must not count // Boundary fixtures relative to fixedNow. boundaryExactId = ( await makeMission( "boundary-exact", "main", new Date(fixedNow.getTime() - 72 * HOUR).toISOString(), ) ).id; await addAttendance(boundaryExactId, memberId, "yes"); await addAttendance(boundaryExactId, leaderAId, "yes"); boundaryPastId = ( await makeMission( "boundary-past", "main", new Date(fixedNow.getTime() - 72 * HOUR - 1).toISOString(), ) ).id; await addAttendance(boundaryPastId, memberId, "yes"); await addAttendance(boundaryPastId, leaderAId, "yes"); memberToken = await mintToken(memberId); lonerToken = await mintToken(lonerId); }); afterAll(async () => { for (const id of attendanceIds) { await payload .delete({ collection: "mission-attendances", id, overrideAccess: true }) .catch(() => {}); } for (const id of missionIds) { await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {}); } for (const id of assignmentIds) { await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {}); } // User deletion trips FK constraints unless the hook-provisioned personal // bank account and profile are removed first. for (const id of userIds) { const accounts = await payload .find({ collection: "bank-accounts", where: { ownerUser: { equals: id } }, 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: id } }, 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, overrideAccess: true }).catch(() => {}); } await payload .delete({ collection: "campaigns", id: campaignId, overrideAccess: true }) .catch(() => {}); await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {}); }); it("rejects unauthenticated requests with 401", async () => { const res = await GET(request()); expect(res.status).toBe(401); }); it("returns only eligible missions with the yes-RSVP direct leaders", async () => { const res = await GET(request(memberToken)); expect(res.status).toBe(200); const body = (await res.json()) as { reminders: Array> }; const ids = body.reminders.map((reminder) => reminder.missionId as number); // Exactly the two eligible missions — nothing else. expect([...ids].sort()).toEqual([eligibleMainId, multiLeaderId].sort()); const byMissionId = new Map( body.reminders.map((reminder) => [reminder.missionId as number, reminder]), ); // Response shape: mission + leader identity only (no rater/comment data). const eligible = byMissionId.get(eligibleMainId)!; expect(Object.keys(eligible).sort()).toEqual([ "completedAt", "leaders", "missionCodeName", "missionId", "missionName", ]); expect(eligible.missionName).toBe(`${RUN} eligible-main`); expect(eligible.missionCodeName).toBe(`${RUN}-eligible-main`); const leaders = eligible.leaders as Array>; expect(leaders).toHaveLength(1); expect(Object.keys(leaders[0]).sort()).toEqual(["displayName", "id", "username"]); expect(leaders[0].id).toBe(leaderAId); expect(leaders[0].username).toBe(`${RUN}-leader-a`); expect(leaders[0].displayName).toBe("LEADER A"); // Both direct leaders who RSVP'd yes are reported. const multi = byMissionId.get(multiLeaderId)!; const multiLeaderIds = (multi.leaders as Array<{ id: number }>).map((leader) => leader.id); expect([...multiLeaderIds].sort()).toEqual([leaderAId, leaderBId].sort()); }); it("returns nothing for a user without direct leaders", async () => { const res = await GET(request(lonerToken)); expect(res.status).toBe(200); const body = (await res.json()) as { reminders: unknown[] }; expect(body.reminders).toEqual([]); }); it("treats the 72h window as inclusive and fails closed just past it", async () => { const result = await getMissionReminders(payload, { id: memberId }, fixedNow); const ids = result.map((reminder) => reminder.missionId); expect(ids).toContain(boundaryExactId); // exactly now - 72h is still eligible expect(ids).not.toContain(boundaryPastId); // one millisecond past the window is not }); });