diff --git a/constants/requests.ts b/constants/requests.ts index f0fdb9907..1173a883e 100644 --- a/constants/requests.ts +++ b/constants/requests.ts @@ -15,6 +15,7 @@ export const REQUEST_TYPE = { EXTENSION: "EXTENSION", TASK: "TASK", ALL: "ALL", + ONBOARDING: "ONBOARDING", }; export const REQUEST_LOG_TYPE = { @@ -53,3 +54,6 @@ export const TASK_REQUEST_MESSAGES = { ERROR_CREATING_TASK_REQUEST: "Error while creating task request", TASK_REQUEST_UPDATED_SUCCESS: "Task request updated successfully", }; + +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 diff --git a/controllers/onboardingExtension.ts b/controllers/onboardingExtension.ts new file mode 100644 index 000000000..c03b45df6 --- /dev/null +++ b/controllers/onboardingExtension.ts @@ -0,0 +1,124 @@ +import { + ERROR_WHILE_CREATING_REQUEST, + LOG_ACTION, + ONBOARDING_REQUEST_CREATED_SUCCESSFULLY, + REQUEST_ALREADY_PENDING, + REQUEST_LOG_TYPE, + REQUEST_STATE, + REQUEST_TYPE, + UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST, +} from "../constants/requests"; +import { userState } from "../constants/userStatus"; +import { addLog } from "../services/logService"; +import { createRequest, getRequestByKeyValues } from "../models/requests"; +import { fetchUser } from "../models/users"; +import { getUserStatus } from "../models/userStatus"; +import { User } from "../typeDefinitions/users"; +import { + CreateOnboardingExtensionBody, + OnboardingExtension, + OnboardingExtensionCreateRequest, + OnboardingExtensionResponse +} from "../types/onboardingExtension"; +import { convertDateStringToMilliseconds, getNewDeadline } from "../utils/requests"; +import { convertDaysToMilliseconds } from "../utils/time"; + +/** +* Controller to handle the creation of onboarding extension requests. +* +* This function processes the request to create an extension for the onboarding period, +* validates the user status, checks existing requests, calculates new deadlines, +* and stores the new request in the database with logging. +* +* @param {OnboardingExtensionCreateRequest} req - The Express request object containing the body with extension details. +* @param {OnboardingExtensionResponse} res - The Express response object used to send back the response. +* @returns {Promise} Resolves to a response with the status and data or an error message. +*/ +export const createOnboardingExtensionRequestController = async (req: OnboardingExtensionCreateRequest, res: OnboardingExtensionResponse): Promise => { + try { + + const data = req.body as CreateOnboardingExtensionBody; + const {user, userExists} = await fetchUser({discordId: data.userId}); + + if(!userExists) { + return res.boom.notFound("User not found"); + } + + const { id: userId, discordJoinedAt, username} = user as User; + const { data: userStatus } = await getUserStatus(userId); + + if(!userStatus || userStatus.currentStatus.state != userState.ONBOARDING){ + return res.boom.forbidden(UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST); + } + + const latestExtensionRequest: OnboardingExtension = await getRequestByKeyValues({ + userId: userId, + type: REQUEST_TYPE.ONBOARDING + }); + + if(latestExtensionRequest && latestExtensionRequest.state === REQUEST_STATE.PENDING){ + return res.boom.conflict(REQUEST_ALREADY_PENDING); + } + + const millisecondsInThirtyOneDays = convertDaysToMilliseconds(31); + const numberOfDaysInMillisecond = convertDaysToMilliseconds(data.numberOfDays); + const { isDate, milliseconds: discordJoinedDateInMillisecond } = convertDateStringToMilliseconds(discordJoinedAt); + + if(!isDate){ + logger.error(ERROR_WHILE_CREATING_REQUEST, "Invalid date"); + return res.boom.badImplementation(ERROR_WHILE_CREATING_REQUEST); + } + + let requestNumber: number; + let oldEndsOn: number; + const currentDate = Date.now(); + + if(!latestExtensionRequest){ + requestNumber = 1; + oldEndsOn = discordJoinedDateInMillisecond + millisecondsInThirtyOneDays; + }else if(latestExtensionRequest.state === REQUEST_STATE.REJECTED) { + requestNumber = latestExtensionRequest.requestNumber + 1; + oldEndsOn = latestExtensionRequest.oldEndsOn; + }else{ + requestNumber = latestExtensionRequest.requestNumber + 1; + oldEndsOn = latestExtensionRequest.newEndsOn; + } + + const newEndsOn = getNewDeadline(currentDate, oldEndsOn, numberOfDaysInMillisecond); + + const onboardingExtension = await createRequest({ + type: REQUEST_TYPE.ONBOARDING, + state: REQUEST_STATE.PENDING, + userId: userId, + requestedBy: username, + oldEndsOn: oldEndsOn, + newEndsOn: newEndsOn, + reason: data.reason, + requestNumber: requestNumber, + }); + + const onboardingExtensionLog = { + type: REQUEST_LOG_TYPE.REQUEST_CREATED, + meta: { + requestId: onboardingExtension.id, + action: LOG_ACTION.CREATE, + userId: userId, + createdAt: Date.now(), + }, + body: onboardingExtension, + }; + + await addLog(onboardingExtensionLog.type, onboardingExtensionLog.meta, onboardingExtensionLog.body); + + return res.status(201).json({ + message: ONBOARDING_REQUEST_CREATED_SUCCESSFULLY, + data: { + id: onboardingExtension.id, + ...onboardingExtension, + } + }); + }catch (err) { + logger.error(ERROR_WHILE_CREATING_REQUEST, err); + return res.boom.badImplementation(ERROR_WHILE_CREATING_REQUEST); + } +}; \ No newline at end of file diff --git a/controllers/progresses.js b/controllers/progresses.js index cdf1f31bc..5e1b24870 100644 --- a/controllers/progresses.js +++ b/controllers/progresses.js @@ -217,7 +217,7 @@ const getProgressRangeData = async (req, res) => { const getProgressBydDateController = async (req, res) => { try { - const data = await getProgressByDate(req.params); + const data = await 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 d4bf87179..ab0333fd0 100644 --- a/controllers/requests.ts +++ b/controllers/requests.ts @@ -13,9 +13,11 @@ import { createTaskExtensionRequest, updateTaskExtensionRequest } from "./extens import { UpdateRequest } from "../types/requests"; import { TaskRequestRequest } from "../types/taskRequests"; import { createTaskRequestController } from "./taskRequestsv2"; +import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse } from "../types/onboardingExtension"; +import { createOnboardingExtensionRequestController } from "./onboardingExtension"; export const createRequestController = async ( - req: OooRequestCreateRequest | ExtensionRequestRequest | TaskRequestRequest, + req: OooRequestCreateRequest | ExtensionRequestRequest | TaskRequestRequest | OnboardingExtensionCreateRequest, res: CustomResponse ) => { const type = req.body.type; @@ -26,6 +28,8 @@ export const createRequestController = async ( return await createTaskExtensionRequest(req as ExtensionRequestRequest, res as ExtensionRequestResponse); case REQUEST_TYPE.TASK: return await createTaskRequestController(req as TaskRequestRequest, res as CustomResponse); + case REQUEST_TYPE.ONBOARDING: + return await createOnboardingExtensionRequestController(req as OnboardingExtensionCreateRequest, res as OnboardingExtensionResponse); default: return res.boom.badRequest("Invalid request type"); } diff --git a/middlewares/skipAuthenticateForOnboardingExtension.ts b/middlewares/skipAuthenticateForOnboardingExtension.ts new file mode 100644 index 000000000..0babcea74 --- /dev/null +++ b/middlewares/skipAuthenticateForOnboardingExtension.ts @@ -0,0 +1,30 @@ +import { NextFunction, Request, Response } from "express" +import { REQUEST_TYPE } from "../constants/requests"; +/** +* Middleware to selectively authenticate or verify Discord bot based on the request type. +* Specifically handles requests for onboarding extensions by skipping authentication. +* +* @param {Function} authenticate - The authentication middleware to apply for general requests. +* @param {Function} verifyDiscordBot - The middleware to verify requests from a Discord bot. +* @returns {Function} A middleware function that processes the request based on its type. +* +* @example +* app.use(skipAuthenticateForOnboardingExtensionRequest(authenticate, verifyDiscordBot)); +*/ +export const skipAuthenticateForOnboardingExtensionRequest = (authenticate, verifyDiscordBot) => { + return async (req: Request, res: Response, next: NextFunction) => { + const type = req.body.type; + const dev = req.query.dev; + + if(type === REQUEST_TYPE.ONBOARDING){ + if (dev != "true"){ + return res.status(501).json({ + message: "Feature not implemented" + }) + } + return await verifyDiscordBot(req, res, next); + } + + return await authenticate(req, res, next) + } +} \ No newline at end of file diff --git a/middlewares/validators/onboardingExtensionRequest.ts b/middlewares/validators/onboardingExtensionRequest.ts new file mode 100644 index 000000000..bf7afe127 --- /dev/null +++ b/middlewares/validators/onboardingExtensionRequest.ts @@ -0,0 +1,42 @@ +import joi from "joi"; +import { NextFunction } from "express"; +import { REQUEST_TYPE } from "../../constants/requests"; +import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse } from "../../types/onboardingExtension"; + +export const createOnboardingExtensionRequestValidator = async ( + req: OnboardingExtensionCreateRequest, + _res: OnboardingExtensionResponse, + _next: NextFunction +) => { + + const schema = joi + .object() + .strict() + .keys({ + numberOfDays: joi.number().required().positive().integer().min(1).messages({ + "number.base": "numberOfDays must be a number", + "any.required": "numberOfDays is required", + "number.positive": "numberOfDays must be positive", + "number.min": "numberOfDays must be greater than zero", + "number.integer": "numberOfDays must be a integer" + }), + reason: joi.string().required().messages({ + "string.empty": "reason cannot be empty", + "any.required": "reason is required", + }), + type: joi.string().valid(REQUEST_TYPE.ONBOARDING).required().messages({ + "string.empty": "type cannot be empty", + "any.required": "type is required", + }), + userId: joi.string().required().messages({ + "string.empty": "userId cannot be empty", + "any.required": "userId is required" + }) + }); + try{ + await schema.validateAsync(req.body, { abortEarly: false }); + }catch(error){ + logger.error(`Error while validating request payload`, error); + throw error; + } +}; diff --git a/middlewares/validators/progresses.js b/middlewares/validators/progresses.js index 27af88a39..a1a17ffcc 100644 --- a/middlewares/validators/progresses.js +++ b/middlewares/validators/progresses.js @@ -63,6 +63,9 @@ const validateGetProgressRecordsQuery = async (req, res, next) => { taskId: joi.string().optional().allow("").messages({ "string.base": "taskId must be a string", }), + dev: joi.boolean().optional().messages({ + "boolean.base": "dev must be a boolean value (true or false).", + }), orderBy: joi .string() .optional() @@ -92,6 +95,7 @@ const validateGetRangeProgressRecordsParams = async (req, res, next) => { taskId: joi.string().optional(), startDate: joi.date().iso().required(), endDate: joi.date().iso().min(joi.ref("startDate")).required(), + dev: joi.boolean().optional(), }) .xor("userId", "taskId") .messages({ @@ -121,6 +125,7 @@ const validateGetDayProgressParams = async (req, res, next) => { }), typeId: joi.string().required(), date: joi.date().iso().required(), + dev: joi.boolean().optional(), }); try { await schema.validateAsync(req.params, { abortEarly: false }); diff --git a/middlewares/validators/requests.ts b/middlewares/validators/requests.ts index 0f5c1909e..2cf3b6983 100644 --- a/middlewares/validators/requests.ts +++ b/middlewares/validators/requests.ts @@ -9,9 +9,11 @@ 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"; export const createRequestsMiddleware = async ( - req: OooRequestCreateRequest|ExtensionRequestRequest | TaskRequestRequest, + req: OooRequestCreateRequest|ExtensionRequestRequest | TaskRequestRequest | OnboardingExtensionCreateRequest, res: CustomResponse, next: NextFunction ) => { @@ -28,6 +30,9 @@ export const createRequestsMiddleware = async ( case REQUEST_TYPE.TASK: await createTaskRequestValidator(req as TaskRequestRequest, res as TaskRequestResponse, next); break; + case REQUEST_TYPE.ONBOARDING: + await createOnboardingExtensionRequestValidator(req as OnboardingExtensionCreateRequest, res as OnboardingExtensionResponse, next); + break; default: res.boom.badRequest(`Invalid request type: ${type}`); } @@ -36,7 +41,7 @@ export const createRequestsMiddleware = async ( } catch (error) { const errorMessages = error.details.map((detail:any) => detail.message); logger.error(`Error while validating request payload : ${errorMessages}`); - res.boom.badRequest(errorMessages); + return res.boom.badRequest(errorMessages); } }; diff --git a/models/progresses.js b/models/progresses.js index 8e8a622d9..e406d9f15 100644 --- a/models/progresses.js +++ b/models/progresses.js @@ -13,6 +13,7 @@ const { getProgressDateTimestamp, buildQueryToSearchProgressByDay, } = require("../utils/progresses"); +const { retrieveUsers } = require("../services/dataAccessLayer"); const { PROGRESS_ALREADY_CREATED, PROGRESS_DOCUMENT_NOT_FOUND } = PROGRESSES_RESPONSE_MESSAGES; /** @@ -47,9 +48,14 @@ const createProgressDocument = async (progressData) => { * @throws {Error} If the userId or taskId is invalid or does not exist. **/ const getProgressDocument = async (queryParams) => { + const { dev } = queryParams; await assertUserOrTaskExists(queryParams); const query = buildQueryToFetchDocs(queryParams); const progressDocs = await getProgressDocs(query); + + if (dev === "true") { + return await addUserDetailsToProgressDocs(progressDocs); + } return progressDocs; }; @@ -77,8 +83,9 @@ const getRangeProgressData = async (queryParams) => { * @returns {Promise} A Promise that resolves with the progress records of the queried user or task. * @throws {Error} If the userId or taskId is invalid or does not exist. **/ -async function getProgressByDate(pathParams) { +async function getProgressByDate(pathParams, queryParams) { const { type, typeId, date } = pathParams; + const { dev } = queryParams; await assertUserOrTaskExists({ [TYPE_MAP[type]]: typeId }); const query = buildQueryToSearchProgressByDay({ [TYPE_MAP[type]]: typeId, date }); const result = await query.get(); @@ -86,7 +93,49 @@ async function getProgressByDate(pathParams) { throw new NotFound(PROGRESS_DOCUMENT_NOT_FOUND); } const doc = result.docs[0]; - return { id: doc.id, ...doc.data() }; + const docData = doc.data(); + if (dev === "true") { + const { user: userData } = await retrieveUsers({ id: docData.userId }); + return { id: doc.id, ...docData, userData }; + } + + return { id: doc.id, ...docData }; } -module.exports = { createProgressDocument, getProgressDocument, getRangeProgressData, getProgressByDate }; +/** + * Adds user details to progress documents by fetching unique users. + * This function retrieves user details for each user ID in the progress documents and attaches the user data to each document. + * + * @param {Array} progressDocs - An array of progress documents. Each document should include a `userId` property. + * @returns {Promise>} A Promise that resolves to an array of progress documents with the `userData` field populated. + * If an error occurs while fetching the user details, the `userData` field will be set to `null` for each document. + */ +const addUserDetailsToProgressDocs = async (progressDocs) => { + try { + const uniqueUserIds = [...new Set(progressDocs.map((doc) => doc.userId))]; + + const uniqueUsersData = await retrieveUsers({ + userIds: uniqueUserIds, + }); + const allUsers = uniqueUsersData.flat(); + const userByIdMap = allUsers.reduce((lookup, user) => { + if (user) lookup[user.id] = user; + return lookup; + }, {}); + + return progressDocs.map((doc) => { + const userDetails = userByIdMap[doc.userId] || null; + return { ...doc, userData: userDetails }; + }); + } catch (err) { + return progressDocs.map((doc) => ({ ...doc, userData: null })); + } +}; + +module.exports = { + createProgressDocument, + getProgressDocument, + getRangeProgressData, + getProgressByDate, + addUserDetailsToProgressDocs, +}; diff --git a/routes/requests.ts b/routes/requests.ts index 5cda581b6..f04cba0c6 100644 --- a/routes/requests.ts +++ b/routes/requests.ts @@ -6,8 +6,10 @@ 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 { skipAuthenticateForOnboardingExtensionRequest } from "../middlewares/skipAuthenticateForOnboardingExtension"; +import { verifyDiscordBot } from "../middlewares/authorizeBot"; router.get("/", getRequestsMiddleware, getRequestsController); -router.post("/",authenticate, createRequestsMiddleware, createRequestController); +router.post("/", skipAuthenticateForOnboardingExtensionRequest(authenticate, verifyDiscordBot), createRequestsMiddleware, createRequestController); router.put("/:id",authenticate, authorizeRoles([SUPERUSER]), updateRequestsMiddleware, updateRequestController); module.exports = router; diff --git a/test/integration/onboardingExtension.test.ts b/test/integration/onboardingExtension.test.ts new file mode 100644 index 000000000..703edeaca --- /dev/null +++ b/test/integration/onboardingExtension.test.ts @@ -0,0 +1,302 @@ +import addUser from "../utils/addUser"; +import chai from "chai"; +const { expect } = chai; +import userDataFixture from "../fixtures/user/user"; +import sinon from "sinon"; +import chaiHttp from "chai-http"; +import cleanDb from "../utils/cleanDb"; +import { CreateOnboardingExtensionBody } from "../../types/onboardingExtension"; +import { + REQUEST_ALREADY_PENDING, + REQUEST_STATE, REQUEST_TYPE, + ONBOARDING_REQUEST_CREATED_SUCCESSFULLY, + UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST +} from "../../constants/requests"; +const { generateToken } = require("../../test/utils/generateBotToken"); +import app from "../../server"; +import { createUserStatusWithState } from "../../utils/userStatus"; +const firestore = require("../../utils/firestore"); +const userStatusModel = firestore.collection("usersStatus"); +import * as requestsQuery from "../../models/requests" +import { userState } from "../../constants/userStatus"; +const { CLOUDFLARE_WORKER, BAD_TOKEN } = require("../../constants/bot"); +const userData = userDataFixture(); +chai.use(chaiHttp); + +describe("/requests Onboarding Extension", () => { + describe("POST /requests", () => { + let testUserId: string; + let testUserIdForInvalidDiscordJoinedDate: string; + let testUserDiscordIdForInvalidDiscordJoinedDate: string = "54321"; + + const testUserDiscordId: string = "654321"; + const extensionRequest = { + state: REQUEST_STATE.APPROVED, + type: REQUEST_TYPE.ONBOARDING, + requestNumber: 1 + }; + const postEndpoint = "/requests"; + const botToken = generateToken({name: CLOUDFLARE_WORKER}) + const body: CreateOnboardingExtensionBody = { + type: REQUEST_TYPE.ONBOARDING, + numberOfDays: 5, + reason: "This is the reason", + userId: testUserDiscordId, + }; + + beforeEach(async () => { + testUserId = await addUser({ + ...userData[6], + discordId: testUserDiscordId, + discordJoinedAt: "2023-04-06T01:47:34.488000+00:00" + }); + testUserIdForInvalidDiscordJoinedDate = await addUser({ + ...userData[1], + discordId: testUserDiscordIdForInvalidDiscordJoinedDate, + discordJoinedAt: "2023-04-06T01" + }); + }); + + afterEach(async ()=>{ + sinon.restore(); + await cleanDb(); + }) + + it("should not call verifyDiscordBot and return 401 response when extension type is not onboarding", (done)=> { + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .send({...body, type: REQUEST_TYPE.OOO}) + .end((err, res)=>{ + if(err) return done(err); + expect(res.statusCode).to.equal(401); + expect(res.body.error).to.equal("Unauthorized"); + expect(res.body.message).to.equal("Unauthenticated User"); + done(); + }) + }) + + it("should return Feature not implemented when dev is not true", (done) => { + chai.request(app) + .post(`${postEndpoint}`) + .send(body) + .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 Invalid Request when authorization header is missing", (done) => { + chai + .request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", "") + .send(body) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.equal("Invalid Request"); + done(); + }) + }) + + it("should return Unauthorized Bot for invalid token", (done) => { + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .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("Unauthorized Bot"); + done(); + }) + }) + + it("should return 400 response for invalid value type of numberOfDays", (done) => { + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send({...body, numberOfDays:"1"}) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.equal("numberOfDays must be a number"); + expect(res.body.error).to.equal("Bad Request"); + done(); + }) + }) + + it("should return 400 response for invalid value of numberOfDays", (done) => { + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send({...body, numberOfDays:1.4}) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.equal("numberOfDays must be a integer"); + expect(res.body.error).to.equal("Bad Request"); + done(); + }) + }) + + it("should return 400 response for invalid userId", (done) => { + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send({...body, userId: undefined}) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.equal("userId is required"); + expect(res.body.error).to.equal("Bad Request"); + done(); + }) + }) + + it("should return 500 response when fails to create extension request", (done) => { + createUserStatusWithState(testUserId, userStatusModel, userState.ONBOARDING); + sinon.stub(requestsQuery, "createRequest") + .throws("Error while creating extension request"); + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send(body) + .end((err, res)=>{ + if (err) return done(err); + expect(res.statusCode).to.equal(500); + expect(res.body.message).to.equal("An internal server error occurred"); + done(); + }) + }) + + it("should return 500 response when discordJoinedAt date string is invalid", (done) => { + createUserStatusWithState(testUserIdForInvalidDiscordJoinedDate, userStatusModel, userState.ONBOARDING); + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send({...body, userId: testUserDiscordIdForInvalidDiscordJoinedDate}) + .end((err, res)=>{ + if (err) return done(err); + expect(res.statusCode).to.equal(500); + expect(res.body.message).to.equal("An internal server error occurred"); + done(); + }) + }) + + it("should return 404 response when user does not exist", (done) => { + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send({...body, userId: "11111"}) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(404); + expect(res.body.error).to.equal("Not Found"); + expect(res.body.message).to.equal("User not found"); + done(); + }) + }) + + it("should return 403 response when user's status is not onboarding", (done)=> { + createUserStatusWithState(testUserId, userStatusModel, userState.ACTIVE); + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .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_CREATE_ONBOARDING_EXTENSION_REQUEST); + done(); + }) + }) + + it("should return 409 response when a user already has a pending request", (done)=> { + createUserStatusWithState(testUserId, userStatusModel, userState.ONBOARDING); + requestsQuery.createRequest({...extensionRequest, state: REQUEST_STATE.PENDING, userId: testUserId}); + + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send(body) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(409); + expect(res.body.error).to.equal("Conflict"); + expect(res.body.message).to.equal(REQUEST_ALREADY_PENDING); + done(); + }) + }) + + it("should return 201 for successful response when user has onboarding state", (done)=> { + createUserStatusWithState(testUserId, userStatusModel, userState.ONBOARDING); + chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send(body) + .end((err, res) => { + if (err) return done(err); + expect(res.statusCode).to.equal(201); + expect(res.body.message).to.equal(ONBOARDING_REQUEST_CREATED_SUCCESSFULLY); + expect(res.body.data.requestNumber).to.equal(1); + expect(res.body.data.reason).to.equal(body.reason); + expect(res.body.data.state).to.equal(REQUEST_STATE.PENDING); + done(); + }) + }) + + it("should return 201 response when previous latest extension request is approved", async () => { + createUserStatusWithState(testUserId, userStatusModel, userState.ONBOARDING); + const latestApprovedExtension = await requestsQuery.createRequest({ + ...extensionRequest, + userId: testUserId, + state: REQUEST_STATE.APPROVED, + newEndsOn: Date.now() + 2*24*60*60*1000, + oldEndsOn: Date.now() - 24*60*60*1000, + }); + + const res = await chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send(body); + + expect(res.statusCode).to.equal(201); + expect(res.body.message).to.equal(ONBOARDING_REQUEST_CREATED_SUCCESSFULLY); + expect(res.body.data.requestNumber).to.equal(2); + expect(res.body.data.reason).to.equal(body.reason); + expect(res.body.data.state).to.equal(REQUEST_STATE.PENDING); + expect(res.body.data.oldEndsOn).to.equal(latestApprovedExtension.newEndsOn); + expect(res.body.data.newEndsOn).to.equal(latestApprovedExtension.newEndsOn + (body.numberOfDays*24*60*60*1000)); + }) + + it("should return 201 response when previous latest extension request is rejected", async () => { + createUserStatusWithState(testUserId, userStatusModel, userState.ONBOARDING); + const currentDate = Date.now(); + const latestRejectedExtension = await requestsQuery.createRequest({ + ...extensionRequest, + state: REQUEST_STATE.REJECTED, + userId: testUserId, + newEndsOn: currentDate, + oldEndsOn: currentDate - 24*60*60*1000, + }); + + const res = await chai.request(app) + .post(`${postEndpoint}?dev=true`) + .set("authorization", `Bearer ${botToken}`) + .send(body); + + expect(res.statusCode).to.equal(201); + expect(res.body.message).to.equal(ONBOARDING_REQUEST_CREATED_SUCCESSFULLY); + expect(res.body.data.requestNumber).to.equal(2); + expect(res.body.data.reason).to.equal(body.reason);; + expect(res.body.data.state).to.equal(REQUEST_STATE.PENDING); + expect(res.body.data.oldEndsOn).to.equal(latestRejectedExtension.oldEndsOn); + expect(new Date(res.body.data.newEndsOn).toDateString()) + .to.equal(new Date(currentDate + (body.numberOfDays*24*60*60*1000)).toDateString()); + }) + }) +}); \ No newline at end of file diff --git a/test/integration/progressesTasks.test.js b/test/integration/progressesTasks.test.js index 704c534db..14a00ffe6 100644 --- a/test/integration/progressesTasks.test.js +++ b/test/integration/progressesTasks.test.js @@ -222,6 +222,76 @@ describe("Test Progress Updates API for Tasks", function () { }); }); + it("Returns the progress array for the task with userData object", function (done) { + chai + .request(app) + .get(`/progresses?taskId=${taskId1}&dev=true`) + .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.message).to.be.equal("Progress document retrieved successfully."); + res.body.data.forEach((progress) => { + expect(progress).to.have.keys([ + "id", + "taskId", + "type", + "completed", + "planned", + "blockers", + "userData", + "userId", + "createdAt", + "date", + ]); + }); + return done(); + }); + }); + + it("Returns the progress array for the task without userData field if dev is false", function (done) { + chai + .request(app) + .get(`/progresses?taskId=${taskId1}&dev=false`) + .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.message).to.be.equal("Progress document retrieved successfully."); + res.body.data.forEach((progress) => { + expect(progress).to.have.keys([ + "id", + "taskId", + "type", + "completed", + "planned", + "blockers", + "userId", + "createdAt", + "date", + ]); + }); + return done(); + }); + }); + + it("Returns a 404 error when the task does not exist", function (done) { + chai + .request(app) + .get(`/progresses?taskId=nonExistingTaskId&dev=true`) + .end((err, res) => { + if (err) return done(err); + + expect(res).to.have.status(404); + expect(res.body).to.have.keys(["message"]); + expect(res.body.message).to.be.equal(`Task with id nonExistingTaskId does not exist.`); + + return done(); + }); + }); + it("Gives 400 status when anything other than -date or date is supplied", function (done) { chai .request(app) @@ -311,6 +381,35 @@ describe("Test Progress Updates API for Tasks", function () { }); }); + it("Returns the progress array for all the tasks with userData object", function (done) { + chai + .request(app) + .get(`/progresses?type=task&dev=true`) + .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.message).to.be.equal("Progress document retrieved successfully."); + expect(res.body.count).to.be.equal(4); + res.body.data.forEach((progress) => { + expect(progress).to.have.keys([ + "id", + "taskId", + "type", + "completed", + "planned", + "blockers", + "userData", + "userId", + "createdAt", + "date", + ]); + }); + return done(); + }); + }); + it("Returns 400 for bad request", function (done) { chai .request(app) diff --git a/test/integration/progressesUsers.test.js b/test/integration/progressesUsers.test.js index e70d5d317..f2458a576 100644 --- a/test/integration/progressesUsers.test.js +++ b/test/integration/progressesUsers.test.js @@ -226,6 +226,60 @@ describe("Test Progress Updates API for Users", function () { }); }); + it("Returns the progress array for a specific user with userData object", function (done) { + chai + .request(app) + .get(`/progresses?userId=${userId1}&dev=true`) + .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.message).to.be.equal("Progress document retrieved successfully."); + res.body.data.forEach((progress) => { + expect(progress).to.have.keys([ + "id", + "type", + "completed", + "planned", + "blockers", + "userId", + "userData", + "createdAt", + "date", + ]); + }); + return done(); + }); + }); + + it("Returns the progress array for all the user with userData object when dev is true", function (done) { + chai + .request(app) + .get(`/progresses?type=user&dev=true`) + .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.message).to.be.equal("Progress document retrieved successfully."); + res.body.data.forEach((progress) => { + expect(progress).to.have.keys([ + "id", + "type", + "completed", + "planned", + "blockers", + "userId", + "userData", + "createdAt", + "date", + ]); + }); + return done(); + }); + }); + it("Returns 400 for bad request", function (done) { chai .request(app) @@ -370,6 +424,31 @@ describe("Test Progress Updates API for Users", function () { }); }); + it("Returns the progress data for a specific user with userData object", function (done) { + chai + .request(app) + .get(`/progresses/user/${userId}/date/2023-05-02?dev=true`) + .end((err, res) => { + if (err) return done(err); + expect(res).to.have.status(200); + expect(res.body).to.have.keys(["message", "data"]); + expect(res.body.data).to.be.an("object"); + expect(res.body.message).to.be.equal("Progress document retrieved successfully."); + expect(res.body.data).to.have.keys([ + "id", + "type", + "completed", + "planned", + "blockers", + "userData", + "userId", + "createdAt", + "date", + ]); + return done(); + }); + }); + it("Should return 404 No progress records found if the document doesn't exist", function (done) { chai .request(app) diff --git a/test/unit/middlewares/onboardingExtensionRequestValidator.test.ts b/test/unit/middlewares/onboardingExtensionRequestValidator.test.ts new file mode 100644 index 000000000..e00ef300c --- /dev/null +++ b/test/unit/middlewares/onboardingExtensionRequestValidator.test.ts @@ -0,0 +1,70 @@ +import { REQUEST_TYPE } from "../../../constants/requests"; +import { createOnboardingExtensionRequestValidator } from "../../../middlewares/validators/onboardingExtensionRequest"; +import sinon from "sinon"; +import { CreateOnboardingExtensionBody } from "../../../types/onboardingExtension"; +import { expect } from "chai"; + +describe("Onboarding Extension Request Validators", () => { + let req: any; + let res: any; + let nextSpy: sinon.SinonSpy; + beforeEach(function () { + res = { + boom: { + badRequest: sinon.spy(), + }, + }; + nextSpy = sinon.spy(); + }); + + describe("createOnboardingExtensionRequestValidator", () => { + const requestBody:CreateOnboardingExtensionBody = { + numberOfDays: 1, + reason: "This is reason", + userId: "22222", + type: REQUEST_TYPE.ONBOARDING + } + it("should validate for a valid create request", async () => { + req = { + body: requestBody + }; + res = {}; + + await createOnboardingExtensionRequestValidator(req as any, res as any, nextSpy); + expect(nextSpy.calledOnce, "next should be called once"); + }); + + it("should not validate for an invalid request on wrong type", async () => { + req = { + body: { ...requestBody, type: REQUEST_TYPE.EXTENSION }, + }; + try { + await createOnboardingExtensionRequestValidator(req as any, res as any, nextSpy); + } catch (error) { + expect(error.details[0].message).to.equal(`"type" must be [ONBOARDING]`); + } + }); + + it("should not validate for an invalid request on wrong numberOfDays", async () => { + req = { + body: { ...requestBody, numberOfDays: "2" }, + }; + try { + await createOnboardingExtensionRequestValidator(req as any, res as any, nextSpy); + } catch (error) { + expect(error.details[0].message).to.equal(`numberOfDays must be a number`); + } + }); + + it("should not validate for an invalid request on wrong userId", async () => { + req = { + body: { ...requestBody, userId: undefined }, + }; + try { + await createOnboardingExtensionRequestValidator(req as any, res as any, nextSpy); + } catch (error) { + expect(error.details[0].message).to.equal(`userId is required`); + } + }); + }); +}); \ No newline at end of file diff --git a/test/unit/middlewares/skipAuthenticateForOnboardingExtension.test.ts b/test/unit/middlewares/skipAuthenticateForOnboardingExtension.test.ts new file mode 100644 index 000000000..b613fc3c6 --- /dev/null +++ b/test/unit/middlewares/skipAuthenticateForOnboardingExtension.test.ts @@ -0,0 +1,47 @@ +import sinon from "sinon"; +import { skipAuthenticateForOnboardingExtensionRequest } from "../../../middlewares/skipAuthenticateForOnboardingExtension"; +import { REQUEST_TYPE } from "../../../constants/requests"; +import { assert } from "chai"; + +describe("skipAuthenticateForOnboardingExtensionRequest Middleware", () => { + let req, res, next, authenticate: sinon.SinonSpy, verifyDiscordBot: sinon.SinonSpy; + let middleware; + beforeEach(() => { + authenticate = sinon.spy(); + verifyDiscordBot = sinon.spy(); + middleware = skipAuthenticateForOnboardingExtensionRequest(authenticate, verifyDiscordBot); + req = { + body:{}, + query:{}, + }, + res = {} + }); + + it("should call authenticate when type is not onboarding", () => { + req.body.type = REQUEST_TYPE.TASK + middleware(req, res, next); + + assert.isTrue(authenticate.calledOnce, "authenticate should be called once"); + assert.isTrue(verifyDiscordBot.notCalled, "verifyDiscordBot should not be called"); + }); + + it("should not call verifyDicordBot and authenticate when dev is not true and type is onboarding", async () => { + req.query.dev = "false"; + req.body.type = REQUEST_TYPE.ONBOARDING; + + middleware(req, res, next); + + assert.isTrue(verifyDiscordBot.notCalled, "verifyDiscordBot should not be called"); + assert.isTrue(authenticate.notCalled, "authenticate should not be called"); + }); + + it("should call verifyDiscordBot when dev is true and type is onboarding", () => { + req.query.dev = "true"; + req.body.type = REQUEST_TYPE.ONBOARDING; + + middleware(req, res, next); + + assert.isTrue(verifyDiscordBot.calledOnce, "verifyDiscordBot should be called once"); + assert.isTrue(authenticate.notCalled, "authenticate should not be called"); + }); +}); \ No newline at end of file diff --git a/test/unit/models/progresses.test.js b/test/unit/models/progresses.test.js new file mode 100644 index 000000000..28a57207b --- /dev/null +++ b/test/unit/models/progresses.test.js @@ -0,0 +1,47 @@ +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 userDataArray = require("../../fixtures/user/user")(); +const { removeSensitiveInfo } = require("../../../services/dataAccessLayer"); +describe("getProgressDocument", 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 users.addOrUpdate(userData); + + const mockProgressDocs = [{ userId: "userIdNotExists", taskId: 101 }]; + + const result = await addUserDetailsToProgressDocs(mockProgressDocs); + + expect(result).to.deep.equal([{ userId: "userIdNotExists", taskId: 101, userData: null }]); + }); +}); diff --git a/test/unit/utils/requests.test.ts b/test/unit/utils/requests.test.ts new file mode 100644 index 000000000..d585cd095 --- /dev/null +++ b/test/unit/utils/requests.test.ts @@ -0,0 +1,45 @@ +import { convertDateStringToMilliseconds, getNewDeadline } from "../../../utils/requests" +import { convertDaysToMilliseconds } from "../../../utils/time"; +import {expect} from "chai"; + +describe("Test getNewDeadline", () => { + const currentDate = Date.now(); + const millisecondsInTwoDays = convertDaysToMilliseconds(2); + let oldEndsOn = currentDate - millisecondsInTwoDays; + const numberOfDaysInMillisecond = convertDaysToMilliseconds(5); + + it("should return correct new deadline when old deadline has been missed", () => { + const res = getNewDeadline(currentDate, oldEndsOn, numberOfDaysInMillisecond); + expect(res).to.equal(currentDate + numberOfDaysInMillisecond); + }) + + it("shoudl return correct new deadline when old deadline has not been missed", () => { + oldEndsOn += millisecondsInTwoDays; + const res = getNewDeadline(currentDate, oldEndsOn, numberOfDaysInMillisecond); + expect(res).to.equal(oldEndsOn + numberOfDaysInMillisecond); + }) +}) + +describe("Test convertDateStringInMilliseconds", () => { + const validDateString = "2024-10-17T16:10:52.668000+00:00"; + const invalidDateString = "2024-10-17T16"; + const emptyDateString = ""; + + it("should return isDate equal false for invalid date string", () => { + const res = convertDateStringToMilliseconds(invalidDateString); + expect(res.isDate).to.equal(false); + expect(res.milliseconds).to.equal(undefined); + }) + + it("should return isDate equal false for empty date string", () => { + const res = convertDateStringToMilliseconds(emptyDateString); + expect(res.isDate).to.equal(false); + expect(res.milliseconds).to.equal(undefined); + }) + + it("should return isDate equal true for valid date string", () => { + const res = convertDateStringToMilliseconds(validDateString); + expect(res.isDate).to.equal(true); + expect(res.milliseconds).to.equal(Date.parse(validDateString)); + }) +}) \ No newline at end of file diff --git a/typeDefinitions/users.ts b/typeDefinitions/users.ts index d74fa4a6c..489cd48c0 100644 --- a/typeDefinitions/users.ts +++ b/typeDefinitions/users.ts @@ -1,4 +1,5 @@ export type User = { + id?: string username?: string; first_name?: string; last_name?: string; @@ -17,7 +18,8 @@ export type User = { roles?: { member?: boolean; in_discord?: boolean; - }; + super_user?: boolean; + } tokens?: { githubAccessToken?: string; }; @@ -29,4 +31,4 @@ export type User = { }; incompleteUserDetails?: boolean; nickname_synced?: boolean; -}; +}; \ No newline at end of file diff --git a/types/onboardingExtension.d.ts b/types/onboardingExtension.d.ts new file mode 100644 index 000000000..ebb2717fe --- /dev/null +++ b/types/onboardingExtension.d.ts @@ -0,0 +1,39 @@ +import { Request, Response } from "express"; +import { Boom } from "express-boom"; +import { REQUEST_STATE, REQUEST_TYPE } from "../constants/requests"; +import { RequestQuery } from "./requests"; + +export type OnboardingExtension = { + id: string; + type: REQUEST_TYPE.ONBOARDING; + oldEndsOn: number; + newEndsOn: number; + message?: string; + reason: string; + requestedBy: string; + state: REQUEST_STATE; + lastModifiedBy?: string; + createdAt: Timestamp; + updatedAt: Timestamp; + requestNumber: number; + userId: string; +} + +export type CreateOnboardingExtensionBody = { + type: string; + numberOfDays: number; + userId: string; + reason: string; +} + +export type OnboardingExtensionRequestQuery = RequestQuery & { + dev?: string +} + +export type OnboardingExtensionResponse = Response & { + boom: Boom +} +export type OnboardingExtensionCreateRequest = Request & { + body: CreateOnboardingExtensionBody; + query: OnboardingExtensionRequestQuery; +} \ No newline at end of file diff --git a/utils/requests.ts b/utils/requests.ts new file mode 100644 index 000000000..9ea61f13c --- /dev/null +++ b/utils/requests.ts @@ -0,0 +1,34 @@ +/** + * Calculates the new deadline based on the current date, the old end date, and the additional duration in milliseconds. + * + * @param {number} currentDate - The current date as a timestamp in milliseconds. + * @param {number} oldEndsOn - The previous end date as a timestamp in milliseconds. + * @param {number} numberOfDaysInMillisecond - The duration to extend the deadline, in milliseconds. + * @returns {number} The new deadline as a timestamp in milliseconds. +*/ +export const getNewDeadline = (currentDate: number, oldEndsOn: number, numberOfDaysInMillisecond: number): number => { + if (currentDate > oldEndsOn) { + return currentDate + numberOfDaysInMillisecond; + } + return oldEndsOn + numberOfDaysInMillisecond; +}; + +/** + * Converts a date string into a timestamp in milliseconds. + * Validates whether the provided string is a valid date format. + * + * @param {string} date - The date string to convert (e.g., "2024-10-17T16:10:52.668Z"). + * @returns {{ isDate: boolean, milliseconds?: number }} An object indicating validity and the timestamp if valid. + */ +export const convertDateStringToMilliseconds = (date: string): { isDate: boolean; milliseconds?: number; } => { + const milliseconds = Date.parse(date); + if (!milliseconds) { + return { + isDate: false, + }; + } + return { + isDate: true, + milliseconds, + }; +}; \ No newline at end of file