import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { SIDEBAR_CACHE_TTL_MS, invalidateSidebarCache, readSidebarCache, sidebarCacheIsFresh, writeSidebarCache, } from "../src/lib/sidebar-data-cache.ts"; const hookSource = readFileSync(new URL("../src/hooks/use-sidebar-data.ts", import.meta.url), "utf8"); const sessionHookSource = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8"); const row = (id: string, title: string) => ({ id, title, pinned: false, archived: false, updatedAt: 1, }); const account = { name: "示例账户", email: "example@invalid.test", credits: 0, initial: "示", avatar: null, }; test("the read-only sidebar reuses a fresh entry instead of fetching again", () => { invalidateSidebarCache(); assert.equal(readSidebarCache(), null); writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "第一条")], account, fetchedAt: 1_000 }); const entry = readSidebarCache(); assert.ok(entry); assert.equal(entry.sessions[0]?.title, "第一条"); // Inside the window: the hook returns without touching the network. assert.equal(sidebarCacheIsFresh(entry, 1_000), true); assert.equal(sidebarCacheIsFresh(entry, 1_000 + SIDEBAR_CACHE_TTL_MS - 1), true); // Past it: shown from cache first, refreshed in the background — exactly one // refetch, because the write that follows resets `fetchedAt`. assert.equal(sidebarCacheIsFresh(entry, 1_000 + SIDEBAR_CACHE_TTL_MS), false); assert.equal(sidebarCacheIsFresh(entry, 60_000_000), false); assert.equal(sidebarCacheIsFresh(null, 1_000), false); // A clock that jumped backwards must not read as fresh forever. assert.equal(sidebarCacheIsFresh(entry, 0), false); }); test("a session write on `/` makes the next secondary page read the new title", () => { invalidateSidebarCache(); writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "旧标题")], account, fetchedAt: 1_000 }); assert.equal(readSidebarCache()?.sessions[0]?.title, "旧标题"); invalidateSidebarCache(); assert.equal(readSidebarCache(), null); assert.equal(sidebarCacheIsFresh(readSidebarCache(), 1_001), false); // Every write path on `/` calls it: create, rename, delete, pin, archive. for (const marker of [ /await persistSession\(nextSession\);\n\s*\/\*[\s\S]*?\*\/\n\s*invalidateSidebarCache\(\);/, /删除聊天记录失败"\);\n\s*invalidateSidebarCache\(\);/, /\{ pinned: nextPinned \}, "update"\)\.then\(invalidateSidebarCache\)/, /\{ archived_at: nextArchivedAt \}, "update"\)\.then\(invalidateSidebarCache\)/, /\);\n\s*invalidateSidebarCache\(\);\n\s*return nextSession;/, ]) { assert.match(sessionHookSource, marker); } }); test("a second account in the same tab never reads the first one's rows", () => { invalidateSidebarCache(); writeSidebarCache({ accountId: "acct-1", sessions: [row("a", "甲的对话")], account, fetchedAt: 1_000 }); writeSidebarCache({ accountId: "acct-2", sessions: [row("b", "乙的对话")], account, fetchedAt: 2_000 }); assert.equal(readSidebarCache()?.accountId, "acct-2"); assert.equal(readSidebarCache()?.sessions[0]?.title, "乙的对话"); invalidateSidebarCache(); }); test("the cache is memory only, and a 401 empties it", () => { // Nothing durable: a stale list surviving a browser restart is worse than one // fetch, and it would outlive a sign-out. const cacheSource = readFileSync(new URL("../src/lib/sidebar-data-cache.ts", import.meta.url), "utf8"); assert.doesNotMatch(cacheSource, /localStorage|sessionStorage|document\.cookie|indexedDB/i); assert.match(hookSource, /if \(sidebarCacheIsFresh\(cached, Date\.now\(\)\)\) return;/); assert.match(hookSource, /invalidateSidebarCache\(\);\n\s*setState\(\{ sessions: \[\], account: null, settled: true, signedOut: true \}\);/); // Read synchronously at mount so a cached list is on screen in the first // frame — no skeleton, no spinner, per the unified-loading ruling. assert.match(hookSource, /useState\(\(\) => \{[\s\S]*?readSidebarCache\(\)/); assert.doesNotMatch(hookSource, /skeleton|Spinner/i); });