feat(storage): multer upload options factory

This commit is contained in:
jubnl
2026-08-16 09:21:15 +02:00
parent bad01a2fd6
commit 7ae7295e1e
2 changed files with 230 additions and 0 deletions
@@ -0,0 +1,68 @@
/**
* Multer options factory for storage-backed uploads.
*
* Every disk-backed upload route consumes this through its module's
* MulterModule.registerAsync DI factory: multer spools the request body into
* the category backend's own spool dir (same volume as the destination), and
* the handler commits with storage.put(category, file.filename,
* { tmpPath: file.path }) — an atomic same-volume rename.
*
* The factory is deliberately ignorant of allowlists and per-route quirks:
* fileFilter closures are caller-owned and passed through untouched.
*/
import * as fs from 'fs';
import * as path from 'path';
import { randomUUID } from 'crypto';
import { diskStorage } from 'multer';
import type { Options } from 'multer';
import type { Request } from 'express';
import { StorageService } from './storage.service';
import type { StorageCategory } from './storage.types';
export interface StorageUploadOptions {
/**
* Constant for single-category modules; a per-request/per-file resolver for
* modules whose routes feed different categories (collections: fieldname
* 'image' → 'places', else 'covers').
*/
category: StorageCategory | ((req: Request, file: Express.Multer.File) => StorageCategory);
/** → limits.fileSize */
maxSize: number;
/** Caller-built closure, passed through untouched. */
fileFilter?: Options['fileFilter'];
/**
* Returns the full final filename. Default: `${randomUUID()}${extname}` —
* extension case-preserved, no fallback for extensionless names (parity with
* the pre-factory call sites).
*/
filename?: (req: Request, file: Express.Multer.File) => string;
/** Parity-phase opt-in (files + collab pass 'utf8'). */
defParamCharset?: 'utf8';
}
export function buildStorageUploadOptions(storage: StorageService, opts: StorageUploadOptions) {
const resolveCategory =
typeof opts.category === 'function' ? opts.category : () => opts.category as StorageCategory;
const makeName =
opts.filename ??
((_req: Request, file: Express.Multer.File) => `${randomUUID()}${path.extname(file.originalname)}`);
return {
storage: diskStorage({
// Resolved per request on purpose (registry no-cached-refs rule): a
// reload() that remaps the category must be picked up by the next upload.
destination: (req, file, cb) => {
try {
const dir = storage.spoolDirFor(resolveCategory(req, file));
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
} catch (err) {
cb(err as Error, '');
}
},
filename: (req, file, cb) => cb(null, makeName(req, file)),
}),
limits: { fileSize: opts.maxSize },
...(opts.fileFilter ? { fileFilter: opts.fileFilter } : {}),
...(opts.defParamCharset ? { defParamCharset: opts.defParamCharset } : {}),
};
}
@@ -0,0 +1,162 @@
/**
* Unit tests for buildStorageUploadOptions — the multer options factory every
* upload call site consumes via MulterModule.registerAsync.
*
* STORAGE-UPLOAD-001 … 010.
*
* The diskStorage engine exposes its destination/filename callbacks as
* getDestination/getFilename on the engine instance (multer/storage/disk.js),
* so the tests drive them directly instead of streaming a real request.
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Request } from 'express';
import { buildStorageUploadOptions } from '../../../../src/nest/storage/storage-upload.factory';
import type { StorageService } from '../../../../src/nest/storage/storage.service';
import type { StorageCategory } from '../../../../src/nest/storage/storage.types';
interface DiskEngine {
getDestination: (req: Request, file: Express.Multer.File, cb: (err: Error | null, dir: string) => void) => void;
getFilename: (req: Request, file: Express.Multer.File, cb: (err: Error | null, name: string) => void) => void;
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function fakeFile(overrides: Partial<Express.Multer.File> = {}): Express.Multer.File {
return { fieldname: 'file', originalname: 'doc.pdf', ...overrides } as Express.Multer.File;
}
const req = {} as Request;
function destination(engine: DiskEngine, file: Express.Multer.File): Promise<string> {
return new Promise((resolve, reject) =>
engine.getDestination(req, file, (err, dir) => (err ? reject(err) : resolve(dir))),
);
}
function filename(engine: DiskEngine, file: Express.Multer.File): Promise<string> {
return new Promise((resolve, reject) =>
engine.getFilename(req, file, (err, name) => (err ? reject(err) : resolve(name))),
);
}
describe('buildStorageUploadOptions', () => {
let dirA: string;
let dirB: string;
beforeAll(() => {
dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-upload-factory-a-'));
dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-upload-factory-b-'));
});
afterAll(() => {
fs.rmSync(dirA, { recursive: true, force: true });
fs.rmSync(dirB, { recursive: true, force: true });
});
it('STORAGE-UPLOAD-001 — destination resolves spoolDirFor per request, never cached', async () => {
const spoolDirFor = vi.fn(() => dirA);
const storage = { spoolDirFor } as unknown as StorageService;
const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 });
const engine = opts.storage as unknown as DiskEngine;
expect(await destination(engine, fakeFile())).toBe(dirA);
spoolDirFor.mockReturnValue(dirB);
expect(await destination(engine, fakeFile())).toBe(dirB);
expect(spoolDirFor).toHaveBeenCalledTimes(2);
expect(spoolDirFor).toHaveBeenCalledWith('files');
});
it('STORAGE-UPLOAD-002 — destination creates the spool dir if missing', async () => {
const missing = path.join(dirA, 'not-yet-created');
const storage = { spoolDirFor: () => missing } as unknown as StorageService;
const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 });
const engine = opts.storage as unknown as DiskEngine;
expect(await destination(engine, fakeFile())).toBe(missing);
expect(fs.existsSync(missing)).toBe(true);
});
it('STORAGE-UPLOAD-003 — category resolver receives (req, file) and routes per file', async () => {
const spoolDirFor = vi.fn((cat: StorageCategory) => (cat === 'places' ? dirA : dirB));
const storage = { spoolDirFor } as unknown as StorageService;
const category = vi.fn((_r: Request, file: Express.Multer.File): StorageCategory =>
file.fieldname === 'image' ? 'places' : 'covers',
);
const opts = buildStorageUploadOptions(storage, { category, maxSize: 10 });
const engine = opts.storage as unknown as DiskEngine;
expect(await destination(engine, fakeFile({ fieldname: 'image' }))).toBe(dirA);
expect(await destination(engine, fakeFile({ fieldname: 'cover' }))).toBe(dirB);
expect(spoolDirFor).toHaveBeenNthCalledWith(1, 'places');
expect(spoolDirFor).toHaveBeenNthCalledWith(2, 'covers');
expect(category).toHaveBeenCalledWith(req, expect.objectContaining({ fieldname: 'cover' }));
});
it('STORAGE-UPLOAD-004 — destination surfaces spoolDirFor errors through the callback', async () => {
const boom = new Error('registry not initialized');
const storage = { spoolDirFor: () => { throw boom; } } as unknown as StorageService;
const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 });
const engine = opts.storage as unknown as DiskEngine;
await expect(destination(engine, fakeFile())).rejects.toBe(boom);
});
it('STORAGE-UPLOAD-005 — default filename is a v4 uuid with the case-preserved extension', async () => {
const storage = { spoolDirFor: () => dirA } as unknown as StorageService;
const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 });
const engine = opts.storage as unknown as DiskEngine;
const upper = await filename(engine, fakeFile({ originalname: 'Photo.JPG' }));
expect(upper).toMatch(/\.JPG$/);
expect(upper.slice(0, -4)).toMatch(UUID_RE);
});
it('STORAGE-UPLOAD-006 — default filename has no fallback extension for extensionless names', async () => {
const storage = { spoolDirFor: () => dirA } as unknown as StorageService;
const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 });
const engine = opts.storage as unknown as DiskEngine;
expect(await filename(engine, fakeFile({ originalname: 'noext' }))).toMatch(UUID_RE);
});
it('STORAGE-UPLOAD-007 — a custom filename hook is used verbatim', async () => {
const storage = { spoolDirFor: () => dirA } as unknown as StorageService;
const opts = buildStorageUploadOptions(storage, {
category: 'journey',
maxSize: 10,
filename: (_r, file) => (file.fieldname === 'poster' ? 'forced.jpg' : 'other.bin'),
});
const engine = opts.storage as unknown as DiskEngine;
expect(await filename(engine, fakeFile({ fieldname: 'poster', originalname: 'x.html' }))).toBe('forced.jpg');
expect(await filename(engine, fakeFile({ fieldname: 'video' }))).toBe('other.bin');
});
it('STORAGE-UPLOAD-008 — maxSize becomes limits.fileSize', () => {
const storage = { spoolDirFor: () => dirA } as unknown as StorageService;
const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 123456 });
expect(opts.limits).toEqual({ fileSize: 123456 });
});
it('STORAGE-UPLOAD-009 — fileFilter passes through untouched, and is absent when not given', () => {
const storage = { spoolDirFor: () => dirA } as unknown as StorageService;
const filter = vi.fn();
const withFilter = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10, fileFilter: filter });
expect(withFilter.fileFilter).toBe(filter);
const without = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 });
expect('fileFilter' in without).toBe(false);
});
it('STORAGE-UPLOAD-010 — defParamCharset is absent unless passed, utf8 when passed', () => {
const storage = { spoolDirFor: () => dirA } as unknown as StorageService;
const without = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 });
expect('defParamCharset' in without).toBe(false);
const withCharset = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10, defParamCharset: 'utf8' });
expect((withCharset as { defParamCharset?: string }).defParamCharset).toBe('utf8');
});
});