// 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'; // 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' } }); let originalNodeEnv: string | undefined; beforeEach(() => { vi.clearAllMocks(); originalNodeEnv = process.env.NODE_ENV; }); afterEach(() => { process.env.NODE_ENV = originalNodeEnv; }); describe('Avatar Storage', () => { it('should generate a unique filename for an authenticated user', () => { process.env.NODE_ENV = 'production'; createUploadMiddleware({ storageType: 'avatar' }); const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0]; const cb = vi.fn(); const mockReq = { user: mockUser } as unknown as Request; 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 = {} as Request; // 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', () => { process.env.NODE_ENV = 'test'; createUploadMiddleware({ storageType: 'avatar' }); const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0]; const cb = vi.fn(); const mockReq = { user: mockUser } as unknown as Request; 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', () => { process.env.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 = {} as Request; storageOptions.filename!(mockReq, mockFlyerFile, cb); expect(cb).toHaveBeenCalledWith( null, expect.stringMatching(/^flyerFile-\d+-\d+-my-flyer-special\.pdf$/i), ); }); it('should generate a predictable filename in test environment', () => { // This test covers lines 43-46 process.env.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 = {} as Request; storageOptions.filename!(mockReq, mockFlyerFile, cb); expect(cb).toHaveBeenCalledWith(null, 'flyerFile-test-flyer-image.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!({} as Request, 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!({} as Request, { ...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; let mockResponse: Partial; let mockNext: NextFunction; beforeEach(() => { mockRequest = {}; 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(); }); });