diff --git a/.circleci/config.yml b/.circleci/config.yml index b3eb85c..0ee8340 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,6 +25,5 @@ jobs: command: npm test - store_artifacts: path: coverage - prefix: coverage - codecov/upload: - file: coverage/coverage-final.json \ No newline at end of file + file: coverage/coverage-final.json diff --git a/db-init/init.sql b/db-init/init.sql index 525788b..9bfbdd0 100644 --- a/db-init/init.sql +++ b/db-init/init.sql @@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS tanks ( name VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, in_use BOOLEAN NOT NULL, + disabled BOOLEAN NOT NULL, update_user INTEGER NULL ); @@ -51,11 +52,12 @@ CREATE TABLE IF NOT EXISTS tanks_audit ( id SERIAL NOT NULL PRIMARY KEY, operation VARCHAR(6) NOT NULL, time_stamp TIMESTAMPTZ NOT NULL, - + tanks_id INTEGER NOT NULL, name VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, in_use BOOLEAN NOT NULL, + disabled BOOLEAN NOT NULL, update_user INTEGER NULL ); @@ -191,10 +193,14 @@ CREATE TABLE IF NOT EXISTS tasks_audit ( -- the batch ID and action name of all open tasks CREATE VIEW open_tasks AS -SELECT actions.name AS action_name, - tasks.batch_id -FROM actions, tasks -WHERE tasks.action_id=actions.id AND +SELECT + actions.name AS action_name, + actions.classname, + tasks.batch_id, + tasks.action_id + FROM actions, + tasks +WHERE tasks.action_id = actions.id AND tasks.completed_on IS NULL; -- EXAMPLE: -- action_name | batch_id @@ -222,7 +228,7 @@ WHERE batches.tank_id=tanks.id AND -- Gets the most recent info for each batch CREATE VIEW most_recent_batch_info AS -SELECT pressure, temperature, SG, PH, ABV, batch_id +SELECT DISTINCT pressure, temperature, SG, PH, ABV, batch_id FROM versions INNER JOIN ( SELECT Max(measured_on) diff --git a/package-lock.json b/package-lock.json index d438b54..ad84d83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1131,6 +1131,15 @@ "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==", "dev": true }, + "@types/morgan": { + "version": "1.7.37", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.7.37.tgz", + "integrity": "sha512-tIdEA10BcHcOumMmUiiYdw8lhiVVq62r0ghih5Xpp4WETkfsMiTUZL4w9jCI502BBOrKhFrAOGml9IeELvVaBA==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/node": { "version": "10.12.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.10.tgz", @@ -1746,6 +1755,14 @@ } } }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + } + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -7511,6 +7528,28 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz", "integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y=" }, + "morgan": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz", + "integrity": "sha512-HQStPIV4y3afTiCYVxirakhlCfGkI161c76kKFca7Fk1JusM//Qeo1ej2XaMniiNeaZklMVrh3vTtIzpzwbpmA==", + "requires": { + "basic-auth": "~2.0.0", + "debug": "2.6.9", + "depd": "~1.1.2", + "on-finished": "~2.3.0", + "on-headers": "~1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -7823,6 +7862,11 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 9919e41..99aaa8a 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "husky": { "hooks": { "pre-commit": "lint-staged", + "pre-push": "npm run build && npm run test", "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" } }, @@ -61,7 +62,7 @@ }, "globals": { "ts-jest": { - "tsConfigFile": "tsconfig.json" + "tsConfig": "tsconfig.json" } }, "testMatch": [ @@ -85,6 +86,7 @@ "jest": "^24.5.0", "joi": "^13.4.0", "jsonwebtoken": "^8.3.0", + "morgan": "^1.9.1", "papaparse": "^4.6.3", "pg": "^7.4.3", "ts-jest": "^24.0.0", @@ -103,6 +105,7 @@ "@types/is": "0.0.21", "@types/joi": "^14.0.0", "@types/jsonwebtoken": "^8.3.0", + "@types/morgan": "^1.7.37", "@types/node": "^10.12.9", "@types/pg": "^7.4.11", "chai": "^4.1.2", diff --git a/src/components/tanks/__tests__/controller.test.ts b/src/components/tanks/__tests__/controller.test.ts new file mode 100644 index 0000000..92dd20b --- /dev/null +++ b/src/components/tanks/__tests__/controller.test.ts @@ -0,0 +1,183 @@ +import Boom from "boom"; +import { NextFunction, Request, RequestHandler, Response } from "express"; +import { TankController } from "../controller"; +import { Tank } from "../types"; + +describe("Tank controller", () => { + let tableName: string; + let controller: TankController; + let tank: Tank; + let rows: Tank[]; + let keys: string[] = ["keys"]; + let values: string[] = ["values"]; + let escapes: string[] = ["escapes"]; + let error: string; + let request: any = {}; + let response: any = {}; + const nextFunction: any = {}; + let json: any; + let send: any; + + beforeEach(() => { + tableName = "tanks"; + controller = new TankController(tableName); + tank = { + disabled: false, + id: 1, + in_use: true, + name: "rad-tank", + status: "brewing", + }; + rows = [tank]; + + keys = ["keys"]; + values = ["values"]; + escapes = ["escapes"]; + + error = "ERROR"; + + json = jest.fn(); + send = jest.fn(); + + request = { + body: { + escapes, keys, values, + }, + params: { + id: tank.id, + }, + }; + response = { + status: jest.fn().mockImplementation(() => ({ + json, + send, + })), + }; + }); + + describe("getTanks", () => { + it("success", async () => { + controller.read = jest.fn().mockResolvedValueOnce({ rows }); + await controller.getTanks(request as Request, response as Response); + expect(response.status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith(rows); + }); + + it("error", async () => { + controller.read = jest.fn().mockRejectedValueOnce(error); + await controller.getTanks(request as Request, response as Response); + expect(response.status).toHaveBeenCalledWith(500); + expect(send).toHaveBeenCalledWith(Boom.badImplementation(error)); + }); + }); + + describe("getTank", () => { + it("success", async () => { + controller.readById = jest.fn().mockResolvedValueOnce({ rows }); + await controller.getTank(request as Request, response as Response, nextFunction as NextFunction); + expect(response.status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith(tank); + }); + + it("error", async () => { + controller.readById = jest.fn().mockRejectedValueOnce(error); + await controller.getTank(request as Request, response as Response, nextFunction as NextFunction); + expect(response.status).toHaveBeenCalledWith(400); + expect(send).toHaveBeenCalledWith(Boom.badImplementation(error)); + }); + }); + + describe("getTankMonitoring", () => { + it("success", async () => { + controller.pool.query = jest.fn().mockResolvedValueOnce({ rows }); + await controller.getTankMonitoring(request as Request, response as Response, nextFunction as NextFunction); + expect(response.status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith(rows); + }); + + it("error", async () => { + controller.pool.query = jest.fn().mockRejectedValueOnce(error); + await controller.getTankMonitoring(request as Request, response as Response, nextFunction as NextFunction); + expect(response.status).toHaveBeenCalledWith(500); + expect(send).toHaveBeenCalledWith(Boom.badImplementation(error)); + }); + }); + + describe("createTank", () => { + beforeAll(() => { + controller.splitObjectKeyVals = jest.fn().mockReturnValue({ keys, values, escapes }); + }); + + it("success", async () => { + controller.create = jest.fn().mockResolvedValueOnce({ rows }); + await controller.createTank(request as Request, response as Response); + expect(response.status).toHaveBeenCalledWith(201); + expect(json).toHaveBeenCalledWith(tank); + }); + + it("error", async () => { + controller.create = jest.fn().mockRejectedValueOnce(error); + await controller.createTank(request as Request, response as Response); + expect(response.status).toHaveBeenCalledWith(400); + expect(send).toHaveBeenCalledWith(Boom.badRequest(error)); + }); + }); + + describe("updateTank", () => { + let query: string; + let idx: string; + + beforeAll(() => { + query = "SELECT * FROm table"; + idx = "2"; + + controller.splitObjectKeyVals = jest.fn().mockReturnValue({ keys, values }); + controller.buildUpdateString = jest.fn().mockReturnValue({ query, idx }); + }); + + it("success", async () => { + controller.update = jest.fn().mockResolvedValueOnce({ rows }); + await controller.updateTank(request as Request, response as Response, nextFunction as NextFunction); + expect(response.status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith(rows); + }); + + it("error", async () => { + controller.update = jest.fn().mockRejectedValueOnce(error); + await controller.updateTank(request as Request, response as Response, nextFunction as NextFunction); + expect(response.status).toHaveBeenCalledWith(500); + expect(send).toHaveBeenCalledWith(Boom.badImplementation(error)); + }); + + it("empty request body error", async () => { + request.body = null; + + controller.update = jest.fn().mockRejectedValueOnce(error); + await controller.updateTank(request as Request, response as Response, nextFunction as NextFunction); + expect(response.status).toHaveBeenCalledWith(400); + expect(send).toHaveBeenCalledWith(Boom.badRequest("Request does not match valid form")); + }); + }); + + describe("deleteTank", () => { + let rowCount: number; + + beforeEach(() => { + rowCount = 4; + }); + + it("success", async () => { + controller.deleteById = jest.fn().mockResolvedValueOnce({rowCount}); + await controller.deleteTank(request as Request, response as Response, nextFunction as NextFunction); + expect(response.status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith(`Successfully deleted tank (id=${tank.id})`); + }); + + it("error", async () => { + controller.deleteById = jest.fn().mockRejectedValueOnce(error); + await controller.deleteTank(request as Request, response as Response, nextFunction as NextFunction); + expect(response.status).toHaveBeenCalledWith(500); + expect(send).toHaveBeenCalledWith(Boom.badImplementation(error)); + }); + }); +}); diff --git a/src/components/tanks/controller.ts b/src/components/tanks/controller.ts index 4c5fdab..aa50504 100644 --- a/src/components/tanks/controller.ts +++ b/src/components/tanks/controller.ts @@ -82,20 +82,34 @@ export class TankController extends PostgresController implements ITankControlle * temperature */ const query = ` - SELECT action_name, open_tasks.batch_id, batch_name, - tank_name, tank_id, beer_name, pressure, temperature - FROM ( + SELECT DISTINCT ON (tanks.id) + tanks.id, + tanks.status, + tanks.name, + action_name, + action_id, + classname, + open_tasks.batch_id, + batch_name, + beer_name, + pressure, + temperature + FROM + ( ( - most_recent_batch_info RIGHT JOIN - open_tasks - ON open_tasks.batch_id = most_recent_batch_info.batch_id - ) - RIGHT JOIN tank_open_batch - ON open_tasks.batch_id = tank_open_batch.batch_id - )`; + ( + most_recent_batch_info RIGHT JOIN open_tasks ON + open_tasks.batch_id = most_recent_batch_info.batch_id + ) + RIGHT JOIN tank_open_batch ON + open_tasks.batch_id = tank_open_batch.batch_id + ) RIGHT JOIN tanks ON + tanks.id=tank_id + ) WHERE tanks.disabled=false + `; try { - const results = await this.pool.query(query); - res.status(200).json(results.rows); + const { rows } = await this.pool.query(query); + res.status(200).json(rows); } catch (err) { res.status(500).send(Boom.badImplementation(err)); } diff --git a/src/components/tanks/types.ts b/src/components/tanks/types.ts index 447b08c..b4b2b44 100644 --- a/src/components/tanks/types.ts +++ b/src/components/tanks/types.ts @@ -3,5 +3,6 @@ export interface Tank { name: string; status: string; in_use: boolean; + disabled?: boolean; update_user?: number; } diff --git a/src/components/tanks/validator.ts b/src/components/tanks/validator.ts index 0fb4b3b..dcee4b1 100644 --- a/src/components/tanks/validator.ts +++ b/src/components/tanks/validator.ts @@ -21,6 +21,7 @@ export const TankValidator = { return { body: Joi.object() .keys({ + disabled: Joi.boolean(), in_use: Joi.boolean(), name: Joi.string(), status: Joi.string(), diff --git a/src/index.ts b/src/index.ts index 1a15235..090844c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import bodyParser from "body-parser"; import Boom from "boom"; import dotenv from "dotenv"; import e, { NextFunction, Request, Response } from "express"; +import morgan from "morgan"; import { routes as ActionsRoutes } from "./components/actions/routes"; import { routes as BatchesRoutes } from "./components/batches/routes"; import { routes as EmployeesRoutes } from "./components/employees/routes"; @@ -21,11 +22,13 @@ const app = e(); app.use(bodyParser.json()); app.use(cors()); +app.use(morgan("combined")); app.use( (err: Error, req: Request, res: Response, next: NextFunction) => res.status(400).send(Boom.badRequest(err.message)), ); +// Only expose these routes in development if (process.env.IS_NOW === "false") { app.use("/employees", EmployeesRoutes()); app.use("/tanks", TanksRoutes()); @@ -34,31 +37,29 @@ if (process.env.IS_NOW === "false") { app.use("/batches", BatchesRoutes()); app.use("/tasks", TasksRoutes()); app.use("/versions", VersionsRoutes()); + app.post("/init", async (req: Request, res: Response) => { + try { + await insertDevelopmentData(false); + res.status(200).send("success"); + } catch (error) { + res.status(500).send(Boom.badImplementation("failed")); + } + }); + + app.post("/init-live", async (req: Request, res: Response) => { + try { + await insertDevelopmentData(true); + res.status(200).send("success"); + } catch (error) { + res.status(500).send(Boom.badImplementation("failed")); + } + }); } -// TODO: remove this health check in production -app.get("/test", async (req: Request, res: Response) => { +app.get("/healthcheck", async (req: Request, res: Response) => { res.status(200).send(`hello world! from root route. NODE_ENV=${process.env.NODE_ENV}`); }); -app.post("/init", async (req: Request, res: Response) => { - try { - await insertDevelopmentData(false); - res.status(200).send("success"); - } catch (error) { - res.status(500).send(Boom.badImplementation("failed")); - } -}); - -app.post("/init-live", async (req: Request, res: Response) => { - try { - await insertDevelopmentData(true); - res.status(200).send("success"); - } catch (error) { - res.status(500).send(Boom.badImplementation("failed")); - } -}); - app.use( (err: Error, req: Request, res: Response, next: NextFunction) => res.status(400).send(Boom.badRequest(err.message)), ); diff --git a/src/middleware/__tests__/auth.test.ts b/src/middleware/__tests__/auth.test.ts new file mode 100644 index 0000000..ad00f36 --- /dev/null +++ b/src/middleware/__tests__/auth.test.ts @@ -0,0 +1,60 @@ +import { NextFunction, Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import { generateAuthToken, requireAuthentication } from "../auth"; + +jest.mock("jsonwebtoken", () => ({ + sign: jest.fn((x: object, y: string, z: object, callback: () => string | Error): string | Error => callback()), + verify: jest.fn( + (x: string, y: string, callback: (error: any, payload: any) => void) => callback(null, { sub: "test"}), + ), +})); + +describe("auth", () => { + describe("generateAuthToken", () => { + let userId: number; + let payload: any; + let expiration: jwt.SignOptions; + + beforeAll(() => { + userId = 1; + payload = { + sub: userId, + }; + expiration = { + expiresIn: "24h", + }; + }); + + it("calls jwt.sign asyncronously with correct args", async () => { + return generateAuthToken(userId).then((data) => + expect(jwt.sign).toHaveBeenCalledWith(payload, process.env.AUTH_KEY, expiration, expect.any(Function)), + ); + }); + }); + + describe("requireAuthentication", () => { + let req: any; + let res: any; + let next: any; + let token: string; + + beforeAll(() => { + token = "test-token-123-yay"; + req = { + get: jest.fn(() => `Bearer ${token}`), + user: "", + }; + res = { + status: jest.fn(() => ({ + send: jest.fn(), + })), + }; + next = jest.fn(); + }); + + it("verify token successfully", () => { + requireAuthentication(req as Request, res as Response, next as NextFunction); + expect(next).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/utils/initial_data.ts b/src/utils/initial_data.ts index f48e014..3613469 100644 --- a/src/utils/initial_data.ts +++ b/src/utils/initial_data.ts @@ -63,6 +63,7 @@ async function insertCSVTestData() { name: row.Tank, status: "available", in_use: false, + disabled: false, update_user: 1, }; tankIndexes[row.Tank] = Object.keys(tankIndexes).length + 1; @@ -215,6 +216,7 @@ async function insertCSVTestData() { const tank = result.rows[0]; tank.in_use = true; + tank.disabled = false; tank.status = "brewing"; const { keys, values, escapes } = tankController.splitObjectKeyVals(tank); // set an update @@ -273,6 +275,7 @@ async function insertDevTanks() { name: `F${i}`, status: "brewing", in_use: true, + disabled: false, update_user: 1, }; if (i > 9) {