Skip to content

Commit cf58689

Browse files
feat(uploads): add createFromBuffer for programmatic GridFS storage (#3290)
* feat(uploads): add createFromBuffer for programmatic GridFS storage (#3289) * fix(uploads): address review feedback — input validation, stream safety, tests - Fix Readable.from(buffer) → Readable.from([buffer]) to avoid byte-by-byte iteration - Add readable error handler for reliable Promise rejection on stream failures - Add Buffer.isBuffer validation with clear AppError (422) for invalid inputs - Add defensive Array.isArray check for kindConfig.formats - Extract MIME_TO_EXT map to module-level constant - Add tests for null/undefined buffer, non-Buffer input, and missing formats * test(uploads): add empty buffer test and clarify runtime config pattern
1 parent a83606c commit cf58689

4 files changed

Lines changed: 260 additions & 0 deletions

File tree

lib/services/gridfs.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from 'path';
22
import crypto from 'crypto';
3+
import { Readable } from 'stream';
34
import mongoose from 'mongoose';
45

56
let storage;
@@ -61,6 +62,37 @@ const getStorage = () => {
6162
return storage;
6263
};
6364

65+
/**
66+
* Store a buffer in GridFS programmatically (no HTTP upload).
67+
* @param {Buffer} buffer - File content
68+
* @param {string} filename - Unique filename (e.g., 'snapshot-abc123.jpeg')
69+
* @param {string} contentType - MIME type (e.g., 'image/jpeg')
70+
* @param {Object} metadata - GridFS metadata (kind, user, organizationId, etc.)
71+
* @returns {Promise<Object>} The stored file document
72+
*/
73+
const createFromBuffer = (buffer, filename, contentType, metadata = {}) => new Promise((resolve, reject) => {
74+
const bucket = new mongoose.mongo.GridFSBucket(mongoose.connection.db, { bucketName: 'uploads' });
75+
const id = new mongoose.Types.ObjectId();
76+
const uploadStream = bucket.openUploadStreamWithId(id, filename, { contentType, metadata });
77+
78+
const readable = Readable.from([buffer]);
79+
readable.on('error', reject);
80+
readable.pipe(
81+
uploadStream
82+
.on('error', reject)
83+
.on('finish', async () => {
84+
try {
85+
const Uploads = mongoose.model('Uploads');
86+
const file = await Uploads.findOne({ _id: id }).exec();
87+
resolve(file);
88+
} catch (err) {
89+
reject(err);
90+
}
91+
}),
92+
);
93+
});
94+
6495
export default {
6596
getStorage,
97+
createFromBuffer,
6698
};

modules/uploads/config/config.uploads.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ const config = {
1515
operations: ['blur', 'bw', 'blur&bw'],
1616
},
1717
},
18+
snapshot: {
19+
kind: 'snapshot',
20+
formats: ['image/jpeg', 'image/png'],
21+
limits: {
22+
fileSize: 5 * 1024 * 1024, // Max file size in bytes (5 MB)
23+
},
24+
},
1825
},
1926
};
2027

modules/uploads/services/uploads.service.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
/**
22
* Module dependencies
33
*/
4+
import crypto from 'crypto';
5+
6+
import config from '../../../config/index.js';
7+
import AppError from '../../../lib/helpers/AppError.js';
48
import multerService from '../../../lib/services/multer.js';
9+
import gridfs from '../../../lib/services/gridfs.js';
510
import UploadRepository from '../repositories/uploads.repository.js';
611

