@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.
@@ -0,0 +1,8 @@
1
+ server:
2
+ port: ${DISMISSIBLE_PORT:-3001}
3
+
4
+ swagger:
5
+ enabled: ${DISMISSIBLE_SWAGGER_ENABLED:-true}
6
+
7
+ db:
8
+ connectionString: ${DISMISSIBLE_POSTGRES_STORAGE_CONNECTION_STRING:-'postgresql://postgres:postgres@localhost:5432/dismissible'}
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
+ }
@@ -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
+ }
@@ -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,2 @@
1
+ export * from './app.config';
2
+ export * from './config.module';
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export * from './health.module';
2
+ export * from './health.controller';
3
+ export * from './health.service';
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './config';
2
+ export * from './server';
3
+ export * from './bootstrap';
4
+ export * from './app.module';
5
+ export * from './app.setup';
package/src/main.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { bootstrap } from './bootstrap';
2
+
3
+ bootstrap();
@@ -0,0 +1 @@
1
+ export * from './server.config';
@@ -0,0 +1,8 @@
1
+ import { IsNumber } from 'class-validator';
2
+ import { Type } from 'class-transformer';
3
+
4
+ export class ServerConfig {
5
+ @IsNumber()
6
+ @Type(() => Number)
7
+ public readonly port!: number;
8
+ }
@@ -0,0 +1,2 @@
1
+ export * from './swagger.factory';
2
+ export * from './swagger.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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../tsconfig.base.json",
3
+ "files": [],
4
+ "include": [],
5
+ "references": [
6
+ {
7
+ "path": "./tsconfig.app.json"
8
+ }
9
+ ],
10
+ "compilerOptions": {
11
+ "esModuleInterop": true
12
+ }
13
+ }