feat(web): 构建漂流瓶移动端 PWA
- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user