Merge pull request #1138 from bluewave-labs/feat/be/status-page-model

feat/be/status page implementation
This commit is contained in:
Alexander Holliday authored and GitHub committed 2024-11-11 15:17:15 -08:00
commit ff350f20fc
12 files changed
+516 -6

No files matched your search

+1 -6
View File
@@ -1,11 +1,6 @@
{
"all": true,
"include": [
"controllers/*.js",
"utils/*.js",
"service/*.js",
"db/mongo/modules/monitorModule.js"
],
"include": ["controllers/*.js", "utils/*.js", "service/*.js", "db/mongo/modules/*.js"],
"exclude": ["**/*.test.js"],
"reporter": ["html", "text", "lcov"],
"sourceMap": false,
@@ -0,0 +1,51 @@
import { handleError, handleValidationError } from "./controllerUtils.js";
import {
createStatusPageBodyValidation,
getStatusPageParamValidation,
} from "../validation/joi.js";
import { successMessages } from "../utils/messages.js";
const SERVICE_NAME = "statusPageController";
const createStatusPage = async (req, res, next) => {
try {
await createStatusPageBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const statusPage = await req.db.createStatusPage(req.body);
return res.status(200).json({
success: true,
msg: successMessages.STATUS_PAGE_CREATE,
data: statusPage,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createStatusPage"));
}
};
const getStatusPageByUrl = async (req, res, next) => {
try {
await getStatusPageParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { url } = req.params;
const statusPage = await req.db.getStatusPageByUrl(url);
return res.status(200).json({
success: true,
msg: successMessages.STATUS_PAGE_BY_URL,
data: statusPage,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getStatusPage"));
}
};
export { createStatusPage, getStatusPageByUrl };
+42
View File
@@ -0,0 +1,42 @@
import mongoose from "mongoose";
const StatusPageSchema = mongoose.Schema(
{
companyName: {
type: String,
required: true,
default: "",
},
url: {
type: String,
unique: true,
required: true,
default: "",
},
timezone: {
type: String,
required: true,
default: "America/Toronto",
},
color: {
type: String,
required: true,
default: "#4169E1",
},
theme: {
type: String,
required: true,
default: "light",
},
monitors: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Monitor",
required: true,
},
],
},
{ timestamps: true }
);
export default mongoose.model("StatusPage", StatusPageSchema);
+7
View File
@@ -169,6 +169,11 @@ import {
//****************************************
import { getAppSettings, updateAppSettings } from "./modules/settingsModule.js";
//****************************************
// Status Page
//****************************************
import { createStatusPage, getStatusPageByUrl } from "./modules/statusPageModule.js";
export default {
connect,
disconnect,
@@ -223,4 +228,6 @@ export default {
deleteNotificationsByMonitorId,
getAppSettings,
updateAppSettings,
createStatusPage,
getStatusPageByUrl,
};
@@ -0,0 +1,40 @@
import StatusPage from "../../models/StatusPage.js";
import { errorMessages } from "../../../utils/messages.js";
const SERVICE_NAME = "statusPageModule";
const createStatusPage = async (statusPageData) => {
try {
const statusPage = new StatusPage({ ...statusPageData });
await statusPage.save();
return statusPage;
} catch (error) {
if (error?.code === 11000) {
// Handle duplicate URL errors
error.status = 400;
error.message = errorMessages.STATUS_PAGE_URL_NOT_UNIQUE;
}
error.service = SERVICE_NAME;
error.method = "createStatusPage";
throw error;
}
};
const getStatusPageByUrl = async (url) => {
try {
const statusPage = await StatusPage.findOne({ url });
if (statusPage === null || statusPage === undefined) {
const error = new Error(errorMessages.STATUS_PAGE_NOT_FOUND);
error.status = 404;
throw error;
}
return statusPage;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getStatusPageByUrl";
throw error;
}
};
export { createStatusPage, getStatusPageByUrl };
+2
View File
@@ -14,6 +14,7 @@ import monitorRouter from "./routes/monitorRoute.js";
import checkRouter from "./routes/checkRoute.js";
import maintenanceWindowRouter from "./routes/maintenanceWindowRoute.js";
import settingsRouter from "./routes/settingsRoute.js";
import statusPageRouter from "./routes/statusPageRoute.js";
import { fileURLToPath } from "url";
import queueRouter from "./routes/queueRoute.js";
@@ -88,6 +89,7 @@ const startApp = async () => {
app.use("/api/v1/checks", verifyJWT, checkRouter);
app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRouter);
app.use("/api/v1/queue", verifyJWT, queueRouter);
app.use("/api/v1/status-page", statusPageRouter);
//health check
app.use("/api/v1/healthy", (req, res) => {
+131
View File
@@ -84,6 +84,10 @@
{
"name": "queue",
"description": "Queue"
},
{
"name": "status-page",
"description": "Status Page"
}
],
"paths": {
@@ -2239,6 +2243,118 @@
}
]
}
},
"/status-page/{url}": {
"get": {
"tags": ["status-page"],
"description": "Get a status page by URL",
"parameters": [
{
"name": "url",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SuccessResponse" }
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
}
},
"post": {
"tags": ["status-page"],
"description": "Create a status page",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateStatusPageBody"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"400": {
"description": "Duplicate URL",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
}
},
"components": {
@@ -2425,6 +2541,21 @@
"format": "date-time"
}
}
},
"CreateStatusPageBody": {
"type": "object",
"required": ["companyName", "url", "timezone", "color", "theme", "monitors"],
"properties": {
"companyName": { "type": "string" },
"url": { "type": "string" },
"timezone": { "type": "string" },
"color": { "type": "string" },
"theme": { "type": "string" },
"monitors": {
"type": "array",
"items": { "type": "string" }
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import express from "express";
import { verifyJWT } from "../middleware/verifyJWT.js";
import {
createStatusPage,
getStatusPageByUrl,
} from "../controllers/statusPageController.js";
const router = express.Router();
router.get("/:url", getStatusPageByUrl);
router.post("/:url", verifyJWT, createStatusPage);
export default router;
@@ -0,0 +1,128 @@
import sinon from "sinon";
import {
createStatusPage,
getStatusPageByUrl,
} from "../../controllers/statusPageController.js";
describe("statusPageController", () => {
let req, res, next;
beforeEach(() => {
req = {
params: {},
body: {},
db: {
createStatusPage: sinon.stub(),
getStatusPageByUrl: sinon.stub(),
},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
});
afterEach(() => {
sinon.restore();
});
describe("createStatusPage", () => {
beforeEach(() => {
req.body = {
companyName: "Test Company",
url: "123456",
timezone: "America/Toronto",
color: "#000000",
theme: "light",
monitors: ["67309ca673788e808884c8ac", "67309ca673788e808884c8ac"],
};
});
afterEach(() => {
sinon.restore();
});
it("should handle a validation error", async () => {
req.body = {
// Invalid data that will trigger validation error
companyName: "",
url: "",
timezone: "",
color: "invalid",
theme: "invalid",
monitors: ["invalid-id"],
};
try {
await createStatusPage(req, res, next);
} catch (error) {
expect(error).to.be.an.instanceOf(Error);
expect(error.message).to.equal("Validation error");
}
});
it("should handle a db error", async () => {
const err = new Error("DB error");
req.db.createStatusPage.throws(err);
try {
await createStatusPage(req, res, next);
} catch (error) {
expect(error).to.deep.equal(err);
}
});
it("should insert a properly formatted status page", async () => {
const result = await createStatusPage(req, res, next);
expect(res.status.firstCall.args[0]).to.equal(200);
expect(res.json.firstCall.args[0].success).to.be.true;
});
});
describe("getStatusPageByUrl", () => {
beforeEach(() => {
req.params = {
url: "123456",
};
});
afterEach(() => {
sinon.restore();
});
it("should handle a validation error", async () => {
req.params = {
url: "",
};
try {
await getStatusPageByUrl(req, res, next);
} catch (error) {
expect(error).to.be.an.instanceOf(Error);
expect(error.message).to.equal("Validation error");
}
});
it("should handle a DB error", async () => {
const err = new Error("DB error");
req.db.getStatusPageByUrl.throws(err);
try {
await getStatusPageByUrl(req, res, next);
} catch (error) {
expect(error).to.deep.equal(err);
}
});
it("should return a status page", async () => {
const statusPage = {
_id: "123456",
companyName: "Test Company",
url: "123456",
};
req.db.getStatusPageByUrl.resolves(statusPage);
const result = await getStatusPageByUrl(req, res, next);
expect(res.status.firstCall.args[0]).to.equal(200);
expect(res.json.firstCall.args[0].success).to.be.true;
expect(res.json.firstCall.args[0].data).to.deep.equal(statusPage);
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import sinon from "sinon";
import {
createStatusPage,
getStatusPageByUrl,
} from "../../db/mongo/modules/statusPageModule.js";
import StatusPage from "../../db/models/StatusPage.js";
import { errorMessages } from "../../utils/messages.js";
describe("statusPageModule", () => {
let statusPageFindOneStub, statusPageSaveStub, statusPageFindStub;
beforeEach(() => {
statusPageSaveStub = sinon.stub(StatusPage.prototype, "save");
statusPageFindOneStub = sinon.stub(StatusPage, "findOne");
statusPageFindStub = sinon.stub(StatusPage, "find");
});
afterEach(() => {
sinon.restore();
});
describe("createStatusPage", () => {
it("should throw an error if a non-unique url is provided", async () => {
statusPageFindOneStub.resolves(true);
try {
await createStatusPage({ url: "test" });
} catch (error) {
expect(error.status).to.equal(400);
expect(error.message).to.equal(errorMessages.STATUS_PAGE_URL_NOT_UNIQUE);
}
});
it("should handle duplicate URL errors", async () => {
const err = new Error("test");
err.code = 11000;
statusPageSaveStub.rejects(err);
try {
await createStatusPage({ url: "test" });
} catch (error) {
expect(error).to.deep.equal(err);
}
});
it("should return a status page if a unique url is provided", async () => {
statusPageFindOneStub.resolves(null);
statusPageFindStub.resolves([]);
const mockStatusPage = { url: "test" };
const statusPage = await createStatusPage(mockStatusPage);
expect(statusPage).to.exist;
expect(statusPage.url).to.equal(mockStatusPage.url);
});
});
describe("getStatusPageByUrl", () => {
it("should throw an error if a status page is not found", async () => {
statusPageFindOneStub.resolves(null);
try {
await getStatusPageByUrl("test");
} catch (error) {
expect(error.status).to.equal(404);
expect(error.message).to.equal(errorMessages.STATUS_PAGE_NOT_FOUND);
}
});
it("should return a status page if a status page is found", async () => {
const mockStatusPage = { url: "test" };
statusPageFindOneStub.resolves(mockStatusPage);
const statusPage = await getStatusPageByUrl(mockStatusPage.url);
expect(statusPage).to.exist;
expect(statusPage).to.deep.equal(mockStatusPage);
});
});
});
+8
View File
@@ -51,6 +51,10 @@ const errorMessages = {
// PING Operations
PING_CANNOT_RESOLVE: "No response",
// Status Page Errors
STATUS_PAGE_NOT_FOUND: "Status page not found",
STATUS_PAGE_URL_NOT_UNIQUE: "Status page url must be unique",
};
const successMessages = {
@@ -115,6 +119,10 @@ const successMessages = {
// App Settings
GET_APP_SETTINGS: "Got app settings successfully",
UPDATE_APP_SETTINGS: "Updated app settings successfully",
// Status Page
STATUS_PAGE_BY_URL: "Got status page by url successfully",
STATUS_PAGE_CREATE: "Status page created successfully",
};
export { errorMessages, successMessages };
+28
View File
@@ -410,6 +410,32 @@ const updateAppSettingsBodyValidation = joi.object({
systemEmailPassword: joi.string().allow(""),
});
//****************************************
// Status Page Validation
//****************************************
const getStatusPageParamValidation = joi.object({
url: joi.string().required(),
});
const createStatusPageBodyValidation = joi.object({
companyName: joi.string().required(),
url: joi.string().required(),
timezone: joi.string().required(),
color: joi.string().required(),
theme: joi.string().required(),
monitors: joi
.array()
.items(joi.string().pattern(/^[0-9a-fA-F]{24}$/))
.required()
.messages({
"string.pattern.base": "Must be a valid monitor ID",
"array.base": "Monitors must be an array",
"array.empty": "At least one monitor is required",
"any.required": "Monitors are required",
}),
});
export {
roleValidatior,
loginValidation,
@@ -465,4 +491,6 @@ export {
editMaintenanceWindowByIdParamValidation,
editMaintenanceByIdWindowBodyValidation,
updateAppSettingsBodyValidation,
createStatusPageBodyValidation,
getStatusPageParamValidation,
};