import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi, } from "vitest"; import { setup } from "../../__testData__/controller-test"; import * as Configuration from "../../../src/init/configuration"; import { generateCurrentTestActivity } from "../../../src/api/controllers/user"; import * as UserDal from "../../../src/dal/user"; import * as AuthUtils from "../../../src/utils/auth"; import * as BlocklistDal from "../../../src/dal/blocklist"; import * as PresetDal from "../../../src/dal/preset"; import * as ConfigDal from "../../../src/dal/config"; import * as ResultDal from "../../../src/dal/result"; import * as ReportDal from "../../../src/dal/report"; import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards"; import * as LeaderboardDal from "../../../src/dal/leaderboards"; import GeorgeQueue from "../../../src/queues/george-queue"; import * as DiscordUtils from "../../../src/utils/discord"; import * as Captcha from "../../../src/utils/captcha"; import * as FirebaseAdmin from "../../../src/init/firebase-admin"; import { FirebaseError } from "firebase-admin"; import * as ApeKeysDal from "../../../src/dal/ape-keys"; import * as LogDal from "../../../src/dal/logs"; import { ObjectId } from "mongodb"; import { PersonalBest } from "@monkeytype/schemas/shared"; import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; import { randomUUID } from "node:crypto"; import { MonkeyMail, UserStreak } from "@monkeytype/schemas/users"; import MonkeyError, { isFirebaseError } from "../../../src/utils/error"; import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard"; import * as ConnectionsDal from "../../../src/dal/connections"; import { pb } from "../../__testData__/users"; import Test from "supertest/lib/test"; const { mockApp, uid, mockAuth } = setup(); const configuration = Configuration.getCachedConfiguration(); describe("user controller test", () => { describe("user signup", () => { const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); const firebaseDeleteUserMock = vi.spyOn(AuthUtils, "deleteUser"); const usernameAvailableMock = vi.spyOn(UserDal, "isNameAvailable"); const verifyCaptchaMock = vi.spyOn(Captcha, "verify"); beforeEach(async () => { await enableSignup(true); usernameAvailableMock.mockResolvedValue(true); }); afterEach(() => { [ blocklistContainsMock, firebaseDeleteUserMock, usernameAvailableMock, ].forEach((it) => it.mockClear()); }); it("should fail if blocklisted", async () => { //GIVEN blocklistContainsMock.mockResolvedValue(true); firebaseDeleteUserMock.mockResolvedValue(); const newUser = { name: "NewUser", uid: uid, email: "newuser@mail.com", captcha: "captcha", }; //WHEN const result = await mockApp .post("/users/signup") .set("Authorization", `Bearer ${uid}`) .send(newUser) .expect(409); //THEN expect(result.body.message).toEqual("Username or email blocked"); expect(blocklistContainsMock).toHaveBeenCalledWith({ name: "NewUser", email: "newuser@mail.com", }); //user will be created in firebase from the frontend, make sure we remove it expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid); expect(verifyCaptchaMock).toHaveBeenCalledWith("captcha"); }); it("should fail if domain is blacklisted", async () => { for (const domain of ["tidal.lol", "selfbot.cc"]) { //GIVEN firebaseDeleteUserMock.mockResolvedValue(); mockAuth.modifyToken({ email: `newuser@${domain}`, }); const newUser = { name: "NewUser", uid: uid, email: `newuser@${domain}`, captcha: "captcha", }; //WHEN const result = await mockApp .post("/users/signup") .set("Authorization", `Bearer ${uid}`) .send(newUser) .set({ Accept: "application/json", }) .expect(400); //THEN expect(result.body.message).toEqual("Invalid domain"); //user will be created in firebase from the frontend, make sure we remove it expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid); } }); it("should fail if username is taken", async () => { //GIVEN usernameAvailableMock.mockResolvedValue(false); firebaseDeleteUserMock.mockResolvedValue(); const newUser = { name: "NewUser", uid: uid, email: "newuser@mail.com", captcha: "captcha", }; //WHEN const result = await mockApp .post("/users/signup") .set("Authorization", `Bearer ${uid}`) .send(newUser) .expect(409); //THEN expect(result.body.message).toEqual("Username unavailable"); expect(usernameAvailableMock).toHaveBeenCalledWith("NewUser", uid); //user will be created in firebase from the frontend, make sure we remove it expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid); }); it("should fail if capture is invalid", async () => { //GIVEN verifyCaptchaMock.mockResolvedValue(false); const newUser = { name: "NewUser", uid: uid, email: "newuser@mail.com", captcha: "captcha", }; //WHEN const { body } = await mockApp .post("/users/signup") .set("Authorization", `Bearer ${uid}`) .send(newUser) .expect(422); //THEN expect(body.message).toEqual("Captcha challenge failed"); }); it("should fail if username too long", async () => { //GIVEN const newUser = { uid: uid, email: "newuser@mail.com", captcha: "captcha", }; //WHEN const { body } = await mockApp .post("/users/signup") .set("Authorization", `Bearer ${uid}`) .send({ ...newUser, name: new Array(17).fill("x").join("") }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"name" String must contain at most 16 character(s)', ], }); }); it("should fail if username contains disallowed word", async () => { //GIVEN const newUser = { uid: uid, email: "newuser@mail.com", captcha: "captcha", }; //WHEN const { body } = await mockApp .post("/users/signup") .set("Authorization", `Bearer ${uid}`) .send({ ...newUser, name: "miodec" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"name" Disallowed word detected. Please remove it. If you believe this is a mistake, please contact us (miodec).', ], }); }); }); describe("checkName", () => { const userIsNameAvailableMock = vi.spyOn(UserDal, "isNameAvailable"); beforeEach(() => { userIsNameAvailableMock.mockClear(); }); it("returns available if name is available", async () => { //GIVEN userIsNameAvailableMock.mockResolvedValue(true); //WHEN const { body } = await mockApp .get("/users/checkName/bob") //no authentication required .expect(200); //THEN expect(body).toEqual({ message: "Check username", data: { available: true }, }); expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", ""); }); it("returns taken if name is not available", async () => { //GIVEN userIsNameAvailableMock.mockResolvedValue(false); //WHEN const { body } = await mockApp .get("/users/checkName/bob") //no authentication required .expect(200); //THEN expect(body).toEqual({ message: "Check username", data: { available: false }, }); expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", ""); }); it("returns ok if name is our own", async () => { //GIVEN userIsNameAvailableMock.mockResolvedValue(true); //WHEN const { body } = await mockApp .get("/users/checkName/bob") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Check username", data: { available: true }, }); expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", uid); }); it("returns 422 if username contains disallowed word", async () => { await mockApp .get("/users/checkName/newMiodec") //no authentication required .expect(422); }); }); describe("sendVerificationEmail", () => { const adminGetUserMock = vi.fn(); const adminGenerateVerificationLinkMock = vi.fn(); const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); vi.spyOn(FirebaseAdmin, "default").mockReturnValue({ auth: () => ({ getUser: adminGetUserMock, generateEmailVerificationLink: adminGenerateVerificationLinkMock, }), } as any); vi.mock("../../../src/queues/email-queue", () => ({ __esModule: true, default: { sendVerificationEmail: vi.fn() }, })); beforeEach(() => { adminGetUserMock.mockClear().mockResolvedValue({ emailVerified: false }); getPartialUserMock.mockClear().mockResolvedValue({ uid, name: "Bob", email: "newuser@mail.com", } as any); }); it("should send verfification email", async () => { //GIVEN //"HEN const { body } = await mockApp .get("/users/verificationEmail") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Email sent", data: null, }); expect(adminGetUserMock).toHaveBeenCalledWith(uid); expect(getPartialUserMock).toHaveBeenCalledWith( uid, "request verification email", ["uid", "name", "email"], ); expect(adminGenerateVerificationLinkMock).toHaveBeenCalledWith( "newuser@mail.com", { url: "http://localhost:3000" }, ); }); it("should fail with missing firebase user", async () => { //GIVEN adminGetUserMock.mockRejectedValue(new Error("test")); //WHEN const { body } = await mockApp .get("/users/verificationEmail") .set("Authorization", `Bearer ${uid}`) .expect(500); //THEN expect(body.message).toContain( "Auth user not found, even though the token got decoded", ); }); it("should fail with already verified email", async () => { //GIVEN adminGetUserMock.mockResolvedValue({ emailVerified: true }); //WHEN const { body } = await mockApp .get("/users/verificationEmail") .set("Authorization", `Bearer ${uid}`) .expect(400); //THEN expect(body.message).toEqual("Email already verified"); }); it("should fail with email not matching the one from the authentication", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ email: "nonmatching@example.com", } as any); //WHEN const { body } = await mockApp .get("/users/verificationEmail") .set("Authorization", `Bearer ${uid}`) .expect(400); //THEN expect(body.message).toEqual( "Authenticated email does not match the email found in the database. This might happen if you recently changed your email. Please refresh and try again.", ); }); it("should fail with too many firebase requests", async () => { //GIVEN const mockFirebaseError = { code: "auth/too-many-requests", codePrefix: "auth", errorInfo: { code: "auth/too-many-requests", message: "Too many requests", }, }; adminGenerateVerificationLinkMock.mockRejectedValue(mockFirebaseError); expect(isFirebaseError(mockFirebaseError)).toBe(true); //WHEN const { body } = await mockApp .get("/users/verificationEmail") .set("Authorization", `Bearer ${uid}`) .expect(429); //THEN expect(body.message).toEqual("Too many requests. Please try again later"); }); it("should fail with firebase user not found", async () => { //GIVEN const mockFirebaseError = { code: "auth/user-not-found", codePrefix: "auth", errorInfo: { code: "auth/user-not-found", message: "User not found", }, }; adminGenerateVerificationLinkMock.mockRejectedValue(mockFirebaseError); expect(isFirebaseError(mockFirebaseError)).toBe(true); //WHEN const { body } = await mockApp .get("/users/verificationEmail") .set("Authorization", `Bearer ${uid}`) .expect(500); //THEN expect(body.message).toEqual( "Auth user not found when the user was found in the database. Contact support with this error message and your email\n" + 'Stack: {"decodedTokenEmail":"newuser@mail.com","userInfoEmail":"newuser@mail.com"}', ); }); it("should fail with unknown error", async () => { //GIVEN const mockFirebaseError = { message: "Internal server error", }; adminGenerateVerificationLinkMock.mockRejectedValue(mockFirebaseError); expect(isFirebaseError(mockFirebaseError)).toBe(false); //WHEN const { body } = await mockApp .get("/users/verificationEmail") .set("Authorization", `Bearer ${uid}`) .expect(500); //THEN expect(body.message).toEqual( "Failed to generate an email verification link: Internal server error", ); }); }); describe("sendForgotPasswordEmail", () => { const sendForgotPasswordEmailMock = vi.spyOn( AuthUtils, "sendForgotPasswordEmail", ); const verifyCaptchaMock = vi.spyOn(Captcha, "verify"); beforeEach(() => { sendForgotPasswordEmailMock.mockClear().mockResolvedValue(); verifyCaptchaMock.mockClear().mockResolvedValue(true); }); it("should send forgot password email without authentication", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/users/forgotPasswordEmail") .send({ email: "bob@example.com", captcha: "" }); //THEN expect(body).toEqual({ message: "Password reset request received. If the email is valid, you will receive an email shortly.", data: null, }); expect(sendForgotPasswordEmailMock).toHaveBeenCalledWith( "bob@example.com", ); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .post("/users/forgotPasswordEmail") .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"captcha" Required', '"email" Required'], }); }); it("should fail without unknown properties", async () => { //WHEN const { body } = await mockApp .post("/users/forgotPasswordEmail") .send({ email: "bob@example.com", extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"captcha" Required', "Unrecognized key(s) in object: 'extra'", ], }); }); }); describe("getTestActivity", () => { const getUserMock = vi.spyOn(UserDal, "getPartialUser"); afterAll(() => { getUserMock.mockClear(); }); it("should return 503 for non premium users", async () => { //given getUserMock.mockResolvedValue({ testActivity: { "2023": [1, 2, 3], "2024": [4, 5, 6] }, } as Partial as UserDal.DBUser); //when await mockApp .get("/users/testActivity") .set("Authorization", `Bearer ${uid}`) .send() .expect(503); }); it("should send data for premium users", async () => { //given getUserMock.mockResolvedValue({ testActivity: { "2023": [1, 2, 3], "2024": [4, 5, 6] }, } as Partial as UserDal.DBUser); vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); await enablePremiumFeatures(true); //when const response = await mockApp .get("/users/testActivity") .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //%hen const result = response.body.data; expect(result["2023"]).toEqual([1, 2, 3]); expect(result["2024"]).toEqual([4, 5, 6]); }); }); describe("generateCurrentTestActivity", () => { beforeAll(() => { vi.useFakeTimers().setSystemTime(1712102400000); }); it("without any data", () => { expect(generateCurrentTestActivity(undefined)).toBeUndefined(); }); it("with current year only", () => { //given const data = { "2024": fillYearWithDay(94).map((it) => 2024000 + it), }; //when const testActivity = generateCurrentTestActivity(data); //then expect(testActivity?.lastDay).toEqual(1712102400000); const testsByDays = testActivity?.testsByDays ?? []; expect(testsByDays).toHaveLength(372); expect(testsByDays[6]).toEqual(undefined); //2023-04-04 expect(testsByDays[277]).toEqual(undefined); //2023-12-31 expect(testsByDays[278]).toEqual(2024001); //2024-01-01 expect(testsByDays[371]).toEqual(2024094); //2024-01 }); it("with current and last year", () => { //given const data = { "2023": fillYearWithDay(365).map((it) => 2023000 + it), "2024": fillYearWithDay(94).map((it) => 2024000 + it), }; //when const testActivity = generateCurrentTestActivity(data); //then expect(testActivity?.lastDay).toEqual(1712102400000); const testsByDays = testActivity?.testsByDays ?? []; expect(testsByDays).toHaveLength(372); expect(testsByDays[6]).toEqual(2023094); //2023-04-04 expect(testsByDays[277]).toEqual(2023365); //2023-12-31 expect(testsByDays[278]).toEqual(2024001); //2024-01-01 expect(testsByDays[371]).toEqual(2024094); //2024-01 }); it("with current and missing days of last year", () => { //given const data = { "2023": fillYearWithDay(20).map((it) => 2023000 + it), "2024": fillYearWithDay(94).map((it) => 2024000 + it), }; //when const testActivity = generateCurrentTestActivity(data); //then expect(testActivity?.lastDay).toEqual(1712102400000); const testsByDays = testActivity?.testsByDays ?? []; expect(testsByDays).toHaveLength(372); expect(testsByDays[6]).toEqual(undefined); //2023-04-04 expect(testsByDays[277]).toEqual(undefined); //2023-12-31 expect(testsByDays[278]).toEqual(2024001); //2024-01-01 expect(testsByDays[371]).toEqual(2024094); //2024-01 }); }); describe("delete user ", () => { const getUserMock = vi.spyOn(UserDal, "getPartialUser"); const deleteUserMock = vi.spyOn(UserDal, "deleteUser"); const firebaseDeleteUserMock = vi.spyOn(AuthUtils, "deleteUser"); const deleteAllApeKeysMock = vi.spyOn(ApeKeysDal, "deleteAllApeKeys"); const deleteAllPresetsMock = vi.spyOn(PresetDal, "deleteAllPresets"); const deleteConfigMock = vi.spyOn(ConfigDal, "deleteConfig"); const deleteAllResultMock = vi.spyOn(ResultDal, "deleteAll"); const purgeUserFromDailyLeaderboardsMock = vi.spyOn( DailyLeaderboards, "purgeUserFromDailyLeaderboards", ); const purgeUserFromXpLeaderboardsMock = vi.spyOn( WeeklyXpLeaderboard, "purgeUserFromXpLeaderboards", ); const blocklistAddMock = vi.spyOn(BlocklistDal, "add"); const connectionsDeletebyUidMock = vi.spyOn(ConnectionsDal, "deleteByUid"); const logsDeleteUserMock = vi.spyOn(LogDal, "deleteUserLogs"); beforeEach(() => { [ firebaseDeleteUserMock, deleteUserMock, blocklistAddMock, deleteAllApeKeysMock, deleteAllPresetsMock, deleteConfigMock, purgeUserFromDailyLeaderboardsMock, purgeUserFromXpLeaderboardsMock, connectionsDeletebyUidMock, logsDeleteUserMock, ].forEach((it) => it.mockResolvedValue(undefined)); deleteAllResultMock.mockResolvedValue({} as any); }); afterEach(() => { [ getUserMock, deleteUserMock, blocklistAddMock, firebaseDeleteUserMock, deleteConfigMock, deleteAllResultMock, deleteAllApeKeysMock, deleteAllPresetsMock, purgeUserFromDailyLeaderboardsMock, purgeUserFromXpLeaderboardsMock, connectionsDeletebyUidMock, logsDeleteUserMock, ].forEach((it) => it.mockClear()); }); it("should add user to blocklist if banned", async () => { //GIVEN const user = { uid, name: "name", email: "email", discordId: "discordId", banned: true, } as Partial as UserDal.DBUser; await getUserMock.mockResolvedValue(user); //WHEN await mockApp .delete("/users/") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(blocklistAddMock).toHaveBeenCalledWith(user); expect(deleteUserMock).toHaveBeenCalledWith(uid); expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid); expect(deleteAllApeKeysMock).toHaveBeenCalledWith(uid); expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards, ); expect(purgeUserFromXpLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).leaderboards.weeklyXp, ); expect(logsDeleteUserMock).toHaveBeenCalledWith(uid); }); it("should delete user without adding to blocklist if not banned", async () => { //GIVEN const user = { uid, name: "name", email: "email", discordId: "discordId", } as Partial as UserDal.DBUser; getUserMock.mockResolvedValue(user); //WHEN await mockApp .delete("/users/") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(blocklistAddMock).not.toHaveBeenCalled(); expect(deleteUserMock).toHaveBeenCalledWith(uid); expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid); expect(deleteAllApeKeysMock).toHaveBeenCalledWith(uid); expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards, ); expect(purgeUserFromXpLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).leaderboards.weeklyXp, ); expect(logsDeleteUserMock).toHaveBeenCalledWith(uid); }); it("should not fail if userInfo cannot be found", async () => { //GIVEN getUserMock.mockRejectedValue(new MonkeyError(404, "user not found")); //WHEN await mockApp .delete("/users/") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(blocklistAddMock).not.toHaveBeenCalled(); expect(deleteUserMock).toHaveBeenCalledWith(uid); expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid); expect(deleteAllApeKeysMock).toHaveBeenCalledWith(uid); expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards, ); expect(purgeUserFromXpLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).leaderboards.weeklyXp, ); expect(logsDeleteUserMock).toHaveBeenCalledWith(uid); }); it("should fail for unknown error from UserDal", async () => { //GIVEN getUserMock.mockRejectedValue(new Error("oops")); //WHEN await mockApp .delete("/users/") .set("Authorization", `Bearer ${uid}`) .expect(500); //THEN expect(blocklistAddMock).not.toHaveBeenCalled(); expect(deleteUserMock).not.toHaveBeenCalledWith(uid); expect(firebaseDeleteUserMock).not.toHaveBeenCalledWith(uid); expect(deleteAllApeKeysMock).not.toHaveBeenCalledWith(uid); expect(deleteAllPresetsMock).not.toHaveBeenCalledWith(uid); expect(deleteConfigMock).not.toHaveBeenCalledWith(uid); expect(deleteAllResultMock).not.toHaveBeenCalledWith(uid); expect(connectionsDeletebyUidMock).not.toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).not.toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards, ); expect(purgeUserFromXpLeaderboardsMock).not.toHaveBeenCalledWith( uid, (await configuration).leaderboards.weeklyXp, ); expect(logsDeleteUserMock).not.toHaveBeenCalled(); }); it("should not fail if firebase user cannot be found", async () => { //GIVEN const user = { uid, name: "name", email: "email", discordId: "discordId", } as Partial as UserDal.DBUser; getUserMock.mockResolvedValue(user); firebaseDeleteUserMock.mockRejectedValue({ code: "user-not-found", codePrefix: "auth", errorInfo: { code: "auth/user-not-found", message: "user not found" }, }); //WHEN await mockApp .delete("/users/") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(blocklistAddMock).not.toHaveBeenCalled(); expect(deleteUserMock).toHaveBeenCalledWith(uid); expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid); expect(deleteAllApeKeysMock).toHaveBeenCalledWith(uid); expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards, ); expect(purgeUserFromXpLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).leaderboards.weeklyXp, ); expect(logsDeleteUserMock).toHaveBeenCalledWith(uid); }); it("should fail for unknown error from firebase", async () => { //GIVEN const user = { uid, name: "name", email: "email", discordId: "discordId", } as Partial as UserDal.DBUser; getUserMock.mockResolvedValue(user); firebaseDeleteUserMock.mockRejectedValue({ code: "unknown", codePrefix: "auth", errorInfo: { code: "auth/unknown", message: "unknown" }, }); //WHEN await mockApp .delete("/users/") .set("Authorization", `Bearer ${uid}`) .expect(500); //THEN expect(blocklistAddMock).not.toHaveBeenCalled(); expect(deleteUserMock).toHaveBeenCalledWith(uid); expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid); expect(deleteAllApeKeysMock).toHaveBeenCalledWith(uid); expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards, ); expect(purgeUserFromXpLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).leaderboards.weeklyXp, ); }); }); describe("resetUser", () => { const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); const resetUserMock = vi.spyOn(UserDal, "resetUser"); const deleteAllApeKeysMock = vi.spyOn(ApeKeysDal, "deleteAllApeKeys"); const deleteAllPresetsMock = vi.spyOn(PresetDal, "deleteAllPresets"); const deleteAllResultsMock = vi.spyOn(ResultDal, "deleteAll"); const deleteConfigMock = vi.spyOn(ConfigDal, "deleteConfig"); const purgeUserFromDailyLeaderboardsMock = vi.spyOn( DailyLeaderboards, "purgeUserFromDailyLeaderboards", ); const purgeUserFromXpLeaderboardsMock = vi.spyOn( WeeklyXpLeaderboard, "purgeUserFromXpLeaderboards", ); const unlinkDiscordMock = vi.spyOn(GeorgeQueue, "unlinkDiscord"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { getPartialUserMock.mockClear().mockResolvedValue({ banned: false, name: "bob", email: "bob@example.com", } as any); deleteAllResultsMock.mockClear().mockResolvedValue(null as any); [ purgeUserFromXpLeaderboardsMock, unlinkDiscordMock, addImportantLogMock, resetUserMock, deleteAllApeKeysMock, deleteAllPresetsMock, deleteConfigMock, purgeUserFromDailyLeaderboardsMock, ].forEach((it) => it.mockClear().mockResolvedValue()); }); it("should reset user", async () => { //GIVEN //WHEN const { body } = await mockApp .patch("/users/reset") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "User reset", data: null, }); for (const it of [ resetUserMock, deleteAllApeKeysMock, deleteAllPresetsMock, deleteAllResultsMock, deleteConfigMock, ]) { expect(it).toHaveBeenCalledWith(uid); } expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await Configuration.getLiveConfiguration()).dailyLeaderboards, ); expect(purgeUserFromXpLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).leaderboards.weeklyXp, ); expect(unlinkDiscordMock).not.toHaveBeenCalled(); /*TODO expect(addImportantLogMock).toHaveBeenCalledWith( "user_reset", "bob@example.com bob", uid );*/ }); it("should unlink discord", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ discordId: "discordId" } as any); //WHEN await mockApp .patch("/users/reset") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN //TODO //expect(unlinkDiscordMock).toHaveBeenCalledWith("discordId", uid); }); it("should fail resetting a banned user", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ banned: true } as any); //WHEN const { body } = await mockApp .patch("/users/reset") .set("Authorization", `Bearer ${uid}`) .expect(403); //THEN expect(body.message).toEqual("Banned users cannot reset their account"); }); }); describe("update name", () => { const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); const updateNameMock = vi.spyOn(UserDal, "updateName"); const connectionsUpdateNameMock = vi.spyOn(ConnectionsDal, "updateName"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { [ blocklistContainsMock, getPartialUserMock, updateNameMock, connectionsUpdateNameMock, addImportantLogMock, ].forEach((it) => { it.mockClear().mockResolvedValue(null as never); }); }); it("should update the username", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ name: "Bob", lastNameChange: 1000, } as any); //WHEN const { body } = await mockApp .patch("/users/name") .set("Authorization", `Bearer ${uid}`) .send({ name: "newName" }) .expect(200); //THEN expect(body).toEqual({ message: "User's name updated", data: null, }); expect(updateNameMock).toHaveBeenCalledWith(uid, "newName", "Bob"); expect(addImportantLogMock).toHaveBeenCalledWith( "user_name_updated", "changed name from Bob to newName", uid, ); expect(connectionsUpdateNameMock).toHaveBeenCalledWith(uid, "newName"); }); it("should fail if username is blocked", async () => { //GIVEN blocklistContainsMock.mockResolvedValue(true); //WHEN const { body } = await mockApp .patch("/users/name") .set("Authorization", `Bearer ${uid}`) .send({ name: "newName" }) .expect(409); //THEN expect(body.message).toEqual("Username blocked"); expect(updateNameMock).not.toHaveBeenCalled(); expect(connectionsUpdateNameMock).not.toHaveBeenCalled(); }); it("should fail for banned users", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ banned: true } as any); //WHEN const { body } = await mockApp .patch("/users/name") .set("Authorization", `Bearer ${uid}`) .send({ name: "newName" }) .expect(403); //THEN expect(body.message).toEqual("Banned users cannot change their name"); expect(updateNameMock).not.toHaveBeenCalled(); }); it("should fail changing name within last 30 days", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ lastNameChange: Date.now().valueOf() - 60_000, } as any); //WHEN const { body } = await mockApp .patch("/users/name") .set("Authorization", `Bearer ${uid}`) .send({ name: "newName" }) .expect(409); //THEN expect(body.message).toEqual( "You can change your name once every 30 days", ); expect(updateNameMock).not.toHaveBeenCalled(); }); it("should update the username within 30 days if user needs to change", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ name: "Bob", lastNameChange: Date.now().valueOf() - 60_000, needsToChangeName: true, } as any); //WHEN const { body } = await mockApp .patch("/users/name") .set("Authorization", `Bearer ${uid}`) .send({ name: "newName" }) .expect(200); //THEN expect(body).toEqual({ message: "User's name updated", data: null, }); expect(updateNameMock).toHaveBeenCalledWith(uid, "newName", "Bob"); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .patch("/users/name") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"name" Required'], }); }); it("should fail without unknown properties", async () => { //WHEN const { body } = await mockApp .patch("/users/name") .set("Authorization", `Bearer ${uid}`) .send({ name: "newName", extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); it("should fail if username contains disallowed word", async () => { //WHEN const { body } = await mockApp .patch("/users/name") .set("Authorization", `Bearer ${uid}`) .send({ name: "miodec" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"name" Disallowed word detected. Please remove it. If you believe this is a mistake, please contact us (miodec).', ], }); }); }); describe("clear PBs", () => { const clearPbMock = vi.spyOn(UserDal, "clearPb"); const purgeUserFromDailyLeaderboardsMock = vi.spyOn( DailyLeaderboards, "purgeUserFromDailyLeaderboards", ); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { [ clearPbMock, purgeUserFromDailyLeaderboardsMock, addImportantLogMock, ].forEach((it) => it.mockClear().mockResolvedValue()); }); it("should clear pb", async () => { //GIVEN //WHEN const { body } = await mockApp .delete("/users/personalBests") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "User's PB cleared", data: null, }); expect(clearPbMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await Configuration.getLiveConfiguration()).dailyLeaderboards, ); expect(addImportantLogMock).toHaveBeenCalledWith( "user_cleared_pbs", "", uid, ); }); }); describe("opt out of leaderboard", () => { const optOutOfLeaderboardsMock = vi.spyOn(UserDal, "optOutOfLeaderboards"); const purgeUserFromDailyLeaderboardsMock = vi.spyOn( DailyLeaderboards, "purgeUserFromDailyLeaderboards", ); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { [ optOutOfLeaderboardsMock.mockClear(), purgeUserFromDailyLeaderboardsMock, addImportantLogMock, ].forEach((it) => it.mockClear().mockResolvedValue()); }); it("should opt out", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/users/optOutOfLeaderboards") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "User opted out of leaderboards", data: null, }); expect(optOutOfLeaderboardsMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await Configuration.getLiveConfiguration()).dailyLeaderboards, ); expect(addImportantLogMock).toHaveBeenCalledWith( "user_opted_out_of_leaderboards", "", uid, ); }); // it("should fail with unknown properties", async () => { //WHEN // const { body } = await mockApp // .post("/users/optOutOfLeaderboards") // .set("Authorization", `Bearer ${uid}`) // .send({ extra: "value" }); //TODO.expect(422); //THEN /* TODO: expect(body).toEqual({}); */ // }); }); describe("update email", () => { const authUpdateEmailMock = vi.spyOn(AuthUtils, "updateUserEmail"); const userUpdateEmailMock = vi.spyOn(UserDal, "updateEmail"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { [authUpdateEmailMock, userUpdateEmailMock, addImportantLogMock].forEach( (it) => it.mockClear().mockResolvedValue(null as never), ); }); it("should update users email", async () => { //GIVEN const newEmail = "newEmail@example.com"; //WHEN const { body } = await mockApp .patch("/users/email") .set("Authorization", `Bearer ${uid}`) .send({ newEmail, previousEmail: "previousEmail@example.com" }) .expect(200); //THEN expect(body).toEqual({ message: "Email updated", data: null, }); expect(authUpdateEmailMock).toHaveBeenCalledWith( uid, newEmail.toLowerCase(), ); expect(userUpdateEmailMock).toHaveBeenCalledWith( uid, newEmail.toLowerCase(), ); expect(addImportantLogMock).toHaveBeenCalledWith( "user_email_updated", "changed email from previousemail@example.com to newemail@example.com", uid, ); }); it("should fail for duplicate email", async () => { //GIVEN const mockFirebaseError = { code: "auth/email-already-exists", codePrefix: "auth", errorInfo: { code: "auth/email-already-exists", message: "Email already exists", }, }; authUpdateEmailMock.mockRejectedValue(mockFirebaseError); expect(isFirebaseError(mockFirebaseError)).toBe(true); //WHEN const { body } = await mockApp .patch("/users/email") .set("Authorization", `Bearer ${uid}`) .send({ newEmail: "newEmail@example.com", previousEmail: "previousEmail@example.com", }) .expect(409); expect(body.message).toEqual( "The email address is already in use by another account", ); expect(userUpdateEmailMock).not.toHaveBeenCalled(); }); it("should fail for invalid email", async () => { //GIVEN const mockFirebaseError = { code: "auth/invalid-email", codePrefix: "auth", errorInfo: { code: "auth/invalid-email", message: "Invalid email", }, }; authUpdateEmailMock.mockRejectedValue(mockFirebaseError); expect(isFirebaseError(mockFirebaseError)).toBe(true); //WHEN const { body } = await mockApp .patch("/users/email") .set("Authorization", `Bearer ${uid}`) .send({ newEmail: "newEmail@example.com", previousEmail: "previousEmail@example.com", }) .expect(400); expect(body.message).toEqual("Invalid email address"); expect(userUpdateEmailMock).not.toHaveBeenCalled(); }); it("should fail for too many requests", async () => { //GIVEN const mockFirebaseError = { code: "auth/too-many-requests", codePrefix: "auth", errorInfo: { code: "auth/too-many-requests", message: "Too many requests", }, }; authUpdateEmailMock.mockRejectedValue(mockFirebaseError); expect(isFirebaseError(mockFirebaseError)).toBe(true); //WHEN const { body } = await mockApp .patch("/users/email") .set("Authorization", `Bearer ${uid}`) .send({ newEmail: "newEmail@example.com", previousEmail: "previousEmail@example.com", }) .expect(429); expect(body.message).toEqual("Too many requests. Please try again later"); expect(userUpdateEmailMock).not.toHaveBeenCalled(); }); it("should fail for unknown user", async () => { //GIVEN const mockFirebaseError = { code: "auth/user-not-found", codePrefix: "auth", errorInfo: { code: "auth/user-not-found", message: "User not found", }, }; authUpdateEmailMock.mockRejectedValue(mockFirebaseError); expect(isFirebaseError(mockFirebaseError)).toBe(true); //WHEN const { body } = await mockApp .patch("/users/email") .set("Authorization", `Bearer ${uid}`) .send({ newEmail: "newEmail@example.com", previousEmail: "previousEmail@example.com", }) .expect(404); expect(body.message).toEqual( "User not found in the auth system\nStack: update email", ); expect(userUpdateEmailMock).not.toHaveBeenCalled(); }); it("should fail for invalid user token", async () => { //GIVEN authUpdateEmailMock.mockRejectedValue({ code: "auth/invalid-user-token", codePrefix: "auth", errorInfo: { code: "auth/invalid-user-token", message: "Invalid user token", }, }); //WHEN const { body } = await mockApp .patch("/users/email") .set("Authorization", `Bearer ${uid}`) .send({ newEmail: "newEmail@example.com", previousEmail: "previousEmail@example.com", }) .expect(401); expect(body.message).toEqual("Invalid user token\nStack: update email"); expect(userUpdateEmailMock).not.toHaveBeenCalled(); }); it("should fail for unknown error", async () => { //GIVEN authUpdateEmailMock.mockRejectedValue({} as FirebaseError); //WHEN await mockApp .patch("/users/email") .set("Authorization", `Bearer ${uid}`) .send({ newEmail: "newEmail@example.com", previousEmail: "previousEmail@example.com", }) .expect(500); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .patch("/users/email") .set("Authorization", `Bearer ${uid}`) .expect(422); expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"newEmail" Required', '"previousEmail" Required'], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .patch("/users/email") .set("Authorization", `Bearer ${uid}`) .send({ newEmail: "newEmail@example.com", previousEmail: "previousEmail@example.com", extra: "value", }) .expect(422); expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); }); describe("update password", () => { const updatePasswordMock = vi.spyOn(AuthUtils, "updateUserPassword"); beforeEach(() => { updatePasswordMock.mockClear().mockResolvedValue(null as never); }); it("should update password", async () => { //WHEN const { body } = await mockApp .patch("/users/password") .set("Authorization", `Bearer ${uid}`) .send({ newPassword: "sw0rdf1sh" }) .expect(200); //THEN expect(body).toEqual({ message: "Password updated", data: null, }); expect(updatePasswordMock).toHaveBeenCalledWith(uid, "sw0rdf1sh"); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .patch("/users/password") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"newPassword" Required'], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .patch("/users/password") .set("Authorization", `Bearer ${uid}`) .send({ newPassword: "sw0rdf1sh", extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); it("should fail with password too short", async () => { //WHEN const { body } = await mockApp .patch("/users/password") .set("Authorization", `Bearer ${uid}`) .send({ newPassword: "test" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"newPassword" String must contain at least 6 character(s)', ], }); }); }); describe("get oauth link", () => { const getOauthLinkMock = vi.spyOn(DiscordUtils, "getOauthLink"); const url = "http://example.com:1234?test"; beforeEach(() => { enableDiscordIntegration(true); getOauthLinkMock.mockClear().mockResolvedValue(url); }); it("should get oauth link", async () => { //WHEN const { body } = await mockApp .get("/users/discord/oauth") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Discord oauth link generated", data: { url }, }); expect(getOauthLinkMock).toHaveBeenCalledWith(uid); }); it("should fail if feature is not enabled", async () => { //GIVEN enableDiscordIntegration(false); //WHEN const { body } = await mockApp .get("/users/discord/oauth") .set("Authorization", `Bearer ${uid}`) .expect(503); //THEN expect(body.message).toEqual( "Discord integration is not available at this time", ); }); }); describe("link discord", () => { const getUserMock = vi.spyOn(UserDal, "getPartialUser"); const isDiscordIdAvailableMock = vi.spyOn(UserDal, "isDiscordIdAvailable"); const isStateValidForUserMock = vi.spyOn( DiscordUtils, "iStateValidForUser", ); const getDiscordUserMock = vi.spyOn(DiscordUtils, "getDiscordUser"); const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); const userLinkDiscordMock = vi.spyOn(UserDal, "linkDiscord"); const georgeLinkDiscordMock = vi.spyOn(GeorgeQueue, "linkDiscord"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(async () => { isStateValidForUserMock.mockResolvedValue(true); getUserMock.mockResolvedValue({} as any); getDiscordUserMock.mockResolvedValue({ id: "discordUserId", avatar: "discordUserAvatar", }); isDiscordIdAvailableMock.mockResolvedValue(true); blocklistContainsMock.mockResolvedValue(false); userLinkDiscordMock.mockResolvedValue(); await enableDiscordIntegration(true); }); afterEach(() => { [ getUserMock, isStateValidForUserMock, isDiscordIdAvailableMock, getDiscordUserMock, blocklistContainsMock, userLinkDiscordMock, georgeLinkDiscordMock, addImportantLogMock, ].forEach((it) => it.mockClear()); }); it("should link discord", async () => { //GIVEN getUserMock.mockResolvedValue({} as any); //WHEN const { body } = await mockApp .post("/users/discord/link") .set("Authorization", `Bearer ${uid}`) .send({ tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", }) .expect(200); //THEN expect(body).toEqual({ message: "Discord account linked", data: { discordId: "discordUserId", discordAvatar: "discordUserAvatar", }, }); expect(isStateValidForUserMock).toHaveBeenCalledWith( "statestatestatestate", uid, ); expect(getUserMock).toHaveBeenCalledWith( uid, "link discord", expect.any(Array), ); expect(getDiscordUserMock).toHaveBeenCalledWith( "tokenType", "accessToken", ); expect(isDiscordIdAvailableMock).toHaveBeenCalledWith("discordUserId"); expect(blocklistContainsMock).toHaveBeenCalledWith({ discordId: "discordUserId", }); expect(userLinkDiscordMock).toHaveBeenCalledWith( uid, "discordUserId", "discordUserAvatar", ); expect(georgeLinkDiscordMock).toHaveBeenCalledWith( "discordUserId", uid, false, ); expect(addImportantLogMock).toHaveBeenCalledWith( "user_discord_link", "linked to discordUserId", uid, ); }); it("should update existing discord avatar", async () => { //GIVEN getUserMock.mockResolvedValue({ discordId: "existingDiscordId" } as any); //WHEN const { body } = await mockApp .post("/users/discord/link") .set("Authorization", `Bearer ${uid}`) .send({ tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", }) .expect(200); //THEN expect(body).toEqual({ message: "Discord avatar updated", data: { discordId: "discordUserId", discordAvatar: "discordUserAvatar", }, }); expect(userLinkDiscordMock).toHaveBeenCalledWith( uid, "existingDiscordId", "discordUserAvatar", ); expect(isDiscordIdAvailableMock).not.toHaveBeenCalled(); expect(blocklistContainsMock).not.toHaveBeenCalled(); expect(georgeLinkDiscordMock).not.toHaveBeenCalled(); expect(addImportantLogMock).not.toHaveBeenCalled(); }); it("should fail for user mismatch", async () => { //GIVEN isStateValidForUserMock.mockResolvedValue(false); //WHEN const { body } = await mockApp .post("/users/discord/link") .set("Authorization", `Bearer ${uid}`) .send({ tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", }) .expect(403); //THEN expect(body.message).toEqual("Invalid user token"); }); it("should fail for banned users", async () => { //GIVEN getUserMock.mockResolvedValue({ banned: true } as any); //WHEN const { body } = await mockApp .post("/users/discord/link") .set("Authorization", `Bearer ${uid}`) .send({ tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", }) .expect(403); //THEN expect(body.message).toEqual("Banned accounts cannot link with Discord"); }); it("should fail for unknown discordId", async () => { //GIVEN getDiscordUserMock.mockResolvedValue({} as any); //WHEN const { body } = await mockApp .post("/users/discord/link") .set("Authorization", `Bearer ${uid}`) .send({ tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", }) .expect(500); //THEN expect(body.message).toEqual( "Could not get Discord account info\nStack: discord id is undefined", ); //THEN expect(userLinkDiscordMock).not.toHaveBeenCalled(); }); it("should fail for already linked discordId", async () => { //GIVEN isDiscordIdAvailableMock.mockResolvedValue(false); //WHEN const { body } = await mockApp .post("/users/discord/link") .set("Authorization", `Bearer ${uid}`) .send({ tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", }) .expect(409); //THEN expect(body.message).toEqual( "This Discord account is linked to a different account", ); //THEN expect(userLinkDiscordMock).not.toHaveBeenCalled(); }); it("should fail if discordId is blocked", async () => { //GIVEN const user = { uid, name: "name", email: "email", } as Partial as UserDal.DBUser; getUserMock.mockResolvedValue(user); blocklistContainsMock.mockResolvedValue(true); //WHEN const result = await mockApp .post("/users/discord/link") .set("Authorization", `Bearer ${uid}`) .send({ tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", }) .expect(409); //THEN expect(result.body.message).toEqual("The Discord account is blocked"); expect(blocklistContainsMock).toHaveBeenCalledWith({ discordId: "discordUserId", }); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .post("/users/discord/link") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"tokenType" Required', '"accessToken" Required', '"state" Required', ], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .post("/users/discord/link") .set("Authorization", `Bearer ${uid}`) .send({ tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", extra: "value", }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); }); describe("unlink discord", () => { const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); const userUnlinkDiscordMock = vi.spyOn(UserDal, "unlinkDiscord"); const georgeUnlinkDiscordMock = vi.spyOn(GeorgeQueue, "unlinkDiscord"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { getPartialUserMock .mockClear() .mockResolvedValue({ discordId: "discordId" } as any); [ userUnlinkDiscordMock, georgeUnlinkDiscordMock, addImportantLogMock, ].forEach((it) => it.mockClear().mockResolvedValue()); }); it("should unlink", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/users/discord/unlink") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Discord account unlinked", data: null, }); expect(userUnlinkDiscordMock).toHaveBeenCalledWith(uid); expect(georgeUnlinkDiscordMock).toHaveBeenCalledWith("discordId", uid); expect(addImportantLogMock).toHaveBeenCalledWith( "user_discord_unlinked", "discordId", uid, ); }); it("should fail for banned user", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ banned: true } as any); //WHEN const { body } = await mockApp .post("/users/discord/unlink") .set("Authorization", `Bearer ${uid}`) .expect(403); //THEN expect(body.message).toEqual("Banned accounts cannot unlink Discord"); expect(userUnlinkDiscordMock).not.toHaveBeenCalled(); expect(georgeUnlinkDiscordMock).not.toHaveBeenCalled(); }); it("should fail for user without discord linked", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ discordId: undefined } as any); //WHEN const { body } = await mockApp .post("/users/discord/unlink") .set("Authorization", `Bearer ${uid}`) .expect(404); //THEN expect(body.message).toEqual( "User does not have a linked Discord account", ); expect(userUnlinkDiscordMock).not.toHaveBeenCalled(); expect(georgeUnlinkDiscordMock).not.toHaveBeenCalled(); }); }); describe("add result filter preset", () => { const validPreset = { _id: "66c61b7a2a65715e66a0cc95", name: "newPreset", pb: { no: true, yes: true }, difficulty: { normal: true, expert: false, master: false }, mode: { words: false, time: false, quote: true, zen: false, custom: false, }, words: { "10": false, "25": false, "50": false, "100": false, custom: false, }, time: { "15": false, "30": false, "60": false, "120": false, custom: false, }, quoteLength: { short: false, medium: false, long: false, thicc: false, }, punctuation: { on: false, off: true, }, numbers: { on: false, off: true, }, date: { last_day: false, last_week: false, last_month: false, last_3months: false, all: true, }, tags: { none: false, }, language: { english: true, }, funbox: { none: true, }, }; const generatedId = new ObjectId(); const addResultFilterPresetMock = vi.spyOn( UserDal, "addResultFilterPreset", ); beforeEach(async () => { addResultFilterPresetMock.mockClear().mockResolvedValue(generatedId); await enableResultFilterPresets(true); }); it("should add", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/users/resultFilterPresets") .set("Authorization", `Bearer ${uid}`) .send(validPreset) .expect(200); //THEN expect(body).toEqual({ message: "Result filter preset created", data: generatedId.toHexString(), }); expect(addResultFilterPresetMock).toHaveBeenCalledWith( uid, validPreset, (await Configuration.getLiveConfiguration()).results.filterPresets .maxPresetsPerUser, ); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .post("/users/resultFilterPresets") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"_id" Required', '"name" Required', '"pb" Required', '"difficulty" Required', '"mode" Required', '"words" Required', '"time" Required', '"quoteLength" Required', '"punctuation" Required', '"numbers" Required', '"date" Required', '"tags" Required', '"language" Required', '"funbox" Required', ], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .post("/users/resultFilterPresets") .set("Authorization", `Bearer ${uid}`) .send({ ...validPreset, extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); it("should fail if feature is disabled", async () => { //GIVEN enableResultFilterPresets(false); //WHEN const { body } = await mockApp .post("/users/resultFilterPresets") .set("Authorization", `Bearer ${uid}`) .send({ validPreset }) .expect(503); //THEN expect(body.message).toEqual( "Result filter presets are not available at this time.", ); }); }); describe("remove result filter preset", () => { const removeResultFilterPresetMock = vi.spyOn( UserDal, "removeResultFilterPreset", ); beforeEach(() => { enableResultFilterPresets(true); removeResultFilterPresetMock.mockClear().mockResolvedValue(); }); it("should remove filter preset", async () => { //WHEN const { body } = await mockApp .delete("/users/resultFilterPresets/myId") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Result filter preset deleted", data: null, }); expect(removeResultFilterPresetMock).toHaveBeenCalledWith(uid, "myId"); }); it("should fail if feature is disabled", async () => { //GIVEN enableResultFilterPresets(false); //WHEN const { body } = await mockApp .delete("/users/resultFilterPresets/myId") .set("Authorization", `Bearer ${uid}`) .expect(503); //THEN expect(body.message).toEqual( "Result filter presets are not available at this time.", ); }); }); describe("add tag", () => { const addTagMock = vi.spyOn(UserDal, "addTag"); const newTag = { _id: new ObjectId(), name: "tagName", personalBests: { time: {}, words: {}, quote: {}, zen: {}, custom: {}, }, }; beforeEach(() => { addTagMock.mockClear().mockResolvedValue(newTag); }); it("should add tag", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/users/tags") .send({ tagName: "tagName" }) .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Tag updated", data: { ...newTag, _id: newTag._id.toHexString() }, }); expect(addTagMock).toHaveBeenCalledWith(uid, "tagName"); }); it("should fail without mandatory properties", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/users/tags") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"tagName" Required'], }); }); it("should fail with unknown properties", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/users/tags") .set("Authorization", `Bearer ${uid}`) .send({ tagName: "tagName", extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); }); describe("clear tag pb", () => { const removeTagPbMock = vi.spyOn(UserDal, "removeTagPb"); beforeEach(() => { removeTagPbMock.mockClear().mockResolvedValue(); }); it("should clear tag pb", async () => { //GIVEN const tagId = new ObjectId().toHexString(); //WHEN const { body } = await mockApp .delete(`/users/tags/${tagId}/personalBest`) .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Tag PB cleared", data: null, }); expect(removeTagPbMock).toHaveBeenLastCalledWith(uid, tagId); }); }); describe("update tag", () => { const editTagMock = vi.spyOn(UserDal, "editTag"); beforeEach(() => { editTagMock.mockClear().mockResolvedValue(); }); it("should update tag", async () => { //GIVEN const tagId = new ObjectId().toHexString(); //WHEN const { body } = await mockApp .patch(`/users/tags`) .set("Authorization", `Bearer ${uid}`) .send({ tagId, newName: "newName" }) .expect(200); //THEN expect(body).toEqual({ message: "Tag updated", data: null, }); expect(editTagMock).toHaveBeenCalledWith(uid, tagId, "newName"); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .patch(`/users/tags`) .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"tagId" Required', '"newName" Required'], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .patch(`/users/tags`) .set("Authorization", `Bearer ${uid}`) .send({ tagId: new ObjectId().toHexString(), newName: "newName", extra: "value", }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); }); describe("remove tag", () => { const removeTagMock = vi.spyOn(UserDal, "removeTag"); beforeEach(() => { removeTagMock.mockClear().mockResolvedValue(); }); it("should remove tag", async () => { //GIVEN const tagId = new ObjectId().toHexString(); //WHEN const { body } = await mockApp .delete(`/users/tags/${tagId}`) .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Tag deleted", data: null, }); expect(removeTagMock).toHaveBeenCalledWith(uid, tagId); }); }); describe("get tags", () => { const getTagsMock = vi.spyOn(UserDal, "getTags"); beforeEach(() => { getTagsMock.mockClear(); }); it("should get tags", async () => { //GIVEN const tagOne: UserDal.DBUserTag = { _id: new ObjectId(), name: "tagOne", personalBests: {} as any, }; const tagTwo: UserDal.DBUserTag = { _id: new ObjectId(), name: "tagOne", personalBests: {} as any, }; getTagsMock.mockResolvedValue([tagOne, tagTwo]); //WHEN const { body } = await mockApp .get("/users/tags") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Tags retrieved", data: [ { ...tagOne, _id: tagOne._id.toHexString() }, { ...tagTwo, _id: tagTwo._id.toHexString() }, ], }); expect(getTagsMock).toHaveBeenCalledWith(uid); }); }); describe("update lb memory", () => { const updateLbMemoryMock = vi.spyOn(UserDal, "updateLbMemory"); beforeEach(() => { updateLbMemoryMock.mockClear().mockResolvedValue(); }); it("should update lb", async () => { //WHEN const { body } = await mockApp .patch("/users/leaderboardMemory") .send({ mode: "time", mode2: "60", language: "english", rank: 7, }) .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Leaderboard memory updated", data: null, }); expect(updateLbMemoryMock).toHaveBeenCalledWith( uid, "time", "60", "english", 7, ); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .patch("/users/leaderboardMemory") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"mode" Required', '"mode2" Needs to be either a number, "zen" or "custom".', '"language" Required', '"rank" Required', ], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .patch("/users/leaderboardMemory") .set("Authorization", `Bearer ${uid}`) .send({ mode: "time", mode2: "60", language: "english", rank: 7, extra: "value", }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); }); describe("get custom themes", () => { const getThemesMock = vi.spyOn(UserDal, "getThemes"); beforeEach(() => { getThemesMock.mockClear(); }); it("should get custom themes", async () => { //GIVEN const themeOne: UserDal.DBCustomTheme = { _id: new ObjectId(), name: "themeOne", colors: new Array(10).fill("#000000") as any, }; const themeTwo: UserDal.DBCustomTheme = { _id: new ObjectId(), name: "themeTwo", colors: new Array(10).fill("#FFFFFF") as any, }; getThemesMock.mockResolvedValue([themeOne, themeTwo]); //WHEN const { body } = await mockApp .get("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Custom themes retrieved", data: [ { ...themeOne, _id: themeOne._id.toHexString() }, { ...themeTwo, _id: themeTwo._id.toHexString() }, ], }); }); }); describe("add custom theme", () => { const addThemeMock = vi.spyOn(UserDal, "addTheme"); beforeEach(() => { addThemeMock.mockClear(); }); it("should add", async () => { //GIVEN const addedTheme: UserDal.DBCustomTheme = { _id: new ObjectId(), name: "custom", colors: new Array(10).fill("#000000") as any, }; addThemeMock.mockResolvedValue(addedTheme); //WHEN const { body } = await mockApp .post("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .send({ name: "customTheme", colors: new Array(10).fill("#000000") as any, }) .expect(200); //THEN expect(body).toEqual({ message: "Custom theme added", data: { ...addedTheme, _id: addedTheme._id.toHexString() }, }); expect(addThemeMock).toHaveBeenCalledWith(uid, { name: "customTheme", colors: new Array(10).fill("#000000") as any, }); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .post("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"name" Required', '"colors" Required'], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .post("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .send({ name: "customTheme", colors: new Array(10).fill("#000000") as any, extra: "value", }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); it("should fail with invalid properties", async () => { //WHEN const { body } = await mockApp .post("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .send({ name: "customThemecustomThemecustomThemecustomTheme", colors: new Array(9).fill("#000") as any, }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"name" String must contain at most 16 character(s)', '"colors" Array must contain at least 10 element(s)', ], }); }); }); describe("remove custom theme", () => { const removeThemeMock = vi.spyOn(UserDal, "removeTheme"); beforeEach(() => { removeThemeMock.mockClear().mockResolvedValue(); }); it("should remove theme", async () => { //GIVEN const themeId = new ObjectId().toHexString(); //WHEN const { body } = await mockApp .delete("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .send({ themeId }) .expect(200); //THEN expect(body).toEqual({ message: "Custom theme removed", data: null, }); expect(removeThemeMock).toHaveBeenCalledWith(uid, themeId); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .delete("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"themeId" Required'], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .delete("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .send({ themeId: new ObjectId().toHexString(), extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); }); describe("edit custom theme", () => { const editThemeMock = vi.spyOn(UserDal, "editTheme"); beforeEach(() => { editThemeMock.mockClear().mockResolvedValue(); }); it("should edit custom theme", async () => { //GIVEN const themeId = new ObjectId().toHexString(); const theme = { name: "newName", colors: new Array(10).fill("#000000") as any, }; //WHEN const { body } = await mockApp .patch("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .send({ themeId, theme, }) .expect(200); //THEN expect(body).toEqual({ message: "Custom theme updated", data: null, }); expect(editThemeMock).toHaveBeenCalledWith(uid, themeId, theme); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .patch("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"themeId" Required', '"theme" Required'], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .patch("/users/customThemes") .set("Authorization", `Bearer ${uid}`) .send({ themeId: new ObjectId().toHexString(), theme: { name: "newName", colors: new Array(10).fill("#000000") as any, extra2: "value", }, extra: "value", }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ `"theme" Unrecognized key(s) in object: 'extra2'`, "Unrecognized key(s) in object: 'extra'", ], }); }); }); describe("get personal bests", () => { const getPBMock = vi.spyOn(UserDal, "getPersonalBests"); beforeEach(() => { getPBMock.mockClear(); }); it("should get pbs", async () => { //GIVEN const personalBest: PersonalBest = pb(15); getPBMock.mockResolvedValue(personalBest); //WHEN const { body } = await mockApp .get("/users/personalBests") .set("Authorization", `Bearer ${uid}`) .query({ mode: "time", mode2: "15" }) .expect(200); //THEN expect(body).toEqual({ message: "Personal bests retrieved", data: personalBest, }); expect(getPBMock).toHaveBeenCalledWith(uid, "time", "15"); }); it("should get pbs with ape key", async () => { //GIVEN await acceptApeKeys(true); const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); //WHEN await mockApp .get("/users/personalBests") .set("authorization", `ApeKey ${apeKey}`) .query({ mode: "time", mode2: "15" }) .expect(200); }); it("should fail without mandatory query parameters", async () => { //WHEN const { body } = await mockApp .get("/users/personalBests") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid query schema", validationErrors: ['"mode" Required'], }); }); it("should fail with unknown query parameters", async () => { //WHEN const { body } = await mockApp .get("/users/personalBests") .set("Authorization", `Bearer ${uid}`) .query({ mode: "time", mode2: "15", extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid query schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); it("should fail with invalid query parameters", async () => { //WHEN const { body } = await mockApp .get("/users/personalBests") .set("Authorization", `Bearer ${uid}`) .query({ mode: "mood", mode2: "happy" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid query schema", validationErrors: [ `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'mood'`, `"mode2" Needs to be a number or a number represented as a string e.g. "10".`, ], }); }); }); describe("get stats", () => { const getStatsMock = vi.spyOn(UserDal, "getStats"); beforeEach(() => { getStatsMock.mockClear(); }); it("should get stats", async () => { //GIVEN const stats: Pick< UserDal.DBUser, "startedTests" | "completedTests" | "timeTyping" > = { startedTests: 5, completedTests: 3, timeTyping: 42, }; getStatsMock.mockResolvedValue(stats); //WHEN const { body } = await mockApp .get("/users/stats") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Personal stats retrieved", data: stats, }); expect(getStatsMock).toHaveBeenCalledWith(uid); }); it("should get stats with ape key", async () => { //GIVEN await acceptApeKeys(true); const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); //WHEN await mockApp .get("/users/stats") .set("authorization", `ApeKey ${apeKey}`) .expect(200); }); }); describe("get favorite quotes", () => { const getFavoriteQuotesMock = vi.spyOn(UserDal, "getFavoriteQuotes"); beforeEach(() => { getFavoriteQuotesMock.mockClear(); }); it("should get favorite quites", async () => { //GIVEN const favoriteQuotes = { english: ["1", "2"], german: ["1", "3"], }; getFavoriteQuotesMock.mockResolvedValue(favoriteQuotes); //WHEN const { body } = await mockApp .get("/users/favoriteQuotes") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Favorite quotes retrieved", data: favoriteQuotes, }); expect(getFavoriteQuotesMock).toHaveBeenCalledWith(uid); }); }); describe("add favorite quotes", () => { const addFavoriteQuoteMock = vi.spyOn(UserDal, "addFavoriteQuote"); beforeEach(() => { addFavoriteQuoteMock.mockClear().mockResolvedValue(); }); it("should add", async () => { //WHEN const { body } = await mockApp .post("/users/favoriteQuotes") .set("Authorization", `Bearer ${uid}`) .send({ language: "english", quoteId: "7" }) .expect(200); //THEN expect(body).toEqual({ message: "Quote added to favorites", data: null, }); expect(addFavoriteQuoteMock).toHaveBeenCalledWith( uid, "english", "7", (await Configuration.getLiveConfiguration()).quotes.maxFavorites, ); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .post("/users/favoriteQuotes") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"language" Required', '"quoteId" Invalid input'], }); }); it("should fail unknown properties", async () => { //WHEN const { body } = await mockApp .post("/users/favoriteQuotes") .set("Authorization", `Bearer ${uid}`) .send({ language: "english", quoteId: "7", extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); }); describe("remove favorite quote", () => { const removeFavoriteQuoteMock = vi.spyOn(UserDal, "removeFavoriteQuote"); beforeEach(() => { removeFavoriteQuoteMock.mockClear().mockResolvedValue(); }); it("should remove quote", async () => { //WHEN const { body } = await mockApp .delete("/users/favoriteQuotes") .set("Authorization", `Bearer ${uid}`) .send({ language: "english", quoteId: "7" }) .expect(200); //THEN expect(body).toEqual({ message: "Quote removed from favorites", data: null, }); expect(removeFavoriteQuoteMock).toHaveBeenCalledWith(uid, "english", "7"); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .delete("/users/favoriteQuotes") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"language" Required', '"quoteId" Invalid input'], }); }); it("should fail unknown properties", async () => { //WHEN const { body } = await mockApp .delete("/users/favoriteQuotes") .set("Authorization", `Bearer ${uid}`) .send({ language: "english", quoteId: "7", extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); }); describe("get profile", () => { const getUserMock = vi.spyOn(UserDal, "getUser"); const getUserByNameMock = vi.spyOn(UserDal, "getUserByName"); const checkIfUserIsPremiumMock = vi.spyOn(UserDal, "checkIfUserIsPremium"); const leaderboardGetRankMock = vi.spyOn(LeaderboardDal, "getRank"); const leaderboardGetCountMock = vi.spyOn(LeaderboardDal, "getCount"); const foundUser: Partial = { _id: new ObjectId(), uid: new ObjectId().toHexString(), name: "bob", banned: false, inventory: { badges: [{ id: 1, selected: true }, { id: 2 }] }, profileDetails: { bio: "bio", keyboard: "keyboard", socialProfiles: { twitter: "twitter", github: "github", }, }, personalBests: { time: { "15": [pb(15), pb(16)], "30": [pb(30), pb(31)], "60": [pb(60), pb(61)], "120": [pb(120), pb(121)], "42": [pb(42), pb(43)], }, words: { "10": [pb(10), pb(11)], "25": [pb(25), pb(26)], "50": [pb(50), pb(51)], "100": [pb(100), pb(101)], "42": [pb(42), pb(43)], }, custom: {}, zen: {}, quote: {}, }, completedTests: 23, startedTests: 42, timeTyping: 234, addedAt: 1000, discordId: "discordId", discordAvatar: "discordAvatar", xp: 10, streak: { length: 2, lastResultTimestamp: 2000, maxLength: 5 }, lbOptOut: false, bananas: 47, //should get removed testActivity: { "2024": fillYearWithDay(94), }, }; beforeEach(async () => { getUserMock.mockClear(); getUserByNameMock.mockClear(); checkIfUserIsPremiumMock.mockClear().mockResolvedValue(true); leaderboardGetRankMock.mockClear(); leaderboardGetCountMock.mockClear(); await enableProfiles(true); }); it("should get by name without authentication", async () => { //GIVEN getUserByNameMock.mockResolvedValue(foundUser as any); const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry; leaderboardGetRankMock.mockResolvedValue(rank); leaderboardGetCountMock.mockResolvedValue(100); //WHEN const { body } = await mockApp.get("/users/bob/profile").expect(200); //THEN expect(body).toEqual({ message: "Profile retrieved", data: { uid: foundUser.uid, name: "bob", banned: false, addedAt: 1000, typingStats: { completedTests: 23, startedTests: 42, timeTyping: 234, }, personalBests: { time: { "15": foundUser.personalBests?.time["15"], "30": foundUser.personalBests?.time["30"], "60": foundUser.personalBests?.time["60"], "120": foundUser.personalBests?.time["120"], }, words: { "10": foundUser.personalBests?.words["10"], "25": foundUser.personalBests?.words["25"], "50": foundUser.personalBests?.words["50"], "100": foundUser.personalBests?.words["100"], }, }, discordId: "discordId", discordAvatar: "discordAvatar", xp: 10, streak: 2, maxStreak: 5, lbOptOut: false, isPremium: true, allTimeLbs: { time: { "15": { english: { count: 100, rank: 24 } }, "60": { english: { count: 100, rank: 24 } }, }, }, inventory: foundUser.inventory, details: foundUser.profileDetails, }, }); expect(getUserByNameMock).toHaveBeenCalledWith("bob", "get user profile"); expect(getUserMock).not.toHaveBeenCalled(); }); it("should get testActivity if enabled", async () => { //GIVEN vi.useFakeTimers().setSystemTime(1712102400000); getUserByNameMock.mockResolvedValue({ ...foundUser, profileDetails: { showActivityOnPublicProfile: true }, } as any); const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry; leaderboardGetRankMock.mockResolvedValue(rank); leaderboardGetCountMock.mockResolvedValue(100); //WHEN const { body } = await mockApp.get("/users/bob/profile").expect(200); //THEN expect(body.data.testActivity).toEqual( expect.objectContaining({ lastDay: 1712102400000, testsByDays: expect.arrayContaining([]), }), ); }); it("should not get testActivity if disabled", async () => { //GIVEN vi.useFakeTimers().setSystemTime(1712102400000); getUserByNameMock.mockResolvedValue({ ...foundUser, profileDetails: { showActivityOnPublicProfile: false }, } as any); const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry; leaderboardGetRankMock.mockResolvedValue(rank); leaderboardGetCountMock.mockResolvedValue(100); //WHEN const { body } = await mockApp.get("/users/bob/profile").expect(200); //THEN expect(body.data.testActivity).toBeUndefined(); }); it("should get base profile for banned user", async () => { //GIVEN getUserByNameMock.mockResolvedValue({ ...foundUser, banned: true, } as any); const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry; leaderboardGetRankMock.mockResolvedValue(rank); leaderboardGetCountMock.mockResolvedValue(100); //WHEN const { body } = await mockApp.get("/users/bob/profile").expect(200); //THEN expect(body).toEqual({ message: "Profile retrived: banned user", data: { name: "bob", banned: true, addedAt: 1000, typingStats: { completedTests: 23, startedTests: 42, timeTyping: 234, }, personalBests: { time: { "15": foundUser.personalBests?.time["15"], "30": foundUser.personalBests?.time["30"], "60": foundUser.personalBests?.time["60"], "120": foundUser.personalBests?.time["120"], }, words: { "10": foundUser.personalBests?.words["10"], "25": foundUser.personalBests?.words["25"], "50": foundUser.personalBests?.words["50"], "100": foundUser.personalBests?.words["100"], }, }, discordId: "discordId", discordAvatar: "discordAvatar", xp: 10, streak: 2, maxStreak: 5, lbOptOut: false, isPremium: true, }, }); expect(getUserByNameMock).toHaveBeenCalledWith("bob", "get user profile"); expect(getUserMock).not.toHaveBeenCalled(); }); it("should get by uid without authentication", async () => { //GIVEN const uid = foundUser.uid; getUserMock.mockResolvedValue(foundUser as any); const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry; leaderboardGetRankMock.mockResolvedValue(rank); leaderboardGetCountMock.mockResolvedValue(100); //WHEN const { body } = await mockApp .get(`/users/${uid}/profile`) .query({ isUid: "" }) .expect(200); //THEN expect(body).toEqual({ message: "Profile retrieved", data: expect.objectContaining({ uid: foundUser.uid, }), }); expect(getUserByNameMock).not.toHaveBeenCalled(); expect(getUserMock).toHaveBeenCalledWith(uid, "get user profile"); }); it("should fail if feature is disabled", async () => { //GIVEN await enableProfiles(false); //WHEN const { body } = await mockApp.get(`/users/bob/profile`).expect(503); //THEN expect(body.message).toEqual("Profiles are not available at this time"); }); }); describe("update profile", () => { const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); const updateProfileMock = vi.spyOn(UserDal, "updateProfile"); beforeEach(async () => { getPartialUserMock.mockClear().mockResolvedValue({ inventory: { badges: [{ id: 4, selected: true }, { id: 2 }, { id: 3 }], }, } as any); updateProfileMock.mockClear().mockResolvedValue(); await enableProfiles(true); }); it("should update", async () => { //GIVEN const newProfile = { bio: "newBio", keyboard: "newKeyboard", socialProfiles: { github: "github", twitter: "twitter", website: "https://monkeytype.com", }, showActivityOnPublicProfile: false, }; //WHEN const { body } = await mockApp .patch("/users/profile") .set("Authorization", `Bearer ${uid}`) .send({ ...newProfile, selectedBadgeId: 2, }) .expect(200); //THEN expect(body).toEqual({ message: "Profile updated", data: newProfile, }); expect(updateProfileMock).toHaveBeenCalledWith( uid, { bio: "newBio", keyboard: "newKeyboard", socialProfiles: { github: "github", twitter: "twitter", website: "https://monkeytype.com", }, showActivityOnPublicProfile: false, }, { badges: [{ id: 4 }, { id: 2, selected: true }, { id: 3 }], }, ); }); it("should update with empty strings", async () => { //GIVEN const newProfile = { bio: "", keyboard: "", socialProfiles: { github: "", twitter: "", website: "", }, }; //WHEN const { body } = await mockApp .patch("/users/profile") .set("Authorization", `Bearer ${uid}`) .send({ ...newProfile, selectedBadgeId: -1, }) .expect(200); //THEN expect(body).toEqual({ message: "Profile updated", data: newProfile, }); expect(updateProfileMock).toHaveBeenCalledWith( uid, { bio: "", keyboard: "", socialProfiles: { github: "", twitter: "", website: "", }, }, { badges: [{ id: 4 }, { id: 2 }, { id: 3 }], }, ); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .patch("/users/profile") .set("Authorization", `Bearer ${uid}`) .send({ extra: "value", socialProfiles: { extra2: "value", }, }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ `"socialProfiles" Unrecognized key(s) in object: 'extra2'`, "Unrecognized key(s) in object: 'extra'", ], }); }); it("should sanitize inputs", async () => { //WHEN await mockApp .patch("/users/profile") .set("Authorization", `Bearer ${uid}`) .send({ bio: "Line1\n\n\nLine2\n\n\n\nLine3", keyboard: " string with many spaces ", }) .expect(200); //THEN expect(updateProfileMock).toHaveBeenCalledWith( uid, { bio: "Line1\n\nLine2\n\nLine3", keyboard: "string with many spaces", socialProfiles: {}, }, expect.objectContaining({}), ); }); it("should fail with disallowed word", async () => { //WHEN const { body } = await mockApp .patch("/users/profile") .set("Authorization", `Bearer ${uid}`) .send({ bio: "miodec", keyboard: "miodec", socialProfiles: { twitter: "miodec", github: "miodec", website: "https://i-luv-miodec.com", }, }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"bio" Disallowed word detected. Please remove it. If you believe this is a mistake, please contact us (miodec).', '"keyboard" Disallowed word detected. Please remove it. If you believe this is a mistake, please contact us (miodec).', '"socialProfiles.twitter" Disallowed word detected. Please remove it. If you believe this is a mistake, please contact us (miodec).', '"socialProfiles.github" Disallowed word detected. Please remove it. If you believe this is a mistake, please contact us (miodec).', '"socialProfiles.website" Disallowed word detected. Please remove it. If you believe this is a mistake, please contact us (https://i-luv-miodec.com).', ], }); }); it("should fail with properties exceeding max lengths", async () => { //WHEN const { body } = await mockApp .patch("/users/profile") .set("Authorization", `Bearer ${uid}`) .send({ bio: new Array(251).fill("x").join(""), keyboard: new Array(76).fill("x").join(""), socialProfiles: { twitter: new Array(21).fill("x").join(""), github: new Array(40).fill("x").join(""), website: "https://" + new Array(201 - "https://".length).fill("x").join(""), }, }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"bio" String must contain at most 250 character(s)', '"keyboard" String must contain at most 75 character(s)', '"socialProfiles.twitter" String must contain at most 20 character(s)', '"socialProfiles.github" String must contain at most 39 character(s)', '"socialProfiles.website" String must contain at most 200 character(s)', ], }); }); it("should fail with website not using https", async () => { //WHEN const { body } = await mockApp .patch("/users/profile") .set("Authorization", `Bearer ${uid}`) .send({ socialProfiles: { website: "http://monkeytype.com", }, }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"socialProfiles.website" Invalid input: must start with "https://"', ], }); }); it("should fail if feature is disabled", async () => { //GIVEN await enableProfiles(false); //WHEN const { body } = await mockApp .patch("/users/profile") .set("Authorization", `Bearer ${uid}`) .send({}) .expect(503); //THEN expect(body.message).toEqual("Profiles are not available at this time"); }); }); describe("get inbox", () => { const getInboxMock = vi.spyOn(UserDal, "getInbox"); beforeEach(async () => { getInboxMock.mockClear(); await enableInbox(true); }); it("should get inbox", async () => { //GIVEN const mailOne: MonkeyMail = { id: randomUUID(), subject: "subjectOne", body: "bodyOne", timestamp: 100, read: false, rewards: [], }; const mailTwo: MonkeyMail = { id: randomUUID(), subject: "subjectTwo", body: "bodyTwo", timestamp: 100, read: false, rewards: [], }; getInboxMock.mockResolvedValue([mailOne, mailTwo]); //WHEN const { body } = await mockApp .get("/users/inbox") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Inbox retrieved", data: { inbox: [mailOne, mailTwo], maxMail: (await Configuration.getLiveConfiguration()).users.inbox .maxMail, }, }); expect(getInboxMock).toHaveBeenCalledWith(uid); }); it("should fail if feature is disabled", async () => { //GIVEN await enableInbox(false); //WHEN const { body } = await mockApp .get("/users/inbox") .set("Authorization", `Bearer ${uid}`) .expect(503); //THEN expect(body.message).toEqual("Your inbox is not available at this time."); }); }); describe("update inbox", () => { const updateInboxMock = vi.spyOn(UserDal, "updateInbox"); const mailIdOne = randomUUID(); const mailIdTwo = randomUUID(); beforeEach(async () => { updateInboxMock.mockClear().mockResolvedValue(); await enableInbox(true); }); it("should update", async () => { //WHEN const { body } = await mockApp .patch("/users/inbox") .set("Authorization", `Bearer ${uid}`) .send({ mailIdsToDelete: [mailIdOne], mailIdsToMarkRead: [mailIdOne, mailIdTwo], }) .expect(200); //THEN expect(body).toEqual({ message: "Inbox updated", data: null, }); expect(updateInboxMock).toHaveBeenCalledWith( uid, [mailIdOne, mailIdTwo], [mailIdOne], ); }); it("should update without body", async () => { //WHEN const { body } = await mockApp .patch("/users/inbox") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "Inbox updated", data: null, }); expect(updateInboxMock).toHaveBeenCalledWith(uid, [], []); }); it("should fail with empty arrays", async () => { //WHEN const { body } = await mockApp .patch("/users/inbox") .set("Authorization", `Bearer ${uid}`) .send({ mailIdsToDelete: [], mailIdsToMarkRead: [], }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"mailIdsToDelete" Array must contain at least 1 element(s)', '"mailIdsToMarkRead" Array must contain at least 1 element(s)', ], }); }); it("should fail if feature is disabled", async () => { //GIVEN await enableInbox(false); //WHEN const { body } = await mockApp .patch("/users/inbox") .set("Authorization", `Bearer ${uid}`) .expect(503); //THEN expect(body.message).toEqual("Your inbox is not available at this time."); }); }); describe("report user", () => { const createReportMock = vi.spyOn(ReportDal, "createReport"); const verifyCaptchaMock = vi.spyOn(Captcha, "verify"); const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); //todo replace with getPartialUser beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(125000); createReportMock.mockClear().mockResolvedValue(); verifyCaptchaMock.mockClear().mockResolvedValue(true); getPartialUserMock.mockClear().mockResolvedValue({} as any); await enableReporting(true); }); afterEach(() => { vi.useRealTimers(); }); it("should report", async () => { //WHEN const uidToReport = new ObjectId().toHexString(); const { body } = await mockApp .post("/users/report") .set("Authorization", `Bearer ${uid}`) .send({ uid: uidToReport, reason: "Suspected cheating", comment: "comment", captcha: "captcha", }) .expect(200); //THEN expect(body).toEqual({ message: "User reported", data: null, }); expect(createReportMock).toHaveBeenCalledWith( expect.objectContaining({ type: "user", timestamp: 125000, uid, contentId: uidToReport, reason: "Suspected cheating", comment: "comment", }), (await Configuration.getLiveConfiguration()).quotes.reporting .maxReports, (await Configuration.getLiveConfiguration()).quotes.reporting .contentReportLimit, ); expect(verifyCaptchaMock).toHaveBeenCalledWith("captcha"); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .post("/users/report") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"uid" Required', '"reason" Required', '"captcha" Required', ], }); }); it("should fail with unknown properties", async () => { //WHEN const { body } = await mockApp .post("/users/report") .set("Authorization", `Bearer ${uid}`) .send({ uid: new ObjectId().toHexString(), reason: "Suspected cheating", comment: "comment", captcha: "captcha", extra: "value", }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); it("should fail with invalid captcha", async () => { //GIVEN verifyCaptchaMock.mockResolvedValue(false); //WHEN const { body } = await mockApp .post("/users/report") .set("Authorization", `Bearer ${uid}`) .send({ uid: new ObjectId().toHexString(), reason: "Suspected cheating", comment: "comment", captcha: "captcha", }) .expect(422); //THEN expect(body.message).toEqual("Captcha challenge failed"); /* TODO expect(body).toEqual({}); */ }); it("should fail with invalid properties", async () => { //WHEN const { body } = await mockApp .post("/users/report") .set("Authorization", `Bearer ${uid}`) .send({ uid: new Array(51).fill("x").join(""), reason: "unfriendly", comment: new Array(251).fill("x").join(""), captcha: "captcha", }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ `"reason" Invalid enum value. Expected 'Inappropriate name' | 'Inappropriate bio' | 'Inappropriate social links' | 'Suspected cheating', received 'unfriendly'`, '"comment" String must contain at most 250 character(s)', ], }); }); it("should fail if user can not report", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ canReport: false } as any); //WHEN const { body } = await mockApp .post("/users/report") .set("Authorization", `Bearer ${uid}`) .send({ uid: new ObjectId().toHexString(), reason: "Suspected cheating", comment: "comment", captcha: "captcha", }) .expect(403); //THEN expect(body.message).toEqual("You don't have permission to do this."); }); it("should fail if feature is disabled", async () => { //GIVEN await enableReporting(false); //WHEN const { body } = await mockApp .post("/users/report") .set("Authorization", `Bearer ${uid}`) .send({ uid: new ObjectId().toHexString(), reason: "Suspected cheating", comment: "comment", captcha: "captcha", }) .expect(503); //THEN expect(body.message).toEqual("User reporting is unavailable."); }); }); describe("set streak hour offset", () => { const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); const setStreakHourOffsetMock = vi.spyOn(UserDal, "setStreakHourOffset"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { getPartialUserMock.mockClear().mockResolvedValue({} as any); setStreakHourOffsetMock.mockClear().mockResolvedValue(); addImportantLogMock.mockClear().mockResolvedValue(); }); it("should set", async () => { //WHEN const { body } = await mockApp .post("/users/setStreakHourOffset") .set("Authorization", `Bearer ${uid}`) .send({ hourOffset: -2 }) .expect(200); //THEN expect(body).toEqual({ message: "Streak hour offset set", data: null, }); expect(setStreakHourOffsetMock).toHaveBeenCalledWith(uid, -2); expect(addImportantLogMock).toHaveBeenCalledWith( "user_streak_hour_offset_set", { hourOffset: -2 }, uid, ); }); it("should fail if offset already set", async () => { //GIVEN getPartialUserMock.mockResolvedValue({ streak: { hourOffset: -2 }, } as any); //WHEN const { body } = await mockApp .post("/users/setStreakHourOffset") .set("Authorization", `Bearer ${uid}`) .send({ hourOffset: -2 }) .expect(403); //THEN expect(body.message).toEqual("Streak hour offset already set"); expect(setStreakHourOffsetMock).not.toHaveBeenCalled(); expect(addImportantLogMock).not.toHaveBeenCalled(); }); it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp .post("/users/setStreakHourOffset") .set("Authorization", `Bearer ${uid}`) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"hourOffset" Required'], }); }); it("should fail with invalid offset", async () => { await mockApp .post("/users/setStreakHourOffset") .set("Authorization", `Bearer ${uid}`) .send({ hourOffset: -12 }) .expect(422); await mockApp .post("/users/setStreakHourOffset") .set("Authorization", `Bearer ${uid}`) .send({ hourOffset: 13 }) .expect(422); await mockApp .post("/users/setStreakHourOffset") .set("Authorization", `Bearer ${uid}`) .send({ hourOffset: "UTC-8" }) .expect(422); }); }); describe("revoke all token", () => { const removeTokensByUidMock = vi.spyOn(AuthUtils, "revokeTokensByUid"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { removeTokensByUidMock.mockClear().mockResolvedValue(); addImportantLogMock.mockClear().mockResolvedValue(); }); it("should revoke all tokens", async () => { //WHEN const { body } = await mockApp .post("/users/revokeAllTokens") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body).toEqual({ message: "All tokens revoked", data: null, }); expect(removeTokensByUidMock).toHaveBeenCalledWith(uid); expect(addImportantLogMock).toHaveBeenCalledWith( "user_tokens_revoked", "", uid, ); }); }); describe("getCurrentTestActivity", () => { const getUserMock = vi.spyOn(UserDal, "getPartialUser"); afterEach(() => { getUserMock.mockClear(); }); it("gets", async () => { //GIVEN vi.useFakeTimers().setSystemTime(1712102400000); const user = { uid: uid, testActivity: { "2024": fillYearWithDay(94), }, } as Partial as UserDal.DBUser; getUserMock.mockResolvedValue(user); //WHEN const result = await mockApp .get("/users/currentTestActivity") .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(result.body.data.lastDay).toEqual(1712102400000); const testsByDays = result.body.data.testsByDays; expect(testsByDays).toHaveLength(372); expect(testsByDays[6]).toEqual(null); //2023-04-04 expect(testsByDays[277]).toEqual(null); //2023-12-31 expect(testsByDays[278]).toEqual(1); //2024-01-01 expect(testsByDays[371]).toEqual(94); //2024-01 }); }); describe("getStreak", () => { const getUserMock = vi.spyOn(UserDal, "getPartialUser"); afterEach(() => { getUserMock.mockClear(); }); it("gets", async () => { //GIVEN const user = { uid: uid, streak: { lastResultTimestamp: 1712102400000, length: 42, maxLength: 1024, hourOffset: 2, }, } as Partial as UserDal.DBUser; getUserMock.mockResolvedValue(user); //WHEN const result = await mockApp .get("/users/streak") .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN const streak: UserStreak = result.body.data; expect(streak).toEqual({ lastResultTimestamp: 1712102400000, length: 42, maxLength: 1024, hourOffset: 2, }); }); }); describe("get friends", () => { const getFriendsMock = vi.spyOn(UserDal, "getFriends"); beforeEach(() => { enableConnectionsEndpoints(true); getFriendsMock.mockClear(); }); it("gets with premium enabled", async () => { //GIVEN enablePremiumFeatures(true); const friend: UserDal.DBFriend = { name: "Bob", isPremium: true, } as any; getFriendsMock.mockResolvedValue([friend]); //WHEN const { body } = await mockApp .get("/users/friends") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body.data).toEqual([{ name: "Bob", isPremium: true }]); }); it("gets with premium disabled", async () => { //GIVEN enablePremiumFeatures(false); const friend: UserDal.DBFriend = { name: "Bob", isPremium: true, } as any; getFriendsMock.mockResolvedValue([friend]); //WHEN const { body } = await mockApp .get("/users/friends") .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN expect(body.data).toEqual([{ name: "Bob" }]); }); it("should fail if friends endpoints are disabled", async () => { await expectFailForDisabledEndpoint( mockApp.get("/users/friends").set("Authorization", `Bearer ${uid}`), ); }); it("should fail without authentication", async () => { await mockApp.get("/users/friends").expect(401); }); }); }); function fillYearWithDay(days: number): number[] { const result: number[] = []; for (let i = 0; i < days; i++) { result.push(i + 1); } return result; } async function enablePremiumFeatures(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.users.premium = { ...mockConfig.users.premium, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function enableSignup(signUp: boolean): Promise { const mockConfig = await configuration; mockConfig.users = { ...mockConfig.users, signUp }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function enableDiscordIntegration(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.users.discordIntegration = { ...mockConfig.users.discordIntegration, enabled, }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function enableResultFilterPresets(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.results.filterPresets = { ...mockConfig.results.filterPresets, enabled, }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function acceptApeKeys(acceptKeys: boolean): Promise { const mockConfig = await configuration; mockConfig.apeKeys = { ...mockConfig.apeKeys, acceptKeys }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function enableProfiles(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.users.profiles = { ...mockConfig.users.profiles, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function enableInbox(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.users.inbox = { ...mockConfig.users.inbox, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function enableReporting(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.quotes.reporting = { ...mockConfig.quotes.reporting, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function enableConnectionsEndpoints(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.connections = { ...mockConfig.connections, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function expectFailForDisabledEndpoint(call: Test): Promise { await enableConnectionsEndpoints(false); const { body } = await call.expect(503); expect(body.message).toEqual("Connections are not available at this time."); }