Skip to content

Commit

Permalink
fix: bring health checks to release early
Browse files Browse the repository at this point in the history
  • Loading branch information
seidior committed Jan 17, 2024
1 parent 028a93e commit 9d96461
Show file tree
Hide file tree
Showing 22 changed files with 768 additions and 10 deletions.
4 changes: 4 additions & 0 deletions api/.dependency-cruiser.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ module.exports = {
from: {},
to: { path: "express" },
},
{
from: {},
to: { path: "http-status-codes" },
},
{
from: {},
to: { path: "crypto" },
Expand Down
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"dedent": "1.5.1",
"express": "4.18.2",
"helmet": "7.1.0",
"http-status-codes": "^2.3.0",
"jsonwebtoken": "9.0.2",
"lodash": "4.17.21",
"serverless-http": "3.2.0",
Expand Down
2 changes: 1 addition & 1 deletion api/serverless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const serverlessConfiguration: AWS = {
],
provider: {
name: "aws",
runtime: "nodejs16.x",
runtime: "nodejs18.x",
stage: stage,
region: region,
iam: {
Expand Down
100 changes: 100 additions & 0 deletions api/src/api/healthCheckRouter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { healthCheckRouterFactory } from "@api/healthCheckRouter";
import type { HealthCheckMethod } from "@domain/types";
import { setupExpress } from "@libs/express";
import type { Express } from "express";
import { ReasonPhrases, StatusCodes } from "http-status-codes";
import request from "supertest";

const mockOKHealthCheckMethod: HealthCheckMethod = async () => {
return new Promise((resolve) => {
resolve({
success: true,
data: {
message: ReasonPhrases.OK,
},
});
});
};

const mockErrorHealthCheckMethod: HealthCheckMethod = async () => {
return new Promise((resolve) => {
resolve({
success: false,
error: {
message: ReasonPhrases.BAD_GATEWAY,
serverResponseBody: "",
serverResponseCode: 404,
timeout: false,
},
});
});
};

const mockTimeoutHealthCheckMethod: HealthCheckMethod = async () => {
return new Promise((resolve) => {
resolve({
success: false,
error: {
message: ReasonPhrases.GATEWAY_TIMEOUT,
timeout: true,
},
});
});
};

describe("healthCheckRouter", () => {
let app: Express;

beforeEach(async () => {
app = setupExpress(false);
app.use(
healthCheckRouterFactory(new Map<string, HealthCheckMethod>([["mockOK", mockOKHealthCheckMethod]])),
healthCheckRouterFactory(new Map<string, HealthCheckMethod>([["mockErr", mockErrorHealthCheckMethod]])),
healthCheckRouterFactory(
new Map<string, HealthCheckMethod>([["mockTimeout", mockTimeoutHealthCheckMethod]])
)
);
});

afterAll(async () => {
await new Promise((resolve) => {
return setTimeout(resolve, 500);
});
});

describe("GET /", () => {
it("should return a successful response for the server's self check", async () => {
const response = await request(app).get("/");
expect(response.status).toBe(StatusCodes.OK);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toEqual("OK");
});
});

describe("GET /mockOK", () => {
it("should return a successful response for a mocked health check", async () => {
const response = await request(app).get("/mockOK");
expect(response.status).toBe(StatusCodes.OK);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toEqual("OK");
});
});

describe("GET /mockErr", () => {
it("should return an unsuccessful response for a errored health check", async () => {
const response = await request(app).get("/mockErr");
expect(response.status).toBe(StatusCodes.BAD_GATEWAY);
expect(response.body.success).toBe(false);
expect(response.body.error.message).toEqual(ReasonPhrases.BAD_GATEWAY);
});
});

describe("GET /mockTimeout", () => {
it("should return a successful response for a mocked health check", async () => {
const response = await request(app).get("/mockTimeout");
expect(response.status).toBe(StatusCodes.GATEWAY_TIMEOUT);
expect(response.body.success).toBe(false);
expect(response.body.error.message).toEqual(ReasonPhrases.GATEWAY_TIMEOUT);
});
});
});
47 changes: 47 additions & 0 deletions api/src/api/healthCheckRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { HealthCheckMetadata, HealthCheckMethod, SuccessfulHealthCheckResponse } from "@domain/types";
import { Router } from "express";
import { ReasonPhrases, StatusCodes } from "http-status-codes";

const requestTimestamp = (): number => Math.round(Date.now() / 1000);

const healthCheckResponseStatus = (response: HealthCheckMetadata): StatusCodes => {
let statusCode = StatusCodes.OK;

if (!response.success && response.error) {
statusCode = response.error.timeout ? StatusCodes.GATEWAY_TIMEOUT : StatusCodes.BAD_GATEWAY;
}

return statusCode;
};

type HealthCheckEndpoint = string;
type HealthChecks = Map<HealthCheckEndpoint, HealthCheckMethod>;

export const healthCheckRouterFactory = (clients: HealthChecks): Router => {
const router = Router();

router.get("/", (_req, res) => {
res.json({
timestamp: requestTimestamp(),
success: true,
data: {
message: ReasonPhrases.OK,
},
} as SuccessfulHealthCheckResponse);
});

for (const [endpoint, client] of clients) {
router.get(`/${endpoint}`, async (_req, res) => {
const timestamp = requestTimestamp();
const healthCheckResult = await client();
const statusCode = healthCheckResponseStatus(healthCheckResult);

res.status(statusCode).json({
timestamp,
...healthCheckResult,
});
});
}

return router;
};
29 changes: 29 additions & 0 deletions api/src/client/WebserviceLicenseStatusClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,33 @@ describe("WebserviceLicenseStatusClient", () => {
mockAxios.post.mockResolvedValue({ data: "" });
expect(await client.search("name", "11111", "some-type")).toEqual([]);
});

it("returns a passing health check if data can be retrieved sucessfully", async () => {
mockAxios.post.mockResolvedValue({});
expect(await client.health()).toEqual({ success: true, data: { message: "OK" } });
});

it("returns a failing health check if unexpected data is retrieved", async () => {
mockAxios.post.mockRejectedValue({ response: { status: 404 }, message: "" });
expect(await client.health()).toEqual({
success: false,
error: {
message: "Bad Gateway",
serverResponseBody: "",
serverResponseCode: 404,
timeout: false,
},
});
});

it("returns a failing health check if axios request times out", async () => {
mockAxios.post.mockRejectedValue({});
expect(await client.health()).toEqual({
success: false,
error: {
message: "Gateway Timeout",
timeout: true,
},
});
});
});
49 changes: 48 additions & 1 deletion api/src/client/WebserviceLicenseStatusClient.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { LicenseStatusClient } from "@domain/types";
import { HealthCheckMetadata, HealthCheckMethod, LicenseStatusClient } from "@domain/types";
import { LogWriterType } from "@libs/logWriter";
import { LicenseEntity } from "@shared/license";
import axios, { AxiosError } from "axios";
import { ReasonPhrases } from "http-status-codes";

export const WebserviceLicenseStatusClient = (
baseUrl: string,
Expand Down Expand Up @@ -33,7 +34,53 @@ export const WebserviceLicenseStatusClient = (
});
};

