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