/** * Chat Persistence Tests * * Unit tests for saveMessages, loadMessages, chatStorageKey, and getStableAnonId. * Uses a minimal localStorage mock (no jsdom needed). */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { chatStorageKey, saveMessages, loadMessages, getStableAnonId, } from "../src/utils/chat-persistence"; // Minimal localStorage mock const store: Record = {}; 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(); }); // Factory for UIMessage-like objects function msg(overrides: Record = {}) { 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); }); });