const health: HealthCheckMethod = () => {
const url = `${baseUrl}/ws/simple/queryLicenseStatus`;
const logId = logWriter.GetId();
const licenseType = "HVACR";
const name = "Innovation Test Business";
const zipCode = 12345;

return axios
.post(url, {
zipCode: zipCode,
businessName: name,
licenseType: licenseType,
})
.then(() => {
return {
success: true,
data: {
message: ReasonPhrases.OK,
},
} as HealthCheckMetadata;
})
.catch((error: AxiosError) => {
console.dir({ error });
logWriter.LogError(`License Status Search - Id:${logId} - Error:`, error);
if (error.response) {
return {
success: false,
error: {
serverResponseBody: error.message,
serverResponseCode: error.response.status,
message: ReasonPhrases.BAD_GATEWAY,
timeout: false,
},
} as HealthCheckMetadata;
}
return {
success: false,
error: {
message: ReasonPhrases.GATEWAY_TIMEOUT,
timeout: true,
},
} as HealthCheckMetadata;
});
};

return {
search,
health,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("WebserviceLicenseStatusProcessorClient", () => {
beforeEach(() => {
stubLicenseStatusClient = {
search: jest.fn(),
health: jest.fn(),
};

searchLicenseStatus = WebserviceLicenseStatusProcessorClient(stubLicenseStatusClient);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { DynamicsElevatorSafetyHealthCheckClient } from "@client/dynamics/elevator-safety/DynamicsElevatorSafetyHealthCheckClient";
import { AccessTokenClient } from "@client/dynamics/types";
import { HealthCheckMethod } from "@domain/types";
import { LogWriter, LogWriterType } from "@libs/logWriter";
import axios from "axios";

jest.mock("axios");
jest.mock("winston");
const mockAxios = axios as jest.Mocked<typeof axios>;

describe("DynamicsElevatorSafetyHealthCheckClient", () => {
let client: HealthCheckMethod;
let logger: LogWriterType;
let stubAccessTokenClient: jest.Mocked<AccessTokenClient>;

const ORG_URL = "www.test-org-url.com";

beforeEach(() => {
logger = LogWriter("NavigatorWebService", "ApiLogs", "us-test-1");
stubAccessTokenClient = {
getAccessToken: jest.fn(),
};
client = DynamicsElevatorSafetyHealthCheckClient(logger, {
accessTokenClient: stubAccessTokenClient,
orgUrl: ORG_URL,
});
});

it("returns a passing health check if data can be retrieved sucessfully", async () => {
mockAxios.get.mockResolvedValue({});
expect(await client()).toEqual({ success: true, data: { message: "OK" } });
});

it("returns a failing health check if unexpected data is retrieved", async () => {
mockAxios.get.mockRejectedValue({ response: { status: 404 }, message: "" });
expect(await client()).toEqual({
success: false,
error: {
message: "Bad Gateway",
serverResponseBody: "",
serverResponseCode: 404,
timeout: false,
},
});
});

it("returns a failing health check if axios request times out", async () => {
mockAxios.get.mockRejectedValue({});
expect(await client()).toEqual({
success: false,
error: {
message: "Gateway Timeout",
timeout: true,
},
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { AccessTokenClient } from "@client/dynamics/types";
import type { HealthCheckMetadata, HealthCheckMethod } from "@domain/types";
import type { LogWriterType } from "@libs/logWriter";
import axios, { type AxiosError } from "axios";
import { ReasonPhrases } from "http-status-codes";

type Config = {
accessTokenClient: AccessTokenClient;
orgUrl: string;
};

export const DynamicsElevatorSafetyHealthCheckClient = (
logWriter: LogWriterType,
config: Config
): HealthCheckMethod => {
return async (): Promise<HealthCheckMetadata> => {
const logId = logWriter.GetId();
const accessToken = await config.accessTokenClient.getAccessToken();
return axios
.get(`${config.orgUrl}/api/data/v9.2/ultra_elsainspections?$top=1`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
.then(() => {
return {
success: true,
data: {
message: ReasonPhrases.OK,
},
} as HealthCheckMetadata;
})
.catch((error: AxiosError) => {
logWriter.LogError(`Dynamics Elevator Safety Health Check Failed - Id:${logId} - Error:`, error);
if (error.response) {
return {
success: false,
error: {
serverResponseBody: error.message,
serverResponseCode: error.response.status,
message: ReasonPhrases.BAD_GATEWAY,
timeout: false,
},
} as HealthCheckMetadata;
}
return {
success: false,
error: {
message: ReasonPhrases.GATEWAY_TIMEOUT,
timeout: true,
},
} as HealthCheckMetadata;
});
};
};
Loading

0 comments on commit 9d96461

Please sign in to comment.