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(); 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((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(); }); });