@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.
- package/cjs/controllers/file-manager.controller.spec.js +135 -0
- package/cjs/controllers/folder.controller.spec.js +86 -0
- package/cjs/controllers/storage-config.controller.spec.js +85 -0
- package/cjs/controllers/upload.controller.spec.js +132 -0
- package/cjs/docs/storage-swagger.config.spec.js +72 -0
- package/cjs/entities/index.spec.js +18 -0
- package/cjs/middlewares/file-serve.middleware.spec.js +211 -0
- package/cjs/modules/storage.module.spec.js +184 -0
- package/cjs/providers/azure-provider.optional.spec.js +228 -0
- package/cjs/providers/local-provider.spec.js +239 -0
- package/cjs/providers/s3-provider.optional.spec.js +243 -0
- package/cjs/providers/sftp-provider.optional.spec.js +217 -0
- package/cjs/providers/storage-factory.service.spec.js +253 -0
- package/cjs/providers/storage-provider.registry.spec.js +67 -0
- package/cjs/services/file-manager.service.spec.js +549 -0
- package/cjs/services/folder.service.spec.js +163 -0
- package/cjs/services/storage-config.service.spec.js +257 -0
- package/cjs/services/storage-datasource.provider.spec.js +217 -0
- package/cjs/services/storage-provider-config.service.spec.js +243 -0
- package/cjs/services/upload.service.spec.js +372 -0
- package/cjs/utils/file-validator.util.spec.js +259 -0
- package/cjs/utils/image-compressor.util.spec.js +166 -0
- package/fesm/controllers/file-manager.controller.spec.js +131 -0
- package/fesm/controllers/folder.controller.spec.js +82 -0
- package/fesm/controllers/storage-config.controller.spec.js +81 -0
- package/fesm/controllers/upload.controller.spec.js +128 -0
- package/fesm/docs/storage-swagger.config.spec.js +68 -0
- package/fesm/entities/index.spec.js +14 -0
- package/fesm/middlewares/file-serve.middleware.spec.js +207 -0
- package/fesm/modules/storage.module.spec.js +180 -0
- package/fesm/providers/azure-provider.optional.spec.js +224 -0
- package/fesm/providers/local-provider.spec.js +194 -0
- package/fesm/providers/s3-provider.optional.spec.js +239 -0
- package/fesm/providers/sftp-provider.optional.spec.js +213 -0
- package/fesm/providers/storage-factory.service.spec.js +249 -0
- package/fesm/providers/storage-provider.registry.spec.js +63 -0
- package/fesm/services/file-manager.service.spec.js +545 -0
- package/fesm/services/folder.service.spec.js +159 -0
- package/fesm/services/storage-config.service.spec.js +253 -0
- package/fesm/services/storage-datasource.provider.spec.js +172 -0
- package/fesm/services/storage-provider-config.service.spec.js +239 -0
- package/fesm/services/upload.service.spec.js +368 -0
- package/fesm/utils/file-validator.util.spec.js +255 -0
- package/fesm/utils/image-compressor.util.spec.js +162 -0
- package/package.json +3 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
|
|
2
|
+
import { StorageConfigController } from './storage-config.controller';
|
|
3
|
+
function buildStorageConfig(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
id: 'storage-config-1',
|
|
6
|
+
provider: 'local',
|
|
7
|
+
isActive: true,
|
|
8
|
+
isDefault: true,
|
|
9
|
+
readOnly: false,
|
|
10
|
+
createdAt: new Date(),
|
|
11
|
+
updatedAt: new Date(),
|
|
12
|
+
...overrides
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
describe('StorageConfigController', ()=>{
|
|
16
|
+
let controller;
|
|
17
|
+
let mockStorageConfigService;
|
|
18
|
+
beforeEach(()=>{
|
|
19
|
+
mockStorageConfigService = {
|
|
20
|
+
insert: jest.fn(),
|
|
21
|
+
findById: jest.fn(),
|
|
22
|
+
getAll: jest.fn(),
|
|
23
|
+
update: jest.fn(),
|
|
24
|
+
delete: jest.fn()
|
|
25
|
+
};
|
|
26
|
+
controller = new StorageConfigController(mockStorageConfigService);
|
|
27
|
+
});
|
|
28
|
+
it('wires the injected service onto the generated controller base', ()=>{
|
|
29
|
+
expect(controller.storageConfigService).toBe(mockStorageConfigService);
|
|
30
|
+
expect(controller.service).toBe(mockStorageConfigService);
|
|
31
|
+
});
|
|
32
|
+
describe('insert', ()=>{
|
|
33
|
+
it('creates a storage config and returns the mapped response', async ()=>{
|
|
34
|
+
const user = buildMockUser();
|
|
35
|
+
mockStorageConfigService.insert.mockResolvedValue(buildStorageConfig());
|
|
36
|
+
const result = await controller.insert({
|
|
37
|
+
provider: 'local'
|
|
38
|
+
}, user);
|
|
39
|
+
expect(mockStorageConfigService.insert).toHaveBeenCalledWith({
|
|
40
|
+
provider: 'local'
|
|
41
|
+
}, user);
|
|
42
|
+
expect(result.success).toBe(true);
|
|
43
|
+
expect(result.data).toEqual(expect.objectContaining({
|
|
44
|
+
id: 'storage-config-1'
|
|
45
|
+
}));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('getAll', ()=>{
|
|
49
|
+
it('paginates storage configs and maps the response list', async ()=>{
|
|
50
|
+
mockStorageConfigService.getAll.mockResolvedValue({
|
|
51
|
+
data: [
|
|
52
|
+
buildStorageConfig()
|
|
53
|
+
],
|
|
54
|
+
total: 1
|
|
55
|
+
});
|
|
56
|
+
const result = await controller.getAll({}, buildMockUser(), '');
|
|
57
|
+
expect(mockStorageConfigService.getAll).toHaveBeenCalledWith('', {}, buildMockUser());
|
|
58
|
+
expect(result.data).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('delete', ()=>{
|
|
62
|
+
it('delegates soft-delete to the service', async ()=>{
|
|
63
|
+
mockStorageConfigService.delete.mockResolvedValue({
|
|
64
|
+
count: 1
|
|
65
|
+
});
|
|
66
|
+
const result = await controller.delete({
|
|
67
|
+
id: [
|
|
68
|
+
'storage-config-1'
|
|
69
|
+
],
|
|
70
|
+
type: 'delete'
|
|
71
|
+
}, buildMockUser());
|
|
72
|
+
expect(mockStorageConfigService.delete).toHaveBeenCalledWith({
|
|
73
|
+
id: [
|
|
74
|
+
'storage-config-1'
|
|
75
|
+
],
|
|
76
|
+
type: 'delete'
|
|
77
|
+
}, buildMockUser());
|
|
78
|
+
expect(result.success).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
|
|
2
|
+
import { UploadController } from './upload.controller';
|
|
3
|
+
describe('UploadController', ()=>{
|
|
4
|
+
let controller;
|
|
5
|
+
let mockUploadService;
|
|
6
|
+
beforeEach(()=>{
|
|
7
|
+
mockUploadService = {
|
|
8
|
+
bytesToKb: jest.fn((bytes)=>bytes / 1024),
|
|
9
|
+
uploadSingleFile: jest.fn(),
|
|
10
|
+
uploadMultipleFiles: jest.fn(),
|
|
11
|
+
deleteSingleFile: jest.fn(),
|
|
12
|
+
deleteMultipleFile: jest.fn()
|
|
13
|
+
};
|
|
14
|
+
controller = new UploadController(mockUploadService);
|
|
15
|
+
});
|
|
16
|
+
describe('uploadSingleFile', ()=>{
|
|
17
|
+
it('uploads a file and maps the response payload', async ()=>{
|
|
18
|
+
const user = buildMockUser();
|
|
19
|
+
const file = {
|
|
20
|
+
originalname: 'photo.png'
|
|
21
|
+
};
|
|
22
|
+
mockUploadService.uploadSingleFile.mockResolvedValue({
|
|
23
|
+
size: 2048,
|
|
24
|
+
name: 'photo.png',
|
|
25
|
+
key: 'files/photo.png',
|
|
26
|
+
contentType: 'image/png',
|
|
27
|
+
location: 's3',
|
|
28
|
+
storageConfigId: 'storage-1'
|
|
29
|
+
});
|
|
30
|
+
const result = await controller.uploadSingleFile(file, {
|
|
31
|
+
folderPath: 'photos'
|
|
32
|
+
}, user);
|
|
33
|
+
expect(mockUploadService.uploadSingleFile).toHaveBeenCalledWith(file, {
|
|
34
|
+
folderPath: 'photos'
|
|
35
|
+
}, user);
|
|
36
|
+
expect(mockUploadService.bytesToKb).toHaveBeenCalledWith(2048);
|
|
37
|
+
expect(result).toEqual({
|
|
38
|
+
success: true,
|
|
39
|
+
message: 'File uploaded successfully',
|
|
40
|
+
messageKey: expect.any(String),
|
|
41
|
+
data: {
|
|
42
|
+
size: 2,
|
|
43
|
+
name: 'photo.png',
|
|
44
|
+
key: 'files/photo.png',
|
|
45
|
+
contentType: 'image/png',
|
|
46
|
+
location: 's3',
|
|
47
|
+
storageConfigId: 'storage-1'
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('defaults location to "local" and storageConfigId to an empty string when absent', async ()=>{
|
|
52
|
+
const user = buildMockUser();
|
|
53
|
+
mockUploadService.uploadSingleFile.mockResolvedValue({
|
|
54
|
+
size: 100,
|
|
55
|
+
name: 'file.txt',
|
|
56
|
+
key: 'files/file.txt',
|
|
57
|
+
contentType: 'text/plain'
|
|
58
|
+
});
|
|
59
|
+
const result = await controller.uploadSingleFile({}, {}, user);
|
|
60
|
+
expect(result.data.location).toBe('local');
|
|
61
|
+
expect(result.data.storageConfigId).toBe('');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('uploadMultipleFiles', ()=>{
|
|
65
|
+
it('uploads multiple files and maps each response payload', async ()=>{
|
|
66
|
+
const user = buildMockUser();
|
|
67
|
+
const files = [
|
|
68
|
+
{
|
|
69
|
+
originalname: 'a.png'
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
originalname: 'b.png'
|
|
73
|
+
}
|
|
74
|
+
];
|
|
75
|
+
mockUploadService.uploadMultipleFiles.mockResolvedValue([
|
|
76
|
+
{
|
|
77
|
+
size: 100,
|
|
78
|
+
name: 'a.png',
|
|
79
|
+
key: 'files/a.png',
|
|
80
|
+
contentType: 'image/png'
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
size: 200,
|
|
84
|
+
name: 'b.png',
|
|
85
|
+
key: 'files/b.png',
|
|
86
|
+
contentType: 'image/png'
|
|
87
|
+
}
|
|
88
|
+
]);
|
|
89
|
+
const result = await controller.uploadMultipleFiles(files, {}, user);
|
|
90
|
+
expect(mockUploadService.uploadMultipleFiles).toHaveBeenCalledWith(files, {}, user);
|
|
91
|
+
expect(result.data).toHaveLength(2);
|
|
92
|
+
expect(result.data[0]).toEqual(expect.objectContaining({
|
|
93
|
+
name: 'a.png',
|
|
94
|
+
key: 'files/a.png'
|
|
95
|
+
}));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('deleteSingleFile', ()=>{
|
|
99
|
+
it('deletes a file by key and storage config', async ()=>{
|
|
100
|
+
const user = buildMockUser();
|
|
101
|
+
mockUploadService.deleteSingleFile.mockResolvedValue(true);
|
|
102
|
+
const result = await controller.deleteSingleFile({
|
|
103
|
+
key: 'files/a.png',
|
|
104
|
+
storageConfigId: 'storage-1'
|
|
105
|
+
}, user);
|
|
106
|
+
expect(mockUploadService.deleteSingleFile).toHaveBeenCalledWith('files/a.png', 'storage-1', user);
|
|
107
|
+
expect(result.data).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('deleteMultipleFile', ()=>{
|
|
111
|
+
it('deletes multiple files by keys and storage config', async ()=>{
|
|
112
|
+
const user = buildMockUser();
|
|
113
|
+
mockUploadService.deleteMultipleFile.mockResolvedValue(true);
|
|
114
|
+
const result = await controller.deleteMultipleFile({
|
|
115
|
+
keys: [
|
|
116
|
+
'files/a.png',
|
|
117
|
+
'files/b.png'
|
|
118
|
+
],
|
|
119
|
+
storageConfigId: 'storage-1'
|
|
120
|
+
}, user);
|
|
121
|
+
expect(mockUploadService.deleteMultipleFile).toHaveBeenCalledWith([
|
|
122
|
+
'files/a.png',
|
|
123
|
+
'files/b.png'
|
|
124
|
+
], 'storage-1', user);
|
|
125
|
+
expect(result.data).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { storageSwaggerConfig } from './storage-swagger.config';
|
|
2
|
+
describe('storageSwaggerConfig', ()=>{
|
|
3
|
+
it('should default to company feature enabled and single-database mode when no config is given', ()=>{
|
|
4
|
+
const config = storageSwaggerConfig();
|
|
5
|
+
expect(config.title).toBe('Storage API');
|
|
6
|
+
expect(config.path).toBe('api/docs/storage');
|
|
7
|
+
expect(config.bearerAuth).toBe(true);
|
|
8
|
+
expect(config.excludeTags).toEqual([]);
|
|
9
|
+
expect(config.excludeSchemaProperties).toBeUndefined();
|
|
10
|
+
expect(config.description).not.toContain('Multi-Tenant Mode');
|
|
11
|
+
expect(config.description).toContain('with multi-tenant support');
|
|
12
|
+
});
|
|
13
|
+
it('should exclude companyId schema properties when the company feature is disabled', ()=>{
|
|
14
|
+
const config = storageSwaggerConfig({
|
|
15
|
+
enableCompanyFeature: false
|
|
16
|
+
});
|
|
17
|
+
expect(config.excludeSchemaProperties).toEqual([
|
|
18
|
+
{
|
|
19
|
+
schemaName: 'FileManagerResponseDto',
|
|
20
|
+
properties: [
|
|
21
|
+
'companyId'
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
schemaName: 'FolderResponseDto',
|
|
26
|
+
properties: [
|
|
27
|
+
'companyId'
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
schemaName: 'StorageConfigResponseDto',
|
|
32
|
+
properties: [
|
|
33
|
+
'companyId'
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
it('should not exclude any schema properties when the company feature is enabled', ()=>{
|
|
39
|
+
const config = storageSwaggerConfig({
|
|
40
|
+
enableCompanyFeature: true
|
|
41
|
+
});
|
|
42
|
+
expect(config.excludeSchemaProperties).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
it('should describe company isolation and multi-tenant sections only when the company feature is enabled', ()=>{
|
|
45
|
+
const withCompany = storageSwaggerConfig({
|
|
46
|
+
enableCompanyFeature: true
|
|
47
|
+
});
|
|
48
|
+
const withoutCompany = storageSwaggerConfig({
|
|
49
|
+
enableCompanyFeature: false
|
|
50
|
+
});
|
|
51
|
+
expect(withCompany.description).toContain('Company isolation');
|
|
52
|
+
expect(withCompany.description).toContain('## Multi-Tenant Support');
|
|
53
|
+
expect(withCompany.description).toContain('## Examples');
|
|
54
|
+
expect(withoutCompany.description).not.toContain('Company isolation');
|
|
55
|
+
expect(withoutCompany.description).not.toContain('## Multi-Tenant Support');
|
|
56
|
+
expect(withoutCompany.description).not.toContain('## Examples');
|
|
57
|
+
});
|
|
58
|
+
it('should mention multi-tenant mode in the description only for multi-tenant database mode', ()=>{
|
|
59
|
+
const multiTenant = storageSwaggerConfig({
|
|
60
|
+
databaseMode: 'multi-tenant'
|
|
61
|
+
});
|
|
62
|
+
const single = storageSwaggerConfig({
|
|
63
|
+
databaseMode: 'single'
|
|
64
|
+
});
|
|
65
|
+
expect(multiTenant.description).toContain('Multi-Tenant Mode');
|
|
66
|
+
expect(single.description).not.toContain('Multi-Tenant Mode');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getStorageEntitiesByConfig, StorageCoreEntities, StorageCompanyEntities } from './index';
|
|
2
|
+
describe('getStorageEntitiesByConfig', ()=>{
|
|
3
|
+
it('returns the core entities when the company feature is disabled', ()=>{
|
|
4
|
+
expect(getStorageEntitiesByConfig(false)).toBe(StorageCoreEntities);
|
|
5
|
+
});
|
|
6
|
+
it('returns the company-scoped entities when the company feature is enabled', ()=>{
|
|
7
|
+
expect(getStorageEntitiesByConfig(true)).toBe(StorageCompanyEntities);
|
|
8
|
+
});
|
|
9
|
+
it('core and company entity sets are disjoint (never mixed)', ()=>{
|
|
10
|
+
const core = getStorageEntitiesByConfig(false);
|
|
11
|
+
const company = getStorageEntitiesByConfig(true);
|
|
12
|
+
expect(core.some((e)=>company.includes(e))).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
import { EventEmitter } from 'events';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
const mockCreateReadStream = jest.fn();
|
|
17
|
+
const mockExistsSync = jest.fn();
|
|
18
|
+
const mockStatSync = jest.fn();
|
|
19
|
+
jest.mock('fs', ()=>({
|
|
20
|
+
...jest.requireActual('fs'),
|
|
21
|
+
createReadStream: (...args)=>mockCreateReadStream(...args),
|
|
22
|
+
existsSync: (...args)=>mockExistsSync(...args),
|
|
23
|
+
statSync: (...args)=>mockStatSync(...args)
|
|
24
|
+
}));
|
|
25
|
+
const mockMimeLookup = jest.fn();
|
|
26
|
+
jest.mock('mime-types', ()=>({
|
|
27
|
+
lookup: (...args)=>mockMimeLookup(...args)
|
|
28
|
+
}));
|
|
29
|
+
import { FileServeMiddleware } from './file-serve.middleware';
|
|
30
|
+
let FakeStream = class FakeStream extends EventEmitter {
|
|
31
|
+
constructor(...args){
|
|
32
|
+
super(...args), _define_property(this, "pipe", jest.fn()), _define_property(this, "destroy", jest.fn());
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
function buildRes() {
|
|
36
|
+
const listeners = {};
|
|
37
|
+
return {
|
|
38
|
+
headersSent: false,
|
|
39
|
+
set: jest.fn(),
|
|
40
|
+
status: jest.fn().mockReturnThis(),
|
|
41
|
+
json: jest.fn(),
|
|
42
|
+
on: jest.fn((event, cb)=>{
|
|
43
|
+
listeners[event] = listeners[event] || [];
|
|
44
|
+
listeners[event].push(cb);
|
|
45
|
+
}),
|
|
46
|
+
emitClose: ()=>(listeners['close'] || []).forEach((cb)=>cb())
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function buildReq(urlPath) {
|
|
50
|
+
return {
|
|
51
|
+
path: urlPath,
|
|
52
|
+
url: urlPath
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
describe('FileServeMiddleware', ()=>{
|
|
56
|
+
let middleware;
|
|
57
|
+
let mockUploadService;
|
|
58
|
+
let next;
|
|
59
|
+
beforeEach(()=>{
|
|
60
|
+
jest.clearAllMocks();
|
|
61
|
+
mockUploadService = {
|
|
62
|
+
getLocalStorageBasePath: jest.fn().mockResolvedValue(null)
|
|
63
|
+
};
|
|
64
|
+
middleware = new FileServeMiddleware(mockUploadService);
|
|
65
|
+
next = jest.fn();
|
|
66
|
+
});
|
|
67
|
+
it('responds 404 when the file path segment is missing from the URL', async ()=>{
|
|
68
|
+
const res = buildRes();
|
|
69
|
+
await middleware.use(buildReq('/storage/upload/file/'), res, next);
|
|
70
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
71
|
+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
|
72
|
+
success: false,
|
|
73
|
+
statusCode: 404
|
|
74
|
+
}));
|
|
75
|
+
expect(mockExistsSync).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
it('blocks path traversal attempts that resolve outside the upload directory', async ()=>{
|
|
78
|
+
const res = buildRes();
|
|
79
|
+
mockExistsSync.mockReturnValue(false);
|
|
80
|
+
await middleware.use(buildReq('/storage/upload/file/../../etc/passwd'), res, next);
|
|
81
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
82
|
+
expect(mockStatSync).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
it('serves a file that exists directly under the upload directory with inline disposition for viewable types', async ()=>{
|
|
85
|
+
const res = buildRes();
|
|
86
|
+
mockExistsSync.mockReturnValue(true);
|
|
87
|
+
mockStatSync.mockReturnValue({
|
|
88
|
+
isFile: ()=>true,
|
|
89
|
+
size: 1234
|
|
90
|
+
});
|
|
91
|
+
mockMimeLookup.mockReturnValue('application/pdf');
|
|
92
|
+
const stream = new FakeStream();
|
|
93
|
+
mockCreateReadStream.mockReturnValue(stream);
|
|
94
|
+
await middleware.use(buildReq('/storage/upload/file/report.pdf'), res, next);
|
|
95
|
+
expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
|
|
96
|
+
'Content-Type': 'application/pdf',
|
|
97
|
+
'Content-Length': 1234,
|
|
98
|
+
'Content-Disposition': 'inline'
|
|
99
|
+
}));
|
|
100
|
+
expect(stream.pipe).toHaveBeenCalledWith(res);
|
|
101
|
+
});
|
|
102
|
+
it('sets an attachment content-disposition with the filename for non-viewable mime types', async ()=>{
|
|
103
|
+
const res = buildRes();
|
|
104
|
+
mockExistsSync.mockReturnValue(true);
|
|
105
|
+
mockStatSync.mockReturnValue({
|
|
106
|
+
isFile: ()=>true,
|
|
107
|
+
size: 500
|
|
108
|
+
});
|
|
109
|
+
mockMimeLookup.mockReturnValue('application/zip');
|
|
110
|
+
mockCreateReadStream.mockReturnValue(new FakeStream());
|
|
111
|
+
await middleware.use(buildReq('/storage/upload/file/archive.zip'), res, next);
|
|
112
|
+
expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
|
|
113
|
+
'Content-Disposition': expect.stringContaining('attachment; filename="archive.zip"')
|
|
114
|
+
}));
|
|
115
|
+
});
|
|
116
|
+
it('always serves .svg files as image/svg+xml regardless of mime-types lookup', async ()=>{
|
|
117
|
+
const res = buildRes();
|
|
118
|
+
mockExistsSync.mockReturnValue(true);
|
|
119
|
+
mockStatSync.mockReturnValue({
|
|
120
|
+
isFile: ()=>true,
|
|
121
|
+
size: 200
|
|
122
|
+
});
|
|
123
|
+
mockCreateReadStream.mockReturnValue(new FakeStream());
|
|
124
|
+
await middleware.use(buildReq('/storage/upload/file/logo.svg'), res, next);
|
|
125
|
+
expect(mockMimeLookup).not.toHaveBeenCalled();
|
|
126
|
+
expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
|
|
127
|
+
'Content-Type': 'image/svg+xml'
|
|
128
|
+
}));
|
|
129
|
+
});
|
|
130
|
+
it('returns 404 when the resolved path exists but is not a regular file', async ()=>{
|
|
131
|
+
const res = buildRes();
|
|
132
|
+
mockExistsSync.mockReturnValue(true);
|
|
133
|
+
mockStatSync.mockReturnValue({
|
|
134
|
+
isFile: ()=>false
|
|
135
|
+
});
|
|
136
|
+
await middleware.use(buildReq('/storage/upload/file/some-dir'), res, next);
|
|
137
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
138
|
+
expect(mockCreateReadStream).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
it('falls back to the configured local storage base path (strategy 2: path already prefixed with base)', async ()=>{
|
|
141
|
+
const res = buildRes();
|
|
142
|
+
const fallbackPath = join('uploads', 'report.pdf');
|
|
143
|
+
mockUploadService.getLocalStorageBasePath.mockResolvedValue('./uploads');
|
|
144
|
+
mockExistsSync.mockImplementation((p)=>p === fallbackPath);
|
|
145
|
+
mockStatSync.mockReturnValue({
|
|
146
|
+
isFile: ()=>true,
|
|
147
|
+
size: 10
|
|
148
|
+
});
|
|
149
|
+
mockMimeLookup.mockReturnValue('application/pdf');
|
|
150
|
+
mockCreateReadStream.mockReturnValue(new FakeStream());
|
|
151
|
+
await middleware.use(buildReq('/storage/upload/file/uploads/report.pdf'), res, next);
|
|
152
|
+
expect(mockCreateReadStream).toHaveBeenCalledWith(fallbackPath);
|
|
153
|
+
});
|
|
154
|
+
it('falls back to the configured local storage base path (strategy 3: legacy path without base prefix)', async ()=>{
|
|
155
|
+
const res = buildRes();
|
|
156
|
+
const fallbackPath = join('uploads', 'report.pdf');
|
|
157
|
+
mockUploadService.getLocalStorageBasePath.mockResolvedValue('uploads');
|
|
158
|
+
mockExistsSync.mockImplementation((p)=>p === fallbackPath);
|
|
159
|
+
mockStatSync.mockReturnValue({
|
|
160
|
+
isFile: ()=>true,
|
|
161
|
+
size: 10
|
|
162
|
+
});
|
|
163
|
+
mockMimeLookup.mockReturnValue('application/pdf');
|
|
164
|
+
mockCreateReadStream.mockReturnValue(new FakeStream());
|
|
165
|
+
await middleware.use(buildReq('/storage/upload/file/report.pdf'), res, next);
|
|
166
|
+
expect(mockCreateReadStream).toHaveBeenCalledWith(fallbackPath);
|
|
167
|
+
});
|
|
168
|
+
it('returns the underlying not-found message when no direct or fallback path resolves', async ()=>{
|
|
169
|
+
const res = buildRes();
|
|
170
|
+
mockExistsSync.mockReturnValue(false);
|
|
171
|
+
mockUploadService.getLocalStorageBasePath.mockResolvedValue(null);
|
|
172
|
+
await middleware.use(buildReq('/storage/upload/file/missing.pdf'), res, next);
|
|
173
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
174
|
+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
|
175
|
+
message: expect.stringContaining('File not found'),
|
|
176
|
+
statusCode: 404
|
|
177
|
+
}));
|
|
178
|
+
});
|
|
179
|
+
it('sends a 404 if the file stream errors before headers are sent', async ()=>{
|
|
180
|
+
const res = buildRes();
|
|
181
|
+
mockExistsSync.mockReturnValue(true);
|
|
182
|
+
mockStatSync.mockReturnValue({
|
|
183
|
+
isFile: ()=>true,
|
|
184
|
+
size: 10
|
|
185
|
+
});
|
|
186
|
+
mockMimeLookup.mockReturnValue('application/pdf');
|
|
187
|
+
const stream = new FakeStream();
|
|
188
|
+
mockCreateReadStream.mockReturnValue(stream);
|
|
189
|
+
await middleware.use(buildReq('/storage/upload/file/report.pdf'), res, next);
|
|
190
|
+
stream.emit('error', new Error('boom'));
|
|
191
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
192
|
+
});
|
|
193
|
+
it('destroys the stream when the response is closed early', async ()=>{
|
|
194
|
+
const res = buildRes();
|
|
195
|
+
mockExistsSync.mockReturnValue(true);
|
|
196
|
+
mockStatSync.mockReturnValue({
|
|
197
|
+
isFile: ()=>true,
|
|
198
|
+
size: 10
|
|
199
|
+
});
|
|
200
|
+
mockMimeLookup.mockReturnValue('application/pdf');
|
|
201
|
+
const stream = new FakeStream();
|
|
202
|
+
mockCreateReadStream.mockReturnValue(stream);
|
|
203
|
+
await middleware.use(buildReq('/storage/upload/file/report.pdf'), res, next);
|
|
204
|
+
res.emitClose();
|
|
205
|
+
expect(stream.destroy).toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
import { StorageModule } from './storage.module';
|
|
15
|
+
import { STORAGE_MODULE_OPTIONS } from '../config/storage.constants';
|
|
16
|
+
import { FileManagerController, FolderController, StorageConfigController, UploadController } from '../controllers';
|
|
17
|
+
import { FileServeMiddleware } from '../middlewares';
|
|
18
|
+
function providerTokens(providers) {
|
|
19
|
+
return providers.map((p)=>typeof p === 'function' ? p : p.provide);
|
|
20
|
+
}
|
|
21
|
+
describe('StorageModule', ()=>{
|
|
22
|
+
describe('forRoot', ()=>{
|
|
23
|
+
it('should default to a non-global module with all controllers registered', ()=>{
|
|
24
|
+
const dynamicModule = StorageModule.forRoot({});
|
|
25
|
+
expect(dynamicModule.module).toBe(StorageModule);
|
|
26
|
+
expect(dynamicModule.global).toBe(false);
|
|
27
|
+
expect(dynamicModule.controllers).toEqual([
|
|
28
|
+
FileManagerController,
|
|
29
|
+
FolderController,
|
|
30
|
+
StorageConfigController,
|
|
31
|
+
UploadController
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
it('should respect global: true', ()=>{
|
|
35
|
+
expect(StorageModule.forRoot({
|
|
36
|
+
global: true
|
|
37
|
+
}).global).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
it('should register no controllers when includeController is false', ()=>{
|
|
40
|
+
expect(StorageModule.forRoot({
|
|
41
|
+
includeController: false
|
|
42
|
+
}).controllers).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
it('should register controllers when includeController is anything other than exactly false', ()=>{
|
|
45
|
+
expect(StorageModule.forRoot({
|
|
46
|
+
includeController: true
|
|
47
|
+
}).controllers).toHaveLength(4);
|
|
48
|
+
expect(StorageModule.forRoot({}).controllers).toHaveLength(4);
|
|
49
|
+
});
|
|
50
|
+
it('should register a STORAGE_MODULE_OPTIONS provider carrying the given options', ()=>{
|
|
51
|
+
const options = {
|
|
52
|
+
bootstrapAppConfig: {
|
|
53
|
+
enableCompanyFeature: true
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const dynamicModule = StorageModule.forRoot(options);
|
|
57
|
+
const provider = dynamicModule.providers.find((p)=>p.provide === STORAGE_MODULE_OPTIONS);
|
|
58
|
+
expect(provider.useValue).toBe(options);
|
|
59
|
+
});
|
|
60
|
+
it('should always export the same fixed set of services', ()=>{
|
|
61
|
+
const dynamicModule = StorageModule.forRoot({});
|
|
62
|
+
expect(dynamicModule.exports).toHaveLength(7);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('forRootAsync', ()=>{
|
|
66
|
+
it('should forward external imports alongside CacheModule/UtilsModule', ()=>{
|
|
67
|
+
let FakeImportedModule = class FakeImportedModule {
|
|
68
|
+
};
|
|
69
|
+
const dynamicModule = StorageModule.forRootAsync({
|
|
70
|
+
bootstrapAppConfig: {},
|
|
71
|
+
imports: [
|
|
72
|
+
FakeImportedModule
|
|
73
|
+
]
|
|
74
|
+
});
|
|
75
|
+
expect(dynamicModule.imports).toEqual(expect.arrayContaining([
|
|
76
|
+
FakeImportedModule
|
|
77
|
+
]));
|
|
78
|
+
});
|
|
79
|
+
it('should register no controllers when includeController is false', ()=>{
|
|
80
|
+
const dynamicModule = StorageModule.forRootAsync({
|
|
81
|
+
bootstrapAppConfig: {},
|
|
82
|
+
includeController: false
|
|
83
|
+
});
|
|
84
|
+
expect(dynamicModule.controllers).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
it('useFactory: builds a STORAGE_MODULE_OPTIONS provider that merges resolved config into the static options', async ()=>{
|
|
87
|
+
const useFactory = jest.fn().mockResolvedValue({
|
|
88
|
+
maxFileSize: 1024
|
|
89
|
+
});
|
|
90
|
+
const dynamicModule = StorageModule.forRootAsync({
|
|
91
|
+
bootstrapAppConfig: {},
|
|
92
|
+
useFactory,
|
|
93
|
+
inject: [
|
|
94
|
+
'SOME_TOKEN'
|
|
95
|
+
]
|
|
96
|
+
});
|
|
97
|
+
const provider = dynamicModule.providers.find((p)=>p.provide === STORAGE_MODULE_OPTIONS);
|
|
98
|
+
expect(provider.inject).toEqual([
|
|
99
|
+
'SOME_TOKEN'
|
|
100
|
+
]);
|
|
101
|
+
const resolved = await provider.useFactory('injected');
|
|
102
|
+
expect(useFactory).toHaveBeenCalledWith('injected');
|
|
103
|
+
expect(resolved.config).toEqual({
|
|
104
|
+
maxFileSize: 1024
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
it('useClass: registers both the class provider and an options provider calling createStorageOptions()', async ()=>{
|
|
108
|
+
let MyOptionsFactory = class MyOptionsFactory {
|
|
109
|
+
constructor(){
|
|
110
|
+
_define_property(this, "createStorageOptions", jest.fn().mockResolvedValue({
|
|
111
|
+
maxFileSize: 2048
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const dynamicModule = StorageModule.forRootAsync({
|
|
116
|
+
bootstrapAppConfig: {},
|
|
117
|
+
useClass: MyOptionsFactory
|
|
118
|
+
});
|
|
119
|
+
const classProvider = dynamicModule.providers.find((p)=>p.provide === MyOptionsFactory);
|
|
120
|
+
expect(classProvider.useClass).toBe(MyOptionsFactory);
|
|
121
|
+
const optionsProvider = dynamicModule.providers.find((p)=>p.provide === STORAGE_MODULE_OPTIONS);
|
|
122
|
+
const instance = new MyOptionsFactory();
|
|
123
|
+
const resolved = await optionsProvider.useFactory(instance);
|
|
124
|
+
expect(instance.createStorageOptions).toHaveBeenCalled();
|
|
125
|
+
expect(resolved.config).toEqual({
|
|
126
|
+
maxFileSize: 2048
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
it('useExisting: registers a single options provider without a duplicate class registration', async ()=>{
|
|
130
|
+
let MyOptionsFactory = class MyOptionsFactory {
|
|
131
|
+
constructor(){
|
|
132
|
+
_define_property(this, "createStorageOptions", jest.fn().mockResolvedValue({
|
|
133
|
+
maxFileSize: 4096
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const dynamicModule = StorageModule.forRootAsync({
|
|
138
|
+
bootstrapAppConfig: {},
|
|
139
|
+
useExisting: MyOptionsFactory
|
|
140
|
+
});
|
|
141
|
+
expect(dynamicModule.providers.filter((p)=>p.provide === MyOptionsFactory)).toHaveLength(0);
|
|
142
|
+
const optionsProvider = dynamicModule.providers.find((p)=>p.provide === STORAGE_MODULE_OPTIONS);
|
|
143
|
+
expect(optionsProvider.inject).toEqual([
|
|
144
|
+
MyOptionsFactory
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
it('should register no STORAGE_MODULE_OPTIONS provider when neither useFactory, useClass, nor useExisting is given', ()=>{
|
|
148
|
+
const dynamicModule = StorageModule.forRootAsync({
|
|
149
|
+
bootstrapAppConfig: {}
|
|
150
|
+
});
|
|
151
|
+
expect(providerTokens(dynamicModule.providers)).not.toContain(STORAGE_MODULE_OPTIONS);
|
|
152
|
+
});
|
|
153
|
+
it('should still register the fixed exported services as providers alongside async options', ()=>{
|
|
154
|
+
const dynamicModule = StorageModule.forRootAsync({
|
|
155
|
+
bootstrapAppConfig: {},
|
|
156
|
+
useFactory: jest.fn()
|
|
157
|
+
});
|
|
158
|
+
expect(dynamicModule.providers).toEqual(expect.arrayContaining([
|
|
159
|
+
...dynamicModule.exports
|
|
160
|
+
]));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe('configure (NestModule)', ()=>{
|
|
164
|
+
it('should wire FileServeMiddleware to the file GET route', ()=>{
|
|
165
|
+
const forRoutes = jest.fn();
|
|
166
|
+
const apply = jest.fn().mockReturnValue({
|
|
167
|
+
forRoutes
|
|
168
|
+
});
|
|
169
|
+
const consumer = {
|
|
170
|
+
apply
|
|
171
|
+
};
|
|
172
|
+
new StorageModule().configure(consumer);
|
|
173
|
+
expect(apply).toHaveBeenCalledWith(FileServeMiddleware);
|
|
174
|
+
expect(forRoutes).toHaveBeenCalledWith({
|
|
175
|
+
path: 'storage/upload/file/*',
|
|
176
|
+
method: expect.anything()
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|