Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions lib/services/gridfs.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'path';
import crypto from 'crypto';
import { Readable } from 'stream';
import mongoose from 'mongoose';

let storage;
Expand Down Expand Up @@ -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<Object>} 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,
};
7 changes: 7 additions & 0 deletions modules/uploads/config/config.uploads.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
},
},
};

Expand Down
49 changes: 49 additions & 0 deletions modules/uploads/services/uploads.service.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Object>} 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,
};
172 changes: 172 additions & 0 deletions modules/uploads/tests/uploads.createFromBuffer.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading