@flusys/nestjs-storage 6.0.2 → 6.1.1

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,239 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { UtilsService } from '@flusys/nestjs-shared/modules';
3
+ import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
4
+ import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
5
+ import { StorageProviderConfigService } from './storage-provider-config.service';
6
+ import { StorageConfigService } from './storage-config.service';
7
+ import { StorageDataSourceProvider } from './storage-datasource.provider';
8
+ import { StorageConfig, StorageConfigWithCompany } from '../entities';
9
+ function buildStorageConfig(overrides = {}) {
10
+ return {
11
+ id: 'config-uuid-1',
12
+ name: 'Default Local',
13
+ storage: 'local',
14
+ config: {
15
+ basePath: './uploads'
16
+ },
17
+ isActive: true,
18
+ isDefault: false,
19
+ createdAt: new Date(),
20
+ updatedAt: new Date(),
21
+ deletedAt: null,
22
+ createdById: null,
23
+ updatedById: null,
24
+ deletedById: null,
25
+ ...overrides
26
+ };
27
+ }
28
+ describe('StorageProviderConfigService', ()=>{
29
+ let service;
30
+ let mockRepo;
31
+ let mockStorageConfig;
32
+ beforeEach(async ()=>{
33
+ mockRepo = createMockRepository();
34
+ const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
35
+ mockStorageConfig = {
36
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
37
+ getAppUrl: jest.fn().mockReturnValue('http://localhost:2002')
38
+ };
39
+ const module = await Test.createTestingModule({
40
+ providers: [
41
+ StorageProviderConfigService,
42
+ {
43
+ provide: 'CACHE_INSTANCE',
44
+ useValue: {}
45
+ },
46
+ {
47
+ provide: UtilsService,
48
+ useValue: {}
49
+ },
50
+ {
51
+ provide: StorageConfigService,
52
+ useValue: mockStorageConfig
53
+ },
54
+ {
55
+ provide: StorageDataSourceProvider,
56
+ useValue: mockDataSourceProvider
57
+ }
58
+ ]
59
+ }).compile();
60
+ service = await module.resolve(StorageProviderConfigService);
61
+ });
62
+ describe('resolveEntity', ()=>{
63
+ it('resolves to the core StorageConfig entity when company feature is disabled', ()=>{
64
+ expect(service.resolveEntity()).toBe(StorageConfig);
65
+ });
66
+ it('resolves to StorageConfigWithCompany when company feature is enabled', ()=>{
67
+ mockStorageConfig.isCompanyFeatureEnabled.mockReturnValue(true);
68
+ expect(service.resolveEntity()).toBe(StorageConfigWithCompany);
69
+ });
70
+ });
71
+ describe('convertSingleDtoToEntity', ()=>{
72
+ beforeEach(async ()=>{
73
+ mockRepo.create.mockReturnValue({});
74
+ await service.ensureDataSourceRepository();
75
+ });
76
+ it('does not attach companyId when the company feature is disabled', async ()=>{
77
+ const entity = await service.convertSingleDtoToEntity({
78
+ name: 'AWS S3',
79
+ storage: 'aws',
80
+ config: {}
81
+ }, buildMockUser());
82
+ expect(entity.companyId).toBeUndefined();
83
+ });
84
+ it('attaches the current user companyId when the company feature is enabled', async ()=>{
85
+ mockStorageConfig.isCompanyFeatureEnabled.mockReturnValue(true);
86
+ const entity = await service.convertSingleDtoToEntity({
87
+ name: 'AWS S3',
88
+ storage: 'aws',
89
+ config: {}
90
+ }, buildMockUser({
91
+ companyId: 'company-42'
92
+ }));
93
+ expect(entity.companyId).toBe('company-42');
94
+ });
95
+ it('defaults companyId to null when company feature is enabled but user has none', async ()=>{
96
+ mockStorageConfig.isCompanyFeatureEnabled.mockReturnValue(true);
97
+ const entity = await service.convertSingleDtoToEntity({
98
+ name: 'AWS S3',
99
+ storage: 'aws',
100
+ config: {}
101
+ }, null);
102
+ expect(entity.companyId).toBeNull();
103
+ });
104
+ });
105
+ describe('getSelectQuery', ()=>{
106
+ it('selects the default field set when none is provided', async ()=>{
107
+ const query = {
108
+ select: jest.fn().mockReturnThis()
109
+ };
110
+ await service.getSelectQuery(query, buildMockUser());
111
+ expect(query.select).toHaveBeenCalledWith([
112
+ 'storageConfig.id',
113
+ 'storageConfig.name',
114
+ 'storageConfig.storage',
115
+ 'storageConfig.config',
116
+ 'storageConfig.isActive',
117
+ 'storageConfig.isDefault',
118
+ 'storageConfig.createdAt',
119
+ 'storageConfig.updatedAt'
120
+ ]);
121
+ });
122
+ it('adds companyId to the default select list when company feature is enabled', async ()=>{
123
+ mockStorageConfig.isCompanyFeatureEnabled.mockReturnValue(true);
124
+ const query = {
125
+ select: jest.fn().mockReturnThis()
126
+ };
127
+ await service.getSelectQuery(query, buildMockUser());
128
+ const selected = query.select.mock.calls[0][0];
129
+ expect(selected).toContain('storageConfig.companyId');
130
+ });
131
+ it('respects an explicitly provided select list', async ()=>{
132
+ const query = {
133
+ select: jest.fn().mockReturnThis()
134
+ };
135
+ await service.getSelectQuery(query, buildMockUser(), [
136
+ 'id',
137
+ 'name'
138
+ ]);
139
+ expect(query.select).toHaveBeenCalledWith([
140
+ 'storageConfig.id',
141
+ 'storageConfig.name'
142
+ ]);
143
+ });
144
+ });
145
+ describe('findByIdDirect', ()=>{
146
+ it('bypasses company filtering and returns the raw entity by id', async ()=>{
147
+ const config = buildStorageConfig();
148
+ mockRepo.findOne.mockResolvedValue(config);
149
+ const result = await service.findByIdDirect('config-uuid-1');
150
+ expect(mockRepo.findOne).toHaveBeenCalledWith({
151
+ where: {
152
+ id: 'config-uuid-1'
153
+ }
154
+ });
155
+ expect(result).toEqual(config);
156
+ });
157
+ it('returns null when no config exists for the given id', async ()=>{
158
+ mockRepo.findOne.mockResolvedValue(null);
159
+ const result = await service.findByIdDirect('missing-id');
160
+ expect(result).toBeNull();
161
+ });
162
+ });
163
+ describe('getDefaultConfig', ()=>{
164
+ it('returns the config explicitly flagged isDefault when one exists', async ()=>{
165
+ const defaultConfig = buildStorageConfig({
166
+ id: 'default-1',
167
+ isDefault: true
168
+ });
169
+ mockRepo.findOne.mockResolvedValueOnce(defaultConfig);
170
+ const result = await service.getDefaultConfig(buildMockUser());
171
+ expect(result).toEqual(defaultConfig);
172
+ expect(mockRepo.findOne).toHaveBeenCalledTimes(1);
173
+ });
174
+ it('falls back to the oldest active config when no default is flagged', async ()=>{
175
+ const fallbackConfig = buildStorageConfig({
176
+ id: 'fallback-1'
177
+ });
178
+ mockRepo.findOne.mockResolvedValueOnce(null).mockResolvedValueOnce(fallbackConfig);
179
+ const result = await service.getDefaultConfig(buildMockUser());
180
+ expect(result).toEqual(fallbackConfig);
181
+ expect(mockRepo.findOne).toHaveBeenCalledTimes(2);
182
+ });
183
+ it('returns null when there is no default and no active config at all', async ()=>{
184
+ mockRepo.findOne.mockResolvedValue(null);
185
+ const result = await service.getDefaultConfig(buildMockUser());
186
+ expect(result).toBeNull();
187
+ });
188
+ it('scopes the query to the user company when company feature is enabled', async ()=>{
189
+ mockStorageConfig.isCompanyFeatureEnabled.mockReturnValue(true);
190
+ mockRepo.findOne.mockResolvedValue(null);
191
+ await service.getDefaultConfig(buildMockUser({
192
+ companyId: 'company-9'
193
+ }));
194
+ expect(mockRepo.findOne).toHaveBeenCalledWith(expect.objectContaining({
195
+ where: expect.objectContaining({
196
+ companyId: 'company-9',
197
+ isActive: true
198
+ })
199
+ }));
200
+ });
201
+ });
202
+ describe('getConfigByType', ()=>{
203
+ it('returns active configs matching the requested storage type', async ()=>{
204
+ const localConfigs = [
205
+ buildStorageConfig({
206
+ storage: 'local'
207
+ })
208
+ ];
209
+ mockRepo.find.mockResolvedValue(localConfigs);
210
+ const result = await service.getConfigByType('local', buildMockUser());
211
+ expect(mockRepo.find).toHaveBeenCalledWith({
212
+ where: {
213
+ storage: 'local',
214
+ isActive: true
215
+ }
216
+ });
217
+ expect(result).toEqual(localConfigs);
218
+ });
219
+ it('scopes the lookup to the current company when company feature is enabled', async ()=>{
220
+ mockStorageConfig.isCompanyFeatureEnabled.mockReturnValue(true);
221
+ mockRepo.find.mockResolvedValue([]);
222
+ await service.getConfigByType('aws', buildMockUser({
223
+ companyId: 'company-5'
224
+ }));
225
+ expect(mockRepo.find).toHaveBeenCalledWith({
226
+ where: {
227
+ storage: 'aws',
228
+ isActive: true,
229
+ companyId: 'company-5'
230
+ }
231
+ });
232
+ });
233
+ it('returns an empty array when no configs match', async ()=>{
234
+ mockRepo.find.mockResolvedValue([]);
235
+ const result = await service.getConfigByType('azure');
236
+ expect(result).toEqual([]);
237
+ });
238
+ });
239
+ });
@@ -0,0 +1,368 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { UploadService } from './upload.service';
3
+ import { StorageFactoryService } from '../providers/storage-factory.service';
4
+ import { StorageConfigService } from './storage-config.service';
5
+ import { StorageProviderConfigService } from './storage-provider-config.service';
6
+ import { FileLocationEnum } from '../enums/file-location.enum';
7
+ import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
8
+ function buildMulterFile(overrides = {}) {
9
+ return {
10
+ fieldname: 'file',
11
+ originalname: 'photo.jpg',
12
+ encoding: '7bit',
13
+ mimetype: 'image/jpeg',
14
+ buffer: Buffer.from([
15
+ 0xff,
16
+ 0xd8,
17
+ 0xff,
18
+ 0xe0
19
+ ]),
20
+ size: 1024,
21
+ stream: undefined,
22
+ destination: '',
23
+ filename: '',
24
+ path: '',
25
+ ...overrides
26
+ };
27
+ }
28
+ describe('UploadService', ()=>{
29
+ let service;
30
+ let mockStorageFactory;
31
+ let mockStorageConfigService;
32
+ let mockStorageProviderConfigService;
33
+ let mockProvider;
34
+ beforeEach(async ()=>{
35
+ mockProvider = {
36
+ uploadFile: jest.fn().mockResolvedValue({
37
+ name: 'photo.jpg',
38
+ key: 'uploads/photo.jpg',
39
+ size: 1024,
40
+ contentType: 'image/jpeg'
41
+ }),
42
+ uploadMultipleFiles: jest.fn().mockResolvedValue([]),
43
+ deleteFile: jest.fn().mockResolvedValue(undefined),
44
+ deleteMultipleFiles: jest.fn().mockResolvedValue(undefined),
45
+ generatePresignedUrl: jest.fn().mockResolvedValue('https://signed.example.com/uploads/photo.jpg')
46
+ };
47
+ mockStorageFactory = {
48
+ createProvider: jest.fn().mockResolvedValue(mockProvider),
49
+ getLocalProviderBasePath: jest.fn().mockReturnValue(null)
50
+ };
51
+ mockStorageConfigService = {
52
+ validateFileSize: jest.fn().mockReturnValue({
53
+ valid: true
54
+ }),
55
+ validateFileType: jest.fn().mockReturnValue({
56
+ valid: true
57
+ }),
58
+ getAllowedFileTypes: jest.fn().mockReturnValue([
59
+ '*/*'
60
+ ]),
61
+ getDefaultLocalStoragePath: jest.fn().mockReturnValue('./uploads'),
62
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
63
+ };
64
+ mockStorageProviderConfigService = {
65
+ findByIdDirect: jest.fn(),
66
+ getDefaultConfig: jest.fn(),
67
+ getConfigByType: jest.fn()
68
+ };
69
+ const module = await Test.createTestingModule({
70
+ providers: [
71
+ UploadService,
72
+ {
73
+ provide: StorageFactoryService,
74
+ useValue: mockStorageFactory
75
+ },
76
+ {
77
+ provide: StorageConfigService,
78
+ useValue: mockStorageConfigService
79
+ },
80
+ {
81
+ provide: StorageProviderConfigService,
82
+ useValue: mockStorageProviderConfigService
83
+ }
84
+ ]
85
+ }).compile();
86
+ service = await module.resolve(UploadService);
87
+ });
88
+ describe('uploadSingleFile', ()=>{
89
+ it('validates, resolves the default storage config, and uploads via the resolved provider', async ()=>{
90
+ mockStorageProviderConfigService.getDefaultConfig.mockResolvedValue({
91
+ id: 'cfg-default',
92
+ storage: 'local',
93
+ config: {}
94
+ });
95
+ const result = await service.uploadSingleFile(buildMulterFile(), {}, buildMockUser());
96
+ expect(mockStorageConfigService.validateFileSize).toHaveBeenCalled();
97
+ expect(mockStorageConfigService.validateFileType).toHaveBeenCalled();
98
+ expect(mockProvider.uploadFile).toHaveBeenCalled();
99
+ expect(result.location).toBe('local');
100
+ expect(result.storageConfigId).toBe('cfg-default');
101
+ });
102
+ it('throws BadRequestException when file size exceeds the configured maximum', async ()=>{
103
+ mockStorageConfigService.validateFileSize.mockReturnValue({
104
+ valid: false,
105
+ message: 'too big',
106
+ messageKey: 'upload.file.too.large'
107
+ });
108
+ await expect(service.uploadSingleFile(buildMulterFile(), {}, buildMockUser())).rejects.toThrow();
109
+ });
110
+ it('throws BadRequestException when the declared MIME type is not allowed', async ()=>{
111
+ mockStorageConfigService.validateFileType.mockReturnValue({
112
+ valid: false,
113
+ message: 'not allowed',
114
+ messageKey: 'upload.invalid.type'
115
+ });
116
+ await expect(service.uploadSingleFile(buildMulterFile(), {}, buildMockUser())).rejects.toThrow();
117
+ });
118
+ it('rejects a file whose content does not match its declared MIME type (spoofing protection)', async ()=>{
119
+ const file = buildMulterFile({
120
+ mimetype: 'application/pdf',
121
+ buffer: Buffer.from([
122
+ 0xff,
123
+ 0xd8,
124
+ 0xff
125
+ ])
126
+ });
127
+ await expect(service.uploadSingleFile(file, {}, buildMockUser())).rejects.toThrow();
128
+ });
129
+ it('sanitizes the original filename to prevent path traversal before uploading', async ()=>{
130
+ mockStorageProviderConfigService.getDefaultConfig.mockResolvedValue({
131
+ id: 'cfg-default',
132
+ storage: 'local',
133
+ config: {}
134
+ });
135
+ const file = buildMulterFile({
136
+ originalname: '../../etc/passwd.jpg'
137
+ });
138
+ await service.uploadSingleFile(file, {}, buildMockUser());
139
+ expect(file.originalname).toBe('passwd.jpg');
140
+ });
141
+ it('uses an explicit storageConfigId when provided and validates company ownership', async ()=>{
142
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue({
143
+ id: 'cfg-1',
144
+ storage: 'aws',
145
+ config: {}
146
+ });
147
+ const result = await service.uploadSingleFile(buildMulterFile(), {
148
+ storageConfigId: 'cfg-1'
149
+ }, buildMockUser());
150
+ expect(mockStorageProviderConfigService.findByIdDirect).toHaveBeenCalledWith('cfg-1');
151
+ expect(result.location).toBe('aws');
152
+ });
153
+ it('throws NotFoundException when the explicit storageConfigId does not resolve to a config', async ()=>{
154
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue(null);
155
+ await expect(service.uploadSingleFile(buildMulterFile(), {
156
+ storageConfigId: 'missing'
157
+ }, buildMockUser())).rejects.toThrow();
158
+ });
159
+ it('throws NotFoundException when no default storage config exists and none was specified', async ()=>{
160
+ mockStorageProviderConfigService.getDefaultConfig.mockResolvedValue(null);
161
+ await expect(service.uploadSingleFile(buildMulterFile(), {}, buildMockUser())).rejects.toThrow();
162
+ });
163
+ it('rejects uploading a config belonging to a different company when company feature is enabled', async ()=>{
164
+ mockStorageConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
165
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue({
166
+ id: 'cfg-1',
167
+ storage: 'aws',
168
+ config: {},
169
+ companyId: 'other-company'
170
+ });
171
+ await expect(service.uploadSingleFile(buildMulterFile(), {
172
+ storageConfigId: 'cfg-1'
173
+ }, buildMockUser({
174
+ companyId: 'my-company'
175
+ }))).rejects.toThrow();
176
+ });
177
+ });
178
+ describe('uploadMultipleFiles', ()=>{
179
+ it('validates every file before uploading any', async ()=>{
180
+ mockStorageProviderConfigService.getDefaultConfig.mockResolvedValue({
181
+ id: 'cfg-default',
182
+ storage: 'local',
183
+ config: {}
184
+ });
185
+ mockProvider.uploadMultipleFiles.mockResolvedValue([
186
+ {
187
+ name: 'a.jpg',
188
+ key: 'a.jpg',
189
+ size: 10,
190
+ contentType: 'image/jpeg'
191
+ },
192
+ {
193
+ name: 'b.jpg',
194
+ key: 'b.jpg',
195
+ size: 20,
196
+ contentType: 'image/jpeg'
197
+ }
198
+ ]);
199
+ const files = [
200
+ buildMulterFile({
201
+ originalname: 'a.jpg'
202
+ }),
203
+ buildMulterFile({
204
+ originalname: 'b.jpg'
205
+ })
206
+ ];
207
+ const result = await service.uploadMultipleFiles(files, {}, buildMockUser());
208
+ expect(result).toHaveLength(2);
209
+ expect(mockStorageConfigService.validateFileSize).toHaveBeenCalledTimes(2);
210
+ });
211
+ it('rejects the whole batch if any single file fails validation', async ()=>{
212
+ mockStorageConfigService.validateFileSize.mockReturnValueOnce({
213
+ valid: true
214
+ }).mockReturnValueOnce({
215
+ valid: false,
216
+ message: 'too big',
217
+ messageKey: 'upload.file.too.large'
218
+ });
219
+ const files = [
220
+ buildMulterFile({
221
+ originalname: 'a.jpg'
222
+ }),
223
+ buildMulterFile({
224
+ originalname: 'b.jpg'
225
+ })
226
+ ];
227
+ await expect(service.uploadMultipleFiles(files, {}, buildMockUser())).rejects.toThrow();
228
+ expect(mockProvider.uploadMultipleFiles).not.toHaveBeenCalled();
229
+ });
230
+ });
231
+ describe('deleteSingleFile', ()=>{
232
+ it('throws BadRequestException when no key is provided', async ()=>{
233
+ await expect(service.deleteSingleFile('')).rejects.toThrow();
234
+ });
235
+ it('resolves a provider by storageConfigId and deletes the file', async ()=>{
236
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue({
237
+ id: 'cfg-1',
238
+ storage: 'aws',
239
+ config: {}
240
+ });
241
+ const result = await service.deleteSingleFile('uploads/photo.jpg', 'cfg-1', buildMockUser());
242
+ expect(mockProvider.deleteFile).toHaveBeenCalledWith('uploads/photo.jpg');
243
+ expect(result).toBe(true);
244
+ });
245
+ it('falls back to a matching config by locationHint when storageConfigId is missing/invalid', async ()=>{
246
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue(null);
247
+ mockStorageProviderConfigService.getConfigByType.mockResolvedValue([
248
+ {
249
+ id: 'cfg-2',
250
+ storage: 'local',
251
+ config: {}
252
+ }
253
+ ]);
254
+ const result = await service.deleteSingleFile('uploads/photo.jpg', 'stale-id', buildMockUser(), 'local');
255
+ expect(mockStorageProviderConfigService.getConfigByType).toHaveBeenCalledWith('local', buildMockUser());
256
+ expect(result).toBe(true);
257
+ });
258
+ it('falls back to a local provider when locationHint is local and no config matches', async ()=>{
259
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue(null);
260
+ mockStorageProviderConfigService.getConfigByType.mockResolvedValue([]);
261
+ await service.deleteSingleFile('uploads/photo.jpg', undefined, buildMockUser(), FileLocationEnum.LOCAL);
262
+ expect(mockStorageFactory.createProvider).toHaveBeenCalledWith(expect.objectContaining({
263
+ provider: FileLocationEnum.LOCAL
264
+ }));
265
+ });
266
+ it('falls back to the default config as a last resort', async ()=>{
267
+ mockStorageProviderConfigService.getConfigByType.mockResolvedValue([]);
268
+ mockStorageProviderConfigService.getDefaultConfig.mockResolvedValue({
269
+ id: 'cfg-default',
270
+ storage: 'aws',
271
+ config: {}
272
+ });
273
+ await service.deleteSingleFile('uploads/photo.jpg', undefined, buildMockUser(), 'aws');
274
+ expect(mockStorageProviderConfigService.getDefaultConfig).toHaveBeenCalled();
275
+ });
276
+ });
277
+ describe('deleteMultipleFile', ()=>{
278
+ it('throws BadRequestException when no keys are provided', async ()=>{
279
+ await expect(service.deleteMultipleFile([])).rejects.toThrow();
280
+ });
281
+ it('deletes all provided keys via the resolved provider', async ()=>{
282
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue({
283
+ id: 'cfg-1',
284
+ storage: 'aws',
285
+ config: {}
286
+ });
287
+ const result = await service.deleteMultipleFile([
288
+ 'a.jpg',
289
+ 'b.jpg'
290
+ ], 'cfg-1', buildMockUser());
291
+ expect(mockProvider.deleteMultipleFiles).toHaveBeenCalledWith([
292
+ 'a.jpg',
293
+ 'b.jpg'
294
+ ]);
295
+ expect(result).toBe(true);
296
+ });
297
+ });
298
+ describe('bytesToKb', ()=>{
299
+ it('converts bytes to kilobytes rounded to 2 decimals', ()=>{
300
+ expect(service.bytesToKb(1024)).toBe(1.02);
301
+ });
302
+ it('returns 0 for a 0-byte file', ()=>{
303
+ expect(service.bytesToKb(0)).toBe(0);
304
+ });
305
+ });
306
+ describe('getLocalStorageBasePath', ()=>{
307
+ it('returns the cached basePath if already available', async ()=>{
308
+ mockStorageFactory.getLocalProviderBasePath.mockReturnValue('./cached-uploads');
309
+ const result = await service.getLocalStorageBasePath();
310
+ expect(result).toBe('./cached-uploads');
311
+ expect(mockStorageProviderConfigService.getConfigByType).not.toHaveBeenCalled();
312
+ });
313
+ it('initializes the local provider from DB config when not yet cached', async ()=>{
314
+ mockStorageFactory.getLocalProviderBasePath.mockReturnValueOnce(null).mockReturnValueOnce('./db-uploads');
315
+ mockStorageProviderConfigService.getConfigByType.mockResolvedValue([
316
+ {
317
+ id: 'cfg-local',
318
+ storage: 'local',
319
+ config: {
320
+ basePath: './db-uploads'
321
+ }
322
+ }
323
+ ]);
324
+ const result = await service.getLocalStorageBasePath();
325
+ expect(result).toBe('./db-uploads');
326
+ });
327
+ it('returns null when there is no local config in the database', async ()=>{
328
+ mockStorageProviderConfigService.getConfigByType.mockResolvedValue([]);
329
+ const result = await service.getLocalStorageBasePath();
330
+ expect(result).toBeNull();
331
+ });
332
+ it('returns null (swallows errors) instead of throwing when lookup fails', async ()=>{
333
+ mockStorageProviderConfigService.getConfigByType.mockRejectedValue(new Error('db down'));
334
+ await expect(service.getLocalStorageBasePath()).resolves.toBeNull();
335
+ });
336
+ });
337
+ describe('makeFileUrl', ()=>{
338
+ it('delegates to the provider generatePresignedUrl when supported', async ()=>{
339
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue({
340
+ id: 'cfg-1',
341
+ storage: 'aws',
342
+ config: {}
343
+ });
344
+ const url = await service.makeFileUrl('uploads/photo.jpg', 'cfg-1', 900, buildMockUser());
345
+ expect(mockProvider.generatePresignedUrl).toHaveBeenCalledWith('uploads/photo.jpg', 900);
346
+ expect(url).toBe('https://signed.example.com/uploads/photo.jpg');
347
+ });
348
+ it('returns the raw key when the provider does not support presigned URLs', async ()=>{
349
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue({
350
+ id: 'cfg-1',
351
+ storage: 'sftp',
352
+ config: {}
353
+ });
354
+ delete mockProvider.generatePresignedUrl;
355
+ const url = await service.makeFileUrl('uploads/photo.jpg', 'cfg-1', 900, buildMockUser());
356
+ expect(url).toBe('uploads/photo.jpg');
357
+ });
358
+ it('defaults expiry to 3600 seconds when not specified', async ()=>{
359
+ mockStorageProviderConfigService.findByIdDirect.mockResolvedValue({
360
+ id: 'cfg-1',
361
+ storage: 'aws',
362
+ config: {}
363
+ });
364
+ await service.makeFileUrl('uploads/photo.jpg', 'cfg-1');
365
+ expect(mockProvider.generatePresignedUrl).toHaveBeenCalledWith('uploads/photo.jpg', 3600);
366
+ });
367
+ });
368
+ });