feat(intelligence): add mission feedback reminders
This commit is contained in:
parent
c2a16120cc
commit
d94da34af2
6 changed files with 1119 additions and 0 deletions
30
src/app/api/mission-reminder/route.ts
Normal file
30
src/app/api/mission-reminder/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import { getMissionReminders } from "@/lib/intelligence/missionReminder";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET /api/mission-reminder
|
||||
*
|
||||
* Returns recently completed main operations (within 72h of completion) where the
|
||||
* authenticated user RSVP'd "yes" and at least one of their assignment-based
|
||||
* direct leaders also RSVP'd "yes". The client uses this to prompt mission
|
||||
* feedback. Leader entries carry identity only — no rater/comment data.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const payload = await getPayload({ config: await config });
|
||||
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
|
||||
if (!isPayloadUser(user)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
try {
|
||||
const reminders = await getMissionReminders(payload, user);
|
||||
return NextResponse.json({ reminders });
|
||||
} catch (error) {
|
||||
console.error("mission-reminder: failed to load reminders", error);
|
||||
return NextResponse.json({ error: "Failed to load mission reminders." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
234
src/components/frontend/intelligence/MissionReminderToasts.tsx
Normal file
234
src/components/frontend/intelligence/MissionReminderToasts.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
|
||||
import {
|
||||
buildReminderEntries,
|
||||
parseMissionReminders,
|
||||
readReminderDismissals,
|
||||
writeReminderDismissals,
|
||||
type Dismissals,
|
||||
type MissionReminder,
|
||||
type ReminderEntry,
|
||||
} from "@/components/frontend/intelligence/missionReminderClient";
|
||||
|
||||
const POLL_MS = 60_000;
|
||||
|
||||
export function MissionReminderToasts() {
|
||||
const router = useRouter();
|
||||
const mountedRef = useRef(false);
|
||||
const fetchInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const expiryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pumpScheduledRef = useRef(false);
|
||||
const pumpQueueRef = useRef<() => void>(() => undefined);
|
||||
const dismissedRef = useRef<Dismissals>({});
|
||||
const currentRef = useRef(new Map<string, ReminderEntry>());
|
||||
const queueRef = useRef<ReminderEntry[]>([]);
|
||||
const queuedKeysRef = useRef(new Set<string>());
|
||||
const shownKeysRef = useRef(new Set<string>());
|
||||
const activeToastRef = useRef<{ key: string; toastId: string } | null>(null);
|
||||
|
||||
const schedulePump = useCallback(() => {
|
||||
if (!mountedRef.current || pumpScheduledRef.current) return;
|
||||
pumpScheduledRef.current = true;
|
||||
setTimeout(() => {
|
||||
pumpScheduledRef.current = false;
|
||||
pumpQueueRef.current();
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const finishToast = useCallback(
|
||||
(key: string, toastId: string) => {
|
||||
const active = activeToastRef.current;
|
||||
if (!active || active.key !== key || active.toastId !== toastId) return;
|
||||
activeToastRef.current = null;
|
||||
schedulePump();
|
||||
},
|
||||
[schedulePump],
|
||||
);
|
||||
|
||||
const scheduleExpiryCheck = useCallback(() => {
|
||||
if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current);
|
||||
const now = Date.now();
|
||||
const nextExpiry = [...currentRef.current.values()]
|
||||
.map((entry) => entry.expiresAt)
|
||||
.filter((expiresAt) => expiresAt > now)
|
||||
.sort((first, second) => first - second)[0];
|
||||
if (nextExpiry === undefined) return;
|
||||
|
||||
expiryTimerRef.current = setTimeout(
|
||||
() => {
|
||||
expiryTimerRef.current = null;
|
||||
const currentNow = Date.now();
|
||||
for (const [key, entry] of currentRef.current) {
|
||||
if (entry.expiresAt <= currentNow) currentRef.current.delete(key);
|
||||
}
|
||||
queueRef.current = queueRef.current.filter((entry) => {
|
||||
const current = currentRef.current.get(entry.key);
|
||||
const keep = current?.expiresAt === entry.expiresAt && entry.expiresAt > currentNow;
|
||||
if (!keep) queuedKeysRef.current.delete(entry.key);
|
||||
return keep;
|
||||
});
|
||||
|
||||
const active = activeToastRef.current;
|
||||
if (active && !currentRef.current.has(active.key)) {
|
||||
activeToastRef.current = null;
|
||||
toast.dismiss(active.toastId);
|
||||
}
|
||||
scheduleExpiryCheck();
|
||||
schedulePump();
|
||||
},
|
||||
Math.max(0, nextExpiry - now + 1),
|
||||
);
|
||||
}, [schedulePump]);
|
||||
|
||||
const applyReminders = useCallback(
|
||||
(reminders: readonly MissionReminder[]) => {
|
||||
const now = Date.now();
|
||||
const entries = buildReminderEntries(reminders, now);
|
||||
const next = new Map(entries.map((entry) => [entry.key, entry]));
|
||||
|
||||
const active = activeToastRef.current;
|
||||
if (active && !next.has(active.key)) {
|
||||
activeToastRef.current = null;
|
||||
toast.dismiss(active.toastId);
|
||||
}
|
||||
|
||||
currentRef.current = next;
|
||||
queueRef.current = queueRef.current.filter((entry) => {
|
||||
const current = next.get(entry.key);
|
||||
const keep = current?.expiresAt === entry.expiresAt;
|
||||
if (!keep) queuedKeysRef.current.delete(entry.key);
|
||||
return keep;
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!shownKeysRef.current.has(entry.key) &&
|
||||
!queuedKeysRef.current.has(entry.key) &&
|
||||
dismissedRef.current[entry.key] !== undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!shownKeysRef.current.has(entry.key) && !queuedKeysRef.current.has(entry.key)) {
|
||||
queueRef.current.push(entry);
|
||||
queuedKeysRef.current.add(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleExpiryCheck();
|
||||
schedulePump();
|
||||
},
|
||||
[scheduleExpiryCheck, schedulePump],
|
||||
);
|
||||
|
||||
const fetchReminders = useCallback(async () => {
|
||||
if (fetchInFlightRef.current) return fetchInFlightRef.current;
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/mission-reminder", { cache: "no-store" });
|
||||
if (!response.ok || !mountedRef.current) return;
|
||||
const body: unknown = await response.json();
|
||||
if (mountedRef.current) applyReminders(parseMissionReminders(body));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
fetchInFlightRef.current = request;
|
||||
try {
|
||||
await request;
|
||||
} finally {
|
||||
if (fetchInFlightRef.current === request) fetchInFlightRef.current = null;
|
||||
}
|
||||
}, [applyReminders]);
|
||||
|
||||
const pumpQueue = useCallback(() => {
|
||||
if (!mountedRef.current || activeToastRef.current) return;
|
||||
|
||||
const now = Date.now();
|
||||
let entry: ReminderEntry | undefined;
|
||||
while (queueRef.current.length > 0) {
|
||||
const candidate = queueRef.current.shift();
|
||||
if (!candidate) break;
|
||||
queuedKeysRef.current.delete(candidate.key);
|
||||
const current = currentRef.current.get(candidate.key);
|
||||
if (
|
||||
!current ||
|
||||
current.expiresAt !== candidate.expiresAt ||
|
||||
candidate.expiresAt <= now ||
|
||||
shownKeysRef.current.has(candidate.key) ||
|
||||
dismissedRef.current[candidate.key] !== undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
entry = candidate;
|
||||
break;
|
||||
}
|
||||
if (!entry) return;
|
||||
|
||||
const toastId = `mission-reminder:${entry.key}`;
|
||||
const leaderName = entry.leader.displayName || entry.leader.username;
|
||||
const missionName = entry.missionName || entry.missionCodeName;
|
||||
shownKeysRef.current.add(entry.key);
|
||||
activeToastRef.current = { key: entry.key, toastId };
|
||||
|
||||
toast("Mission feedback", {
|
||||
id: toastId,
|
||||
description: `Rate ${leaderName}'s leadership on ${missionName}.`,
|
||||
action: {
|
||||
label: "Rate leader",
|
||||
onClick: () => {
|
||||
toast.dismiss(toastId);
|
||||
finishToast(entry.key, toastId);
|
||||
router.push(`/profile/${encodeURIComponent(entry.leader.username)}`);
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: "Dismiss reminder",
|
||||
onClick: () => {
|
||||
dismissedRef.current[entry.key] = entry.expiresAt;
|
||||
writeReminderDismissals(dismissedRef.current, Date.now());
|
||||
toast.dismiss(toastId);
|
||||
finishToast(entry.key, toastId);
|
||||
},
|
||||
},
|
||||
onAutoClose: () => finishToast(entry.key, toastId),
|
||||
onDismiss: () => finishToast(entry.key, toastId),
|
||||
});
|
||||
}, [finishToast, router]);
|
||||
|
||||
pumpQueueRef.current = pumpQueue;
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const now = Date.now();
|
||||
dismissedRef.current = readReminderDismissals(now);
|
||||
writeReminderDismissals(dismissedRef.current, now);
|
||||
void fetchReminders();
|
||||
|
||||
const interval = setInterval(() => void fetchReminders(), POLL_MS);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearInterval(interval);
|
||||
if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current);
|
||||
const active = activeToastRef.current;
|
||||
activeToastRef.current = null;
|
||||
if (active) toast.dismiss(active.toastId);
|
||||
queueRef.current = [];
|
||||
queuedKeysRef.current.clear();
|
||||
currentRef.current.clear();
|
||||
};
|
||||
}, [fetchReminders]);
|
||||
|
||||
useGameTick(() => {
|
||||
void fetchReminders();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
122
src/components/frontend/intelligence/missionReminderClient.ts
Normal file
122
src/components/frontend/intelligence/missionReminderClient.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
const REMINDER_EXPIRY_MS = 72 * 60 * 60 * 1000;
|
||||
const DISMISSALS_STORAGE_KEY = "ptf:mission-reminder:dismissals";
|
||||
|
||||
export type ReminderLeader = {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly displayName: string;
|
||||
};
|
||||
|
||||
export type MissionReminder = {
|
||||
readonly missionId: number;
|
||||
readonly missionName: string;
|
||||
readonly missionCodeName: string;
|
||||
readonly completedAt: string;
|
||||
readonly leaders: readonly ReminderLeader[];
|
||||
};
|
||||
|
||||
export type ReminderEntry = MissionReminder & {
|
||||
readonly key: string;
|
||||
readonly leader: ReminderLeader;
|
||||
readonly expiresAt: number;
|
||||
};
|
||||
|
||||
export type Dismissals = Record<string, number>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isReminderLeader(value: unknown): value is ReminderLeader {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.id === "number" &&
|
||||
typeof value.username === "string" &&
|
||||
typeof value.displayName === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isMissionReminder(value: unknown): value is MissionReminder {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.missionId === "number" &&
|
||||
typeof value.missionName === "string" &&
|
||||
typeof value.missionCodeName === "string" &&
|
||||
typeof value.completedAt === "string" &&
|
||||
Array.isArray(value.leaders) &&
|
||||
value.leaders.every(isReminderLeader)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMissionReminders(value: unknown): readonly MissionReminder[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.reminders)) return [];
|
||||
return value.reminders.filter(isMissionReminder);
|
||||
}
|
||||
|
||||
export function missionReminderKey(missionId: number, leaderId: number): string {
|
||||
return `${missionId}:${leaderId}`;
|
||||
}
|
||||
|
||||
export function buildReminderEntries(
|
||||
reminders: readonly MissionReminder[],
|
||||
now: number,
|
||||
): readonly ReminderEntry[] {
|
||||
const entries: ReminderEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const reminder of reminders) {
|
||||
const completedAt = Date.parse(reminder.completedAt);
|
||||
if (!Number.isFinite(completedAt)) continue;
|
||||
const expiresAt = completedAt + REMINDER_EXPIRY_MS;
|
||||
if (expiresAt <= now) continue;
|
||||
|
||||
for (const leader of reminder.leaders) {
|
||||
const key = missionReminderKey(reminder.missionId, leader.id);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push({ ...reminder, key, leader, expiresAt });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Browser storage is read during mount and written during mount or the
|
||||
// explicit dismissal handler; it is never accessed during render.
|
||||
export function readReminderDismissals(now: number): Dismissals {
|
||||
if (typeof window === "undefined") return {};
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DISMISSALS_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) return {};
|
||||
|
||||
const dismissals: Dismissals = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > now) {
|
||||
dismissals[key] = value;
|
||||
}
|
||||
}
|
||||
return dismissals;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return {};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeReminderDismissals(dismissals: Dismissals, now: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const activeDismissals: Dismissals = {};
|
||||
for (const [key, expiresAt] of Object.entries(dismissals)) {
|
||||
if (expiresAt > now) activeDismissals[key] = expiresAt;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(activeDismissals));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
174
src/lib/intelligence/missionReminder.ts
Normal file
174
src/lib/intelligence/missionReminder.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import type { Payload } from "payload";
|
||||
|
||||
/**
|
||||
* Mission feedback reminder window: a completed main operation only prompts for
|
||||
* leader feedback within 72 hours of its completion timestamp.
|
||||
*/
|
||||
export const MISSION_REMINDER_WINDOW_MS = 72 * 60 * 60 * 1000;
|
||||
|
||||
/** Leader identity, enough to render a link to `/profile/[username]`. */
|
||||
export interface ReminderLeader {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/** One eligible mission with the direct leaders who RSVP'd "yes". */
|
||||
export interface MissionReminder {
|
||||
missionId: number;
|
||||
missionName: string;
|
||||
missionCodeName: string;
|
||||
completedAt: string;
|
||||
leaders: ReminderLeader[];
|
||||
}
|
||||
|
||||
function refId(value: number | { id: number } | null | undefined): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (value && typeof value.id === "number") return value.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's direct leaders, using the same assignment model as
|
||||
* `isDirectLeaderOf` in `@/lib/evaluations`: a leader is anyone who leads an
|
||||
* assignment whose members include this user.
|
||||
*/
|
||||
async function findDirectLeaderIds(payload: Payload, userId: number): Promise<number[]> {
|
||||
const res = await payload.find({
|
||||
collection: "assignments",
|
||||
where: { members: { contains: userId } },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const ids = new Set<number>();
|
||||
for (const assignment of res.docs) {
|
||||
const leaderId = refId(assignment.leader);
|
||||
if (leaderId !== null) ids.add(leaderId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recently completed main operations that need feedback from `user`: the mission
|
||||
* is a main operation, transitioned to Completed within the last 72 hours
|
||||
* (`ownershipAndStatus.completedAt`), `user` RSVP'd "yes", and at least one of
|
||||
* the user's assignment-based direct leaders also RSVP'd "yes".
|
||||
*
|
||||
* Fail-closed: missions with a missing or unparseable completion timestamp are
|
||||
* never eligible, and `updatedAt` is never used as a completion proxy.
|
||||
*/
|
||||
export async function getMissionReminders(
|
||||
payload: Payload,
|
||||
user: { id: number },
|
||||
now: Date = new Date(),
|
||||
): Promise<MissionReminder[]> {
|
||||
const cutoff = new Date(now.getTime() - MISSION_REMINDER_WINDOW_MS);
|
||||
|
||||
// One query for the window + type + status. Missions without a completion
|
||||
// timestamp cannot match the date comparison, and are re-checked below anyway.
|
||||
const missionsRes = await payload.find({
|
||||
collection: "missions",
|
||||
where: {
|
||||
and: [
|
||||
{ operationType: { equals: "main" } },
|
||||
{ "ownershipAndStatus.status": { equals: "Completed" } },
|
||||
{ "ownershipAndStatus.completedAt": { greater_than_equal: cutoff.toISOString() } },
|
||||
],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const candidates = missionsRes.docs
|
||||
.map((mission) => {
|
||||
const completedAt = mission.ownershipAndStatus?.completedAt;
|
||||
if (!completedAt || typeof completedAt !== "string") return null; // fail closed on missing
|
||||
const time = new Date(completedAt).getTime();
|
||||
if (Number.isNaN(time)) return null; // fail closed on unknown data
|
||||
if (time < cutoff.getTime()) return null;
|
||||
return { mission, completedAt };
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
candidate,
|
||||
): candidate is { mission: (typeof missionsRes.docs)[number]; completedAt: string } =>
|
||||
candidate !== null,
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const missionIds = candidates.map(({ mission }) => mission.id);
|
||||
|
||||
// All "yes" RSVPs for the candidate missions in a single query.
|
||||
const attendanceRes = await payload.find({
|
||||
collection: "mission-attendances",
|
||||
where: {
|
||||
and: [{ mission: { in: missionIds } }, { response: { equals: "yes" } }],
|
||||
},
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const yesByMission = new Map<number, Set<number>>();
|
||||
for (const doc of attendanceRes.docs) {
|
||||
const missionId = refId(doc.mission);
|
||||
const userId = refId(doc.user);
|
||||
if (missionId === null || userId === null) continue;
|
||||
let yesUsers = yesByMission.get(missionId);
|
||||
if (!yesUsers) {
|
||||
yesUsers = new Set<number>();
|
||||
yesByMission.set(missionId, yesUsers);
|
||||
}
|
||||
yesUsers.add(userId);
|
||||
}
|
||||
|
||||
const leaderIds = await findDirectLeaderIds(payload, user.id);
|
||||
if (leaderIds.length === 0) return [];
|
||||
const leaderSet = new Set(leaderIds);
|
||||
|
||||
const eligible: Array<{
|
||||
mission: (typeof missionsRes.docs)[number];
|
||||
completedAt: string;
|
||||
leaderIds: number[];
|
||||
}> = [];
|
||||
for (const { mission, completedAt } of candidates) {
|
||||
const yesUsers = yesByMission.get(mission.id) ?? new Set<number>();
|
||||
if (!yesUsers.has(user.id)) continue; // the user must have RSVP'd "yes"
|
||||
const leaders = [...yesUsers].filter((id) => leaderSet.has(id));
|
||||
if (leaders.length === 0) continue; // at least one direct leader must have RSVP'd "yes"
|
||||
eligible.push({ mission, completedAt, leaderIds: leaders });
|
||||
}
|
||||
|
||||
if (eligible.length === 0) return [];
|
||||
|
||||
const allLeaderIds = [...new Set(eligible.flatMap((entry) => entry.leaderIds))];
|
||||
const usersRes = await payload.find({
|
||||
collection: "users",
|
||||
where: { id: { in: allLeaderIds } },
|
||||
limit: allLeaderIds.length,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const leaderById = new Map<number, ReminderLeader>();
|
||||
for (const doc of usersRes.docs) {
|
||||
if (typeof doc.id !== "number") continue;
|
||||
leaderById.set(doc.id, {
|
||||
id: doc.id,
|
||||
username: String(doc.username ?? ""),
|
||||
displayName: String(doc.displayName ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
return eligible.map(({ mission, completedAt, leaderIds }) => ({
|
||||
missionId: mission.id,
|
||||
missionName: mission.name,
|
||||
missionCodeName: mission.codeName,
|
||||
completedAt,
|
||||
leaders: leaderIds
|
||||
.map((id) => leaderById.get(id))
|
||||
.filter((leader): leader is ReminderLeader => leader !== undefined),
|
||||
}));
|
||||
}
|
||||
150
tests/int/mission-reminder-toasts.int.spec.ts
Normal file
150
tests/int/mission-reminder-toasts.int.spec.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { createElement } from "react";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "sonner";
|
||||
import { GAME_TICK_EVENT } from "@/hooks/useGameTick";
|
||||
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
||||
|
||||
const routerPush = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => {
|
||||
const mockedToast = Object.assign(vi.fn(), { dismiss: vi.fn() });
|
||||
return { toast: mockedToast };
|
||||
});
|
||||
|
||||
type Leader = {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
type Reminder = {
|
||||
missionId: number;
|
||||
missionName: string;
|
||||
missionCodeName: string;
|
||||
completedAt: string;
|
||||
leaders: readonly Leader[];
|
||||
};
|
||||
|
||||
const reminder: Reminder = {
|
||||
missionId: 42,
|
||||
missionName: "Operation Nightfall",
|
||||
missionCodeName: "OP:NIGHTFALL",
|
||||
completedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
leaders: [
|
||||
{ id: 7, username: "leader-one", displayName: "Leader One" },
|
||||
{ id: 8, username: "leader-two", displayName: "Leader Two" },
|
||||
],
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const mockedToast = vi.mocked(toast);
|
||||
|
||||
function responseFor(reminders: readonly Reminder[]): Response {
|
||||
return new Response(JSON.stringify({ reminders }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function firstToastOptions() {
|
||||
const options = mockedToast.mock.calls[0]?.[1];
|
||||
if (!options) throw new Error("Expected the first reminder toast to have options.");
|
||||
return options;
|
||||
}
|
||||
|
||||
function invokeButton(button: unknown): void {
|
||||
if (typeof button !== "object" || button === null || !("onClick" in button)) {
|
||||
throw new Error("Expected a Sonner button contract.");
|
||||
}
|
||||
const onClick = button.onClick;
|
||||
if (typeof onClick !== "function") throw new Error("Expected a Sonner button handler.");
|
||||
onClick(new MouseEvent("click"));
|
||||
}
|
||||
|
||||
describe("MissionReminderToasts", () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockedToast.mockReturnValue("mission-reminder:42:7");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows eligible leaders one at a time and links the action to the leader profile", async () => {
|
||||
fetchMock.mockResolvedValue(responseFor([reminder]));
|
||||
|
||||
render(createElement(MissionReminderToasts));
|
||||
|
||||
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
|
||||
expect(firstToastOptions().action).toMatchObject({ label: "Rate leader" });
|
||||
expect(firstToastOptions().cancel).toMatchObject({ label: "Dismiss reminder" });
|
||||
|
||||
invokeButton(firstToastOptions().action);
|
||||
expect(routerPush).toHaveBeenCalledWith("/profile/leader-one");
|
||||
|
||||
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(2));
|
||||
expect(mockedToast.mock.calls[1]?.[1]?.description).toContain("Leader Two");
|
||||
});
|
||||
|
||||
it("does not issue a second request while a realtime refresh overlaps the initial poll", async () => {
|
||||
let resolveFetch: ((value: Response) => void) | undefined;
|
||||
fetchMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(createElement(MissionReminderToasts));
|
||||
act(() => window.dispatchEvent(new Event(GAME_TICK_EVENT)));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
resolveFetch?.(responseFor([reminder]));
|
||||
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("persists an explicit dismissal for that mission and leader only", async () => {
|
||||
fetchMock.mockResolvedValue(responseFor([reminder]));
|
||||
render(createElement(MissionReminderToasts));
|
||||
|
||||
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
|
||||
invokeButton(firstToastOptions().cancel);
|
||||
|
||||
const stored = localStorage.getItem("ptf:mission-reminder:dismissals");
|
||||
expect(stored).toContain('"42:7"');
|
||||
expect(stored).not.toContain('"42:8"');
|
||||
|
||||
cleanup();
|
||||
mockedToast.mockClear();
|
||||
fetchMock.mockResolvedValue(responseFor([reminder]));
|
||||
render(createElement(MissionReminderToasts));
|
||||
|
||||
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
|
||||
expect(mockedToast.mock.calls[0]?.[1]?.description).toContain("Leader Two");
|
||||
});
|
||||
|
||||
it("does not show a reminder whose completion window has expired", async () => {
|
||||
const expiredReminder = {
|
||||
...reminder,
|
||||
completedAt: new Date(Date.now() - 73 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
fetchMock.mockResolvedValue(responseFor([expiredReminder]));
|
||||
|
||||
render(createElement(MissionReminderToasts));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockedToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
409
tests/int/mission-reminder.int.spec.ts
Normal file
409
tests/int/mission-reminder.int.spec.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
// @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
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue