Files
test/backend/__tests__/api/controllers/user.spec.ts
Benjamin Falch 2bc741fb78
Some checks failed
Mark Stale PRs / stale (push) Has been cancelled
adding monkeytype
2026-04-23 13:53:44 +02:00

4057 lines
118 KiB
TypeScript

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<UserDal.DBUser> 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<UserDal.DBUser> 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<UserDal.DBUser> 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<UserDal.DBUser> 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<UserDal.DBUser> 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<UserDal.DBUser> 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<UserDal.DBUser> 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<UserDal.DBUser> = {
_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<UserDal.DBUser> 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<UserDal.DBUser> 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<void> {
const mockConfig = await configuration;
mockConfig.users.premium = { ...mockConfig.users.premium, enabled };
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig,
);
}
async function enableSignup(signUp: boolean): Promise<void> {
const mockConfig = await configuration;
mockConfig.users = { ...mockConfig.users, signUp };
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig,
);
}
async function enableDiscordIntegration(enabled: boolean): Promise<void> {
const mockConfig = await configuration;
mockConfig.users.discordIntegration = {
...mockConfig.users.discordIntegration,
enabled,
};
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig,
);
}
async function enableResultFilterPresets(enabled: boolean): Promise<void> {
const mockConfig = await configuration;
mockConfig.results.filterPresets = {
...mockConfig.results.filterPresets,
enabled,
};
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig,
);
}
async function acceptApeKeys(acceptKeys: boolean): Promise<void> {
const mockConfig = await configuration;
mockConfig.apeKeys = { ...mockConfig.apeKeys, acceptKeys };
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig,
);
}
async function enableProfiles(enabled: boolean): Promise<void> {
const mockConfig = await configuration;
mockConfig.users.profiles = { ...mockConfig.users.profiles, enabled };
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig,
);
}
async function enableInbox(enabled: boolean): Promise<void> {
const mockConfig = await configuration;
mockConfig.users.inbox = { ...mockConfig.users.inbox, enabled };
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig,
);
}
async function enableReporting(enabled: boolean): Promise<void> {
const mockConfig = await configuration;
mockConfig.quotes.reporting = { ...mockConfig.quotes.reporting, enabled };
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig,
);
}
async function enableConnectionsEndpoints(enabled: boolean): Promise<void> {
const mockConfig = await configuration;
mockConfig.connections = { ...mockConfig.connections, enabled };
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig,
);
}
async function expectFailForDisabledEndpoint(call: Test): Promise<void> {
await enableConnectionsEndpoints(false);
const { body } = await call.expect(503);
expect(body.message).toEqual("Connections are not available at this time.");
}