// @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) })); vi.mock("./use-comment-events", () => ({ useCommentEvents: (_scope: unknown, refresh: () => Promise) => { events.refresh = refresh; } })); vi.mock("./comment-card", () => ({ CommentCard: ({ item, defaultExpanded, onChange, overview }: { item: CommentItem; defaultExpanded: boolean; onChange: () => Promise; overview: React.ReactNode }) => {item.text}{overview}, })); 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(); 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()); } 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('[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((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); }); });