mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
Merge PR #120: document attachment previews + thumbnails (COD-38)
Adds attachment cards with first-page thumbnails and inline document previews (PDF/Office via pdftoppm + LibreOffice), plus review hardening (converter concurrency limiter, bounded preview cache, fixed detected-doc preview routing). See PR #120. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> # Conflicts: # src/web/public/styles.css
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runWithConversionLimit, getActiveConversionCount } from '../src/document-conversion-limiter.js';
|
||||
|
||||
describe('document-conversion-limiter', () => {
|
||||
it('never runs more converters than the cap (default 3) concurrently', async () => {
|
||||
let running = 0;
|
||||
let maxObserved = 0;
|
||||
|
||||
const task = () => async () => {
|
||||
running++;
|
||||
maxObserved = Math.max(maxObserved, running);
|
||||
// The module's own accounting must also stay within the cap.
|
||||
expect(getActiveConversionCount()).toBeLessThanOrEqual(3);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
running--;
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: 12 }, () => runWithConversionLimit(task())));
|
||||
|
||||
expect(maxObserved).toBeLessThanOrEqual(3);
|
||||
expect(maxObserved).toBeGreaterThan(1); // proves it genuinely parallelizes, not serializes
|
||||
expect(getActiveConversionCount()).toBe(0); // every slot released
|
||||
});
|
||||
|
||||
it('processes every queued task even when far more are submitted than the cap', async () => {
|
||||
let completed = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: 25 }, () =>
|
||||
runWithConversionLimit(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
completed++;
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(completed).toBe(25);
|
||||
expect(getActiveConversionCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('releases the slot when a task throws', async () => {
|
||||
await expect(
|
||||
runWithConversionLimit(async () => {
|
||||
throw new Error('boom');
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
expect(getActiveConversionCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { clearDocumentPreviewCache, getOfficePreviewPdfPath } from '../src/document-preview-cache.js';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn((_cmd, _args, _options, callback) => {
|
||||
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
stat: vi.fn(),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
copyFile: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => '/tmp/codeman-document-preview-cache/work-test'),
|
||||
readdir: vi.fn(async () => ['deck.pdf']),
|
||||
rename: vi.fn(async () => undefined),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedExecFile = vi.mocked(execFile);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRename = vi.mocked(fs.rename);
|
||||
|
||||
describe('document-preview-cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDocumentPreviewCache();
|
||||
|
||||
let cachedPdfExists = false;
|
||||
mockedStat.mockImplementation(async (path) => {
|
||||
const pathText = String(path);
|
||||
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
|
||||
if (!cachedPdfExists) throw new Error('ENOENT');
|
||||
return { size: 4096, isFile: () => true } as never;
|
||||
}
|
||||
|
||||
return { size: 1845494, mtimeMs: 12345, isFile: () => true } as never;
|
||||
});
|
||||
mockedRename.mockImplementation(async () => {
|
||||
cachedPdfExists = true;
|
||||
});
|
||||
mockedExecFile.mockImplementation((cmd, _args, _options, callback) => {
|
||||
if (cmd === 'powershell.exe') {
|
||||
cachedPdfExists = true;
|
||||
}
|
||||
|
||||
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
|
||||
return {} as never;
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates concurrent Office preview conversions and reuses the cached PDF', async () => {
|
||||
const [firstPath, secondPath] = await Promise.all([
|
||||
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
|
||||
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
|
||||
]);
|
||||
const thirdPath = await getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx');
|
||||
|
||||
expect(firstPath).toBeTruthy();
|
||||
expect(secondPath).toBe(firstPath);
|
||||
expect(thirdPath).toBe(firstPath);
|
||||
expect(mockedExecFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'soffice',
|
||||
expect.arrayContaining([
|
||||
'--headless',
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
expect.stringMatching(/^-env:UserInstallation=file:/),
|
||||
]),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers Microsoft Word for DOCX files on Windows-mounted paths', async () => {
|
||||
const result = await getOfficePreviewPdfPath(
|
||||
'/mnt/c/Users/aakhter/Documents/codeman-inline-viewer-test.docx',
|
||||
'docx'
|
||||
);
|
||||
|
||||
expect(result).toContain('/mnt/c/Users/aakhter/AppData/Local/Temp/codeman-document-preview-cache/');
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
expect.arrayContaining(['-NoProfile', '-NonInteractive', '-EncodedCommand']),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
const powerShellCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'powershell.exe');
|
||||
const encodedCommand = powerShellCall?.[1]?.at(-1);
|
||||
const decodedCommand = Buffer.from(String(encodedCommand), 'base64').toString('utf16le');
|
||||
expect(decodedCommand).toContain('$source = ');
|
||||
expect(decodedCommand).toContain('C:\\Users\\aakhter\\AppData\\Local\\Temp\\codeman-document-preview-cache\\');
|
||||
expect(decodedCommand).toContain('.docx');
|
||||
expect(decodedCommand).toContain('$output = ');
|
||||
expect(decodedCommand).toContain('.pdf');
|
||||
expect(mockedExecFile).not.toHaveBeenCalledWith(
|
||||
'soffice',
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { generateFirstPageThumbnail } from '../src/document-thumbnailer.js';
|
||||
import { clearDocumentPreviewCache } from '../src/document-preview-cache.js';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn((_cmd, _args, _options, callback) => {
|
||||
callback(null, { stdout: '', stderr: '' });
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
||||
readFile: vi.fn(async () => Buffer.from('png')),
|
||||
readdir: vi.fn(async () => ['converted.pdf']),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => '/tmp/codeman-thumb-test'),
|
||||
rename: vi.fn(async () => undefined),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedExecFile = vi.mocked(execFile);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedMkdir = vi.mocked(fs.mkdir);
|
||||
const mockedMkdtemp = vi.mocked(fs.mkdtemp);
|
||||
const mockedRename = vi.mocked(fs.rename);
|
||||
const mockedRm = vi.mocked(fs.rm);
|
||||
|
||||
describe('document-thumbnailer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDocumentPreviewCache();
|
||||
mockedStat.mockImplementation(async (path) => {
|
||||
const pathText = String(path);
|
||||
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
|
||||
return { size: 500 * 1024 * 1024, mtimeMs: 12345, isFile: () => true } as never;
|
||||
});
|
||||
mockedReadFile.mockResolvedValue(Buffer.from('large thumbnail') as never);
|
||||
mockedReaddir.mockResolvedValue(['converted.pdf'] as never);
|
||||
mockedMkdir.mockResolvedValue(undefined as never);
|
||||
mockedMkdtemp.mockResolvedValue('/tmp/codeman-thumb-test' as never);
|
||||
mockedRename.mockResolvedValue(undefined as never);
|
||||
mockedRm.mockResolvedValue(undefined as never);
|
||||
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, { stdout: '', stderr: '' });
|
||||
return {} as never;
|
||||
});
|
||||
});
|
||||
|
||||
it('renders first-page thumbnails for large documents without an app-level size cap', async () => {
|
||||
const result = await generateFirstPageThumbnail('/tmp/large-deck.pdf', 'pdf');
|
||||
|
||||
expect(result).toEqual({
|
||||
content: Buffer.from('large thumbnail'),
|
||||
contentType: 'image/png',
|
||||
});
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'pdftoppm',
|
||||
expect.arrayContaining(['-png', '-singlefile', '-f', '1', '-l', '1']),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => {
|
||||
mockedMkdtemp.mockImplementation(async (prefix) =>
|
||||
String(prefix).includes('codeman-document-preview-cache')
|
||||
? '/tmp/codeman-document-preview-cache/work-test'
|
||||
: '/tmp/codeman-thumb-pdf-test'
|
||||
);
|
||||
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, { stdout: '', stderr: '' });
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const result = await generateFirstPageThumbnail('/tmp/deck.pptx', 'pptx');
|
||||
|
||||
expect(result).toEqual({
|
||||
content: Buffer.from('large thumbnail'),
|
||||
contentType: 'image/png',
|
||||
});
|
||||
const pdftoppmCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'pdftoppm');
|
||||
expect(pdftoppmCall?.[1]).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('codeman-document-preview-cache')])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -145,9 +145,9 @@ describe('ImageWatcher', () => {
|
||||
// ========== Image Detection ==========
|
||||
|
||||
describe('image detection', () => {
|
||||
it('should emit image:detected (popup) for .png files', () => {
|
||||
it('should emit attachment:detected for .png files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
watcher.on('attachment:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
@@ -161,11 +161,14 @@ describe('ImageWatcher', () => {
|
||||
expect(event.fileName).toBe('screenshot.png');
|
||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||
expect(event.relativePath).toBe('screenshot.png');
|
||||
expect(event.extension).toBe('png');
|
||||
expect(event.attachmentType).toBe('image');
|
||||
expect(event.size).toBe(2048);
|
||||
});
|
||||
|
||||
it('should not emit attachment:detected for .png (stays on the popup path)', () => {
|
||||
it('should not emit legacy image:detected for .png attachment cards', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('attachment:detected', handler);
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @fileoverview Route coverage for the document preview/thumbnail endpoints
|
||||
* added in COD-38 (PR #120): the by-attachmentId routes
|
||||
* (/attachments/:id/preview|thumbnail) and the workspace-path routes
|
||||
* (/file-preview|/file-thumbnail). Converters are mocked, so no real
|
||||
* pdftoppm/LibreOffice is needed. Uses app.inject() — no real ports.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => Buffer.from('%PDF-1.4 fake pdf bytes')),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true, mtimeMs: 1 })),
|
||||
readdir: vi.fn(async () => []),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => '/tmp/codeman-preview-test'),
|
||||
rename: vi.fn(async () => undefined),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, realpathSync: vi.fn((p: string) => p) };
|
||||
});
|
||||
|
||||
// Mock the converters so the routes don't shell out to real binaries.
|
||||
vi.mock('../../src/document-thumbnailer.js', () => ({
|
||||
generateFirstPageThumbnail: vi.fn(async () => ({ content: Buffer.from('\x89PNG fake'), contentType: 'image/png' })),
|
||||
}));
|
||||
vi.mock('../../src/document-preview-cache.js', () => ({
|
||||
getOfficePreviewPdfPath: vi.fn(async () => '/tmp/codeman-document-preview-cache/out.pdf'),
|
||||
getPreviewPdfDownloadName: vi.fn((name: string) => `${name.replace(/\.[^.]+$/, '')}.pdf`),
|
||||
}));
|
||||
|
||||
import { generateFirstPageThumbnail } from '../../src/document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath } from '../../src/document-preview-cache.js';
|
||||
import { attachmentRegistry, type AttachmentRecord } from '../../src/attachment-registry.js';
|
||||
|
||||
const SID = 'test-session-1';
|
||||
const WORKDIR = '/tmp/test-workdir';
|
||||
|
||||
function makeRecord(over: Partial<AttachmentRecord>): AttachmentRecord {
|
||||
return {
|
||||
attachmentId: 'att_x',
|
||||
sessionId: SID,
|
||||
filePath: `${WORKDIR}/file`,
|
||||
fileName: 'file',
|
||||
extension: 'pdf',
|
||||
attachmentType: 'document',
|
||||
size: 100,
|
||||
mtimeMs: 1,
|
||||
timestamp: 1,
|
||||
source: 'detected',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('file-routes preview/thumbnail (COD-38)', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes, { sessionId: SID });
|
||||
vi.clearAllMocks();
|
||||
attachmentRegistry.clearSession(SID);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
attachmentRegistry.clearSession(SID);
|
||||
});
|
||||
|
||||
// ---- workspace-path routes ----
|
||||
|
||||
it('file-preview converts a workspace DOCX to an inline PDF', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=deck.docx` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/pdf');
|
||||
expect(res.headers['content-disposition']).toContain('inline');
|
||||
expect(getOfficePreviewPdfPath).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('file-preview redirects a non-Office workspace file (PDF) to the raw route', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=report.pdf` });
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||
expect(res.statusCode).toBeLessThan(400);
|
||||
expect(res.headers.location).toContain('/file-raw?path=report.pdf');
|
||||
expect(getOfficePreviewPdfPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('file-preview 400s for a missing path parameter', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('file-thumbnail returns a PNG for a supported workspace file', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=deck.pdf` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(generateFirstPageThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('file-thumbnail 400s for an unsupported extension', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=notes.exe` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(generateFirstPageThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- by-attachmentId routes ----
|
||||
|
||||
it('by-id preview converts a registered DOCX attachment', async () => {
|
||||
attachmentRegistry.register(
|
||||
makeRecord({
|
||||
attachmentId: 'att_docx',
|
||||
filePath: `${WORKDIR}/deck.docx`,
|
||||
fileName: 'deck.docx',
|
||||
extension: 'docx',
|
||||
})
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SID}/attachments/att_docx/preview`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/pdf');
|
||||
});
|
||||
|
||||
it('by-id preview redirects a non-Office attachment (PNG) to its raw route', async () => {
|
||||
attachmentRegistry.register(
|
||||
makeRecord({ attachmentId: 'att_png', filePath: `${WORKDIR}/shot.png`, fileName: 'shot.png', extension: 'png' })
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SID}/attachments/att_png/preview`,
|
||||
});
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||
expect(res.statusCode).toBeLessThan(400);
|
||||
expect(res.headers.location).toContain('/attachments/att_png/raw');
|
||||
});
|
||||
|
||||
it('by-id thumbnail returns a PNG for a registered attachment', async () => {
|
||||
attachmentRegistry.register(
|
||||
makeRecord({ attachmentId: 'att_pdf', filePath: `${WORKDIR}/deck.pdf`, fileName: 'deck.pdf', extension: 'pdf' })
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SID}/attachments/att_pdf/thumbnail`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user