import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { flushPromises } from "@vue/test-utils"; import { api, useAuthStore } from "./auth"; import { useChatStore } from "./chat"; import type { Conversation } from "./chat"; import type { ChatMessage } from "./messages"; const socket = vi.hoisted(() => ({ connected: false, on: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), })); vi.mock("../api/socket", () => ({ createChatSocket: () => socket, sendSocketMessage: vi.fn(), })); const me = { accountId: "a", publicId: "p", nickname: null, avatarColor: null, bio: null, profileReviewStatus: null, }; function message(seq: number): ChatMessage { return { id: `m-${seq}`, conversationId: "c-1", sender: { publicId: "p" }, clientMsgId: `client-${seq}`, seq: String(seq), text: String(seq), status: "sent", sentAt: new Date(seq).toISOString(), }; } const conversation = (): Conversation => ({ id: "c-1", status: "ACTIVE", lastMessageAt: new Date(0).toISOString(), lastMessage: null, unread: "0", lastReadSeq: "0", }); beforeEach(() => { setActivePinia(createPinia()); vi.restoreAllMocks(); socket.connected = false; socket.disconnect.mockClear(); }); describe("chat history synchronization", () => { it("loads every page beyond 100 messages and reliably deduplicates overlap", async () => { const pages = [ Array.from({ length: 100 }, (_, index) => message(index + 1)), Array.from({ length: 100 }, (_, index) => message(index + 100)), Array.from({ length: 51 }, (_, index) => message(index + 199)), [], ]; const get = vi .spyOn(api, "get") .mockImplementation(() => Promise.resolve({ items: pages.shift() ?? [] }), ); const chat = useChatStore(); const result = await chat.loadMessages("c-1"); expect(result).toBeDefined(); expect(result ?? []).toHaveLength(249); expect(result?.at(-1)?.seq).toBe("249"); expect(get).toHaveBeenCalledTimes(3); expect(get.mock.calls.map(([path]) => path)).toEqual([ "/conversations/c-1/messages?afterSeq=0&limit=100", "/conversations/c-1/messages?afterSeq=100&limit=100", "/conversations/c-1/messages?afterSeq=199&limit=100", ]); }); it("stops paging after the page cap to bound waterfall requests", async () => { const pages = [ Array.from({ length: 100 }, (_, index) => message(index + 1)), Array.from({ length: 100 }, (_, index) => message(index + 101)), Array.from({ length: 100 }, (_, index) => message(index + 201)), Array.from({ length: 100 }, (_, index) => message(index + 301)), Array.from({ length: 100 }, (_, index) => message(index + 401)), [], ]; const get = vi .spyOn(api, "get") .mockImplementation(() => Promise.resolve({ items: pages.shift() ?? [] }), ); const chat = useChatStore(); const result = await chat.loadMessages("c-1"); expect(result ?? []).toHaveLength(500); expect(get.mock.calls.length).toBeLessThanOrEqual(5); expect(get.mock.calls.at(-1)?.[0]).toBe( "/conversations/c-1/messages?afterSeq=400&limit=100", ); }); it("syncs only the most recent active conversations when reconnecting", async () => { const get = vi.spyOn(api, "get").mockResolvedValue({ items: [] }); const chat = useChatStore(); chat.conversations = Array.from({ length: 30 }, (_, index) => { const id = `c-${index}`; return { id, status: "ACTIVE", lastMessageAt: new Date(1700000000000 + index).toISOString(), lastMessage: null, unread: "0", lastReadSeq: "0", }; }); await chat.syncAll(); expect( get.mock.calls.filter(([path]) => path.includes("/messages")), ).toHaveLength(5); expect( get.mock.calls.filter(([path]) => path.includes("/messages"))[0]![0], ).toContain("c-29"); }); }); describe("chat session lifecycle", () => { it("disconnects and clears account state when authentication ends", async () => { vi.spyOn(api, "post").mockResolvedValue({ accessToken: "fresh" }); vi.spyOn(api, "get").mockResolvedValue(me); const auth = useAuthStore(); await auth.login("13800000000", "123456", "device"); expect(auth.isAuthenticated).toBe(true); const chat = useChatStore(); chat.conversations = [conversation()]; chat.messages = { "c-1": [message(1)] }; expect(chat.conversations).toHaveLength(1); auth.me = null; await flushPromises(); expect(auth.isAuthenticated).toBe(false); expect(socket.disconnect).toHaveBeenCalled(); expect(chat.conversations).toEqual([]); expect(chat.messages).toEqual({}); expect(chat.connected).toBe(false); }); it("reset clears conversations and disconnects even when called directly", () => { const auth = useAuthStore(); auth.me = me; const chat = useChatStore(); chat.conversations = [conversation()]; chat.messages = { "c-1": [message(1)] }; chat.reset(); expect(socket.disconnect).toHaveBeenCalled(); expect(chat.conversations).toEqual([]); expect(chat.messages).toEqual({}); }); });