From e3b4e192c54131a385e45eb3df0480233bfc4080 Mon Sep 17 00:00:00 2001 From: Vinjatovix Imanub Date: Mon, 2 Oct 2023 04:50:12 +0200 Subject: [PATCH] feat(Heatlh) add ep and tests (#8) --- .github/workflows/build.yml | 5 ++ .gitignore | 1 + config/{dev.env => env.env.example} | 0 jest.config.js | 8 +- package.json | 1 + sonar-project.properties | 1 + src/apps/apiApp/backend/ApiApp.ts | 20 +++++ .../controllers/health/GetStatusController.ts | 8 ++ .../dependency-injection/application.yml | 2 + .../dependency-injection/application_dev.yml | 2 + .../application_production.yml | 2 + .../dependency-injection/application_test.yml | 2 + .../dependency-injection/apps/application.yml | 4 + .../backend/dependency-injection/index.ts | 9 +++ .../backend/routes/health/health.routes.ts | 14 ++++ src/apps/apiApp/backend/routes/index.ts | 18 +++++ src/apps/apiApp/backend/server.ts | 80 +++++++++++++++++++ src/apps/apiApp/backend/start.ts | 22 +++++ .../apiApp/shared/interfaces/Controller.ts | 5 ++ .../health/infraestructure/getStatus.test.ts | 25 ++++++ .../health/unit/getStatusController.test.ts | 21 +++++ .../Contexts/api/backEnd/unit/apiApp.test.ts | 40 ++++++++++ .../backend/features/health/getStatus.feature | 15 ++++ .../step_definitions/controller.steps.ts | 38 +++++++++ tsconfig.json | 2 +- 25 files changed, 343 insertions(+), 2 deletions(-) rename config/{dev.env => env.env.example} (100%) create mode 100644 src/apps/apiApp/backend/ApiApp.ts create mode 100644 src/apps/apiApp/backend/controllers/health/GetStatusController.ts create mode 100644 src/apps/apiApp/backend/dependency-injection/application.yml create mode 100644 src/apps/apiApp/backend/dependency-injection/application_dev.yml create mode 100644 src/apps/apiApp/backend/dependency-injection/application_production.yml create mode 100644 src/apps/apiApp/backend/dependency-injection/application_test.yml create mode 100644 src/apps/apiApp/backend/dependency-injection/apps/application.yml create mode 100644 src/apps/apiApp/backend/dependency-injection/index.ts create mode 100644 src/apps/apiApp/backend/routes/health/health.routes.ts create mode 100644 src/apps/apiApp/backend/routes/index.ts create mode 100644 src/apps/apiApp/backend/server.ts create mode 100644 src/apps/apiApp/backend/start.ts create mode 100644 src/apps/apiApp/shared/interfaces/Controller.ts create mode 100644 tests/Contexts/api/backEnd/health/infraestructure/getStatus.test.ts create mode 100644 tests/Contexts/api/backEnd/health/unit/getStatusController.test.ts create mode 100644 tests/Contexts/api/backEnd/unit/apiApp.test.ts create mode 100644 tests/apps/apiApp/backend/features/health/getStatus.feature create mode 100644 tests/apps/apiApp/backend/features/step_definitions/controller.steps.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98e441d..9d7d33b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,6 +14,11 @@ jobs: with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: '18' + - name: Install dependencies run: npm install # Adjust for your specific setup diff --git a/.gitignore b/.gitignore index ef2097d..a8c3beb 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ dist test-report.xml debug.json +config/*.env diff --git a/config/dev.env b/config/env.env.example similarity index 100% rename from config/dev.env rename to config/env.env.example diff --git a/jest.config.js b/jest.config.js index 8600083..9dd82c3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,5 +9,11 @@ module.exports = { reporters: ['default'], testResultsProcessor: 'jest-sonar-reporter', coverageReporters: ['text', 'html', 'lcov', 'clover'], - coveragePathIgnorePatterns: ['/node_modules/', '/test/'] + coveragePathIgnorePatterns: [ + '/interfaces/', + '/node_modules/', + '/test/', + '/start.ts', + '/server.ts', + ] }; diff --git a/package.json b/package.json index 7eb51aa..4dd55ea 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "check-dependencies": "madge --circular ./src", "dev:api": "cross-env NODE_ENV=dev ts-node-dev --ignore-watch node_modules ./src/apps/apiApp/backend/start.ts", + "start:api": "cross-env NODE_ENV=production ts-node ./src/apps/apiApp/backend/start.ts", "lint": "eslint . --ext .ts --max-warnings=0", "lint:fix": "eslint . --ext .ts --fix", "pre-commit": "lint-staged", diff --git a/sonar-project.properties b/sonar-project.properties index 10271a6..c07d9a5 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,7 @@ sonar.projectKey=vinjatovix_ts-api sonar.organization=vinjatovix sonar.javascript.lcov.reportPaths=coverage/lcov.info +sonar.coverage.exclusions=**/tests/**/*, **/node_modules/**/*, **/start.ts # This is the name and version displayed in the SonarCloud UI. diff --git a/src/apps/apiApp/backend/ApiApp.ts b/src/apps/apiApp/backend/ApiApp.ts new file mode 100644 index 0000000..62a9ab4 --- /dev/null +++ b/src/apps/apiApp/backend/ApiApp.ts @@ -0,0 +1,20 @@ +import { Server } from './server'; + +export class ApiApp { + server?: Server; + + async start() { + const port: string = process.env.PORT ?? '0'; + this.server = new Server(port); + + return this.server.listen(); + } + + async stop() { + return this.server?.stop(); + } + + get httpServer() { + return this.server?.getHTTPServer(); + } +} diff --git a/src/apps/apiApp/backend/controllers/health/GetStatusController.ts b/src/apps/apiApp/backend/controllers/health/GetStatusController.ts new file mode 100644 index 0000000..e2e96ea --- /dev/null +++ b/src/apps/apiApp/backend/controllers/health/GetStatusController.ts @@ -0,0 +1,8 @@ +import { Request, Response } from 'express'; +import { Controller } from '../../../shared/interfaces/Controller'; + +export class GetStatusController implements Controller { + async run(_req: Request, res: Response): Promise { + res.status(200).json({ status: 'OK' }); + } +} diff --git a/src/apps/apiApp/backend/dependency-injection/application.yml b/src/apps/apiApp/backend/dependency-injection/application.yml new file mode 100644 index 0000000..83c7327 --- /dev/null +++ b/src/apps/apiApp/backend/dependency-injection/application.yml @@ -0,0 +1,2 @@ +imports: + - { resource: ./apps/application.yml } diff --git a/src/apps/apiApp/backend/dependency-injection/application_dev.yml b/src/apps/apiApp/backend/dependency-injection/application_dev.yml new file mode 100644 index 0000000..70f7112 --- /dev/null +++ b/src/apps/apiApp/backend/dependency-injection/application_dev.yml @@ -0,0 +1,2 @@ +imports: + - { resource: ./application.yml } diff --git a/src/apps/apiApp/backend/dependency-injection/application_production.yml b/src/apps/apiApp/backend/dependency-injection/application_production.yml new file mode 100644 index 0000000..70f7112 --- /dev/null +++ b/src/apps/apiApp/backend/dependency-injection/application_production.yml @@ -0,0 +1,2 @@ +imports: + - { resource: ./application.yml } diff --git a/src/apps/apiApp/backend/dependency-injection/application_test.yml b/src/apps/apiApp/backend/dependency-injection/application_test.yml new file mode 100644 index 0000000..70f7112 --- /dev/null +++ b/src/apps/apiApp/backend/dependency-injection/application_test.yml @@ -0,0 +1,2 @@ +imports: + - { resource: ./application.yml } diff --git a/src/apps/apiApp/backend/dependency-injection/apps/application.yml b/src/apps/apiApp/backend/dependency-injection/apps/application.yml new file mode 100644 index 0000000..3ca16b6 --- /dev/null +++ b/src/apps/apiApp/backend/dependency-injection/apps/application.yml @@ -0,0 +1,4 @@ +services: + Apps.apiApp.controllers.health.GetStatusController: + class: ../../controllers/health/GetStatusController + arguments: [] diff --git a/src/apps/apiApp/backend/dependency-injection/index.ts b/src/apps/apiApp/backend/dependency-injection/index.ts new file mode 100644 index 0000000..ab88262 --- /dev/null +++ b/src/apps/apiApp/backend/dependency-injection/index.ts @@ -0,0 +1,9 @@ +import { ContainerBuilder, YamlFileLoader } from 'node-dependency-injection'; + +const container = new ContainerBuilder(); +const loader = new YamlFileLoader(container); +const env = process.env.NODE_ENV ?? 'dev'; + +loader.load(`${__dirname}/application_${env}.yml`); + +export default container; diff --git a/src/apps/apiApp/backend/routes/health/health.routes.ts b/src/apps/apiApp/backend/routes/health/health.routes.ts new file mode 100644 index 0000000..0989c75 --- /dev/null +++ b/src/apps/apiApp/backend/routes/health/health.routes.ts @@ -0,0 +1,14 @@ +import { Request, Response, Router } from 'express'; +import { GetStatusController } from '../../controllers/health/GetStatusController'; +import container from '../../dependency-injection'; + +const prefix = '/api/v1/health'; + +export const register = (router: Router) => { + const controller: GetStatusController = container.get( + 'Apps.apiApp.controllers.health.GetStatusController' + ); + router.get(`${prefix}/http`, (req: Request, res: Response) => { + controller.run(req, res); + }); +}; diff --git a/src/apps/apiApp/backend/routes/index.ts b/src/apps/apiApp/backend/routes/index.ts new file mode 100644 index 0000000..823407a --- /dev/null +++ b/src/apps/apiApp/backend/routes/index.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { globSync } from 'glob'; + +export function registerRoutes(router: Router) { + const routes = globSync('**/*.routes.*', { + cwd: __dirname, + ignore: '**/index.ts' + }); + routes.forEach((route) => { + _register(route, router); + }); +} + +const _register = (routePath: string, router: Router) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const route = require(__dirname + '/' + routePath); + route.register(router); +}; diff --git a/src/apps/apiApp/backend/server.ts b/src/apps/apiApp/backend/server.ts new file mode 100644 index 0000000..a454dbc --- /dev/null +++ b/src/apps/apiApp/backend/server.ts @@ -0,0 +1,80 @@ +import dotenv from 'dotenv'; +import bodyParser from 'body-parser'; +import errorHandler from 'errorhandler'; +import express, { Request, Response, NextFunction } from 'express'; +import Router from 'express-promise-router'; +import helmet from 'helmet'; +import * as http from 'http'; +import httpStatus from 'http-status'; +import cors from 'cors'; +import { registerRoutes } from './routes'; + +dotenv.config(); +const corsOptions = { + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + credentials: true +}; + +export class Server { + private express: express.Express; + private port: string; + private httpServer?: http.Server; + + constructor(port: string) { + this.port = port; + this.express = express(); + this.express.use(cors(corsOptions)); + this.express.use(bodyParser.json()); + this.express.use(bodyParser.urlencoded({ extended: true })); + this.express.use(helmet.xssFilter()); + this.express.use(helmet.noSniff()); + this.express.use(helmet.hidePoweredBy()); + this.express.use(helmet.frameguard({ action: 'deny' })); + const router = Router(); + router.use(errorHandler()); + this.express.use(router); + + registerRoutes(router); + + router.use( + (err: Error, _req: Request, res: Response, _next: NextFunction): void => { + console.log(err); + res.status(httpStatus.INTERNAL_SERVER_ERROR).send(err.message); + } + ); + } + + async listen(): Promise { + return new Promise((resolve) => { + const env = this.express.get('env') as string; + this.httpServer = this.express.listen(this.port, () => { + console.log( + ` Mock Backend App is running at http://localhost:${this.port} in ${env} mode` + ); + console.log(' Press CTRL-C to stop\n'); + resolve(); + }); + }); + } + + getHTTPServer() { + return this.httpServer; + } + + async stop(): Promise { + return new Promise((resolve, reject) => { + if (this.httpServer) { + this.httpServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + } else { + resolve(); + } + }); + } +} diff --git a/src/apps/apiApp/backend/start.ts b/src/apps/apiApp/backend/start.ts new file mode 100644 index 0000000..8d4275f --- /dev/null +++ b/src/apps/apiApp/backend/start.ts @@ -0,0 +1,22 @@ +import dotenv from 'dotenv'; +import path from 'path'; +import { ApiApp } from './ApiApp'; + +dotenv.config({ + path: path.resolve( + __dirname, + `../../../../config/${process.env.NODE_ENV}.env` + ) +}); + +try { + new ApiApp().start(); +} catch (e) { + console.log(e); + process.exit(1); +} + +process.on('uncaughtException', (err) => { + console.log('uncaughtException', err); + process.exit(1); +}); diff --git a/src/apps/apiApp/shared/interfaces/Controller.ts b/src/apps/apiApp/shared/interfaces/Controller.ts new file mode 100644 index 0000000..70f0d2a --- /dev/null +++ b/src/apps/apiApp/shared/interfaces/Controller.ts @@ -0,0 +1,5 @@ +import { Request, Response } from 'express'; + +export interface Controller { + run(req: Request, res: Response): Promise; +} diff --git a/tests/Contexts/api/backEnd/health/infraestructure/getStatus.test.ts b/tests/Contexts/api/backEnd/health/infraestructure/getStatus.test.ts new file mode 100644 index 0000000..f6b5d63 --- /dev/null +++ b/tests/Contexts/api/backEnd/health/infraestructure/getStatus.test.ts @@ -0,0 +1,25 @@ +import request from 'supertest'; +import { describe, beforeAll, afterAll, it, expect } from '@jest/globals'; +import { ApiApp } from '../../../../../../src/apps/apiApp/backend/ApiApp'; + +let app: ApiApp; + +describe('Health Check Endpoint', () => { + beforeAll(async () => { + app = new ApiApp(); + await app.start(); + }); + + afterAll(async () => { + await app.stop(); + }); + + it('should return status 200 and "OK"', async () => { + const { status, body } = await request(app.httpServer).get( + '/api/v1/health/http' + ); + + expect(status).toBe(200); + expect(body).toMatchObject({ status: 'OK' }); + }); +}); diff --git a/tests/Contexts/api/backEnd/health/unit/getStatusController.test.ts b/tests/Contexts/api/backEnd/health/unit/getStatusController.test.ts new file mode 100644 index 0000000..f6cbee9 --- /dev/null +++ b/tests/Contexts/api/backEnd/health/unit/getStatusController.test.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { GetStatusController } from '../../../../../../src/apps/apiApp/backend/controllers/health/GetStatusController'; + +describe('GetStatusController', () => { + let controller: GetStatusController; + let mockResponse: Partial; + + beforeEach(() => { + controller = new GetStatusController(); + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + }); + + it('should send a status of 200 with the message "OK"', async () => { + await controller.run({} as Request, mockResponse as Response); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ status: 'OK' }); + }); +}); diff --git a/tests/Contexts/api/backEnd/unit/apiApp.test.ts b/tests/Contexts/api/backEnd/unit/apiApp.test.ts new file mode 100644 index 0000000..976a4df --- /dev/null +++ b/tests/Contexts/api/backEnd/unit/apiApp.test.ts @@ -0,0 +1,40 @@ +import { ApiApp } from '../../../../../src/apps/apiApp/backend/ApiApp'; +import { Server } from '../../../../../src/apps/apiApp/backend/server'; + +describe('ApiApp', () => { + let apiApp: ApiApp; + const listenSpy = jest.spyOn(Server.prototype, 'listen'); + const stopSpy = jest.spyOn(Server.prototype, 'stop'); + + beforeAll(() => { + apiApp = new ApiApp(); + }); + + afterEach(async () => { + if (apiApp.httpServer?.listening) { + await apiApp.stop(); + } + }); + + it('should start the server', async () => { + await apiApp.start(); + + expect(listenSpy).toHaveBeenCalled(); + }); + + it('should stop the server', async () => { + await apiApp.start(); + + await apiApp.stop(); + + expect(stopSpy).toHaveBeenCalled(); + }); + + it('should return the HTTP server', async () => { + await apiApp.start(); + + const httpServer = apiApp.httpServer; + + expect(httpServer).toBeDefined(); + }); +}); diff --git a/tests/apps/apiApp/backend/features/health/getStatus.feature b/tests/apps/apiApp/backend/features/health/getStatus.feature new file mode 100644 index 0000000..3886b22 --- /dev/null +++ b/tests/apps/apiApp/backend/features/health/getStatus.feature @@ -0,0 +1,15 @@ +Feature: Api Health Check + In order to verify the application's availability + As a health check client + I want check the API status + + Scenario: Performing a health check + Given a GET request to "/api/v1/health/http" + Then the response status code should be 200 + Then the response body should be + """ + { + "status": "OK" + } + """ + diff --git a/tests/apps/apiApp/backend/features/step_definitions/controller.steps.ts b/tests/apps/apiApp/backend/features/step_definitions/controller.steps.ts new file mode 100644 index 0000000..833ec7f --- /dev/null +++ b/tests/apps/apiApp/backend/features/step_definitions/controller.steps.ts @@ -0,0 +1,38 @@ +import { AfterAll, BeforeAll, Given, Then } from '@cucumber/cucumber'; +import request from 'supertest'; +import { ApiApp } from '../../../../../../src/apps/apiApp/backend/ApiApp'; +import chai from 'chai'; + +const expect = chai.expect; +let _request: request.Test; +let app: ApiApp; + +BeforeAll(async () => { + app = new ApiApp(); + await app.start(); +}); + +AfterAll(async () => { + await app.stop(); +}); + +Given('a GET request to {string}', (route: string) => { + _request = request(app.httpServer).get(route); +}); + +Then('the response status code should be {int}', async (status: number) => { + const response = await _request; + expect(response.status).to.be.equal(status); +}); + +Then('the response body should be', async (docString: string) => { + const response = await _request; + const expectedResponseBody = JSON.parse(docString); + expect(response.body).to.deep.equal(expectedResponseBody); +}); + +Then('the response body should not be', async (docString: string) => { + const response = await _request; + const expectedResponseBody = JSON.parse(docString); + expect(response.body).to.not.deep.equal(expectedResponseBody); +}); diff --git a/tsconfig.json b/tsconfig.json index a5a281f..1386157 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,6 @@ "noUnusedLocals": true, "outDir": "./dist" }, - "include": ["src/**/**.ts"], + "include": ["src/**/*.ts", "tests/**/*.ts"], "exclude": ["node_modules"] }