@dismissible/nestjs-api 0.0.2-canary.c91edbc.0 → 0.0.2-canary.d2f56d7.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.
Potentially problematic release.
This version of @dismissible/nestjs-api might be problematic. Click here for more details.
- package/config/.env.yaml +19 -2
- package/jest.config.ts +23 -1
- package/jest.e2e-config.ts +3 -1
- package/package.json +13 -7
- package/project.json +6 -3
- package/scripts/performance-test.ts +2 -2
- package/src/app.module.ts +13 -1
- package/src/app.setup.ts +7 -5
- package/src/config/app.config.spec.ts +118 -0
- package/src/config/app.config.ts +5 -0
- package/src/config/default-app.config.spec.ts +74 -0
- package/src/config/default-app.config.ts +5 -0
- package/src/cors/cors.config.ts +1 -1
- package/src/helmet/helmet.config.ts +1 -1
- package/src/server/server.config.spec.ts +65 -0
- package/src/swagger/swagger.config.spec.ts +113 -0
- package/src/swagger/swagger.config.ts +2 -10
- package/src/swagger/swagger.factory.spec.ts +126 -0
- package/src/validation/index.ts +1 -0
- package/src/validation/validation.config.ts +47 -0
- package/test/config/.env.yaml +23 -0
- package/test/config-jwt-auth/.env.yaml +26 -0
- package/test/dismiss.e2e-spec.ts +64 -0
- package/test/full-cycle.e2e-spec.ts +57 -0
- package/test/get-or-create.e2e-spec.ts +51 -0
- package/test/jwt-auth.e2e-spec.ts +353 -0
- package/test/restore.e2e-spec.ts +63 -0
- package/tsconfig.e2e.json +12 -0
- package/tsconfig.spec.json +12 -0
- package/src/app.e2e-spec.ts +0 -221
- package/src/utils/index.ts +0 -2
- package/src/utils/transform-boolean.decorator.spec.ts +0 -47
- package/src/utils/transform-boolean.decorator.ts +0 -19
- package/src/utils/transform-comma-separated.decorator.ts +0 -16
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { INestApplication } from '@nestjs/common';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { createTestApp, cleanupTestData } from '../src/app-test.factory';
|
|
5
|
+
import { JwtAuthService, IJwtValidationResult } from '@dismissible/nestjs-jwt-auth-hook';
|
|
6
|
+
|
|
7
|
+
describe('JWT Authentication E2E', () => {
|
|
8
|
+
let app: INestApplication;
|
|
9
|
+
let mockValidateToken: jest.Mock;
|
|
10
|
+
|
|
11
|
+
// Helper to create a mock validation result
|
|
12
|
+
const createValidResult = (sub = 'test-user'): IJwtValidationResult => ({
|
|
13
|
+
valid: true,
|
|
14
|
+
payload: {
|
|
15
|
+
sub,
|
|
16
|
+
iss: 'https://auth.example.com',
|
|
17
|
+
aud: 'dismissible-api',
|
|
18
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
19
|
+
iat: Math.floor(Date.now() / 1000),
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const createInvalidResult = (error: string): IJwtValidationResult => ({
|
|
24
|
+
valid: false,
|
|
25
|
+
error,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
// Create the mock function before app initialization
|
|
30
|
+
mockValidateToken = jest.fn();
|
|
31
|
+
|
|
32
|
+
app = await createTestApp({
|
|
33
|
+
moduleOptions: {
|
|
34
|
+
// Use test-specific config directory with JWT auth enabled
|
|
35
|
+
configPath: join(__dirname, 'config-jwt-auth'),
|
|
36
|
+
},
|
|
37
|
+
customize: (builder) => {
|
|
38
|
+
// Override JwtAuthService to prevent real JWKS calls during onModuleInit
|
|
39
|
+
return builder.overrideProvider(JwtAuthService).useValue({
|
|
40
|
+
// Mock initializeJwksClient to do nothing
|
|
41
|
+
initializeJwksClient: jest.fn().mockResolvedValue(undefined),
|
|
42
|
+
// Use the real extractBearerToken logic
|
|
43
|
+
extractBearerToken: (authHeader: string | undefined) => {
|
|
44
|
+
if (!authHeader) return null;
|
|
45
|
+
const parts = authHeader.split(' ');
|
|
46
|
+
if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') return null;
|
|
47
|
+
return parts[1];
|
|
48
|
+
},
|
|
49
|
+
// Use our mock for validateToken
|
|
50
|
+
validateToken: mockValidateToken,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await cleanupTestData(app);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
if (app) {
|
|
60
|
+
await cleanupTestData(app);
|
|
61
|
+
await app.close();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
// Reset mock before each test
|
|
67
|
+
mockValidateToken.mockReset();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('Successful authentication scenarios', () => {
|
|
71
|
+
it('should allow GET request with valid bearer token', async () => {
|
|
72
|
+
mockValidateToken.mockResolvedValue(createValidResult('auth-user-1'));
|
|
73
|
+
|
|
74
|
+
const response = await request(app.getHttpServer())
|
|
75
|
+
.get('/v1/users/auth-user-1/items/jwt-test-get')
|
|
76
|
+
.set('Authorization', 'Bearer valid-token-123')
|
|
77
|
+
.expect(200);
|
|
78
|
+
|
|
79
|
+
expect(response.body.data).toBeDefined();
|
|
80
|
+
expect(response.body.data.itemId).toBe('jwt-test-get');
|
|
81
|
+
expect(response.body.data.userId).toBe('auth-user-1');
|
|
82
|
+
expect(mockValidateToken).toHaveBeenCalledWith('valid-token-123');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should allow DELETE (dismiss) request with valid bearer token', async () => {
|
|
86
|
+
mockValidateToken.mockResolvedValue(createValidResult('auth-user-2'));
|
|
87
|
+
|
|
88
|
+
// First create the item
|
|
89
|
+
await request(app.getHttpServer())
|
|
90
|
+
.get('/v1/users/auth-user-2/items/jwt-test-dismiss')
|
|
91
|
+
.set('Authorization', 'Bearer valid-token-123')
|
|
92
|
+
.expect(200);
|
|
93
|
+
|
|
94
|
+
// Then dismiss it
|
|
95
|
+
const response = await request(app.getHttpServer())
|
|
96
|
+
.delete('/v1/users/auth-user-2/items/jwt-test-dismiss')
|
|
97
|
+
.set('Authorization', 'Bearer valid-token-123')
|
|
98
|
+
.expect(200);
|
|
99
|
+
|
|
100
|
+
expect(response.body.data).toBeDefined();
|
|
101
|
+
expect(response.body.data.dismissedAt).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should allow POST (restore) request with valid bearer token', async () => {
|
|
105
|
+
mockValidateToken.mockResolvedValue(createValidResult('auth-user-3'));
|
|
106
|
+
|
|
107
|
+
// Create and dismiss the item
|
|
108
|
+
await request(app.getHttpServer())
|
|
109
|
+
.get('/v1/users/auth-user-3/items/jwt-test-restore')
|
|
110
|
+
.set('Authorization', 'Bearer valid-token-123')
|
|
111
|
+
.expect(200);
|
|
112
|
+
|
|
113
|
+
await request(app.getHttpServer())
|
|
114
|
+
.delete('/v1/users/auth-user-3/items/jwt-test-restore')
|
|
115
|
+
.set('Authorization', 'Bearer valid-token-123')
|
|
116
|
+
.expect(200);
|
|
117
|
+
|
|
118
|
+
// Restore it
|
|
119
|
+
const response = await request(app.getHttpServer())
|
|
120
|
+
.post('/v1/users/auth-user-3/items/jwt-test-restore')
|
|
121
|
+
.set('Authorization', 'Bearer valid-token-123')
|
|
122
|
+
.expect(201);
|
|
123
|
+
|
|
124
|
+
expect(response.body.data).toBeDefined();
|
|
125
|
+
expect(response.body.data.dismissedAt).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should handle lowercase bearer prefix', async () => {
|
|
129
|
+
mockValidateToken.mockResolvedValue(createValidResult('auth-user-4'));
|
|
130
|
+
|
|
131
|
+
const response = await request(app.getHttpServer())
|
|
132
|
+
.get('/v1/users/auth-user-4/items/jwt-test-lowercase')
|
|
133
|
+
.set('Authorization', 'bearer lowercase-token')
|
|
134
|
+
.expect(200);
|
|
135
|
+
|
|
136
|
+
expect(response.body.data).toBeDefined();
|
|
137
|
+
expect(mockValidateToken).toHaveBeenCalledWith('lowercase-token');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('Failed authentication scenarios', () => {
|
|
142
|
+
it('should reject request without Authorization header', async () => {
|
|
143
|
+
const response = await request(app.getHttpServer())
|
|
144
|
+
.get('/v1/users/noauth-user/items/jwt-test-noheader')
|
|
145
|
+
.expect(401);
|
|
146
|
+
|
|
147
|
+
expect(response.body.error).toBeDefined();
|
|
148
|
+
expect(response.body.error.message).toContain('Missing or invalid bearer token');
|
|
149
|
+
expect(mockValidateToken).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should reject request with empty Authorization header', async () => {
|
|
153
|
+
const response = await request(app.getHttpServer())
|
|
154
|
+
.get('/v1/users/noauth-user/items/jwt-test-empty')
|
|
155
|
+
.set('Authorization', '')
|
|
156
|
+
.expect(401);
|
|
157
|
+
|
|
158
|
+
expect(response.body.error).toBeDefined();
|
|
159
|
+
expect(response.body.error.message).toContain('Missing or invalid bearer token');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should reject request with non-Bearer auth scheme', async () => {
|
|
163
|
+
const response = await request(app.getHttpServer())
|
|
164
|
+
.get('/v1/users/noauth-user/items/jwt-test-basic')
|
|
165
|
+
.set('Authorization', 'Basic dXNlcjpwYXNz')
|
|
166
|
+
.expect(401);
|
|
167
|
+
|
|
168
|
+
expect(response.body.error).toBeDefined();
|
|
169
|
+
expect(response.body.error.message).toContain('Missing or invalid bearer token');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should reject request with Bearer but no token', async () => {
|
|
173
|
+
const response = await request(app.getHttpServer())
|
|
174
|
+
.get('/v1/users/noauth-user/items/jwt-test-notoken')
|
|
175
|
+
.set('Authorization', 'Bearer')
|
|
176
|
+
.expect(401);
|
|
177
|
+
|
|
178
|
+
expect(response.body.error).toBeDefined();
|
|
179
|
+
expect(response.body.error.message).toContain('Missing or invalid bearer token');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should reject request with expired token', async () => {
|
|
183
|
+
mockValidateToken.mockResolvedValue(createInvalidResult('jwt expired'));
|
|
184
|
+
|
|
185
|
+
const response = await request(app.getHttpServer())
|
|
186
|
+
.get('/v1/users/expired-user/items/jwt-test-expired')
|
|
187
|
+
.set('Authorization', 'Bearer expired-token')
|
|
188
|
+
.expect(401);
|
|
189
|
+
|
|
190
|
+
expect(response.body.error).toBeDefined();
|
|
191
|
+
expect(response.body.error.message).toContain('jwt expired');
|
|
192
|
+
expect(mockValidateToken).toHaveBeenCalledWith('expired-token');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should reject request with invalid token signature', async () => {
|
|
196
|
+
mockValidateToken.mockResolvedValue(createInvalidResult('invalid signature'));
|
|
197
|
+
|
|
198
|
+
const response = await request(app.getHttpServer())
|
|
199
|
+
.get('/v1/users/invalid-user/items/jwt-test-invalid-sig')
|
|
200
|
+
.set('Authorization', 'Bearer tampered-token')
|
|
201
|
+
.expect(401);
|
|
202
|
+
|
|
203
|
+
expect(response.body.error).toBeDefined();
|
|
204
|
+
expect(response.body.error.message).toContain('invalid signature');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should reject request with malformed token', async () => {
|
|
208
|
+
mockValidateToken.mockResolvedValue(createInvalidResult('Invalid token format'));
|
|
209
|
+
|
|
210
|
+
const response = await request(app.getHttpServer())
|
|
211
|
+
.get('/v1/users/malformed-user/items/jwt-test-malformed')
|
|
212
|
+
.set('Authorization', 'Bearer not.a.valid.jwt.token')
|
|
213
|
+
.expect(401);
|
|
214
|
+
|
|
215
|
+
expect(response.body.error).toBeDefined();
|
|
216
|
+
expect(response.body.error.message).toContain('Invalid token format');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should reject request when token is missing key ID (kid)', async () => {
|
|
220
|
+
mockValidateToken.mockResolvedValue(createInvalidResult('Token missing key ID (kid)'));
|
|
221
|
+
|
|
222
|
+
const response = await request(app.getHttpServer())
|
|
223
|
+
.get('/v1/users/nokid-user/items/jwt-test-nokid')
|
|
224
|
+
.set('Authorization', 'Bearer token-without-kid')
|
|
225
|
+
.expect(401);
|
|
226
|
+
|
|
227
|
+
expect(response.body.error).toBeDefined();
|
|
228
|
+
expect(response.body.error.message).toContain('Token missing key ID');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should reject request when signing key cannot be found', async () => {
|
|
232
|
+
mockValidateToken.mockResolvedValue(createInvalidResult('Unable to find signing key'));
|
|
233
|
+
|
|
234
|
+
const response = await request(app.getHttpServer())
|
|
235
|
+
.get('/v1/users/nokey-user/items/jwt-test-nokey')
|
|
236
|
+
.set('Authorization', 'Bearer token-with-unknown-kid')
|
|
237
|
+
.expect(401);
|
|
238
|
+
|
|
239
|
+
expect(response.body.error).toBeDefined();
|
|
240
|
+
expect(response.body.error.message).toContain('Unable to find signing key');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should reject dismiss request without valid token', async () => {
|
|
244
|
+
// First create the item with a valid token
|
|
245
|
+
mockValidateToken.mockResolvedValue(createValidResult('dismiss-noauth-user'));
|
|
246
|
+
|
|
247
|
+
await request(app.getHttpServer())
|
|
248
|
+
.get('/v1/users/dismiss-noauth-user/items/jwt-test-dismiss-fail')
|
|
249
|
+
.set('Authorization', 'Bearer valid-token')
|
|
250
|
+
.expect(200);
|
|
251
|
+
|
|
252
|
+
// Reset mock and try to dismiss without token
|
|
253
|
+
mockValidateToken.mockReset();
|
|
254
|
+
|
|
255
|
+
const response = await request(app.getHttpServer())
|
|
256
|
+
.delete('/v1/users/dismiss-noauth-user/items/jwt-test-dismiss-fail')
|
|
257
|
+
.expect(401);
|
|
258
|
+
|
|
259
|
+
expect(response.body.error).toBeDefined();
|
|
260
|
+
expect(response.body.error.message).toContain('Missing or invalid bearer token');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should reject restore request without valid token', async () => {
|
|
264
|
+
// First create and dismiss the item with a valid token
|
|
265
|
+
mockValidateToken.mockResolvedValue(createValidResult('restore-noauth-user'));
|
|
266
|
+
|
|
267
|
+
await request(app.getHttpServer())
|
|
268
|
+
.get('/v1/users/restore-noauth-user/items/jwt-test-restore-fail')
|
|
269
|
+
.set('Authorization', 'Bearer valid-token')
|
|
270
|
+
.expect(200);
|
|
271
|
+
|
|
272
|
+
await request(app.getHttpServer())
|
|
273
|
+
.delete('/v1/users/restore-noauth-user/items/jwt-test-restore-fail')
|
|
274
|
+
.set('Authorization', 'Bearer valid-token')
|
|
275
|
+
.expect(200);
|
|
276
|
+
|
|
277
|
+
// Reset mock and try to restore without token
|
|
278
|
+
mockValidateToken.mockReset();
|
|
279
|
+
|
|
280
|
+
const response = await request(app.getHttpServer())
|
|
281
|
+
.post('/v1/users/restore-noauth-user/items/jwt-test-restore-fail')
|
|
282
|
+
.expect(401);
|
|
283
|
+
|
|
284
|
+
expect(response.body.error).toBeDefined();
|
|
285
|
+
expect(response.body.error.message).toContain('Missing or invalid bearer token');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('Token validation edge cases', () => {
|
|
290
|
+
it('should validate token for each request independently', async () => {
|
|
291
|
+
// First request with valid token
|
|
292
|
+
mockValidateToken.mockResolvedValueOnce(createValidResult('multi-user'));
|
|
293
|
+
|
|
294
|
+
await request(app.getHttpServer())
|
|
295
|
+
.get('/v1/users/multi-user/items/jwt-test-multi-1')
|
|
296
|
+
.set('Authorization', 'Bearer token-1')
|
|
297
|
+
.expect(200);
|
|
298
|
+
|
|
299
|
+
// Second request with different valid token
|
|
300
|
+
mockValidateToken.mockResolvedValueOnce(createValidResult('multi-user'));
|
|
301
|
+
|
|
302
|
+
await request(app.getHttpServer())
|
|
303
|
+
.get('/v1/users/multi-user/items/jwt-test-multi-2')
|
|
304
|
+
.set('Authorization', 'Bearer token-2')
|
|
305
|
+
.expect(200);
|
|
306
|
+
|
|
307
|
+
expect(mockValidateToken).toHaveBeenCalledTimes(2);
|
|
308
|
+
expect(mockValidateToken).toHaveBeenNthCalledWith(1, 'token-1');
|
|
309
|
+
expect(mockValidateToken).toHaveBeenNthCalledWith(2, 'token-2');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should handle validation service errors gracefully', async () => {
|
|
313
|
+
mockValidateToken.mockRejectedValue(new Error('JWKS client error'));
|
|
314
|
+
|
|
315
|
+
const response = await request(app.getHttpServer())
|
|
316
|
+
.get('/v1/users/error-user/items/jwt-test-error')
|
|
317
|
+
.set('Authorization', 'Bearer some-token')
|
|
318
|
+
.expect(500);
|
|
319
|
+
|
|
320
|
+
// Generic errors are handled by NestJS default exception filter
|
|
321
|
+
// which returns { statusCode, message, error } format
|
|
322
|
+
expect(response.body.statusCode).toBe(500);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should reject token with wrong issuer', async () => {
|
|
326
|
+
mockValidateToken.mockResolvedValue(
|
|
327
|
+
createInvalidResult('jwt issuer invalid. expected: https://auth.example.com'),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const response = await request(app.getHttpServer())
|
|
331
|
+
.get('/v1/users/issuer-user/items/jwt-test-issuer')
|
|
332
|
+
.set('Authorization', 'Bearer wrong-issuer-token')
|
|
333
|
+
.expect(401);
|
|
334
|
+
|
|
335
|
+
expect(response.body.error).toBeDefined();
|
|
336
|
+
expect(response.body.error.message).toContain('issuer invalid');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should reject token with wrong audience', async () => {
|
|
340
|
+
mockValidateToken.mockResolvedValue(
|
|
341
|
+
createInvalidResult('jwt audience invalid. expected: dismissible-api'),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const response = await request(app.getHttpServer())
|
|
345
|
+
.get('/v1/users/audience-user/items/jwt-test-audience')
|
|
346
|
+
.set('Authorization', 'Bearer wrong-audience-token')
|
|
347
|
+
.expect(401);
|
|
348
|
+
|
|
349
|
+
expect(response.body.error).toBeDefined();
|
|
350
|
+
expect(response.body.error.message).toContain('audience invalid');
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { INestApplication } from '@nestjs/common';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { createTestApp, cleanupTestData } from '../src/app-test.factory';
|
|
5
|
+
|
|
6
|
+
describe('POST /v1/users/:userId/items/:id (restore)', () => {
|
|
7
|
+
let app: INestApplication;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
app = await createTestApp({
|
|
11
|
+
moduleOptions: {
|
|
12
|
+
configPath: join(__dirname, 'config'),
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
await cleanupTestData(app);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(async () => {
|
|
19
|
+
await cleanupTestData(app);
|
|
20
|
+
await app.close();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should restore a dismissed item', async () => {
|
|
24
|
+
const userId = 'user-restore-1';
|
|
25
|
+
// Create and dismiss the item
|
|
26
|
+
await request(app.getHttpServer()).get(`/v1/users/${userId}/items/restore-test-1`).expect(200);
|
|
27
|
+
|
|
28
|
+
await request(app.getHttpServer())
|
|
29
|
+
.delete(`/v1/users/${userId}/items/restore-test-1`)
|
|
30
|
+
.expect(200);
|
|
31
|
+
|
|
32
|
+
// Restore it
|
|
33
|
+
const response = await request(app.getHttpServer())
|
|
34
|
+
.post(`/v1/users/${userId}/items/restore-test-1`)
|
|
35
|
+
.expect(201);
|
|
36
|
+
|
|
37
|
+
expect(response.body.data).toBeDefined();
|
|
38
|
+
expect(response.body.data.dismissedAt).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should return 400 when restoring non-existent item', async () => {
|
|
42
|
+
const response = await request(app.getHttpServer())
|
|
43
|
+
.post('/v1/users/user-123/items/non-existent-restore')
|
|
44
|
+
.expect(400);
|
|
45
|
+
|
|
46
|
+
expect(response.body.error).toBeDefined();
|
|
47
|
+
expect(response.body.error.message).toContain('not found');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return 400 when restoring non-dismissed item', async () => {
|
|
51
|
+
const userId = 'user-restore-2';
|
|
52
|
+
// Create item but don't dismiss it
|
|
53
|
+
await request(app.getHttpServer()).get(`/v1/users/${userId}/items/restore-test-2`).expect(200);
|
|
54
|
+
|
|
55
|
+
// Try to restore
|
|
56
|
+
const response = await request(app.getHttpServer())
|
|
57
|
+
.post(`/v1/users/${userId}/items/restore-test-2`)
|
|
58
|
+
.expect(400);
|
|
59
|
+
|
|
60
|
+
expect(response.body.error).toBeDefined();
|
|
61
|
+
expect(response.body.error.message).toContain('not dismissed');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../dist/out-tsc",
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"types": ["node", "jest"],
|
|
7
|
+
"emitDecoratorMetadata": true,
|
|
8
|
+
"experimentalDecorators": true,
|
|
9
|
+
"target": "ES2021"
|
|
10
|
+
},
|
|
11
|
+
"include": ["test/**/*.e2e-spec.ts", "src/**/*.ts"]
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../dist/out-tsc",
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"types": ["node", "jest"],
|
|
7
|
+
"emitDecoratorMetadata": true,
|
|
8
|
+
"experimentalDecorators": true,
|
|
9
|
+
"target": "ES2021"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
|
12
|
+
}
|
package/src/app.e2e-spec.ts
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { INestApplication } from '@nestjs/common';
|
|
2
|
-
import request from 'supertest';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { createTestApp, cleanupTestData } from './app-test.factory';
|
|
5
|
-
|
|
6
|
-
describe('Dismissible API (e2e)', () => {
|
|
7
|
-
let app: INestApplication;
|
|
8
|
-
|
|
9
|
-
beforeAll(async () => {
|
|
10
|
-
app = await createTestApp({
|
|
11
|
-
moduleOptions: {
|
|
12
|
-
configPath: join(__dirname, '../config'),
|
|
13
|
-
},
|
|
14
|
-
});
|
|
15
|
-
await cleanupTestData(app);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterAll(async () => {
|
|
19
|
-
await cleanupTestData(app);
|
|
20
|
-
await app.close();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe('GET /v1/user/:userId/dismissible-item/:id (get-or-create)', () => {
|
|
24
|
-
it('should create a new item on first request', async () => {
|
|
25
|
-
const response = await request(app.getHttpServer())
|
|
26
|
-
.get('/v1/user/user-123/dismissible-item/test-banner-1')
|
|
27
|
-
.expect(200);
|
|
28
|
-
|
|
29
|
-
expect(response.body.data).toBeDefined();
|
|
30
|
-
expect(response.body.data.itemId).toBe('test-banner-1');
|
|
31
|
-
expect(response.body.data.userId).toBe('user-123');
|
|
32
|
-
expect(response.body.data.createdAt).toBeDefined();
|
|
33
|
-
expect(response.body.data.dismissedAt).toBeUndefined();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('should return existing item on subsequent requests', async () => {
|
|
37
|
-
// First request - creates the item
|
|
38
|
-
const firstResponse = await request(app.getHttpServer())
|
|
39
|
-
.get('/v1/user/user-456/dismissible-item/test-banner-2')
|
|
40
|
-
.expect(200);
|
|
41
|
-
|
|
42
|
-
const createdAt = firstResponse.body.data.createdAt;
|
|
43
|
-
|
|
44
|
-
// Second request - returns existing item
|
|
45
|
-
const secondResponse = await request(app.getHttpServer())
|
|
46
|
-
.get('/v1/user/user-456/dismissible-item/test-banner-2')
|
|
47
|
-
.expect(200);
|
|
48
|
-
|
|
49
|
-
expect(secondResponse.body.data.itemId).toBe('test-banner-2');
|
|
50
|
-
expect(secondResponse.body.data.createdAt).toBe(createdAt);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('should store and retrieve metadata', async () => {
|
|
54
|
-
const response = await request(app.getHttpServer())
|
|
55
|
-
.get('/v1/user/user-789/dismissible-item/test-banner-metadata')
|
|
56
|
-
.query({ metadata: ['version:2', 'category:promo'] })
|
|
57
|
-
.expect(200);
|
|
58
|
-
|
|
59
|
-
expect(response.body.data.metadata).toEqual({
|
|
60
|
-
version: 2,
|
|
61
|
-
category: 'promo',
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe('DELETE /v1/user/:userId/dismissible-item/:id (dismiss)', () => {
|
|
67
|
-
it('should dismiss an existing item', async () => {
|
|
68
|
-
const userId = 'user-dismiss-1';
|
|
69
|
-
// First create the item
|
|
70
|
-
await request(app.getHttpServer())
|
|
71
|
-
.get(`/v1/user/${userId}/dismissible-item/dismiss-test-1`)
|
|
72
|
-
.expect(200);
|
|
73
|
-
|
|
74
|
-
// Then dismiss it
|
|
75
|
-
const response = await request(app.getHttpServer())
|
|
76
|
-
.delete(`/v1/user/${userId}/dismissible-item/dismiss-test-1`)
|
|
77
|
-
.expect(200);
|
|
78
|
-
|
|
79
|
-
expect(response.body.data).toBeDefined();
|
|
80
|
-
expect(response.body.data.dismissedAt).toBeDefined();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('should return 400 when dismissing non-existent item', async () => {
|
|
84
|
-
const response = await request(app.getHttpServer())
|
|
85
|
-
.delete('/v1/user/user-123/dismissible-item/non-existent-item')
|
|
86
|
-
.expect(400);
|
|
87
|
-
|
|
88
|
-
expect(response.body.error).toBeDefined();
|
|
89
|
-
expect(response.body.error.message).toContain('not found');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('should return 400 when dismissing already dismissed item', async () => {
|
|
93
|
-
const userId = 'user-dismiss-2';
|
|
94
|
-
// Create the item
|
|
95
|
-
await request(app.getHttpServer())
|
|
96
|
-
.get(`/v1/user/${userId}/dismissible-item/dismiss-test-2`)
|
|
97
|
-
.expect(200);
|
|
98
|
-
|
|
99
|
-
// Dismiss it first time
|
|
100
|
-
await request(app.getHttpServer())
|
|
101
|
-
.delete(`/v1/user/${userId}/dismissible-item/dismiss-test-2`)
|
|
102
|
-
.expect(200);
|
|
103
|
-
|
|
104
|
-
// Try to dismiss again
|
|
105
|
-
const response = await request(app.getHttpServer())
|
|
106
|
-
.delete(`/v1/user/${userId}/dismissible-item/dismiss-test-2`)
|
|
107
|
-
.expect(400);
|
|
108
|
-
|
|
109
|
-
expect(response.body.error).toBeDefined();
|
|
110
|
-
expect(response.body.error.message).toContain('already dismissed');
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe('POST /v1/user/:userId/dismissible-item/:id (restore)', () => {
|
|
115
|
-
it('should restore a dismissed item', async () => {
|
|
116
|
-
const userId = 'user-restore-1';
|
|
117
|
-
// Create and dismiss the item
|
|
118
|
-
await request(app.getHttpServer())
|
|
119
|
-
.get(`/v1/user/${userId}/dismissible-item/restore-test-1`)
|
|
120
|
-
.expect(200);
|
|
121
|
-
|
|
122
|
-
await request(app.getHttpServer())
|
|
123
|
-
.delete(`/v1/user/${userId}/dismissible-item/restore-test-1`)
|
|
124
|
-
.expect(200);
|
|
125
|
-
|
|
126
|
-
// Restore it
|
|
127
|
-
const response = await request(app.getHttpServer())
|
|
128
|
-
.post(`/v1/user/${userId}/dismissible-item/restore-test-1`)
|
|
129
|
-
.expect(201);
|
|
130
|
-
|
|
131
|
-
expect(response.body.data).toBeDefined();
|
|
132
|
-
expect(response.body.data.dismissedAt).toBeUndefined();
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('should return 400 when restoring non-existent item', async () => {
|
|
136
|
-
const response = await request(app.getHttpServer())
|
|
137
|
-
.post('/v1/user/user-123/dismissible-item/non-existent-restore')
|
|
138
|
-
.expect(400);
|
|
139
|
-
|
|
140
|
-
expect(response.body.error).toBeDefined();
|
|
141
|
-
expect(response.body.error.message).toContain('not found');
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('should return 400 when restoring non-dismissed item', async () => {
|
|
145
|
-
const userId = 'user-restore-2';
|
|
146
|
-
// Create item but don't dismiss it
|
|
147
|
-
await request(app.getHttpServer())
|
|
148
|
-
.get(`/v1/user/${userId}/dismissible-item/restore-test-2`)
|
|
149
|
-
.expect(200);
|
|
150
|
-
|
|
151
|
-
// Try to restore
|
|
152
|
-
const response = await request(app.getHttpServer())
|
|
153
|
-
.post(`/v1/user/${userId}/dismissible-item/restore-test-2`)
|
|
154
|
-
.expect(400);
|
|
155
|
-
|
|
156
|
-
expect(response.body.error).toBeDefined();
|
|
157
|
-
expect(response.body.error.message).toContain('not dismissed');
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe('Full lifecycle flow', () => {
|
|
162
|
-
it('should handle create -> dismiss -> restore -> dismiss cycle', async () => {
|
|
163
|
-
const userId = 'lifecycle-user';
|
|
164
|
-
const itemId = 'lifecycle-test';
|
|
165
|
-
|
|
166
|
-
// Create
|
|
167
|
-
const createResponse = await request(app.getHttpServer())
|
|
168
|
-
.get(`/v1/user/${userId}/dismissible-item/${itemId}`)
|
|
169
|
-
.expect(200);
|
|
170
|
-
|
|
171
|
-
expect(createResponse.body.data.dismissedAt).toBeUndefined();
|
|
172
|
-
|
|
173
|
-
// Dismiss
|
|
174
|
-
const dismissResponse = await request(app.getHttpServer())
|
|
175
|
-
.delete(`/v1/user/${userId}/dismissible-item/${itemId}`)
|
|
176
|
-
.expect(200);
|
|
177
|
-
|
|
178
|
-
expect(dismissResponse.body.data.dismissedAt).toBeDefined();
|
|
179
|
-
|
|
180
|
-
// Restore
|
|
181
|
-
const restoreResponse = await request(app.getHttpServer())
|
|
182
|
-
.post(`/v1/user/${userId}/dismissible-item/${itemId}`)
|
|
183
|
-
.expect(201);
|
|
184
|
-
|
|
185
|
-
expect(restoreResponse.body.data.dismissedAt).toBeUndefined();
|
|
186
|
-
|
|
187
|
-
// Dismiss again
|
|
188
|
-
const dismissAgainResponse = await request(app.getHttpServer())
|
|
189
|
-
.delete(`/v1/user/${userId}/dismissible-item/${itemId}`)
|
|
190
|
-
.expect(200);
|
|
191
|
-
|
|
192
|
-
expect(dismissAgainResponse.body.data.dismissedAt).toBeDefined();
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('should preserve metadata through dismiss and restore', async () => {
|
|
196
|
-
const userId = 'user-metadata';
|
|
197
|
-
const itemId = 'metadata-preserve-test';
|
|
198
|
-
|
|
199
|
-
// Create with metadata
|
|
200
|
-
await request(app.getHttpServer())
|
|
201
|
-
.get(`/v1/user/${userId}/dismissible-item/${itemId}`)
|
|
202
|
-
.query({ metadata: ['key:value', 'count:42'] })
|
|
203
|
-
.expect(200);
|
|
204
|
-
|
|
205
|
-
// Dismiss
|
|
206
|
-
await request(app.getHttpServer())
|
|
207
|
-
.delete(`/v1/user/${userId}/dismissible-item/${itemId}`)
|
|
208
|
-
.expect(200);
|
|
209
|
-
|
|
210
|
-
// Restore and verify metadata
|
|
211
|
-
const restoreResponse = await request(app.getHttpServer())
|
|
212
|
-
.post(`/v1/user/${userId}/dismissible-item/${itemId}`)
|
|
213
|
-
.expect(201);
|
|
214
|
-
|
|
215
|
-
expect(restoreResponse.body.data.metadata).toEqual({
|
|
216
|
-
key: 'value',
|
|
217
|
-
count: 42,
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
});
|
package/src/utils/index.ts
DELETED