import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { MetadataCache } from "../../src/server/services/metadata-cache.js"; import type { CacheEntry } from "../../src/server/services/metadata-cache.js"; import fs from "fs/promises"; import path from "path"; import os from "os"; function makeCacheEntry(overrides: Partial = {}): CacheEntry { return { mtimeMs: 1700000000000, size: 1024, messageCount: 5, firstPrompt: "Hello", summary: "Session summary", firstTimestamp: "2025-01-01T10:00:00Z", lastTimestamp: "2025-01-01T11:00:00Z", ...overrides, }; } describe("MetadataCache", () => { let tmpDir: string; let cachePath: string; beforeEach(async () => { tmpDir = path.join(os.tmpdir(), `sv-cache-test-${Date.now()}`); await fs.mkdir(tmpDir, { recursive: true }); cachePath = path.join(tmpDir, "metadata.json"); }); afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); it("returns null for unknown file path", async () => { const cache = new MetadataCache(cachePath); await cache.load(); expect(cache.get("/unknown/path.jsonl", 1000, 500)).toBeNull(); }); it("returns entry when mtimeMs and size match", async () => { const cache = new MetadataCache(cachePath); await cache.load(); const entry = makeCacheEntry({ mtimeMs: 1000, size: 500 }); cache.set("/test/session.jsonl", entry); const result = cache.get("/test/session.jsonl", 1000, 500); expect(result).not.toBeNull(); expect(result!.messageCount).toBe(5); expect(result!.firstPrompt).toBe("Hello"); }); it("returns null when mtimeMs differs", async () => { const cache = new MetadataCache(cachePath); await cache.load(); const entry = makeCacheEntry({ mtimeMs: 1000, size: 500 }); cache.set("/test/session.jsonl", entry); expect(cache.get("/test/session.jsonl", 2000, 500)).toBeNull(); }); it("returns null when size differs", async () => { const cache = new MetadataCache(cachePath); await cache.load(); const entry = makeCacheEntry({ mtimeMs: 1000, size: 500 }); cache.set("/test/session.jsonl", entry); expect(cache.get("/test/session.jsonl", 1000, 999)).toBeNull(); }); it("save is no-op when not dirty", async () => { const cache = new MetadataCache(cachePath); await cache.load(); await cache.save(); // File should not exist since nothing was set await expect(fs.access(cachePath)).rejects.toThrow(); }); it("save writes to disk when dirty", async () => { const cache = new MetadataCache(cachePath); await cache.load(); cache.set("/test/session.jsonl", makeCacheEntry()); await cache.save(); const raw = await fs.readFile(cachePath, "utf-8"); const parsed = JSON.parse(raw); expect(parsed.version).toBe(1); expect(parsed.entries["/test/session.jsonl"]).toBeDefined(); expect(parsed.entries["/test/session.jsonl"].messageCount).toBe(5); }); it("save prunes entries not in existingPaths", async () => { const cache = new MetadataCache(cachePath); await cache.load(); cache.set("/test/a.jsonl", makeCacheEntry()); cache.set("/test/b.jsonl", makeCacheEntry()); cache.set("/test/c.jsonl", makeCacheEntry()); const existingPaths = new Set(["/test/a.jsonl", "/test/c.jsonl"]); await cache.save(existingPaths); const raw = await fs.readFile(cachePath, "utf-8"); const parsed = JSON.parse(raw); expect(Object.keys(parsed.entries)).toHaveLength(2); expect(parsed.entries["/test/b.jsonl"]).toBeUndefined(); }); it("load handles missing cache file", async () => { const cache = new MetadataCache( path.join(tmpDir, "nonexistent", "cache.json") ); await cache.load(); expect(cache.get("/test/session.jsonl", 1000, 500)).toBeNull(); }); it("load handles corrupt cache file", async () => { await fs.writeFile(cachePath, "not valid json {{{"); const cache = new MetadataCache(cachePath); await cache.load(); expect(cache.get("/test/session.jsonl", 1000, 500)).toBeNull(); }); it("persists and reloads across instances", async () => { const cache1 = new MetadataCache(cachePath); await cache1.load(); cache1.set("/test/session.jsonl", makeCacheEntry({ mtimeMs: 42, size: 100 })); await cache1.save(); const cache2 = new MetadataCache(cachePath); await cache2.load(); const result = cache2.get("/test/session.jsonl", 42, 100); expect(result).not.toBeNull(); expect(result!.messageCount).toBe(5); }); it("isDirty returns false initially, true after set", async () => { const cache = new MetadataCache(cachePath); await cache.load(); expect(cache.isDirty()).toBe(false); cache.set("/test/session.jsonl", makeCacheEntry()); expect(cache.isDirty()).toBe(true); }); it("isDirty resets to false after save", async () => { const cache = new MetadataCache(cachePath); await cache.load(); cache.set("/test/session.jsonl", makeCacheEntry()); expect(cache.isDirty()).toBe(true); await cache.save(); expect(cache.isDirty()).toBe(false); }); it("flush writes without pruning", async () => { const cache = new MetadataCache(cachePath); await cache.load(); cache.set("/test/a.jsonl", makeCacheEntry()); cache.set("/test/b.jsonl", makeCacheEntry()); await cache.flush(); const raw = await fs.readFile(cachePath, "utf-8"); const parsed = JSON.parse(raw); // Both should be present (no pruning on flush) expect(Object.keys(parsed.entries)).toHaveLength(2); }); });