12+
const MIME_TO_EXT = {
13+
'image/jpeg': 'jpeg',
14+
'image/jpg': 'jpg',
15+
'image/png': 'png',
16+
'image/gif': 'gif',
17+
'application/pdf': 'pdf',
18+
};
19+
720
/**
821
* @desc Function to ask repository to get an upload
922
* @param {String} uploadName
@@ -53,9 +66,45 @@ const remove = async (upload) => {
5366
return Promise.resolve(result);
5467
};
5568

69+
/**
70+
* @desc Store a buffer as an upload programmatically (no HTTP request)
71+
* @param {Buffer} buffer - File content
72+
* @param {string} contentType - MIME type (e.g., 'image/jpeg')
73+
* @param {string} kind - Upload kind matching config (e.g., 'snapshot')
74+
* @param {Object} metadata - Additional metadata (user, organizationId, etc.)
75+
* @returns {Promise<Object>} The created upload document
76+
*/
77+
const createFromBuffer = async (buffer, contentType, kind, metadata = {}) => {
78+
if (!Buffer.isBuffer(buffer)) {
79+
throw new AppError('Upload: buffer is required and must be a Buffer', { code: 'SERVICE_ERROR', status: 422 });
80+
}
81+
82+
const kindConfig = config.uploads?.[kind];
83+
if (!kindConfig) throw new AppError(`Upload: unknown kind "${kind}"`, { code: 'SERVICE_ERROR', status: 422 });
84+
85+
if (!Array.isArray(kindConfig.formats)) {
86+
throw new AppError(`Upload: kind "${kind}" has no formats configured`, { code: 'SERVICE_ERROR', status: 500 });
87+
}
88+
89+
if (!kindConfig.formats.includes(contentType)) {
90+
throw new AppError(`Upload: content type "${contentType}" not allowed for kind "${kind}"`, { code: 'SERVICE_ERROR', status: 422 });
91+
}
92+
93+
if (kindConfig.limits?.fileSize && buffer.length > kindConfig.limits.fileSize) {
94+
throw new AppError(`Upload: buffer size ${buffer.length} exceeds limit ${kindConfig.limits.fileSize}`, { code: 'SERVICE_ERROR', status: 422 });
95+
}
96+
97+
const ext = MIME_TO_EXT[contentType] || 'bin';
98+
const filename = `${crypto.randomBytes(32).toString('hex')}.${ext}`;
99+
100+
const result = await gridfs.createFromBuffer(buffer, filename, contentType, { ...metadata, kind, contentType });
101+
return result;
102+
};
103+
56104
export default {
57105
get,
58106
getStream,
59107
update,
60108
remove,
109+
createFromBuffer,
61110
};
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for uploads createFromBuffer service
8+
*/
9+
describe('Uploads createFromBuffer unit tests:', () => {
10+
let UploadsService;
11+
let mockGridfs;
12+
let mockConfig;
13+
14+
const fakeFile = {
15+
_id: '507f1f77bcf86cd799439011',
16+
filename: 'abc123.jpeg',
17+
contentType: 'image/jpeg',
18+
metadata: { kind: 'snapshot', contentType: 'image/jpeg' },
19+
length: 1024,
20+
};
21+
22+
beforeEach(async () => {
23+
jest.resetModules();
24+
25+
mockGridfs = {
26+
createFromBuffer: jest.fn().mockResolvedValue(fakeFile),
27+
getStorage: jest.fn(),
28+
};
29+
30+
mockConfig = {
31+
uploads: {
32+
snapshot: {
33+
kind: 'snapshot',
34+
formats: ['image/jpeg', 'image/png'],
35+
limits: { fileSize: 5 * 1024 * 1024 },
36+
},
37+
avatar: {
38+
kind: 'avatar',
39+
formats: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'],
40+
limits: { fileSize: 1 * 1024 * 1024 },
41+
},
42+
},
43+
};
44+
45+
jest.unstable_mockModule('../../../lib/services/gridfs.js', () => ({
46+
default: mockGridfs,
47+
}));
48+
49+
jest.unstable_mockModule('../../../config/index.js', () => ({
50+
default: mockConfig,
51+
}));
52+
53+
jest.unstable_mockModule('../../../lib/services/multer.js', () => ({
54+
default: { generateFileName: jest.fn() },
55+
}));
56+
57+
jest.unstable_mockModule('../repositories/uploads.repository.js', () => ({
58+
default: {
59+
get: jest.fn(),
60+
getStream: jest.fn(),
61+
update: jest.fn(),
62+
remove: jest.fn(),
63+
},
64+
}));
65+
66+
UploadsService = (await import('../services/uploads.service.js')).default;
67+
});
68+
69+
afterEach(() => {
70+
jest.restoreAllMocks();
71+
});
72+
73+
test('should store a valid JPEG buffer and return upload document', async () => {
74+
const buffer = Buffer.alloc(1024);
75+
const result = await UploadsService.createFromBuffer(buffer, 'image/jpeg', 'snapshot', { user: '507f1f77bcf86cd799439011' });
76+
77+
expect(result).toBeDefined();
78+
expect(result.contentType).toBe('image/jpeg');
79+
expect(result.metadata.kind).toBe('snapshot');
80+
expect(mockGridfs.createFromBuffer).toHaveBeenCalledTimes(1);
81+
82+
const [buf, filename, contentType, metadata] = mockGridfs.createFromBuffer.mock.calls[0];
83+
expect(buf).toBe(buffer);
84+
expect(filename).toMatch(/^[a-f0-9]{64}\.jpeg$/);
85+
expect(contentType).toBe('image/jpeg');
86+
expect(metadata.kind).toBe('snapshot');
87+
expect(metadata.user).toBe('507f1f77bcf86cd799439011');
88+
});
89+
90+
test('should store a valid PNG buffer and return upload document', async () => {
91+
const buffer = Buffer.alloc(512);
92+
mockGridfs.createFromBuffer.mockResolvedValue({ ...fakeFile, contentType: 'image/png' });
93+
94+
const result = await UploadsService.createFromBuffer(buffer, 'image/png', 'snapshot');
95+
expect(result).toBeDefined();
96+
expect(result.contentType).toBe('image/png');
97+
98+
const [, filename] = mockGridfs.createFromBuffer.mock.calls[0];
99+
expect(filename).toMatch(/^[a-f0-9]{64}\.png$/);
100+
});
101+
102+
test('should throw error when buffer exceeds size limit', async () => {
103+
const oversizedBuffer = Buffer.alloc(6 * 1024 * 1024); // 6 MB > 5 MB limit
104+
105+
await expect(
106+
UploadsService.createFromBuffer(oversizedBuffer, 'image/jpeg', 'snapshot'),
107+
).rejects.toThrow(/buffer size .* exceeds limit/);
108+
109+
expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled();
110+
});
111+
112+
test('should throw error when content type is not allowed for kind', async () => {
113+
const buffer = Buffer.alloc(1024);
114+
115+
await expect(
116+
UploadsService.createFromBuffer(buffer, 'application/pdf', 'snapshot'),
117+
).rejects.toThrow(/content type .* not allowed/);
118+
119+
expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled();
120+
});
121+
122+
test('should throw error when kind is unknown', async () => {
123+
const buffer = Buffer.alloc(1024);
124+
125+
await expect(
126+
UploadsService.createFromBuffer(buffer, 'image/jpeg', 'unknown'),
127+
).rejects.toThrow(/unknown kind/);
128+
129+
expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled();
130+
});
131+
132+
test('should throw error when buffer is null or undefined', async () => {
133+
await expect(
134+
UploadsService.createFromBuffer(null, 'image/jpeg', 'snapshot'),
135+
).rejects.toThrow(/buffer is required/);
136+
137+
await expect(
138+
UploadsService.createFromBuffer(undefined, 'image/jpeg', 'snapshot'),
139+
).rejects.toThrow(/buffer is required/);
140+
141+
expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled();
142+
});
143+
144+
test('should throw error when buffer is not a Buffer', async () => {
145+
await expect(
146+
UploadsService.createFromBuffer('not a buffer', 'image/jpeg', 'snapshot'),
147+
).rejects.toThrow(/buffer is required/);
148+
149+
expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled();
150+
});
151+
152+
test('should accept empty buffer (0-byte file)', async () => {
153+
const emptyBuffer = Buffer.alloc(0);
154+
mockGridfs.createFromBuffer.mockResolvedValue({ ...fakeFile, length: 0 });
155+
156+
const result = await UploadsService.createFromBuffer(emptyBuffer, 'image/jpeg', 'snapshot');
157+
expect(result).toBeDefined();
158+
expect(result.length).toBe(0);
159+
expect(mockGridfs.createFromBuffer).toHaveBeenCalledTimes(1);
160+
});
161+
162+
test('should throw error when kind has no formats configured', async () => {
163+
// Adding 'broken' kind at runtime — service reads config dynamically via module reference
164+
mockConfig.uploads.broken = { kind: 'broken', limits: { fileSize: 1024 } };
165+
166+
await expect(
167+
UploadsService.createFromBuffer(Buffer.alloc(10), 'image/jpeg', 'broken'),
168+
).rejects.toThrow(/no formats configured/);
169+
170+
expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled();
171+
});
172+
});

0 commit comments

Comments
 (0)