import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- const { mockSetMessages, mockPrependMessages, mockIsChannelLoaded, mockGetChannelMessages, mockSetChannelLoading, mockSetChannelLoadError, } = vi.hoisted(() => ({ mockSetMessages: vi.fn(), mockPrependMessages: vi.fn(), mockIsChannelLoaded: vi.fn((): boolean => false), mockGetChannelMessages: vi.fn((): Array<{ id: number; content?: string }> => []), mockSetChannelLoading: vi.fn(), mockSetChannelLoadError: vi.fn(), })); vi.mock("@lib/logger", () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }), })); vi.mock("@stores/messages.store", () => ({ setMessages: mockSetMessages, prependMessages: mockPrependMessages, isChannelLoaded: mockIsChannelLoaded, getChannelMessages: mockGetChannelMessages, setChannelLoading: mockSetChannelLoading, setChannelLoadError: mockSetChannelLoadError, })); // --------------------------------------------------------------------------- // Imports (after mocks) // --------------------------------------------------------------------------- import { createMessageController, createPendingDeleteManager, } from "../../src/pages/main-page/MessageController"; import type { MessageControllerOptions } from "../../src/pages/main-page/MessageController"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeApi(overrides: Partial = {}) { return { getMessages: vi.fn().mockResolvedValue({ messages: [{ id: 1, content: "hi" }], has_more: false, }), ...overrides, } as unknown as MessageControllerOptions["api"]; } function makeAbort(): { signal: AbortSignal; abort: () => void } { const ctrl = new AbortController(); return { signal: ctrl.signal, abort: () => ctrl.abort() }; } // --------------------------------------------------------------------------- // MessageController // --------------------------------------------------------------------------- describe("createMessageController", () => { let showError: Mock<(msg: string) => void>; beforeEach(() => { vi.clearAllMocks(); showError = vi.fn<(msg: string) => void>(); mockIsChannelLoaded.mockReturnValue(false); mockGetChannelMessages.mockReturnValue([]); }); describe("loadMessages", () => { it("loads messages and stores them", async () => { const api = makeApi(); const ctrl = createMessageController({ api, showError }); const { signal } = makeAbort(); await ctrl.loadMessages(42, signal); expect(api.getMessages).toHaveBeenCalledWith(42, { limit: 50 }, signal); expect(mockSetMessages).toHaveBeenCalledWith(42, [{ id: 1, content: "hi" }], false); }); it("marks the channel loading before the fetch resolves", async () => { const api = makeApi(); const ctrl = createMessageController({ api, showError }); const pending = ctrl.loadMessages(42, makeAbort().signal); // Synchronous prefix: the loading placeholder is visible from the // first render, before the first await. expect(mockSetChannelLoading).toHaveBeenCalledWith(42); await pending; }); it("skips fetch when channel is already loaded", async () => { mockIsChannelLoaded.mockReturnValue(true); const api = makeApi(); const ctrl = createMessageController({ api, showError }); await ctrl.loadMessages(42, makeAbort().signal); expect(api.getMessages).not.toHaveBeenCalled(); expect(mockSetMessages).not.toHaveBeenCalled(); expect(mockSetChannelLoading).not.toHaveBeenCalled(); }); it("does not store messages after abort", async () => { const { signal, abort } = makeAbort(); const api = makeApi({ getMessages: vi.fn().mockImplementation(async () => { abort(); return { messages: [{ id: 1 }], has_more: false }; }), }); const ctrl = createMessageController({ api, showError }); await ctrl.loadMessages(42, signal); expect(mockSetMessages).not.toHaveBeenCalled(); }); it("marks the channel load-errored on fetch failure (inline error, not a toast)", async () => { const api = makeApi({ getMessages: vi.fn().mockRejectedValue(new Error("network error")), }); const ctrl = createMessageController({ api, showError }); await ctrl.loadMessages(42, makeAbort().signal); // The region renders an inline error + Retry (UX spec §2); no toast. expect(mockSetChannelLoadError).toHaveBeenCalledWith(42); expect(showError).not.toHaveBeenCalled(); }); it("falls back to a toast on failure when the channel already has rows", async () => { // Live broadcasts or an optimistic send can populate a channel before // history loads; the inline region won't render then, so the failure // must surface as a toast instead of silently. mockGetChannelMessages.mockReturnValue([{ id: 5, content: "live row" }]); const api = makeApi({ getMessages: vi.fn().mockRejectedValue(new Error("network error")), }); const ctrl = createMessageController({ api, showError }); await ctrl.loadMessages(42, makeAbort().signal); expect(mockSetChannelLoadError).toHaveBeenCalledWith(42); expect(showError).toHaveBeenCalledWith("Failed to load message history"); }); it("does not mark an error when aborted before failure", async () => { const { signal, abort } = makeAbort(); abort(); const api = makeApi({ getMessages: vi.fn().mockRejectedValue(new Error("aborted")), }); const ctrl = createMessageController({ api, showError }); await ctrl.loadMessages(42, signal); expect(mockSetChannelLoadError).not.toHaveBeenCalled(); expect(showError).not.toHaveBeenCalled(); }); it("discards a stale tail response if the channel was loaded by something else while the fetch was in flight (e.g. a same-channel jump's around-window)", async () => { // Not loaded when the fetch starts (so it proceeds), but loaded by the // time it resolves — simulating MessageJump's setAroundMessages winning // the race and installing a window this response must not clobber. mockIsChannelLoaded.mockReturnValueOnce(false).mockReturnValueOnce(true); const api = makeApi(); const ctrl = createMessageController({ api, showError }); await ctrl.loadMessages(42, makeAbort().signal); expect(mockSetMessages).not.toHaveBeenCalled(); }); it("does not mark load-errored or toast when a stale tail fetch rejects after the channel was loaded by something else (e.g. a same-channel jump's around-window won the race)", async () => { // Not loaded when the fetch starts (so it proceeds past the early // return), but loaded by the time it rejects — simulating // MessageJump.jumpTo's setAroundMessages winning the race and // rendering the jump target while this mount-time tail fetch is still // in flight. The window it installed has rows, matching the real // repro where the user already sees a fully-loaded, correct channel. mockIsChannelLoaded.mockReturnValueOnce(false).mockReturnValueOnce(true); mockGetChannelMessages.mockReturnValue([{ id: 5, content: "around window row" }]); const api = makeApi({ getMessages: vi.fn().mockRejectedValue(new Error("network error")), }); const ctrl = createMessageController({ api, showError }); await ctrl.loadMessages(42, makeAbort().signal); // The jump already succeeded and rendered correctly — a losing tail // fetch must not retroactively flag the channel load-errored or pop an // error toast on top of a visibly-working jump. expect(mockSetChannelLoadError).not.toHaveBeenCalled(); expect(showError).not.toHaveBeenCalled(); }); }); describe("loadOlderMessages", () => { it("prepends older messages using oldest id", async () => { mockGetChannelMessages.mockReturnValue([ { id: 10, content: "oldest" }, { id: 20, content: "newest" }, ]); const api = makeApi({ getMessages: vi.fn().mockResolvedValue({ messages: [{ id: 5, content: "older" }], has_more: true, }), }); const ctrl = createMessageController({ api, showError }); const { signal } = makeAbort(); await ctrl.loadOlderMessages(42, signal); expect(api.getMessages).toHaveBeenCalledWith(42, { before: 10, limit: 50 }, signal); expect(mockPrependMessages).toHaveBeenCalledWith(42, [{ id: 5, content: "older" }], true); }); it("does nothing when channel has no messages", async () => { mockGetChannelMessages.mockReturnValue([]); const api = makeApi(); const ctrl = createMessageController({ api, showError }); await ctrl.loadOlderMessages(42, makeAbort().signal); expect(api.getMessages).not.toHaveBeenCalled(); }); it("shows error on fetch failure", async () => { mockGetChannelMessages.mockReturnValue([{ id: 1 }]); const api = makeApi({ getMessages: vi.fn().mockRejectedValue(new Error("fail")), }); const ctrl = createMessageController({ api, showError }); await ctrl.loadOlderMessages(42, makeAbort().signal); expect(showError).toHaveBeenCalledWith("Failed to load older messages"); }); it("does not show error when aborted before failure", async () => { mockGetChannelMessages.mockReturnValue([{ id: 1 }]); const { signal, abort } = makeAbort(); abort(); const api = makeApi({ getMessages: vi.fn().mockRejectedValue(new Error("aborted")), }); const ctrl = createMessageController({ api, showError }); await ctrl.loadOlderMessages(42, signal); expect(showError).not.toHaveBeenCalled(); }); it("does not prepend after abort", async () => { mockGetChannelMessages.mockReturnValue([{ id: 1 }]); const { signal, abort } = makeAbort(); const api = makeApi({ getMessages: vi.fn().mockImplementation(async () => { abort(); return { messages: [{ id: 0 }], has_more: false }; }), }); const ctrl = createMessageController({ api, showError }); await ctrl.loadOlderMessages(42, signal); expect(mockPrependMessages).not.toHaveBeenCalled(); }); it("discards a stale older-page if the window was replaced while the fetch was in flight (e.g. a same-channel jump swapped in an around-window)", async () => { // First read (before the fetch): oldest visible row is id 10. Second // read (after the await resolves): the window has already been // replaced — id 10 is no longer at the front — so splicing this page // onto it would duplicate/misorder rows. mockGetChannelMessages .mockReturnValueOnce([ { id: 10, content: "oldest" }, { id: 20, content: "newest" }, ]) .mockReturnValueOnce([ { id: 77, content: "replaced" }, { id: 78, content: "replaced2" }, ]); const api = makeApi({ getMessages: vi.fn().mockResolvedValue({ messages: [{ id: 5, content: "older" }], has_more: true, }), }); const ctrl = createMessageController({ api, showError }); await ctrl.loadOlderMessages(42, makeAbort().signal); expect(mockPrependMessages).not.toHaveBeenCalled(); }); }); }); // --------------------------------------------------------------------------- // PendingDeleteManager // --------------------------------------------------------------------------- describe("createPendingDeleteManager", () => { beforeEach(() => { vi.useFakeTimers(); }); it("returns 'pending' on first click", () => { const mgr = createPendingDeleteManager(); expect(mgr.tryDelete(1)).toBe("pending"); }); it("returns 'confirmed' on second click within timeout", () => { const mgr = createPendingDeleteManager(); mgr.tryDelete(1); expect(mgr.tryDelete(1)).toBe("confirmed"); }); it("returns 'pending' again after timeout expires", () => { const mgr = createPendingDeleteManager(); mgr.tryDelete(1); vi.advanceTimersByTime(5001); // just past the 5000ms pending timeout expect(mgr.tryDelete(1)).toBe("pending"); }); it("tracks multiple messages independently", () => { const mgr = createPendingDeleteManager(); mgr.tryDelete(1); mgr.tryDelete(2); expect(mgr.tryDelete(1)).toBe("confirmed"); expect(mgr.tryDelete(2)).toBe("confirmed"); }); it("cleanup clears all pending timeouts", () => { const mgr = createPendingDeleteManager(); mgr.tryDelete(1); mgr.tryDelete(2); mgr.cleanup(); // After cleanup, both should be fresh "pending" again expect(mgr.tryDelete(1)).toBe("pending"); expect(mgr.tryDelete(2)).toBe("pending"); }); afterEach(() => { vi.useRealTimers(); }); });