Files
Codeman/test/document-preview-cache-eviction.test.ts
T
Claude (Codeman maintainer) 5fbe451c26 fix(attachments): harden document preview/thumbnail path (review of #120)
Follow-up hardening applied during review of PR #120, addressing the
adversarial multi-agent findings:

- fix(preview): render auto-detected (workspace, unregistered) DOCX/PPTX via
  the file-preview route and PDFs via file-raw in openFilePreview. Previously
  the Preview button fell through to file-content, dumping the binary Office/PDF
  bytes as mojibake, and the new file-preview route was unreachable dead code.
  (MAJOR: file-preview-route-unreachable-detected-office)

- perf(convert): add a global converter-concurrency limiter
  (document-conversion-limiter.ts) wrapping every pdftoppm / soffice /
  powershell spawn, so N simultaneous preview/thumbnail requests can no longer
  fork unbounded converter processes. Default cap 3, CODEMAN_MAX_DOCUMENT_CONVERSIONS.
  (MAJOR: no-converter-concurrency-limit)

- fix(cache): bound the converted-PDF disk cache with LRU-by-mtime eviction
  (pruneDocumentPreviewCache, default 100 files, CODEMAN_MAX_PREVIEW_CACHE_FILES),
  run after each successful conversion. Was unbounded.
  (MAJOR/MINOR: preview-cache-unbounded-disk-growth)

Tests: document-conversion-limiter.test.ts, document-preview-cache-eviction.test.ts,
and route coverage for the four new endpoints in
routes/file-routes-preview-thumbnail.test.ts (closes the missing-route-test gap).
Verified end-to-end against real pdftoppm (thumbnail render + concurrency cap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:43:57 +02:00

56 lines
2.1 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import fs from 'node:fs/promises';
import { pruneDocumentPreviewCache } from '../src/document-preview-cache.js';
vi.mock('node:fs/promises', () => ({
default: {
readdir: vi.fn(),
stat: vi.fn(),
rm: vi.fn(async () => undefined),
},
}));
const mockedReaddir = vi.mocked(fs.readdir);
const mockedStat = vi.mocked(fs.stat);
const mockedRm = vi.mocked(fs.rm);
describe('pruneDocumentPreviewCache', () => {
beforeEach(() => {
vi.clearAllMocks();
// mtime encoded in the filename (f0 oldest ... f101 newest)
mockedStat.mockImplementation(async (p) => {
const m = /f(\d+)\.pdf$/.exec(String(p));
return { mtimeMs: m ? Number(m[1]) : 0, isFile: () => true } as never;
});
});
it('evicts the oldest *.pdf files once the cache exceeds the cap (default 100)', async () => {
const names = Array.from({ length: 102 }, (_, i) => `f${i}.pdf`);
mockedReaddir.mockResolvedValue(names as never);
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
// 102 - 100 = 2 oldest removed
expect(mockedRm).toHaveBeenCalledTimes(2);
const removed = mockedRm.mock.calls.map((c) => String(c[0]));
expect(removed.some((p) => p.endsWith('f0.pdf'))).toBe(true);
expect(removed.some((p) => p.endsWith('f1.pdf'))).toBe(true);
expect(removed.some((p) => p.endsWith('f101.pdf'))).toBe(false); // newest kept
});
it('ignores non-pdf entries (e.g. transient work-* dirs) when counting', async () => {
const names = [...Array.from({ length: 50 }, (_, i) => `f${i}.pdf`), 'work-abc', 'work-def'];
mockedReaddir.mockResolvedValue(names as never);
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
expect(mockedRm).not.toHaveBeenCalled(); // 50 pdfs <= cap
});
it('never throws when the cache dir cannot be read', async () => {
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
await expect(pruneDocumentPreviewCache('/tmp/missing')).resolves.toBeUndefined();
expect(mockedRm).not.toHaveBeenCalled();
});
});