// src/engine/reflection/retention.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { pruneOldSnapshots, enforceDiskCap, runReflectionRetentionSweep, type RetentionDeps, } from './retention.js'; // ── Fixtures ────────────────────────────────────────────────────────────────── const USER_ID = 'u-retention-test'; /** Format a Date as the snapshot directory name prefix (YYYYMMDDTHHmmssZ). */ function fmtTs(d: Date): string { const pad2 = (n: number) => String(n).padStart(2, '0'); return ( `${d.getUTCFullYear()}` + `${pad2(d.getUTCMonth() + 1)}` + `${pad2(d.getUTCDate())}` + `T${pad2(d.getUTCHours())}` + `${pad2(d.getUTCMinutes())}` + `${pad2(d.getUTCSeconds())}` + `Z` ); } /** Create a fake snapshot dir for the given age in days (relative to now). */ function makeSnapshot( histDir: string, ageDays: number, jobId: string, fileSizeBytes = 100, ): string { const d = new Date(Date.now() - ageDays * 86_400_000); const name = `${fmtTs(d)}-${jobId}`; const dir = join(histDir, name); mkdirSync(dir, { recursive: true }); // Write a dummy meta.json + a content file of the requested size writeFileSync( join(dir, 'meta.json'), JSON.stringify({ snapshotId: name, ts: d.toISOString(), pieceName: 'chat' }), 'utf-8', ); writeFileSync(join(dir, 'data.bin'), Buffer.alloc(fileSizeBytes), 'binary'); return name; } function makeDeps(dataDir: string): RetentionDeps { return { dataDir }; } function histDir(dataDir: string, userId: string): string { return join(dataDir, userId, '.reflection-history'); } // ── Test suite ──────────────────────────────────────────────────────────────── describe('pruneOldSnapshots', () => { let tmpDir: string; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'retention-age-')); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('does NOT prune a snapshot newer than retentionDays', async () => { const hDir = histDir(tmpDir, USER_ID); mkdirSync(hDir, { recursive: true }); const snapId = makeSnapshot(hDir, 5, 'j-new'); // 5 days old const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90); expect(result.pruned).toHaveLength(0); expect(existsSync(join(hDir, snapId))).toBe(true); }); it('prunes a snapshot older than retentionDays', async () => { const hDir = histDir(tmpDir, USER_ID); mkdirSync(hDir, { recursive: true }); const snapId = makeSnapshot(hDir, 100, 'j-old'); // 100 days old, retention=90 const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90); expect(result.pruned).toContain(snapId); expect(existsSync(join(hDir, snapId))).toBe(false); }); it('prunes old but keeps new when both present', async () => { const hDir = histDir(tmpDir, USER_ID); mkdirSync(hDir, { recursive: true }); const oldId = makeSnapshot(hDir, 200, 'j-old'); // old const newId = makeSnapshot(hDir, 10, 'j-new'); // new const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90); expect(result.pruned).toContain(oldId); expect(result.pruned).not.toContain(newId); expect(existsSync(join(hDir, oldId))).toBe(false); expect(existsSync(join(hDir, newId))).toBe(true); }); it('returns empty pruned when no history dir exists', async () => { const result = await pruneOldSnapshots(makeDeps(tmpDir), 'u-ghost', 90); expect(result.pruned).toHaveLength(0); }); it('does not prune index.jsonl (not a parseable snapshot dir name)', async () => { const hDir = histDir(tmpDir, USER_ID); mkdirSync(hDir, { recursive: true }); // Write index.jsonl as a file (not a dir), should be ignored writeFileSync(join(hDir, 'index.jsonl'), '', 'utf-8'); const snapId = makeSnapshot(hDir, 5, 'j-ok'); const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90); expect(result.pruned).toHaveLength(0); expect(existsSync(join(hDir, 'index.jsonl'))).toBe(true); expect(existsSync(join(hDir, snapId))).toBe(true); }); }); describe('enforceDiskCap', () => { let tmpDir: string; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'retention-cap-')); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('does not prune when total bytes is under cap', async () => { const hDir = histDir(tmpDir, USER_ID); mkdirSync(hDir, { recursive: true }); // Each snapshot ≈ 200 bytes (100 data + small meta.json); cap = 10 MiB const snapId = makeSnapshot(hDir, 5, 'j-small', 100); const cap = 10 * 1024 * 1024; // 10 MiB const result = await enforceDiskCap(makeDeps(tmpDir), USER_ID, cap); expect(result.pruned).toHaveLength(0); expect(existsSync(join(hDir, snapId))).toBe(true); }); it('prunes oldest snapshots first when over cap', async () => { const hDir = histDir(tmpDir, USER_ID); mkdirSync(hDir, { recursive: true }); // Create 3 snapshots: oldest, middle, newest — each ~1024 bytes of data const oldestId = makeSnapshot(hDir, 30, 'j-oldest', 1024); const middleId = makeSnapshot(hDir, 20, 'j-middle', 1024); const newestId = makeSnapshot(hDir, 10, 'j-newest', 1024); // The total will be around 3 * (1024 + meta) bytes. // Set cap so that only 2 snapshots fit (i.e., total > 2 * entry_size). // Each entry is ~1024 + ~80 bytes meta ≈ 1104 bytes. // Cap at 2200 bytes → oldest must be pruned. const cap = 2200; const result = await enforceDiskCap(makeDeps(tmpDir), USER_ID, cap); // oldest should be pruned, newest should survive expect(result.pruned).toContain(oldestId); expect(existsSync(join(hDir, oldestId))).toBe(false); expect(existsSync(join(hDir, newestId))).toBe(true); // middle may or may not be pruned depending on actual sizes, but newest must survive void middleId; // referenced to suppress unused variable warning }); it('newest snapshot survives when only one exists and under cap', async () => { const hDir = histDir(tmpDir, USER_ID); mkdirSync(hDir, { recursive: true }); const snapId = makeSnapshot(hDir, 5, 'j-only', 100); const cap = 10 * 1024 * 1024; // 10 MiB — easily above 100 bytes const result = await enforceDiskCap(makeDeps(tmpDir), USER_ID, cap); expect(result.pruned).toHaveLength(0); expect(existsSync(join(hDir, snapId))).toBe(true); }); it('returns empty pruned when no history dir exists', async () => { const result = await enforceDiskCap(makeDeps(tmpDir), 'u-ghost', 1024); expect(result.pruned).toHaveLength(0); }); }); describe('runReflectionRetentionSweep', () => { let tmpDir: string; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'retention-sweep-')); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('sweeps all users and prunes old snapshots', async () => { const uid1 = 'u-sweep-1'; const uid2 = 'u-sweep-2'; const hDir1 = histDir(tmpDir, uid1); const hDir2 = histDir(tmpDir, uid2); mkdirSync(hDir1, { recursive: true }); mkdirSync(hDir2, { recursive: true }); const old1 = makeSnapshot(hDir1, 100, 'j-old1'); // old const new1 = makeSnapshot(hDir1, 5, 'j-new1'); // new const old2 = makeSnapshot(hDir2, 200, 'j-old2'); // old await runReflectionRetentionSweep(makeDeps(tmpDir), { snapshotRetentionDays: 90, snapshotMaxBytesPerUser: 100 * 1024 * 1024, // 100 MiB — won't trigger }); expect(existsSync(join(hDir1, old1))).toBe(false); expect(existsSync(join(hDir1, new1))).toBe(true); expect(existsSync(join(hDir2, old2))).toBe(false); }); it('skips users with no .reflection-history dir', async () => { // Create a user dir without a history dir const uid = 'u-no-history'; mkdirSync(join(tmpDir, uid), { recursive: true }); // Should not throw await expect( runReflectionRetentionSweep(makeDeps(tmpDir), { snapshotRetentionDays: 90, snapshotMaxBytesPerUser: 100 * 1024 * 1024, }), ).resolves.toBeUndefined(); }); it('handles a non-existent dataDir gracefully', async () => { await expect( runReflectionRetentionSweep( { dataDir: join(tmpDir, 'does-not-exist') }, { snapshotRetentionDays: 90, snapshotMaxBytesPerUser: 100 * 1024 * 1024 }, ), ).resolves.toBeUndefined(); }); it('single snapshot under cap and within retention is not touched', async () => { const uid = 'u-clean'; const hDir = histDir(tmpDir, uid); mkdirSync(hDir, { recursive: true }); const snapId = makeSnapshot(hDir, 5, 'j-only', 100); await runReflectionRetentionSweep(makeDeps(tmpDir), { snapshotRetentionDays: 90, snapshotMaxBytesPerUser: 100 * 1024 * 1024, }); expect(existsSync(join(hDir, snapId))).toBe(true); }); });