diff --git a/api/package.json b/api/package.json index 95c26e98..da824681 100644 --- a/api/package.json +++ b/api/package.json @@ -24,6 +24,7 @@ "@nestjs/jwt": "10.2.0", "@nestjs/passport": "10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/terminus": "10.2.3", "@nestjs/typeorm": "^10.0.2", "@ts-rest/nest": "3.45.2", "bcrypt": "5.1.1", diff --git a/api/src/app.controller.ts b/api/src/app.controller.ts index 3345dcd1..2dc14b1d 100644 --- a/api/src/app.controller.ts +++ b/api/src/app.controller.ts @@ -3,19 +3,33 @@ import { AppService } from '@api/app.service'; import { tsRestHandler, TsRestHandler } from '@ts-rest/nest'; import { contactContract as c } from '@shared/contracts/contact.contract'; import { Public } from '@api/decorators/is-public.decorator'; +import { ControllerResponse } from '@api/types/controller.type'; +import { + HealthCheck, + HealthCheckService, + TypeOrmHealthIndicator, +} from '@nestjs/terminus'; @Controller() export class AppController { - constructor(private readonly appService: AppService) {} + constructor( + private readonly health: HealthCheckService, + private readonly db: TypeOrmHealthIndicator, + private readonly appService: AppService, + ) {} @Public() - @Get() - getHello(): string { - return this.appService.getHello(); + @Get('/health') + @HealthCheck({ noCache: true }) + public checkHealth(): ControllerResponse { + return this.health.check([ + async () => this.db.pingCheck('database', { timeout: 1500 }), + ]); } + @Public() @TsRestHandler(c.contact) - async recoverPassword(): Promise { + async recoverPassword(): Promise { return tsRestHandler(c.contact, async ({ body }) => { await this.appService.contact(body); return { body: null, status: HttpStatus.CREATED }; diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 17767d84..da5303d8 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -17,9 +17,13 @@ import { WidgetsModule } from '@api/modules/widgets/widgets.module'; import { DataSourceManager } from '@api/infrastructure/data-source-manager'; import { LoggingModule } from '@api/modules/logging/logging.module'; import { SQLAdapter } from '@api/infrastructure/sql-adapter'; +import { TerminusModule } from '@nestjs/terminus'; + +const NODE_ENV = process.env.NODE_ENV; @Module({ imports: [ + TerminusModule.forRoot({ logger: NODE_ENV === 'test' ? false : true }), TsRestModule.register({ isGlobal: true, validateRequestBody: true, diff --git a/api/src/app.service.ts b/api/src/app.service.ts index abf657fb..98ecbccc 100644 --- a/api/src/app.service.ts +++ b/api/src/app.service.ts @@ -4,9 +4,6 @@ import { ContactForm, ContactMailer } from '@api/contact.mailer'; @Injectable() export class AppService { constructor(private readonly contactMailer: ContactMailer) {} - getHello(): string { - return 'Hello World!'; - } async contact(contactForm: ContactForm): Promise { await this.contactMailer.sendContactMail(contactForm); diff --git a/api/test/e2e/health/health.spec.ts b/api/test/e2e/health/health.spec.ts new file mode 100644 index 00000000..180ef5d6 --- /dev/null +++ b/api/test/e2e/health/health.spec.ts @@ -0,0 +1,46 @@ +import { TestManager } from 'api/test/utils/test-manager'; + +describe('Health', () => { + let testManager: TestManager; + + beforeAll(async () => { + testManager = await TestManager.createTestManager({ logger: false }); + }); + + beforeEach(async () => { + await testManager.clearDatabase(); + }); + + afterAll(async () => { + await testManager.close(); + }); + + it("Should return the app's health status: OK", async () => { + // Given + // The app is running + + // When + const { statusCode, body } = await testManager.request().get('/health'); + + // Then + expect(statusCode).toBe(200); + expect(body).toStrictEqual({ + status: 'ok', + info: { database: { status: 'up' } }, + error: {}, + details: { database: { status: 'up' } }, + }); + }); + + it("Should return the app's health status: Unavailable", async () => { + // Given + // The app is running and the database becomes unavailable + await testManager.dataSource.destroy(); + + // When + const { statusCode } = await testManager.request().get('/health'); + + // Then + expect(statusCode).toBe(503); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0215602b..687e2cef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@types/node': specifier: 20.14.2 version: 20.14.2 + typeorm: + specifier: 0.3.20 + version: 0.3.20 typescript: specifier: 5.4.5 version: 5.4.5 @@ -43,6 +46,9 @@ importers: '@nestjs/platform-express': specifier: ^10.0.0 version: 10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8) + '@nestjs/terminus': + specifier: 10.2.3 + version: 10.2.3(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/typeorm@10.0.2(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.11.5)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.11.5)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))) '@nestjs/typeorm': specifier: ^10.0.2 version: 10.0.2(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.11.5)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))) @@ -1205,6 +1211,54 @@ packages: peerDependencies: typescript: '>=4.8.2' + '@nestjs/terminus@10.2.3': + resolution: {integrity: sha512-iX7gXtAooePcyQqFt57aDke5MzgdkBeYgF5YsFNNFwOiAFdIQEhfv3PR0G+HlH9F6D7nBCDZt9U87Pks/qHijg==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^1.0.0 || ^2.0.0 || ^3.0.0 + '@nestjs/common': ^9.0.0 || ^10.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 + '@nestjs/microservices': ^9.0.0 || ^10.0.0 + '@nestjs/mongoose': ^9.0.0 || ^10.0.0 + '@nestjs/sequelize': ^9.0.0 || ^10.0.0 + '@nestjs/typeorm': ^9.0.0 || ^10.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + '@nestjs/testing@10.3.8': resolution: {integrity: sha512-hpX9das2TdFTKQ4/2ojhjI6YgXtCfXRKui3A4Qaj54VVzc5+mtK502Jj18Vzji98o9MVS6skmYu+S/UvW3U6Fw==} peerDependencies: @@ -2817,6 +2871,9 @@ packages: ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -3016,6 +3073,10 @@ packages: bowser@2.11.0: resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -3112,6 +3173,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -3147,6 +3212,10 @@ packages: class-variance-authority@0.7.0: resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -6552,6 +6621,10 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -7763,6 +7836,18 @@ snapshots: transitivePeerDependencies: - chokidar + '@nestjs/terminus@10.2.3(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/typeorm@10.0.2(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.11.5)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.11.5)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)))': + dependencies: + '@nestjs/common': 10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.1 + optionalDependencies: + '@nestjs/typeorm': 10.0.2(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(pg@8.11.5)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))) + typeorm: 0.3.20(pg@8.11.5)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + '@nestjs/testing@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.8(@nestjs/common@10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.8))': dependencies: '@nestjs/common': 10.3.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -9524,6 +9609,10 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -9789,6 +9878,17 @@ snapshots: bowser@2.11.0: {} + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -9887,6 +9987,8 @@ snapshots: chardet@0.7.0: {} + check-disk-space@3.4.0: {} + check-error@2.1.1: {} chokidar@3.6.0: @@ -9927,6 +10029,8 @@ snapshots: dependencies: clsx: 2.0.0 + cli-boxes@2.2.1: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -13745,6 +13849,10 @@ snapshots: dependencies: string-width: 4.2.3 + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: