diff --git a/constants/logs.ts b/constants/logs.ts index 5988a5ae7..86ad01a9c 100644 --- a/constants/logs.ts +++ b/constants/logs.ts @@ -16,7 +16,12 @@ export const logType = { EXTENSION_REQUESTS: "extensionRequests", TASK: "task", TASK_REQUESTS: "taskRequests", - USER_DETAILS_UPDATED: "USER_DETAILS_UPDATED", + USER_DETAILS_UPDATED: "USER_DETAILS_UPDATED", + REQUEST_DOES_NOT_EXIST:"REQUEST_DOES_NOT_EXIST", + UNAUTHORIZED_TO_UPDATE_REQUEST: "UNAUTHORIZED_TO_UPDATE_REQUEST", + INVALID_REQUEST_TYPE: "INVALID_REQUEST_TYPE", + PENDING_REQUEST_CAN_BE_UPDATED: "PENDING_REQUEST_CAN_BE_UPDATED", + INVALID_REQUEST_DEADLINE: "INVALID_REQUEST_DEADLINE", ...REQUEST_LOG_TYPE, }; diff --git a/constants/progresses.ts b/constants/progresses.ts index ee2a5e9c6..2ef81f400 100644 --- a/constants/progresses.ts +++ b/constants/progresses.ts @@ -18,7 +18,8 @@ const TYPE_MAP = { task: "taskId", }; const PROGRESS_VALID_SORT_FIELDS = ["date", "-date"]; - +const PROGRESSES_SIZE = 20; +const PROGRESSES_PAGE_SIZE = 0; const VALID_PROGRESS_TYPES = ["task", "user"]; module.exports = { @@ -28,4 +29,6 @@ module.exports = { TYPE_MAP, VALID_PROGRESS_TYPES, PROGRESS_VALID_SORT_FIELDS, + PROGRESSES_SIZE, + PROGRESSES_PAGE_SIZE, }; diff --git a/constants/requests.ts b/constants/requests.ts index 1173a883e..8a9635d2d 100644 --- a/constants/requests.ts +++ b/constants/requests.ts @@ -24,6 +24,7 @@ export const REQUEST_LOG_TYPE = { REQUEST_REJECTED: "REQUEST_REJECTED", REQUEST_BLOCKED: "REQUEST_BLOCKED", REQUEST_CANCELLED: "REQUEST_CANCELLED", + REQUEST_UPDATED: "REQUEST_UPDATED", }; export const REQUEST_CREATED_SUCCESSFULLY = "Request created successfully"; @@ -56,4 +57,10 @@ export const TASK_REQUEST_MESSAGES = { }; export const ONBOARDING_REQUEST_CREATED_SUCCESSFULLY = "Onboarding extension request created successfully" -export const UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST = "Only super user and onboarding user are authorized to create an onboarding extension request" \ No newline at end of file +export const UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST = "Only super user and onboarding user are authorized to create an onboarding extension request" + +export const PENDING_REQUEST_UPDATED = "Only pending extension request can be updated"; +export const INVALID_REQUEST_TYPE = "Invalid request type"; +export const INVALID_REQUEST_DEADLINE = "New deadline of the request must be greater than old deadline"; +export const REQUEST_UPDATED_SUCCESSFULLY = "Request updated successfully"; +export const UNAUTHORIZED_TO_UPDATE_REQUEST = "Unauthorized to update request"; diff --git a/controllers/onboardingExtension.ts b/controllers/onboardingExtension.ts index 9ea0565b3..356bd20e9 100644 --- a/controllers/onboardingExtension.ts +++ b/controllers/onboardingExtension.ts @@ -10,7 +10,9 @@ import { REQUEST_REJECTED_SUCCESSFULLY, REQUEST_STATE, REQUEST_TYPE, + REQUEST_UPDATED_SUCCESSFULLY, UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST, + UNAUTHORIZED_TO_UPDATE_REQUEST, } from "../constants/requests"; import { userState } from "../constants/userStatus"; import { addLog } from "../services/logService"; @@ -24,10 +26,15 @@ import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse, UpdateOnboardingExtensionStateRequest, - UpdateOnboardingExtensionStateRequestBody + UpdateOnboardingExtensionStateRequestBody, + UpdateOnboardingExtensionRequest, + UpdateOnboardingExtensionRequestBody } from "../types/onboardingExtension"; import { convertDateStringToMilliseconds, getNewDeadline } from "../utils/requests"; import { convertDaysToMilliseconds } from "../utils/time"; +import firestore from "../utils/firestore"; +import { updateOnboardingExtensionRequest, validateOnboardingExtensionUpdateRequest } from "../services/onboardingExtension"; +const requestModel = firestore.collection("requests"); /** * Controller to handle the creation of onboarding extension requests. @@ -200,3 +207,62 @@ export const updateOnboardingExtensionRequestState = async ( return res.boom.badImplementation(ERROR_WHILE_UPDATING_REQUEST); } } + +/** + * Updates an onboarding extension request. + * + * @param {UpdateOnboardingExtensionRequest} req - The request object. + * @param {OnboardingExtensionResponse} res - The response object. + * @returns {Promise} Resolves with success or failure. + */ +export const updateOnboardingExtensionRequestController = async ( + req: UpdateOnboardingExtensionRequest, + res: OnboardingExtensionResponse): Promise => +{ + + const body = req.body as UpdateOnboardingExtensionRequestBody; + const id = req.params.id; + const lastModifiedBy = req?.userData?.id; + const isSuperuser = req?.userData?.roles?.super_user === true; + const dev = req.query.dev === "true"; + + if(!dev) return res.boom.notImplemented("Feature not implemented"); + + try{ + const extensionRequestDoc = await requestModel.doc(id).get(); + const validationResponse = await validateOnboardingExtensionUpdateRequest( + extensionRequestDoc, + id, + isSuperuser, + lastModifiedBy, + body.newEndsOn, + ) + + if (validationResponse){ + if(validationResponse.error === REQUEST_DOES_NOT_EXIST){ + return res.boom.notFound(validationResponse.error); + } + if(validationResponse.error === UNAUTHORIZED_TO_UPDATE_REQUEST){ + return res.boom.forbidden(UNAUTHORIZED_TO_UPDATE_REQUEST); + } + return res.boom.badRequest(validationResponse.error); + } + + const requestBody = await updateOnboardingExtensionRequest( + id, + body, + lastModifiedBy, + ) + + return res.status(200).json({ + message: REQUEST_UPDATED_SUCCESSFULLY, + data: { + id: extensionRequestDoc.id, + ...requestBody + } + }) + }catch(error){ + logger.error(ERROR_WHILE_UPDATING_REQUEST, error); + return res.boom.badImplementation(ERROR_WHILE_UPDATING_REQUEST); + } +} diff --git a/controllers/progresses.js b/controllers/progresses.js index 5e1b24870..693980c04 100644 --- a/controllers/progresses.js +++ b/controllers/progresses.js @@ -1,11 +1,11 @@ const { Conflict, NotFound } = require("http-errors"); +const progressesModel = require("../models/progresses"); const { - createProgressDocument, - getProgressDocument, - getRangeProgressData, - getProgressByDate, -} = require("../models/progresses"); -const { PROGRESSES_RESPONSE_MESSAGES, INTERNAL_SERVER_ERROR_MESSAGE } = require("../constants/progresses"); + PROGRESSES_RESPONSE_MESSAGES, + INTERNAL_SERVER_ERROR_MESSAGE, + PROGRESSES_SIZE, + PROGRESSES_PAGE_SIZE, +} = require("../constants/progresses"); const { sendTaskUpdate } = require("../utils/sendTaskUpdate"); const { PROGRESS_DOCUMENT_RETRIEVAL_SUCCEEDED, PROGRESS_DOCUMENT_CREATED_SUCCEEDED } = PROGRESSES_RESPONSE_MESSAGES; @@ -49,7 +49,7 @@ const createProgress = async (req, res) => { body: { type, completed, planned, blockers, taskId }, } = req; try { - const { data, taskTitle } = await createProgressDocument({ ...req.body, userId: req.userData.id }); + const { data, taskTitle } = await progressesModel.createProgressDocument({ ...req.body, userId: req.userData.id }); await sendTaskUpdate(completed, blockers, planned, req.userData.username, taskId, taskTitle); return res.status(201).json({ data, @@ -107,8 +107,35 @@ const createProgress = async (req, res) => { */ const getProgress = async (req, res) => { + const { dev, page = PROGRESSES_PAGE_SIZE, size = PROGRESSES_SIZE, type, userId, taskId } = req.query; try { - const data = await getProgressDocument(req.query); + if (dev === "true") { + const { progressDocs, totalProgressCount } = await progressesModel.getPaginatedProgressDocument(req.query); + const limit = parseInt(size, 10); + const offset = parseInt(page, 10) * limit; + const nextPage = offset + limit < totalProgressCount ? parseInt(page, 10) + 1 : null; + const prevPage = page > 0 ? parseInt(page, 10) - 1 : null; + let baseUrl = `${req.baseUrl}`; + if (type) { + baseUrl += `?type=${type}`; + } else if (userId) { + baseUrl += `?userId=${userId}`; + } else if (taskId) { + baseUrl += `?taskId=${taskId}`; + } + const nextLink = nextPage !== null ? `${baseUrl}&page=${nextPage}&size=${size}&dev=${dev}` : null; + const prevLink = prevPage !== null ? `${baseUrl}&page=${prevPage}&size=${size}&dev=${dev}` : null; + return res.json({ + message: PROGRESS_DOCUMENT_RETRIEVAL_SUCCEEDED, + count: progressDocs.length, + data: progressDocs, + links: { + prev: prevLink, + next: nextLink, + }, + }); + } + const data = await progressesModel.getProgressDocument(req.query); return res.json({ message: PROGRESS_DOCUMENT_RETRIEVAL_SUCCEEDED, count: data.length, @@ -163,7 +190,7 @@ const getProgress = async (req, res) => { const getProgressRangeData = async (req, res) => { try { - const data = await getRangeProgressData(req.query); + const data = await progressesModel.getRangeProgressData(req.query); return res.json({ message: PROGRESS_DOCUMENT_RETRIEVAL_SUCCEEDED, data, @@ -217,7 +244,7 @@ const getProgressRangeData = async (req, res) => { const getProgressBydDateController = async (req, res) => { try { - const data = await getProgressByDate(req.params, req.query); + const data = await progressesModel.getProgressByDate(req.params, req.query); return res.json({ message: PROGRESS_DOCUMENT_RETRIEVAL_SUCCEEDED, data, diff --git a/controllers/requests.ts b/controllers/requests.ts index 1d1e6aa78..fd8974ea0 100644 --- a/controllers/requests.ts +++ b/controllers/requests.ts @@ -14,7 +14,10 @@ import { UpdateRequest } from "../types/requests"; import { TaskRequestRequest } from "../types/taskRequests"; import { createTaskRequestController } from "./taskRequestsv2"; import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse, UpdateOnboardingExtensionStateRequest } from "../types/onboardingExtension"; -import { createOnboardingExtensionRequestController, updateOnboardingExtensionRequestState } from "./onboardingExtension"; +import { createOnboardingExtensionRequestController, updateOnboardingExtensionRequestController, updateOnboardingExtensionRequestState } from "./onboardingExtension"; +import { UpdateOnboardingExtensionRequest } from "../types/onboardingExtension"; + +import { Request } from "express"; export const createRequestController = async ( req: OooRequestCreateRequest | ExtensionRequestRequest | TaskRequestRequest | OnboardingExtensionCreateRequest, @@ -30,8 +33,6 @@ export const createRequestController = async ( return await createTaskRequestController(req as TaskRequestRequest, res as CustomResponse); case REQUEST_TYPE.ONBOARDING: return await createOnboardingExtensionRequestController(req as OnboardingExtensionCreateRequest, res as OnboardingExtensionResponse); - case REQUEST_TYPE.ONBOARDING: - return await createOnboardingExtensionRequestController(req as OnboardingExtensionCreateRequest, res as OnboardingExtensionResponse); default: return res.boom.badRequest("Invalid request type"); } @@ -59,6 +60,13 @@ export const getRequestsController = async (req: any, res: any) => { return res.status(204).send(); } + if (query.id) { + return res.status(200).json({ + message: REQUEST_FETCHED_SUCCESSFULLY, + data: requests, + }); + } + const { allRequests, next, prev, page } = requests; if (allRequests.length === 0) { return res.status(204).send(); @@ -105,3 +113,21 @@ export const getRequestsController = async (req: any, res: any) => { return res.boom.badImplementation(ERROR_WHILE_FETCHING_REQUEST); } }; + +/** + * Processes update requests before acknowledgment based on type. + * + * @param {Request} req - The request object. + * @param {CustomResponse} res - The response object. + * @returns {Promise} Resolves or sends an error for invalid types. + */ +export const updateRequestBeforeAcknowledgedController = async (req: Request, res: CustomResponse) => { + const type = req.body.type; + switch(type){ + case REQUEST_TYPE.ONBOARDING: + await updateOnboardingExtensionRequestController(req as UpdateOnboardingExtensionRequest, res as OnboardingExtensionResponse); + break; + default: + return res.boom.badRequest("Invalid request"); + } +} \ No newline at end of file diff --git a/middlewares/validators/onboardingExtensionRequest.ts b/middlewares/validators/onboardingExtensionRequest.ts index bf7afe127..953a26aba 100644 --- a/middlewares/validators/onboardingExtensionRequest.ts +++ b/middlewares/validators/onboardingExtensionRequest.ts @@ -1,7 +1,7 @@ import joi from "joi"; import { NextFunction } from "express"; import { REQUEST_TYPE } from "../../constants/requests"; -import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse } from "../../types/onboardingExtension"; +import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse, UpdateOnboardingExtensionRequest } from "../../types/onboardingExtension"; export const createOnboardingExtensionRequestValidator = async ( req: OnboardingExtensionCreateRequest, @@ -40,3 +40,41 @@ export const createOnboardingExtensionRequestValidator = async ( throw error; } }; + +/** + * Validates onboarding extension request payload. + * + * @param {UpdateOnboardingExtensionRequest} req - Request object. + * @param {OnboardingExtensionResponse} res - Response object. + * @param {NextFunction} next - Next middleware if valid. + * @returns {Promise} Resolves or sends errors. + */ +export const updateOnboardingExtensionRequestValidator = async ( + req: UpdateOnboardingExtensionRequest, + res: OnboardingExtensionResponse, + next: NextFunction): Promise => { + const schema = joi + .object() + .strict() + .keys({ + reason: joi.string().optional(), + newEndsOn: joi.number().positive().min(Date.now()).required().messages({ + 'number.any': 'newEndsOn is required', + 'number.base': 'newEndsOn must be a number', + 'number.positive': 'newEndsOn must be positive', + 'number.greater': 'newEndsOn must be greater than current date', + }), + type: joi.string().equal(REQUEST_TYPE.ONBOARDING).required().messages({ + "type.any": "type is required", + }) + }); + + try { + await schema.validateAsync(req.body, { abortEarly: false }); + next(); + } catch (error) { + const errorMessages = error.details.map((detail:{message: string}) => detail.message); + logger.error(`Error while validating request payload : ${errorMessages}`); + return res.boom.badRequest(errorMessages); + } +} diff --git a/middlewares/validators/progresses.js b/middlewares/validators/progresses.js index a1a17ffcc..2b04befee 100644 --- a/middlewares/validators/progresses.js +++ b/middlewares/validators/progresses.js @@ -73,6 +73,15 @@ const validateGetProgressRecordsQuery = async (req, res, next) => { .messages({ "string.base": "orderBy must be a string", }), + size: joi.number().optional().min(1).max(100).messages({ + "number.base": "size must be a number", + "number.min": "size must be in the range 1-100", + "number.max": "size must be in the range 1-100", + }), + page: joi.number().optional().min(0).messages({ + "number.base": "page must be a number", + "number.min": "page must be a positive number or zero", + }), }) .xor("type", "userId", "taskId") .messages({ diff --git a/middlewares/validators/requests.ts b/middlewares/validators/requests.ts index 3839e358e..80ff0478b 100644 --- a/middlewares/validators/requests.ts +++ b/middlewares/validators/requests.ts @@ -9,8 +9,8 @@ import { ExtensionRequestRequest, ExtensionRequestResponse } from "../../types/e import { CustomResponse } from "../../typeDefinitions/global"; import { UpdateRequest } from "../../types/requests"; import { TaskRequestRequest, TaskRequestResponse } from "../../types/taskRequests"; -import { createOnboardingExtensionRequestValidator } from "./onboardingExtensionRequest"; -import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse } from "../../types/onboardingExtension"; +import { createOnboardingExtensionRequestValidator, updateOnboardingExtensionRequestValidator } from "./onboardingExtensionRequest"; +import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse, UpdateOnboardingExtensionRequest } from "../../types/onboardingExtension"; export const createRequestsMiddleware = async ( req: OooRequestCreateRequest|ExtensionRequestRequest | TaskRequestRequest | OnboardingExtensionCreateRequest, @@ -121,3 +121,28 @@ export const getRequestsMiddleware = async (req: OooRequestCreateRequest, res: O res.boom.badRequest(errorMessages); } }; + +/** + * Validates update requests based on their type. + * + * @param {UpdateOnboardingExtensionRequest} req - Request object. + * @param {CustomResponse} res - Response object. + * @param {NextFunction} next - Next middleware if valid. + * @returns {Promise} Resolves or sends errors. + */ +export const updateRequestValidator = async ( + req: UpdateOnboardingExtensionRequest, + res: CustomResponse, + next: NextFunction + ): Promise => { + const type = req.body.type; + switch (type) { + case REQUEST_TYPE.ONBOARDING: + await updateOnboardingExtensionRequestValidator( + req, + res as OnboardingExtensionResponse, next); + break; + default: + return res.boom.badRequest("Invalid type"); + } +}; \ No newline at end of file diff --git a/models/progresses.js b/models/progresses.js index e406d9f15..befc56dcd 100644 --- a/models/progresses.js +++ b/models/progresses.js @@ -12,6 +12,8 @@ const { assertTaskExists, getProgressDateTimestamp, buildQueryToSearchProgressByDay, + buildQueryToFetchPaginatedDocs, + getPaginatedProgressDocs, } = require("../utils/progresses"); const { retrieveUsers } = require("../services/dataAccessLayer"); const { PROGRESS_ALREADY_CREATED, PROGRESS_DOCUMENT_NOT_FOUND } = PROGRESSES_RESPONSE_MESSAGES; @@ -59,6 +61,22 @@ const getProgressDocument = async (queryParams) => { return progressDocs; }; +/** + * Retrieves a paginated list of progress documents based on the provided query parameters. + * @param {object} queryParams - Query data, including type, userId, taskId, and optional pagination details (page and pageSize). + * @returns {Promise} Resolves with paginated progress documents. + * @throws {Error} If userId or taskId is invalid or not found. + **/ + +const getPaginatedProgressDocument = async (queryParams) => { + await assertUserOrTaskExists(queryParams); + const page = queryParams.page || 0; + const { baseQuery, totalProgressCount } = await buildQueryToFetchPaginatedDocs(queryParams); + + let progressDocs = await getPaginatedProgressDocs(baseQuery, page); + progressDocs = await addUserDetailsToProgressDocs(progressDocs); + return { progressDocs, totalProgressCount }; +}; /** * This function fetches the progress records for a particular user or task within the specified date range, from start to end date. * @param queryParams {object} This is the data that will be used for querying. It should be an object that includes key-value pairs for the fields - userId, taskId, startDate, and endDate. @@ -135,6 +153,7 @@ const addUserDetailsToProgressDocs = async (progressDocs) => { module.exports = { createProgressDocument, getProgressDocument, + getPaginatedProgressDocument, getRangeProgressData, getProgressByDate, addUserDetailsToProgressDocs, diff --git a/routes/requests.ts b/routes/requests.ts index f04cba0c6..098e00a82 100644 --- a/routes/requests.ts +++ b/routes/requests.ts @@ -2,14 +2,26 @@ import express from "express"; const router = express.Router(); const authorizeRoles = require("../middlewares/authorizeRoles"); const { SUPERUSER } = require("../constants/roles"); - import authenticate from "../middlewares/authenticate"; -import { createRequestsMiddleware,updateRequestsMiddleware,getRequestsMiddleware } from "../middlewares/validators/requests"; -import { createRequestController , updateRequestController, getRequestsController} from "../controllers/requests"; +import { + createRequestsMiddleware, + updateRequestsMiddleware, + getRequestsMiddleware, + updateRequestValidator +} from "../middlewares/validators/requests"; +import { + createRequestController , + updateRequestController, + getRequestsController, + updateRequestBeforeAcknowledgedController +} from "../controllers/requests"; import { skipAuthenticateForOnboardingExtensionRequest } from "../middlewares/skipAuthenticateForOnboardingExtension"; import { verifyDiscordBot } from "../middlewares/authorizeBot"; + router.get("/", getRequestsMiddleware, getRequestsController); router.post("/", skipAuthenticateForOnboardingExtensionRequest(authenticate, verifyDiscordBot), createRequestsMiddleware, createRequestController); router.put("/:id",authenticate, authorizeRoles([SUPERUSER]), updateRequestsMiddleware, updateRequestController); +router.patch("/:id", authenticate, updateRequestValidator, updateRequestBeforeAcknowledgedController); module.exports = router; + diff --git a/services/onboardingExtension.ts b/services/onboardingExtension.ts new file mode 100644 index 000000000..f2b21fea9 --- /dev/null +++ b/services/onboardingExtension.ts @@ -0,0 +1,130 @@ +import { logType } from "../constants/logs"; +import { + INVALID_REQUEST_DEADLINE, + INVALID_REQUEST_TYPE, + LOG_ACTION, + PENDING_REQUEST_UPDATED, + REQUEST_DOES_NOT_EXIST, + REQUEST_LOG_TYPE, + REQUEST_STATE, + REQUEST_TYPE, + UNAUTHORIZED_TO_UPDATE_REQUEST +} from "../constants/requests"; +import { OnboardingExtension, UpdateOnboardingExtensionRequestBody } from "../types/onboardingExtension"; +import { addLog } from "./logService"; +import firestore from "../utils/firestore"; +const requestModel = firestore.collection("requests"); + +/** + * Validates the onboarding extension update request. + * + * @param {object} extensionRequestDoc - The extension request document. + * @param {string} id - Unique identifier for the request. + * @param {boolean} isSuperuser - Whether the user has superuser privileges. + * @param {string} lastModifiedBy - ID of the user modifying the request. + * @param {number} newEndsOn - Proposed new end date. + * @returns {Promise} Error details if validation fails. + */ +export const validateOnboardingExtensionUpdateRequest = async ( + extensionRequestDoc, + id: string, + isSuperuser: boolean, + lastModifiedBy: string, + newEndsOn: number +) => { + try{ + + if(!extensionRequestDoc.exists){ + await addLog(logType.REQUEST_DOES_NOT_EXIST, { id }, { message: REQUEST_DOES_NOT_EXIST }); + return { + error: REQUEST_DOES_NOT_EXIST, + } + } + + const extensionRequest = extensionRequestDoc.data() as OnboardingExtension; + + if(!isSuperuser && lastModifiedBy !== extensionRequest.userId) { + await addLog(logType.UNAUTHORIZED_TO_UPDATE_REQUEST, + { lastModifiedBy, userId: extensionRequest.userId }, + { message: UNAUTHORIZED_TO_UPDATE_REQUEST } + ); + return { + error: UNAUTHORIZED_TO_UPDATE_REQUEST + }; + } + + if(extensionRequest.type !== REQUEST_TYPE.ONBOARDING) { + await addLog(logType.INVALID_REQUEST_TYPE, + { type: extensionRequest.type }, + { message: INVALID_REQUEST_TYPE } + ); + return { + error: INVALID_REQUEST_TYPE + }; + } + + if(extensionRequest.state !== REQUEST_STATE.PENDING){ + await addLog(logType.PENDING_REQUEST_CAN_BE_UPDATED, + { state: extensionRequest.state }, + { message:PENDING_REQUEST_UPDATED } + ); + return { + error: PENDING_REQUEST_UPDATED + }; + } + + if(extensionRequest.oldEndsOn >= newEndsOn) { + await addLog(logType.INVALID_REQUEST_DEADLINE, + { oldEndsOn: extensionRequest.oldEndsOn, newEndsOn: newEndsOn }, + { message: INVALID_REQUEST_DEADLINE } + ); + return { + error: INVALID_REQUEST_DEADLINE + }; + } + }catch(error){ + logger.error("Error while validating onboarding extension update request", error); + throw error; + } +} + +/** + * Updates an onboarding extension request. + * + * @param {string} id - The extension request document. + * @param {UpdateOnboardingExtensionRequestBody} body - New request details. + * @param {string} lastModifiedBy - ID of the user updating the request. + * @returns {Promise} Updated request body. + */ +export const updateOnboardingExtensionRequest = async ( + id: string, + body: UpdateOnboardingExtensionRequestBody, + lastModifiedBy: string +) => { + try{ + const requestBody = { + ...body, + lastModifiedBy, + updatedAt: Date.now(), + } + + await requestModel.doc(id).update(requestBody); + + const requestLog = { + type: REQUEST_LOG_TYPE.REQUEST_UPDATED, + meta: { + requestId: id, + action: LOG_ACTION.UPDATE, + createdBy: lastModifiedBy, + }, + body: requestBody, + }; + + await addLog(requestLog.type, requestLog.meta, requestLog.body); + + return requestBody; + }catch(error){ + logger.error("Error while updating onboarding extension request", error); + throw error; + } +} \ No newline at end of file diff --git a/test/integration/onboardingExtension.test.ts b/test/integration/onboardingExtension.test.ts index 541df3fd1..4c81e98b7 100644 --- a/test/integration/onboardingExtension.test.ts +++ b/test/integration/onboardingExtension.test.ts @@ -11,7 +11,13 @@ import { REQUEST_STATE, REQUEST_TYPE, ONBOARDING_REQUEST_CREATED_SUCCESSFULLY, UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST, - REQUEST_FETCHED_SUCCESSFULLY + REQUEST_FETCHED_SUCCESSFULLY, + INVALID_REQUEST_DEADLINE, + PENDING_REQUEST_UPDATED, + REQUEST_UPDATED_SUCCESSFULLY, + INVALID_REQUEST_TYPE, + REQUEST_DOES_NOT_EXIST, + UNAUTHORIZED_TO_UPDATE_REQUEST } from "../../constants/requests"; const { generateToken } = require("../../test/utils/generateBotToken"); import app from "../../server"; @@ -22,6 +28,9 @@ import * as requestsQuery from "../../models/requests" import { userState } from "../../constants/userStatus"; import { generateAuthToken } from "../../services/authService"; const { CLOUDFLARE_WORKER, BAD_TOKEN } = require("../../constants/bot"); +import * as logUtils from "../../services/logService"; +import { convertDaysToMilliseconds } from "../../utils/time"; +import { OooStatusRequest } from "../../types/oooRequest"; const userData = userDataFixture(); chai.use(chaiHttp); @@ -412,12 +421,12 @@ describe("/requests Onboarding Extension", () => { putEndpoint = `/requests/${latestExtension.id}?dev=true`; authToken = generateAuthToken({userId}); }) - + afterEach(async () => { sinon.restore(); await cleanDb(); }) - + it("should return 401 response when user is not a super user", (done) => { chai.request(app) .put(putEndpoint) @@ -597,5 +606,237 @@ describe("/requests Onboarding Extension", () => { }) }) }); + + describe("PATCH /requests", () => { + const body = { + type: REQUEST_TYPE.ONBOARDING, + newEndsOn: Date.now() + convertDaysToMilliseconds(3), + reason: "" + } + let latestValidExtension: OnboardingExtension; + let userId: string; + let invalidUserId: string; + let superUserId: string; + let patchEndpoint: string; + let authToken: string; + let latestApprovedExtension: OnboardingExtension; + let latestInvalidExtension: OnboardingExtension; + let oooRequest: OooStatusRequest; + + beforeEach(async () => { + userId = await addUser(userData[6]); + invalidUserId = await addUser(userData[0]); + superUserId = await addUser(userData[4]); + latestInvalidExtension = await requestsQuery.createRequest({ + state: REQUEST_STATE.PENDING, + type: REQUEST_TYPE.ONBOARDING, + oldEndsOn: Date.now() + convertDaysToMilliseconds(5), + userId: userId, + }); + latestValidExtension = await requestsQuery.createRequest({ + state: REQUEST_STATE.PENDING, + type: REQUEST_TYPE.ONBOARDING, + oldEndsOn: Date.now() - convertDaysToMilliseconds(3), + userId: userId + }); + latestApprovedExtension = await requestsQuery.createRequest({ + state: REQUEST_STATE.APPROVED, + type: REQUEST_TYPE.ONBOARDING, + oldEndsOn: Date.now(), + userId: userId + }); + oooRequest = await requestsQuery.createRequest({type: REQUEST_TYPE.OOO, userId: userId}); + patchEndpoint = `/requests/${latestValidExtension.id}?dev=true`; + authToken = generateAuthToken({userId}); + }) + + afterEach(async () => { + sinon.restore(); + await cleanDb(); + }) + + it("should return 400 response for incorrect type", (done) => { + chai.request(app) + .patch(patchEndpoint) + .set("authorization", `Bearer ${authToken}`) + .send({...body, type: ""}) + .end((err, res) => { + if(err) return done(err); + expect(res.statusCode).to.equal(400); + expect(res.body.error).to.equal("Bad Request"); + expect(res.body.message).to.equal("Invalid type"); + done(); + }) + }) + + it("should return Feature not implemented when dev is not true", (done) => { + chai.request(app) + .patch(`/requests/1111?dev=false`) + .send(body) + .set("authorization", `Bearer ${authToken}`) + .end((err, res)=>{ + if (err) return done(err); + expect(res.statusCode).to.equal(501); + expect(res.body.message).to.equal("Feature not implemented"); + done(); + }) + }) + + it("should return Unauthenticated User when authorization header is missing", (done) => { + chai + .request(app) + .patch(patchEndpoint) + .set("authorization", "") + .send(body) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(401); + expect(res.body.message).to.equal("Unauthenticated User"); + done(); + }) + }) + + it("should return Unauthenticated User for invalid token", (done) => { + chai.request(app) + .patch(patchEndpoint) + .set("authorization", `Bearer ${BAD_TOKEN}`) + .send(body) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(401); + expect(res.body.message).to.equal("Unauthenticated User"); + done(); + }) + }) + + it("should return 400 response for invalid value of newEndsOn", (done) => { + chai.request(app) + .patch(patchEndpoint) + .set("authorization", `Bearer ${authToken}`) + .send({...body, newEndsOn: Date.now()}) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(400); + expect(res.body.error).to.equal("Bad Request"); + expect(res.body.message).contain(`"newEndsOn" must be greater than or equal to`) + done(); + }) + }) + + it("should return 404 response for invalid extension id", (done) => { + chai.request(app) + .patch(`/requests/1111?dev=true`) + .set("authorization", `Bearer ${authToken}`) + .send(body) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(404); + expect(res.body.message).to.equal(REQUEST_DOES_NOT_EXIST); + expect(res.body.error).to.equal("Not Found"); + done(); + }) + }) + + it("should return 403 response when super user and request owner are not updating the request", (done) => { + chai.request(app) + .patch(patchEndpoint) + .set("authorization", `Bearer ${generateAuthToken({userId: invalidUserId})}`) + .send(body) + .end((err, res)=>{ + if(err) return done(err); + expect(res.statusCode).to.equal(403); + expect(res.body.error).to.equal("Forbidden"); + expect(res.body.message).to.equal(UNAUTHORIZED_TO_UPDATE_REQUEST); + done(); + }) + }) + + it("should return 400 response when request type is not onboarding", (done) => { + chai.request(app) + .patch(`/requests/${oooRequest.id}?dev=true`) + .set("authorization", `Bearer ${authToken}`) + .send(body) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.equal(INVALID_REQUEST_TYPE); + expect(res.body.error).to.equal("Bad Request"); + done(); + }) + }) + + it("should return 400 response when extension state is not pending", (done) => { + chai.request(app) + .patch(`/requests/${latestApprovedExtension.id}?dev=true`) + .set("authorization", `Bearer ${authToken}`) + .send(body) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.equal(PENDING_REQUEST_UPDATED); + expect(res.body.error).to.equal("Bad Request"); + done(); + }) + }) + + it("should return 400 response when old dealdine is greater than new deadline", (done) => { + chai.request(app) + .patch(`/requests/${latestInvalidExtension.id}?dev=true`) + .set("authorization", `Bearer ${authToken}`) + .send(body) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.equal(INVALID_REQUEST_DEADLINE); + expect(res.body.error).to.equal("Bad Request"); + done(); + }) + }) + + it("should return 200 success response when request owner is updating the request", (done) => { + chai.request(app) + .patch(patchEndpoint) + .set("authorization", `Bearer ${authToken}`) + .send(body) + .end((err, res)=>{ + if(err) return done(err); + expect(res.statusCode).to.equal(200); + expect(res.body.message).to.equal(REQUEST_UPDATED_SUCCESSFULLY); + expect(res.body.data.id).to.equal(latestValidExtension.id); + expect(res.body.data.newEndsOn).to.equal(body.newEndsOn) + done(); + }) + }) + + it("should return 200 success response when super user is updating the request", (done) => { + chai.request(app) + .patch(patchEndpoint) + .set("authorization", `Bearer ${generateAuthToken({userId: superUserId})}`) + .send(body) + .end((err, res)=>{ + if(err) return done(err); + expect(res.statusCode).to.equal(200); + expect(res.body.message).to.equal(REQUEST_UPDATED_SUCCESSFULLY); + expect(res.body.data.id).to.equal(latestValidExtension.id); + expect(res.body.data.newEndsOn).to.equal(body.newEndsOn) + done(); + }) + }) + + + it("should return 500 response for unexpected error", (done) => { + sinon.stub(logUtils, "addLog").throws("Error") + chai.request(app) + .patch(patchEndpoint) + .send(body) + .set("authorization", `Bearer ${authToken}`) + .end((err, res)=>{ + if(err) return done(err); + expect(res.statusCode).to.equal(500); + expect(res.body.error).to.equal("Internal Server Error"); + done(); + }) + }) + }) }); diff --git a/test/integration/progressesTasks.test.js b/test/integration/progressesTasks.test.js index 14a00ffe6..f6ff11a90 100644 --- a/test/integration/progressesTasks.test.js +++ b/test/integration/progressesTasks.test.js @@ -5,7 +5,7 @@ const firestore = require("../../utils/firestore"); const app = require("../../server"); const authService = require("../../services/authService"); const tasks = require("../../models/tasks"); - +const progressesModel = require("../../models/progresses"); const addUser = require("../utils/addUser"); const cleanDb = require("../utils/cleanDb"); const { @@ -16,7 +16,7 @@ const { const userData = require("../fixtures/user/user")(); const taskData = require("../fixtures/tasks/tasks")(); - +const { INTERNAL_SERVER_ERROR_MESSAGE } = require("../../constants/progresses"); const cookieName = config.get("userToken.cookieName"); const { expect } = chai; @@ -229,7 +229,7 @@ describe("Test Progress Updates API for Tasks", function () { .end((err, res) => { if (err) return done(err); expect(res).to.have.status(200); - expect(res.body).to.have.keys(["message", "data", "count"]); + expect(res.body).to.have.keys(["message", "data", "count", "links"]); expect(res.body.data).to.be.an("array"); expect(res.body.message).to.be.equal("Progress document retrieved successfully."); res.body.data.forEach((progress) => { @@ -388,7 +388,7 @@ describe("Test Progress Updates API for Tasks", function () { .end((err, res) => { if (err) return done(err); expect(res).to.have.status(200); - expect(res.body).to.have.keys(["message", "data", "count"]); + expect(res.body).to.have.keys(["message", "data", "count", "links"]); expect(res.body.data).to.be.an("array"); expect(res.body.message).to.be.equal("Progress document retrieved successfully."); expect(res.body.count).to.be.equal(4); @@ -615,4 +615,155 @@ describe("Test Progress Updates API for Tasks", function () { }); }); }); + + describe("GET /progresses (getPaginatedProgressDocument)", function () { + beforeEach(async function () { + const userId = await addUser(userData[1]); + const taskObject1 = await tasks.updateTask(taskData[0]); + const taskId1 = taskObject1.taskId; + const progressData1 = stubbedModelTaskProgressData(userId, taskId1, 1683626400000, 1683590400000); // 2023-05-09 + const progressData2 = stubbedModelTaskProgressData(userId, taskId1, 1683885600000, 1683849600000); // 2023-05-12 + await firestore.collection("progresses").doc("taskProgressDocument1").set(progressData1); + await firestore.collection("progresses").doc("taskProgressDocument2").set(progressData2); + }); + + afterEach(async function () { + await cleanDb(); + }); + + it("should return paginated results when dev=true is passed", function (done) { + chai + .request(app) + .get(`/progresses?type=task&dev=true&page=0&size=1`) + .end((err, res) => { + if (err) return done(err); + expect(res).to.have.status(200); + expect(res.body).to.have.keys(["message", "data", "count", "links"]); + expect(res.body.links).to.have.keys(["next", "prev"]); + expect(res.body.data).to.be.an("array"); + expect(res.body.message).to.be.equal("Progress document retrieved successfully."); + expect(res.body.count).to.be.equal(1); + res.body.data.forEach((progress) => { + expect(progress).to.have.keys([ + "id", + "type", + "completed", + "planned", + "blockers", + "userId", + "userData", + "taskId", + "createdAt", + "date", + ]); + }); + + return done(); + }); + }); + + it("should not return paginated results when dev=false is passed", function (done) { + chai + .request(app) + .get(`/progresses?type=task&dev=false&page=0&size=1`) + .end((err, res) => { + if (err) return done(err); + expect(res).to.have.status(200); + expect(res.body).to.have.keys(["message", "data", "count"]); + expect(res.body.data).to.be.an("array"); + expect(res.body.count).to.not.equal(1); + expect(res.body.message).to.be.equal("Progress document retrieved successfully."); + res.body.data.forEach((progress) => { + expect(progress).to.have.keys([ + "id", + "type", + "completed", + "planned", + "blockers", + "userId", + "taskId", + "createdAt", + "date", + ]); + }); + + return done(); + }); + }); + + it("should return null for next link on the last page", function (done) { + const size = 1; + const page = 1; + + chai + .request(app) + .get(`/progresses?type=task&dev=true&page=${page}&size=${size}`) + .end((err, res) => { + if (err) return done(err); + expect(res).to.have.status(200); + expect(res.body).to.have.keys(["message", "data", "count", "links"]); + expect(res.body.links).to.have.keys(["next", "prev"]); + expect(res.body.data).to.be.an("array"); + expect(res.body.message).to.be.equal("Progress document retrieved successfully."); + expect(res.body.links.next).to.be.equal(null); + expect(res.body.links.prev).to.equal(`/progresses?type=task&page=${page - 1}&size=${size}&dev=true`); + return done(); + }); + }); + + it("should return a bad request error for invalid size parameter", function (done) { + chai + .request(app) + .get(`/progresses?type=task&dev=true&page=0&size=104`) + .end((_err, res) => { + expect(res).to.have.status(400); + expect(res.body).to.be.an("object"); + expect(res.body.message).to.equal("size must be in the range 1-100"); + return done(); + }); + }); + + it("should return an empty array of progresses data on a page with no data", function (done) { + const size = 10; + const page = 100; + + chai + .request(app) + .get(`/progresses?type=task&dev=true&page=${page}&size=${size}`) + .end((err, res) => { + if (err) return done(err); + expect(res).to.have.status(200); + expect(res.body).to.be.an("object"); + expect(res.body.message).to.equal("Progress document retrieved successfully."); + // eslint-disable-next-line no-unused-expressions + expect(res.body.data).to.be.an("array").that.is.empty; + expect(res.body.links).to.have.keys(["next", "prev"]); + // eslint-disable-next-line no-unused-expressions + expect(res.body.links.next).to.be.null; + expect(res.body.links.prev).to.equal(`/progresses?type=task&page=${page - 1}&size=${size}&dev=true`); + return done(); + }); + }); + + it("Should return 500 Internal Server Error if there is an exception", function (done) { + sinon.stub(progressesModel, "getPaginatedProgressDocument").throws(new Error("Database error")); + + chai + .request(app) + .get(`/progresses?type=task&dev=true&page=0&size=1`) + .end((err, res) => { + if (err) return done(err); + + if (err) { + return done(err); + } + + expect(res).to.have.status(500); + expect(res.body).to.deep.equal({ + message: INTERNAL_SERVER_ERROR_MESSAGE, + }); + return done(); + }); + }); + }); }); diff --git a/test/integration/progressesUsers.test.js b/test/integration/progressesUsers.test.js index f2458a576..9f7ee492b 100644 --- a/test/integration/progressesUsers.test.js +++ b/test/integration/progressesUsers.test.js @@ -4,7 +4,7 @@ const sinon = require("sinon"); const firestore = require("../../utils/firestore"); const app = require("../../server"); const authService = require("../../services/authService"); - +const progressesModel = require("../../models/progresses"); const addUser = require("../utils/addUser"); const cleanDb = require("../utils/cleanDb"); const { @@ -14,7 +14,7 @@ const { } = require("../fixtures/progress/progresses"); const userData = require("../fixtures/user/user")(); - +const { INTERNAL_SERVER_ERROR_MESSAGE } = require("../../constants/progresses"); const cookieName = config.get("userToken.cookieName"); const { expect } = chai; @@ -233,7 +233,7 @@ describe("Test Progress Updates API for Users", function () { .end((err, res) => { if (err) return done(err); expect(res).to.have.status(200); - expect(res.body).to.have.keys(["message", "data", "count"]); + expect(res.body).to.have.keys(["message", "data", "count", "links"]); expect(res.body.data).to.be.an("array"); expect(res.body.message).to.be.equal("Progress document retrieved successfully."); res.body.data.forEach((progress) => { @@ -260,7 +260,7 @@ describe("Test Progress Updates API for Users", function () { .end((err, res) => { if (err) return done(err); expect(res).to.have.status(200); - expect(res.body).to.have.keys(["message", "data", "count"]); + expect(res.body).to.have.keys(["message", "data", "count", "links"]); expect(res.body.data).to.be.an("array"); expect(res.body.message).to.be.equal("Progress document retrieved successfully."); res.body.data.forEach((progress) => { @@ -499,4 +499,124 @@ describe("Test Progress Updates API for Users", function () { }); }); }); + + describe("GET /progresses (getPaginatedProgressDocument)", function () { + beforeEach(async function () { + const userId1 = await addUser(userData[0]); + const userId2 = await addUser(userData[1]); + const progressData1 = stubbedModelProgressData(userId1, 1683957764140, 1683936000000); + const progressData2 = stubbedModelProgressData(userId2, 1683957764140, 1683936000000); + await firestore.collection("progresses").doc("progressDoc1").set(progressData1); + await firestore.collection("progresses").doc("progressDoc2").set(progressData2); + }); + + afterEach(async function () { + await cleanDb(); + }); + + it("should return paginated results when dev=true is passed", function (done) { + chai + .request(app) + .get(`/progresses?type=user&dev=true&page=0&size=1`) + .end((err, res) => { + if (err) return done(err); + expect(res).to.have.status(200); + expect(res.body).to.have.keys(["message", "data", "count", "links"]); + expect(res.body.links).to.have.keys(["next", "prev"]); + expect(res.body.data).to.be.an("array"); + expect(res.body.message).to.be.equal("Progress document retrieved successfully."); + expect(res.body.count).to.be.equal(1); + res.body.data.forEach((progress) => { + expect(progress).to.have.keys([ + "id", + "type", + "completed", + "planned", + "blockers", + "userData", + "userId", + "createdAt", + "date", + ]); + }); + + return done(); + }); + }); + + it("should return null for next link on the last page", function (done) { + const size = 1; + const page = 1; + + chai + .request(app) + .get(`/progresses?type=user&dev=true&page=${page}&size=${size}`) + .end((err, res) => { + if (err) return done(err); + expect(res).to.have.status(200); + expect(res.body).to.have.keys(["message", "data", "count", "links"]); + expect(res.body.links).to.have.keys(["next", "prev"]); + expect(res.body.data).to.be.an("array"); + expect(res.body.message).to.be.equal("Progress document retrieved successfully."); + expect(res.body.links.next).to.be.equal(null); + expect(res.body.links.prev).to.equal(`/progresses?type=user&page=${page - 1}&size=${size}&dev=true`); + return done(); + }); + }); + + it("should return a bad request error for invalid size parameter", function (done) { + chai + .request(app) + .get(`/progresses?type=user&dev=true&page=0&size=104`) + .end((_err, res) => { + expect(res).to.have.status(400); + expect(res.body).to.be.an("object"); + expect(res.body.message).to.equal("size must be in the range 1-100"); + return done(); + }); + }); + + it("should return an empty array of progresses data on a page with no data", function (done) { + const size = 10; + const page = 100; + + chai + .request(app) + .get(`/progresses?type=user&dev=true&page=${page}&size=${size}`) + .end((err, res) => { + if (err) return done(err); + expect(res).to.have.status(200); + expect(res.body).to.be.an("object"); + expect(res.body.message).to.equal("Progress document retrieved successfully."); + // eslint-disable-next-line no-unused-expressions + expect(res.body.data).to.be.an("array").that.is.empty; + expect(res.body.links).to.have.keys(["next", "prev"]); + // eslint-disable-next-line no-unused-expressions + expect(res.body.links.next).to.be.null; + expect(res.body.links.prev).to.equal(`/progresses?type=user&page=${page - 1}&size=${size}&dev=true`); + return done(); + }); + }); + + it("Should return 500 Internal Server Error if there is an exception", function (done) { + sinon.stub(progressesModel, "getPaginatedProgressDocument").throws(new Error("Database error")); + + chai + .request(app) + .get(`/progresses?type=user&dev=true&page=0&size=1`) + .end((err, res) => { + if (err) return done(err); + + if (err) { + return done(err); + } + + expect(res).to.have.status(500); + expect(res.body).to.deep.equal({ + message: INTERNAL_SERVER_ERROR_MESSAGE, + }); + return done(); + }); + }); + }); }); diff --git a/test/integration/requests.test.ts b/test/integration/requests.test.ts index faa96ef82..0d6fe74c2 100644 --- a/test/integration/requests.test.ts +++ b/test/integration/requests.test.ts @@ -273,6 +273,17 @@ describe("/requests OOO", function () { }); }); + it("should return the request by Id query", function (done) { + chai + .request(app) + .get(`/requests?id=${oooRequestId}`) + .end(function (err, res) { + expect(res).to.have.status(200); + expect(res.body.data.id === oooRequestId); + done(); + }); + }); + it("should return all requests by specific user", function (done) { chai .request(app) diff --git a/test/unit/middlewares/requests.test.ts b/test/unit/middlewares/requests.test.ts index c57fe4baf..f1eeefffd 100644 --- a/test/unit/middlewares/requests.test.ts +++ b/test/unit/middlewares/requests.test.ts @@ -6,6 +6,7 @@ import { createRequestsMiddleware, getRequestsMiddleware, updateRequestsMiddleware, + updateRequestValidator, } from "../../../middlewares/validators/requests"; import { validOooStatusRequests, @@ -14,6 +15,9 @@ import { invalidOooStatusUpdate, } from "../../fixtures/oooRequest/oooRequest"; import { OooRequestCreateRequest, OooRequestResponse } from "../../../types/oooRequest"; +import { REQUEST_TYPE } from "../../../constants/requests"; +import { convertDaysToMilliseconds } from "../../../utils/time"; +import { updateOnboardingExtensionRequestValidator } from "../../../middlewares/validators/onboardingExtensionRequest"; describe("Create Request Validators", function () { let req: any; @@ -110,3 +114,77 @@ describe("Create Request Validators", function () { }); }); }); + +describe("updateRequestValidator", () => { + let req, res, next: sinon.SinonSpy; + + beforeEach(() => { + next = sinon.spy(); + res = { boom: { badRequest: sinon.spy() } } + }); + + afterEach(() => { + sinon.restore(); + }) + + it("should call next for correct type", async () => { + req = { body: { type: REQUEST_TYPE.ONBOARDING, newEndsOn: Date.now() + convertDaysToMilliseconds(2) } }; + await updateRequestValidator(req, res, next); + expect(next.calledOnce).to.be.true; + }) + + it("should not call next for incorrect type", async () => { + req = { body: { type: REQUEST_TYPE.OOO } }; + await updateRequestValidator(req, res, next); + expect(next.notCalled).to.be.true; + }) +}) + +describe("updateOnboardingExtensionRequestValidator", () => { + let req, res, next: sinon.SinonSpy; + + beforeEach(() => { + next = sinon.spy(); + res = { boom: { badRequest: sinon.spy() } }; + }); + + afterEach(() => { + sinon.restore(); + }) + + it("should not call next for incorrect type ", async () => { + req = { + body: { + type: REQUEST_TYPE.OOO, + newEndsOn: Date.now() + convertDaysToMilliseconds(3) + } + } + + await updateOnboardingExtensionRequestValidator(req, res, next); + expect(next.notCalled).to.be.true; + }); + + it("should not call next for incorrect newEndsOn ", async () => { + req = { + body: { + type: REQUEST_TYPE.ONBOARDING, + newEndsOn: Date.now() - convertDaysToMilliseconds(1) + } + } + + await updateOnboardingExtensionRequestValidator(req, res, next); + expect(next.notCalled).to.be.true; + }); + + it("should call next for successful validaton", async () => { + req = { + body: { + type: REQUEST_TYPE.ONBOARDING, + newEndsOn: Date.now() + convertDaysToMilliseconds(3) + } + } + + await updateOnboardingExtensionRequestValidator(req, res, next); + expect(next.calledOnce).to.be.true; + }); +}) \ No newline at end of file diff --git a/test/unit/models/progresses.test.js b/test/unit/models/progresses.test.js index 28a57207b..2ee7f2ab8 100644 --- a/test/unit/models/progresses.test.js +++ b/test/unit/models/progresses.test.js @@ -1,47 +1,144 @@ const chai = require("chai"); const sinon = require("sinon"); const { expect } = chai; -const { addUserDetailsToProgressDocs } = require("../../../models/progresses"); const cleanDb = require("../../utils/cleanDb"); -const users = require("../../../models/users"); +const { addUserDetailsToProgressDocs, getPaginatedProgressDocument } = require("../../../models/progresses"); +const fireStore = require("../../../utils/firestore"); +const progressesCollection = fireStore.collection("progresses"); +const { stubbedModelTaskProgressData, stubbedModelProgressData } = require("../../fixtures/progress/progresses"); +const addUser = require("../../utils/addUser"); const userDataArray = require("../../fixtures/user/user")(); const { removeSensitiveInfo } = require("../../../services/dataAccessLayer"); -describe("getProgressDocument", function () { - afterEach(function () { - cleanDb(); +const { + PROGRESSES_RESPONSE_MESSAGES: { PROGRESS_DOCUMENT_NOT_FOUND }, +} = require("../../../constants/progresses"); +const users = require("../../../models/users"); +describe("progressModel", function () { + afterEach(async function () { + await cleanDb(); sinon.restore(); }); - it("should add userData to progress documents correctly", async function () { - const userData = userDataArray[0]; - const userData2 = userDataArray[1]; - const { userId } = await users.addOrUpdate(userData); - const { userId: userId2 } = await users.addOrUpdate(userData2); - const updatedUserData = { ...userData, id: userId }; - const updatedUserData2 = { ...userData2, id: userId2 }; - removeSensitiveInfo(updatedUserData); - removeSensitiveInfo(updatedUserData2); - const mockProgressDocs = [ - { userId: userId, taskId: 101 }, - { userId: userId2, taskId: 102 }, - ]; - - const result = await addUserDetailsToProgressDocs(mockProgressDocs); - - expect(result).to.deep.equal([ - { userId, taskId: 101, userData: updatedUserData }, - { userId: userId2, taskId: 102, userData: updatedUserData2 }, - ]); + describe("getPaginatedProgressDocument", function () { + let userId; + let userId2; + let userId3; + const taskId = "taskId1"; + const taskId2 = "taskId2"; + + beforeEach(async function () { + userId = await addUser(userDataArray[0]); + userId2 = await addUser(userDataArray[1]); + userId3 = await addUser(userDataArray[2]); + const progressData = stubbedModelTaskProgressData(userId, taskId, 1683072000000, 1682985600000); + const progressData2 = stubbedModelTaskProgressData(userId2, taskId2, 1683072000000, 1682985600000); + const progressData3 = stubbedModelProgressData(userId, 1683072000000, 1682985600000); + const progressData4 = stubbedModelProgressData(userId2, 1683072000000, 1682985600000); + await progressesCollection.add(progressData); + await progressesCollection.add(progressData2); + await progressesCollection.add(progressData3); + await progressesCollection.add(progressData4); + }); + + afterEach(async function () { + await cleanDb(); + }); + + it("should return progress documents for type=task", async function () { + const size = 1; + const { progressDocs, totalProgressCount } = await getPaginatedProgressDocument({ type: "task", size }); + + expect(progressDocs).to.have.lengthOf(size); + expect(totalProgressCount).to.equal(2); + expect(progressDocs[0].type).to.equal("task"); + }); + + it("should return paginated progress documents and total count", async function () { + const size = 1; + const { progressDocs, totalProgressCount } = await getPaginatedProgressDocument({ type: "user", size }); + + expect(progressDocs).to.have.lengthOf(size); + expect(totalProgressCount).to.equal(2); + }); + + it("should throw error when no progress documents match the query", async function () { + try { + await getPaginatedProgressDocument({ + size: 1, + userId: userId3, + }); + throw new Error("Test failed: expected a NotFound error to be thrown."); + } catch (err) { + expect(err.message).to.equal(PROGRESS_DOCUMENT_NOT_FOUND); + } + }); + + it("should paginate results correctly when a specific page is requested", async function () { + const page = 1; + const size = 1; + const { progressDocs, totalProgressCount } = await getPaginatedProgressDocument({ type: "user", page, size }); + + expect(progressDocs).to.have.lengthOf(size); + expect(totalProgressCount).to.equal(2); + }); + + it("should use default page value when page is not provided", async function () { + const size = 2; + const { progressDocs, totalProgressCount } = await getPaginatedProgressDocument({ type: "user", size }); + + expect(progressDocs).to.have.lengthOf(size); + expect(totalProgressCount).to.equal(2); + }); + + it("should filter progress documents by userId", async function () { + const size = 1; + const { progressDocs, totalProgressCount } = await getPaginatedProgressDocument({ + size, + userId, + }); + + expect(totalProgressCount).to.equal(1); + expect(progressDocs).to.have.lengthOf(size); + }); }); - it("should handle errors and set userData as null", async function () { - const userData = userDataArray[0]; - await users.addOrUpdate(userData); + describe("addUserDetailsToProgressDocs", function () { + afterEach(function () { + cleanDb(); + sinon.restore(); + }); + + it("should add userData to progress documents correctly", async function () { + const userData = userDataArray[0]; + const userData2 = userDataArray[1]; + const { userId } = await users.addOrUpdate(userData); + const { userId: userId2 } = await users.addOrUpdate(userData2); + const updatedUserData = { ...userData, id: userId }; + const updatedUserData2 = { ...userData2, id: userId2 }; + removeSensitiveInfo(updatedUserData); + removeSensitiveInfo(updatedUserData2); + const mockProgressDocs = [ + { userId: userId, taskId: 101 }, + { userId: userId2, taskId: 102 }, + ]; + + const result = await addUserDetailsToProgressDocs(mockProgressDocs); + + expect(result).to.deep.equal([ + { userId, taskId: 101, userData: updatedUserData }, + { userId: userId2, taskId: 102, userData: updatedUserData2 }, + ]); + }); + + it("should handle errors and set userData as null", async function () { + const userData = userDataArray[0]; + await addUser(userData); - const mockProgressDocs = [{ userId: "userIdNotExists", taskId: 101 }]; + const mockProgressDocs = [{ userId: "userIdNotExists", taskId: 101 }]; - const result = await addUserDetailsToProgressDocs(mockProgressDocs); + const result = await addUserDetailsToProgressDocs(mockProgressDocs); - expect(result).to.deep.equal([{ userId: "userIdNotExists", taskId: 101, userData: null }]); + expect(result).to.deep.equal([{ userId: "userIdNotExists", taskId: 101, userData: null }]); + }); }); }); diff --git a/test/unit/services/onboardingExtension.test.ts b/test/unit/services/onboardingExtension.test.ts new file mode 100644 index 000000000..35f85e64a --- /dev/null +++ b/test/unit/services/onboardingExtension.test.ts @@ -0,0 +1,200 @@ +import { + INVALID_REQUEST_DEADLINE, + INVALID_REQUEST_TYPE, + PENDING_REQUEST_UPDATED, + REQUEST_DOES_NOT_EXIST, + REQUEST_STATE, + REQUEST_TYPE, + UNAUTHORIZED_TO_UPDATE_REQUEST +} from "../../../constants/requests" +import { + updateOnboardingExtensionRequest, + validateOnboardingExtensionUpdateRequest +} from "../../../services/onboardingExtension" +import { expect } from "chai" +import firestore from "../../../utils/firestore"; +import { convertDaysToMilliseconds } from "../../../utils/time"; +import cleanDb from "../../utils/cleanDb"; +const requestModel = firestore.collection("requests"); +import * as logService from "../../../services/logService"; +import sinon from "sinon"; + +describe("Test Onboarding Extension Service", () => { + let validExtensionRequest; + let validExtensionRequestDoc; + const userId = "11111"; + const errorMessage = "Unexpected error occured"; + + beforeEach(async ()=>{ + validExtensionRequest = await requestModel.add({ + type: REQUEST_TYPE.ONBOARDING, + oldEndsOn: Date.now() - convertDaysToMilliseconds(2), + state: REQUEST_STATE.PENDING, + userId, + }) + validExtensionRequestDoc = await requestModel.doc(validExtensionRequest.id).get(); + }) + + afterEach(async ()=>{ + await cleanDb(); + sinon.restore(); + }) + + describe("validateOnboardingExtensionUpdateRequest", () => { + let invalidTypeRequest; + let invalidTypeRequestDoc; + let invalidStateRequest; + let invalidStateRequestDoc; + let invalidDeadlineRequest; + let invalidDeadlineRequestDoc; + + beforeEach(async ()=>{ + invalidTypeRequest = await requestModel.add({ + type: REQUEST_TYPE.OOO, + userId, + }); + invalidTypeRequestDoc = await requestModel.doc(invalidTypeRequest.id).get(); + invalidStateRequest = await requestModel.add({ + state: REQUEST_STATE.APPROVED, + userId, + type: REQUEST_TYPE.ONBOARDING, + }) + invalidStateRequestDoc = await requestModel.doc(invalidStateRequest.id).get(); + invalidDeadlineRequest = await requestModel.add({ + type: REQUEST_TYPE.ONBOARDING, + state: REQUEST_STATE.PENDING, + oldEndsOn: Date.now() + convertDaysToMilliseconds(2), + userId, + }) + invalidDeadlineRequestDoc = await requestModel.doc(invalidDeadlineRequest.id).get(); + }) + + afterEach(async ()=>{ + await cleanDb(); + sinon.restore(); + }) + + it("should return undefined when all validation checks passes", async () => { + const response = await validateOnboardingExtensionUpdateRequest( + validExtensionRequestDoc, + validExtensionRequest.id, + true, + userId, + Date.now() + ) + expect(response).to.be.undefined; + }); + + it("should return REQUEST_DOES_NOT_EXIST error", async () => { + const response = await validateOnboardingExtensionUpdateRequest( + false, + "23345", + false, + "2341", + Date.now(), + ); + expect(response).to.not.be.undefined; + expect(response.error).to.equal(REQUEST_DOES_NOT_EXIST) + }); + + it("shoud return UNAUTHORIZED_TO_UPDATE_REQUEST error when super user and request owner are not updating request", async () => { + const response = await validateOnboardingExtensionUpdateRequest( + validExtensionRequestDoc, + validExtensionRequest.id, + false, + "2333", + Date.now() + ); + expect(response).to.be.not.undefined; + expect(response.error).to.equal(UNAUTHORIZED_TO_UPDATE_REQUEST); + }); + + it("should return INVALID_REQUEST_TYPE error", async () => { + const response = await validateOnboardingExtensionUpdateRequest( + invalidTypeRequestDoc, + invalidTypeRequest.id, + true, + userId, + Date.now() + ) + expect(response).to.be.not.undefined; + expect(response.error).to.equal(INVALID_REQUEST_TYPE); + }); + + it("should return PENDING_REQUEST_UPDATED error", async () => { + const response = await validateOnboardingExtensionUpdateRequest( + invalidStateRequestDoc, + invalidStateRequest.id, + true, + userId, + Date.now() + ) + expect(response).to.be.not.undefined; + expect(response.error).to.equal(PENDING_REQUEST_UPDATED); + }); + + it("should return INVALID_REQUEST_DEADLINE error", async () => { + const response = await validateOnboardingExtensionUpdateRequest( + invalidDeadlineRequestDoc, + invalidDeadlineRequest.id, + true, + userId, + Date.now(), + ) + expect(response).to.be.not.undefined; + expect(response.error).to.equal(INVALID_REQUEST_DEADLINE); + }); + + it("should throw error", async () => { + sinon.stub(logService, "addLog").throws(new Error(errorMessage)); + try{ + await validateOnboardingExtensionUpdateRequest( + validExtensionRequestDoc, + validExtensionRequest.id, + false, + "1111", + Date.now(), + ) + }catch(error){ + expect(error.message).to.equal(errorMessage); + } + }) + }); + + describe("updateOnboardingExtensionRequest", () => { + it("should update request", async () => { + const newDate = Date.now(); + const response = await updateOnboardingExtensionRequest( + validExtensionRequest.id, + { + reason:"test-reason", + newEndsOn: newDate, + type: REQUEST_TYPE.ONBOARDING, + }, + userId, + ); + expect(response).to.be.not.undefined; + expect(response.lastModifiedBy).to.equal(userId); + expect(response.newEndsOn).to.equal(newDate); + expect(response.reason).to.equal("test-reason"); + expect(new Date(response.updatedAt).toDateString()).to.equal(new Date(newDate).toDateString()); + }); + + it("should throw error", async () => { + sinon.stub(logService, "addLog").throws(new Error(errorMessage)); + try{ + await updateOnboardingExtensionRequest( + validExtensionRequest.id, + { + reason:"test-reason", + newEndsOn: Date.now(), + type: REQUEST_TYPE.ONBOARDING, + }, + userId, + ); + }catch(error){ + expect(error.message).to.equal(errorMessage); + } + }); + }) +}) \ No newline at end of file diff --git a/test/unit/utils/progresses.test.js b/test/unit/utils/progresses.test.js new file mode 100644 index 000000000..a5c7277cc --- /dev/null +++ b/test/unit/utils/progresses.test.js @@ -0,0 +1,156 @@ +const chai = require("chai"); +const { expect } = chai; +const sinon = require("sinon"); +const cleanDb = require("../../utils/cleanDb"); +const { buildQueryToFetchPaginatedDocs, getPaginatedProgressDocs } = require("../../../utils/progresses"); +const fireStore = require("../../../utils/firestore"); +const progressesCollection = fireStore.collection("progresses"); +const { stubbedModelTaskProgressData, stubbedModelProgressData } = require("../../fixtures/progress/progresses"); +const { + PROGRESSES_RESPONSE_MESSAGES: { PROGRESS_DOCUMENT_NOT_FOUND }, +} = require("../../../constants/progresses"); + +describe("Utils | Progresses", function () { + afterEach(async function () { + await cleanDb(); + sinon.restore(); + }); + + describe("buildQueryToFetchPaginatedDocs", function () { + beforeEach(async function () { + const progressData = stubbedModelTaskProgressData("userId", "task1", 1683072000000, 1682985600000); + const progressData2 = stubbedModelTaskProgressData("userId2", "task2", 1683072000000, 1682985600000); + const progressData3 = stubbedModelProgressData("userId", 1683072000000, 1682985600000); + const progressData4 = stubbedModelProgressData("userId2", 1683072000000, 1682985600000); + await progressesCollection.add(progressData); + await progressesCollection.add(progressData2); + await progressesCollection.add(progressData3); + await progressesCollection.add(progressData4); + }); + + afterEach(async function () { + await cleanDb(); + }); + + it("should build a query with type filter", async function () { + const queryParams = { + type: "task", + size: 100, + page: 0, + }; + + const { totalProgressCount } = await buildQueryToFetchPaginatedDocs(queryParams); + expect(totalProgressCount).to.equal(2); + }); + + it("should build a query with userId filter", async function () { + const queryParams = { + userId: "userId", + size: 100, + page: 0, + }; + + const { baseQuery, totalProgressCount } = await buildQueryToFetchPaginatedDocs(queryParams); + const results = await baseQuery.get(); + const docs = results.docs.map((doc) => doc.data()); + expect(docs[0].type).to.equal("user"); + expect(totalProgressCount).to.equal(1); + }); + + it("should build a query with taskId filter", async function () { + const queryParams = { + taskId: "task1", + size: 100, + page: 0, + }; + + const { totalProgressCount } = await buildQueryToFetchPaginatedDocs(queryParams); + expect(totalProgressCount).to.equal(1); + }); + + it("should apply default sorting when orderBy is not provided", async function () { + const queryParams = { + type: "task", + size: 100, + page: 0, + }; + + const { baseQuery } = await buildQueryToFetchPaginatedDocs(queryParams); + const results = await baseQuery.get(); + const docs = results.docs.map((doc) => doc.data()); + + expect(docs[0].type).to.equal("task"); + }); + + it("should handle pagination correctly", async function () { + const queryParams = { + type: "task", + size: 1, + page: 1, + }; + + const { baseQuery } = await buildQueryToFetchPaginatedDocs(queryParams); + const results = await baseQuery.get(); + expect(results.size).to.equal(1); + }); + + it("should return empty results for a large page number", async function () { + const queryParams = { + type: "task", + size: 100, + page: 10, + }; + + const { baseQuery } = await buildQueryToFetchPaginatedDocs(queryParams); + const results = await baseQuery.get(); + expect(results.size).to.equal(0); + }); + }); + + describe("getPaginatedProgressDocs", function () { + beforeEach(async function () { + const progressData = stubbedModelTaskProgressData("userId", "task1", 1683072000000, 1682985600000); + const progressData2 = stubbedModelTaskProgressData("userId2", "task2", 1683072000000, 1682985600000); + const progressData3 = stubbedModelProgressData("userId", 1683072000000, 1682985600000); + const progressData4 = stubbedModelProgressData("userId2", 1683072000000, 1682985600000); + await progressesCollection.add(progressData); + await progressesCollection.add(progressData2); + await progressesCollection.add(progressData3); + await progressesCollection.add(progressData4); + }); + + afterEach(async function () { + await cleanDb(); + sinon.restore(); + }); + + it("should throw a NotFound error if no documents are found and no page is specified", async function () { + const query = progressesCollection.where("userId", "==", "nonExistentUser"); + + try { + await getPaginatedProgressDocs(query); + throw new Error("Test failed: expected a NotFound error to be thrown."); + } catch (err) { + expect(err.message).to.equal(PROGRESS_DOCUMENT_NOT_FOUND); + } + }); + + it("should return an empty array if no documents are found and a page is specified", async function () { + const query = progressesCollection.where("userId", "==", "nonExistentUser"); + const results = await getPaginatedProgressDocs(query, 1); + + // eslint-disable-next-line no-unused-expressions + expect(results).to.be.an("array").that.is.empty; + }); + + it("should handle queries returning multiple documents", async function () { + const query = progressesCollection.where("type", "==", "task"); + const results = await getPaginatedProgressDocs(query); + + expect(results).to.be.an("array").that.has.lengthOf(2); + results.forEach((doc) => { + expect(doc).to.have.property("id").that.is.a("string"); + }); + }); + }); +}); diff --git a/types/onboardingExtension.d.ts b/types/onboardingExtension.d.ts index 4602e6b5d..f2474a434 100644 --- a/types/onboardingExtension.d.ts +++ b/types/onboardingExtension.d.ts @@ -56,3 +56,16 @@ export type UpdateOnboardingExtensionStateRequest = Request & { query: OnboardingExtensionRequestQuery; params: RequestParams; }; + +export type UpdateOnboardingExtensionRequestBody = { + reason?: string + newEndsOn: number + type: REQUEST_TYPE.ONBOARDING +} + +export type UpdateOnboardingExtensionRequest = Request & { + body: UpdateOnboardingExtensionRequestBody; + userData: userData; + query: OnboardingExtensionRequestQuery; + params: RequestParams; +} diff --git a/utils/progresses.js b/utils/progresses.js index 6459a4beb..629f90a1f 100644 --- a/utils/progresses.js +++ b/utils/progresses.js @@ -8,6 +8,8 @@ const { PROGRESSES_RESPONSE_MESSAGES: { PROGRESS_DOCUMENT_NOT_FOUND }, MILLISECONDS_IN_DAY, PROGRESS_VALID_SORT_FIELDS, + PROGRESSES_PAGE_SIZE, + PROGRESSES_SIZE, } = require("../constants/progresses"); const { convertTimestampToUTCStartOrEndOfDay } = require("./time"); const progressesCollection = fireStore.collection("progresses"); @@ -120,6 +122,42 @@ const buildQueryToFetchDocs = (queryParams) => { } }; +/** + * Builds a Firestore query to retrieve a paginated list of progress documents within a date range, + * optionally filtered by user ID, task ID, type, and sorted by a specific field. + * @param {Object} queryParams - Query parameters including userId, taskId, type, orderBy, size, and page. + * @returns {Query} A Firestore query object for filtered and paginated progress documents. + */ + +const buildQueryToFetchPaginatedDocs = async (queryParams) => { + const { type, userId, taskId, orderBy, size = PROGRESSES_SIZE, page = PROGRESSES_PAGE_SIZE } = queryParams; + const orderByField = PROGRESS_VALID_SORT_FIELDS[0]; + const isAscOrDsc = orderBy && PROGRESS_VALID_SORT_FIELDS[0] === orderBy ? "asc" : "desc"; + const limit = parseInt(size, 10); + const offset = parseInt(page, 10) * limit; + + let baseQuery; + if (type) { + baseQuery = progressesCollection.where("type", "==", type).orderBy(orderByField, isAscOrDsc); + } else if (userId) { + baseQuery = progressesCollection + .where("type", "==", "user") + .where("userId", "==", userId) + .orderBy(orderByField, isAscOrDsc); + } else { + baseQuery = progressesCollection + .where("type", "==", "task") + .where("taskId", "==", taskId) + .orderBy(orderByField, isAscOrDsc); + } + + const totalProgress = await baseQuery.get(); + const totalProgressCount = totalProgress.size; + + baseQuery = baseQuery.limit(limit).offset(offset); + return { baseQuery, totalProgressCount }; +}; + /** * Retrieves progress documents from Firestore based on the given query. * @param {Query} query - A Firestore query object for fetching progress documents. @@ -137,6 +175,31 @@ const getProgressDocs = async (query) => { }); return docsData; }; +/** + * Retrieves progress documents from Firestore based on the given query and page number. + * + * @param {Query} query - A Firestore query object for fetching progress documents. + * @param {number} [pageNumber] - The current page number (optional). If not provided, it will check for documents without pagination. + * @returns {Array.} An array of objects representing the retrieved progress documents. + * Each object contains the document ID (`id`) and its associated data. + * + * @throws {NotFound} If no progress documents are found and no page number is specified. + */ +const getPaginatedProgressDocs = async (query, page) => { + const progressesDocs = await query.get(); + if (!page && !progressesDocs.size) { + throw new NotFound(PROGRESS_DOCUMENT_NOT_FOUND); + } + if (!progressesDocs.size) { + return []; + } + const docsData = []; + progressesDocs.forEach((doc) => { + docsData.push({ id: doc.id, ...doc.data() }); + }); + return docsData; +}; + /** * Builds a Firestore query for retrieving progress documents within a date range and optionally filtered by user ID or task ID. * @param {Object} queryParams - An object containing the query parameters. @@ -231,8 +294,10 @@ module.exports = { assertUserOrTaskExists, buildQueryToFetchDocs, getProgressDocs, + getPaginatedProgressDocs, buildRangeProgressQuery, getProgressRecords, buildQueryToSearchProgressByDay, buildProgressQueryForMissedUpdates, + buildQueryToFetchPaginatedDocs, };