Files
buzz-sheet/components/comments/admin-comment-inbox.test.tsx
T

178 lines
9.6 KiB
TypeScript

// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommentItem, CommentPage } from "@/lib/comments/types";
const events = vi.hoisted(() => ({ refresh: undefined as undefined | (() => Promise<unknown>) }));
vi.mock("./use-comment-events", () => ({ useCommentEvents: (_scope: unknown, refresh: () => Promise<unknown>) => { events.refresh = refresh; } }));
vi.mock("./comment-card", () => ({
CommentCard: ({ item, defaultExpanded, onChange, overview }: { item: CommentItem; defaultExpanded: boolean; onChange: () => Promise<unknown>; overview: React.ReactNode }) =>
<tr id={`comment-${item.id}`} data-expanded={defaultExpanded}><td>{item.text}<button onClick={() => void onChange()}>Update {item.id}</button></td><td>{overview}</td></tr>,
}));
import { AdminCommentInbox } from "./admin-comment-inbox";
const viewer = { id: "admin", admin: true, name: "Admin" };
const target = "stygian:test";
const selectedId = "00000000-0000-4000-8000-000000000001";
function comment(id: string, text = id): CommentItem {
return {
id, rootId: null, replyToId: null, replyToName: null,
authorName: "Author", authorImage: null, authorAdmin: false, text, images: [], version: 1,
createdAt: "2026-10-08T12:00:00Z", editedAt: "2026-10-08T12:00:00Z",
hidden: false, ownHidden: false, deleted: false, hearted: false, likes: 0, reaction: 0,
replyCount: 1, hasReplies: true, canEdit: true, canInteract: true, canModerate: true,
target, targetName: "Test round", targetImage: null, href: "/stygian/comment?schedule=test",
};
}
function page(items: CommentItem[], nextCursor: string | null = null): CommentPage { return { items, nextCursor, viewer }; }
let container: HTMLDivElement;
let root: Root;
let inbox: CommentPage;
let nextPage: CommentPage;
let selected: CommentItem;
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
events.refresh = undefined;
selected = comment(selectedId, "Selected conversation");
inbox = page([comment("newer", "Newer conversation")]);
nextPage = page([]);
fetchMock.mockReset();
fetchMock.mockImplementation(async (input) => {
const url = new URL(String(input), "https://guide.sudloh.com");
return Response.json(url.pathname === "/api/admin/comments" ? url.searchParams.has("cursor") ? nextPage : inbox : { item: selected, viewer });
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
async function mount(initial = inbox, notification: string | undefined = selectedId) {
await act(async () => root.render(<AdminCommentInbox initial={initial} initialTarget={target} initialComment={notification}
targets={[{ value: target, label: "Test round", image: null, rarity: null, characterKey: null }]} />));
}
function rows() { return [...container.querySelectorAll("tbody tr[id]")].map((row) => row.id); }
async function click(label: string) {
const button = [...container.querySelectorAll("button")].find((item) => item.textContent?.trim() === label);
expect(button).toBeDefined();
await act(async () => button!.click());
}
function singleRequests() { return fetchMock.mock.calls.filter(([url]) => String(url) === `/api/comments/${selectedId}`); }
describe("notification conversations in the admin inbox", () => {
it("defaults to newest and keeps oldest across pagination and refresh", async () => {
await mount(page(inbox.items, "newest-page"), "");
await click("รีเฟรช");
expect(new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams.get("sort")).toBe("newest");
inbox = page([comment("oldest")], "oldest-page");
await click("เก่าสุด");
expect(rows()).toEqual(["comment-oldest"]);
nextPage = page([comment("next-oldest")]);
await click("ดูความคิดเห็นเพิ่มเติม");
const pagination = new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams;
expect(pagination.get("sort")).toBe("oldest");
expect(pagination.get("cursor")).toBe("oldest-page");
await click("รีเฟรช");
expect(new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams.get("sort")).toBe("oldest");
await click("ล่าสุด");
const newest = new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams;
expect(newest.get("sort")).toBe("newest");
expect(newest.has("cursor")).toBe(false);
});
it("moves a loaded conversation to the first normal row, expands it, and avoids a second lookup", async () => {
await mount(page([...inbox.items, selected]));
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
expect(container.querySelector(`#comment-${selectedId}`)?.getAttribute("data-expanded")).toBe("true");
expect(container.querySelector(`#comment-${selectedId} td:last-child a`)?.textContent).toBe("Test round");
expect(container.textContent).not.toContain("การสนทนาจากการแจ้งเตือน");
expect(singleRequests()).toHaveLength(0);
});
it("loads an older conversation and deduplicates it when pagination catches up", async () => {
await mount(page(inbox.items, "next-page"));
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
expect(singleRequests()).toHaveLength(1);
nextPage = page([comment(selectedId, "Fresh page content"), comment("oldest")]);
await click("ดูความคิดเห็นเพิ่มเติม");
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer", "comment-oldest"]);
expect(container.textContent).toContain("Fresh page content");
expect(singleRequests()).toHaveLength(1);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("cursor=next-page"))).toBe(true);
});
it("keeps an older conversation fresh after mutations, manual refresh, and live updates", async () => {
await mount();
selected = comment(selectedId, "After reply or moderation");
await click(`Update ${selectedId}`);
expect(container.textContent).toContain("After reply or moderation");
selected = comment(selectedId, "After manual refresh");
await click("รีเฟรช");
expect(container.textContent).toContain("After manual refresh");
selected = comment(selectedId, "After live update");
await act(async () => { await events.refresh!(); });
expect(container.textContent).toContain("After live update");
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
});
it.each(["visibility", "answered", "target", "sort"])("clears the notification selection when the %s filter changes", async (filter) => {
await mount();
inbox = page([comment("filtered")]);
if (filter === "target") {
const clear = container.querySelector<HTMLButtonElement>('[data-slot="combobox-clear"]');
expect(clear).not.toBeNull();
await act(async () => clear!.click());
} else await click(filter === "visibility" ? "ซ่อนอยู่" : filter === "sort" ? "เก่าสุด" : "ยังไม่ได้ตอบ");
expect(rows()).toEqual(["comment-filtered"]);
expect(singleRequests()).toHaveLength(1);
const query = new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams;
if (filter === "target") expect(query.has("target")).toBe(false);
else if (filter === "sort") expect(query.get("sort")).toBe("oldest");
else expect(query.get(filter === "visibility" ? "status" : "unanswered")).toBe(filter === "visibility" ? "hidden" : "true");
});
it("shows loading without an empty state and ignores a late lookup after filtering", async () => {
let resolve!: (response: Response) => void;
fetchMock.mockImplementationOnce(() => new Promise<Response>((done) => { resolve = done; }));
await mount(page([]));
expect(container.querySelector('[role="status"]')).not.toBeNull();
expect(container.textContent).not.toContain("ไม่พบความคิดเห็นที่ตรงกัน");
const signal = singleRequests()[0][1]?.signal;
await click("ซ่อนอยู่");
expect(signal?.aborted).toBe(true);
await act(async () => resolve(Response.json({ item: selected, viewer })));
expect(rows()).toEqual(["comment-newer"]);
});
it.each(["missing", "wrong target", "reply"])("shows an inline error for a %s conversation while keeping the list usable", async (failure) => {
if (failure === "missing") fetchMock.mockResolvedValueOnce(Response.json({ error: "comment-not-found" }, { status: 404 }));
else selected = { ...selected, ...(failure === "reply" ? { rootId: "another-root" } : { target: "guide:other" }) };
await mount();
expect(container.querySelector('[role="alert"]')).not.toBeNull();
expect(rows()).toEqual(["comment-newer"]);
expect(container.querySelector('[role="status"]')).toBeNull();
selected = comment(selectedId, "Recovered conversation");
await click("รีเฟรช");
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
});
it("preserves the normal inbox order without a notification", async () => {
await mount(page([comment("newer"), selected]), "");
expect(rows()).toEqual(["comment-newer", `comment-${selectedId}`]);
expect(singleRequests()).toHaveLength(0);
});
});