@flusys/nestjs-shared 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 (63) hide show
  1. package/cjs/classes/api-controller.class.spec.js +684 -0
  2. package/cjs/classes/api-service.class.spec.js +809 -0
  3. package/cjs/classes/handle-custome-db.spec.js +82 -0
  4. package/cjs/classes/hybrid-cache.class.spec.js +168 -0
  5. package/cjs/classes/winston.logger.class.spec.js +330 -0
  6. package/cjs/decorators/api-response.decorator.spec.js +126 -0
  7. package/cjs/decorators/current-user.decorator.spec.js +74 -0
  8. package/cjs/decorators/log-action.decorator.spec.js +353 -0
  9. package/cjs/decorators/public.decorator.spec.js +50 -0
  10. package/cjs/decorators/require-permission.decorator.spec.js +147 -0
  11. package/cjs/exceptions/base-app.exception.spec.js +157 -0
  12. package/cjs/exceptions/permission.exception.spec.js +82 -0
  13. package/cjs/filters/global-exception.filter.spec.js +440 -0
  14. package/cjs/guards/jwt-auth.guard.spec.js +75 -0
  15. package/cjs/guards/optional-jwt-auth.guard.spec.js +55 -0
  16. package/cjs/guards/permission.guard.spec.js +358 -0
  17. package/cjs/interceptors/delete-empty-id-from-body.interceptor.spec.js +107 -0
  18. package/cjs/interceptors/idempotency.interceptor.spec.js +116 -0
  19. package/cjs/interceptors/response-meta.interceptor.spec.js +89 -0
  20. package/cjs/interceptors/set-user-field-on-body.interceptor.spec.js +128 -0
  21. package/cjs/interceptors/slug.interceptor.spec.js +110 -0
  22. package/cjs/middlewares/logger.middleware.spec.js +462 -0
  23. package/cjs/modules/cache/cache.module.spec.js +48 -0
  24. package/cjs/modules/datasource/multi-tenant-datasource.service.spec.js +381 -0
  25. package/cjs/modules/utils/utils.service.spec.js +169 -0
  26. package/cjs/utils/date-time.util.spec.js +84 -0
  27. package/cjs/utils/html-sanitizer.util.spec.js +76 -0
  28. package/cjs/utils/permission-cache-key.util.spec.js +88 -0
  29. package/cjs/utils/query-helpers.util.spec.js +209 -0
  30. package/cjs/utils/request.util.spec.js +249 -0
  31. package/cjs/utils/string.util.spec.js +63 -0
  32. package/fesm/classes/api-controller.class.spec.js +680 -0
  33. package/fesm/classes/api-service.class.spec.js +805 -0
  34. package/fesm/classes/handle-custome-db.spec.js +78 -0
  35. package/fesm/classes/hybrid-cache.class.spec.js +164 -0
  36. package/fesm/classes/winston.logger.class.spec.js +327 -0
  37. package/fesm/decorators/api-response.decorator.spec.js +122 -0
  38. package/fesm/decorators/current-user.decorator.spec.js +73 -0
  39. package/fesm/decorators/log-action.decorator.spec.js +349 -0
  40. package/fesm/decorators/public.decorator.spec.js +46 -0
  41. package/fesm/decorators/require-permission.decorator.spec.js +143 -0
  42. package/fesm/exceptions/base-app.exception.spec.js +153 -0
  43. package/fesm/exceptions/permission.exception.spec.js +78 -0
  44. package/fesm/filters/global-exception.filter.spec.js +436 -0
  45. package/fesm/guards/jwt-auth.guard.spec.js +71 -0
  46. package/fesm/guards/optional-jwt-auth.guard.spec.js +51 -0
  47. package/fesm/guards/permission.guard.spec.js +354 -0
  48. package/fesm/interceptors/delete-empty-id-from-body.interceptor.spec.js +103 -0
  49. package/fesm/interceptors/idempotency.interceptor.spec.js +112 -0
  50. package/fesm/interceptors/response-meta.interceptor.spec.js +85 -0
  51. package/fesm/interceptors/set-user-field-on-body.interceptor.spec.js +124 -0
  52. package/fesm/interceptors/slug.interceptor.spec.js +106 -0
  53. package/fesm/middlewares/logger.middleware.spec.js +459 -0
  54. package/fesm/modules/cache/cache.module.spec.js +44 -0
  55. package/fesm/modules/datasource/multi-tenant-datasource.service.spec.js +377 -0
  56. package/fesm/modules/utils/utils.service.spec.js +165 -0
  57. package/fesm/utils/date-time.util.spec.js +80 -0
  58. package/fesm/utils/html-sanitizer.util.spec.js +72 -0
  59. package/fesm/utils/permission-cache-key.util.spec.js +84 -0
  60. package/fesm/utils/query-helpers.util.spec.js +205 -0
  61. package/fesm/utils/request.util.spec.js +245 -0
  62. package/fesm/utils/string.util.spec.js +59 -0
  63. package/package.json +2 -2
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ const _rxjs = require("rxjs");
6
+ const _setuserfieldonbodyinterceptor = require("./set-user-field-on-body.interceptor");
7
+ function buildContext(body, user) {
8
+ const request = {
9
+ body,
10
+ user
11
+ };
12
+ const context = {
13
+ switchToHttp: ()=>({
14
+ getRequest: ()=>request
15
+ })
16
+ };
17
+ return {
18
+ context,
19
+ request
20
+ };
21
+ }
22
+ function buildCallHandler() {
23
+ return {
24
+ handle: jest.fn().mockReturnValue((0, _rxjs.of)('next-result'))
25
+ };
26
+ }
27
+ describe('createSetUserFieldInterceptor', ()=>{
28
+ it('should set the field on a single object body from the authenticated user', ()=>{
29
+ const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('createdById');
30
+ const interceptor = new Interceptor();
31
+ const { context, request } = buildContext({
32
+ name: 'Product'
33
+ }, {
34
+ id: 'user-1'
35
+ });
36
+ interceptor.intercept(context, buildCallHandler());
37
+ expect(request.body).toEqual({
38
+ name: 'Product',
39
+ createdById: 'user-1'
40
+ });
41
+ });
42
+ it('should set the field on every item of an array body', ()=>{
43
+ const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('updatedById');
44
+ const interceptor = new Interceptor();
45
+ const { context, request } = buildContext([
46
+ {
47
+ name: 'A'
48
+ },
49
+ {
50
+ name: 'B'
51
+ }
52
+ ], {
53
+ id: 'user-2'
54
+ });
55
+ interceptor.intercept(context, buildCallHandler());
56
+ expect(request.body).toEqual([
57
+ {
58
+ name: 'A',
59
+ updatedById: 'user-2'
60
+ },
61
+ {
62
+ name: 'B',
63
+ updatedById: 'user-2'
64
+ }
65
+ ]);
66
+ });
67
+ it('should not modify the body when there is no authenticated user', ()=>{
68
+ const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('deletedById');
69
+ const interceptor = new Interceptor();
70
+ const { context, request } = buildContext({
71
+ name: 'Product'
72
+ }, undefined);
73
+ interceptor.intercept(context, buildCallHandler());
74
+ expect(request.body).toEqual({
75
+ name: 'Product'
76
+ });
77
+ });
78
+ it('should not throw when body is null and there is no user', ()=>{
79
+ const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('createdById');
80
+ const interceptor = new Interceptor();
81
+ const { context, request } = buildContext(null, undefined);
82
+ expect(()=>interceptor.intercept(context, buildCallHandler())).not.toThrow();
83
+ expect(request.body).toBeNull();
84
+ });
85
+ it('should return next.handle() observable', (done)=>{
86
+ const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('createdById');
87
+ const interceptor = new Interceptor();
88
+ const { context } = buildContext({}, {
89
+ id: 'user-1'
90
+ });
91
+ interceptor.intercept(context, buildCallHandler()).subscribe((value)=>{
92
+ expect(value).toBe('next-result');
93
+ done();
94
+ });
95
+ });
96
+ });
97
+ describe('preconfigured user-field interceptors', ()=>{
98
+ it('should set createdById via SetCreatedByOnBody', ()=>{
99
+ const interceptor = new _setuserfieldonbodyinterceptor.SetCreatedByOnBody();
100
+ const { context, request } = buildContext({}, {
101
+ id: 'user-1'
102
+ });
103
+ interceptor.intercept(context, buildCallHandler());
104
+ expect(request.body).toEqual({
105
+ createdById: 'user-1'
106
+ });
107
+ });
108
+ it('should set updatedById via SetUpdateByOnBody', ()=>{
109
+ const interceptor = new _setuserfieldonbodyinterceptor.SetUpdateByOnBody();
110
+ const { context, request } = buildContext({}, {
111
+ id: 'user-1'
112
+ });
113
+ interceptor.intercept(context, buildCallHandler());
114
+ expect(request.body).toEqual({
115
+ updatedById: 'user-1'
116
+ });
117
+ });
118
+ it('should set deletedById via SetDeletedByOnBody', ()=>{
119
+ const interceptor = new _setuserfieldonbodyinterceptor.SetDeletedByOnBody();
120
+ const { context, request } = buildContext({}, {
121
+ id: 'user-1'
122
+ });
123
+ interceptor.intercept(context, buildCallHandler());
124
+ expect(request.body).toEqual({
125
+ deletedById: 'user-1'
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ const _rxjs = require("rxjs");
6
+ const _sluginterceptor = require("./slug.interceptor");
7
+ function buildContext(body) {
8
+ const request = {
9
+ body
10
+ };
11
+ const context = {
12
+ switchToHttp: ()=>({
13
+ getRequest: ()=>request
14
+ })
15
+ };
16
+ return {
17
+ context,
18
+ request
19
+ };
20
+ }
21
+ function buildCallHandler() {
22
+ return {
23
+ handle: jest.fn().mockReturnValue((0, _rxjs.of)('next-result'))
24
+ };
25
+ }
26
+ describe('Slug interceptor', ()=>{
27
+ let utilsService;
28
+ let interceptor;
29
+ beforeEach(()=>{
30
+ utilsService = {
31
+ transformToSlug: jest.fn((value)=>value.toLowerCase().replace(/\s+/g, '-'))
32
+ };
33
+ interceptor = new _sluginterceptor.Slug(utilsService);
34
+ });
35
+ it('should generate a slug from name on a single object body without one', ()=>{
36
+ const { context, request } = buildContext({
37
+ name: 'My Product'
38
+ });
39
+ interceptor.intercept(context, buildCallHandler());
40
+ expect(utilsService.transformToSlug).toHaveBeenCalledWith('My Product');
41
+ expect(request.body).toEqual({
42
+ name: 'My Product',
43
+ slug: 'my-product'
44
+ });
45
+ });
46
+ it('should not overwrite an existing slug on a single object body', ()=>{
47
+ const { context, request } = buildContext({
48
+ name: 'My Product',
49
+ slug: 'custom-slug'
50
+ });
51
+ interceptor.intercept(context, buildCallHandler());
52
+ expect(utilsService.transformToSlug).not.toHaveBeenCalled();
53
+ expect(request.body).toEqual({
54
+ name: 'My Product',
55
+ slug: 'custom-slug'
56
+ });
57
+ });
58
+ it('should not generate a slug when there is no name', ()=>{
59
+ const { context, request } = buildContext({
60
+ description: 'no name here'
61
+ });
62
+ interceptor.intercept(context, buildCallHandler());
63
+ expect(utilsService.transformToSlug).not.toHaveBeenCalled();
64
+ expect(request.body).toEqual({
65
+ description: 'no name here'
66
+ });
67
+ });
68
+ it('should generate slugs for each array item missing one', ()=>{
69
+ const { context, request } = buildContext([
70
+ {
71
+ name: 'Item One'
72
+ },
73
+ {
74
+ name: 'Item Two',
75
+ slug: 'existing'
76
+ },
77
+ {
78
+ description: 'no name'
79
+ }
80
+ ]);
81
+ interceptor.intercept(context, buildCallHandler());
82
+ expect(request.body).toEqual([
83
+ {
84
+ name: 'Item One',
85
+ slug: 'item-one'
86
+ },
87
+ {
88
+ name: 'Item Two',
89
+ slug: 'existing'
90
+ },
91
+ {
92
+ description: 'no name'
93
+ }
94
+ ]);
95
+ });
96
+ it('should not throw when body is undefined', ()=>{
97
+ const { context, request } = buildContext(undefined);
98
+ expect(()=>interceptor.intercept(context, buildCallHandler())).not.toThrow();
99
+ expect(request.body).toBeUndefined();
100
+ });
101
+ it('should return next.handle() observable', (done)=>{
102
+ const { context } = buildContext({
103
+ name: 'X'
104
+ });
105
+ interceptor.intercept(context, buildCallHandler()).subscribe((value)=>{
106
+ expect(value).toBe('next-result');
107
+ done();
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,462 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ const mockGetLogConfig = jest.fn();
6
+ jest.mock('@flusys/nestjs-core/config', ()=>({
7
+ envConfig: {
8
+ getLogConfig: (...args)=>mockGetLogConfig(...args)
9
+ }
10
+ }));
11
+ const mockLoggerInfo = jest.fn();
12
+ const mockLoggerWarn = jest.fn();
13
+ const mockLoggerError = jest.fn();
14
+ const mockLoggerLog = jest.fn();
15
+ jest.mock('../classes/winston.logger.class', ()=>({
16
+ instance: {
17
+ info: (...args)=>mockLoggerInfo(...args),
18
+ warn: (...args)=>mockLoggerWarn(...args),
19
+ error: (...args)=>mockLoggerError(...args),
20
+ log: (...args)=>mockLoggerLog(...args)
21
+ }
22
+ }));
23
+ let uuidCounter = 0;
24
+ jest.mock('uuid', ()=>({
25
+ v4: ()=>`generated-uuid-${++uuidCounter}`
26
+ }));
27
+ function loadMiddleware(logConfig) {
28
+ jest.resetModules();
29
+ jest.clearAllMocks();
30
+ uuidCounter = 0;
31
+ mockGetLogConfig.mockReturnValue({
32
+ dir: 'logs',
33
+ level: 'info',
34
+ maxSize: '20m',
35
+ maxFiles: '14d',
36
+ disableHttpLogging: false,
37
+ ...logConfig
38
+ });
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ return require('./logger.middleware');
41
+ }
42
+ function makeReqRes(overrides = {}) {
43
+ const req = {
44
+ headers: {},
45
+ method: 'GET',
46
+ originalUrl: '/api/users',
47
+ path: '/api/users',
48
+ query: {},
49
+ body: undefined,
50
+ socket: {
51
+ remoteAddress: '10.0.0.1'
52
+ },
53
+ ...overrides
54
+ };
55
+ const res = {
56
+ statusCode: 200,
57
+ statusMessage: 'OK',
58
+ setHeader: jest.fn(),
59
+ getHeader: jest.fn().mockReturnValue(undefined),
60
+ send: jest.fn(function(body) {
61
+ return body;
62
+ }),
63
+ json: jest.fn(function(body) {
64
+ return body;
65
+ }),
66
+ end: jest.fn(function() {
67
+ return this;
68
+ }),
69
+ on: jest.fn()
70
+ };
71
+ return {
72
+ req,
73
+ res
74
+ };
75
+ }
76
+ describe('LoggerMiddleware', ()=>{
77
+ describe('request correlation', ()=>{
78
+ it('should generate a request id when none is provided and echo it back on the response', ()=>{
79
+ const { LoggerMiddleware } = loadMiddleware({});
80
+ const middleware = new LoggerMiddleware();
81
+ const { req, res } = makeReqRes();
82
+ const next = jest.fn();
83
+ middleware.use(req, res, next);
84
+ expect(res.setHeader).toHaveBeenCalledWith('x-request-id', 'generated-uuid-1');
85
+ expect(next).toHaveBeenCalledTimes(1);
86
+ });
87
+ it('should reuse an incoming x-request-id header instead of generating one', ()=>{
88
+ const { LoggerMiddleware } = loadMiddleware({});
89
+ const middleware = new LoggerMiddleware();
90
+ const { req, res } = makeReqRes({
91
+ headers: {
92
+ 'x-request-id': 'incoming-id'
93
+ }
94
+ });
95
+ const next = jest.fn();
96
+ middleware.use(req, res, next);
97
+ expect(res.setHeader).toHaveBeenCalledWith('x-request-id', 'incoming-id');
98
+ });
99
+ it('should expose requestId/tenantId via context accessors inside the request scope', ()=>{
100
+ const mod = loadMiddleware({});
101
+ const middleware = new mod.LoggerMiddleware();
102
+ const { req, res } = makeReqRes({
103
+ headers: {
104
+ 'x-request-id': 'req-1',
105
+ 'x-tenant-id': 'tenant-1'
106
+ }
107
+ });
108
+ let seenDuringRequest;
109
+ const next = jest.fn(()=>{
110
+ seenDuringRequest = {
111
+ requestId: mod.getRequestId(),
112
+ tenantId: mod.getTenantId()
113
+ };
114
+ });
115
+ middleware.use(req, res, next);
116
+ expect(seenDuringRequest).toEqual({
117
+ requestId: 'req-1',
118
+ tenantId: 'tenant-1'
119
+ });
120
+ // outside the request scope the store is gone
121
+ expect(mod.getRequestId()).toBeUndefined();
122
+ expect(mod.getTenantId()).toBeUndefined();
123
+ });
124
+ it('should leave tenantId undefined when no x-tenant-id header is present', ()=>{
125
+ const mod = loadMiddleware({});
126
+ const middleware = new mod.LoggerMiddleware();
127
+ const { req, res } = makeReqRes();
128
+ let tenantIdSeen;
129
+ const next = jest.fn(()=>{
130
+ tenantIdSeen = mod.getTenantId();
131
+ });
132
+ middleware.use(req, res, next);
133
+ expect(tenantIdSeen).toBeUndefined();
134
+ });
135
+ it('getUserId/getCompanyId should return undefined when not explicitly set on the context', ()=>{
136
+ const mod = loadMiddleware({});
137
+ const middleware = new mod.LoggerMiddleware();
138
+ const { req, res } = makeReqRes();
139
+ let seen;
140
+ const next = jest.fn(()=>{
141
+ seen = {
142
+ userId: mod.getUserId(),
143
+ companyId: mod.getCompanyId()
144
+ };
145
+ });
146
+ middleware.use(req, res, next);
147
+ expect(seen).toEqual({
148
+ userId: undefined,
149
+ companyId: undefined
150
+ });
151
+ });
152
+ });
153
+ describe('excluded / disabled logging', ()=>{
154
+ it.each([
155
+ '/health',
156
+ '/metrics',
157
+ '/favicon.ico'
158
+ ])('should skip request logging for excluded path %s', (path)=>{
159
+ const { LoggerMiddleware } = loadMiddleware({});
160
+ const middleware = new LoggerMiddleware();
161
+ const { req, res } = makeReqRes({
162
+ originalUrl: path,
163
+ path
164
+ });
165
+ const next = jest.fn();
166
+ middleware.use(req, res, next);
167
+ expect(mockLoggerInfo).not.toHaveBeenCalled();
168
+ expect(next).toHaveBeenCalled();
169
+ });
170
+ it('should skip request logging entirely when DISABLE_HTTP_LOGGING is true', ()=>{
171
+ const { LoggerMiddleware } = loadMiddleware({
172
+ disableHttpLogging: true
173
+ });
174
+ const middleware = new LoggerMiddleware();
175
+ const { req, res } = makeReqRes();
176
+ const next = jest.fn();
177
+ middleware.use(req, res, next);
178
+ expect(mockLoggerInfo).not.toHaveBeenCalled();
179
+ expect(next).toHaveBeenCalled();
180
+ });
181
+ it('should still set up request correlation even when logging is disabled', ()=>{
182
+ const mod = loadMiddleware({
183
+ disableHttpLogging: true
184
+ });
185
+ const middleware = new mod.LoggerMiddleware();
186
+ const { req, res } = makeReqRes({
187
+ headers: {
188
+ 'x-request-id': 'req-1'
189
+ }
190
+ });
191
+ let seen;
192
+ const next = jest.fn(()=>{
193
+ seen = mod.getRequestId();
194
+ });
195
+ middleware.use(req, res, next);
196
+ expect(seen).toBe('req-1');
197
+ });
198
+ });
199
+ describe('request logging', ()=>{
200
+ it('should log a descriptive start message with method/url/ip', ()=>{
201
+ const { LoggerMiddleware } = loadMiddleware({});
202
+ const middleware = new LoggerMiddleware();
203
+ const { req, res } = makeReqRes({
204
+ method: 'POST',
205
+ originalUrl: '/api/orders'
206
+ });
207
+ middleware.use(req, res, jest.fn());
208
+ expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Started processing request "POST" "/api/orders" from "10.0.0.1"'), expect.objectContaining({
209
+ context: 'HTTP',
210
+ method: 'POST',
211
+ url: '/api/orders'
212
+ }));
213
+ });
214
+ it('should prefer x-forwarded-for over the socket remote address', ()=>{
215
+ const { LoggerMiddleware } = loadMiddleware({});
216
+ const middleware = new LoggerMiddleware();
217
+ const { req, res } = makeReqRes({
218
+ headers: {
219
+ 'x-forwarded-for': '203.0.113.5, 10.0.0.1'
220
+ }
221
+ });
222
+ middleware.use(req, res, jest.fn());
223
+ expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('from "203.0.113.5"'), expect.anything());
224
+ });
225
+ it('should fall back to "unknown" ip when neither header nor socket is available', ()=>{
226
+ const { LoggerMiddleware } = loadMiddleware({});
227
+ const middleware = new LoggerMiddleware();
228
+ const { req, res } = makeReqRes({
229
+ socket: undefined
230
+ });
231
+ middleware.use(req, res, jest.fn());
232
+ expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('from "unknown"'), expect.anything());
233
+ });
234
+ it('should include the query object only when it has keys', ()=>{
235
+ const { LoggerMiddleware } = loadMiddleware({});
236
+ const middleware = new LoggerMiddleware();
237
+ const { req, res } = makeReqRes({
238
+ query: {
239
+ page: '1'
240
+ }
241
+ });
242
+ middleware.use(req, res, jest.fn());
243
+ expect(mockLoggerInfo).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
244
+ query: {
245
+ page: '1'
246
+ }
247
+ }));
248
+ });
249
+ it('should omit the query field entirely for an empty query object', ()=>{
250
+ const { LoggerMiddleware } = loadMiddleware({});
251
+ const middleware = new LoggerMiddleware();
252
+ const { req, res } = makeReqRes({
253
+ query: {}
254
+ });
255
+ middleware.use(req, res, jest.fn());
256
+ const logData = mockLoggerInfo.mock.calls[0][1];
257
+ expect(logData.query).toBeUndefined();
258
+ });
259
+ it('should not include a body summary when not in debug mode', ()=>{
260
+ const { LoggerMiddleware } = loadMiddleware({
261
+ level: 'info'
262
+ });
263
+ const middleware = new LoggerMiddleware();
264
+ const { req, res } = makeReqRes({
265
+ body: {
266
+ pagination: {
267
+ currentPage: 2
268
+ }
269
+ }
270
+ });
271
+ middleware.use(req, res, jest.fn());
272
+ const logData = mockLoggerInfo.mock.calls[0][1];
273
+ expect(logData.body).toBeUndefined();
274
+ });
275
+ it('should include a summarized body when in debug mode', ()=>{
276
+ const { LoggerMiddleware } = loadMiddleware({
277
+ level: 'debug'
278
+ });
279
+ const middleware = new LoggerMiddleware();
280
+ const { req, res } = makeReqRes({
281
+ body: {
282
+ pagination: {
283
+ currentPage: 2,
284
+ pageSize: 10
285
+ },
286
+ filter: {
287
+ active: true
288
+ },
289
+ id: 'x-1'
290
+ }
291
+ });
292
+ middleware.use(req, res, jest.fn());
293
+ const logData = mockLoggerInfo.mock.calls[0][1];
294
+ expect(JSON.parse(logData.body)).toEqual({
295
+ page: 2,
296
+ pageSize: 10,
297
+ filterKeys: [
298
+ 'active'
299
+ ],
300
+ id: 'x-1'
301
+ });
302
+ });
303
+ it('should truncate an overly long non-object body in debug mode', ()=>{
304
+ const { LoggerMiddleware } = loadMiddleware({
305
+ level: 'debug'
306
+ });
307
+ const middleware = new LoggerMiddleware();
308
+ const longBody = 'x'.repeat(1500);
309
+ const { req, res } = makeReqRes({
310
+ body: longBody
311
+ });
312
+ middleware.use(req, res, jest.fn());
313
+ const logData = mockLoggerInfo.mock.calls[0][1];
314
+ expect(logData.body.endsWith('...')).toBe(true);
315
+ expect(logData.body.length).toBe(1003);
316
+ });
317
+ });
318
+ describe('response logging', ()=>{
319
+ it('should log once even when send/json/end are all invoked for the same response', ()=>{
320
+ const { LoggerMiddleware } = loadMiddleware({});
321
+ const middleware = new LoggerMiddleware();
322
+ const { req, res } = makeReqRes();
323
+ middleware.use(req, res, jest.fn());
324
+ mockLoggerLog.mockClear();
325
+ res.json({
326
+ success: true
327
+ });
328
+ res.send({
329
+ success: true
330
+ });
331
+ res.end();
332
+ expect(mockLoggerLog).toHaveBeenCalledTimes(1);
333
+ });
334
+ it('should log at "info" for 2xx responses', ()=>{
335
+ const { LoggerMiddleware } = loadMiddleware({});
336
+ const middleware = new LoggerMiddleware();
337
+ const { req, res } = makeReqRes();
338
+ res.statusCode = 200;
339
+ middleware.use(req, res, jest.fn());
340
+ mockLoggerLog.mockClear();
341
+ res.json({
342
+ success: true
343
+ });
344
+ expect(mockLoggerLog).toHaveBeenCalledWith('info', expect.any(String), expect.any(Object));
345
+ });
346
+ it('should log at "warn" for 4xx responses and include a response body summary', ()=>{
347
+ const { LoggerMiddleware } = loadMiddleware({});
348
+ const middleware = new LoggerMiddleware();
349
+ const { req, res } = makeReqRes();
350
+ res.statusCode = 404;
351
+ middleware.use(req, res, jest.fn());
352
+ mockLoggerLog.mockClear();
353
+ res.json({
354
+ success: false,
355
+ message: 'Not found'
356
+ });
357
+ expect(mockLoggerLog).toHaveBeenCalledWith('warn', expect.any(String), expect.objectContaining({
358
+ responseBody: expect.stringContaining('Not found')
359
+ }));
360
+ });
361
+ it('should log at "error" for 5xx responses', ()=>{
362
+ const { LoggerMiddleware } = loadMiddleware({});
363
+ const middleware = new LoggerMiddleware();
364
+ const { req, res } = makeReqRes();
365
+ res.statusCode = 500;
366
+ middleware.use(req, res, jest.fn());
367
+ mockLoggerLog.mockClear();
368
+ res.json({
369
+ success: false
370
+ });
371
+ expect(mockLoggerLog).toHaveBeenCalledWith('error', expect.any(String), expect.any(Object));
372
+ });
373
+ it('should not include a response body summary for successful non-debug responses', ()=>{
374
+ const { LoggerMiddleware } = loadMiddleware({
375
+ level: 'info'
376
+ });
377
+ const middleware = new LoggerMiddleware();
378
+ const { req, res } = makeReqRes();
379
+ res.statusCode = 200;
380
+ middleware.use(req, res, jest.fn());
381
+ mockLoggerLog.mockClear();
382
+ res.json({
383
+ success: true
384
+ });
385
+ const logData = mockLoggerLog.mock.calls[0][2];
386
+ expect(logData.responseBody).toBeUndefined();
387
+ });
388
+ it('should include a response body summary for successful responses in debug mode', ()=>{
389
+ const { LoggerMiddleware } = loadMiddleware({
390
+ level: 'debug'
391
+ });
392
+ const middleware = new LoggerMiddleware();
393
+ const { req, res } = makeReqRes();
394
+ res.statusCode = 200;
395
+ middleware.use(req, res, jest.fn());
396
+ mockLoggerLog.mockClear();
397
+ res.json({
398
+ success: true,
399
+ data: [
400
+ 1,
401
+ 2,
402
+ 3
403
+ ]
404
+ });
405
+ const logData = mockLoggerLog.mock.calls[0][2];
406
+ expect(JSON.parse(logData.responseBody)).toEqual({
407
+ success: true,
408
+ dataCount: 3
409
+ });
410
+ });
411
+ it('should warn about slow requests over the 3s threshold on successful responses', ()=>{
412
+ const { LoggerMiddleware } = loadMiddleware({});
413
+ const middleware = new LoggerMiddleware();
414
+ const { req, res } = makeReqRes();
415
+ res.statusCode = 200;
416
+ const realNow = Date.now;
417
+ let callCount = 0;
418
+ jest.spyOn(Date, 'now').mockImplementation(()=>{
419
+ callCount += 1;
420
+ // first call = startTime, subsequent calls happen after the "slow" work
421
+ return callCount === 1 ? 1_000 : 5_000;
422
+ });
423
+ middleware.use(req, res, jest.fn());
424
+ res.json({
425
+ success: true
426
+ });
427
+ expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('Slow request detected'), expect.objectContaining({
428
+ threshold: '3000ms'
429
+ }));
430
+ Date.now.mockRestore?.() ?? (Date.now = realNow);
431
+ });
432
+ it('should not warn about slow requests when the response was an error', ()=>{
433
+ const { LoggerMiddleware } = loadMiddleware({});
434
+ const middleware = new LoggerMiddleware();
435
+ const { req, res } = makeReqRes();
436
+ res.statusCode = 500;
437
+ let callCount = 0;
438
+ jest.spyOn(Date, 'now').mockImplementation(()=>{
439
+ callCount += 1;
440
+ return callCount === 1 ? 1_000 : 5_000;
441
+ });
442
+ middleware.use(req, res, jest.fn());
443
+ res.json({
444
+ success: false
445
+ });
446
+ expect(mockLoggerWarn).not.toHaveBeenCalled();
447
+ Date.now.mockRestore();
448
+ });
449
+ it('should log response errors emitted on the res "error" event', ()=>{
450
+ const { LoggerMiddleware } = loadMiddleware({});
451
+ const middleware = new LoggerMiddleware();
452
+ const { req, res } = makeReqRes();
453
+ middleware.use(req, res, jest.fn());
454
+ const errorHandler = res.on.mock.calls.find(([event])=>event === 'error')?.[1];
455
+ expect(errorHandler).toBeDefined();
456
+ errorHandler(new Error('stream broke'));
457
+ expect(mockLoggerError).toHaveBeenCalledWith('Response error', expect.objectContaining({
458
+ error: 'stream broke'
459
+ }));
460
+ });
461
+ });
462
+ });