@dismissible/nestjs-api 0.0.2-canary.8976e84.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 +8 -0
- package/jest.config.ts +13 -0
- package/jest.e2e-config.ts +12 -0
- package/nest-cli.json +16 -0
- package/package.json +49 -0
- package/project.json +91 -0
- package/src/app-test.factory.ts +32 -0
- package/src/app.e2e-spec.ts +221 -0
- package/src/app.module.ts +48 -0
- package/src/app.setup.ts +17 -0
- package/src/bootstrap.ts +24 -0
- package/src/config/app.config.ts +15 -0
- package/src/config/config.module.spec.ts +96 -0
- package/src/config/config.module.ts +50 -0
- package/src/config/default-app.config.ts +9 -0
- package/src/config/index.ts +2 -0
- package/src/health/health.controller.spec.ts +48 -0
- package/src/health/health.controller.ts +17 -0
- package/src/health/health.module.ts +10 -0
- package/src/health/health.service.spec.ts +46 -0
- package/src/health/health.service.ts +16 -0
- package/src/health/index.ts +3 -0
- package/src/index.ts +5 -0
- package/src/main.ts +3 -0
- package/src/server/index.ts +1 -0
- package/src/server/server.config.ts +8 -0
- package/src/swagger/index.ts +2 -0
- package/src/swagger/swagger.config.ts +20 -0
- package/src/swagger/swagger.factory.ts +25 -0
- package/tsconfig.app.json +13 -0
- package/tsconfig.json +13 -0
package/config/.env.yaml
ADDED
package/jest.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
displayName: 'api',
|
|
3
|
+
preset: '../jest.preset.js',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
transform: {
|
|
6
|
+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }],
|
|
7
|
+
},
|
|
8
|
+
moduleFileExtensions: ['ts', 'js', 'html'],
|
|
9
|
+
coverageDirectory: '../coverage/api',
|
|
10
|
+
testMatch: ['**/*.spec.ts'],
|
|
11
|
+
testPathIgnorePatterns: ['\\.e2e-spec\\.ts$'],
|
|
12
|
+
transformIgnorePatterns: ['node_modules/(?!(nest-typed-config|uuid)/)'],
|
|
13
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
displayName: 'api-e2e',
|
|
3
|
+
preset: '../jest.preset.js',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
transform: {
|
|
6
|
+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }],
|
|
7
|
+
},
|
|
8
|
+
moduleFileExtensions: ['ts', 'js', 'html'],
|
|
9
|
+
coverageDirectory: '../coverage/api-e2e',
|
|
10
|
+
testMatch: ['**/*.e2e-spec.ts'],
|
|
11
|
+
transformIgnorePatterns: ['node_modules/(?!(nest-typed-config|uuid)/)'],
|
|
12
|
+
};
|
package/nest-cli.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/nest-cli",
|
|
3
|
+
"collection": "@nestjs/schematics",
|
|
4
|
+
"sourceRoot": "src",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"deleteOutDir": true,
|
|
7
|
+
"tsConfigPath": "tsconfig.app.json",
|
|
8
|
+
"assets": [
|
|
9
|
+
{
|
|
10
|
+
"include": "../config/**/*.yaml",
|
|
11
|
+
"outDir": "../dist/api/config",
|
|
12
|
+
"watchAssets": true
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dismissible/nestjs-api",
|
|
3
|
+
"version": "0.0.2-canary.8976e84.0",
|
|
4
|
+
"description": "Dismissible API application",
|
|
5
|
+
"main": "./src/main.js",
|
|
6
|
+
"types": "./src/main.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/main.mjs",
|
|
10
|
+
"require": "./src/main.js",
|
|
11
|
+
"types": "./src/main.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"dismissible-api": "./src/main.js"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node src/main.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@dismissible/nestjs-dismissible": "^0.0.2-canary.8976e84.0",
|
|
22
|
+
"@dismissible/nestjs-dismissible-item": "^0.0.2-canary.8976e84.0",
|
|
23
|
+
"@dismissible/nestjs-storage": "^0.0.2-canary.8976e84.0",
|
|
24
|
+
"@dismissible/nestjs-postgres-storage": "^0.0.2-canary.8976e84.0",
|
|
25
|
+
"@dismissible/nestjs-logger": "^0.0.2-canary.8976e84.0",
|
|
26
|
+
"@nestjs/common": "^11.1.9",
|
|
27
|
+
"@nestjs/core": "^11.1.9",
|
|
28
|
+
"@nestjs/platform-express": "^11.1.9",
|
|
29
|
+
"@nestjs/swagger": "^11.0.0",
|
|
30
|
+
"class-transformer": "^0.5.1",
|
|
31
|
+
"class-validator": "^0.14.1",
|
|
32
|
+
"nest-typed-config": "^2.0.0",
|
|
33
|
+
"reflect-metadata": "^0.2.2",
|
|
34
|
+
"rxjs": "^7.8.2"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"nestjs",
|
|
38
|
+
"api"
|
|
39
|
+
],
|
|
40
|
+
"author": "",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/DismissibleIo/dismissible-api"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "api",
|
|
3
|
+
"$schema": "../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "api/src",
|
|
5
|
+
"projectType": "application",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"targets": {
|
|
8
|
+
"build": {
|
|
9
|
+
"executor": "@nx/js:tsc",
|
|
10
|
+
"outputs": ["{options.outputPath}"],
|
|
11
|
+
"dependsOn": ["^build"],
|
|
12
|
+
"options": {
|
|
13
|
+
"outputPath": "dist/api",
|
|
14
|
+
"main": "api/src/main.ts",
|
|
15
|
+
"tsConfig": "api/tsconfig.app.json",
|
|
16
|
+
"assets": [
|
|
17
|
+
{
|
|
18
|
+
"input": "api/config",
|
|
19
|
+
"glob": "**/*.yaml",
|
|
20
|
+
"output": "config"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"generatePackageJson": true
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"serve": {
|
|
27
|
+
"executor": "@nx/js:node",
|
|
28
|
+
"options": {
|
|
29
|
+
"buildTarget": "api:build",
|
|
30
|
+
"runBuildTargetDependencies": true,
|
|
31
|
+
"watch": true
|
|
32
|
+
},
|
|
33
|
+
"configurations": {
|
|
34
|
+
"development": {
|
|
35
|
+
"buildTarget": "api:build"
|
|
36
|
+
},
|
|
37
|
+
"production": {
|
|
38
|
+
"buildTarget": "api:build"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"defaultConfiguration": "development"
|
|
42
|
+
},
|
|
43
|
+
"lint": {
|
|
44
|
+
"executor": "@nx/eslint:lint",
|
|
45
|
+
"outputs": ["{options.outputFile}"],
|
|
46
|
+
"options": {
|
|
47
|
+
"lintFilePatterns": ["api/**/*.ts"]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"test": {
|
|
51
|
+
"executor": "@nx/jest:jest",
|
|
52
|
+
"outputs": ["{workspaceRoot}/coverage/api"],
|
|
53
|
+
"options": {
|
|
54
|
+
"jestConfig": "api/jest.config.ts",
|
|
55
|
+
"passWithNoTests": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"test-e2e": {
|
|
59
|
+
"executor": "@nx/jest:jest",
|
|
60
|
+
"outputs": ["{workspaceRoot}/coverage/api-e2e"],
|
|
61
|
+
"options": {
|
|
62
|
+
"jestConfig": "api/jest.e2e-config.ts"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"prisma:generate": {
|
|
66
|
+
"executor": "nx:run-commands",
|
|
67
|
+
"options": {
|
|
68
|
+
"command": "npx prisma generate --schema=libs/postgres-storage/prisma/schema.prisma"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"prisma:migrate": {
|
|
72
|
+
"executor": "nx:run-commands",
|
|
73
|
+
"options": {
|
|
74
|
+
"command": "npx prisma migrate dev --schema=libs/postgres-storage/prisma/schema.prisma"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"prisma:push": {
|
|
78
|
+
"executor": "nx:run-commands",
|
|
79
|
+
"options": {
|
|
80
|
+
"command": "npx prisma db push --schema=libs/postgres-storage/prisma/schema.prisma"
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"npm-publish": {
|
|
84
|
+
"executor": "nx:run-commands",
|
|
85
|
+
"options": {
|
|
86
|
+
"command": "npm publish --access public",
|
|
87
|
+
"cwd": "dist/api"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Test, TestingModuleBuilder } from '@nestjs/testing';
|
|
2
|
+
import { INestApplication } from '@nestjs/common';
|
|
3
|
+
import { AppModule, AppModuleOptions } from './app.module';
|
|
4
|
+
import { configureApp } from './app.setup';
|
|
5
|
+
import { PrismaService } from '@dismissible/nestjs-postgres-storage';
|
|
6
|
+
|
|
7
|
+
export type TestAppOptions = {
|
|
8
|
+
moduleOptions?: AppModuleOptions;
|
|
9
|
+
customize?: (builder: TestingModuleBuilder) => TestingModuleBuilder;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function createTestApp(options?: TestAppOptions): Promise<INestApplication> {
|
|
13
|
+
let builder = Test.createTestingModule({
|
|
14
|
+
imports: [AppModule.forRoot(options?.moduleOptions)],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (options?.customize) {
|
|
18
|
+
builder = options.customize(builder);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const moduleFixture = await builder.compile();
|
|
22
|
+
const app = moduleFixture.createNestApplication();
|
|
23
|
+
await configureApp(app);
|
|
24
|
+
await app.init();
|
|
25
|
+
|
|
26
|
+
return app;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function cleanupTestData(app: INestApplication): Promise<void> {
|
|
30
|
+
const prisma = app.get(PrismaService);
|
|
31
|
+
await prisma.dismissibleItem.deleteMany({});
|
|
32
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
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 app.close();
|
|
20
|
+
await cleanupTestData(app);
|
|
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
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Module, ModuleMetadata, Type } from '@nestjs/common';
|
|
2
|
+
import { HealthModule } from './health';
|
|
3
|
+
import { ConfigModule } from './config';
|
|
4
|
+
import { AppConfig } from './config/app.config';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { DismissibleModule } from '@dismissible/nestjs-dismissible';
|
|
7
|
+
import { IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
8
|
+
import { DefaultAppConfig } from './config/default-app.config';
|
|
9
|
+
import { PostgresStorageConfig, PostgresStorageModule } from '@dismissible/nestjs-postgres-storage';
|
|
10
|
+
|
|
11
|
+
export type AppModuleOptions = {
|
|
12
|
+
configPath?: string;
|
|
13
|
+
schema?: new () => DefaultAppConfig;
|
|
14
|
+
logger?: Type<IDismissibleLogger>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
@Module({})
|
|
18
|
+
export class AppModule {
|
|
19
|
+
static forRoot(options?: AppModuleOptions) {
|
|
20
|
+
return {
|
|
21
|
+
module: AppModule,
|
|
22
|
+
...this.getModuleMetadata(options),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static getModuleMetadata(options?: AppModuleOptions): ModuleMetadata {
|
|
27
|
+
return {
|
|
28
|
+
imports: [
|
|
29
|
+
ConfigModule.forRoot({
|
|
30
|
+
path: options?.configPath ?? join(__dirname, '../config'),
|
|
31
|
+
schema: options?.schema ?? AppConfig,
|
|
32
|
+
}),
|
|
33
|
+
HealthModule,
|
|
34
|
+
DismissibleModule.forRoot({
|
|
35
|
+
logger: options?.logger,
|
|
36
|
+
storage: PostgresStorageModule.forRootAsync({
|
|
37
|
+
useFactory(config: PostgresStorageConfig) {
|
|
38
|
+
return {
|
|
39
|
+
connectionString: config.connectionString,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
inject: [PostgresStorageConfig],
|
|
43
|
+
}),
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/app.setup.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { SwaggerConfig, configureAppWithSwagger } from './swagger';
|
|
3
|
+
|
|
4
|
+
export async function configureApp(app: INestApplication): Promise<void> {
|
|
5
|
+
// Global validation pipe for request validation
|
|
6
|
+
app.useGlobalPipes(
|
|
7
|
+
new ValidationPipe({
|
|
8
|
+
whitelist: true,
|
|
9
|
+
forbidNonWhitelisted: true,
|
|
10
|
+
transform: true,
|
|
11
|
+
disableErrorMessages: false,
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const swaggerConfig = app.get(SwaggerConfig);
|
|
16
|
+
configureAppWithSwagger(app, swaggerConfig);
|
|
17
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NestFactory } from '@nestjs/core';
|
|
2
|
+
import { AppModule } from './app.module';
|
|
3
|
+
import { ServerConfig } from './server/server.config';
|
|
4
|
+
import { configureApp } from './app.setup';
|
|
5
|
+
import { DefaultAppConfig } from './config/default-app.config';
|
|
6
|
+
import { IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
7
|
+
import { Type } from '@nestjs/common';
|
|
8
|
+
|
|
9
|
+
export type IBootstrapOptions = {
|
|
10
|
+
configPath?: string;
|
|
11
|
+
schema?: new () => DefaultAppConfig;
|
|
12
|
+
logger?: Type<IDismissibleLogger>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function bootstrap(options?: IBootstrapOptions) {
|
|
16
|
+
const app = await NestFactory.create(AppModule.forRoot(options));
|
|
17
|
+
|
|
18
|
+
await configureApp(app);
|
|
19
|
+
|
|
20
|
+
const serverConfig = app.get(ServerConfig);
|
|
21
|
+
const port = serverConfig.port ?? 3000;
|
|
22
|
+
await app.listen(port);
|
|
23
|
+
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ValidateNested } from 'class-validator';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { SwaggerConfig } from '../swagger';
|
|
4
|
+
import { DefaultAppConfig } from './default-app.config';
|
|
5
|
+
import { PostgresStorageConfig } from '@dismissible/nestjs-postgres-storage';
|
|
6
|
+
|
|
7
|
+
export class AppConfig extends DefaultAppConfig {
|
|
8
|
+
@ValidateNested()
|
|
9
|
+
@Type(() => SwaggerConfig)
|
|
10
|
+
public readonly swagger!: SwaggerConfig;
|
|
11
|
+
|
|
12
|
+
@ValidateNested()
|
|
13
|
+
@Type(() => PostgresStorageConfig)
|
|
14
|
+
public readonly db!: PostgresStorageConfig;
|
|
15
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ConfigModule } from './config.module';
|
|
2
|
+
import { TypedConfigModule } from 'nest-typed-config';
|
|
3
|
+
import { fileLoader } from 'nest-typed-config';
|
|
4
|
+
|
|
5
|
+
// Mock nest-typed-config
|
|
6
|
+
jest.mock('nest-typed-config', () => ({
|
|
7
|
+
TypedConfigModule: {
|
|
8
|
+
forRoot: jest.fn(),
|
|
9
|
+
},
|
|
10
|
+
fileLoader: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock path
|
|
14
|
+
jest.mock('path', () => ({
|
|
15
|
+
join: jest.fn((...args) => args.join('/')),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe('ConfigModule', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
jest.clearAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('forRoot', () => {
|
|
24
|
+
it('should return a dynamic module with default path', () => {
|
|
25
|
+
const mockSchema = class TestSchema {};
|
|
26
|
+
const mockFileLoader = jest.fn();
|
|
27
|
+
(fileLoader as jest.Mock).mockReturnValue(mockFileLoader);
|
|
28
|
+
(TypedConfigModule.forRoot as jest.Mock).mockReturnValue({});
|
|
29
|
+
|
|
30
|
+
const result = ConfigModule.forRoot({
|
|
31
|
+
schema: mockSchema,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(result.module).toBe(ConfigModule);
|
|
35
|
+
expect(result.exports).toContain(TypedConfigModule);
|
|
36
|
+
expect(fileLoader).toHaveBeenCalledWith({
|
|
37
|
+
searchFrom: expect.stringContaining('config'),
|
|
38
|
+
ignoreEnvironmentVariableSubstitution: false,
|
|
39
|
+
});
|
|
40
|
+
expect(TypedConfigModule.forRoot).toHaveBeenCalledWith({
|
|
41
|
+
schema: mockSchema,
|
|
42
|
+
load: [mockFileLoader],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should use custom path when provided', () => {
|
|
47
|
+
const mockSchema = class TestSchema {};
|
|
48
|
+
const customPath = '/custom/config/path';
|
|
49
|
+
const mockFileLoader = jest.fn();
|
|
50
|
+
(fileLoader as jest.Mock).mockReturnValue(mockFileLoader);
|
|
51
|
+
(TypedConfigModule.forRoot as jest.Mock).mockReturnValue({});
|
|
52
|
+
|
|
53
|
+
const result = ConfigModule.forRoot({
|
|
54
|
+
schema: mockSchema,
|
|
55
|
+
path: customPath,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result.module).toBe(ConfigModule);
|
|
59
|
+
expect(fileLoader).toHaveBeenCalledWith({
|
|
60
|
+
searchFrom: customPath,
|
|
61
|
+
ignoreEnvironmentVariableSubstitution: false,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should respect ignoreEnvironmentVariableSubstitution option', () => {
|
|
66
|
+
const mockSchema = class TestSchema {};
|
|
67
|
+
const mockFileLoader = jest.fn();
|
|
68
|
+
(fileLoader as jest.Mock).mockReturnValue(mockFileLoader);
|
|
69
|
+
(TypedConfigModule.forRoot as jest.Mock).mockReturnValue({});
|
|
70
|
+
|
|
71
|
+
ConfigModule.forRoot({
|
|
72
|
+
schema: mockSchema,
|
|
73
|
+
ignoreEnvironmentVariableSubstitution: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(fileLoader).toHaveBeenCalledWith({
|
|
77
|
+
searchFrom: expect.any(String),
|
|
78
|
+
ignoreEnvironmentVariableSubstitution: true,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should include TypedConfigModule in imports', () => {
|
|
83
|
+
const mockSchema = class TestSchema {};
|
|
84
|
+
const mockFileLoader = jest.fn();
|
|
85
|
+
(fileLoader as jest.Mock).mockReturnValue(mockFileLoader);
|
|
86
|
+
const mockTypedConfigModule = {};
|
|
87
|
+
(TypedConfigModule.forRoot as jest.Mock).mockReturnValue(mockTypedConfigModule);
|
|
88
|
+
|
|
89
|
+
const result = ConfigModule.forRoot({
|
|
90
|
+
schema: mockSchema,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.imports).toContain(mockTypedConfigModule);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { DynamicModule, Global, Module } from '@nestjs/common';
|
|
2
|
+
import { fileLoader, TypedConfigModule } from 'nest-typed-config';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
export interface IConfigModuleOptions<T> {
|
|
6
|
+
/**
|
|
7
|
+
* The configuration schema class to validate against
|
|
8
|
+
*/
|
|
9
|
+
schema: new () => T;
|
|
10
|
+
/**
|
|
11
|
+
* The path to search for configuration files
|
|
12
|
+
* Defaults to a 'config' directory two levels up from the current file
|
|
13
|
+
*/
|
|
14
|
+
path?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether to ignore environment variable substitution in config files
|
|
17
|
+
* Defaults to false (environment variables will be substituted)
|
|
18
|
+
*/
|
|
19
|
+
ignoreEnvironmentVariableSubstitution?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Global()
|
|
23
|
+
@Module({})
|
|
24
|
+
export class ConfigModule {
|
|
25
|
+
/**
|
|
26
|
+
* Register the ConfigModule with a configuration schema
|
|
27
|
+
* @param options Configuration options including the schema class
|
|
28
|
+
* @returns A dynamic module configured with TypedConfigModule
|
|
29
|
+
*/
|
|
30
|
+
static forRoot<T extends object>(options: IConfigModuleOptions<T>): DynamicModule {
|
|
31
|
+
const configPath = options.path ?? join(__dirname, '../../config');
|
|
32
|
+
const ignoreEnvSubstitution = options.ignoreEnvironmentVariableSubstitution ?? false;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
module: ConfigModule,
|
|
36
|
+
imports: [
|
|
37
|
+
TypedConfigModule.forRoot({
|
|
38
|
+
schema: options.schema,
|
|
39
|
+
load: [
|
|
40
|
+
fileLoader({
|
|
41
|
+
searchFrom: configPath,
|
|
42
|
+
ignoreEnvironmentVariableSubstitution: ignoreEnvSubstitution,
|
|
43
|
+
}),
|
|
44
|
+
],
|
|
45
|
+
}),
|
|
46
|
+
],
|
|
47
|
+
exports: [TypedConfigModule],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ValidateNested } from 'class-validator';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { ServerConfig } from '../server/server.config';
|
|
4
|
+
|
|
5
|
+
export class DefaultAppConfig {
|
|
6
|
+
@ValidateNested()
|
|
7
|
+
@Type(() => ServerConfig)
|
|
8
|
+
public readonly server!: ServerConfig;
|
|
9
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mock, Mock } from 'ts-jest-mocker';
|
|
2
|
+
import { HealthController } from './health.controller';
|
|
3
|
+
import { HealthService } from './health.service';
|
|
4
|
+
|
|
5
|
+
describe('HealthController', () => {
|
|
6
|
+
let controller: HealthController;
|
|
7
|
+
let healthService: Mock<HealthService>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
healthService = mock<HealthService>({
|
|
11
|
+
failIfMockNotProvided: false,
|
|
12
|
+
});
|
|
13
|
+
controller = new HealthController(healthService);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('getHealth', () => {
|
|
17
|
+
it('should return health status from service', () => {
|
|
18
|
+
const mockHealth = {
|
|
19
|
+
status: 'ok',
|
|
20
|
+
timestamp: '2024-01-15T10:00:00.000Z',
|
|
21
|
+
uptime: 123.45,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
healthService.getHealth.mockReturnValue(mockHealth);
|
|
25
|
+
|
|
26
|
+
const result = controller.getHealth();
|
|
27
|
+
|
|
28
|
+
expect(result).toEqual(mockHealth);
|
|
29
|
+
expect(healthService.getHealth).toHaveBeenCalledTimes(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return the exact response from service', () => {
|
|
33
|
+
const mockHealth = {
|
|
34
|
+
status: 'ok',
|
|
35
|
+
timestamp: '2024-01-20T15:30:00.000Z',
|
|
36
|
+
uptime: 999.99,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
healthService.getHealth.mockReturnValue(mockHealth);
|
|
40
|
+
|
|
41
|
+
const result = controller.getHealth();
|
|
42
|
+
|
|
43
|
+
expect(result.status).toBe('ok');
|
|
44
|
+
expect(result.timestamp).toBe('2024-01-20T15:30:00.000Z');
|
|
45
|
+
expect(result.uptime).toBe(999.99);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common';
|
|
2
|
+
import { HealthService } from './health.service';
|
|
3
|
+
|
|
4
|
+
@Controller('health')
|
|
5
|
+
export class HealthController {
|
|
6
|
+
constructor(private readonly healthService: HealthService) {}
|
|
7
|
+
|
|
8
|
+
@Get()
|
|
9
|
+
@HttpCode(HttpStatus.OK)
|
|
10
|
+
getHealth(): {
|
|
11
|
+
status: string;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
uptime: number;
|
|
14
|
+
} {
|
|
15
|
+
return this.healthService.getHealth();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { HealthController } from './health.controller';
|
|
3
|
+
import { HealthService } from './health.service';
|
|
4
|
+
|
|
5
|
+
@Module({
|
|
6
|
+
controllers: [HealthController],
|
|
7
|
+
providers: [HealthService],
|
|
8
|
+
exports: [HealthService],
|
|
9
|
+
})
|
|
10
|
+
export class HealthModule {}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { HealthService } from './health.service';
|
|
2
|
+
|
|
3
|
+
describe('HealthService', () => {
|
|
4
|
+
let service: HealthService;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
service = new HealthService();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('getHealth', () => {
|
|
11
|
+
it('should return health status with ok status', () => {
|
|
12
|
+
const result = service.getHealth();
|
|
13
|
+
|
|
14
|
+
expect(result.status).toBe('ok');
|
|
15
|
+
expect(result).toHaveProperty('timestamp');
|
|
16
|
+
expect(result).toHaveProperty('uptime');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should return a valid ISO timestamp', () => {
|
|
20
|
+
const result = service.getHealth();
|
|
21
|
+
const timestamp = new Date(result.timestamp);
|
|
22
|
+
|
|
23
|
+
expect(timestamp.toISOString()).toBe(result.timestamp);
|
|
24
|
+
expect(timestamp.getTime()).toBeGreaterThan(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return process uptime', () => {
|
|
28
|
+
const result = service.getHealth();
|
|
29
|
+
|
|
30
|
+
expect(typeof result.uptime).toBe('number');
|
|
31
|
+
expect(result.uptime).toBeGreaterThanOrEqual(0);
|
|
32
|
+
// Uptime should be approximately equal to process.uptime() (within 0.1 seconds)
|
|
33
|
+
expect(Math.abs(result.uptime - process.uptime())).toBeLessThan(0.1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should return current timestamp', () => {
|
|
37
|
+
const before = new Date();
|
|
38
|
+
const result = service.getHealth();
|
|
39
|
+
const after = new Date();
|
|
40
|
+
const timestamp = new Date(result.timestamp);
|
|
41
|
+
|
|
42
|
+
expect(timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
|
43
|
+
expect(timestamp.getTime()).toBeLessThanOrEqual(after.getTime());
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
@Injectable()
|
|
4
|
+
export class HealthService {
|
|
5
|
+
getHealth(): {
|
|
6
|
+
status: string;
|
|
7
|
+
timestamp: string;
|
|
8
|
+
uptime: number;
|
|
9
|
+
} {
|
|
10
|
+
return {
|
|
11
|
+
status: 'ok',
|
|
12
|
+
timestamp: new Date().toISOString(),
|
|
13
|
+
uptime: process.uptime(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/index.ts
ADDED
package/src/main.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './server.config';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
|
2
|
+
import { Transform } from 'class-transformer';
|
|
3
|
+
|
|
4
|
+
export class SwaggerConfig {
|
|
5
|
+
@IsBoolean()
|
|
6
|
+
@Transform(({ value }) => {
|
|
7
|
+
if (typeof value === 'boolean') {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
if (typeof value === 'string') {
|
|
11
|
+
return value.toLowerCase() === 'true';
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
})
|
|
15
|
+
public readonly enabled!: boolean;
|
|
16
|
+
|
|
17
|
+
@IsString()
|
|
18
|
+
@IsOptional()
|
|
19
|
+
public readonly path?: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { INestApplication } from '@nestjs/common';
|
|
2
|
+
import { DocumentBuilder, SwaggerDocumentOptions, SwaggerModule } from '@nestjs/swagger';
|
|
3
|
+
import { SwaggerConfig } from './swagger.config';
|
|
4
|
+
|
|
5
|
+
const swaggerDocumentOptions: SwaggerDocumentOptions = {
|
|
6
|
+
// operation names like createUser instead of UserController_createUser
|
|
7
|
+
operationIdFactory: (_controllerKey: string, methodKey: string) => methodKey,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function configureAppWithSwagger(app: INestApplication, swaggerConfig: SwaggerConfig) {
|
|
11
|
+
if (swaggerConfig.enabled) {
|
|
12
|
+
const { path = 'docs' } = swaggerConfig;
|
|
13
|
+
|
|
14
|
+
const config = new DocumentBuilder()
|
|
15
|
+
.setTitle('Dismissible')
|
|
16
|
+
.setDescription('An API to handle dismissible items for users')
|
|
17
|
+
.setVersion('1.0')
|
|
18
|
+
.build();
|
|
19
|
+
|
|
20
|
+
const documentFactory = () => SwaggerModule.createDocument(app, config, swaggerDocumentOptions);
|
|
21
|
+
SwaggerModule.setup(path, app, documentFactory, {
|
|
22
|
+
useGlobalPrefix: true,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../dist/out-tsc",
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"types": ["node"],
|
|
7
|
+
"emitDecoratorMetadata": true,
|
|
8
|
+
"experimentalDecorators": true,
|
|
9
|
+
"target": "ES2021"
|
|
10
|
+
},
|
|
11
|
+
"exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts", "**/*.e2e-spec.ts"],
|
|
12
|
+
"include": ["src/**/*.ts"]
|
|
13
|
+
}
|