@flusys/nestjs-storage 6.0.2 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/cjs/controllers/file-manager.controller.spec.js +135 -0
  2. package/cjs/controllers/folder.controller.spec.js +86 -0
  3. package/cjs/controllers/storage-config.controller.spec.js +85 -0
  4. package/cjs/controllers/upload.controller.spec.js +132 -0
  5. package/cjs/docs/storage-swagger.config.spec.js +72 -0
  6. package/cjs/entities/index.spec.js +18 -0
  7. package/cjs/middlewares/file-serve.middleware.spec.js +211 -0
  8. package/cjs/modules/storage.module.spec.js +184 -0
  9. package/cjs/providers/azure-provider.optional.spec.js +228 -0
  10. package/cjs/providers/local-provider.spec.js +239 -0
  11. package/cjs/providers/s3-provider.optional.spec.js +243 -0
  12. package/cjs/providers/sftp-provider.optional.spec.js +217 -0
  13. package/cjs/providers/storage-factory.service.spec.js +253 -0
  14. package/cjs/providers/storage-provider.registry.spec.js +67 -0
  15. package/cjs/services/file-manager.service.spec.js +549 -0
  16. package/cjs/services/folder.service.spec.js +163 -0
  17. package/cjs/services/storage-config.service.spec.js +257 -0
  18. package/cjs/services/storage-datasource.provider.spec.js +217 -0
  19. package/cjs/services/storage-provider-config.service.spec.js +243 -0
  20. package/cjs/services/upload.service.spec.js +372 -0
  21. package/cjs/utils/file-validator.util.spec.js +259 -0
  22. package/cjs/utils/image-compressor.util.spec.js +166 -0
  23. package/fesm/controllers/file-manager.controller.spec.js +131 -0
  24. package/fesm/controllers/folder.controller.spec.js +82 -0
  25. package/fesm/controllers/storage-config.controller.spec.js +81 -0
  26. package/fesm/controllers/upload.controller.spec.js +128 -0
  27. package/fesm/docs/storage-swagger.config.spec.js +68 -0
  28. package/fesm/entities/index.spec.js +14 -0
  29. package/fesm/middlewares/file-serve.middleware.spec.js +207 -0
  30. package/fesm/modules/storage.module.spec.js +180 -0
  31. package/fesm/providers/azure-provider.optional.spec.js +224 -0
  32. package/fesm/providers/local-provider.spec.js +194 -0
  33. package/fesm/providers/s3-provider.optional.spec.js +239 -0
  34. package/fesm/providers/sftp-provider.optional.spec.js +213 -0
  35. package/fesm/providers/storage-factory.service.spec.js +249 -0
  36. package/fesm/providers/storage-provider.registry.spec.js +63 -0
  37. package/fesm/services/file-manager.service.spec.js +545 -0
  38. package/fesm/services/folder.service.spec.js +159 -0
  39. package/fesm/services/storage-config.service.spec.js +253 -0
  40. package/fesm/services/storage-datasource.provider.spec.js +172 -0
  41. package/fesm/services/storage-provider-config.service.spec.js +239 -0
  42. package/fesm/services/upload.service.spec.js +368 -0
  43. package/fesm/utils/file-validator.util.spec.js +255 -0
  44. package/fesm/utils/image-compressor.util.spec.js +162 -0
  45. package/package.json +3 -3
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ const _events = require("events");
6
+ const _path = require("path");
7
+ const _fileservemiddleware = require("./file-serve.middleware");
8
+ function _define_property(obj, key, value) {
9
+ if (key in obj) {
10
+ Object.defineProperty(obj, key, {
11
+ value: value,
12
+ enumerable: true,
13
+ configurable: true,
14
+ writable: true
15
+ });
16
+ } else {
17
+ obj[key] = value;
18
+ }
19
+ return obj;
20
+ }
21
+ const mockCreateReadStream = jest.fn();
22
+ const mockExistsSync = jest.fn();
23
+ const mockStatSync = jest.fn();
24
+ jest.mock('fs', ()=>({
25
+ ...jest.requireActual('fs'),
26
+ createReadStream: (...args)=>mockCreateReadStream(...args),
27
+ existsSync: (...args)=>mockExistsSync(...args),
28
+ statSync: (...args)=>mockStatSync(...args)
29
+ }));
30
+ const mockMimeLookup = jest.fn();
31
+ jest.mock('mime-types', ()=>({
32
+ lookup: (...args)=>mockMimeLookup(...args)
33
+ }));
34
+ let FakeStream = class FakeStream extends _events.EventEmitter {
35
+ constructor(...args){
36
+ super(...args), _define_property(this, "pipe", jest.fn()), _define_property(this, "destroy", jest.fn());
37
+ }
38
+ };
39
+ function buildRes() {
40
+ const listeners = {};
41
+ return {
42
+ headersSent: false,
43
+ set: jest.fn(),
44
+ status: jest.fn().mockReturnThis(),
45
+ json: jest.fn(),
46
+ on: jest.fn((event, cb)=>{
47
+ listeners[event] = listeners[event] || [];
48
+ listeners[event].push(cb);
49
+ }),
50
+ emitClose: ()=>(listeners['close'] || []).forEach((cb)=>cb())
51
+ };
52
+ }
53
+ function buildReq(urlPath) {
54
+ return {
55
+ path: urlPath,
56
+ url: urlPath
57
+ };
58
+ }
59
+ describe('FileServeMiddleware', ()=>{
60
+ let middleware;
61
+ let mockUploadService;
62
+ let next;
63
+ beforeEach(()=>{
64
+ jest.clearAllMocks();
65
+ mockUploadService = {
66
+ getLocalStorageBasePath: jest.fn().mockResolvedValue(null)
67
+ };
68
+ middleware = new _fileservemiddleware.FileServeMiddleware(mockUploadService);
69
+ next = jest.fn();
70
+ });
71
+ it('responds 404 when the file path segment is missing from the URL', async ()=>{
72
+ const res = buildRes();
73
+ await middleware.use(buildReq('/storage/upload/file/'), res, next);
74
+ expect(res.status).toHaveBeenCalledWith(404);
75
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
76
+ success: false,
77
+ statusCode: 404
78
+ }));
79
+ expect(mockExistsSync).not.toHaveBeenCalled();
80
+ });
81
+ it('blocks path traversal attempts that resolve outside the upload directory', async ()=>{
82
+ const res = buildRes();
83
+ mockExistsSync.mockReturnValue(false);
84
+ await middleware.use(buildReq('/storage/upload/file/../../etc/passwd'), res, next);
85
+ expect(res.status).toHaveBeenCalledWith(404);
86
+ expect(mockStatSync).not.toHaveBeenCalled();
87
+ });
88
+ it('serves a file that exists directly under the upload directory with inline disposition for viewable types', async ()=>{
89
+ const res = buildRes();
90
+ mockExistsSync.mockReturnValue(true);
91
+ mockStatSync.mockReturnValue({
92
+ isFile: ()=>true,
93
+ size: 1234
94
+ });
95
+ mockMimeLookup.mockReturnValue('application/pdf');
96
+ const stream = new FakeStream();
97
+ mockCreateReadStream.mockReturnValue(stream);
98
+ await middleware.use(buildReq('/storage/upload/file/report.pdf'), res, next);
99
+ expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
100
+ 'Content-Type': 'application/pdf',
101
+ 'Content-Length': 1234,
102
+ 'Content-Disposition': 'inline'
103
+ }));
104
+ expect(stream.pipe).toHaveBeenCalledWith(res);
105
+ });
106
+ it('sets an attachment content-disposition with the filename for non-viewable mime types', async ()=>{
107
+ const res = buildRes();
108
+ mockExistsSync.mockReturnValue(true);
109
+ mockStatSync.mockReturnValue({
110
+ isFile: ()=>true,
111
+ size: 500
112
+ });
113
+ mockMimeLookup.mockReturnValue('application/zip');
114
+ mockCreateReadStream.mockReturnValue(new FakeStream());
115
+ await middleware.use(buildReq('/storage/upload/file/archive.zip'), res, next);
116
+ expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
117
+ 'Content-Disposition': expect.stringContaining('attachment; filename="archive.zip"')
118
+ }));
119
+ });
120
+ it('always serves .svg files as image/svg+xml regardless of mime-types lookup', async ()=>{
121
+ const res = buildRes();
122
+ mockExistsSync.mockReturnValue(true);
123
+ mockStatSync.mockReturnValue({
124
+ isFile: ()=>true,
125
+ size: 200
126
+ });
127
+ mockCreateReadStream.mockReturnValue(new FakeStream());
128
+ await middleware.use(buildReq('/storage/upload/file/logo.svg'), res, next);
129
+ expect(mockMimeLookup).not.toHaveBeenCalled();
130
+ expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
131
+ 'Content-Type': 'image/svg+xml'
132
+ }));
133
+ });
134
+ it('returns 404 when the resolved path exists but is not a regular file', async ()=>{
135
+ const res = buildRes();
136
+ mockExistsSync.mockReturnValue(true);
137
+ mockStatSync.mockReturnValue({
138
+ isFile: ()=>false
139
+ });
140
+ await middleware.use(buildReq('/storage/upload/file/some-dir'), res, next);
141
+ expect(res.status).toHaveBeenCalledWith(404);
142
+ expect(mockCreateReadStream).not.toHaveBeenCalled();
143
+ });
144
+ it('falls back to the configured local storage base path (strategy 2: path already prefixed with base)', async ()=>{
145
+ const res = buildRes();
146
+ const fallbackPath = (0, _path.join)('uploads', 'report.pdf');
147
+ mockUploadService.getLocalStorageBasePath.mockResolvedValue('./uploads');
148
+ mockExistsSync.mockImplementation((p)=>p === fallbackPath);
149
+ mockStatSync.mockReturnValue({
150
+ isFile: ()=>true,
151
+ size: 10
152
+ });
153
+ mockMimeLookup.mockReturnValue('application/pdf');
154
+ mockCreateReadStream.mockReturnValue(new FakeStream());
155
+ await middleware.use(buildReq('/storage/upload/file/uploads/report.pdf'), res, next);
156
+ expect(mockCreateReadStream).toHaveBeenCalledWith(fallbackPath);
157
+ });
158
+ it('falls back to the configured local storage base path (strategy 3: legacy path without base prefix)', async ()=>{
159
+ const res = buildRes();
160
+ const fallbackPath = (0, _path.join)('uploads', 'report.pdf');
161
+ mockUploadService.getLocalStorageBasePath.mockResolvedValue('uploads');
162
+ mockExistsSync.mockImplementation((p)=>p === fallbackPath);
163
+ mockStatSync.mockReturnValue({
164
+ isFile: ()=>true,
165
+ size: 10
166
+ });
167
+ mockMimeLookup.mockReturnValue('application/pdf');
168
+ mockCreateReadStream.mockReturnValue(new FakeStream());
169
+ await middleware.use(buildReq('/storage/upload/file/report.pdf'), res, next);
170
+ expect(mockCreateReadStream).toHaveBeenCalledWith(fallbackPath);
171
+ });
172
+ it('returns the underlying not-found message when no direct or fallback path resolves', async ()=>{
173
+ const res = buildRes();
174
+ mockExistsSync.mockReturnValue(false);
175
+ mockUploadService.getLocalStorageBasePath.mockResolvedValue(null);
176
+ await middleware.use(buildReq('/storage/upload/file/missing.pdf'), res, next);
177
+ expect(res.status).toHaveBeenCalledWith(404);
178
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
179
+ message: expect.stringContaining('File not found'),
180
+ statusCode: 404
181
+ }));
182
+ });
183
+ it('sends a 404 if the file stream errors before headers are sent', async ()=>{
184
+ const res = buildRes();
185
+ mockExistsSync.mockReturnValue(true);
186
+ mockStatSync.mockReturnValue({
187
+ isFile: ()=>true,
188
+ size: 10
189
+ });
190
+ mockMimeLookup.mockReturnValue('application/pdf');
191
+ const stream = new FakeStream();
192
+ mockCreateReadStream.mockReturnValue(stream);
193
+ await middleware.use(buildReq('/storage/upload/file/report.pdf'), res, next);
194
+ stream.emit('error', new Error('boom'));
195
+ expect(res.status).toHaveBeenCalledWith(404);
196
+ });
197
+ it('destroys the stream when the response is closed early', async ()=>{
198
+ const res = buildRes();
199
+ mockExistsSync.mockReturnValue(true);
200
+ mockStatSync.mockReturnValue({
201
+ isFile: ()=>true,
202
+ size: 10
203
+ });
204
+ mockMimeLookup.mockReturnValue('application/pdf');
205
+ const stream = new FakeStream();
206
+ mockCreateReadStream.mockReturnValue(stream);
207
+ await middleware.use(buildReq('/storage/upload/file/report.pdf'), res, next);
208
+ res.emitClose();
209
+ expect(stream.destroy).toHaveBeenCalled();
210
+ });
211
+ });
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ const _storagemodule = require("./storage.module");
6
+ const _storageconstants = require("../config/storage.constants");
7
+ const _controllers = require("../controllers");
8
+ const _middlewares = require("../middlewares");
9
+ function _define_property(obj, key, value) {
10
+ if (key in obj) {
11
+ Object.defineProperty(obj, key, {
12
+ value: value,
13
+ enumerable: true,
14
+ configurable: true,
15
+ writable: true
16
+ });
17
+ } else {
18
+ obj[key] = value;
19
+ }
20
+ return obj;
21
+ }
22
+ function providerTokens(providers) {
23
+ return providers.map((p)=>typeof p === 'function' ? p : p.provide);
24
+ }
25
+ describe('StorageModule', ()=>{
26
+ describe('forRoot', ()=>{
27
+ it('should default to a non-global module with all controllers registered', ()=>{
28
+ const dynamicModule = _storagemodule.StorageModule.forRoot({});
29
+ expect(dynamicModule.module).toBe(_storagemodule.StorageModule);
30
+ expect(dynamicModule.global).toBe(false);
31
+ expect(dynamicModule.controllers).toEqual([
32
+ _controllers.FileManagerController,
33
+ _controllers.FolderController,
34
+ _controllers.StorageConfigController,
35
+ _controllers.UploadController
36
+ ]);
37
+ });
38
+ it('should respect global: true', ()=>{
39
+ expect(_storagemodule.StorageModule.forRoot({
40
+ global: true
41
+ }).global).toBe(true);
42
+ });
43
+ it('should register no controllers when includeController is false', ()=>{
44
+ expect(_storagemodule.StorageModule.forRoot({
45
+ includeController: false
46
+ }).controllers).toEqual([]);
47
+ });
48
+ it('should register controllers when includeController is anything other than exactly false', ()=>{
49
+ expect(_storagemodule.StorageModule.forRoot({
50
+ includeController: true
51
+ }).controllers).toHaveLength(4);
52
+ expect(_storagemodule.StorageModule.forRoot({}).controllers).toHaveLength(4);
53
+ });
54
+ it('should register a STORAGE_MODULE_OPTIONS provider carrying the given options', ()=>{
55
+ const options = {
56
+ bootstrapAppConfig: {
57
+ enableCompanyFeature: true
58
+ }
59
+ };
60
+ const dynamicModule = _storagemodule.StorageModule.forRoot(options);
61
+ const provider = dynamicModule.providers.find((p)=>p.provide === _storageconstants.STORAGE_MODULE_OPTIONS);
62
+ expect(provider.useValue).toBe(options);
63
+ });
64
+ it('should always export the same fixed set of services', ()=>{
65
+ const dynamicModule = _storagemodule.StorageModule.forRoot({});
66
+ expect(dynamicModule.exports).toHaveLength(7);
67
+ });
68
+ });
69
+ describe('forRootAsync', ()=>{
70
+ it('should forward external imports alongside CacheModule/UtilsModule', ()=>{
71
+ let FakeImportedModule = class FakeImportedModule {
72
+ };
73
+ const dynamicModule = _storagemodule.StorageModule.forRootAsync({
74
+ bootstrapAppConfig: {},
75
+ imports: [
76
+ FakeImportedModule
77
+ ]
78
+ });
79
+ expect(dynamicModule.imports).toEqual(expect.arrayContaining([
80
+ FakeImportedModule
81
+ ]));
82
+ });
83
+ it('should register no controllers when includeController is false', ()=>{
84
+ const dynamicModule = _storagemodule.StorageModule.forRootAsync({
85
+ bootstrapAppConfig: {},
86
+ includeController: false
87
+ });
88
+ expect(dynamicModule.controllers).toEqual([]);
89
+ });
90
+ it('useFactory: builds a STORAGE_MODULE_OPTIONS provider that merges resolved config into the static options', async ()=>{
91
+ const useFactory = jest.fn().mockResolvedValue({
92
+ maxFileSize: 1024
93
+ });
94
+ const dynamicModule = _storagemodule.StorageModule.forRootAsync({
95
+ bootstrapAppConfig: {},
96
+ useFactory,
97
+ inject: [
98
+ 'SOME_TOKEN'
99
+ ]
100
+ });
101
+ const provider = dynamicModule.providers.find((p)=>p.provide === _storageconstants.STORAGE_MODULE_OPTIONS);
102
+ expect(provider.inject).toEqual([
103
+ 'SOME_TOKEN'
104
+ ]);
105
+ const resolved = await provider.useFactory('injected');
106
+ expect(useFactory).toHaveBeenCalledWith('injected');
107
+ expect(resolved.config).toEqual({
108
+ maxFileSize: 1024
109
+ });
110
+ });
111
+ it('useClass: registers both the class provider and an options provider calling createStorageOptions()', async ()=>{
112
+ let MyOptionsFactory = class MyOptionsFactory {
113
+ constructor(){
114
+ _define_property(this, "createStorageOptions", jest.fn().mockResolvedValue({
115
+ maxFileSize: 2048
116
+ }));
117
+ }
118
+ };
119
+ const dynamicModule = _storagemodule.StorageModule.forRootAsync({
120
+ bootstrapAppConfig: {},
121
+ useClass: MyOptionsFactory
122
+ });
123
+ const classProvider = dynamicModule.providers.find((p)=>p.provide === MyOptionsFactory);
124
+ expect(classProvider.useClass).toBe(MyOptionsFactory);
125
+ const optionsProvider = dynamicModule.providers.find((p)=>p.provide === _storageconstants.STORAGE_MODULE_OPTIONS);
126
+ const instance = new MyOptionsFactory();
127
+ const resolved = await optionsProvider.useFactory(instance);
128
+ expect(instance.createStorageOptions).toHaveBeenCalled();
129
+ expect(resolved.config).toEqual({
130
+ maxFileSize: 2048
131
+ });
132
+ });
133
+ it('useExisting: registers a single options provider without a duplicate class registration', async ()=>{
134
+ let MyOptionsFactory = class MyOptionsFactory {
135
+ constructor(){
136
+ _define_property(this, "createStorageOptions", jest.fn().mockResolvedValue({
137
+ maxFileSize: 4096
138
+ }));
139
+ }
140
+ };
141
+ const dynamicModule = _storagemodule.StorageModule.forRootAsync({
142
+ bootstrapAppConfig: {},
143
+ useExisting: MyOptionsFactory
144
+ });
145
+ expect(dynamicModule.providers.filter((p)=>p.provide === MyOptionsFactory)).toHaveLength(0);
146
+ const optionsProvider = dynamicModule.providers.find((p)=>p.provide === _storageconstants.STORAGE_MODULE_OPTIONS);
147
+ expect(optionsProvider.inject).toEqual([
148
+ MyOptionsFactory
149
+ ]);
150
+ });
151
+ it('should register no STORAGE_MODULE_OPTIONS provider when neither useFactory, useClass, nor useExisting is given', ()=>{
152
+ const dynamicModule = _storagemodule.StorageModule.forRootAsync({
153
+ bootstrapAppConfig: {}
154
+ });
155
+ expect(providerTokens(dynamicModule.providers)).not.toContain(_storageconstants.STORAGE_MODULE_OPTIONS);
156
+ });
157
+ it('should still register the fixed exported services as providers alongside async options', ()=>{
158
+ const dynamicModule = _storagemodule.StorageModule.forRootAsync({
159
+ bootstrapAppConfig: {},
160
+ useFactory: jest.fn()
161
+ });
162
+ expect(dynamicModule.providers).toEqual(expect.arrayContaining([
163
+ ...dynamicModule.exports
164
+ ]));
165
+ });
166
+ });
167
+ describe('configure (NestModule)', ()=>{
168
+ it('should wire FileServeMiddleware to the file GET route', ()=>{
169
+ const forRoutes = jest.fn();
170
+ const apply = jest.fn().mockReturnValue({
171
+ forRoutes
172
+ });
173
+ const consumer = {
174
+ apply
175
+ };
176
+ new _storagemodule.StorageModule().configure(consumer);
177
+ expect(apply).toHaveBeenCalledWith(_middlewares.FileServeMiddleware);
178
+ expect(forRoutes).toHaveBeenCalledWith({
179
+ path: 'storage/upload/file/*',
180
+ method: expect.anything()
181
+ });
182
+ });
183
+ });
184
+ });
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ const _azureprovideroptional = require("./azure-provider.optional");
6
+ const _imagecompressorutil = require("../utils/image-compressor.util");
7
+ function _define_property(obj, key, value) {
8
+ if (key in obj) {
9
+ Object.defineProperty(obj, key, {
10
+ value: value,
11
+ enumerable: true,
12
+ configurable: true,
13
+ writable: true
14
+ });
15
+ } else {
16
+ obj[key] = value;
17
+ }
18
+ return obj;
19
+ }
20
+ const mockCreateIfNotExists = jest.fn();
21
+ const mockBlockBlobUpload = jest.fn();
22
+ const mockDeleteIfExists = jest.fn();
23
+ const mockContainerExists = jest.fn();
24
+ function buildBlockBlobClient() {
25
+ return {
26
+ upload: mockBlockBlobUpload,
27
+ deleteIfExists: mockDeleteIfExists,
28
+ url: 'https://account.blob.core.windows.net/container/blob-key.jpg',
29
+ credential: 'shared-key-credential'
30
+ };
31
+ }
32
+ const mockGetBlockBlobClient = jest.fn().mockImplementation(()=>buildBlockBlobClient());
33
+ const mockGetContainerClient = jest.fn().mockImplementation(()=>({
34
+ createIfNotExists: mockCreateIfNotExists,
35
+ getBlockBlobClient: mockGetBlockBlobClient,
36
+ exists: mockContainerExists
37
+ }));
38
+ const mockFromConnectionString = jest.fn().mockImplementation(()=>({
39
+ getContainerClient: mockGetContainerClient
40
+ }));
41
+ const mockBlobServiceClientCtor = jest.fn().mockImplementation(()=>({
42
+ getContainerClient: mockGetContainerClient
43
+ }));
44
+ mockBlobServiceClientCtor.fromConnectionString = mockFromConnectionString;
45
+ const mockGenerateBlobSASQueryParameters = jest.fn().mockReturnValue({
46
+ toString: ()=>'sv=2022&sig=abc123'
47
+ });
48
+ let MockStorageSharedKeyCredential = class MockStorageSharedKeyCredential {
49
+ constructor(accountName, accountKey){
50
+ _define_property(this, "accountName", void 0);
51
+ _define_property(this, "accountKey", void 0);
52
+ this.accountName = accountName;
53
+ this.accountKey = accountKey;
54
+ }
55
+ };
56
+ let MockBlobSASPermissions = class MockBlobSASPermissions {
57
+ static parse(input) {
58
+ return {
59
+ permissions: input
60
+ };
61
+ }
62
+ };
63
+ jest.mock('@azure/storage-blob', ()=>({
64
+ BlobServiceClient: mockBlobServiceClientCtor,
65
+ StorageSharedKeyCredential: MockStorageSharedKeyCredential,
66
+ BlobSASPermissions: MockBlobSASPermissions,
67
+ generateBlobSASQueryParameters: (...args)=>mockGenerateBlobSASQueryParameters(...args)
68
+ }));
69
+ jest.mock('../utils/image-compressor.util', ()=>({
70
+ ImageCompressor: {
71
+ compress: jest.fn()
72
+ }
73
+ }));
74
+ function buildMulterFile(overrides = {}) {
75
+ return {
76
+ fieldname: 'file',
77
+ originalname: 'photo.jpg',
78
+ encoding: '7bit',
79
+ mimetype: 'image/jpeg',
80
+ buffer: Buffer.from('file-bytes'),
81
+ size: 10,
82
+ stream: undefined,
83
+ destination: '',
84
+ filename: '',
85
+ path: '',
86
+ ...overrides
87
+ };
88
+ }
89
+ describe('AzureProvider', ()=>{
90
+ let provider;
91
+ beforeEach(async ()=>{
92
+ jest.clearAllMocks();
93
+ mockCreateIfNotExists.mockResolvedValue(undefined);
94
+ mockBlockBlobUpload.mockResolvedValue(undefined);
95
+ mockDeleteIfExists.mockResolvedValue(undefined);
96
+ mockContainerExists.mockResolvedValue(true);
97
+ mockGetBlockBlobClient.mockImplementation(()=>buildBlockBlobClient());
98
+ mockGetContainerClient.mockImplementation(()=>({
99
+ createIfNotExists: mockCreateIfNotExists,
100
+ getBlockBlobClient: mockGetBlockBlobClient,
101
+ exists: mockContainerExists
102
+ }));
103
+ provider = new _azureprovideroptional.AzureProvider();
104
+ await provider.initialize({
105
+ accountName: 'myaccount',
106
+ accountKey: 'key123',
107
+ containerName: 'my-container'
108
+ });
109
+ });
110
+ describe('initialize', ()=>{
111
+ it('builds a BlobServiceClient using an account key credential when no connection string is given', ()=>{
112
+ expect(mockBlobServiceClientCtor).toHaveBeenCalledWith('https://myaccount.blob.core.windows.net', expect.any(MockStorageSharedKeyCredential));
113
+ expect(mockCreateIfNotExists).toHaveBeenCalled();
114
+ });
115
+ it('builds a BlobServiceClient from a connection string when provided', async ()=>{
116
+ jest.clearAllMocks();
117
+ mockCreateIfNotExists.mockResolvedValue(undefined);
118
+ const p = new _azureprovideroptional.AzureProvider();
119
+ await p.initialize({
120
+ accountName: 'acct2',
121
+ accountKey: 'key2',
122
+ containerName: 'container2',
123
+ connectionString: 'DefaultEndpointsProtocol=https;AccountName=acct2;AccountKey=key2'
124
+ });
125
+ expect(mockFromConnectionString).toHaveBeenCalledWith('DefaultEndpointsProtocol=https;AccountName=acct2;AccountKey=key2');
126
+ });
127
+ it('throws InternalServerErrorException when the Azure SDK cannot be initialized', async ()=>{
128
+ mockBlobServiceClientCtor.mockImplementationOnce(()=>{
129
+ throw new Error('boom');
130
+ });
131
+ const brokenProvider = new _azureprovideroptional.AzureProvider();
132
+ await expect(brokenProvider.initialize({
133
+ accountName: 'a',
134
+ accountKey: 'b',
135
+ containerName: 'c'
136
+ })).rejects.toThrow('Azure Storage SDK not found');
137
+ });
138
+ });
139
+ describe('uploadFile', ()=>{
140
+ it('uploads the blob and returns file metadata', async ()=>{
141
+ const file = buildMulterFile();
142
+ const result = await provider.uploadFile(file, {});
143
+ expect(mockBlockBlobUpload).toHaveBeenCalledWith(file.buffer, file.buffer.length, expect.objectContaining({
144
+ blobHTTPHeaders: {
145
+ blobContentType: 'image/jpeg'
146
+ }
147
+ }));
148
+ expect(result.name).toMatch(/photo\.jpg$/);
149
+ expect(result.key).toBe(result.name);
150
+ });
151
+ it('prefixes blob name with folderPath when provided', async ()=>{
152
+ const file = buildMulterFile();
153
+ const result = await provider.uploadFile(file, {
154
+ folderPath: 'docs'
155
+ });
156
+ expect(result.key).toMatch(/^docs\//);
157
+ });
158
+ it('compresses image files when options.compress is true', async ()=>{
159
+ _imagecompressorutil.ImageCompressor.compress.mockResolvedValue({
160
+ buffer: Buffer.from('small'),
161
+ format: 'image/webp'
162
+ });
163
+ const file = buildMulterFile();
164
+ const result = await provider.uploadFile(file, {
165
+ compress: true
166
+ });
167
+ expect(_imagecompressorutil.ImageCompressor.compress).toHaveBeenCalled();
168
+ expect(result.contentType).toBe('image/webp');
169
+ });
170
+ });
171
+ describe('uploadMultipleFiles', ()=>{
172
+ it('uploads all files', async ()=>{
173
+ const files = [
174
+ buildMulterFile({
175
+ originalname: 'a.jpg'
176
+ }),
177
+ buildMulterFile({
178
+ originalname: 'b.jpg'
179
+ })
180
+ ];
181
+ const results = await provider.uploadMultipleFiles(files, {});
182
+ expect(results).toHaveLength(2);
183
+ expect(mockBlockBlobUpload).toHaveBeenCalledTimes(2);
184
+ });
185
+ });
186
+ describe('deleteFile', ()=>{
187
+ it('calls deleteIfExists on the block blob client for the given key', async ()=>{
188
+ await provider.deleteFile('blob-key.jpg');
189
+ expect(mockGetBlockBlobClient).toHaveBeenCalledWith('blob-key.jpg');
190
+ expect(mockDeleteIfExists).toHaveBeenCalled();
191
+ });
192
+ });
193
+ describe('deleteMultipleFiles', ()=>{
194
+ it('deletes every provided key', async ()=>{
195
+ await provider.deleteMultipleFiles([
196
+ 'a.jpg',
197
+ 'b.jpg'
198
+ ]);
199
+ expect(mockDeleteIfExists).toHaveBeenCalledTimes(2);
200
+ });
201
+ });
202
+ describe('generatePresignedUrl', ()=>{
203
+ it('generates a SAS URL appended with the query string', async ()=>{
204
+ const url = await provider.generatePresignedUrl('blob-key.jpg', 1800);
205
+ expect(mockGenerateBlobSASQueryParameters).toHaveBeenCalledWith(expect.objectContaining({
206
+ containerName: 'my-container',
207
+ blobName: 'blob-key.jpg'
208
+ }), 'shared-key-credential');
209
+ expect(url).toBe('https://account.blob.core.windows.net/container/blob-key.jpg?sv=2022&sig=abc123');
210
+ });
211
+ it('defaults the expiry window to 3600 seconds', async ()=>{
212
+ await provider.generatePresignedUrl('blob-key.jpg');
213
+ const call = mockGenerateBlobSASQueryParameters.mock.calls[0][0];
214
+ const diffSeconds = (call.expiresOn.getTime() - call.startsOn.getTime()) / 1000;
215
+ expect(diffSeconds).toBeCloseTo(3600, 0);
216
+ });
217
+ });
218
+ describe('healthCheck', ()=>{
219
+ it('returns true when the container exists', async ()=>{
220
+ mockContainerExists.mockResolvedValue(true);
221
+ await expect(provider.healthCheck()).resolves.toBe(true);
222
+ });
223
+ it('returns false when checking existence throws', async ()=>{
224
+ mockContainerExists.mockRejectedValue(new Error('network error'));
225
+ await expect(provider.healthCheck()).resolves.toBe(false);
226
+ });
227
+ });
228
+ });