@flusys/nestjs-shared 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.
- package/cjs/classes/api-controller.class.spec.js +684 -0
- package/cjs/classes/api-service.class.spec.js +809 -0
- package/cjs/classes/handle-custome-db.spec.js +82 -0
- package/cjs/classes/hybrid-cache.class.spec.js +168 -0
- package/cjs/classes/winston.logger.class.spec.js +330 -0
- package/cjs/decorators/api-response.decorator.spec.js +126 -0
- package/cjs/decorators/current-user.decorator.spec.js +74 -0
- package/cjs/decorators/log-action.decorator.spec.js +353 -0
- package/cjs/decorators/public.decorator.spec.js +50 -0
- package/cjs/decorators/require-permission.decorator.spec.js +147 -0
- package/cjs/exceptions/base-app.exception.spec.js +157 -0
- package/cjs/exceptions/permission.exception.spec.js +82 -0
- package/cjs/filters/global-exception.filter.spec.js +440 -0
- package/cjs/guards/jwt-auth.guard.spec.js +75 -0
- package/cjs/guards/optional-jwt-auth.guard.spec.js +55 -0
- package/cjs/guards/permission.guard.spec.js +358 -0
- package/cjs/interceptors/delete-empty-id-from-body.interceptor.spec.js +107 -0
- package/cjs/interceptors/idempotency.interceptor.spec.js +116 -0
- package/cjs/interceptors/response-meta.interceptor.spec.js +89 -0
- package/cjs/interceptors/set-user-field-on-body.interceptor.spec.js +128 -0
- package/cjs/interceptors/slug.interceptor.spec.js +110 -0
- package/cjs/middlewares/logger.middleware.spec.js +462 -0
- package/cjs/modules/cache/cache.module.spec.js +48 -0
- package/cjs/modules/datasource/multi-tenant-datasource.service.spec.js +381 -0
- package/cjs/modules/utils/utils.service.spec.js +169 -0
- package/cjs/utils/date-time.util.spec.js +84 -0
- package/cjs/utils/html-sanitizer.util.spec.js +76 -0
- package/cjs/utils/permission-cache-key.util.spec.js +88 -0
- package/cjs/utils/query-helpers.util.spec.js +209 -0
- package/cjs/utils/request.util.spec.js +249 -0
- package/cjs/utils/string.util.spec.js +63 -0
- package/fesm/classes/api-controller.class.spec.js +680 -0
- package/fesm/classes/api-service.class.spec.js +805 -0
- package/fesm/classes/handle-custome-db.spec.js +78 -0
- package/fesm/classes/hybrid-cache.class.spec.js +164 -0
- package/fesm/classes/winston.logger.class.spec.js +327 -0
- package/fesm/decorators/api-response.decorator.spec.js +122 -0
- package/fesm/decorators/current-user.decorator.spec.js +73 -0
- package/fesm/decorators/log-action.decorator.spec.js +349 -0
- package/fesm/decorators/public.decorator.spec.js +46 -0
- package/fesm/decorators/require-permission.decorator.spec.js +143 -0
- package/fesm/exceptions/base-app.exception.spec.js +153 -0
- package/fesm/exceptions/permission.exception.spec.js +78 -0
- package/fesm/filters/global-exception.filter.spec.js +436 -0
- package/fesm/guards/jwt-auth.guard.spec.js +71 -0
- package/fesm/guards/optional-jwt-auth.guard.spec.js +51 -0
- package/fesm/guards/permission.guard.spec.js +354 -0
- package/fesm/interceptors/delete-empty-id-from-body.interceptor.spec.js +103 -0
- package/fesm/interceptors/idempotency.interceptor.spec.js +112 -0
- package/fesm/interceptors/response-meta.interceptor.spec.js +85 -0
- package/fesm/interceptors/set-user-field-on-body.interceptor.spec.js +124 -0
- package/fesm/interceptors/slug.interceptor.spec.js +106 -0
- package/fesm/middlewares/logger.middleware.spec.js +459 -0
- package/fesm/modules/cache/cache.module.spec.js +44 -0
- package/fesm/modules/datasource/multi-tenant-datasource.service.spec.js +377 -0
- package/fesm/modules/utils/utils.service.spec.js +165 -0
- package/fesm/utils/date-time.util.spec.js +80 -0
- package/fesm/utils/html-sanitizer.util.spec.js +72 -0
- package/fesm/utils/permission-cache-key.util.spec.js +84 -0
- package/fesm/utils/query-helpers.util.spec.js +205 -0
- package/fesm/utils/request.util.spec.js +245 -0
- package/fesm/utils/string.util.spec.js +59 -0
- package/package.json +2 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const _handlecustomedb = require("./handle-custome-db");
|
|
6
|
+
const mockGetTypeOrmConfig = jest.fn();
|
|
7
|
+
jest.mock('@flusys/nestjs-core', ()=>({
|
|
8
|
+
envConfig: {
|
|
9
|
+
getTypeOrmConfig: (...args)=>mockGetTypeOrmConfig(...args)
|
|
10
|
+
}
|
|
11
|
+
}));
|
|
12
|
+
describe('handle-custome-db', ()=>{
|
|
13
|
+
beforeEach(()=>{
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
describe('getJsonColumnType', ()=>{
|
|
17
|
+
it('should return jsonb for postgres', ()=>{
|
|
18
|
+
mockGetTypeOrmConfig.mockReturnValue({
|
|
19
|
+
type: 'postgres'
|
|
20
|
+
});
|
|
21
|
+
expect((0, _handlecustomedb.getJsonColumnType)()).toBe('jsonb');
|
|
22
|
+
});
|
|
23
|
+
it('should return json for mysql', ()=>{
|
|
24
|
+
mockGetTypeOrmConfig.mockReturnValue({
|
|
25
|
+
type: 'mysql'
|
|
26
|
+
});
|
|
27
|
+
expect((0, _handlecustomedb.getJsonColumnType)()).toBe('json');
|
|
28
|
+
});
|
|
29
|
+
it('should return simple-json for mariadb', ()=>{
|
|
30
|
+
mockGetTypeOrmConfig.mockReturnValue({
|
|
31
|
+
type: 'mariadb'
|
|
32
|
+
});
|
|
33
|
+
expect((0, _handlecustomedb.getJsonColumnType)()).toBe('simple-json');
|
|
34
|
+
});
|
|
35
|
+
it('should return simple-json for sqlite', ()=>{
|
|
36
|
+
mockGetTypeOrmConfig.mockReturnValue({
|
|
37
|
+
type: 'sqlite'
|
|
38
|
+
});
|
|
39
|
+
expect((0, _handlecustomedb.getJsonColumnType)()).toBe('simple-json');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('getCastIntType', ()=>{
|
|
43
|
+
it('should return INTEGER for postgres', ()=>{
|
|
44
|
+
mockGetTypeOrmConfig.mockReturnValue({
|
|
45
|
+
type: 'postgres'
|
|
46
|
+
});
|
|
47
|
+
expect((0, _handlecustomedb.getCastIntType)()).toBe('INTEGER');
|
|
48
|
+
});
|
|
49
|
+
it('should return UNSIGNED for mysql', ()=>{
|
|
50
|
+
mockGetTypeOrmConfig.mockReturnValue({
|
|
51
|
+
type: 'mysql'
|
|
52
|
+
});
|
|
53
|
+
expect((0, _handlecustomedb.getCastIntType)()).toBe('UNSIGNED');
|
|
54
|
+
});
|
|
55
|
+
it('should return UNSIGNED for mariadb', ()=>{
|
|
56
|
+
mockGetTypeOrmConfig.mockReturnValue({
|
|
57
|
+
type: 'mariadb'
|
|
58
|
+
});
|
|
59
|
+
expect((0, _handlecustomedb.getCastIntType)()).toBe('UNSIGNED');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('buildQuery', ()=>{
|
|
63
|
+
it('should leave the sql untouched for mysql', ()=>{
|
|
64
|
+
expect((0, _handlecustomedb.buildQuery)('SELECT * FROM t WHERE id = ?', 'mysql')).toBe('SELECT * FROM t WHERE id = ?');
|
|
65
|
+
});
|
|
66
|
+
it('should leave the sql untouched for mariadb', ()=>{
|
|
67
|
+
expect((0, _handlecustomedb.buildQuery)('SELECT * FROM t WHERE id = ?', 'mariadb')).toBe('SELECT * FROM t WHERE id = ?');
|
|
68
|
+
});
|
|
69
|
+
it('should convert a single ? placeholder to $1 for postgres', ()=>{
|
|
70
|
+
expect((0, _handlecustomedb.buildQuery)('SELECT * FROM t WHERE id = ?', 'postgres')).toBe('SELECT * FROM t WHERE id = $1');
|
|
71
|
+
});
|
|
72
|
+
it('should convert multiple ? placeholders in order for postgres', ()=>{
|
|
73
|
+
expect((0, _handlecustomedb.buildQuery)('WHERE a = ? AND b = ? AND c = ?', 'postgres')).toBe('WHERE a = $1 AND b = $2 AND c = $3');
|
|
74
|
+
});
|
|
75
|
+
it('should return the sql unchanged when there are no placeholders', ()=>{
|
|
76
|
+
expect((0, _handlecustomedb.buildQuery)('SELECT 1', 'postgres')).toBe('SELECT 1');
|
|
77
|
+
});
|
|
78
|
+
it('should handle an empty sql string', ()=>{
|
|
79
|
+
expect((0, _handlecustomedb.buildQuery)('', 'postgres')).toBe('');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const _hybridcacheclass = require("./hybrid-cache.class");
|
|
6
|
+
const mockTryGetValue = jest.fn();
|
|
7
|
+
const mockGetRedisUrl = jest.fn();
|
|
8
|
+
jest.mock('@flusys/nestjs-core/config', ()=>({
|
|
9
|
+
envConfig: {
|
|
10
|
+
tryGetValue: (...args)=>mockTryGetValue(...args),
|
|
11
|
+
getRedisUrl: (...args)=>mockGetRedisUrl(...args)
|
|
12
|
+
}
|
|
13
|
+
}));
|
|
14
|
+
const mockKeyvGet = jest.fn();
|
|
15
|
+
const mockKeyvSet = jest.fn();
|
|
16
|
+
const mockKeyvDelete = jest.fn();
|
|
17
|
+
const mockKeyvClear = jest.fn();
|
|
18
|
+
const KeyvCtor = jest.fn().mockImplementation(()=>({
|
|
19
|
+
get: mockKeyvGet,
|
|
20
|
+
set: mockKeyvSet,
|
|
21
|
+
delete: mockKeyvDelete,
|
|
22
|
+
clear: mockKeyvClear
|
|
23
|
+
}));
|
|
24
|
+
jest.mock('keyv', ()=>({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
default: KeyvCtor
|
|
27
|
+
}));
|
|
28
|
+
jest.mock('@keyv/redis', ()=>({
|
|
29
|
+
__esModule: true,
|
|
30
|
+
default: jest.fn().mockImplementation(()=>({}))
|
|
31
|
+
}));
|
|
32
|
+
jest.mock('cacheable', ()=>({
|
|
33
|
+
CacheableMemory: jest.fn().mockImplementation(()=>({}))
|
|
34
|
+
}));
|
|
35
|
+
describe('HybridCache', ()=>{
|
|
36
|
+
beforeEach(()=>{
|
|
37
|
+
jest.clearAllMocks();
|
|
38
|
+
mockGetRedisUrl.mockReturnValue('redis://localhost:6379');
|
|
39
|
+
});
|
|
40
|
+
describe('construction', ()=>{
|
|
41
|
+
it('should default to memory-only cache when USE_CACHE_LABEL is unset', ()=>{
|
|
42
|
+
mockTryGetValue.mockReturnValue('');
|
|
43
|
+
new _hybridcacheclass.HybridCache();
|
|
44
|
+
expect(KeyvCtor).toHaveBeenCalledTimes(1);
|
|
45
|
+
});
|
|
46
|
+
it('should create only a memory store for "memory" mode', ()=>{
|
|
47
|
+
mockTryGetValue.mockReturnValue('memory');
|
|
48
|
+
new _hybridcacheclass.HybridCache();
|
|
49
|
+
expect(KeyvCtor).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
it('should create only a redis store for "redis" mode', async ()=>{
|
|
52
|
+
mockTryGetValue.mockReturnValue('redis');
|
|
53
|
+
const cache = new _hybridcacheclass.HybridCache();
|
|
54
|
+
mockKeyvGet.mockResolvedValue('redis-value');
|
|
55
|
+
const result = await cache.get('key');
|
|
56
|
+
expect(KeyvCtor).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(result).toBe('redis-value');
|
|
58
|
+
});
|
|
59
|
+
it('should create both stores for "hybrid" mode', ()=>{
|
|
60
|
+
mockTryGetValue.mockReturnValue('hybrid');
|
|
61
|
+
new _hybridcacheclass.HybridCache();
|
|
62
|
+
expect(KeyvCtor).toHaveBeenCalledTimes(2);
|
|
63
|
+
});
|
|
64
|
+
it('should pass custom ttl and size through to the memory store options', ()=>{
|
|
65
|
+
mockTryGetValue.mockReturnValue('memory');
|
|
66
|
+
const { CacheableMemory } = require('cacheable');
|
|
67
|
+
new _hybridcacheclass.HybridCache(30_000, 100);
|
|
68
|
+
expect(CacheableMemory).toHaveBeenCalledWith({
|
|
69
|
+
ttl: 30_000,
|
|
70
|
+
lruSize: 100
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('get (hybrid mode)', ()=>{
|
|
75
|
+
let cache;
|
|
76
|
+
beforeEach(()=>{
|
|
77
|
+
mockTryGetValue.mockReturnValue('hybrid');
|
|
78
|
+
cache = new _hybridcacheclass.HybridCache();
|
|
79
|
+
});
|
|
80
|
+
it('should return the memory value without touching redis when present', async ()=>{
|
|
81
|
+
mockKeyvGet.mockResolvedValueOnce('memory-value');
|
|
82
|
+
const result = await cache.get('key');
|
|
83
|
+
expect(result).toBe('memory-value');
|
|
84
|
+
expect(mockKeyvGet).toHaveBeenCalledTimes(1);
|
|
85
|
+
});
|
|
86
|
+
it('should fall back to redis and backfill memory on L1 miss', async ()=>{
|
|
87
|
+
mockKeyvGet.mockResolvedValueOnce(undefined).mockResolvedValueOnce('redis-value');
|
|
88
|
+
const result = await cache.get('key');
|
|
89
|
+
expect(result).toBe('redis-value');
|
|
90
|
+
expect(mockKeyvSet).toHaveBeenCalledWith('key', 'redis-value');
|
|
91
|
+
});
|
|
92
|
+
it('should return undefined without backfilling when redis also misses', async ()=>{
|
|
93
|
+
mockKeyvGet.mockResolvedValue(undefined);
|
|
94
|
+
const result = await cache.get('key');
|
|
95
|
+
expect(result).toBeUndefined();
|
|
96
|
+
expect(mockKeyvSet).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
it('should treat falsy-but-defined values (0, "", false) as cache hits', async ()=>{
|
|
99
|
+
mockKeyvGet.mockResolvedValueOnce(0);
|
|
100
|
+
const result = await cache.get('key');
|
|
101
|
+
expect(result).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('get (memory-only mode)', ()=>{
|
|
105
|
+
it('should return undefined when there is no redis store to fall back to', async ()=>{
|
|
106
|
+
mockTryGetValue.mockReturnValue('memory');
|
|
107
|
+
const cache = new _hybridcacheclass.HybridCache();
|
|
108
|
+
mockKeyvGet.mockResolvedValue(undefined);
|
|
109
|
+
const result = await cache.get('key');
|
|
110
|
+
expect(result).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('set', ()=>{
|
|
114
|
+
it('should write to both stores in hybrid mode with the given ttl', async ()=>{
|
|
115
|
+
mockTryGetValue.mockReturnValue('hybrid');
|
|
116
|
+
const cache = new _hybridcacheclass.HybridCache();
|
|
117
|
+
await cache.set('key', {
|
|
118
|
+
a: 1
|
|
119
|
+
}, 5000);
|
|
120
|
+
expect(mockKeyvSet).toHaveBeenNthCalledWith(1, 'key', {
|
|
121
|
+
a: 1
|
|
122
|
+
}, 5000);
|
|
123
|
+
expect(mockKeyvSet).toHaveBeenNthCalledWith(2, 'key', {
|
|
124
|
+
a: 1
|
|
125
|
+
}, 5000);
|
|
126
|
+
});
|
|
127
|
+
it('should write only to memory when redis is not configured', async ()=>{
|
|
128
|
+
mockTryGetValue.mockReturnValue('memory');
|
|
129
|
+
const cache = new _hybridcacheclass.HybridCache();
|
|
130
|
+
await cache.set('key', 'value');
|
|
131
|
+
expect(mockKeyvSet).toHaveBeenCalledTimes(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('del', ()=>{
|
|
135
|
+
it('should delete from both stores in hybrid mode', async ()=>{
|
|
136
|
+
mockTryGetValue.mockReturnValue('hybrid');
|
|
137
|
+
const cache = new _hybridcacheclass.HybridCache();
|
|
138
|
+
await cache.del('key');
|
|
139
|
+
expect(mockKeyvDelete).toHaveBeenCalledTimes(2);
|
|
140
|
+
});
|
|
141
|
+
it('should be a no-op safe call when no stores are configured', async ()=>{
|
|
142
|
+
mockTryGetValue.mockReturnValue('unknown-mode');
|
|
143
|
+
const cache = new _hybridcacheclass.HybridCache();
|
|
144
|
+
await expect(cache.del('key')).resolves.toBeUndefined();
|
|
145
|
+
expect(mockKeyvDelete).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('reset / resetL2', ()=>{
|
|
149
|
+
it('reset should clear only the memory store', async ()=>{
|
|
150
|
+
mockTryGetValue.mockReturnValue('hybrid');
|
|
151
|
+
const cache = new _hybridcacheclass.HybridCache();
|
|
152
|
+
await cache.reset();
|
|
153
|
+
expect(mockKeyvClear).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
it('resetL2 should clear only the redis store', async ()=>{
|
|
156
|
+
mockTryGetValue.mockReturnValue('hybrid');
|
|
157
|
+
const cache = new _hybridcacheclass.HybridCache();
|
|
158
|
+
await cache.resetL2();
|
|
159
|
+
expect(mockKeyvClear).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
it('reset should be a no-op when memory is not configured', async ()=>{
|
|
162
|
+
mockTryGetValue.mockReturnValue('redis');
|
|
163
|
+
const cache = new _hybridcacheclass.HybridCache();
|
|
164
|
+
await expect(cache.reset()).resolves.toBeUndefined();
|
|
165
|
+
expect(mockKeyvClear).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
function _define_property(obj, key, value) {
|
|
6
|
+
if (key in obj) {
|
|
7
|
+
Object.defineProperty(obj, key, {
|
|
8
|
+
value: value,
|
|
9
|
+
enumerable: true,
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true
|
|
12
|
+
});
|
|
13
|
+
} else {
|
|
14
|
+
obj[key] = value;
|
|
15
|
+
}
|
|
16
|
+
return obj;
|
|
17
|
+
}
|
|
18
|
+
const mockExistsSync = jest.fn();
|
|
19
|
+
const mockMkdirSync = jest.fn();
|
|
20
|
+
jest.mock('fs', ()=>({
|
|
21
|
+
existsSync: (...args)=>mockExistsSync(...args),
|
|
22
|
+
mkdirSync: (...args)=>mockMkdirSync(...args)
|
|
23
|
+
}));
|
|
24
|
+
const mockGetLogConfig = jest.fn();
|
|
25
|
+
const mockUseTenantMode = jest.fn();
|
|
26
|
+
const mockIsProduction = jest.fn();
|
|
27
|
+
jest.mock('@flusys/nestjs-core/config', ()=>({
|
|
28
|
+
envConfig: {
|
|
29
|
+
getLogConfig: (...args)=>mockGetLogConfig(...args),
|
|
30
|
+
useTenantMode: (...args)=>mockUseTenantMode(...args),
|
|
31
|
+
isProduction: (...args)=>mockIsProduction(...args)
|
|
32
|
+
}
|
|
33
|
+
}));
|
|
34
|
+
const { EventEmitter } = require('events');
|
|
35
|
+
let FakeDailyRotateFile = class FakeDailyRotateFile extends EventEmitter {
|
|
36
|
+
constructor(options){
|
|
37
|
+
super(), _define_property(this, "options", void 0), _define_property(this, "log", jest.fn((_info, callback)=>callback?.()));
|
|
38
|
+
this.options = options;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
jest.mock('winston-daily-rotate-file', ()=>({
|
|
42
|
+
__esModule: true,
|
|
43
|
+
default: FakeDailyRotateFile
|
|
44
|
+
}));
|
|
45
|
+
let capturedPrintf;
|
|
46
|
+
jest.mock('winston', ()=>{
|
|
47
|
+
const actual = jest.requireActual('winston');
|
|
48
|
+
// winston's `format` export exposes its sub-formatters (colorize, combine, printf, ...)
|
|
49
|
+
// as non-enumerable getters, so a plain object spread silently drops them. Delegate through
|
|
50
|
+
// the prototype chain instead and only shadow `printf` to capture the formatter callback.
|
|
51
|
+
const realPrintf = actual.format.printf;
|
|
52
|
+
const mockFormat = Object.create(actual.format);
|
|
53
|
+
Object.defineProperty(mockFormat, 'printf', {
|
|
54
|
+
value: (fn)=>{
|
|
55
|
+
capturedPrintf = fn;
|
|
56
|
+
return realPrintf(fn);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
...actual,
|
|
61
|
+
format: mockFormat
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
describe('winston.logger.class', ()=>{
|
|
65
|
+
let consoleErrorSpy;
|
|
66
|
+
beforeEach(()=>{
|
|
67
|
+
jest.resetModules();
|
|
68
|
+
jest.clearAllMocks();
|
|
69
|
+
// FakeDailyRotateFile isn't a real winston-transport instance, so winston logs a
|
|
70
|
+
// harmless "legacy transport" deprecation warning via console.error on each createLogger call.
|
|
71
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(()=>undefined);
|
|
72
|
+
mockExistsSync.mockReturnValue(true);
|
|
73
|
+
mockGetLogConfig.mockReturnValue({
|
|
74
|
+
dir: 'logs',
|
|
75
|
+
level: 'debug',
|
|
76
|
+
maxSize: '20m',
|
|
77
|
+
maxFiles: '14d',
|
|
78
|
+
disableHttpLogging: false
|
|
79
|
+
});
|
|
80
|
+
mockUseTenantMode.mockReturnValue(false);
|
|
81
|
+
});
|
|
82
|
+
afterEach(()=>{
|
|
83
|
+
consoleErrorSpy.mockRestore();
|
|
84
|
+
});
|
|
85
|
+
it('should create the log directory when it does not exist', ()=>{
|
|
86
|
+
mockExistsSync.mockReturnValue(false);
|
|
87
|
+
mockIsProduction.mockReturnValue(false);
|
|
88
|
+
require('./winston.logger.class');
|
|
89
|
+
expect(mockMkdirSync).toHaveBeenCalledWith('logs', {
|
|
90
|
+
recursive: true
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
it('should not attempt to create the log directory when it already exists', ()=>{
|
|
94
|
+
mockExistsSync.mockReturnValue(true);
|
|
95
|
+
mockIsProduction.mockReturnValue(false);
|
|
96
|
+
require('./winston.logger.class');
|
|
97
|
+
expect(mockMkdirSync).not.toHaveBeenCalledWith('logs', {
|
|
98
|
+
recursive: true
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
it('should export a dev (console-based) instance when not in production', ()=>{
|
|
102
|
+
mockIsProduction.mockReturnValue(false);
|
|
103
|
+
const { instance } = require('./winston.logger.class');
|
|
104
|
+
expect(instance).toBeDefined();
|
|
105
|
+
expect(typeof instance.log).toBe('function');
|
|
106
|
+
});
|
|
107
|
+
it('should export a prod (file-rotation-based) instance when in production', ()=>{
|
|
108
|
+
mockIsProduction.mockReturnValue(true);
|
|
109
|
+
const { instance } = require('./winston.logger.class');
|
|
110
|
+
expect(instance).toBeDefined();
|
|
111
|
+
expect(typeof instance.log).toBe('function');
|
|
112
|
+
});
|
|
113
|
+
describe('createModuleLogger', ()=>{
|
|
114
|
+
it('should return the shared dev logger instance in non-production mode', ()=>{
|
|
115
|
+
mockIsProduction.mockReturnValue(false);
|
|
116
|
+
const { createModuleLogger, instance } = require('./winston.logger.class');
|
|
117
|
+
expect(createModuleLogger('auth')).toBe(instance);
|
|
118
|
+
expect(createModuleLogger('iam')).toBe(instance);
|
|
119
|
+
});
|
|
120
|
+
it('should create a distinct, cached logger per module in production mode', ()=>{
|
|
121
|
+
mockIsProduction.mockReturnValue(true);
|
|
122
|
+
const { createModuleLogger } = require('./winston.logger.class');
|
|
123
|
+
const authLogger1 = createModuleLogger('auth');
|
|
124
|
+
const authLogger2 = createModuleLogger('auth');
|
|
125
|
+
const iamLogger = createModuleLogger('iam');
|
|
126
|
+
expect(authLogger1).toBe(authLogger2);
|
|
127
|
+
expect(authLogger1).not.toBe(iamLogger);
|
|
128
|
+
});
|
|
129
|
+
it('should create the module-specific log directory on first use in production mode', ()=>{
|
|
130
|
+
mockIsProduction.mockReturnValue(true);
|
|
131
|
+
mockExistsSync.mockImplementation((p)=>p === 'logs');
|
|
132
|
+
const { createModuleLogger } = require('./winston.logger.class');
|
|
133
|
+
createModuleLogger('storage');
|
|
134
|
+
expect(mockMkdirSync).toHaveBeenCalledWith(expect.stringContaining('storage'), {
|
|
135
|
+
recursive: true
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
it('should not recreate the module directory or transport on subsequent calls', ()=>{
|
|
139
|
+
mockIsProduction.mockReturnValue(true);
|
|
140
|
+
mockExistsSync.mockReturnValue(true);
|
|
141
|
+
const { createModuleLogger } = require('./winston.logger.class');
|
|
142
|
+
createModuleLogger('storage');
|
|
143
|
+
const mkdirCallsAfterFirst = mockMkdirSync.mock.calls.length;
|
|
144
|
+
createModuleLogger('storage');
|
|
145
|
+
expect(mockMkdirSync.mock.calls.length).toBe(mkdirCallsAfterFirst);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('structuredFormat (captured printf callback)', ()=>{
|
|
149
|
+
beforeEach(()=>{
|
|
150
|
+
mockIsProduction.mockReturnValue(true);
|
|
151
|
+
capturedPrintf = undefined;
|
|
152
|
+
require('./winston.logger.class');
|
|
153
|
+
});
|
|
154
|
+
it('should capture the printf formatter', ()=>{
|
|
155
|
+
expect(capturedPrintf).toBeInstanceOf(Function);
|
|
156
|
+
});
|
|
157
|
+
it('should default the request id when absent and map known levels', ()=>{
|
|
158
|
+
const out = capturedPrintf({
|
|
159
|
+
timestamp: 'T',
|
|
160
|
+
level: 'error',
|
|
161
|
+
message: 'boom'
|
|
162
|
+
});
|
|
163
|
+
expect(out).toContain('[no-request-id]');
|
|
164
|
+
expect(out).toContain('Error');
|
|
165
|
+
});
|
|
166
|
+
it('should use the provided requestId and title-case unknown levels', ()=>{
|
|
167
|
+
const out = capturedPrintf({
|
|
168
|
+
timestamp: 'T',
|
|
169
|
+
level: 'silly',
|
|
170
|
+
message: 'x',
|
|
171
|
+
requestId: 'req-1'
|
|
172
|
+
});
|
|
173
|
+
expect(out).toContain('[req-1]');
|
|
174
|
+
expect(out).toContain('Silly');
|
|
175
|
+
});
|
|
176
|
+
it('should render context, endpoint, status, duration and user/tenant/company parts', ()=>{
|
|
177
|
+
const out = capturedPrintf({
|
|
178
|
+
timestamp: 'T',
|
|
179
|
+
level: 'info',
|
|
180
|
+
message: 'handled',
|
|
181
|
+
context: 'AuthService',
|
|
182
|
+
method: 'POST',
|
|
183
|
+
path: '/login',
|
|
184
|
+
statusCode: 200,
|
|
185
|
+
duration: '12ms',
|
|
186
|
+
userId: 'u1',
|
|
187
|
+
tenantId: 't1',
|
|
188
|
+
companyId: 'c1'
|
|
189
|
+
});
|
|
190
|
+
expect(out).toContain('Context: AuthService');
|
|
191
|
+
expect(out).toContain('Endpoint: POST /login');
|
|
192
|
+
expect(out).toContain('Status: 200');
|
|
193
|
+
expect(out).toContain('Duration: 12ms');
|
|
194
|
+
expect(out).toContain('UserId: u1');
|
|
195
|
+
expect(out).toContain('TenantId: t1');
|
|
196
|
+
expect(out).toContain('CompanyId: c1');
|
|
197
|
+
expect(out).toContain('handled');
|
|
198
|
+
});
|
|
199
|
+
it('should fall back to `url` for the endpoint line when `path` is absent', ()=>{
|
|
200
|
+
const out = capturedPrintf({
|
|
201
|
+
timestamp: 'T',
|
|
202
|
+
level: 'info',
|
|
203
|
+
message: 'm',
|
|
204
|
+
method: 'GET',
|
|
205
|
+
url: '/legacy'
|
|
206
|
+
});
|
|
207
|
+
expect(out).toContain('Endpoint: GET /legacy');
|
|
208
|
+
});
|
|
209
|
+
it('should omit the endpoint line entirely when method has no path or url', ()=>{
|
|
210
|
+
const out = capturedPrintf({
|
|
211
|
+
timestamp: 'T',
|
|
212
|
+
level: 'info',
|
|
213
|
+
message: 'm',
|
|
214
|
+
method: 'GET'
|
|
215
|
+
});
|
|
216
|
+
expect(out).not.toContain('Endpoint:');
|
|
217
|
+
});
|
|
218
|
+
it('should append remaining metadata as JSON, excluding the `splat` key', ()=>{
|
|
219
|
+
const out = capturedPrintf({
|
|
220
|
+
timestamp: 'T',
|
|
221
|
+
level: 'info',
|
|
222
|
+
message: 'm',
|
|
223
|
+
splat: [
|
|
224
|
+
'ignored'
|
|
225
|
+
],
|
|
226
|
+
orderId: 'o1',
|
|
227
|
+
amount: 50
|
|
228
|
+
});
|
|
229
|
+
expect(out).toContain('Metadata: {"orderId":"o1","amount":50}');
|
|
230
|
+
expect(out).not.toContain('ignored');
|
|
231
|
+
});
|
|
232
|
+
it('should omit the Metadata line when no extra metadata remains', ()=>{
|
|
233
|
+
const out = capturedPrintf({
|
|
234
|
+
timestamp: 'T',
|
|
235
|
+
level: 'info',
|
|
236
|
+
message: 'm',
|
|
237
|
+
splat: []
|
|
238
|
+
});
|
|
239
|
+
expect(out).not.toContain('Metadata:');
|
|
240
|
+
});
|
|
241
|
+
it('should append the stack trace when present', ()=>{
|
|
242
|
+
const out = capturedPrintf({
|
|
243
|
+
timestamp: 'T',
|
|
244
|
+
level: 'error',
|
|
245
|
+
message: 'crashed',
|
|
246
|
+
stack: 'Error: crashed\n at foo.ts:1:1'
|
|
247
|
+
});
|
|
248
|
+
expect(out).toContain('Stack: Error: crashed');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
describe('TenantAwareTransport routing', ()=>{
|
|
252
|
+
it('should route to the default transport when tenant mode is disabled', ()=>{
|
|
253
|
+
mockIsProduction.mockReturnValue(true);
|
|
254
|
+
mockUseTenantMode.mockReturnValue(false);
|
|
255
|
+
const { instance } = require('./winston.logger.class');
|
|
256
|
+
const tenantAwareTransport = instance.transports[0];
|
|
257
|
+
const defaultTransport = tenantAwareTransport.defaultTransport;
|
|
258
|
+
const logSpy = jest.spyOn(defaultTransport, 'log');
|
|
259
|
+
const cb = jest.fn();
|
|
260
|
+
tenantAwareTransport.log({
|
|
261
|
+
level: 'info',
|
|
262
|
+
message: 'x',
|
|
263
|
+
tenantId: 't1'
|
|
264
|
+
}, cb);
|
|
265
|
+
expect(logSpy).toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
it('should route to the default transport when tenant mode is enabled but no tenantId is present', ()=>{
|
|
268
|
+
mockIsProduction.mockReturnValue(true);
|
|
269
|
+
mockUseTenantMode.mockReturnValue(true);
|
|
270
|
+
const { instance } = require('./winston.logger.class');
|
|
271
|
+
const tenantAwareTransport = instance.transports[0];
|
|
272
|
+
const defaultTransport = tenantAwareTransport.defaultTransport;
|
|
273
|
+
const logSpy = jest.spyOn(defaultTransport, 'log');
|
|
274
|
+
const cb = jest.fn();
|
|
275
|
+
tenantAwareTransport.log({
|
|
276
|
+
level: 'info',
|
|
277
|
+
message: 'x'
|
|
278
|
+
}, cb);
|
|
279
|
+
expect(logSpy).toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
it('should route to a tenant-specific transport when tenant mode is enabled and tenantId is present', ()=>{
|
|
282
|
+
mockIsProduction.mockReturnValue(true);
|
|
283
|
+
mockUseTenantMode.mockReturnValue(true);
|
|
284
|
+
mockExistsSync.mockReturnValue(true);
|
|
285
|
+
const { instance } = require('./winston.logger.class');
|
|
286
|
+
const tenantAwareTransport = instance.transports[0];
|
|
287
|
+
const defaultTransport = tenantAwareTransport.defaultTransport;
|
|
288
|
+
const defaultLogSpy = jest.spyOn(defaultTransport, 'log');
|
|
289
|
+
const cb = jest.fn();
|
|
290
|
+
tenantAwareTransport.log({
|
|
291
|
+
level: 'info',
|
|
292
|
+
message: 'x',
|
|
293
|
+
tenantId: 'tenant-a'
|
|
294
|
+
}, cb);
|
|
295
|
+
expect(defaultLogSpy).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
it('should read tenantId from nested `metadata.tenantId` when top-level tenantId is absent', ()=>{
|
|
298
|
+
mockIsProduction.mockReturnValue(true);
|
|
299
|
+
mockUseTenantMode.mockReturnValue(true);
|
|
300
|
+
mockExistsSync.mockReturnValue(true);
|
|
301
|
+
const { instance } = require('./winston.logger.class');
|
|
302
|
+
const tenantAwareTransport = instance.transports[0];
|
|
303
|
+
const defaultTransport = tenantAwareTransport.defaultTransport;
|
|
304
|
+
const defaultLogSpy = jest.spyOn(defaultTransport, 'log');
|
|
305
|
+
const cb = jest.fn();
|
|
306
|
+
tenantAwareTransport.log({
|
|
307
|
+
level: 'info',
|
|
308
|
+
message: 'x',
|
|
309
|
+
metadata: {
|
|
310
|
+
tenantId: 'tenant-b'
|
|
311
|
+
}
|
|
312
|
+
}, cb);
|
|
313
|
+
expect(defaultLogSpy).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
it('should re-emit "logged" so winston can track transport completion', (done)=>{
|
|
316
|
+
mockIsProduction.mockReturnValue(true);
|
|
317
|
+
mockUseTenantMode.mockReturnValue(false);
|
|
318
|
+
const { instance } = require('./winston.logger.class');
|
|
319
|
+
const tenantAwareTransport = instance.transports[0];
|
|
320
|
+
tenantAwareTransport.once('logged', (info)=>{
|
|
321
|
+
expect(info.message).toBe('done-check');
|
|
322
|
+
done();
|
|
323
|
+
});
|
|
324
|
+
tenantAwareTransport.log({
|
|
325
|
+
level: 'info',
|
|
326
|
+
message: 'done-check'
|
|
327
|
+
}, jest.fn());
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
});
|