@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.
- 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
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
|
2
|
-
import {
|
|
2
|
+
import { TransformBoolean } from '@dismissible/nestjs-validation';
|
|
3
3
|
|
|
4
4
|
export class SwaggerConfig {
|
|
5
5
|
@IsBoolean()
|
|
6
|
-
@
|
|
7
|
-
if (typeof value === 'boolean') {
|
|
8
|
-
return value;
|
|
9
|
-
}
|
|
10
|
-
if (typeof value === 'string') {
|
|
11
|
-
return value.toLowerCase() === 'true';
|
|
12
|
-
}
|
|
13
|
-
return value;
|
|
14
|
-
})
|
|
6
|
+
@TransformBoolean()
|
|
15
7
|
public readonly enabled!: boolean;
|
|
16
8
|
|
|
17
9
|
@IsString()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { INestApplication } from '@nestjs/common';
|
|
2
|
+
import { DocumentBuilder } from '@nestjs/swagger';
|
|
3
|
+
import { configureAppWithSwagger } from './swagger.factory';
|
|
4
|
+
import { SwaggerConfig } from './swagger.config';
|
|
5
|
+
|
|
6
|
+
// Mock SwaggerModule and DocumentBuilder
|
|
7
|
+
const mockCreateDocument = jest.fn();
|
|
8
|
+
const mockSetup = jest.fn();
|
|
9
|
+
const mockSetTitle = jest.fn().mockReturnThis();
|
|
10
|
+
const mockSetDescription = jest.fn().mockReturnThis();
|
|
11
|
+
const mockSetVersion = jest.fn().mockReturnThis();
|
|
12
|
+
const mockBuild = jest.fn().mockReturnValue({});
|
|
13
|
+
|
|
14
|
+
jest.mock('@nestjs/swagger', () => ({
|
|
15
|
+
SwaggerModule: {
|
|
16
|
+
createDocument: (...args: any[]) => mockCreateDocument(...args),
|
|
17
|
+
setup: (...args: any[]) => mockSetup(...args),
|
|
18
|
+
},
|
|
19
|
+
DocumentBuilder: jest.fn().mockImplementation(() => ({
|
|
20
|
+
setTitle: mockSetTitle,
|
|
21
|
+
setDescription: mockSetDescription,
|
|
22
|
+
setVersion: mockSetVersion,
|
|
23
|
+
build: mockBuild,
|
|
24
|
+
})),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
describe('configureAppWithSwagger', () => {
|
|
28
|
+
let mockApp: jest.Mocked<INestApplication>;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
mockApp = {} as any;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should configure Swagger when enabled is true', () => {
|
|
36
|
+
const swaggerConfig: SwaggerConfig = {
|
|
37
|
+
enabled: true,
|
|
38
|
+
path: 'docs',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
configureAppWithSwagger(mockApp, swaggerConfig);
|
|
42
|
+
|
|
43
|
+
expect(DocumentBuilder).toHaveBeenCalled();
|
|
44
|
+
expect(mockSetTitle).toHaveBeenCalledWith('Dismissible');
|
|
45
|
+
expect(mockSetDescription).toHaveBeenCalledWith('An API to handle dismissible items for users');
|
|
46
|
+
expect(mockSetVersion).toHaveBeenCalledWith('1.0');
|
|
47
|
+
expect(mockBuild).toHaveBeenCalled();
|
|
48
|
+
expect(mockSetup).toHaveBeenCalledWith('docs', mockApp, expect.any(Function), {
|
|
49
|
+
useGlobalPrefix: true,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should use default path "docs" when path is not provided', () => {
|
|
54
|
+
const swaggerConfig: SwaggerConfig = {
|
|
55
|
+
enabled: true,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
configureAppWithSwagger(mockApp, swaggerConfig);
|
|
59
|
+
|
|
60
|
+
expect(mockSetup).toHaveBeenCalledWith('docs', mockApp, expect.any(Function), {
|
|
61
|
+
useGlobalPrefix: true,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should use custom path when provided', () => {
|
|
66
|
+
const swaggerConfig: SwaggerConfig = {
|
|
67
|
+
enabled: true,
|
|
68
|
+
path: 'api-docs',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
configureAppWithSwagger(mockApp, swaggerConfig);
|
|
72
|
+
|
|
73
|
+
expect(mockSetup).toHaveBeenCalledWith('api-docs', mockApp, expect.any(Function), {
|
|
74
|
+
useGlobalPrefix: true,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should not configure Swagger when enabled is false', () => {
|
|
79
|
+
const swaggerConfig: SwaggerConfig = {
|
|
80
|
+
enabled: false,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
configureAppWithSwagger(mockApp, swaggerConfig);
|
|
84
|
+
|
|
85
|
+
expect(DocumentBuilder).not.toHaveBeenCalled();
|
|
86
|
+
expect(mockSetup).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should create document with correct operationIdFactory', () => {
|
|
90
|
+
const swaggerConfig: SwaggerConfig = {
|
|
91
|
+
enabled: true,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
configureAppWithSwagger(mockApp, swaggerConfig);
|
|
95
|
+
|
|
96
|
+
const setupCall = mockSetup.mock.calls[0];
|
|
97
|
+
const documentFactory = setupCall[2];
|
|
98
|
+
documentFactory();
|
|
99
|
+
|
|
100
|
+
expect(mockCreateDocument).toHaveBeenCalledWith(
|
|
101
|
+
mockApp,
|
|
102
|
+
{},
|
|
103
|
+
expect.objectContaining({
|
|
104
|
+
operationIdFactory: expect.any(Function),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should use methodKey as operationId', () => {
|
|
110
|
+
const swaggerConfig: SwaggerConfig = {
|
|
111
|
+
enabled: true,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
configureAppWithSwagger(mockApp, swaggerConfig);
|
|
115
|
+
|
|
116
|
+
const setupCall = mockSetup.mock.calls[0];
|
|
117
|
+
const documentFactory = setupCall[2];
|
|
118
|
+
documentFactory();
|
|
119
|
+
|
|
120
|
+
const createDocumentCall = mockCreateDocument.mock.calls[0];
|
|
121
|
+
const operationIdFactory = createDocumentCall[2].operationIdFactory;
|
|
122
|
+
|
|
123
|
+
expect(operationIdFactory('UserController', 'createUser')).toBe('createUser');
|
|
124
|
+
expect(operationIdFactory('ItemController', 'getItem')).toBe('getItem');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './validation.config';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { IsBoolean, IsOptional } from 'class-validator';
|
|
2
|
+
import { TransformBoolean } from '@dismissible/nestjs-validation';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration for NestJS ValidationPipe
|
|
6
|
+
* @see https://docs.nestjs.com/techniques/validation
|
|
7
|
+
*/
|
|
8
|
+
export class ValidationConfig {
|
|
9
|
+
/**
|
|
10
|
+
* Whether to disable error messages in validation responses.
|
|
11
|
+
* Should be true in production to prevent information disclosure.
|
|
12
|
+
* @default true in production, false in development
|
|
13
|
+
*/
|
|
14
|
+
@IsBoolean()
|
|
15
|
+
@IsOptional()
|
|
16
|
+
@TransformBoolean()
|
|
17
|
+
public readonly disableErrorMessages?: boolean;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* If set to true, validator will strip validated (returned) object of any properties
|
|
21
|
+
* that do not use any validation decorators.
|
|
22
|
+
* @default true
|
|
23
|
+
*/
|
|
24
|
+
@IsBoolean()
|
|
25
|
+
@IsOptional()
|
|
26
|
+
@TransformBoolean()
|
|
27
|
+
public readonly whitelist?: boolean;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* If set to true, instead of stripping non-whitelisted properties,
|
|
31
|
+
* validator will throw an error.
|
|
32
|
+
* @default true
|
|
33
|
+
*/
|
|
34
|
+
@IsBoolean()
|
|
35
|
+
@IsOptional()
|
|
36
|
+
@TransformBoolean()
|
|
37
|
+
public readonly forbidNonWhitelisted?: boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* If set to true, class-transformer will attempt transformation based on TS reflected type.
|
|
41
|
+
* @default true
|
|
42
|
+
*/
|
|
43
|
+
@IsBoolean()
|
|
44
|
+
@IsOptional()
|
|
45
|
+
@TransformBoolean()
|
|
46
|
+
public readonly transform?: boolean;
|
|
47
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
server:
|
|
2
|
+
port: 3001
|
|
3
|
+
|
|
4
|
+
cors:
|
|
5
|
+
enabled: false
|
|
6
|
+
|
|
7
|
+
helmet:
|
|
8
|
+
enabled: false
|
|
9
|
+
|
|
10
|
+
swagger:
|
|
11
|
+
enabled: false
|
|
12
|
+
|
|
13
|
+
db:
|
|
14
|
+
connectionString: ${DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/dismissible}
|
|
15
|
+
|
|
16
|
+
jwtAuth:
|
|
17
|
+
enabled: false
|
|
18
|
+
|
|
19
|
+
validation:
|
|
20
|
+
disableErrorMessages: false
|
|
21
|
+
whitelist: true
|
|
22
|
+
forbidNonWhitelisted: true
|
|
23
|
+
transform: true
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
server:
|
|
2
|
+
port: 3001
|
|
3
|
+
|
|
4
|
+
cors:
|
|
5
|
+
enabled: false
|
|
6
|
+
|
|
7
|
+
helmet:
|
|
8
|
+
enabled: false
|
|
9
|
+
|
|
10
|
+
swagger:
|
|
11
|
+
enabled: false
|
|
12
|
+
|
|
13
|
+
db:
|
|
14
|
+
connectionString: ${DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/dismissible}
|
|
15
|
+
|
|
16
|
+
jwtAuth:
|
|
17
|
+
enabled: true
|
|
18
|
+
wellKnownUrl: https://auth.example.com/.well-known/openid-configuration
|
|
19
|
+
issuer: https://auth.example.com
|
|
20
|
+
audience: dismissible-api
|
|
21
|
+
|
|
22
|
+
validation:
|
|
23
|
+
disableErrorMessages: false
|
|
24
|
+
whitelist: true
|
|
25
|
+
forbidNonWhitelisted: true
|
|
26
|
+
transform: true
|
|
@@ -0,0 +1,64 @@
|
|
|
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('DELETE /v1/users/:userId/items/:id (dismiss)', () => {
|
|
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 dismiss an existing item', async () => {
|
|
24
|
+
const userId = 'user-dismiss-1';
|
|
25
|
+
// First create the item
|
|
26
|
+
await request(app.getHttpServer()).get(`/v1/users/${userId}/items/dismiss-test-1`).expect(200);
|
|
27
|
+
|
|
28
|
+
// Then dismiss it
|
|
29
|
+
const response = await request(app.getHttpServer())
|
|
30
|
+
.delete(`/v1/users/${userId}/items/dismiss-test-1`)
|
|
31
|
+
.expect(200);
|
|
32
|
+
|
|
33
|
+
expect(response.body.data).toBeDefined();
|
|
34
|
+
expect(response.body.data.dismissedAt).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return 400 when dismissing non-existent item', async () => {
|
|
38
|
+
const response = await request(app.getHttpServer())
|
|
39
|
+
.delete('/v1/users/user-123/items/non-existent-item')
|
|
40
|
+
.expect(400);
|
|
41
|
+
|
|
42
|
+
expect(response.body.error).toBeDefined();
|
|
43
|
+
expect(response.body.error.message).toContain('not found');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should return 400 when dismissing already dismissed item', async () => {
|
|
47
|
+
const userId = 'user-dismiss-2';
|
|
48
|
+
// Create the item
|
|
49
|
+
await request(app.getHttpServer()).get(`/v1/users/${userId}/items/dismiss-test-2`).expect(200);
|
|
50
|
+
|
|
51
|
+
// Dismiss it first time
|
|
52
|
+
await request(app.getHttpServer())
|
|
53
|
+
.delete(`/v1/users/${userId}/items/dismiss-test-2`)
|
|
54
|
+
.expect(200);
|
|
55
|
+
|
|
56
|
+
// Try to dismiss again
|
|
57
|
+
const response = await request(app.getHttpServer())
|
|
58
|
+
.delete(`/v1/users/${userId}/items/dismiss-test-2`)
|
|
59
|
+
.expect(400);
|
|
60
|
+
|
|
61
|
+
expect(response.body.error).toBeDefined();
|
|
62
|
+
expect(response.body.error.message).toContain('already dismissed');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
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('Full lifecycle flow', () => {
|
|
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
|
+
if (app) {
|
|
20
|
+
await cleanupTestData(app);
|
|
21
|
+
await app.close();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should handle create -> dismiss -> restore -> dismiss cycle', async () => {
|
|
26
|
+
const userId = 'lifecycle-user';
|
|
27
|
+
const itemId = 'lifecycle-test';
|
|
28
|
+
|
|
29
|
+
// Create
|
|
30
|
+
const createResponse = await request(app.getHttpServer())
|
|
31
|
+
.get(`/v1/users/${userId}/items/${itemId}`)
|
|
32
|
+
.expect(200);
|
|
33
|
+
|
|
34
|
+
expect(createResponse.body.data.dismissedAt).toBeUndefined();
|
|
35
|
+
|
|
36
|
+
// Dismiss
|
|
37
|
+
const dismissResponse = await request(app.getHttpServer())
|
|
38
|
+
.delete(`/v1/users/${userId}/items/${itemId}`)
|
|
39
|
+
.expect(200);
|
|
40
|
+
|
|
41
|
+
expect(dismissResponse.body.data.dismissedAt).toBeDefined();
|
|
42
|
+
|
|
43
|
+
// Restore
|
|
44
|
+
const restoreResponse = await request(app.getHttpServer())
|
|
45
|
+
.post(`/v1/users/${userId}/items/${itemId}`)
|
|
46
|
+
.expect(201);
|
|
47
|
+
|
|
48
|
+
expect(restoreResponse.body.data.dismissedAt).toBeUndefined();
|
|
49
|
+
|
|
50
|
+
// Dismiss again
|
|
51
|
+
const dismissAgainResponse = await request(app.getHttpServer())
|
|
52
|
+
.delete(`/v1/users/${userId}/items/${itemId}`)
|
|
53
|
+
.expect(200);
|
|
54
|
+
|
|
55
|
+
expect(dismissAgainResponse.body.data.dismissedAt).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
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('GET /v1/users/:userId/items/:id (get-or-create)', () => {
|
|
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 create a new item on first request', async () => {
|
|
24
|
+
const response = await request(app.getHttpServer())
|
|
25
|
+
.get('/v1/users/user-123/items/test-banner-1')
|
|
26
|
+
.expect(200);
|
|
27
|
+
|
|
28
|
+
expect(response.body.data).toBeDefined();
|
|
29
|
+
expect(response.body.data.itemId).toBe('test-banner-1');
|
|
30
|
+
expect(response.body.data.userId).toBe('user-123');
|
|
31
|
+
expect(response.body.data.createdAt).toBeDefined();
|
|
32
|
+
expect(response.body.data.dismissedAt).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return existing item on subsequent requests', async () => {
|
|
36
|
+
// First request - creates the item
|
|
37
|
+
const firstResponse = await request(app.getHttpServer())
|
|
38
|
+
.get('/v1/users/user-456/items/test-banner-2')
|
|
39
|
+
.expect(200);
|
|
40
|
+
|
|
41
|
+
const createdAt = firstResponse.body.data.createdAt;
|
|
42
|
+
|
|
43
|
+
// Second request - returns existing item
|
|
44
|
+
const secondResponse = await request(app.getHttpServer())
|
|
45
|
+
.get('/v1/users/user-456/items/test-banner-2')
|
|
46
|
+
.expect(200);
|
|
47
|
+
|
|
48
|
+
expect(secondResponse.body.data.itemId).toBe('test-banner-2');
|
|
49
|
+
expect(secondResponse.body.data.createdAt).toBe(createdAt);
|
|
50
|
+
});
|
|
51
|
+
});
|