| |
| |
| |
| |
| |
| |
| import { describe, it, expect, beforeEach, vi } from "vitest"; |
| import { |
| chatStorageKey, |
| saveMessages, |
| loadMessages, |
| getStableAnonId, |
| } from "../src/utils/chat-persistence"; |
|
|
| |
| const store: Record<string, string> = {}; |
| const localStorageMock = { |
| getItem: vi.fn((key: string) => store[key] ?? null), |
| setItem: vi.fn((key: string, value: string) => { store[key] = value; }), |
| removeItem: vi.fn((key: string) => { delete store[key]; }), |
| clear: vi.fn(() => { for (const k in store) delete store[k]; }), |
| get length() { return Object.keys(store).length; }, |
| key: vi.fn((_i: number) => null), |
| }; |
|
|
| vi.stubGlobal("localStorage", localStorageMock); |
|
|
| beforeEach(() => { |
| localStorageMock.clear(); |
| vi.clearAllMocks(); |
| }); |
|
|
| |
| function msg(overrides: Record<string, unknown> = {}) { |
| return { |
| id: "m1", |
| role: "user" as const, |
| parts: [{ type: "text", text: "hello" }], |
| content: "hello", |
| createdAt: new Date("2025-06-01T12:00:00Z"), |
| ...overrides, |
| }; |
| } |
|
|
| describe("chatStorageKey", () => { |
| it("builds key from user name and scope", () => { |
| expect(chatStorageKey("alice", "article")).toBe("chat:alice:article"); |
| }); |
|
|
| it("handles embed scopes", () => { |
| expect(chatStorageKey("bob", "embed:banner.html")).toBe("chat:bob:embed:banner.html"); |
| }); |
| }); |
|
|
| describe("saveMessages / loadMessages round-trip", () => { |
| it("saves and loads a single message", () => { |
| const m = msg(); |
| saveMessages("alice", "article", [m as any]); |
|
|
| const loaded = loadMessages("alice", "article"); |
| expect(loaded).toBeDefined(); |
| expect(loaded).toHaveLength(1); |
| expect(loaded![0].id).toBe("m1"); |
| expect(loaded![0].role).toBe("user"); |
| }); |
|
|
| it("restores createdAt as a Date object", () => { |
| const m = msg(); |
| saveMessages("alice", "article", [m as any]); |
|
|
| const loaded = loadMessages("alice", "article")! as any; |
| expect(loaded[0].createdAt).toBeInstanceOf(Date); |
| expect(loaded[0].createdAt!.toISOString()).toBe("2025-06-01T12:00:00.000Z"); |
| }); |
|
|
| it("handles messages without createdAt", () => { |
| const m = msg({ createdAt: undefined }); |
| saveMessages("alice", "article", [m as any]); |
|
|
| const loaded = loadMessages("alice", "article")! as any; |
| expect(loaded[0].createdAt).toBeUndefined(); |
| }); |
|
|
| it("preserves parts array", () => { |
| const m = msg({ parts: [{ type: "text", text: "hi" }, { type: "text", text: "there" }] }); |
| saveMessages("alice", "article", [m as any]); |
|
|
| const loaded = loadMessages("alice", "article")!; |
| expect(loaded[0].parts).toEqual([{ type: "text", text: "hi" }, { type: "text", text: "there" }]); |
| }); |
|
|
| it("saves multiple messages and preserves order", () => { |
| const msgs = [ |
| msg({ id: "m1", role: "user" }), |
| msg({ id: "m2", role: "assistant", content: "hey" }), |
| msg({ id: "m3", role: "user", content: "thanks" }), |
| ]; |
| saveMessages("alice", "article", msgs as any); |
|
|
| const loaded = loadMessages("alice", "article")!; |
| expect(loaded).toHaveLength(3); |
| expect(loaded.map((m) => m.id)).toEqual(["m1", "m2", "m3"]); |
| expect(loaded.map((m) => m.role)).toEqual(["user", "assistant", "user"]); |
| }); |
| }); |
|
|
| describe("saveMessages - edge cases", () => { |
| it("removes key when saving empty array", () => { |
| saveMessages("alice", "article", [msg() as any]); |
| expect(localStorageMock.setItem).toHaveBeenCalled(); |
|
|
| saveMessages("alice", "article", []); |
| expect(localStorageMock.removeItem).toHaveBeenCalledWith("chat:alice:article"); |
|
|
| const loaded = loadMessages("alice", "article"); |
| expect(loaded).toBeUndefined(); |
| }); |
|
|
| it("does not throw when localStorage.setItem fails", () => { |
| localStorageMock.setItem.mockImplementationOnce(() => { throw new Error("QuotaExceeded"); }); |
| expect(() => saveMessages("alice", "article", [msg() as any])).not.toThrow(); |
| }); |
| }); |
|
|
| describe("loadMessages - edge cases", () => { |
| it("returns undefined for missing key", () => { |
| expect(loadMessages("unknown", "article")).toBeUndefined(); |
| }); |
|
|
| it("returns undefined for corrupt JSON", () => { |
| store["chat:alice:article"] = "not json!!!"; |
| expect(loadMessages("alice", "article")).toBeUndefined(); |
| }); |
|
|
| it("returns undefined for non-array JSON", () => { |
| store["chat:alice:article"] = JSON.stringify({ not: "an array" }); |
| expect(loadMessages("alice", "article")).toBeUndefined(); |
| }); |
|
|
| it("returns undefined for empty array JSON", () => { |
| store["chat:alice:article"] = JSON.stringify([]); |
| expect(loadMessages("alice", "article")).toBeUndefined(); |
| }); |
| }); |
|
|
| describe("user scoping", () => { |
| it("isolates messages between users", () => { |
| saveMessages("alice", "article", [msg({ id: "a1" }) as any]); |
| saveMessages("bob", "article", [msg({ id: "b1" }) as any]); |
|
|
| expect(loadMessages("alice", "article")![0].id).toBe("a1"); |
| expect(loadMessages("bob", "article")![0].id).toBe("b1"); |
| }); |
|
|
| it("isolates article and embed scopes for same user", () => { |
| saveMessages("alice", "article", [msg({ id: "art" }) as any]); |
| saveMessages("alice", "embed:chart.html", [msg({ id: "emb" }) as any]); |
|
|
| expect(loadMessages("alice", "article")![0].id).toBe("art"); |
| expect(loadMessages("alice", "embed:chart.html")![0].id).toBe("emb"); |
| }); |
| }); |
|
|
| describe("getStableAnonId", () => { |
| it("returns a string starting with 'anon-'", () => { |
| const id = getStableAnonId(); |
| expect(id).toMatch(/^anon-/); |
| }); |
|
|
| it("returns the same value on subsequent calls", () => { |
| const first = getStableAnonId(); |
| const second = getStableAnonId(); |
| expect(first).toBe(second); |
| }); |
|
|
| it("persists the id in localStorage", () => { |
| const id = getStableAnonId(); |
| expect(store["collab-editor:anon-id"]).toBe(id); |
| }); |
| }); |
|
|