All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m39s
273 lines
9.4 KiB
TypeScript
273 lines
9.4 KiB
TypeScript
// src/middleware/multer.middleware.test.ts
|
|
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
|
import multer from 'multer';
|
|
import type { Request, Response, NextFunction } from 'express';
|
|
import { createUploadMiddleware, handleMulterError } from './multer.middleware';
|
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
|
import { ValidationError } from '../services/db/errors.db';
|
|
import { createMockRequest } from '../tests/utils/createMockRequest';
|
|
|
|
// 1. Hoist the mocks so they can be referenced inside vi.mock factories.
|
|
const mocks = vi.hoisted(() => ({
|
|
mkdir: vi.fn(),
|
|
logger: {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// 2. Mock node:fs/promises.
|
|
// We mock the default export because that's how it's imported in the source file.
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
mkdir: mocks.mkdir,
|
|
},
|
|
}));
|
|
|
|
// 3. Mock the logger service.
|
|
vi.mock('../services/logger.server', () => ({
|
|
logger: mocks.logger,
|
|
}));
|
|
|
|
// 4. Mock multer to prevent it from doing anything during import.
|
|
vi.mock('multer', () => {
|
|
const diskStorage = vi.fn((options) => options);
|
|
// A more realistic mock for MulterError that maps error codes to messages,
|
|
// similar to how the actual multer library works.
|
|
class MulterError extends Error {
|
|
code: string;
|
|
field?: string;
|
|
|
|
constructor(code: string, field?: string) {
|
|
const messages: { [key: string]: string } = {
|
|
LIMIT_FILE_SIZE: 'File too large',
|
|
LIMIT_UNEXPECTED_FILE: 'Unexpected file',
|
|
// Add other codes as needed for tests
|
|
};
|
|
const message = messages[code] || code;
|
|
super(message);
|
|
this.code = code;
|
|
this.name = 'MulterError';
|
|
if (field) {
|
|
this.field = field;
|
|
}
|
|
}
|
|
}
|
|
const multer = vi.fn(() => ({
|
|
single: vi.fn().mockImplementation(() => (req: any, res: any, next: any) => next()),
|
|
array: vi.fn().mockImplementation(() => (req: any, res: any, next: any) => next()),
|
|
}));
|
|
(multer as any).diskStorage = diskStorage;
|
|
(multer as any).MulterError = MulterError;
|
|
return {
|
|
default: multer,
|
|
diskStorage,
|
|
MulterError,
|
|
};
|
|
});
|
|
|
|
describe('Multer Middleware Directory Creation', () => {
|
|
beforeEach(() => {
|
|
// Critical: Reset modules to ensure the top-level IIFE runs again for each test.
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should attempt to create directories on module load and log success', async () => {
|
|
// Arrange
|
|
mocks.mkdir.mockResolvedValue(undefined);
|
|
|
|
// Act: Dynamic import triggers the top-level code execution
|
|
await import('./multer.middleware');
|
|
|
|
// Assert
|
|
// It should try to create both the flyer storage and avatar storage paths
|
|
expect(mocks.mkdir).toHaveBeenCalledTimes(2);
|
|
expect(mocks.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
|
expect(mocks.logger.info).toHaveBeenCalledWith('Ensured multer storage directories exist.');
|
|
expect(mocks.logger.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should log an error if directory creation fails', async () => {
|
|
// Arrange
|
|
const error = new Error('Permission denied');
|
|
mocks.mkdir.mockRejectedValue(error);
|
|
|
|
// Act
|
|
await import('./multer.middleware');
|
|
|
|
// Assert
|
|
expect(mocks.mkdir).toHaveBeenCalled();
|
|
expect(mocks.logger.error).toHaveBeenCalledWith(
|
|
{ error },
|
|
'Failed to create multer storage directories on startup.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createUploadMiddleware', () => {
|
|
const mockFile = { originalname: 'test.png' } as Express.Multer.File;
|
|
const mockUser = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@user.com' } });
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('Avatar Storage', () => {
|
|
it('should generate a unique filename for an authenticated user', () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
createUploadMiddleware({ storageType: 'avatar' });
|
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
|
const cb = vi.fn();
|
|
const mockReq = createMockRequest({ user: mockUser });
|
|
|
|
storageOptions.filename!(mockReq, mockFile, cb);
|
|
|
|
expect(cb).toHaveBeenCalledWith(null, expect.stringContaining('user-123-'));
|
|
expect(cb).toHaveBeenCalledWith(null, expect.stringContaining('.png'));
|
|
});
|
|
|
|
it('should call the callback with an error for an unauthenticated user', () => {
|
|
// This test covers line 37
|
|
createUploadMiddleware({ storageType: 'avatar' });
|
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
|
const cb = vi.fn();
|
|
const mockReq = createMockRequest(); // No user on request
|
|
|
|
storageOptions.filename!(mockReq, mockFile, cb);
|
|
|
|
expect(cb).toHaveBeenCalledWith(
|
|
new Error('User not authenticated for avatar upload'),
|
|
expect.any(String),
|
|
);
|
|
});
|
|
|
|
it('should use a predictable filename in test environment', () => {
|
|
vi.stubEnv('NODE_ENV', 'test');
|
|
createUploadMiddleware({ storageType: 'avatar' });
|
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
|
const cb = vi.fn();
|
|
const mockReq = createMockRequest({ user: mockUser });
|
|
|
|
storageOptions.filename!(mockReq, mockFile, cb);
|
|
|
|
expect(cb).toHaveBeenCalledWith(null, 'test-avatar.png');
|
|
});
|
|
});
|
|
|
|
describe('Flyer Storage', () => {
|
|
it('should generate a unique, sanitized filename in production environment', () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
const mockFlyerFile = {
|
|
fieldname: 'flyerFile',
|
|
originalname: 'My Flyer (Special!).pdf',
|
|
} as Express.Multer.File;
|
|
createUploadMiddleware({ storageType: 'flyer' });
|
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
|
const cb = vi.fn();
|
|
const mockReq = createMockRequest();
|
|
|
|
storageOptions.filename!(mockReq, mockFlyerFile, cb);
|
|
|
|
expect(cb).toHaveBeenCalledWith(
|
|
null,
|
|
expect.stringMatching(/^flyerFile-\d+-\d+-my-flyer-special\.pdf$/i),
|
|
);
|
|
});
|
|
|
|
it('should generate a unique filename in test environment', () => {
|
|
// This test covers the default case in getStorageConfig
|
|
vi.stubEnv('NODE_ENV', 'test');
|
|
const mockFlyerFile = {
|
|
fieldname: 'flyerFile',
|
|
originalname: 'test-flyer.jpg',
|
|
} as Express.Multer.File;
|
|
createUploadMiddleware({ storageType: 'flyer' });
|
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
|
const cb = vi.fn();
|
|
const mockReq = createMockRequest();
|
|
|
|
storageOptions.filename!(mockReq, mockFlyerFile, cb);
|
|
|
|
expect(cb).toHaveBeenCalledWith(
|
|
null,
|
|
expect.stringMatching(/^flyerFile-\d+-\d+-test-flyer\.jpg$/),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Image File Filter', () => {
|
|
it('should accept files with an image mimetype', () => {
|
|
createUploadMiddleware({ storageType: 'flyer', fileFilter: 'image' });
|
|
const multerOptions = vi.mocked(multer).mock.calls[0][0];
|
|
const cb = vi.fn();
|
|
const mockImageFile = { mimetype: 'image/png' } as Express.Multer.File;
|
|
|
|
multerOptions!.fileFilter!(createMockRequest(), mockImageFile, cb);
|
|
|
|
expect(cb).toHaveBeenCalledWith(null, true);
|
|
});
|
|
|
|
it('should reject files without an image mimetype', () => {
|
|
createUploadMiddleware({ storageType: 'flyer', fileFilter: 'image' });
|
|
const multerOptions = vi.mocked(multer).mock.calls[0][0];
|
|
const cb = vi.fn();
|
|
const mockTextFile = { mimetype: 'text/plain' } as Express.Multer.File;
|
|
|
|
multerOptions!.fileFilter!(createMockRequest(), { ...mockTextFile, fieldname: 'test' }, cb);
|
|
|
|
const error = (cb as Mock).mock.calls[0][0];
|
|
expect(error).toBeInstanceOf(ValidationError);
|
|
expect(error.validationErrors[0].message).toBe('Only image files are allowed!');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleMulterError Middleware', () => {
|
|
let mockRequest: Partial<Request>;
|
|
let mockResponse: Partial<Response>;
|
|
let mockNext: NextFunction;
|
|
|
|
beforeEach(() => {
|
|
mockRequest = createMockRequest();
|
|
mockResponse = {
|
|
status: vi.fn().mockReturnThis(),
|
|
json: vi.fn(),
|
|
};
|
|
mockNext = vi.fn();
|
|
});
|
|
|
|
it('should handle a MulterError (e.g., file too large)', () => {
|
|
const err = new multer.MulterError('LIMIT_FILE_SIZE');
|
|
handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
|
|
expect(mockResponse.status).toHaveBeenCalledWith(400);
|
|
expect(mockResponse.json).toHaveBeenCalledWith({
|
|
message: 'File upload error: File too large',
|
|
});
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should pass on a ValidationError to the next handler', () => {
|
|
const err = new ValidationError([], 'Only image files are allowed!');
|
|
handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
|
|
// It should now pass the error to the global error handler
|
|
expect(mockNext).toHaveBeenCalledWith(err);
|
|
expect(mockResponse.status).not.toHaveBeenCalled();
|
|
expect(mockResponse.json).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should pass on non-multer errors to the next error handler', () => {
|
|
const err = new Error('A generic error');
|
|
handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
|
|
expect(mockNext).toHaveBeenCalledWith(err);
|
|
expect(mockResponse.status).not.toHaveBeenCalled();
|
|
});
|
|
});
|