From 968ff96a4094cca9a2fe368554bbe9152c040823 Mon Sep 17 00:00:00 2001 From: Akul Sharma <37033715+akulsharma1@users.noreply.github.com> Date: Wed, 25 Oct 2023 17:08:31 -0500 Subject: [PATCH] Add profile service tests & fix profile service (#102) * added more fields to test tools * added profile router testing file * profile router bug fixes * added leaderboard query limit * changed all status codes to constants --- src/constants.ts | 1 + src/services/profile/profile-router.test.ts | 173 ++++++++++++++++++++ src/services/profile/profile-router.ts | 40 +++-- src/testTools.ts | 2 + 4 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 src/services/profile/profile-router.test.ts diff --git a/src/constants.ts b/src/constants.ts index 8cd5c78e..a42595ce 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -41,6 +41,7 @@ abstract class Constants { static readonly MILLISECONDS_PER_SECOND: number = 1000; static readonly DEFAULT_POINT_VALUE: number = 0; static readonly DEFAULT_FOOD_WAVE: number = 0; + static readonly LEADERBOARD_QUERY_LIMIT: number = 25; static readonly QR_EXPIRY_TIME: string = "20s"; } diff --git a/src/services/profile/profile-router.test.ts b/src/services/profile/profile-router.test.ts new file mode 100644 index 00000000..7e3b84ca --- /dev/null +++ b/src/services/profile/profile-router.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import Models from "../../database/models.js"; +import { TESTER, delAsUser, getAsAdmin, getAsUser, postAsAttendee, postAsUser } from "../../testTools.js"; +import { ProfileFormat } from "./profile-formats.js"; +import Constants from "../../constants.js"; +import { AttendeeMetadata, AttendeeProfile } from "database/attendee-db.js"; +import { StatusCode } from "status-code-enum"; + +const TESTER_USER = { + userId: TESTER.id, + displayName: TESTER.name, + avatarUrl: TESTER.avatarUrl, + discordTag: TESTER.discordTag, + points: 0, +} satisfies AttendeeProfile; + +const TESTER_METADATA = { + userId: TESTER.id, + foodWave: 0, +} satisfies AttendeeMetadata; + +const TESTER_USER_2 = { + userId: "tester2", + displayName: TESTER.name + "2", + avatarUrl: TESTER.avatarUrl, + discordTag: TESTER.discordTag, + points: 12, +} satisfies AttendeeProfile; + +const TESTER_USER_3 = { + userId: "tester3", + displayName: TESTER.name + "3", + avatarUrl: TESTER.avatarUrl, + discordTag: TESTER.discordTag, + points: 12, +} satisfies AttendeeProfile; + +const profile: ProfileFormat = { + userId: TESTER.id, + displayName: TESTER.name, + avatarUrl: TESTER.avatarUrl, + discordTag: TESTER.discordTag, + points: 0, +}; + +beforeEach(async () => { + Models.initialize(); + await Models.AttendeeProfile.create(TESTER_USER); + await Models.AttendeeMetadata.create(TESTER_METADATA); + await Models.AttendeeProfile.create(TESTER_USER_2); + await Models.AttendeeProfile.create(TESTER_USER_3); +}); + +describe("POST /profile", () => { + it("works for an attendee", async () => { + await Models.AttendeeProfile.deleteOne({ userId: TESTER_USER.userId }); + const response = await postAsAttendee("/profile/").send(profile).expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toHaveProperty("displayName", TESTER.name); + }); + + it("fails when a profile is already created", async () => { + await Models.AttendeeProfile.deleteOne({ userId: TESTER_USER.userId }); + const response = await postAsUser("/profile/").send(profile).expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toHaveProperty("displayName", TESTER.name); + + // to verify they can't double create + const response2 = await postAsUser("/profile/").send(profile).expect(StatusCode.ClientErrorBadRequest); + expect(JSON.parse(response2.text)).toHaveProperty("error", "UserAlreadyExists"); + }); + + it("fails when invalid data is provided", async () => { + const response = await postAsUser("/profile/") + .send({ + displayName: 123, + avatarUrl: 1, + discordTag: "test", + }) + .expect(StatusCode.ClientErrorBadRequest); + + expect(JSON.parse(response.text)).toHaveProperty("error", "InvalidParams"); + }); +}); + +describe("GET /profile", () => { + it("fails to get a profile that doesn't exist", async () => { + await Models.AttendeeProfile.deleteOne({ userId: TESTER_USER.userId }); + + const response = await getAsUser("/profile/").expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toHaveProperty("error", "UserNotFound"); + }); + + it("gets a profile that exists", async () => { + const response = await getAsUser("/profile/").expect(StatusCode.SuccessOK); + expect(JSON.parse(response.text)).toHaveProperty("displayName", TESTER.name); + }); +}); + +describe("GET /profile/id/:USERID", () => { + it("redirects with no id provided", async () => { + await getAsUser("/profile/id").expect(StatusCode.RedirectFound); + }); + + it("fails to get a profile as a user", async () => { + const response = await getAsUser("/profile/id/" + TESTER.id).expect(StatusCode.ClientErrorForbidden); + + expect(JSON.parse(response.text)).toHaveProperty("error", "Forbidden"); + }); + + it("gets a profile as an admin", async () => { + const response = await getAsAdmin("/profile/id/" + TESTER.id).expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toHaveProperty("displayName", TESTER.name); + }); + + it("gets a user that doesnt exist", async () => { + const response = await getAsAdmin("/profile/id/doesnotexist").expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toHaveProperty("error", "UserNotFound"); + }); +}); + +describe("DELETE /profile/", () => { + it("fails to delete a profile that doesn't exist", async () => { + await Models.AttendeeProfile.deleteOne({ userId: TESTER_USER.userId }); + const response = await delAsUser("/profile").expect(StatusCode.ClientErrorNotFound); + expect(JSON.parse(response.text)).toHaveProperty("error", "AttendeeNotFound"); + }); + + it("deletes a profile", async () => { + const response = await delAsUser("/profile").expect(StatusCode.SuccessOK); + expect(JSON.parse(response.text)).toHaveProperty("success", true); + }); +}); + +describe("GET /profile/leaderboard", () => { + it("gets 3 entries when no limit is set", async () => { + await getAsUser("/profile/leaderboard").expect(StatusCode.SuccessOK); + }); + + it("gets with a limit of 2", async () => { + const response = await getAsUser("/profile/leaderboard?limit=2").expect(StatusCode.SuccessOK); + + const responseArray = JSON.parse(response.text); + expect(responseArray.profiles.length).toBeLessThan(3); + }); + + it("only gets the max limit when no limit is set", async () => { + for (let i = 0; i < Constants.LEADERBOARD_QUERY_LIMIT + 15; i++) { + await Models.AttendeeProfile.create({ + userId: TESTER.id + " " + i, + displayName: TESTER.name + " " + i, + avatarUrl: TESTER.avatarUrl, + discordTag: TESTER.discordTag, + points: i, + }); + } + + const response = await getAsUser("/profile/leaderboard").expect(StatusCode.SuccessOK); + + const responseArray = JSON.parse(response.text); + + expect(responseArray.profiles.length).toBeLessThan(Constants.LEADERBOARD_QUERY_LIMIT + 1); + }); + + it("fails when an invalid limit is set", async () => { + const response = await getAsUser("/profile/leaderboard?limit=0").expect(StatusCode.ClientErrorBadRequest); + + expect(JSON.parse(response.text)).toHaveProperty("error", "InvalidLimit"); + }); +}); diff --git a/src/services/profile/profile-router.ts b/src/services/profile/profile-router.ts index b3f5fd74..4a055fe4 100644 --- a/src/services/profile/profile-router.ts +++ b/src/services/profile/profile-router.ts @@ -13,6 +13,7 @@ import { JwtPayload } from "../auth/auth-models.js"; import { strongJwtVerification } from "../../middleware/verify-jwt.js"; import { ProfileFormat, isValidProfileFormat } from "./profile-formats.js"; import { hasElevatedPerms } from "../auth/auth-lib.js"; +import { DeleteResult } from "mongodb"; import { StatusCode } from "status-code-enum"; const profileRouter: Router = Router(); @@ -57,13 +58,20 @@ profileRouter.get("/leaderboard/", async (req: Request, res: Response) => { // Returns NaN if invalid input is passed in if (limitString) { - const limit = parseInt(limitString); + let limit = parseInt(limitString); // Check for limit validity if (!limit || !isValidLimit) { return res.status(StatusCode.ClientErrorBadRequest).send({ error: "InvalidLimit" }); } + // if the limit is above the leaderboard query limit, set it to the query limit + limit = Math.min(limit, Constants.LEADERBOARD_QUERY_LIMIT); + + leaderboardQuery = leaderboardQuery.limit(limit); + } else { + const limit = Constants.LEADERBOARD_QUERY_LIMIT; + leaderboardQuery = leaderboardQuery.limit(limit); } // Perform the actual query, filter, and return the results @@ -113,11 +121,11 @@ profileRouter.get("/", strongJwtVerification, async (_: Request, res: Response) const payload: JwtPayload = res.locals.payload as JwtPayload; const userId: string = payload.id; - console.log(userId); + const user: AttendeeProfile | null = await Models.AttendeeProfile.findOne({ userId: userId }); if (!user) { - return res.status(StatusCode.ClientErrorBadRequest).send({ error: "UserNotFound" }); + return res.status(StatusCode.ClientErrorNotFound).send({ error: "UserNotFound" }); } return res.status(StatusCode.SuccessOK).send(user); @@ -162,24 +170,25 @@ profileRouter.get("/id/:USERID", strongJwtVerification, async (req: Request, res const userId: string | undefined = req.params.USERID; const payload: JwtPayload = res.locals.payload as JwtPayload; - if (!userId) { - return res.redirect("/user/"); - } - // Trying to perform elevated operation (getting someone else's profile without elevated perms) - if (userId != payload.id && !hasElevatedPerms(payload)) { + if (!hasElevatedPerms(payload)) { return res.status(StatusCode.ClientErrorForbidden).send({ error: "Forbidden" }); } const user: AttendeeProfile | null = await Models.AttendeeProfile.findOne({ userId: userId }); if (!user) { - return res.status(StatusCode.ClientErrorBadRequest).send({ error: "UserNotFound" }); + return res.status(StatusCode.ClientErrorNotFound).send({ error: "UserNotFound" }); } return res.status(StatusCode.SuccessOK).send(user); }); +profileRouter.get("/id", (_: Request, res: Response) => { + // Redirect to the root URL + return res.redirect("/user"); +}); + /** * @api {post} /profile POST /profile * @apiGroup Profile @@ -221,6 +230,9 @@ profileRouter.post("/", strongJwtVerification, async (req: Request, res: Respons const profile: ProfileFormat = req.body as ProfileFormat; profile.points = Constants.DEFAULT_POINT_VALUE; + const payload: JwtPayload = res.locals.payload as JwtPayload; + profile.userId = payload.id; + if (!isValidProfileFormat(profile)) { return res.status(StatusCode.ClientErrorBadRequest).send({ error: "InvalidParams" }); } @@ -264,9 +276,15 @@ profileRouter.post("/", strongJwtVerification, async (req: Request, res: Respons profileRouter.delete("/", strongJwtVerification, async (_: Request, res: Response) => { const decodedData: JwtPayload = res.locals.payload as JwtPayload; - await Models.AttendeeProfile.deleteOne({ userId: decodedData.id }); - await Models.AttendeeMetadata.deleteOne({ userId: decodedData.id }); + const attendeeProfileDeleteResponse: DeleteResult = await Models.AttendeeProfile.deleteOne({ userId: decodedData.id }); + const attendeeMetadataDeleteResponse: DeleteResult = await Models.AttendeeMetadata.deleteOne({ userId: decodedData.id }); + if ( + attendeeMetadataDeleteResponse.deletedCount == Constants.ZERO || + attendeeProfileDeleteResponse.deletedCount == Constants.ZERO + ) { + return res.status(StatusCode.ClientErrorNotFound).send({ success: false, error: "AttendeeNotFound" }); + } return res.status(StatusCode.SuccessOK).send({ success: true }); }); diff --git a/src/testTools.ts b/src/testTools.ts index 03c30e95..dd46c639 100644 --- a/src/testTools.ts +++ b/src/testTools.ts @@ -9,6 +9,8 @@ export const TESTER = { id: "bob-the-tester101010101011", email: "bob-the-tester@hackillinois.org", name: "Bob Tester", + avatarUrl: "https://www.hackillinois.org", + discordTag: "hackillinoistest", }; // A mapping of role to roles they have, used for JWT generation