diff --git a/test/integration/application.test.ts b/test/integration/application.test.ts index a3104d33e..309bb8d3d 100644 --- a/test/integration/application.test.ts +++ b/test/integration/application.test.ts @@ -356,6 +356,153 @@ describe("Application", function () { }); }); + describe("PATCH /applications/:applicationId", function () { + it("should return 200 and update application when owner sends valid payload", function (done) { + chai + .request(app) + .patch(`/applications/${applicationId1}`) + .set("cookie", `${cookieName}=${jwt}`) + .send({ introduction: "Updated introduction text" }) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(200); + expect(res.body.message).to.be.equal("Application updated successfully!"); + return done(); + }); + }); + + it("should return 400 when request body is empty", function (done) { + chai + .request(app) + .patch(`/applications/${applicationId1}`) + .set("cookie", `${cookieName}=${jwt}`) + .send({}) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(400); + expect(res.body.error).to.be.equal("Bad Request"); + expect(res.body.message).to.include("at least one allowed field"); + return done(); + }); + }); + + it("should return 400 when request body contains disallowed field", function (done) { + chai + .request(app) + .patch(`/applications/${applicationId1}`) + .set("cookie", `${cookieName}=${jwt}`) + .send({ batman: true }) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(400); + expect(res.body.error).to.be.equal("Bad Request"); + return done(); + }); + }); + + it("should return 400 when imageUrl is not a valid URI", function (done) { + chai + .request(app) + .patch(`/applications/${applicationId1}`) + .set("cookie", `${cookieName}=${jwt}`) + .send({ imageUrl: "not-a-valid-uri" }) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(400); + expect(res.body.error).to.be.equal("Bad Request"); + return done(); + }); + }); + + it("should return 404 when application does not exist", function (done) { + chai + .request(app) + .patch(`/applications/non-existent-application-id`) + .set("cookie", `${cookieName}=${jwt}`) + .send({ introduction: "Updated" }) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(404); + expect(res.body.error).to.be.equal("Not Found"); + expect(res.body.message).to.be.equal("Application not found"); + return done(); + }); + }); + + it("should return 401 when user is not authenticated", function (done) { + chai + .request(app) + .patch(`/applications/${applicationId1}`) + .send({ introduction: "Updated" }) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(401); + expect(res.body.error).to.be.equal("Unauthorized"); + expect(res.body.message).to.be.equal("Unauthenticated User"); + return done(); + }); + }); + + it("should return 401 when user does not own the application", function (done) { + chai + .request(app) + .patch(`/applications/${applicationId1}`) + .set("cookie", `${cookieName}=${secondUserJwt}`) + .send({ introduction: "Updated" }) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(401); + expect(res.body.error).to.be.equal("Unauthorized"); + expect(res.body.message).to.be.equal("You are not authorized to edit this application"); + return done(); + }); + }); + + it("should return 409 when edit is attempted within 24 hours of last edit", async function () { + const applicationForEditTest = { ...applicationsData[0], userId }; + const editTestApplicationId = await applicationModel.addApplication(applicationForEditTest); + + const firstRes = await chai + .request(app) + .patch(`/applications/${editTestApplicationId}`) + .set("cookie", `${cookieName}=${jwt}`) + .send({ introduction: "First edit" }); + + expect(firstRes).to.have.status(200); + + const secondRes = await chai + .request(app) + .patch(`/applications/${editTestApplicationId}`) + .set("cookie", `${cookieName}=${jwt}`) + .send({ foundFrom: "Second edit" }); + + expect(secondRes).to.have.status(409); + expect(secondRes.body.error).to.be.equal("Conflict"); + expect(secondRes.body.message).to.be.equal(APPLICATION_ERROR_MESSAGES.EDIT_TOO_SOON); + }); + }); + describe("PATCH /applications/:applicationId/feedback", function () { it("should return 200 if the user is super user and application feedback is submitted", function (done) { chai diff --git a/test/unit/controllers/applications.test.ts b/test/unit/controllers/applications.test.ts index 39aff8302..605169b22 100644 --- a/test/unit/controllers/applications.test.ts +++ b/test/unit/controllers/applications.test.ts @@ -3,7 +3,152 @@ import sinon from "sinon"; import { CustomRequest, CustomResponse } from "../../../types/global"; const applicationsController = require("../../../controllers/applications"); const ApplicationModel = require("../../../models/applications"); +const logsModel = require("../../../models/logs"); const { API_RESPONSE_MESSAGES, APPLICATION_ERROR_MESSAGES } = require("../../../constants/application"); +const { APPLICATION_STATUS } = require("../../../constants/application"); + +describe("updateApplication", () => { + let req: Partial; + let res: Partial & { + json: sinon.SinonSpy; + boom: { + notFound: sinon.SinonSpy; + unauthorized: sinon.SinonSpy; + conflict: sinon.SinonSpy; + badImplementation: sinon.SinonSpy; + }; + }; + let jsonSpy: sinon.SinonSpy; + let boomNotFound: sinon.SinonSpy; + let boomUnauthorized: sinon.SinonSpy; + let boomConflict: sinon.SinonSpy; + let boomBadImplementation: sinon.SinonSpy; + let updateApplicationStub: sinon.SinonStub; + let addLogStub: sinon.SinonStub; + + const mockApplicationId = "app-id-123"; + const mockUserId = "user-id-456"; + const mockUsername = "testuser"; + + beforeEach(() => { + jsonSpy = sinon.spy(); + boomNotFound = sinon.spy(); + boomUnauthorized = sinon.spy(); + boomConflict = sinon.spy(); + boomBadImplementation = sinon.spy(); + + req = { + params: { applicationId: mockApplicationId }, + body: { introduction: "Updated introduction text" }, + userData: { id: mockUserId, username: mockUsername }, + }; + + res = { + json: jsonSpy, + boom: { + notFound: boomNotFound, + unauthorized: boomUnauthorized, + conflict: boomConflict, + badImplementation: boomBadImplementation, + }, + }; + + updateApplicationStub = sinon.stub(ApplicationModel, "updateApplication"); + addLogStub = sinon.stub(logsModel, "addLog").resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("Success cases", () => { + it("should return 200 and log when update succeeds", async () => { + updateApplicationStub.resolves({ status: APPLICATION_STATUS.success }); + + await applicationsController.updateApplication(req as CustomRequest, res as CustomResponse); + + expect(updateApplicationStub.calledOnce).to.be.true; + expect(updateApplicationStub.firstCall.args[0]).to.deep.include({ "intro.introduction": "Updated introduction text" }); + expect(updateApplicationStub.firstCall.args[1]).to.equal(mockApplicationId); + expect(updateApplicationStub.firstCall.args[2]).to.equal(mockUserId); + + expect(addLogStub.calledOnce).to.be.true; + expect(addLogStub.firstCall.args[1]).to.deep.include({ applicationId: mockApplicationId, userId: mockUserId, username: mockUsername }); + + expect(jsonSpy.calledOnce).to.be.true; + expect(jsonSpy.firstCall.args[0].message).to.equal("Application updated successfully!"); + }); + + it("should build payload with professional and intro fields correctly", async () => { + req.body = { + professional: { institution: "MIT", skills: "React, Node" }, + whyRds: "one ".repeat(100).trim(), + }; + updateApplicationStub.resolves({ status: APPLICATION_STATUS.success }); + + await applicationsController.updateApplication(req as CustomRequest, res as CustomResponse); + + const payload = updateApplicationStub.firstCall.args[0]; + expect(payload).to.have.property("professional.institution", "MIT"); + expect(payload).to.have.property("professional.skills", "React, Node"); + expect(payload).to.have.property("intro.whyRds"); + expect(jsonSpy.calledOnce).to.be.true; + }); + }); + + describe("Error cases", () => { + it("should return 404 when application is not found", async () => { + updateApplicationStub.resolves({ status: APPLICATION_STATUS.notFound }); + + await applicationsController.updateApplication(req as CustomRequest, res as CustomResponse); + + expect(boomNotFound.calledOnce).to.be.true; + expect(boomNotFound.firstCall.args[0]).to.equal("Application not found"); + expect(addLogStub.called).to.be.false; + expect(jsonSpy.called).to.be.false; + }); + + it("should return 401 when user is not the owner", async () => { + updateApplicationStub.resolves({ status: APPLICATION_STATUS.unauthorized }); + + await applicationsController.updateApplication(req as CustomRequest, res as CustomResponse); + + expect(boomUnauthorized.calledOnce).to.be.true; + expect(boomUnauthorized.firstCall.args[0]).to.equal("You are not authorized to edit this application"); + expect(addLogStub.called).to.be.false; + expect(jsonSpy.called).to.be.false; + }); + + it("should return 409 when edit is attempted within 24 hours", async () => { + updateApplicationStub.resolves({ status: APPLICATION_STATUS.tooSoon }); + + await applicationsController.updateApplication(req as CustomRequest, res as CustomResponse); + + expect(boomConflict.calledOnce).to.be.true; + expect(boomConflict.firstCall.args[0]).to.equal(APPLICATION_ERROR_MESSAGES.EDIT_TOO_SOON); + expect(addLogStub.called).to.be.false; + expect(jsonSpy.called).to.be.false; + }); + + it("should return 500 when model returns unexpected status", async () => { + updateApplicationStub.resolves({ status: "unknown" }); + + await applicationsController.updateApplication(req as CustomRequest, res as CustomResponse); + + expect(boomBadImplementation.calledOnce).to.be.true; + expect(jsonSpy.called).to.be.false; + }); + + it("should return 500 when model throws", async () => { + updateApplicationStub.rejects(new Error("DB error")); + + await applicationsController.updateApplication(req as CustomRequest, res as CustomResponse); + + expect(boomBadImplementation.calledOnce).to.be.true; + expect(jsonSpy.called).to.be.false; + }); + }); +}); describe("nudgeApplication", () => { let req: Partial; diff --git a/test/unit/middlewares/application-validator.test.ts b/test/unit/middlewares/application-validator.test.ts index 84c80faf9..b8de3b247 100644 --- a/test/unit/middlewares/application-validator.test.ts +++ b/test/unit/middlewares/application-validator.test.ts @@ -89,7 +89,7 @@ describe("application validator test", function () { }); }); - describe("validateApplicationUpdateData", function () { + describe("validateApplicationFeedbackData", function () { let req: any; let res: any; let nextSpy: sinon.SinonSpy; @@ -210,6 +210,136 @@ describe("application validator test", function () { }); }); + describe("validateApplicationUpdateData", function () { + let req: any; + let res: any; + let nextSpy: sinon.SinonSpy; + + const validWordString = + "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty " + + "twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven twenty-eight twenty-nine thirty " + + "thirty-one thirty-two thirty-three thirty-four thirty-five thirty-six thirty-seven thirty-eight thirty-nine forty " + + "forty-one forty-two forty-three forty-four forty-five forty-six forty-seven forty-eight forty-nine fifty " + + "fifty-one fifty-two fifty-three fifty-four fifty-five fifty-six fifty-seven fifty-eight fifty-nine sixty " + + "sixty-one sixty-two sixty-three sixty-four sixty-five sixty-six sixty-seven sixty-eight sixty-nine seventy " + + "seventy-one seventy-two seventy-three seventy-four seventy-five seventy-six seventy-seven seventy-eight seventy-nine eighty " + + "eighty-one eighty-two eighty-three eighty-four eighty-five eighty-six eighty-seven eighty-eight eighty-nine ninety " + + "ninety-one ninety-two ninety-three ninety-four ninety-five ninety-six ninety-seven ninety-eight ninety-nine hundred"; + + beforeEach(function () { + req = { body: {} }; + res = { boom: { badRequest: Sinon.spy() } }; + nextSpy = Sinon.spy(); + }); + + it("should call next when body has at least one allowed field (introduction)", async function () { + req.body = { introduction: "Updated intro" }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(1); + expect(res.boom.badRequest.called).to.be.false; + }); + + it("should call next when body has imageUrl as valid URI", async function () { + req.body = { imageUrl: "https://example.com/photo.jpg" }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(1); + }); + + it("should call next when body has foundFrom", async function () { + req.body = { foundFrom: "LinkedIn" }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(1); + }); + + it("should call next when body has numberOfHours within range", async function () { + req.body = { numberOfHours: 50 }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(1); + }); + + it("should call next when body has professional with institution and skills", async function () { + req.body = { professional: { institution: "MIT", skills: "React, Node.js, TypeScript" } }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(1); + }); + + it("should call next when body has socialLink with valid phoneNo", async function () { + req.body = { socialLink: { phoneNo: "+919876543210", github: "https://github.com/user" } }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(1); + }); + + it("should call next when body has forFun/funFact/whyRds with at least 100 words", async function () { + req.body = { forFun: validWordString }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(1); + }); + + it("should not call next when body is empty", async function () { + req.body = {}; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(0); + expect(res.boom.badRequest.calledOnce).to.be.true; + expect(res.boom.badRequest.firstCall.args[0]).to.include("at least one allowed field"); + }); + + it("should not call next when body has only disallowed field", async function () { + req.body = { batman: true }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(0); + expect(res.boom.badRequest.called).to.be.true; + }); + + it("should not call next when imageUrl is not a valid URI", async function () { + req.body = { imageUrl: "not-a-uri" }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(0); + expect(res.boom.badRequest.called).to.be.true; + }); + + it("should not call next when professional.skills has fewer than 5 characters", async function () { + req.body = { professional: { skills: "abc" } }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(0); + expect(res.boom.badRequest.called).to.be.true; + }); + + it("should not call next when forFun has fewer than 100 words", async function () { + req.body = { forFun: "just a few words here" }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(0); + expect(res.boom.badRequest.called).to.be.true; + }); + + it("should not call next when numberOfHours is less than 1", async function () { + req.body = { numberOfHours: 0 }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(0); + expect(res.boom.badRequest.called).to.be.true; + }); + + it("should not call next when numberOfHours is greater than 168", async function () { + req.body = { numberOfHours: 170 }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(0); + expect(res.boom.badRequest.called).to.be.true; + }); + + it("should not call next when socialLink.phoneNo has invalid format", async function () { + req.body = { socialLink: { phoneNo: "invalid" } }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(nextSpy.callCount).to.equal(0); + expect(res.boom.badRequest.called).to.be.true; + }); + + it("should trim phoneNo when socialLink.phoneNo is provided", async function () { + req.body = { socialLink: { phoneNo: " +919876543210 " } }; + await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + expect(req.body.socialLink.phoneNo).to.equal("+919876543210"); + expect(nextSpy.callCount).to.equal(1); + }); + }); + describe("validateApplicationQueryParam", function () { let req: any; let res: any; diff --git a/test/unit/models/application.test.ts b/test/unit/models/application.test.ts index 7a939a3bc..62c0888c3 100644 --- a/test/unit/models/application.test.ts +++ b/test/unit/models/application.test.ts @@ -296,4 +296,51 @@ describe("applications", function () { expect(application.feedback[0].createdAt <= afterTime).to.be.true; }); }); + + describe("updateApplication", function () { + it("should return success when application exists, userId matches, and no previous edit", async function () { + const dataToUpdate = { "intro.introduction": "Updated introduction" }; + const result = await ApplicationModel.updateApplication(dataToUpdate, applicationId1, "faksdjfkdfjdkfjksdfkj"); + + expect(result.status).to.be.equal("success"); + + const application = await ApplicationModel.getApplicationById(applicationId1); + expect(application.intro.introduction).to.be.equal("Updated introduction"); + expect(application.lastEditAt).to.exist; + }); + + it("should return notFound when application does not exist", async function () { + const dataToUpdate = { "intro.introduction": "Updated" }; + const result = await ApplicationModel.updateApplication( + dataToUpdate, + "non-existent-application-id", + "faksdjfkdfjdkfjksdfkj" + ); + + expect(result.status).to.be.equal("notFound"); + }); + + it("should return unauthorized when userId does not match application owner", async function () { + const dataToUpdate = { "intro.introduction": "Updated" }; + const result = await ApplicationModel.updateApplication(dataToUpdate, applicationId1, "different-user-id"); + + expect(result.status).to.be.equal("unauthorized"); + + const application = await ApplicationModel.getApplicationById(applicationId1); + expect(application.intro).to.not.have.property("introduction", "Updated"); + }); + + it("should return tooSoon when edit is attempted within 24 hours of last edit", async function () { + const applicationData = { ...applicationsData[0], userId: "edit-test-user" }; + const newApplicationId = await ApplicationModel.addApplication(applicationData); + + const firstUpdate = { "intro.introduction": "First edit" }; + const firstResult = await ApplicationModel.updateApplication(firstUpdate, newApplicationId, "edit-test-user"); + expect(firstResult.status).to.be.equal("success"); + + const secondUpdate = { "intro.forFun": "Second edit" }; + const secondResult = await ApplicationModel.updateApplication(secondUpdate, newApplicationId, "edit-test-user"); + expect(secondResult.status).to.be.equal("tooSoon"); + }); + }); });