// Copyright 2026 OpenObserve Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . /// /* global BufferSource, Event, IDBCursorWithValue, IDBObjectStore, IDBRequest, IDBVersionChangeEvent, queueMicrotask, setTimeout, crypto, TextEncoder */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import type { ChatMessage } from "@/ts/interfaces/chat"; // --------------------------------------------------------------------------- // crypto.subtle mock — deterministic fake hash based on input bytes // Must be declared before the composable is imported so the module-level // computeUserOrgKey function sees the mock on first call. // --------------------------------------------------------------------------- vi.stubGlobal("crypto", { subtle: { digest: vi.fn().mockImplementation(async (_algo: string, data: BufferSource) => { const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array((data as ArrayBufferView).buffer); const buf = new Uint8Array(32); for (let i = 0; i < bytes.length && i < 32; i++) { buf[i] = bytes[i]; } return buf.buffer; }), }, }); // --------------------------------------------------------------------------- // Minimal in-memory IndexedDB mock // // Implements only the IDB surface used by useChatHistory: // open, objectStore, put, get, getAll, delete, openCursor, // createIndex, transaction, IDBKeyRange.only // // All callbacks are invoked via microtask (queueMicrotask) so that the // composable's Promise chains resolve naturally when the test awaits the // returned promise — no setTimeout / sleep required. // --------------------------------------------------------------------------- type RecordMap = Map>; interface MockIndex { name: string; keyPath: string; getAll(keyRange: { value: unknown }): MockRequest; openCursor(keyRange: { value: unknown }): MockRequest; } interface MockObjectStore { name: string; records: RecordMap; indexes: Map; indexNames: { contains(name: string): boolean }; createIndex(name: string, keyPath: string, opts?: unknown): MockIndex; index(name: string): MockIndex; put(record: Record): MockRequest; get(id: number): MockRequest; delete(id: number): MockRequest; } interface MockRequest { result: unknown; error: unknown; onsuccess: ((event: Event) => void) | null; onerror: ((event: Event) => void) | null; } interface MockTransaction { objectStore(name: string): MockObjectStore; oncomplete: (() => void) | null; // setter fires after microtasks via setTimeout(0) onerror: (() => void) | null; _stores: Map; _complete(): void; } interface MockDB { objectStoreNames: { contains(name: string): boolean }; createObjectStore( name: string, opts: { keyPath: string; autoIncrement: boolean }, ): MockObjectStore; transaction(storeName: string, mode: string): MockTransaction; _stores: Map; } // In-memory database registry — persists across transactions within a test // Also tracks the version at which each DB was last upgraded so we don't // repeat the onupgradeneeded sequence on every open() call. const dbRegistry = new Map(); // Global auto-increment counter used by the put mock so that consecutive calls // to the composable's saveToHistory (which uses Date.now() as the chatId) never // collide even when multiple calls happen within the same millisecond. let _autoIdCounter = 1; function makeRequest(initialResult: unknown = undefined): MockRequest { return { result: initialResult, error: null, onsuccess: null, onerror: null, }; } function makeIndex(store: MockObjectStore, indexName: string, keyPath: string): MockIndex { return { name: indexName, keyPath, getAll(keyRange: { value: unknown }): MockRequest { const req = makeRequest(); queueMicrotask(() => { const results: Record[] = []; store.records.forEach((record) => { if (record[keyPath] === keyRange.value) { results.push({ ...record }); } }); req.result = results; if (req.onsuccess) req.onsuccess({ target: req } as unknown as Event); }); return req; }, openCursor(keyRange: { value: unknown }): MockRequest { const req = makeRequest(); const matchingKeys: number[] = []; store.records.forEach((record, key) => { if (record[keyPath] === keyRange.value) { matchingKeys.push(key); } }); let cursor: IDBCursorWithValue | null = null; let idx = 0; const advanceCursor = () => { if (idx < matchingKeys.length) { const key = matchingKeys[idx]; const record = store.records.get(key); idx++; cursor = { value: record ? { ...record } : null, key, primaryKey: key, direction: "next", source: null as unknown as IDBObjectStore, request: null as unknown as IDBRequest, advance: vi.fn(), continue: vi.fn().mockImplementation(() => { queueMicrotask(() => { // Re-check after potential deletions // rebuild matching keys from current store state while (idx < matchingKeys.length && !store.records.has(matchingKeys[idx])) { idx++; } advanceCursor(); }); }), continuePrimaryKey: vi.fn(), delete: vi.fn().mockImplementation(() => { if (key !== undefined) store.records.delete(key); return makeRequest(); }), update: vi.fn(), } as unknown as IDBCursorWithValue; req.result = cursor; if (req.onsuccess) req.onsuccess({ target: req } as unknown as Event); } else { req.result = null; if (req.onsuccess) req.onsuccess({ target: req } as unknown as Event); } }; queueMicrotask(advanceCursor); return req; }, }; } function makeObjectStore(name: string): MockObjectStore { const records: RecordMap = new Map(); const indexes = new Map(); const store: MockObjectStore = { name, records, indexes, indexNames: { contains(indexName: string) { return indexes.has(indexName); }, }, createIndex(indexName: string, keyPath: string) { const idx = makeIndex(store, indexName, keyPath); indexes.set(indexName, idx); return idx; }, index(indexName: string) { const idx = indexes.get(indexName); if (!idx) throw new Error(`Index '${indexName}' not found on store '${name}'`); return idx; }, put(record: Record) { const req = makeRequest(); queueMicrotask(() => { let id = record.id as number | undefined; if (id === undefined || id === null) { id = _autoIdCounter++; } else if (records.has(id)) { // Check if this is a genuine update (same record) or a Date.now() collision const existing = records.get(id)!; if ( existing.userOrgKey !== record.userOrgKey || existing.sessionId !== record.sessionId ) { // Different record — collision from Date.now(); assign a fresh ID id = _autoIdCounter++; } // else: intentional update to the same record — keep the same id } records.set(id, { ...record, id }); req.result = id; if (req.onsuccess) req.onsuccess({ target: req } as unknown as Event); }); return req; }, get(id: number) { const req = makeRequest(); queueMicrotask(() => { const record = records.get(id); req.result = record ? { ...record } : undefined; if (req.onsuccess) req.onsuccess({ target: req } as unknown as Event); }); return req; }, delete(id: number) { const req = makeRequest(); queueMicrotask(() => { records.delete(id); req.result = undefined; if (req.onsuccess) req.onsuccess({ target: req } as unknown as Event); }); return req; }, }; return store; } function makeTransaction(db: MockDB): MockTransaction { let _oncomplete: (() => void) | null = null; let _onerror: (() => void) | null = null; const tx: MockTransaction = { _stores: new Map(), // oncomplete fires via setTimeout(0) so that all pending microtasks // (store.delete() calls) have drained before the transaction resolves. get oncomplete() { return _oncomplete; }, set oncomplete(fn: (() => void) | null) { _oncomplete = fn; if (fn) { setTimeout(() => { if (_oncomplete) _oncomplete(); }, 0); } }, get onerror() { return _onerror; }, set onerror(fn: (() => void) | null) { _onerror = fn; }, objectStore(name: string) { if (!tx._stores.has(name)) { const existing = db._stores.get(name); if (!existing) throw new Error(`Object store '${name}' not found`); // Share the same store records across transactions within the same DB tx._stores.set(name, existing); } return tx._stores.get(name)!; }, _complete() { if (tx.oncomplete) tx.oncomplete(); }, }; return tx; } function makeDB(): MockDB { const stores = new Map(); const db: MockDB = { _stores: stores, objectStoreNames: { contains(name: string) { return stores.has(name); }, }, createObjectStore(name: string) { const store = makeObjectStore(name); stores.set(name, store); return store; }, transaction() { return makeTransaction(db); }, }; return db; } // Replace global indexedDB with the in-memory mock const mockIndexedDB = { open(name: string, version: number) { const req = makeRequest() as MockRequest & { onerror: ((_e: Event) => void) | null; onsuccess: ((_e: Event) => void) | null; onupgradeneeded: ((_e: IDBVersionChangeEvent) => void) | null; }; req.onupgradeneeded = null; queueMicrotask(() => { const entry = dbRegistry.get(name); const isNew = !entry; const oldVersion = entry ? entry.version : 0; let db: MockDB; if (isNew) { db = makeDB(); dbRegistry.set(name, { db, version }); } else { db = entry!.db; } const needsUpgrade = isNew || version > oldVersion; if (needsUpgrade && req.onupgradeneeded) { // Update the stored version if (!isNew) dbRegistry.set(name, { db, version }); // Simulate upgrade event so the composable can create the object store const upgradeEvent = { target: { result: db, transaction: db.transaction("chatHistory", "versionchange"), }, oldVersion, newVersion: version, } as unknown as IDBVersionChangeEvent; req.onupgradeneeded(upgradeEvent); } req.result = db; if (req.onsuccess) req.onsuccess({ target: req } as unknown as Event); }); return req; }, }; vi.stubGlobal("indexedDB", mockIndexedDB); // IDBKeyRange.only — used by loadHistory and clearAllHistory vi.stubGlobal("IDBKeyRange", { only: (value: unknown) => ({ value }), }); // --------------------------------------------------------------------------- // Import composable AFTER mocks are set up // --------------------------------------------------------------------------- import { useChatHistory } from "./useChatHistory"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeMessages(count = 1): ChatMessage[] { return Array.from({ length: count }, (_, i) => ({ role: i % 2 === 0 ? ("user" as const) : ("assistant" as const), content: `message ${i}`, })); } /** Resolve the userOrgKey for a given email+org the same way the composable does. */ async function resolveKey(email: string, org: string): Promise { const raw = `${email}:${org}`; const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)); return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("useChatHistory", () => { const USER1 = "user1@example.com"; const ORG1 = "org-alpha"; const USER2 = "user2@example.com"; const ORG2 = "org-beta"; beforeEach(() => { // Reset the DB registry and ID counter so every test starts with a fresh // in-memory database. The composable caches _keyPromise per instance, and // each test creates new instances, so no key cache leaks between tests. dbRegistry.clear(); _autoIdCounter = 1; }); afterEach(() => { vi.clearAllMocks(); }); /** * Thin wrapper around composable.saveToHistory. * The put mock automatically assigns a unique ID when Date.now() would * collide, so no fake timers or manual ID management is needed here. */ async function saveUnique( composable: ReturnType, messages: ChatMessage[], sessionId: string, title?: string, existingChatId?: number | null, ): Promise { return composable.saveToHistory(messages, sessionId, title, existingChatId); } // ------------------------------------------------------------------------- describe("saveToHistory", () => { it("should return null when messages array is empty", async () => { const { saveToHistory } = useChatHistory( () => USER1, () => ORG1, ); const result = await saveToHistory([], "session-001"); expect(result).toBeNull(); }); it("should return a numeric chat ID when messages are provided", async () => { const { saveToHistory } = useChatHistory( () => USER1, () => ORG1, ); const result = await saveToHistory(makeMessages(2), "session-001"); expect(typeof result).toBe("number"); expect(result).toBeGreaterThan(0); }); it("should use the provided title when one is given", async () => { const { saveToHistory, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const chatId = await saveToHistory(makeMessages(1), "session-001", "My Custom Title"); expect(chatId).not.toBeNull(); const entry = await loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.title).toBe("My Custom Title"); }); it("should use the first user message as title when no title is provided", async () => { const { saveToHistory, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const messages: ChatMessage[] = [ { role: "user", content: "Hello, world!" }, { role: "assistant", content: "Hi there!" }, ]; const chatId = await saveToHistory(messages, "session-002"); expect(chatId).not.toBeNull(); const entry = await loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.title).toBe("Hello, world!"); }); it("should truncate the auto-generated title at 40 characters with ellipsis", async () => { const { saveToHistory, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const longContent = "A".repeat(50); const messages: ChatMessage[] = [{ role: "user", content: longContent }]; const chatId = await saveToHistory(messages, "session-003"); expect(chatId).not.toBeNull(); const entry = await loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.title).toBe("A".repeat(40) + "..."); expect(entry!.title.length).toBe(43); }); it("should not truncate title when first user message is exactly 40 characters", async () => { const { saveToHistory, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const exactContent = "B".repeat(40); const messages: ChatMessage[] = [{ role: "user", content: exactContent }]; const chatId = await saveToHistory(messages, "session-004"); const entry = await loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.title).toBe(exactContent); }); it("should store the userOrgKey on the saved record", async () => { const { saveToHistory, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const expectedKey = await resolveKey(USER1, ORG1); const chatId = await saveToHistory(makeMessages(1), "session-005"); expect(chatId).not.toBeNull(); const entry = await loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.userOrgKey).toBe(expectedKey); }); it("should update an existing chat when existingChatId is provided", async () => { const { saveToHistory, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const chatId = await saveToHistory(makeMessages(1), "session-006", "Original Title"); expect(chatId).not.toBeNull(); const updated = await saveToHistory(makeMessages(2), "session-006", "Updated Title", chatId); expect(updated).toBe(chatId); const entry = await loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.title).toBe("Updated Title"); expect(entry!.messages).toHaveLength(2); }); it("should use 'New Chat' as title when no user message exists", async () => { const { saveToHistory, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const messages: ChatMessage[] = [{ role: "assistant", content: "I am ready to help." }]; const chatId = await saveToHistory(messages, "session-007"); expect(chatId).not.toBeNull(); const entry = await loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.title).toBe("New Chat"); }); }); // ------------------------------------------------------------------------- describe("loadHistory", () => { it("should return an empty array when no history exists", async () => { const { loadHistory } = useChatHistory( () => USER1, () => ORG1, ); const history = await loadHistory(); expect(history).toEqual([]); }); it("should return only the entries belonging to the current user+org", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); await saveUnique(h1, makeMessages(1), "s1", "Chat A"); await saveUnique(h2, makeMessages(1), "s2", "Chat B"); const user1History = await h1.loadHistory(); expect(user1History).toHaveLength(1); expect(user1History[0].title).toBe("Chat A"); }); it("should sort results by timestamp descending (newest first)", async () => { const composable = useChatHistory( () => USER1, () => ORG1, ); const { loadHistory } = composable; // Each saveUnique advances the fake clock by 1 ms, giving distinct IDs // and distinct ISO timestamps so the sort order is deterministic. const id1 = await saveUnique(composable, makeMessages(1), "s1", "Oldest"); const id2 = await saveUnique(composable, makeMessages(1), "s2", "Middle"); const id3 = await saveUnique(composable, makeMessages(1), "s3", "Newest"); // Patch timestamps to ensure deterministic ordering independent of wall clock const db = dbRegistry.get("o2ChatDB")!.db; const storeRecords = db._stores.get("chatHistory")!.records; storeRecords.get(id1!)!.timestamp = "2024-01-01T10:00:00.000Z"; storeRecords.get(id2!)!.timestamp = "2024-01-02T10:00:00.000Z"; storeRecords.get(id3!)!.timestamp = "2024-01-03T10:00:00.000Z"; const history = await loadHistory(); expect(history).toHaveLength(3); expect(history[0].title).toBe("Newest"); expect(history[1].title).toBe("Middle"); expect(history[2].title).toBe("Oldest"); }); it("should prune entries beyond 100 items for the current user+org only", async () => { const composable = useChatHistory( () => USER1, () => ORG1, ); const { loadHistory } = composable; // Save 105 chats for user1; saveUnique advances the clock each call for (let i = 0; i < 105; i++) { await saveUnique(composable, makeMessages(1), `session-${i}`, `Chat ${i}`); } const history = await loadHistory(); expect(history).toHaveLength(100); }); it("should not prune entries belonging to other users when current user exceeds 100", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); // 105 for user1; saveUnique ensures unique IDs for (let i = 0; i < 105; i++) { await saveUnique(h1, makeMessages(1), `s1-${i}`, `U1 Chat ${i}`); } // 2 for user2 await saveUnique(h2, makeMessages(1), "s2-0", "U2 Chat 0"); await saveUnique(h2, makeMessages(1), "s2-1", "U2 Chat 1"); // Trigger pruning for user1 await h1.loadHistory(); // user2's records must still be intact const user2History = await h2.loadHistory(); expect(user2History).toHaveLength(2); }); }); // ------------------------------------------------------------------------- describe("loadChat", () => { it("should return the chat entry when ID and ownership match", async () => { const { saveToHistory, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const messages = makeMessages(2); const chatId = await saveToHistory(messages, "session-load-1", "Load Test"); expect(chatId).not.toBeNull(); const entry = await loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.id).toBe(chatId); expect(entry!.title).toBe("Load Test"); expect(entry!.messages).toHaveLength(2); }); it("should return null when the chatId does not exist", async () => { const { loadChat } = useChatHistory( () => USER1, () => ORG1, ); const entry = await loadChat(99999); expect(entry).toBeNull(); }); it("should return null when the record belongs to a different user+org", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); const chatId = await h1.saveToHistory(makeMessages(1), "s-cross", "User1 Chat"); expect(chatId).not.toBeNull(); // user2 must not be able to read user1's chat const entry = await h2.loadChat(chatId!); expect(entry).toBeNull(); }); it("should return a legacy record (no userOrgKey) for the same user context", async () => { // Legacy records have no userOrgKey — the composable allows them through const { loadChat } = useChatHistory( () => USER1, () => ORG1, ); // First: ensure DB is initialised by doing a read await useChatHistory( () => USER1, () => ORG1, ).loadHistory(); const legacyId = 777; const storeRecords = dbRegistry.get("o2ChatDB")!.db._stores.get("chatHistory")!.records; storeRecords.set(legacyId, { id: legacyId, timestamp: "2023-01-01T00:00:00.000Z", title: "Legacy Chat", messages: [], sessionId: "legacy-session", // no userOrgKey property }); const entry = await loadChat(legacyId); expect(entry).not.toBeNull(); expect(entry!.title).toBe("Legacy Chat"); }); }); // ------------------------------------------------------------------------- describe("deleteChatById", () => { it("should delete an owned record and return true", async () => { const { saveToHistory, deleteChatById, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const chatId = await saveToHistory(makeMessages(1), "s-del-1", "To Delete"); expect(chatId).not.toBeNull(); const deleted = await deleteChatById(chatId!); expect(deleted).toBe(true); const entry = await loadChat(chatId!); expect(entry).toBeNull(); }); it("should return false when the chatId does not exist", async () => { const { deleteChatById } = useChatHistory( () => USER1, () => ORG1, ); // Initialise DB first await useChatHistory( () => USER1, () => ORG1, ).loadHistory(); const result = await deleteChatById(99999); expect(result).toBe(false); }); it("should return false when the record belongs to a different user+org", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); const chatId = await h1.saveToHistory(makeMessages(1), "s-del-cross", "User1 Chat"); expect(chatId).not.toBeNull(); const result = await h2.deleteChatById(chatId!); expect(result).toBe(false); // Original record must still exist for user1 const entry = await h1.loadChat(chatId!); expect(entry).not.toBeNull(); }); }); // ------------------------------------------------------------------------- describe("clearAllHistory", () => { it("should return true and remove all records for the current user+org", async () => { const composable = useChatHistory( () => USER1, () => ORG1, ); const { clearAllHistory, loadHistory } = composable; await saveUnique(composable, makeMessages(1), "s1", "Chat 1"); await saveUnique(composable, makeMessages(1), "s2", "Chat 2"); const result = await clearAllHistory(); expect(result).toBe(true); const history = await loadHistory(); expect(history).toHaveLength(0); }); it("should return true even when there is no history to clear", async () => { const { clearAllHistory } = useChatHistory( () => USER1, () => ORG1, ); // Initialise DB await useChatHistory( () => USER1, () => ORG1, ).loadHistory(); const result = await clearAllHistory(); expect(result).toBe(true); }); it("should only delete records for the current user+org, leaving others intact", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); await saveUnique(h1, makeMessages(1), "s1", "User1 Chat 1"); await saveUnique(h1, makeMessages(1), "s2", "User1 Chat 2"); await saveUnique(h2, makeMessages(1), "s3", "User2 Chat 1"); const cleared = await h1.clearAllHistory(); expect(cleared).toBe(true); const user1History = await h1.loadHistory(); expect(user1History).toHaveLength(0); const user2History = await h2.loadHistory(); expect(user2History).toHaveLength(1); expect(user2History[0].title).toBe("User2 Chat 1"); }); }); // ------------------------------------------------------------------------- describe("updateChatTitle", () => { it("should update the title and return true for an owned record", async () => { const { saveToHistory, updateChatTitle, loadChat } = useChatHistory( () => USER1, () => ORG1, ); const chatId = await saveToHistory(makeMessages(1), "s-upd-1", "Old Title"); expect(chatId).not.toBeNull(); const result = await updateChatTitle(chatId!, "New Title"); expect(result).toBe(true); const entry = await loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.title).toBe("New Title"); }); it("should return false when the chatId does not exist", async () => { const { updateChatTitle } = useChatHistory( () => USER1, () => ORG1, ); // Initialise DB await useChatHistory( () => USER1, () => ORG1, ).loadHistory(); const result = await updateChatTitle(99999, "Any Title"); expect(result).toBe(false); }); it("should return false when the record belongs to a different user+org", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); const chatId = await h1.saveToHistory(makeMessages(1), "s-upd-cross", "Original"); expect(chatId).not.toBeNull(); const result = await h2.updateChatTitle(chatId!, "Hijacked Title"); expect(result).toBe(false); // Title must remain unchanged for user1 const entry = await h1.loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.title).toBe("Original"); }); }); // ------------------------------------------------------------------------- describe("user+org isolation", () => { it("should produce different userOrgKeys for different user+org combinations", async () => { const key1 = await resolveKey(USER1, ORG1); const key2 = await resolveKey(USER2, ORG2); const key3 = await resolveKey(USER1, ORG2); expect(key1).not.toBe(key2); expect(key1).not.toBe(key3); expect(key2).not.toBe(key3); }); it("should produce the same userOrgKey for repeated calls with the same email+org", async () => { const key1 = await resolveKey(USER1, ORG1); const key2 = await resolveKey(USER1, ORG1); expect(key1).toBe(key2); }); it("should not return another user's chats via loadHistory", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); for (let i = 0; i < 3; i++) { await saveUnique(h1, makeMessages(1), `s1-${i}`, `User1 Chat ${i}`); } for (let i = 0; i < 2; i++) { await saveUnique(h2, makeMessages(1), `s2-${i}`, `User2 Chat ${i}`); } const h1History = await h1.loadHistory(); const h2History = await h2.loadHistory(); expect(h1History).toHaveLength(3); expect(h2History).toHaveLength(2); h1History.forEach((entry) => expect(entry.title).toMatch(/^User1/)); h2History.forEach((entry) => expect(entry.title).toMatch(/^User2/)); }); it("should not allow loadChat to return a record owned by another user+org", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); const chatId = await h1.saveToHistory(makeMessages(2), "s-iso-load", "Isolated Chat"); expect(chatId).not.toBeNull(); expect(await h2.loadChat(chatId!)).toBeNull(); expect(await h1.loadChat(chatId!)).not.toBeNull(); }); it("should not allow deleteChatById to delete a record owned by another user+org", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); const chatId = await h1.saveToHistory(makeMessages(1), "s-iso-del", "Should Stay"); expect(chatId).not.toBeNull(); expect(await h2.deleteChatById(chatId!)).toBe(false); expect(await h1.loadChat(chatId!)).not.toBeNull(); }); it("should not allow clearAllHistory to delete records owned by another user+org", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); await saveUnique(h1, makeMessages(1), "s-clear-1", "User1 A"); await saveUnique(h1, makeMessages(1), "s-clear-2", "User1 B"); await saveUnique(h2, makeMessages(1), "s-clear-3", "User2 A"); await h2.clearAllHistory(); expect(await h2.loadHistory()).toHaveLength(0); expect(await h1.loadHistory()).toHaveLength(2); }); it("should not allow updateChatTitle to modify a record owned by another user+org", async () => { const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); const chatId = await h1.saveToHistory(makeMessages(1), "s-iso-upd", "Protected Title"); expect(chatId).not.toBeNull(); expect(await h2.updateChatTitle(chatId!, "Tampered")).toBe(false); const entry = await h1.loadChat(chatId!); expect(entry).not.toBeNull(); expect(entry!.title).toBe("Protected Title"); }); it("should scope the userOrgKey cache per composable instance", async () => { // Two instances for different user+org pairs created from the same factory call const h1 = useChatHistory( () => USER1, () => ORG1, ); const h2 = useChatHistory( () => USER2, () => ORG2, ); const id1 = await saveUnique(h1, makeMessages(1), "key-cache-1", "H1 Chat"); const id2 = await saveUnique(h2, makeMessages(1), "key-cache-2", "H2 Chat"); expect(id1).not.toBeNull(); expect(id2).not.toBeNull(); // Each instance must only see its own record expect(await h1.loadChat(id2!)).toBeNull(); expect(await h2.loadChat(id1!)).toBeNull(); }); it("should recompute the hash when the org getter returns a new value", async () => { // Simulate an org switch by using a mutable variable as the getter source let currentOrg = ORG1; const composable = useChatHistory( () => USER1, () => currentOrg, ); // Save a chat while in ORG1 const chatIdOrg1 = await composable.saveToHistory(makeMessages(1), "s-switch-1", "Org1 Chat"); expect(chatIdOrg1).not.toBeNull(); // Switch to ORG2 — the getter now returns ORG2 currentOrg = ORG2; // Save a chat while in ORG2 const chatIdOrg2 = await composable.saveToHistory(makeMessages(1), "s-switch-2", "Org2 Chat"); expect(chatIdOrg2).not.toBeNull(); // History after the switch should only show ORG2's chat const historyOrg2 = await composable.loadHistory(); expect(historyOrg2).toHaveLength(1); expect(historyOrg2[0].title).toBe("Org2 Chat"); // Switch back to ORG1 — should see only ORG1's chat currentOrg = ORG1; const historyOrg1 = await composable.loadHistory(); expect(historyOrg1).toHaveLength(1); expect(historyOrg1[0].title).toBe("Org1 Chat"); }); }); });