diff --git a/lib/services/gridfs.js b/lib/services/gridfs.js index 73e6ef82f..d8f2847f9 100644 --- a/lib/services/gridfs.js +++ b/lib/services/gridfs.js @@ -1,5 +1,6 @@ import path from 'path'; import crypto from 'crypto'; +import { Readable } from 'stream'; import mongoose from 'mongoose'; let storage; @@ -61,6 +62,37 @@ const getStorage = () => { return storage; }; +/** + * Store a buffer in GridFS programmatically (no HTTP upload). + * @param {Buffer} buffer - File content + * @param {string} filename - Unique filename (e.g., 'snapshot-abc123.jpeg') + * @param {string} contentType - MIME type (e.g., 'image/jpeg') + * @param {Object} metadata - GridFS metadata (kind, user, organizationId, etc.) + * @returns {Promise} The stored file document + */ +const createFromBuffer = (buffer, filename, contentType, metadata = {}) => new Promise((resolve, reject) => { + const bucket = new mongoose.mongo.GridFSBucket(mongoose.connection.db, { bucketName: 'uploads' }); + const id = new mongoose.Types.ObjectId(); + const uploadStream = bucket.openUploadStreamWithId(id, filename, { contentType, metadata }); + + const readable = Readable.from([buffer]); + readable.on('error', reject); + readable.pipe( + uploadStream + .on('error', reject) + .on('finish', async () => { + try { + const Uploads = mongoose.model('Uploads'); + const file = await Uploads.findOne({ _id: id }).exec(); + resolve(file); + } catch (err) { + reject(err); + } + }), + ); +}); + export default { getStorage, + createFromBuffer, }; diff --git a/modules/uploads/config/config.uploads.js b/modules/uploads/config/config.uploads.js index 5abb849c6..9c19a0baf 100644 --- a/modules/uploads/config/config.uploads.js +++ b/modules/uploads/config/config.uploads.js @@ -15,6 +15,13 @@ const config = { operations: ['blur', 'bw', 'blur&bw'], }, }, + snapshot: { + kind: 'snapshot', + formats: ['image/jpeg', 'image/png'], + limits: { + fileSize: 5 * 1024 * 1024, // Max file size in bytes (5 MB) + }, + }, }, }; diff --git a/modules/uploads/services/uploads.service.js b/modules/uploads/services/uploads.service.js index f23b94767..0c8e51cbe 100644 --- a/modules/uploads/services/uploads.service.js +++ b/modules/uploads/services/uploads.service.js @@ -1,9 +1,22 @@ /** * Module dependencies */ +import crypto from 'crypto'; + +import config from '../../../config/index.js'; +import AppError from '../../../lib/helpers/AppError.js'; import multerService from '../../../lib/services/multer.js'; +import gridfs from '../../../lib/services/gridfs.js'; import UploadRepository from '../repositories/uploads.repository.js'; +const MIME_TO_EXT = { + 'image/jpeg': 'jpeg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'application/pdf': 'pdf', +}; + /** * @desc Function to ask repository to get an upload * @param {String} uploadName @@ -53,9 +66,45 @@ const remove = async (upload) => { return Promise.resolve(result); }; +/** + * @desc Store a buffer as an upload programmatically (no HTTP request) + * @param {Buffer} buffer - File content + * @param {string} contentType - MIME type (e.g., 'image/jpeg') + * @param {string} kind - Upload kind matching config (e.g., 'snapshot') + * @param {Object} metadata - Additional metadata (user, organizationId, etc.) + * @returns {Promise} The created upload document + */ +const createFromBuffer = async (buffer, contentType, kind, metadata = {}) => { + if (!Buffer.isBuffer(buffer)) { + throw new AppError('Upload: buffer is required and must be a Buffer', { code: 'SERVICE_ERROR', status: 422 }); + } + + const kindConfig = config.uploads?.[kind]; + if (!kindConfig) throw new AppError(`Upload: unknown kind "${kind}"`, { code: 'SERVICE_ERROR', status: 422 }); + + if (!Array.isArray(kindConfig.formats)) { + throw new AppError(`Upload: kind "${kind}" has no formats configured`, { code: 'SERVICE_ERROR', status: 500 }); + } + + if (!kindConfig.formats.includes(contentType)) { + throw new AppError(`Upload: content type "${contentType}" not allowed for kind "${kind}"`, { code: 'SERVICE_ERROR', status: 422 }); + } + + if (kindConfig.limits?.fileSize && buffer.length > kindConfig.limits.fileSize) { + throw new AppError(`Upload: buffer size ${buffer.length} exceeds limit ${kindConfig.limits.fileSize}`, { code: 'SERVICE_ERROR', status: 422 }); + } + + const ext = MIME_TO_EXT[contentType] || 'bin'; + const filename = `${crypto.randomBytes(32).toString('hex')}.${ext}`; + + const result = await gridfs.createFromBuffer(buffer, filename, contentType, { ...metadata, kind, contentType }); + return result; +}; + export default { get, getStream, update, remove, + createFromBuffer, }; diff --git a/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js b/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js new file mode 100644 index 000000000..5dd32cf2f --- /dev/null +++ b/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js @@ -0,0 +1,172 @@ +/** + * Module dependencies. + */ +import { jest, beforeEach, afterEach } from '@jest/globals'; + +/** + * Unit tests for uploads createFromBuffer service + */ +describe('Uploads createFromBuffer unit tests:', () => { + let UploadsService; + let mockGridfs; + let mockConfig; + + const fakeFile = { + _id: '507f1f77bcf86cd799439011', + filename: 'abc123.jpeg', + contentType: 'image/jpeg', + metadata: { kind: 'snapshot', contentType: 'image/jpeg' }, + length: 1024, + }; + + beforeEach(async () => { + jest.resetModules(); + + mockGridfs = { + createFromBuffer: jest.fn().mockResolvedValue(fakeFile), + getStorage: jest.fn(), + }; + + mockConfig = { + uploads: { + snapshot: { + kind: 'snapshot', + formats: ['image/jpeg', 'image/png'], + limits: { fileSize: 5 * 1024 * 1024 }, + }, + avatar: { + kind: 'avatar', + formats: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'], + limits: { fileSize: 1 * 1024 * 1024 }, + }, + }, + }; + + jest.unstable_mockModule('../../../lib/services/gridfs.js', () => ({ + default: mockGridfs, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: mockConfig, + })); + + jest.unstable_mockModule('../../../lib/services/multer.js', () => ({ + default: { generateFileName: jest.fn() }, + })); + + jest.unstable_mockModule('../repositories/uploads.repository.js', () => ({ + default: { + get: jest.fn(), + getStream: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }, + })); + + UploadsService = (await import('../services/uploads.service.js')).default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should store a valid JPEG buffer and return upload document', async () => { + const buffer = Buffer.alloc(1024); + const result = await UploadsService.createFromBuffer(buffer, 'image/jpeg', 'snapshot', { user: '507f1f77bcf86cd799439011' }); + + expect(result).toBeDefined(); + expect(result.contentType).toBe('image/jpeg'); + expect(result.metadata.kind).toBe('snapshot'); + expect(mockGridfs.createFromBuffer).toHaveBeenCalledTimes(1); + + const [buf, filename, contentType, metadata] = mockGridfs.createFromBuffer.mock.calls[0]; + expect(buf).toBe(buffer); + expect(filename).toMatch(/^[a-f0-9]{64}\.jpeg$/); + expect(contentType).toBe('image/jpeg'); + expect(metadata.kind).toBe('snapshot'); + expect(metadata.user).toBe('507f1f77bcf86cd799439011'); + }); + + test('should store a valid PNG buffer and return upload document', async () => { + const buffer = Buffer.alloc(512); + mockGridfs.createFromBuffer.mockResolvedValue({ ...fakeFile, contentType: 'image/png' }); + + const result = await UploadsService.createFromBuffer(buffer, 'image/png', 'snapshot'); + expect(result).toBeDefined(); + expect(result.contentType).toBe('image/png'); + + const [, filename] = mockGridfs.createFromBuffer.mock.calls[0]; + expect(filename).toMatch(/^[a-f0-9]{64}\.png$/); + }); + + test('should throw error when buffer exceeds size limit', async () => { + const oversizedBuffer = Buffer.alloc(6 * 1024 * 1024); // 6 MB > 5 MB limit + + await expect( + UploadsService.createFromBuffer(oversizedBuffer, 'image/jpeg', 'snapshot'), + ).rejects.toThrow(/buffer size .* exceeds limit/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); + + test('should throw error when content type is not allowed for kind', async () => { + const buffer = Buffer.alloc(1024); + + await expect( + UploadsService.createFromBuffer(buffer, 'application/pdf', 'snapshot'), + ).rejects.toThrow(/content type .* not allowed/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); + + test('should throw error when kind is unknown', async () => { + const buffer = Buffer.alloc(1024); + + await expect( + UploadsService.createFromBuffer(buffer, 'image/jpeg', 'unknown'), + ).rejects.toThrow(/unknown kind/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); + + test('should throw error when buffer is null or undefined', async () => { + await expect( + UploadsService.createFromBuffer(null, 'image/jpeg', 'snapshot'), + ).rejects.toThrow(/buffer is required/); + + await expect( + UploadsService.createFromBuffer(undefined, 'image/jpeg', 'snapshot'), + ).rejects.toThrow(/buffer is required/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); + + test('should throw error when buffer is not a Buffer', async () => { + await expect( + UploadsService.createFromBuffer('not a buffer', 'image/jpeg', 'snapshot'), + ).rejects.toThrow(/buffer is required/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); + + test('should accept empty buffer (0-byte file)', async () => { + const emptyBuffer = Buffer.alloc(0); + mockGridfs.createFromBuffer.mockResolvedValue({ ...fakeFile, length: 0 }); + + const result = await UploadsService.createFromBuffer(emptyBuffer, 'image/jpeg', 'snapshot'); + expect(result).toBeDefined(); + expect(result.length).toBe(0); + expect(mockGridfs.createFromBuffer).toHaveBeenCalledTimes(1); + }); + + test('should throw error when kind has no formats configured', async () => { + // Adding 'broken' kind at runtime — service reads config dynamically via module reference + mockConfig.uploads.broken = { kind: 'broken', limits: { fileSize: 1024 } }; + + await expect( + UploadsService.createFromBuffer(Buffer.alloc(10), 'image/jpeg', 'broken'), + ).rejects.toThrow(/no formats configured/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); +});