409 lines
14 KiB
TypeScript
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
|
|
});
|
|
});
|