1
0
Fork 0
polaris-task-force/tests/int/mission-reminder.int.spec.ts

409 lines
14 KiB
TypeScript

// @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<string> {
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<User> => {
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<Assignment> => {
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<Mission> => {
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<Record<string, unknown>> };
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<Record<string, unknown>>;
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
});
});