7ca855f19c
- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
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({});
|
|
});
|
|
});
|