@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.
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,354 @@
1
+ import { UnauthorizedException } from '@nestjs/common';
2
+ import { Reflector } from '@nestjs/core';
3
+ import { AUTH_MESSAGES, IS_PUBLIC_KEY, PERMISSIONS_KEY } from '../constants';
4
+ import { InsufficientPermissionsException, NoPermissionsFoundException, PermissionSystemUnavailableException } from '../exceptions/permission.exception';
5
+ import { PermissionGuard } from './permission.guard';
6
+ function buildContext(request) {
7
+ return {
8
+ getHandler: ()=>({}),
9
+ getClass: ()=>({}),
10
+ switchToHttp: ()=>({
11
+ getRequest: ()=>request
12
+ })
13
+ };
14
+ }
15
+ describe('PermissionGuard', ()=>{
16
+ let reflector;
17
+ let cache;
18
+ let getAllAndOverrideMock;
19
+ beforeEach(()=>{
20
+ reflector = new Reflector();
21
+ cache = {
22
+ get: jest.fn(),
23
+ set: jest.fn(),
24
+ del: jest.fn()
25
+ };
26
+ getAllAndOverrideMock = jest.fn();
27
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation(getAllAndOverrideMock);
28
+ });
29
+ function mockMetadata(isPublic, permissionConfig) {
30
+ getAllAndOverrideMock.mockImplementation((key)=>{
31
+ if (key === IS_PUBLIC_KEY) return isPublic;
32
+ if (key === PERMISSIONS_KEY) return permissionConfig;
33
+ return undefined;
34
+ });
35
+ }
36
+ describe('public routes and missing permission metadata', ()=>{
37
+ it('should allow access when the route is public', async ()=>{
38
+ mockMetadata(true, undefined);
39
+ const guard = new PermissionGuard(reflector, cache);
40
+ await expect(guard.canActivate(buildContext({}))).resolves.toBe(true);
41
+ });
42
+ it('should allow access when no permission metadata is set', async ()=>{
43
+ mockMetadata(false, undefined);
44
+ const guard = new PermissionGuard(reflector, cache);
45
+ await expect(guard.canActivate(buildContext({
46
+ user: {
47
+ id: 'u1'
48
+ }
49
+ }))).resolves.toBe(true);
50
+ });
51
+ });
52
+ describe('authentication requirement', ()=>{
53
+ it('should throw UnauthorizedException when there is no user on the request', async ()=>{
54
+ mockMetadata(false, 'user.read');
55
+ const guard = new PermissionGuard(reflector, cache);
56
+ await expect(guard.canActivate(buildContext({}))).rejects.toMatchObject({
57
+ constructor: UnauthorizedException
58
+ });
59
+ try {
60
+ await guard.canActivate(buildContext({}));
61
+ } catch (error) {
62
+ expect(error.getResponse()).toMatchObject({
63
+ messageKey: AUTH_MESSAGES.TOKEN_REQUIRED
64
+ });
65
+ }
66
+ });
67
+ });
68
+ describe('cache availability', ()=>{
69
+ it('should throw PermissionSystemUnavailableException when no cache is injected', async ()=>{
70
+ mockMetadata(false, 'user.read');
71
+ const guard = new PermissionGuard(reflector, undefined);
72
+ await expect(guard.canActivate(buildContext({
73
+ user: {
74
+ id: 'u1'
75
+ }
76
+ }))).rejects.toBeInstanceOf(PermissionSystemUnavailableException);
77
+ });
78
+ });
79
+ describe('permission resolution', ()=>{
80
+ it('should throw NoPermissionsFoundException when the user has no cached permissions', async ()=>{
81
+ mockMetadata(false, 'user.read');
82
+ cache.get.mockResolvedValue(undefined);
83
+ const guard = new PermissionGuard(reflector, cache);
84
+ await expect(guard.canActivate(buildContext({
85
+ user: {
86
+ id: 'u1'
87
+ }
88
+ }))).rejects.toBeInstanceOf(NoPermissionsFoundException);
89
+ });
90
+ it('should throw NoPermissionsFoundException when the cached permission array is empty', async ()=>{
91
+ mockMetadata(false, 'user.read');
92
+ cache.get.mockResolvedValue([]);
93
+ const guard = new PermissionGuard(reflector, cache);
94
+ await expect(guard.canActivate(buildContext({
95
+ user: {
96
+ id: 'u1'
97
+ }
98
+ }))).rejects.toBeInstanceOf(NoPermissionsFoundException);
99
+ });
100
+ it('should allow access for a single string permission that the user has', async ()=>{
101
+ mockMetadata(false, 'user.read');
102
+ cache.get.mockResolvedValue([
103
+ 'user.read'
104
+ ]);
105
+ const guard = new PermissionGuard(reflector, cache);
106
+ await expect(guard.canActivate(buildContext({
107
+ user: {
108
+ id: 'u1'
109
+ }
110
+ }))).resolves.toBe(true);
111
+ });
112
+ it('should throw InsufficientPermissionsException for a single string permission the user lacks', async ()=>{
113
+ mockMetadata(false, 'user.write');
114
+ cache.get.mockResolvedValue([
115
+ 'user.read'
116
+ ]);
117
+ const guard = new PermissionGuard(reflector, cache);
118
+ await expect(guard.canActivate(buildContext({
119
+ user: {
120
+ id: 'u1'
121
+ }
122
+ }))).rejects.toBeInstanceOf(InsufficientPermissionsException);
123
+ });
124
+ it('should support a wildcard "*" permission granting everything', async ()=>{
125
+ mockMetadata(false, 'anything.here');
126
+ cache.get.mockResolvedValue([
127
+ '*'
128
+ ]);
129
+ const guard = new PermissionGuard(reflector, cache);
130
+ await expect(guard.canActivate(buildContext({
131
+ user: {
132
+ id: 'u1'
133
+ }
134
+ }))).resolves.toBe(true);
135
+ });
136
+ it('should support a namespaced wildcard like "user.*"', async ()=>{
137
+ mockMetadata(false, 'user.delete');
138
+ cache.get.mockResolvedValue([
139
+ 'user.*'
140
+ ]);
141
+ const guard = new PermissionGuard(reflector, cache);
142
+ await expect(guard.canActivate(buildContext({
143
+ user: {
144
+ id: 'u1'
145
+ }
146
+ }))).resolves.toBe(true);
147
+ });
148
+ it('should evaluate an array of permissions as AND logic', async ()=>{
149
+ mockMetadata(false, [
150
+ 'user.read',
151
+ 'user.write'
152
+ ]);
153
+ cache.get.mockResolvedValue([
154
+ 'user.read'
155
+ ]);
156
+ const guard = new PermissionGuard(reflector, cache);
157
+ await expect(guard.canActivate(buildContext({
158
+ user: {
159
+ id: 'u1'
160
+ }
161
+ }))).rejects.toBeInstanceOf(InsufficientPermissionsException);
162
+ });
163
+ it('should pass an array of permissions when the user has all of them', async ()=>{
164
+ mockMetadata(false, [
165
+ 'user.read',
166
+ 'user.write'
167
+ ]);
168
+ cache.get.mockResolvedValue([
169
+ 'user.read',
170
+ 'user.write'
171
+ ]);
172
+ const guard = new PermissionGuard(reflector, cache);
173
+ await expect(guard.canActivate(buildContext({
174
+ user: {
175
+ id: 'u1'
176
+ }
177
+ }))).resolves.toBe(true);
178
+ });
179
+ it('should return true for an empty permission array (no-op logic node)', async ()=>{
180
+ mockMetadata(false, []);
181
+ cache.get.mockResolvedValue([
182
+ 'user.read'
183
+ ]);
184
+ const guard = new PermissionGuard(reflector, cache);
185
+ await expect(guard.canActivate(buildContext({
186
+ user: {
187
+ id: 'u1'
188
+ }
189
+ }))).resolves.toBe(true);
190
+ });
191
+ it('should evaluate SimplePermissionConfig with OR operator', async ()=>{
192
+ mockMetadata(false, {
193
+ permissions: [
194
+ 'admin',
195
+ 'manager'
196
+ ],
197
+ operator: 'OR'
198
+ });
199
+ cache.get.mockResolvedValue([
200
+ 'manager'
201
+ ]);
202
+ const guard = new PermissionGuard(reflector, cache);
203
+ await expect(guard.canActivate(buildContext({
204
+ user: {
205
+ id: 'u1'
206
+ }
207
+ }))).resolves.toBe(true);
208
+ });
209
+ it('should reject SimplePermissionConfig with OR operator when none match', async ()=>{
210
+ mockMetadata(false, {
211
+ permissions: [
212
+ 'admin',
213
+ 'manager'
214
+ ],
215
+ operator: 'OR'
216
+ });
217
+ cache.get.mockResolvedValue([
218
+ 'guest'
219
+ ]);
220
+ const guard = new PermissionGuard(reflector, cache);
221
+ await expect(guard.canActivate(buildContext({
222
+ user: {
223
+ id: 'u1'
224
+ }
225
+ }))).rejects.toBeInstanceOf(InsufficientPermissionsException);
226
+ });
227
+ it('should return true for SimplePermissionConfig with an empty permissions array', async ()=>{
228
+ mockMetadata(false, {
229
+ permissions: [],
230
+ operator: 'AND'
231
+ });
232
+ cache.get.mockResolvedValue([
233
+ 'user.read'
234
+ ]);
235
+ const guard = new PermissionGuard(reflector, cache);
236
+ await expect(guard.canActivate(buildContext({
237
+ user: {
238
+ id: 'u1'
239
+ }
240
+ }))).resolves.toBe(true);
241
+ });
242
+ it('should evaluate a nested ILogicNode tree (AND of action + OR group)', async ()=>{
243
+ mockMetadata(false, {
244
+ type: 'group',
245
+ operator: 'AND',
246
+ children: [
247
+ {
248
+ type: 'action',
249
+ actionId: 'users.read'
250
+ },
251
+ {
252
+ type: 'group',
253
+ operator: 'OR',
254
+ children: [
255
+ {
256
+ type: 'action',
257
+ actionId: 'admin'
258
+ },
259
+ {
260
+ type: 'action',
261
+ actionId: 'manager'
262
+ }
263
+ ]
264
+ }
265
+ ]
266
+ });
267
+ cache.get.mockResolvedValue([
268
+ 'users.read',
269
+ 'manager'
270
+ ]);
271
+ const guard = new PermissionGuard(reflector, cache);
272
+ await expect(guard.canActivate(buildContext({
273
+ user: {
274
+ id: 'u1'
275
+ }
276
+ }))).resolves.toBe(true);
277
+ });
278
+ it('should fail-closed on a group node with no children', async ()=>{
279
+ mockMetadata(false, {
280
+ type: 'group',
281
+ operator: 'AND',
282
+ children: []
283
+ });
284
+ cache.get.mockResolvedValue([
285
+ 'user.read'
286
+ ]);
287
+ const guard = new PermissionGuard(reflector, cache);
288
+ await expect(guard.canActivate(buildContext({
289
+ user: {
290
+ id: 'u1'
291
+ }
292
+ }))).rejects.toBeInstanceOf(InsufficientPermissionsException);
293
+ });
294
+ it('should read backendCodes from an object-shaped cache value', async ()=>{
295
+ mockMetadata(false, 'user.read');
296
+ cache.get.mockResolvedValue({
297
+ backendCodes: [
298
+ 'user.read'
299
+ ]
300
+ });
301
+ const guard = new PermissionGuard(reflector, cache);
302
+ await expect(guard.canActivate(buildContext({
303
+ user: {
304
+ id: 'u1'
305
+ }
306
+ }))).resolves.toBe(true);
307
+ });
308
+ it('should treat an object-shaped cache value with no backendCodes as no permissions', async ()=>{
309
+ mockMetadata(false, 'user.read');
310
+ cache.get.mockResolvedValue({});
311
+ const guard = new PermissionGuard(reflector, cache);
312
+ await expect(guard.canActivate(buildContext({
313
+ user: {
314
+ id: 'u1'
315
+ }
316
+ }))).rejects.toBeInstanceOf(NoPermissionsFoundException);
317
+ });
318
+ });
319
+ describe('company-scoped cache key', ()=>{
320
+ it('should include companyId/branchId in the cache lookup when company feature is enabled', async ()=>{
321
+ mockMetadata(false, 'user.read');
322
+ cache.get.mockResolvedValue([
323
+ 'user.read'
324
+ ]);
325
+ const guard = new PermissionGuard(reflector, cache, {
326
+ enableCompanyFeature: true
327
+ });
328
+ await guard.canActivate(buildContext({
329
+ user: {
330
+ id: 'u1',
331
+ companyId: 'c1',
332
+ branchId: 'b1'
333
+ }
334
+ }));
335
+ expect(cache.get).toHaveBeenCalledWith(expect.stringContaining('company:c1:branch:b1:user:u1'));
336
+ });
337
+ it('should fall back to the user-scoped cache key when company feature is disabled', async ()=>{
338
+ mockMetadata(false, 'user.read');
339
+ cache.get.mockResolvedValue([
340
+ 'user.read'
341
+ ]);
342
+ const guard = new PermissionGuard(reflector, cache);
343
+ await guard.canActivate(buildContext({
344
+ user: {
345
+ id: 'u1',
346
+ companyId: 'c1',
347
+ branchId: 'b1'
348
+ }
349
+ }));
350
+ expect(cache.get).toHaveBeenCalledWith(expect.stringContaining(':user:u1'));
351
+ expect(cache.get).toHaveBeenCalledWith(expect.not.stringContaining('company'));
352
+ });
353
+ });
354
+ });
@@ -0,0 +1,103 @@
1
+ import { of } from 'rxjs';
2
+ import { DeleteEmptyIdFromBodyInterceptor } from './delete-empty-id-from-body.interceptor';
3
+ function buildContext(body) {
4
+ const request = {
5
+ body
6
+ };
7
+ const context = {
8
+ switchToHttp: ()=>({
9
+ getRequest: ()=>request
10
+ })
11
+ };
12
+ return {
13
+ context,
14
+ request
15
+ };
16
+ }
17
+ function buildCallHandler() {
18
+ return {
19
+ handle: jest.fn().mockReturnValue(of('next-result'))
20
+ };
21
+ }
22
+ describe('DeleteEmptyIdFromBodyInterceptor', ()=>{
23
+ let interceptor;
24
+ beforeEach(()=>{
25
+ interceptor = new DeleteEmptyIdFromBodyInterceptor();
26
+ });
27
+ it('should delete an empty-string id on a single object body', ()=>{
28
+ const { context, request } = buildContext({
29
+ id: '',
30
+ name: 'Product'
31
+ });
32
+ interceptor.intercept(context, buildCallHandler());
33
+ expect(request.body).toEqual({
34
+ name: 'Product'
35
+ });
36
+ });
37
+ it('should delete a null id on a single object body', ()=>{
38
+ const { context, request } = buildContext({
39
+ id: null,
40
+ name: 'Product'
41
+ });
42
+ interceptor.intercept(context, buildCallHandler());
43
+ expect(request.body).toEqual({
44
+ name: 'Product'
45
+ });
46
+ });
47
+ it('should keep a truthy id on a single object body', ()=>{
48
+ const { context, request } = buildContext({
49
+ id: 'uuid-1',
50
+ name: 'Product'
51
+ });
52
+ interceptor.intercept(context, buildCallHandler());
53
+ expect(request.body).toEqual({
54
+ id: 'uuid-1',
55
+ name: 'Product'
56
+ });
57
+ });
58
+ it('should strip empty ids from each item in an array body', ()=>{
59
+ const { context, request } = buildContext([
60
+ {
61
+ id: '',
62
+ name: 'A'
63
+ },
64
+ {
65
+ id: 'uuid-2',
66
+ name: 'B'
67
+ },
68
+ {
69
+ id: null,
70
+ name: 'C'
71
+ }
72
+ ]);
73
+ interceptor.intercept(context, buildCallHandler());
74
+ expect(request.body).toEqual([
75
+ {
76
+ name: 'A'
77
+ },
78
+ {
79
+ id: 'uuid-2',
80
+ name: 'B'
81
+ },
82
+ {
83
+ name: 'C'
84
+ }
85
+ ]);
86
+ });
87
+ it('should leave a null body untouched', ()=>{
88
+ const { context, request } = buildContext(null);
89
+ interceptor.intercept(context, buildCallHandler());
90
+ expect(request.body).toBeNull();
91
+ });
92
+ it('should call next.handle() and return its observable', (done)=>{
93
+ const { context } = buildContext({
94
+ id: ''
95
+ });
96
+ const callHandler = buildCallHandler();
97
+ interceptor.intercept(context, callHandler).subscribe((value)=>{
98
+ expect(value).toBe('next-result');
99
+ expect(callHandler.handle).toHaveBeenCalled();
100
+ done();
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,112 @@
1
+ import { ConflictException } from '@nestjs/common';
2
+ import { of } from 'rxjs';
3
+ import { IDEMPOTENCY_CACHE_PREFIX, IDEMPOTENCY_KEY_HEADER } from '../constants';
4
+ import { IdempotencyInterceptor } from './idempotency.interceptor';
5
+ function buildContext(headers = {}) {
6
+ const request = {
7
+ headers
8
+ };
9
+ return {
10
+ switchToHttp: ()=>({
11
+ getRequest: ()=>request
12
+ })
13
+ };
14
+ }
15
+ function buildCallHandler(response = {
16
+ success: true
17
+ }) {
18
+ return {
19
+ handle: jest.fn().mockReturnValue(of(response))
20
+ };
21
+ }
22
+ describe('IdempotencyInterceptor', ()=>{
23
+ let cache;
24
+ beforeEach(()=>{
25
+ cache = {
26
+ get: jest.fn(),
27
+ set: jest.fn(),
28
+ del: jest.fn()
29
+ };
30
+ });
31
+ it('should skip idempotency handling when no cache is injected', async ()=>{
32
+ const interceptor = new IdempotencyInterceptor(undefined);
33
+ const callHandler = buildCallHandler('raw-result');
34
+ const result$ = await interceptor.intercept(buildContext({
35
+ [IDEMPOTENCY_KEY_HEADER]: 'key-1'
36
+ }), callHandler);
37
+ await new Promise((resolve)=>{
38
+ result$.subscribe((value)=>{
39
+ expect(value).toBe('raw-result');
40
+ resolve();
41
+ });
42
+ });
43
+ expect(callHandler.handle).toHaveBeenCalled();
44
+ });
45
+ it('should skip idempotency handling when no idempotency key header is provided', async ()=>{
46
+ const interceptor = new IdempotencyInterceptor(cache);
47
+ const callHandler = buildCallHandler('raw-result');
48
+ const result$ = await interceptor.intercept(buildContext({}), callHandler);
49
+ await new Promise((resolve)=>{
50
+ result$.subscribe((value)=>{
51
+ expect(value).toBe('raw-result');
52
+ resolve();
53
+ });
54
+ });
55
+ expect(cache.get).not.toHaveBeenCalled();
56
+ });
57
+ it('should process and cache the response when the key is new', async ()=>{
58
+ cache.get.mockResolvedValue(undefined);
59
+ const interceptor = new IdempotencyInterceptor(cache);
60
+ const callHandler = buildCallHandler({
61
+ success: true,
62
+ data: 'X'
63
+ });
64
+ const result$ = await interceptor.intercept(buildContext({
65
+ [IDEMPOTENCY_KEY_HEADER]: 'key-1'
66
+ }), callHandler);
67
+ const cacheKey = `${IDEMPOTENCY_CACHE_PREFIX}:key-1`;
68
+ expect(cache.set).toHaveBeenCalledWith(cacheKey, '__PROCESSING__', 24 * 60 * 60 * 1000);
69
+ await new Promise((resolve)=>{
70
+ result$.subscribe((value)=>{
71
+ expect(value).toEqual({
72
+ success: true,
73
+ data: 'X'
74
+ });
75
+ resolve();
76
+ });
77
+ });
78
+ // Allow the tap() side-effect microtask to flush
79
+ await Promise.resolve();
80
+ await Promise.resolve();
81
+ expect(cache.set).toHaveBeenCalledWith(cacheKey, {
82
+ success: true,
83
+ data: 'X'
84
+ }, 24 * 60 * 60 * 1000);
85
+ });
86
+ it('should return the cached response when the key already has a completed result', async ()=>{
87
+ const cachedResponse = {
88
+ success: true,
89
+ data: 'cached'
90
+ };
91
+ cache.get.mockResolvedValue(cachedResponse);
92
+ const interceptor = new IdempotencyInterceptor(cache);
93
+ const callHandler = buildCallHandler();
94
+ const result$ = await interceptor.intercept(buildContext({
95
+ [IDEMPOTENCY_KEY_HEADER]: 'key-1'
96
+ }), callHandler);
97
+ await new Promise((resolve)=>{
98
+ result$.subscribe((value)=>{
99
+ expect(value).toEqual(cachedResponse);
100
+ resolve();
101
+ });
102
+ });
103
+ expect(callHandler.handle).not.toHaveBeenCalled();
104
+ });
105
+ it('should throw ConflictException when the key is still marked as processing', async ()=>{
106
+ cache.get.mockResolvedValue('__PROCESSING__');
107
+ const interceptor = new IdempotencyInterceptor(cache);
108
+ await expect(interceptor.intercept(buildContext({
109
+ [IDEMPOTENCY_KEY_HEADER]: 'key-1'
110
+ }), buildCallHandler())).rejects.toBeInstanceOf(ConflictException);
111
+ });
112
+ });
@@ -0,0 +1,85 @@
1
+ import { of } from 'rxjs';
2
+ import { REQUEST_ID_HEADER } from '../constants';
3
+ import { ResponseMetaInterceptor } from './response-meta.interceptor';
4
+ function buildContext(headers = {}) {
5
+ const request = {
6
+ headers
7
+ };
8
+ const context = {
9
+ switchToHttp: ()=>({
10
+ getRequest: ()=>request
11
+ })
12
+ };
13
+ return {
14
+ context,
15
+ request
16
+ };
17
+ }
18
+ function buildCallHandler(response) {
19
+ return {
20
+ handle: jest.fn().mockReturnValue(of(response))
21
+ };
22
+ }
23
+ describe('ResponseMetaInterceptor', ()=>{
24
+ let interceptor;
25
+ beforeEach(()=>{
26
+ interceptor = new ResponseMetaInterceptor();
27
+ });
28
+ it('should attach _meta to a response object with a success field', (done)=>{
29
+ const { context } = buildContext();
30
+ interceptor.intercept(context, buildCallHandler({
31
+ success: true,
32
+ data: {}
33
+ })).subscribe((result)=>{
34
+ expect(result.success).toBe(true);
35
+ expect(result._meta).toMatchObject({
36
+ requestId: expect.stringMatching(/^req_/),
37
+ timestamp: expect.any(String),
38
+ responseTime: expect.any(Number)
39
+ });
40
+ done();
41
+ });
42
+ });
43
+ it('should use the existing x-request-id header instead of generating one', (done)=>{
44
+ const { context } = buildContext({
45
+ [REQUEST_ID_HEADER]: 'existing-id'
46
+ });
47
+ interceptor.intercept(context, buildCallHandler({
48
+ success: true
49
+ })).subscribe((result)=>{
50
+ expect(result._meta.requestId).toBe('existing-id');
51
+ done();
52
+ });
53
+ });
54
+ it('should set request.requestId as a side effect', ()=>{
55
+ const { context, request } = buildContext();
56
+ interceptor.intercept(context, buildCallHandler({
57
+ success: true
58
+ })).subscribe();
59
+ expect(request.requestId).toEqual(expect.stringMatching(/^req_/));
60
+ });
61
+ it('should not modify a response without a "success" field', (done)=>{
62
+ const { context } = buildContext();
63
+ const response = {
64
+ data: 'raw'
65
+ };
66
+ interceptor.intercept(context, buildCallHandler(response)).subscribe((result)=>{
67
+ expect(result).toBe(response);
68
+ done();
69
+ });
70
+ });
71
+ it('should not modify a primitive response', (done)=>{
72
+ const { context } = buildContext();
73
+ interceptor.intercept(context, buildCallHandler('plain-string')).subscribe((result)=>{
74
+ expect(result).toBe('plain-string');
75
+ done();
76
+ });
77
+ });
78
+ it('should not modify a null response', (done)=>{
79
+ const { context } = buildContext();
80
+ interceptor.intercept(context, buildCallHandler(null)).subscribe((result)=>{
81
+ expect(result).toBeNull();
82
+ done();
83
+ });
84
+ });
85
+ });