This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as Migration from "../../../__migration__/testActivity";
|
||||
import * as UserTestData from "../../__testData__/users";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import * as ResultDal from "../../../src/dal/result";
|
||||
import { DBResult } from "../../../src/utils/result";
|
||||
|
||||
describe("testActivity migration", () => {
|
||||
it("migrates users without results", async () => {
|
||||
//given
|
||||
const user1 = await UserTestData.createUser();
|
||||
const user2 = await UserTestData.createUser();
|
||||
|
||||
//when
|
||||
await Migration.migrate();
|
||||
|
||||
//then
|
||||
const readUser1 = await UserDal.getUser(user1.uid, "");
|
||||
expect(readUser1.testActivity).toEqual({});
|
||||
|
||||
const readUser2 = await UserDal.getUser(user2.uid, "");
|
||||
expect(readUser2.testActivity).toEqual({});
|
||||
});
|
||||
|
||||
it("migrates users with results", async () => {
|
||||
//given
|
||||
const withResults = await UserTestData.createUserWithoutMigration();
|
||||
const withoutResults = await UserTestData.createUserWithoutMigration();
|
||||
|
||||
const uid = withResults.uid;
|
||||
|
||||
//2023-01-02
|
||||
await createResult(uid, 1672621200000);
|
||||
|
||||
//2024-01-01
|
||||
await createResult(uid, 1704070800000);
|
||||
await createResult(uid, 1704070800000 + 3600000);
|
||||
await createResult(uid, 1704070800000 + 3600000);
|
||||
|
||||
//2024-01-02
|
||||
await createResult(uid, 1704157200000);
|
||||
//2024-01-03
|
||||
await createResult(uid, 1704243600000);
|
||||
|
||||
//when
|
||||
await Migration.migrate();
|
||||
|
||||
//then
|
||||
const readWithResults = await UserDal.getUser(withResults.uid, "");
|
||||
expect(readWithResults.testActivity).toEqual({
|
||||
"2023": [null, 1],
|
||||
"2024": [3, 1, 1],
|
||||
});
|
||||
|
||||
const readWithoutResults = await UserDal.getUser(withoutResults.uid, "");
|
||||
expect(readWithoutResults.testActivity).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
async function createResult(uid: string, timestamp: number): Promise<void> {
|
||||
await ResultDal.addResult(uid, {
|
||||
wpm: 0,
|
||||
rawWpm: 0,
|
||||
charStats: [1, 2, 3, 4],
|
||||
acc: 0,
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
timestamp: timestamp,
|
||||
testDuration: 1,
|
||||
consistency: 0,
|
||||
keyConsistency: 0,
|
||||
chartData: "toolong",
|
||||
name: "",
|
||||
} as unknown as DBResult);
|
||||
}
|
||||
30
backend/__tests__/__integration__/dal/admin-uids.spec.ts
Normal file
30
backend/__tests__/__integration__/dal/admin-uids.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as AdminUidsDal from "../../../src/dal/admin-uids";
|
||||
|
||||
describe("AdminUidsDal", () => {
|
||||
describe("isAdmin", () => {
|
||||
it("should return true for existing admin user", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
await AdminUidsDal.getCollection().insertOne({
|
||||
_id: new ObjectId(),
|
||||
uid: uid,
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
expect(await AdminUidsDal.isAdmin(uid)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-existing admin user", async () => {
|
||||
//GIVEN
|
||||
await AdminUidsDal.getCollection().insertOne({
|
||||
_id: new ObjectId(),
|
||||
uid: "admin",
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
expect(await AdminUidsDal.isAdmin("regularUser")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
107
backend/__tests__/__integration__/dal/ape-keys.spec.ts
Normal file
107
backend/__tests__/__integration__/dal/ape-keys.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ObjectId } from "mongodb";
|
||||
import {
|
||||
addApeKey,
|
||||
DBApeKey,
|
||||
editApeKey,
|
||||
getApeKey,
|
||||
updateLastUsedOn,
|
||||
} from "../../../src/dal/ape-keys";
|
||||
|
||||
describe("ApeKeysDal", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
describe("addApeKey", () => {
|
||||
it("should be able to add a new ape key", async () => {
|
||||
const apeKey = buildApeKey();
|
||||
|
||||
const apeKeyId = await addApeKey(apeKey);
|
||||
|
||||
expect(apeKeyId).toBe(apeKey._id.toHexString());
|
||||
|
||||
const read = await getApeKey(apeKeyId);
|
||||
expect(read).toEqual({
|
||||
...apeKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("editApeKey", () => {
|
||||
it("should edit name of an existing ape key", async () => {
|
||||
//GIVEN
|
||||
const apeKey = buildApeKey({ useCount: 5, enabled: true });
|
||||
const apeKeyId = await addApeKey(apeKey);
|
||||
|
||||
//WHEN
|
||||
const newName = "new name";
|
||||
await editApeKey(apeKey.uid, apeKeyId, newName, undefined);
|
||||
|
||||
//THENa
|
||||
const readAfterEdit = (await getApeKey(apeKeyId)) as DBApeKey;
|
||||
expect(readAfterEdit).toEqual({
|
||||
...apeKey,
|
||||
name: newName,
|
||||
modifiedOn: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
it("should edit enabled of an existing ape key", async () => {
|
||||
//GIVEN
|
||||
const apeKey = buildApeKey({ useCount: 5, enabled: true });
|
||||
const apeKeyId = await addApeKey(apeKey);
|
||||
|
||||
//WHEN
|
||||
|
||||
await editApeKey(apeKey.uid, apeKeyId, undefined, false);
|
||||
|
||||
//THEN
|
||||
const readAfterEdit = (await getApeKey(apeKeyId)) as DBApeKey;
|
||||
expect(readAfterEdit).toEqual({
|
||||
...apeKey,
|
||||
enabled: false,
|
||||
modifiedOn: Date.now(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateLastUsedOn", () => {
|
||||
it("should update lastUsedOn and increment useCount when editing with lastUsedOn", async () => {
|
||||
//GIVEN
|
||||
const apeKey = buildApeKey({
|
||||
useCount: 5,
|
||||
lastUsedOn: 42,
|
||||
});
|
||||
const apeKeyId = await addApeKey(apeKey);
|
||||
|
||||
//WHEN
|
||||
await updateLastUsedOn(apeKey.uid, apeKeyId);
|
||||
await updateLastUsedOn(apeKey.uid, apeKeyId);
|
||||
|
||||
//THENa
|
||||
const readAfterEdit = (await getApeKey(apeKeyId)) as DBApeKey;
|
||||
expect(readAfterEdit).toEqual({
|
||||
...apeKey,
|
||||
modifiedOn: readAfterEdit.modifiedOn,
|
||||
lastUsedOn: Date.now(),
|
||||
useCount: 5 + 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildApeKey(overrides: Partial<DBApeKey> = {}): DBApeKey {
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
uid: "123",
|
||||
name: "test",
|
||||
hash: "12345",
|
||||
createdOn: Date.now(),
|
||||
modifiedOn: Date.now(),
|
||||
lastUsedOn: Date.now(),
|
||||
useCount: 0,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
363
backend/__tests__/__integration__/dal/blocklist.spec.ts
Normal file
363
backend/__tests__/__integration__/dal/blocklist.spec.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as BlacklistDal from "../../../src/dal/blocklist";
|
||||
|
||||
describe("BlocklistDal", () => {
|
||||
beforeAll(async () => {
|
||||
await BlacklistDal.createIndicies();
|
||||
});
|
||||
describe("add", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it("adds user", async () => {
|
||||
//GIVEN
|
||||
const now = 1715082588;
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.add({ name, email });
|
||||
|
||||
//THEN
|
||||
await expect(
|
||||
BlacklistDal.getCollection().findOne({
|
||||
emailHash: BlacklistDal.hash(email),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
emailHash: BlacklistDal.hash(email),
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
BlacklistDal.getCollection().findOne({
|
||||
usernameHash: BlacklistDal.hash(name),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
usernameHash: BlacklistDal.hash(name),
|
||||
timestamp: now,
|
||||
});
|
||||
});
|
||||
it("adds user with discordId", async () => {
|
||||
//GIVEN
|
||||
const now = 1715082588;
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
const discordId = `${name}DiscordId`;
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.add({ name, email, discordId });
|
||||
|
||||
//THEN
|
||||
await expect(
|
||||
BlacklistDal.getCollection().findOne({
|
||||
discordIdHash: BlacklistDal.hash(discordId),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
discordIdHash: BlacklistDal.hash(discordId),
|
||||
timestamp: now,
|
||||
});
|
||||
});
|
||||
it("adds user should not create duplicate name", async () => {
|
||||
//GIVEN
|
||||
const now = 1715082588;
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
const email2 = `${name}@otherdomain.com`;
|
||||
await BlacklistDal.add({ name, email });
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.add({ name, email: email2 });
|
||||
|
||||
//THEN
|
||||
await expect(
|
||||
BlacklistDal.getCollection()
|
||||
.find({
|
||||
usernameHash: BlacklistDal.hash(name),
|
||||
})
|
||||
.toArray(),
|
||||
).resolves.toHaveLength(1);
|
||||
await expect(
|
||||
BlacklistDal.getCollection()
|
||||
.find({
|
||||
emailHash: BlacklistDal.hash(email),
|
||||
})
|
||||
.toArray(),
|
||||
).resolves.toHaveLength(1);
|
||||
await expect(
|
||||
BlacklistDal.getCollection()
|
||||
.find({
|
||||
emailHash: BlacklistDal.hash(email2),
|
||||
})
|
||||
.toArray(),
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
it("adds user should not create duplicate email", async () => {
|
||||
//GIVEN
|
||||
const now = 1715082588;
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
const name2 = "user" + new ObjectId().toHexString();
|
||||
await BlacklistDal.add({ name, email });
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.add({ name: name2, email });
|
||||
|
||||
//THEN
|
||||
await expect(
|
||||
BlacklistDal.getCollection()
|
||||
.find({
|
||||
emailHash: BlacklistDal.hash(email),
|
||||
})
|
||||
.toArray(),
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
it("adds user should not create duplicate discordId", async () => {
|
||||
//GIVEN
|
||||
const now = 1715082588;
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const name2 = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
const discordId = `${name}DiscordId`;
|
||||
|
||||
await BlacklistDal.add({ name, email, discordId });
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.add({ name: name2, email, discordId });
|
||||
|
||||
//THEN
|
||||
|
||||
await expect(
|
||||
BlacklistDal.getCollection()
|
||||
.find({
|
||||
discordIdHash: BlacklistDal.hash(discordId),
|
||||
})
|
||||
.toArray(),
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe("contains", () => {
|
||||
it("contains user", async () => {
|
||||
//GIVEN
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
const discordId = `${name}DiscordId`;
|
||||
await BlacklistDal.add({ name, email, discordId });
|
||||
await BlacklistDal.add({ name: "test", email: "test@example.com" });
|
||||
|
||||
//WHEN / THEN
|
||||
//by name
|
||||
await expect(BlacklistDal.contains({ name })).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ name: name.toUpperCase() }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ name, email: "unknown", discordId: "unknown" }),
|
||||
).resolves.toBeTruthy();
|
||||
|
||||
//by email
|
||||
await expect(BlacklistDal.contains({ email })).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ email: email.toUpperCase() }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ name: "unknown", email, discordId: "unknown" }),
|
||||
).resolves.toBeTruthy();
|
||||
|
||||
//by discordId
|
||||
await expect(BlacklistDal.contains({ discordId })).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ discordId: discordId.toUpperCase() }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ name: "unknown", email: "unknown", discordId }),
|
||||
).resolves.toBeTruthy();
|
||||
|
||||
//by name and email and discordId
|
||||
await expect(
|
||||
BlacklistDal.contains({ name, email, discordId }),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("does not contain user", async () => {
|
||||
//GIVEN
|
||||
await BlacklistDal.add({ name: "test", email: "test@example.com" });
|
||||
await BlacklistDal.add({ name: "test2", email: "test2@example.com" });
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(
|
||||
BlacklistDal.contains({ name: "unknown" }),
|
||||
).resolves.toBeFalsy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ email: "unknown" }),
|
||||
).resolves.toBeFalsy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ discordId: "unknown" }),
|
||||
).resolves.toBeFalsy();
|
||||
await expect(
|
||||
BlacklistDal.contains({
|
||||
name: "unknown",
|
||||
email: "unknown",
|
||||
discordId: "unknown",
|
||||
}),
|
||||
).resolves.toBeFalsy();
|
||||
|
||||
await expect(BlacklistDal.contains({})).resolves.toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("removes existing username", async () => {
|
||||
//GIVEN
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
await BlacklistDal.add({ name, email });
|
||||
await BlacklistDal.add({ name: "test", email: "test@example.com" });
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.remove({ name });
|
||||
|
||||
//THEN
|
||||
await expect(BlacklistDal.contains({ name })).resolves.toBeFalsy();
|
||||
await expect(BlacklistDal.contains({ email })).resolves.toBeTruthy();
|
||||
|
||||
//decoy still exists
|
||||
await expect(
|
||||
BlacklistDal.contains({ name: "test" }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ email: "test@example.com" }),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("removes existing email", async () => {
|
||||
//GIVEN
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
await BlacklistDal.add({ name, email });
|
||||
await BlacklistDal.add({ name: "test", email: "test@example.com" });
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.remove({ email });
|
||||
|
||||
//THEN
|
||||
await expect(BlacklistDal.contains({ email })).resolves.toBeFalsy();
|
||||
await expect(BlacklistDal.contains({ name })).resolves.toBeTruthy();
|
||||
|
||||
//decoy still exists
|
||||
await expect(
|
||||
BlacklistDal.contains({ name: "test" }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ email: "test@example.com" }),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("removes existing discordId", async () => {
|
||||
//GIVEN
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
const discordId = `${name}DiscordId`;
|
||||
await BlacklistDal.add({ name, email, discordId });
|
||||
await BlacklistDal.add({
|
||||
name: "test",
|
||||
email: "test@example.com",
|
||||
discordId: "testDiscordId",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.remove({ discordId });
|
||||
|
||||
//THEN
|
||||
await expect(BlacklistDal.contains({ discordId })).resolves.toBeFalsy();
|
||||
await expect(BlacklistDal.contains({ name })).resolves.toBeTruthy();
|
||||
await expect(BlacklistDal.contains({ email })).resolves.toBeTruthy();
|
||||
|
||||
//decoy still exists
|
||||
await expect(
|
||||
BlacklistDal.contains({ name: "test" }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ email: "test@example.com" }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ discordId: "testDiscordId" }),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("removes existing username,email and discordId", async () => {
|
||||
//GIVEN
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
const discordId = `${name}DiscordId`;
|
||||
await BlacklistDal.add({ name, email, discordId });
|
||||
await BlacklistDal.add({
|
||||
name: "test",
|
||||
email: "test@example.com",
|
||||
discordId: "testDiscordId",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.remove({ name, email, discordId });
|
||||
|
||||
//THEN
|
||||
await expect(BlacklistDal.contains({ email })).resolves.toBeFalsy();
|
||||
await expect(BlacklistDal.contains({ name })).resolves.toBeFalsy();
|
||||
await expect(BlacklistDal.contains({ discordId })).resolves.toBeFalsy();
|
||||
|
||||
//decoy still exists
|
||||
await expect(
|
||||
BlacklistDal.contains({ name: "test" }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ email: "test@example.com" }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
BlacklistDal.contains({ discordId: "testDiscordId" }),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not remove for empty user", async () => {
|
||||
//GIVEN
|
||||
const name = "user" + new ObjectId().toHexString();
|
||||
const email = `${name}@example.com`;
|
||||
const discordId = `${name}DiscordId`;
|
||||
await BlacklistDal.add({ name, email, discordId });
|
||||
await BlacklistDal.add({ name: "test", email: "test@example.com" });
|
||||
|
||||
//WHEN
|
||||
await BlacklistDal.remove({});
|
||||
|
||||
//THEN
|
||||
await expect(BlacklistDal.contains({ email })).resolves.toBeTruthy();
|
||||
await expect(BlacklistDal.contains({ name })).resolves.toBeTruthy();
|
||||
await expect(BlacklistDal.contains({ discordId })).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
describe("hash", () => {
|
||||
it("hashes case insensitive", () => {
|
||||
["test", "TEST", "tESt"].forEach((value) =>
|
||||
expect(BlacklistDal.hash(value)).toEqual(
|
||||
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
backend/__tests__/__integration__/dal/config.spec.ts
Normal file
42
backend/__tests__/__integration__/dal/config.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ObjectId } from "mongodb";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as ConfigDal from "../../../src/dal/config";
|
||||
|
||||
const getConfigCollection = ConfigDal.__testing.getConfigCollection;
|
||||
|
||||
describe("ConfigDal", () => {
|
||||
describe("saveConfig", () => {
|
||||
it("should save and update user configuration correctly", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toString();
|
||||
await getConfigCollection().insertOne({
|
||||
uid,
|
||||
config: {
|
||||
ads: "on",
|
||||
time: 60,
|
||||
quickTab: true, //legacy value
|
||||
},
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
await ConfigDal.saveConfig(uid, {
|
||||
ads: "on",
|
||||
difficulty: "normal",
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
await ConfigDal.saveConfig(uid, { ads: "off" });
|
||||
|
||||
//THEN
|
||||
const savedConfig = (await ConfigDal.getConfig(
|
||||
uid,
|
||||
)) as ConfigDal.DBConfig;
|
||||
|
||||
expect(savedConfig.config.ads).toBe("off");
|
||||
expect(savedConfig.config.time).toBe(60);
|
||||
|
||||
//should remove legacy values
|
||||
expect((savedConfig.config as any)["quickTab"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
493
backend/__tests__/__integration__/dal/connections.spec.ts
Normal file
493
backend/__tests__/__integration__/dal/connections.spec.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
import * as ConnectionsDal from "../../../src/dal/connections";
|
||||
import { createConnection } from "../../__testData__/connections";
|
||||
import { createUser } from "../../__testData__/users";
|
||||
|
||||
describe("ConnectionsDal", () => {
|
||||
beforeAll(async () => {
|
||||
await ConnectionsDal.createIndicies();
|
||||
});
|
||||
|
||||
describe("getRequests", () => {
|
||||
it("get by uid", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const initOne = await createConnection({ initiatorUid: uid });
|
||||
const initTwo = await createConnection({ initiatorUid: uid });
|
||||
const friendOne = await createConnection({ receiverUid: uid });
|
||||
const _decoy = await createConnection({});
|
||||
|
||||
//WHEN / THEM
|
||||
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
}),
|
||||
).toStrictEqual([initOne, initTwo, friendOne]);
|
||||
});
|
||||
|
||||
it("get by uid and status", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const initAccepted = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const _initPending = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "pending",
|
||||
});
|
||||
const initBlocked = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "blocked",
|
||||
});
|
||||
|
||||
const friendAccepted = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const _friendPending = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const _decoy = await createConnection({ status: "accepted" });
|
||||
|
||||
//WHEN / THEN
|
||||
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
status: ["accepted", "blocked"],
|
||||
}),
|
||||
).toStrictEqual([initAccepted, initBlocked, friendAccepted]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
const now = 1715082588;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should fail creating duplicates", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
|
||||
//WHEN/THEN
|
||||
await expect(
|
||||
createConnection({
|
||||
initiatorUid: first.receiverUid,
|
||||
receiverUid: uid,
|
||||
}),
|
||||
).rejects.toThrow("Connection request already sent");
|
||||
});
|
||||
|
||||
it("should create", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const receiverUid = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const created = await ConnectionsDal.create(
|
||||
{ uid, name: "Bob" },
|
||||
{ uid: receiverUid, name: "Kevin" },
|
||||
2,
|
||||
);
|
||||
|
||||
//THEN
|
||||
expect(created).toEqual({
|
||||
_id: created._id,
|
||||
initiatorUid: uid,
|
||||
initiatorName: "Bob",
|
||||
receiverUid: receiverUid,
|
||||
receiverName: "Kevin",
|
||||
lastModified: now,
|
||||
status: "pending",
|
||||
key: `${uid}/${receiverUid}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if maximum connections are reached", async () => {
|
||||
//GIVEN
|
||||
const initiatorUid = new ObjectId().toHexString();
|
||||
await createConnection({ initiatorUid });
|
||||
await createConnection({ initiatorUid });
|
||||
|
||||
//WHEN / THEM
|
||||
await expect(createConnection({ initiatorUid }, 2)).rejects.toThrow(
|
||||
"Maximum number of connections reached\nStack: create connection request",
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail creating if blocked", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "blocked",
|
||||
});
|
||||
|
||||
//WHEN/THEN
|
||||
await expect(
|
||||
createConnection({
|
||||
initiatorUid: first.receiverUid,
|
||||
receiverUid: uid,
|
||||
}),
|
||||
).rejects.toThrow("Connection blocked");
|
||||
});
|
||||
});
|
||||
describe("updateStatus", () => {
|
||||
const now = 1715082588;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it("should update the status", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
receiverUid: uid,
|
||||
lastModified: 100,
|
||||
});
|
||||
const second = await createConnection({
|
||||
receiverUid: uid,
|
||||
lastModified: 200,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.updateStatus(
|
||||
uid,
|
||||
first._id.toHexString(),
|
||||
"accepted",
|
||||
);
|
||||
|
||||
//THEN
|
||||
expect(await ConnectionsDal.getConnections({ receiverUid: uid })).toEqual(
|
||||
[{ ...first, status: "accepted", lastModified: now }, second],
|
||||
);
|
||||
|
||||
//can update twice to the same status
|
||||
await ConnectionsDal.updateStatus(
|
||||
uid,
|
||||
first._id.toHexString(),
|
||||
"accepted",
|
||||
);
|
||||
});
|
||||
it("should fail if uid does not match the reeceiverUid", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(
|
||||
ConnectionsDal.updateStatus(uid, first._id.toHexString(), "accepted"),
|
||||
).rejects.toThrow("No permission or connection not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteById", () => {
|
||||
it("should delete by initiator", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
const second = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.deleteById(uid, first._id.toHexString());
|
||||
|
||||
//THEN
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({ initiatorUid: uid }),
|
||||
).toStrictEqual([second]);
|
||||
});
|
||||
|
||||
it("should delete by receiver", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
receiverUid: uid,
|
||||
});
|
||||
const second = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.deleteById(uid, first._id.toHexString());
|
||||
|
||||
//THEN
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: second.initiatorUid,
|
||||
}),
|
||||
).toStrictEqual([second]);
|
||||
});
|
||||
|
||||
it("should fail if uid does not match", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(
|
||||
ConnectionsDal.deleteById("Bob", first._id.toHexString()),
|
||||
).rejects.toThrow("No permission or connection not found");
|
||||
});
|
||||
|
||||
it("should fail if initiator deletes blocked by receiver", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const myRequestWasBlocked = await createConnection({
|
||||
initiatorName: uid,
|
||||
status: "blocked",
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(
|
||||
ConnectionsDal.deleteById(uid, myRequestWasBlocked._id.toHexString()),
|
||||
).rejects.toThrow("No permission or connection not found");
|
||||
});
|
||||
it("allow receiver to delete blocked", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const myBlockedUser = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "blocked",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.deleteById(uid, myBlockedUser._id.toHexString());
|
||||
|
||||
//THEN
|
||||
expect(await ConnectionsDal.getConnections({ receiverUid: uid })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteByUid", () => {
|
||||
it("should delete by uid", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const _initOne = await createConnection({ initiatorUid: uid });
|
||||
const _initTwo = await createConnection({ initiatorUid: uid });
|
||||
const _friendOne = await createConnection({ receiverUid: uid });
|
||||
const decoy = await createConnection({});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.deleteByUid(uid);
|
||||
|
||||
//THEN
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: decoy.initiatorUid,
|
||||
}),
|
||||
).toEqual([decoy]);
|
||||
});
|
||||
});
|
||||
describe("updateName", () => {
|
||||
it("should update the name", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const initOne = await createConnection({
|
||||
initiatorUid: uid,
|
||||
initiatorName: "Bob",
|
||||
});
|
||||
const initTwo = await createConnection({
|
||||
initiatorUid: uid,
|
||||
initiatorName: "Bob",
|
||||
});
|
||||
const friendOne = await createConnection({
|
||||
receiverUid: uid,
|
||||
receiverName: "Bob",
|
||||
});
|
||||
const decoy = await createConnection({});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.updateName(uid, "King Bob");
|
||||
|
||||
//THEN
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
}),
|
||||
).toEqual([
|
||||
{ ...initOne, initiatorName: "King Bob" },
|
||||
{ ...initTwo, initiatorName: "King Bob" },
|
||||
{ ...friendOne, receiverName: "King Bob" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: decoy.initiatorUid,
|
||||
}),
|
||||
).toEqual([decoy]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFriendsUids", () => {
|
||||
it("should return friend uids", async () => {
|
||||
//GIVE
|
||||
const uid = new ObjectId().toHexString();
|
||||
const friendOne = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const friendTwo = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const friendThree = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const _pending = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "pending",
|
||||
});
|
||||
const _blocked = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "blocked",
|
||||
});
|
||||
const _decoy = await createConnection({});
|
||||
|
||||
//WHEN
|
||||
const friendUids = await ConnectionsDal.getFriendsUids(uid);
|
||||
|
||||
//THEN
|
||||
expect(friendUids).toEqual([
|
||||
uid,
|
||||
friendOne.receiverUid,
|
||||
friendTwo.initiatorUid,
|
||||
friendThree.initiatorUid,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateWithAcceptedConnections", () => {
|
||||
it("should return friend uids", async () => {
|
||||
//GIVE
|
||||
const uid = (await createUser()).uid;
|
||||
const friendOne = await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: (await createUser()).uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const friendTwo = await createConnection({
|
||||
initiatorUid: (await createUser()).uid,
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const friendThree = await createConnection({
|
||||
initiatorUid: (await createUser()).uid,
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const _pending = await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: (await createUser()).uid,
|
||||
status: "pending",
|
||||
});
|
||||
const _blocked = await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: (await createUser()).uid,
|
||||
status: "blocked",
|
||||
});
|
||||
const _decoy = await createConnection({
|
||||
receiverUid: (await createUser()).uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const friendUids = await ConnectionsDal.aggregateWithAcceptedConnections<{
|
||||
uid: string;
|
||||
}>({ collectionName: "users", uid }, [{ $project: { uid: true } }]);
|
||||
|
||||
//THEN
|
||||
expect(friendUids.flatMap((it) => it.uid).toSorted()).toEqual([
|
||||
uid,
|
||||
friendOne.receiverUid,
|
||||
friendTwo.initiatorUid,
|
||||
friendThree.initiatorUid,
|
||||
]);
|
||||
});
|
||||
it("should return friend uids and metaData", async () => {
|
||||
//GIVE
|
||||
const me = await createUser();
|
||||
const friend = await createUser();
|
||||
|
||||
const connection = await createConnection({
|
||||
initiatorUid: me.uid,
|
||||
receiverUid: friend.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const friendUids = await ConnectionsDal.aggregateWithAcceptedConnections(
|
||||
{ collectionName: "users", uid: me.uid, includeMetaData: true },
|
||||
[
|
||||
{
|
||||
$project: {
|
||||
uid: true,
|
||||
lastModified: "$connectionMeta.lastModified",
|
||||
connectionId: "$connectionMeta._id",
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
//THEN
|
||||
expect(friendUids).toEqual([
|
||||
{
|
||||
_id: friend._id,
|
||||
connectionId: connection._id,
|
||||
lastModified: connection.lastModified,
|
||||
uid: friend.uid,
|
||||
},
|
||||
{
|
||||
_id: me._id,
|
||||
uid: me.uid,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,544 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import * as LeaderboardsDal from "../../../src/dal/leaderboards";
|
||||
import * as PublicDal from "../../../src/dal/public";
|
||||
import type { DBLeaderboardEntry } from "../../../src/dal/leaderboards";
|
||||
import type { PersonalBest } from "@monkeytype/schemas/shared";
|
||||
|
||||
import * as DB from "../../../src/init/db";
|
||||
import { LbPersonalBests } from "../../../src/utils/pb";
|
||||
|
||||
import { pb } from "../../__testData__/users";
|
||||
import { createConnection } from "../../__testData__/connections";
|
||||
import { omit } from "../../../src/utils/misc";
|
||||
|
||||
describe("LeaderboardsDal", () => {
|
||||
afterEach(async () => {
|
||||
await DB.collection("users").deleteMany({});
|
||||
});
|
||||
describe("update", () => {
|
||||
it("should ignore unapplicable users on leaderboard", async () => {
|
||||
//GIVEN
|
||||
const lbPersonalBests = lbBests(pb(100), pb(90));
|
||||
const applicableUser = await createUser(lbPersonalBests);
|
||||
await createUser(lbPersonalBests, { banned: true });
|
||||
await createUser(lbPersonalBests, { lbOptOut: true });
|
||||
await createUser(lbPersonalBests, { needsToChangeName: true });
|
||||
await createUser(lbPersonalBests, { timeTyping: 0 });
|
||||
await createUser(lbBests(pb(0, 90, 1)));
|
||||
await createUser(lbBests(pb(60, 0, 1)));
|
||||
await createUser(lbBests(pb(60, 90, 0)));
|
||||
await createUser(lbBests(undefined, pb(60)));
|
||||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "15", "english");
|
||||
const results = await LeaderboardsDal.get("time", "15", "english", 0, 50);
|
||||
|
||||
//THEN
|
||||
expect(results).toHaveLength(1);
|
||||
expect(
|
||||
(results as LeaderboardsDal.DBLeaderboardEntry[])[0],
|
||||
).toHaveProperty("uid", applicableUser.uid);
|
||||
});
|
||||
|
||||
it("should create leaderboard time english 15", async () => {
|
||||
//GIVEN
|
||||
const rank1 = await createUser(lbBests(pb(100, 90, 2)));
|
||||
const rank2 = await createUser(lbBests(pb(100, 90, 1)));
|
||||
const rank3 = await createUser(lbBests(pb(100, 80, 2)));
|
||||
const rank4 = await createUser(lbBests(pb(90, 100, 1)));
|
||||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "15", "english");
|
||||
const results = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0,
|
||||
50,
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
||||
const lb = results.map((it) => omit(it, ["_id"]));
|
||||
|
||||
expect(lb).toEqual([
|
||||
expectedLbEntry("15", { rank: 1, user: rank1 }),
|
||||
expectedLbEntry("15", { rank: 2, user: rank2 }),
|
||||
expectedLbEntry("15", { rank: 3, user: rank3 }),
|
||||
expectedLbEntry("15", { rank: 4, user: rank4 }),
|
||||
]);
|
||||
});
|
||||
it("should create leaderboard time english 60", async () => {
|
||||
//GIVEN
|
||||
const rank1 = await createUser(lbBests(pb(90), pb(100, 90, 2)));
|
||||
const rank2 = await createUser(lbBests(undefined, pb(100, 90, 1)));
|
||||
const rank3 = await createUser(lbBests(undefined, pb(100, 80, 2)));
|
||||
const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1)));
|
||||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
const results = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"60",
|
||||
"english",
|
||||
0,
|
||||
50,
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = results.map((it) => omit(it, ["_id"]));
|
||||
|
||||
expect(lb).toEqual([
|
||||
expectedLbEntry("60", { rank: 1, user: rank1 }),
|
||||
expectedLbEntry("60", { rank: 2, user: rank2 }),
|
||||
expectedLbEntry("60", { rank: 3, user: rank3 }),
|
||||
expectedLbEntry("60", { rank: 4, user: rank4 }),
|
||||
]);
|
||||
});
|
||||
it("should not include discord properties for users without discord connection", async () => {
|
||||
//GIVEN
|
||||
await createUser(lbBests(pb(90), pb(100, 90, 2)), {
|
||||
discordId: undefined,
|
||||
discordAvatar: undefined,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
const lb = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"60",
|
||||
"english",
|
||||
0,
|
||||
50,
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
expect(lb[0]).not.toHaveProperty("discordId");
|
||||
expect(lb[0]).not.toHaveProperty("discordAvatar");
|
||||
});
|
||||
|
||||
it("should remove consistency from results if null", async () => {
|
||||
//GIVEN
|
||||
const stats = pb(100, 90, 2);
|
||||
//@ts-ignore
|
||||
stats.consistency = undefined;
|
||||
|
||||
await createUser(lbBests(stats));
|
||||
|
||||
//WHEN
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "15", "english");
|
||||
const lb = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0,
|
||||
50,
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
expect(lb[0]).not.toHaveProperty("consistency");
|
||||
});
|
||||
|
||||
it("should update public speedHistogram for time english 15", async () => {
|
||||
//GIVEN
|
||||
await createUser(lbBests(pb(10), pb(60)));
|
||||
await createUser(lbBests(pb(24)));
|
||||
await createUser(lbBests(pb(28)));
|
||||
await createUser(lbBests(pb(31)));
|
||||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "15", "english");
|
||||
const result = await PublicDal.getSpeedHistogram("english", "time", "15");
|
||||
|
||||
//THEN
|
||||
expect(result).toEqual({ "10": 1, "20": 2, "30": 1 });
|
||||
});
|
||||
|
||||
it("should update public speedHistogram for time english 60", async () => {
|
||||
//GIVEN
|
||||
await createUser(lbBests(pb(60), pb(20)));
|
||||
await createUser(lbBests(undefined, pb(21)));
|
||||
await createUser(lbBests(undefined, pb(110)));
|
||||
await createUser(lbBests(undefined, pb(115)));
|
||||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
const result = await PublicDal.getSpeedHistogram("english", "time", "60");
|
||||
|
||||
//THEN
|
||||
expect(result).toEqual({ "20": 2, "110": 2 });
|
||||
});
|
||||
|
||||
it("should create leaderboard with badges", async () => {
|
||||
//GIVEN
|
||||
const noBadge = await createUser(lbBests(pb(4)));
|
||||
const oneBadgeSelected = await createUser(lbBests(pb(3)), {
|
||||
inventory: { badges: [{ id: 1, selected: true }] },
|
||||
});
|
||||
const oneBadgeNotSelected = await createUser(lbBests(pb(2)), {
|
||||
inventory: { badges: [{ id: 1, selected: false }] },
|
||||
});
|
||||
const multipleBadges = await createUser(lbBests(pb(1)), {
|
||||
inventory: {
|
||||
badges: [
|
||||
{ id: 1, selected: false },
|
||||
{ id: 2, selected: true },
|
||||
{ id: 3, selected: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "15", "english");
|
||||
const result = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0,
|
||||
50,
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => omit(it, ["_id"]));
|
||||
|
||||
expect(lb).toEqual([
|
||||
expectedLbEntry("15", { rank: 1, user: noBadge }),
|
||||
expectedLbEntry("15", {
|
||||
rank: 2,
|
||||
user: oneBadgeSelected,
|
||||
badgeId: 1,
|
||||
}),
|
||||
expectedLbEntry("15", { rank: 3, user: oneBadgeNotSelected }),
|
||||
expectedLbEntry("15", {
|
||||
rank: 4,
|
||||
user: multipleBadges,
|
||||
badgeId: 2,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create leaderboard with premium", async () => {
|
||||
//GIVEN
|
||||
vi.useRealTimers(); //timestamp for premium is calculated in mongo
|
||||
const noPremium = await createUser(lbBests(pb(4)));
|
||||
const lifetime = await createUser(lbBests(pb(3)), premium(-1));
|
||||
const validPremium = await createUser(lbBests(pb(2)), premium(1000));
|
||||
const expiredPremium = await createUser(lbBests(pb(1)), premium(-10));
|
||||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "15", "english");
|
||||
|
||||
const result = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0,
|
||||
50,
|
||||
true,
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => omit(it, ["_id"]));
|
||||
|
||||
expect(lb).toEqual([
|
||||
expectedLbEntry("15", { rank: 1, user: noPremium }),
|
||||
expectedLbEntry("15", {
|
||||
rank: 2,
|
||||
user: lifetime,
|
||||
isPremium: true,
|
||||
}),
|
||||
expectedLbEntry("15", {
|
||||
rank: 3,
|
||||
user: validPremium,
|
||||
isPremium: true,
|
||||
}),
|
||||
expectedLbEntry("15", { rank: 4, user: expiredPremium }),
|
||||
]);
|
||||
});
|
||||
it("should create leaderboard without premium if feature disabled", async () => {
|
||||
//GIVEN
|
||||
// const lifetime = await createUser(lbBests(pb(3)), premium(-1));
|
||||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "15", "english");
|
||||
const results = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0,
|
||||
50,
|
||||
false,
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
expect(results[0]?.isPremium).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should get for page", async () => {
|
||||
//GIVEN
|
||||
const _rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2)));
|
||||
const _rank2 = await createUser(lbBests(undefined, pb(100, 90, 1)));
|
||||
const rank3 = await createUser(lbBests(undefined, pb(95, 80, 2)));
|
||||
const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1)));
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
|
||||
//WHEN
|
||||
|
||||
const results = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"60",
|
||||
"english",
|
||||
1,
|
||||
2,
|
||||
true,
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = results.map((it) => omit(it, ["_id"]));
|
||||
|
||||
expect(lb).toEqual([
|
||||
expectedLbEntry("60", { rank: 3, user: rank3 }),
|
||||
expectedLbEntry("60", { rank: 4, user: rank4 }),
|
||||
]);
|
||||
});
|
||||
it("should get for friends only", async () => {
|
||||
//GIVEN
|
||||
const rank1 = await createUser(lbBests(pb(90), pb(100, 90, 2)));
|
||||
const uid = rank1.uid;
|
||||
const _rank2 = await createUser(lbBests(undefined, pb(100, 90, 1)));
|
||||
const _rank3 = await createUser(lbBests(undefined, pb(100, 80, 2)));
|
||||
const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1)));
|
||||
|
||||
//two friends, one is not on the leaderboard
|
||||
await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: rank4.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
await createConnection({ initiatorUid: uid, status: "accepted" });
|
||||
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
|
||||
//WHEN
|
||||
|
||||
const results = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"60",
|
||||
"english",
|
||||
0,
|
||||
50,
|
||||
false,
|
||||
uid,
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = results.map((it) => omit(it, ["_id"]));
|
||||
|
||||
expect(lb).toEqual([
|
||||
expectedLbEntry("60", { rank: 1, user: rank1, friendsRank: 1 }),
|
||||
expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 2 }),
|
||||
]);
|
||||
});
|
||||
it("should get for friends only with page", async () => {
|
||||
//GIVEN
|
||||
const rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2)));
|
||||
const uid = rank1.uid;
|
||||
const rank2 = await createUser(lbBests(undefined, pb(100, 90, 1)));
|
||||
const _rank3 = await createUser(lbBests(undefined, pb(95, 80, 2)));
|
||||
const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1)));
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
|
||||
await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: rank2.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
await createConnection({
|
||||
initiatorUid: rank4.uid,
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const results = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"60",
|
||||
"english",
|
||||
1,
|
||||
2,
|
||||
false,
|
||||
uid,
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = results.map((it) => omit(it, ["_id"]));
|
||||
|
||||
expect(lb).toEqual([
|
||||
expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 3 }),
|
||||
]);
|
||||
});
|
||||
it("should return empty list if no friends", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const results = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
"60",
|
||||
"english",
|
||||
1,
|
||||
2,
|
||||
false,
|
||||
uid,
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
//THEN
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
describe("getCount / getRank", () => {
|
||||
it("should get count", async () => {
|
||||
//GIVEN
|
||||
await createUser(lbBests(undefined, pb(105)), { name: "One" });
|
||||
await createUser(lbBests(undefined, pb(100)), { name: "Two" });
|
||||
const me = await createUser(lbBests(undefined, pb(95)), { name: "Me" });
|
||||
await createUser(lbBests(undefined, pb(90)), { name: "Three" });
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
|
||||
//WHEN / THEN
|
||||
|
||||
expect(await LeaderboardsDal.getCount("time", "60", "english")) //
|
||||
.toEqual(4);
|
||||
expect(await LeaderboardsDal.getRank("time", "60", "english", me.uid)) //
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
wpm: 95,
|
||||
rank: 3,
|
||||
name: me.name,
|
||||
uid: me.uid,
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("should get for friends only", async () => {
|
||||
//GIVEN
|
||||
const friendOne = await createUser(lbBests(undefined, pb(105)));
|
||||
await createUser(lbBests(undefined, pb(100)));
|
||||
await createUser(lbBests(undefined, pb(95)));
|
||||
const friendTwo = await createUser(lbBests(undefined, pb(90)));
|
||||
const me = await createUser(lbBests(undefined, pb(99)));
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
|
||||
await createConnection({
|
||||
initiatorUid: me.uid,
|
||||
receiverUid: friendOne.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
await createConnection({
|
||||
initiatorUid: friendTwo.uid,
|
||||
receiverUid: me.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
|
||||
expect(await LeaderboardsDal.getCount("time", "60", "english", me.uid)) //
|
||||
.toEqual(3);
|
||||
expect(
|
||||
await LeaderboardsDal.getRank("time", "60", "english", me.uid, true),
|
||||
) //
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
wpm: 99,
|
||||
rank: 3,
|
||||
friendsRank: 2,
|
||||
name: me.name,
|
||||
uid: me.uid,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function expectedLbEntry(
|
||||
time: string,
|
||||
{ rank, user, badgeId, isPremium, friendsRank }: ExpectedLbEntry,
|
||||
) {
|
||||
// @ts-expect-error
|
||||
const lbBest: PersonalBest =
|
||||
// @ts-expect-error
|
||||
user.lbPersonalBests?.time[Number.parseInt(time)].english;
|
||||
|
||||
return {
|
||||
rank,
|
||||
uid: user.uid,
|
||||
name: user.name,
|
||||
wpm: lbBest.wpm,
|
||||
acc: lbBest.acc,
|
||||
timestamp: lbBest.timestamp,
|
||||
raw: lbBest.raw,
|
||||
consistency: lbBest.consistency,
|
||||
discordId: user.discordId,
|
||||
discordAvatar: user.discordAvatar,
|
||||
badgeId,
|
||||
isPremium,
|
||||
friendsRank,
|
||||
};
|
||||
}
|
||||
|
||||
async function createUser(
|
||||
lbPersonalBests?: LbPersonalBests,
|
||||
userProperties?: Partial<UserDal.DBUser>,
|
||||
): Promise<UserDal.DBUser> {
|
||||
const uid = new ObjectId().toHexString();
|
||||
await UserDal.addUser("User " + uid, uid + "@example.com", uid);
|
||||
|
||||
await DB.getDb()
|
||||
?.collection<UserDal.DBUser>("users")
|
||||
.updateOne(
|
||||
{ uid },
|
||||
{
|
||||
$set: {
|
||||
timeTyping: 7200,
|
||||
discordId: "discord " + uid,
|
||||
discordAvatar: "avatar " + uid,
|
||||
...userProperties,
|
||||
lbPersonalBests,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return await UserDal.getUser(uid, "test");
|
||||
}
|
||||
|
||||
function lbBests(pb15?: PersonalBest, pb60?: PersonalBest): LbPersonalBests {
|
||||
const result: LbPersonalBests = { time: {} };
|
||||
if (pb15) result.time["15"] = { english: pb15 };
|
||||
if (pb60) result.time["60"] = { english: pb60 };
|
||||
return result;
|
||||
}
|
||||
|
||||
function premium(expirationDeltaSeconds: number) {
|
||||
return {
|
||||
premium: {
|
||||
startTimestamp: 0,
|
||||
expirationTimestamp:
|
||||
expirationDeltaSeconds === -1
|
||||
? -1
|
||||
: Date.now() + expirationDeltaSeconds * 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ExpectedLbEntry = {
|
||||
rank: number;
|
||||
user: UserDal.DBUser;
|
||||
badgeId?: number;
|
||||
isPremium?: boolean;
|
||||
friendsRank?: number;
|
||||
};
|
||||
482
backend/__tests__/__integration__/dal/preset.spec.ts
Normal file
482
backend/__tests__/__integration__/dal/preset.spec.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as PresetDal from "../../../src/dal/preset";
|
||||
|
||||
describe("PresetDal", () => {
|
||||
describe("readPreset", () => {
|
||||
it("should read", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
const first = await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
});
|
||||
const second = await PresetDal.addPreset(uid, {
|
||||
name: "second",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
});
|
||||
|
||||
await PresetDal.addPreset(decoyUid, {
|
||||
name: "unknown",
|
||||
config: {},
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
|
||||
//THEN
|
||||
expect(read).toHaveLength(2);
|
||||
expect(read).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first.presetId),
|
||||
uid: uid,
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(second.presetId),
|
||||
uid: uid,
|
||||
name: "second",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addPreset", () => {
|
||||
it("should return error if maximum is reached", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await PresetDal.addPreset(uid, { name: "test", config: {} });
|
||||
}
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(() =>
|
||||
PresetDal.addPreset(uid, { name: "max", config: {} }),
|
||||
).rejects.toThrow("Too many presets");
|
||||
});
|
||||
it("should add preset", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await PresetDal.addPreset(uid, { name: "test", config: {} });
|
||||
}
|
||||
|
||||
//WHEN
|
||||
const newPreset = await PresetDal.addPreset(uid, {
|
||||
name: "new",
|
||||
config: {
|
||||
ads: "sellout",
|
||||
},
|
||||
});
|
||||
|
||||
//THEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
|
||||
expect(read).toHaveLength(10);
|
||||
expect(read).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(newPreset.presetId),
|
||||
uid: uid,
|
||||
name: "new",
|
||||
config: { ads: "sellout" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editPreset", () => {
|
||||
it("should not fail if preset is unknown", async () => {
|
||||
const uid = new ObjectId().toHexString();
|
||||
await PresetDal.editPreset(uid, {
|
||||
_id: new ObjectId().toHexString(),
|
||||
name: "new",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should edit", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
})
|
||||
).presetId;
|
||||
const second = (
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "second",
|
||||
config: {
|
||||
ads: "result",
|
||||
},
|
||||
})
|
||||
).presetId;
|
||||
const decoy = (
|
||||
await PresetDal.addPreset(decoyUid, {
|
||||
name: "unknown",
|
||||
config: { ads: "result" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
await PresetDal.editPreset(uid, {
|
||||
_id: first,
|
||||
name: "newName",
|
||||
config: { ads: "off" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
expect(read).toHaveLength(2);
|
||||
expect(read).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first),
|
||||
uid: uid,
|
||||
name: "newName",
|
||||
config: { ads: "off" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(second),
|
||||
uid: uid,
|
||||
name: "second",
|
||||
config: { ads: "result" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await PresetDal.getPresets(decoyUid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(decoy),
|
||||
uid: decoyUid,
|
||||
name: "unknown",
|
||||
config: { ads: "result" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should edit with name only - full preset", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN empty
|
||||
await PresetDal.editPreset(uid, {
|
||||
_id: first,
|
||||
name: "newName",
|
||||
});
|
||||
expect(await PresetDal.getPresets(uid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first),
|
||||
uid: uid,
|
||||
name: "newName",
|
||||
config: { ads: "sellout" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("should edit with name only - partial preset", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN empty
|
||||
await PresetDal.editPreset(uid, {
|
||||
_id: first,
|
||||
name: "newName",
|
||||
});
|
||||
expect(await PresetDal.getPresets(uid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first),
|
||||
uid: uid,
|
||||
name: "newName",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("should not edit present not matching uid", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
await PresetDal.editPreset(decoyUid, {
|
||||
_id: first,
|
||||
name: "newName",
|
||||
config: { ads: "off" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
|
||||
expect(read).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first),
|
||||
uid: uid,
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("should edit when partial is edited to full", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
})
|
||||
).presetId;
|
||||
//WHEN
|
||||
await PresetDal.editPreset(uid, {
|
||||
_id: first,
|
||||
name: "newName",
|
||||
settingGroups: null,
|
||||
config: { ads: "off" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(await PresetDal.getPresets(uid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first),
|
||||
uid: uid,
|
||||
name: "newName",
|
||||
config: { ads: "off" },
|
||||
settingGroups: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("should edit when full is edited to partial", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: {
|
||||
ads: "off",
|
||||
},
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
await PresetDal.editPreset(uid, {
|
||||
_id: first,
|
||||
name: "newName",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(await PresetDal.getPresets(uid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first),
|
||||
uid: uid,
|
||||
name: "newName",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePreset", () => {
|
||||
it("should fail if preset is unknown", async () => {
|
||||
const uid = new ObjectId().toHexString();
|
||||
await expect(() =>
|
||||
PresetDal.removePreset(uid, new ObjectId().toHexString()),
|
||||
).rejects.toThrow("Preset not found");
|
||||
});
|
||||
it("should remove", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, { name: "first", config: {} })
|
||||
).presetId;
|
||||
const second = (
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "second",
|
||||
config: { ads: "result" },
|
||||
})
|
||||
).presetId;
|
||||
const decoy = (
|
||||
await PresetDal.addPreset(decoyUid, {
|
||||
name: "unknown",
|
||||
config: { ads: "result" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
await PresetDal.removePreset(uid, first);
|
||||
|
||||
//THEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
expect(read).toHaveLength(1);
|
||||
expect(read).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(second),
|
||||
uid: uid,
|
||||
name: "second",
|
||||
config: { ads: "result" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await PresetDal.getPresets(decoyUid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(decoy),
|
||||
uid: decoyUid,
|
||||
name: "unknown",
|
||||
config: { ads: "result" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("should not remove present not matching uid", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
await expect(() =>
|
||||
PresetDal.removePreset(decoyUid, first),
|
||||
).rejects.toThrow("Preset not found");
|
||||
|
||||
//THEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
|
||||
expect(read).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first),
|
||||
uid: uid,
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAllPresets", () => {
|
||||
it("should not fail if preset is unknown", async () => {
|
||||
const uid = new ObjectId().toHexString();
|
||||
await PresetDal.deleteAllPresets(uid);
|
||||
});
|
||||
it("should delete all", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
await PresetDal.addPreset(uid, { name: "first", config: {} });
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "second",
|
||||
config: { ads: "result" },
|
||||
});
|
||||
const decoy = (
|
||||
await PresetDal.addPreset(decoyUid, {
|
||||
name: "unknown",
|
||||
config: { ads: "result" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
await PresetDal.deleteAllPresets(uid);
|
||||
|
||||
//THEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
expect(read).toHaveLength(0);
|
||||
|
||||
expect(await PresetDal.getPresets(decoyUid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(decoy),
|
||||
uid: decoyUid,
|
||||
name: "unknown",
|
||||
config: { ads: "result" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
backend/__tests__/__integration__/dal/public.spec.ts
Normal file
26
backend/__tests__/__integration__/dal/public.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as PublicDAL from "../../../src/dal/public";
|
||||
|
||||
describe("PublicDAL", function () {
|
||||
it("should be able to update stats", async function () {
|
||||
// checks it doesn't throw an error. the actual values are checked in another test.
|
||||
await PublicDAL.updateStats(1, 15);
|
||||
});
|
||||
|
||||
it("should be able to get typing stats", async function () {
|
||||
const typingStats = await PublicDAL.getTypingStats();
|
||||
expect(typingStats).toHaveProperty("testsCompleted");
|
||||
expect(typingStats).toHaveProperty("testsStarted");
|
||||
expect(typingStats).toHaveProperty("timeTyping");
|
||||
});
|
||||
|
||||
it("should increment stats on update", async function () {
|
||||
// checks that both functions are working on the same data in mongo
|
||||
const priorStats = await PublicDAL.getTypingStats();
|
||||
await PublicDAL.updateStats(1, 60);
|
||||
const afterStats = await PublicDAL.getTypingStats();
|
||||
expect(afterStats.testsCompleted).toBe(priorStats.testsCompleted + 1);
|
||||
expect(afterStats.testsStarted).toBe(priorStats.testsStarted + 2);
|
||||
expect(afterStats.timeTyping).toBe(priorStats.timeTyping + 60);
|
||||
});
|
||||
});
|
||||
190
backend/__tests__/__integration__/dal/result.spec.ts
Normal file
190
backend/__tests__/__integration__/dal/result.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as ResultDal from "../../../src/dal/result";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import { DBResult } from "../../../src/utils/result";
|
||||
import * as ResultUtils from "../../../src/utils/result";
|
||||
|
||||
let uid: string;
|
||||
const timestamp = Date.now() - 60000;
|
||||
|
||||
async function createDummyData(
|
||||
uid: string,
|
||||
count: number,
|
||||
modify?: Partial<DBResult>,
|
||||
): Promise<void> {
|
||||
const dummyUser: UserDal.DBUser = {
|
||||
_id: new ObjectId(),
|
||||
uid,
|
||||
addedAt: 0,
|
||||
email: "test@example.com",
|
||||
name: "Bob",
|
||||
personalBests: {
|
||||
time: {},
|
||||
words: {},
|
||||
quote: {},
|
||||
custom: {},
|
||||
zen: {},
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(UserDal, "getUser").mockResolvedValue(dummyUser);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await ResultDal.addResult(uid, {
|
||||
...{
|
||||
_id: new ObjectId(),
|
||||
wpm: i,
|
||||
rawWpm: i,
|
||||
charStats: [0, 0, 0, 0],
|
||||
acc: 0,
|
||||
mode: "time",
|
||||
mode2: "10" as never,
|
||||
quoteLength: 1,
|
||||
timestamp,
|
||||
restartCount: 0,
|
||||
incompleteTestSeconds: 0,
|
||||
incompleteTests: [],
|
||||
testDuration: 10,
|
||||
afkDuration: 0,
|
||||
tags: [],
|
||||
consistency: 100,
|
||||
keyConsistency: 100,
|
||||
chartData: { wpm: [], burst: [], err: [] },
|
||||
uid,
|
||||
keySpacingStats: { average: 0, sd: 0 },
|
||||
keyDurationStats: { average: 0, sd: 0 },
|
||||
difficulty: "normal",
|
||||
language: "english",
|
||||
isPb: false,
|
||||
name: "Test",
|
||||
funbox: ["58008", "read_ahead"],
|
||||
},
|
||||
...modify,
|
||||
});
|
||||
}
|
||||
}
|
||||
describe("ResultDal", () => {
|
||||
const replaceLegacyValuesMock = vi.spyOn(ResultUtils, "replaceLegacyValues");
|
||||
|
||||
beforeEach(() => {
|
||||
uid = new ObjectId().toHexString();
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (uid) await ResultDal.deleteAll(uid);
|
||||
replaceLegacyValuesMock.mockClear();
|
||||
});
|
||||
describe("getResults", () => {
|
||||
it("should read lastest 10 results ordered by timestamp", async () => {
|
||||
//GIVEN
|
||||
await createDummyData(uid, 10, { timestamp: timestamp - 2000 });
|
||||
await createDummyData(uid, 20, { tags: ["current"] });
|
||||
|
||||
//WHEN
|
||||
const results = await ResultDal.getResults(uid, { limit: 10 });
|
||||
|
||||
//THEN
|
||||
expect(results).toHaveLength(10);
|
||||
let last = results[0]?.timestamp as number;
|
||||
results.forEach((it) => {
|
||||
expect(it.tags).toContain("current");
|
||||
expect(it.timestamp).toBeGreaterThanOrEqual(last);
|
||||
last = it.timestamp;
|
||||
});
|
||||
});
|
||||
it("should read all if not limited", async () => {
|
||||
//GIVEN
|
||||
await createDummyData(uid, 10, { timestamp: timestamp - 2000 });
|
||||
await createDummyData(uid, 20);
|
||||
|
||||
//WHEN
|
||||
const results = await ResultDal.getResults(uid, {});
|
||||
|
||||
//THEN
|
||||
expect(results).toHaveLength(30);
|
||||
});
|
||||
it("should read results onOrAfterTimestamp", async () => {
|
||||
//GIVEN
|
||||
await createDummyData(uid, 10, { timestamp: timestamp - 2000 });
|
||||
await createDummyData(uid, 20, { tags: ["current"] });
|
||||
|
||||
//WHEN
|
||||
const results = await ResultDal.getResults(uid, {
|
||||
onOrAfterTimestamp: timestamp,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(results).toHaveLength(20);
|
||||
results.forEach((it) => {
|
||||
expect(it.tags).toContain("current");
|
||||
});
|
||||
});
|
||||
it("should read next 10 results", async () => {
|
||||
//GIVEN
|
||||
await createDummyData(uid, 10, {
|
||||
timestamp: timestamp - 2000,
|
||||
tags: ["old"],
|
||||
});
|
||||
await createDummyData(uid, 20);
|
||||
|
||||
//WHEN
|
||||
const results = await ResultDal.getResults(uid, {
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(results).toHaveLength(10);
|
||||
results.forEach((it) => {
|
||||
expect(it.tags).toContain("old");
|
||||
});
|
||||
});
|
||||
it("should call replaceLegacyValues", async () => {
|
||||
//GIVEN
|
||||
await createDummyData(uid, 1);
|
||||
|
||||
//WHEN
|
||||
await ResultDal.getResults(uid);
|
||||
|
||||
//THEN
|
||||
expect(replaceLegacyValuesMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("getResult", () => {
|
||||
it("should call replaceLegacyValues", async () => {
|
||||
//GIVEN
|
||||
await createDummyData(uid, 1);
|
||||
const resultId = (await ResultDal.getLastResult(uid))._id.toHexString();
|
||||
|
||||
//WHEN
|
||||
await ResultDal.getResult(uid, resultId);
|
||||
|
||||
//THEN
|
||||
expect(replaceLegacyValuesMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("getLastResult", () => {
|
||||
it("should call replaceLegacyValues", async () => {
|
||||
//GIVEN
|
||||
await createDummyData(uid, 1);
|
||||
|
||||
//WHEN
|
||||
await ResultDal.getLastResult(uid);
|
||||
|
||||
//THEN
|
||||
expect(replaceLegacyValuesMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("getResultByTimestamp", () => {
|
||||
it("should call replaceLegacyValues", async () => {
|
||||
//GIVEN
|
||||
await createDummyData(uid, 1);
|
||||
|
||||
//WHEN
|
||||
await ResultDal.getResultByTimestamp(uid, timestamp);
|
||||
|
||||
//THEN
|
||||
expect(replaceLegacyValuesMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
2218
backend/__tests__/__integration__/dal/user.spec.ts
Normal file
2218
backend/__tests__/__integration__/dal/user.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
51
backend/__tests__/__integration__/global-setup.ts
Normal file
51
backend/__tests__/__integration__/global-setup.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { GenericContainer, StartedTestContainer, Wait } from "testcontainers";
|
||||
|
||||
let startedMongoContainer: StartedTestContainer | undefined;
|
||||
let startedRedisContainer: StartedTestContainer | undefined;
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
process.env.TZ = "UTC";
|
||||
|
||||
//use testcontainer to start mongodb
|
||||
console.log("\x1b[36mMongoDB starting...\x1b[0m");
|
||||
const mongoContainer = new GenericContainer("mongo:5.0.13")
|
||||
.withExposedPorts(27017)
|
||||
.withWaitStrategy(Wait.forListeningPorts());
|
||||
|
||||
startedMongoContainer = await mongoContainer.start();
|
||||
|
||||
const mongoUrl = `mongodb://${startedMongoContainer?.getHost()}:${startedMongoContainer?.getMappedPort(
|
||||
27017,
|
||||
)}`;
|
||||
process.env["TEST_DB_URL"] = mongoUrl;
|
||||
console.log(`\x1b[32mMongoDB is running on ${mongoUrl}\x1b[0m`);
|
||||
|
||||
//use testcontainer to start redis
|
||||
console.log("\x1b[36mRedis starting...\x1b[0m");
|
||||
const redisContainer = new GenericContainer("redis:6.2.6")
|
||||
.withExposedPorts(6379)
|
||||
.withWaitStrategy(Wait.forLogMessage("Ready to accept connections"));
|
||||
|
||||
startedRedisContainer = await redisContainer.start();
|
||||
|
||||
const redisUrl = `redis://${startedRedisContainer.getHost()}:${startedRedisContainer.getMappedPort(
|
||||
6379,
|
||||
)}`;
|
||||
process.env["REDIS_URI"] = redisUrl;
|
||||
console.log(`\x1b[32mRedis is running on ${redisUrl}\x1b[0m`);
|
||||
}
|
||||
|
||||
async function stopContainers(): Promise<void> {
|
||||
console.log("\x1b[36mMongoDB stopping...\x1b[0m");
|
||||
await startedMongoContainer?.stop();
|
||||
console.log("\x1b[36mRedis stopping...\x1b[0m");
|
||||
await startedRedisContainer?.stop();
|
||||
console.log(`\x1b[32mContainers stopped.\x1b[0m`);
|
||||
}
|
||||
|
||||
export async function teardown(): Promise<void> {
|
||||
await stopContainers();
|
||||
}
|
||||
|
||||
process.on("SIGTERM", stopContainers);
|
||||
process.on("SIGINT", stopContainers);
|
||||
11
backend/__tests__/__integration__/redis.ts
Normal file
11
backend/__tests__/__integration__/redis.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getConnection, connect } from "../../src/init/redis";
|
||||
|
||||
export async function redisSetup(): Promise<void> {
|
||||
await connect();
|
||||
}
|
||||
export async function cleanupKeys(prefix: string): Promise<void> {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
const connection = getConnection()!;
|
||||
const keys = await connection.keys(`${prefix}*`);
|
||||
await Promise.all(keys?.map((it) => connection.del(it)));
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { RedisXpLeaderboardEntry } from "@monkeytype/schemas/leaderboards";
|
||||
import { cleanupKeys, redisSetup } from "../redis";
|
||||
|
||||
const leaderboardsConfig: Configuration["leaderboards"]["weeklyXp"] = {
|
||||
enabled: true,
|
||||
expirationTimeInDays: 7,
|
||||
xpRewardBrackets: [],
|
||||
};
|
||||
|
||||
describe("Weekly XP Leaderboards", () => {
|
||||
beforeAll(async () => {
|
||||
await redisSetup();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await cleanupKeys(WeeklyXpLeaderboard.__testing.namespace);
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should get if enabled", () => {
|
||||
expect(WeeklyXpLeaderboard.get(leaderboardsConfig)).toBeInstanceOf(
|
||||
WeeklyXpLeaderboard.WeeklyXpLeaderboard,
|
||||
);
|
||||
});
|
||||
it("should return null if disabled", () => {
|
||||
expect(WeeklyXpLeaderboard.get({ enabled: false } as any)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WeeklyXpLeaderboard class", () => {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
const lb = WeeklyXpLeaderboard.get(leaderboardsConfig)!;
|
||||
|
||||
describe("addResult", () => {
|
||||
it("adds results for user", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult(100, { timeTypedSeconds: 5 });
|
||||
await givenResult(50, { ...user1, timeTypedSeconds: 5 });
|
||||
const user2 = await givenResult(100, {
|
||||
isPremium: true,
|
||||
timeTypedSeconds: 7,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(0, 10, leaderboardsConfig, true);
|
||||
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 2,
|
||||
entries: [
|
||||
{
|
||||
...user1,
|
||||
rank: 1,
|
||||
timeTypedSeconds: 10,
|
||||
totalXp: 150,
|
||||
isPremium: false,
|
||||
},
|
||||
{
|
||||
...user2,
|
||||
rank: 2,
|
||||
timeTypedSeconds: 7,
|
||||
totalXp: 100,
|
||||
isPremium: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResults", () => {
|
||||
it("gets results", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult(150);
|
||||
const user2 = await givenResult(100);
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(0, 10, leaderboardsConfig, true);
|
||||
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 2,
|
||||
entries: [
|
||||
{ rank: 1, totalXp: 150, ...user1 },
|
||||
{ rank: 2, totalXp: 100, ...user2 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("gets results for page", async () => {
|
||||
//GIVEN
|
||||
const _user1 = await givenResult(100);
|
||||
const _user2 = await givenResult(75);
|
||||
const user3 = await givenResult(50);
|
||||
const user4 = await givenResult(25);
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(1, 2, leaderboardsConfig, true);
|
||||
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 4,
|
||||
entries: [
|
||||
{ rank: 3, totalXp: 50, ...user3 },
|
||||
{ rank: 4, totalXp: 25, ...user4 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("gets results without premium", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult(150, { isPremium: true });
|
||||
const user2 = await givenResult(100);
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(0, 10, leaderboardsConfig, false);
|
||||
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 2,
|
||||
entries: [
|
||||
{ rank: 1, totalXp: 150, ...user1, isPremium: undefined },
|
||||
{ rank: 2, totalXp: 100, ...user2, isPremium: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("gets results for friends only", async () => {
|
||||
//GIVEN
|
||||
const _user1 = await givenResult(100);
|
||||
const user2 = await givenResult(75);
|
||||
const _user3 = await givenResult(50);
|
||||
const user4 = await givenResult(25);
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(0, 5, leaderboardsConfig, true, [
|
||||
user2.uid,
|
||||
user4.uid,
|
||||
new ObjectId().toHexString(),
|
||||
]);
|
||||
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 2,
|
||||
entries: [
|
||||
{ rank: 2, friendsRank: 1, totalXp: 75, ...user2 },
|
||||
{ rank: 4, friendsRank: 2, totalXp: 25, ...user4 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("gets results for friends only with page", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult(100);
|
||||
const user2 = await givenResult(75);
|
||||
const _user3 = await givenResult(50);
|
||||
const user4 = await givenResult(25);
|
||||
const _user5 = await givenResult(5);
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(1, 2, leaderboardsConfig, true, [
|
||||
user1.uid,
|
||||
user2.uid,
|
||||
user4.uid,
|
||||
new ObjectId().toHexString(),
|
||||
]);
|
||||
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 3,
|
||||
entries: [{ rank: 4, friendsRank: 3, totalXp: 25, ...user4 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty list if no friends", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(0, 5, leaderboardsConfig, true, []);
|
||||
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 0,
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRank", () => {
|
||||
it("gets rank", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult(100);
|
||||
const _user2 = await givenResult(150);
|
||||
|
||||
//WHEN
|
||||
const rank = await lb.getRank(user1.uid, leaderboardsConfig);
|
||||
//THEN
|
||||
expect(rank).toEqual({ rank: 2, totalXp: 100, ...user1 });
|
||||
});
|
||||
|
||||
it("should return null for unknown user", async () => {
|
||||
expect(await lb.getRank("decoy", leaderboardsConfig)).toBeNull();
|
||||
expect(
|
||||
await lb.getRank("decoy", leaderboardsConfig, [
|
||||
"unknown",
|
||||
"unknown2",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("gets rank for friends", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult(50);
|
||||
const user2 = await givenResult(60);
|
||||
const _user3 = await givenResult(70);
|
||||
|
||||
const friends = [user1.uid, user2.uid, "decoy"];
|
||||
|
||||
//WHEN / THEN
|
||||
expect(
|
||||
await lb.getRank(user2.uid, leaderboardsConfig, friends),
|
||||
).toEqual({ rank: 2, friendsRank: 1, totalXp: 60, ...user2 });
|
||||
|
||||
expect(
|
||||
await lb.getRank(user1.uid, leaderboardsConfig, friends),
|
||||
).toEqual({ rank: 3, friendsRank: 2, totalXp: 50, ...user1 });
|
||||
});
|
||||
});
|
||||
|
||||
it("purgeUserFromDailyLeaderboards", async () => {
|
||||
//GIVEN
|
||||
const cheater = await givenResult(50);
|
||||
const validUser = await givenResult(1000);
|
||||
|
||||
//WHEN
|
||||
await WeeklyXpLeaderboard.purgeUserFromXpLeaderboards(
|
||||
cheater.uid,
|
||||
leaderboardsConfig,
|
||||
);
|
||||
//THEN
|
||||
expect(await lb.getRank(cheater.uid, leaderboardsConfig)).toBeNull();
|
||||
expect(await lb.getResults(0, 50, leaderboardsConfig, true)).toEqual({
|
||||
count: 1,
|
||||
entries: [{ rank: 1, totalXp: 1000, ...validUser }],
|
||||
});
|
||||
});
|
||||
|
||||
async function givenResult(
|
||||
xpGained: number,
|
||||
entry?: Partial<RedisXpLeaderboardEntry>,
|
||||
): Promise<RedisXpLeaderboardEntry> {
|
||||
const uid = new ObjectId().toHexString();
|
||||
const result: RedisXpLeaderboardEntry = {
|
||||
uid,
|
||||
name: `User ${uid}`,
|
||||
lastActivityTimestamp: Date.now(),
|
||||
timeTypedSeconds: 42,
|
||||
badgeId: 2,
|
||||
discordAvatar: `${uid}Avatar`,
|
||||
discordId: `${uid}DiscordId`,
|
||||
isPremium: false,
|
||||
...entry,
|
||||
};
|
||||
|
||||
await lb.addResult(leaderboardsConfig, { xpGained, entry: result });
|
||||
return result;
|
||||
}
|
||||
});
|
||||
});
|
||||
46
backend/__tests__/__integration__/setup-integration-tests.ts
Normal file
46
backend/__tests__/__integration__/setup-integration-tests.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { afterAll, beforeAll, afterEach, vi } from "vitest";
|
||||
import { Collection, Db, MongoClient, WithId } from "mongodb";
|
||||
import { setupCommonMocks } from "../setup-common-mocks";
|
||||
import { getConnection } from "../../src/init/redis";
|
||||
|
||||
process.env["MODE"] = "dev";
|
||||
|
||||
let db: Db;
|
||||
let client: MongoClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
client = new MongoClient(process.env["TEST_DB_URL"] as string);
|
||||
await client.connect();
|
||||
db = client.db();
|
||||
|
||||
vi.mock("../../src/init/db", () => ({
|
||||
__esModule: true,
|
||||
getDb: (): Db => db,
|
||||
collection: <T>(name: string): Collection<WithId<T>> =>
|
||||
db.collection<WithId<T>>(name),
|
||||
close: () => {
|
||||
//
|
||||
},
|
||||
}));
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
//we compare the time in mongodb to calculate premium status, so we have to use real time here
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
//nothing
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await client?.close();
|
||||
// @ts-ignore
|
||||
db = undefined;
|
||||
//@ts-ignore
|
||||
client = undefined;
|
||||
|
||||
await getConnection()?.quit();
|
||||
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { Mode, Mode2 } from "@monkeytype/schemas/shared";
|
||||
import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards";
|
||||
import { cleanupKeys, redisSetup } from "../redis";
|
||||
import { Language } from "@monkeytype/schemas/languages";
|
||||
|
||||
import { RedisDailyLeaderboardEntry } from "@monkeytype/schemas/leaderboards";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
|
||||
const dailyLeaderboardsConfig: Configuration["dailyLeaderboards"] = {
|
||||
enabled: true,
|
||||
maxResults: 10,
|
||||
leaderboardExpirationTimeInDays: 1,
|
||||
validModeRules: [
|
||||
{
|
||||
language: "(english|spanish)",
|
||||
mode: "time",
|
||||
mode2: "(15|60)",
|
||||
},
|
||||
{
|
||||
language: "french",
|
||||
mode: "words",
|
||||
mode2: "\\d+",
|
||||
},
|
||||
],
|
||||
topResultsToAnnounce: 3,
|
||||
xpRewardBrackets: [],
|
||||
scheduleRewardsModeRules: [],
|
||||
};
|
||||
|
||||
describe("Daily Leaderboards", () => {
|
||||
beforeAll(async () => {
|
||||
await redisSetup();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await cleanupKeys(DailyLeaderboards.__testing.namespace);
|
||||
});
|
||||
describe("should properly handle valid and invalid modes", () => {
|
||||
const testCases: {
|
||||
language: Language;
|
||||
mode: Mode;
|
||||
mode2: Mode2<any>;
|
||||
expected: boolean;
|
||||
}[] = [
|
||||
{
|
||||
language: "english",
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
language: "spanish",
|
||||
mode: "time",
|
||||
mode2: "15",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
language: "english",
|
||||
mode: "time",
|
||||
mode2: "600",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
language: "spanish",
|
||||
mode: "words",
|
||||
mode2: "150",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
language: "french",
|
||||
mode: "time",
|
||||
mode2: "600",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
language: "french",
|
||||
mode: "words",
|
||||
mode2: "100",
|
||||
expected: true,
|
||||
},
|
||||
];
|
||||
|
||||
it.for(testCases)(
|
||||
`language=$language, mode=$mode mode2=$mode2 expect $expected`,
|
||||
({ language, mode, mode2, expected }) => {
|
||||
const result = DailyLeaderboards.getDailyLeaderboard(
|
||||
language,
|
||||
mode,
|
||||
mode2 as any,
|
||||
dailyLeaderboardsConfig,
|
||||
);
|
||||
expect(!!result).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
describe("DailyLeaderboard class", () => {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
const lb = DailyLeaderboards.getDailyLeaderboard(
|
||||
"english",
|
||||
"time",
|
||||
"60",
|
||||
dailyLeaderboardsConfig,
|
||||
)!;
|
||||
describe("addResult", () => {
|
||||
it("adds best result for user", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
await givenResult({ uid, wpm: 50 });
|
||||
const bestResult = await givenResult({ uid, wpm: 55 });
|
||||
await givenResult({ uid, wpm: 53 });
|
||||
|
||||
const user2 = await givenResult({ wpm: 20 });
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(
|
||||
0,
|
||||
5,
|
||||
dailyLeaderboardsConfig,
|
||||
true,
|
||||
);
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 2,
|
||||
minWpm: 20,
|
||||
entries: [
|
||||
{ rank: 1, ...bestResult },
|
||||
{ rank: 2, ...user2 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("limits max amount of results", async () => {
|
||||
//GIVEN
|
||||
const maxResults = dailyLeaderboardsConfig.maxResults;
|
||||
|
||||
const bob = await givenResult({ wpm: 10 });
|
||||
await Promise.all(
|
||||
new Array(maxResults - 1)
|
||||
.fill(0)
|
||||
.map(() => givenResult({ wpm: 20 + Math.random() * 100 })),
|
||||
);
|
||||
expect(
|
||||
await lb.getResults(0, 5, dailyLeaderboardsConfig, true),
|
||||
).toEqual(expect.objectContaining({ count: maxResults }));
|
||||
|
||||
expect(await lb.getRank(bob.uid, dailyLeaderboardsConfig)).toEqual({
|
||||
rank: maxResults,
|
||||
...bob,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await givenResult({ wpm: 11 });
|
||||
|
||||
//THEN
|
||||
//max count is still the same, but bob is no longer on the leaderboard
|
||||
expect(
|
||||
await lb.getResults(0, 5, dailyLeaderboardsConfig, true),
|
||||
).toEqual(expect.objectContaining({ count: maxResults }));
|
||||
expect(await lb.getRank(bob.uid, dailyLeaderboardsConfig)).toBeNull();
|
||||
});
|
||||
});
|
||||
describe("getResults", () => {
|
||||
it("gets result", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult({ wpm: 50, isPremium: true });
|
||||
const user2 = await givenResult({ wpm: 60 });
|
||||
const user3 = await givenResult({ wpm: 40 });
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(
|
||||
0,
|
||||
5,
|
||||
dailyLeaderboardsConfig,
|
||||
true,
|
||||
);
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 3,
|
||||
minWpm: 40,
|
||||
entries: [
|
||||
{ rank: 1, ...user2 },
|
||||
{ rank: 2, ...user1 },
|
||||
{ rank: 3, ...user3 },
|
||||
],
|
||||
});
|
||||
});
|
||||
it("gets result for page", async () => {
|
||||
//GIVEN
|
||||
const user4 = await givenResult({ wpm: 45 });
|
||||
const _user5 = await givenResult({ wpm: 20 });
|
||||
const _user1 = await givenResult({ wpm: 50 });
|
||||
const _user2 = await givenResult({ wpm: 60 });
|
||||
const user3 = await givenResult({ wpm: 40 });
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(
|
||||
1,
|
||||
2,
|
||||
dailyLeaderboardsConfig,
|
||||
true,
|
||||
);
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 5,
|
||||
minWpm: 20,
|
||||
entries: [
|
||||
{ rank: 3, ...user4 },
|
||||
{ rank: 4, ...user3 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("gets result without premium", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult({ wpm: 50, isPremium: true });
|
||||
const user2 = await givenResult({ wpm: 60 });
|
||||
const user3 = await givenResult({ wpm: 40, isPremium: true });
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(
|
||||
0,
|
||||
5,
|
||||
dailyLeaderboardsConfig,
|
||||
false,
|
||||
);
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 3,
|
||||
minWpm: 40,
|
||||
entries: [
|
||||
{ rank: 1, ...user2, isPremium: undefined },
|
||||
{ rank: 2, ...user1, isPremium: undefined },
|
||||
{ rank: 3, ...user3, isPremium: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should get for friends only", async () => {
|
||||
//GIVEN
|
||||
const _user1 = await givenResult({ wpm: 90 });
|
||||
const user2 = await givenResult({ wpm: 80 });
|
||||
const _user3 = await givenResult({ wpm: 70 });
|
||||
const user4 = await givenResult({ wpm: 60 });
|
||||
const _user5 = await givenResult({ wpm: 50 });
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(
|
||||
0,
|
||||
5,
|
||||
dailyLeaderboardsConfig,
|
||||
true,
|
||||
[user2.uid, user4.uid, new ObjectId().toHexString()],
|
||||
);
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 2,
|
||||
minWpm: 60,
|
||||
entries: [
|
||||
{ rank: 2, friendsRank: 1, ...user2 },
|
||||
{ rank: 4, friendsRank: 2, ...user4 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should get for friends only with page", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult({ wpm: 105 });
|
||||
const user2 = await givenResult({ wpm: 100 });
|
||||
const _user3 = await givenResult({ wpm: 95 });
|
||||
const user4 = await givenResult({ wpm: 90 });
|
||||
const _user5 = await givenResult({ wpm: 70 });
|
||||
|
||||
//WHEN
|
||||
|
||||
const results = await lb.getResults(
|
||||
1,
|
||||
2,
|
||||
dailyLeaderboardsConfig,
|
||||
true,
|
||||
[user1.uid, user2.uid, user4.uid, new ObjectId().toHexString()],
|
||||
);
|
||||
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 3,
|
||||
minWpm: 90,
|
||||
entries: [{ rank: 4, friendsRank: 3, ...user4 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty list if no friends", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const results = await lb.getResults(
|
||||
0,
|
||||
5,
|
||||
dailyLeaderboardsConfig,
|
||||
true,
|
||||
[],
|
||||
);
|
||||
//THEN
|
||||
expect(results).toEqual({
|
||||
count: 0,
|
||||
minWpm: 0,
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRank", () => {
|
||||
it("gets rank", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult({ wpm: 50 });
|
||||
const user2 = await givenResult({ wpm: 60 });
|
||||
|
||||
//WHEN / THEN
|
||||
expect(await lb.getRank(user1.uid, dailyLeaderboardsConfig)).toEqual({
|
||||
rank: 2,
|
||||
...user1,
|
||||
});
|
||||
expect(await lb.getRank(user2.uid, dailyLeaderboardsConfig)).toEqual({
|
||||
rank: 1,
|
||||
...user2,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for unknown user", async () => {
|
||||
expect(await lb.getRank("decoy", dailyLeaderboardsConfig)).toBeNull();
|
||||
expect(
|
||||
await lb.getRank("decoy", dailyLeaderboardsConfig, [
|
||||
"unknown",
|
||||
"unknown2",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("gets rank for friends", async () => {
|
||||
//GIVEN
|
||||
const user1 = await givenResult({ wpm: 50 });
|
||||
const user2 = await givenResult({ wpm: 60 });
|
||||
const _user3 = await givenResult({ wpm: 70 });
|
||||
const friends = [user1.uid, user2.uid, "decoy"];
|
||||
|
||||
//WHEN / THEN
|
||||
expect(
|
||||
await lb.getRank(user2.uid, dailyLeaderboardsConfig, friends),
|
||||
).toEqual({ rank: 2, friendsRank: 1, ...user2 });
|
||||
|
||||
expect(
|
||||
await lb.getRank(user1.uid, dailyLeaderboardsConfig, friends),
|
||||
).toEqual({ rank: 3, friendsRank: 2, ...user1 });
|
||||
});
|
||||
});
|
||||
|
||||
it("purgeUserFromDailyLeaderboards", async () => {
|
||||
//GIVEN
|
||||
const cheater = await givenResult({ wpm: 50 });
|
||||
const user1 = await givenResult({ wpm: 60 });
|
||||
const user2 = await givenResult({ wpm: 40 });
|
||||
|
||||
//WHEN
|
||||
await DailyLeaderboards.purgeUserFromDailyLeaderboards(
|
||||
cheater.uid,
|
||||
dailyLeaderboardsConfig,
|
||||
);
|
||||
//THEN
|
||||
expect(await lb.getRank(cheater.uid, dailyLeaderboardsConfig)).toBeNull();
|
||||
expect(await lb.getResults(0, 50, dailyLeaderboardsConfig, true)).toEqual(
|
||||
{
|
||||
count: 2,
|
||||
minWpm: 40,
|
||||
entries: [
|
||||
{ rank: 1, ...user1 },
|
||||
{ rank: 2, ...user2 },
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
async function givenResult(
|
||||
entry?: Partial<RedisDailyLeaderboardEntry>,
|
||||
): Promise<RedisDailyLeaderboardEntry> {
|
||||
const uid = new ObjectId().toHexString();
|
||||
const result = {
|
||||
acc: 85,
|
||||
name: `User ${uid}`,
|
||||
raw: 100,
|
||||
wpm: 95,
|
||||
timestamp: Date.now(),
|
||||
uid: uid,
|
||||
badgeId: 2,
|
||||
consistency: 90,
|
||||
discordAvatar: `${uid}Avatar`,
|
||||
discordId: `${uid}DiscordId`,
|
||||
isPremium: false,
|
||||
...entry,
|
||||
};
|
||||
await lb.addResult(result, dailyLeaderboardsConfig);
|
||||
return result;
|
||||
}
|
||||
});
|
||||
});
|
||||
83
backend/__tests__/__testData__/auth.ts
Normal file
83
backend/__tests__/__testData__/auth.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { expect, vi } from "vitest";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { randomBytes } from "crypto";
|
||||
import { hash } from "bcrypt";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { base64UrlEncode } from "../../src/utils/misc";
|
||||
import * as ApeKeyDal from "../../src/dal/ape-keys";
|
||||
import { DecodedIdToken } from "firebase-admin/auth";
|
||||
import * as AuthUtils from "../../src/utils/auth";
|
||||
|
||||
export async function mockAuthenticateWithApeKey(
|
||||
uid: string,
|
||||
config: Configuration,
|
||||
): Promise<string> {
|
||||
if (!config.apeKeys.acceptKeys) {
|
||||
throw Error("config.apeKeys.acceptedKeys needs to be set to true");
|
||||
}
|
||||
const { apeKeyBytes, apeKeySaltRounds } = config.apeKeys;
|
||||
|
||||
const apiKey = randomBytes(apeKeyBytes).toString("base64url");
|
||||
const saltyHash = await hash(apiKey, apeKeySaltRounds);
|
||||
|
||||
const apeKey: ApeKeyDal.DBApeKey = {
|
||||
_id: new ObjectId(),
|
||||
name: "bob",
|
||||
enabled: true,
|
||||
uid,
|
||||
hash: saltyHash,
|
||||
createdOn: Date.now(),
|
||||
modifiedOn: Date.now(),
|
||||
lastUsedOn: -1,
|
||||
useCount: 0,
|
||||
};
|
||||
|
||||
const apeKeyId = new ObjectId().toHexString();
|
||||
|
||||
vi.spyOn(ApeKeyDal, "getApeKey").mockResolvedValue(apeKey);
|
||||
vi.spyOn(ApeKeyDal, "updateLastUsedOn").mockResolvedValue();
|
||||
|
||||
return base64UrlEncode(`${apeKeyId}.${apiKey}`);
|
||||
}
|
||||
|
||||
export function mockBearerAuthentication(uid: string) {
|
||||
const mockDecodedToken = {
|
||||
uid,
|
||||
email: "newuser@mail.com",
|
||||
iat: Date.now(),
|
||||
} as DecodedIdToken;
|
||||
const verifyIdTokenMock = vi.spyOn(AuthUtils, "verifyIdToken");
|
||||
|
||||
return {
|
||||
/**
|
||||
* Reset the mock and return a default token. Call this method in the `beforeEach` of all tests.
|
||||
*/
|
||||
beforeEach: (): void => {
|
||||
verifyIdTokenMock.mockClear();
|
||||
verifyIdTokenMock.mockResolvedValue(mockDecodedToken);
|
||||
},
|
||||
/**
|
||||
* Reset the mock results in the authentication to fail.
|
||||
*/
|
||||
noAuth: (): void => {
|
||||
verifyIdTokenMock.mockClear();
|
||||
},
|
||||
/**
|
||||
* verify the authentication has been called
|
||||
*/
|
||||
expectToHaveBeenCalled: (): void => {
|
||||
expect(verifyIdTokenMock).toHaveBeenCalled();
|
||||
},
|
||||
/**
|
||||
* modify the token returned by the mock. This can be used to e.g. return a stale token.
|
||||
* @param customize
|
||||
*/
|
||||
modifyToken: (customize: Partial<DecodedIdToken>): void => {
|
||||
verifyIdTokenMock.mockClear();
|
||||
verifyIdTokenMock.mockResolvedValue({
|
||||
...mockDecodedToken,
|
||||
...customize,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
24
backend/__tests__/__testData__/connections.ts
Normal file
24
backend/__tests__/__testData__/connections.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as ConnectionsDal from "../../src/dal/connections";
|
||||
|
||||
export async function createConnection(
|
||||
data: Partial<ConnectionsDal.DBConnection>,
|
||||
maxPerUser = 25,
|
||||
): Promise<ConnectionsDal.DBConnection> {
|
||||
const defaultName = "user" + new ObjectId().toHexString();
|
||||
const result = await ConnectionsDal.create(
|
||||
{
|
||||
uid: data.initiatorUid ?? new ObjectId().toHexString(),
|
||||
name: data.initiatorName ?? defaultName,
|
||||
},
|
||||
{
|
||||
uid: data.receiverUid ?? new ObjectId().toHexString(),
|
||||
name: data.receiverName ?? defaultName,
|
||||
},
|
||||
maxPerUser,
|
||||
);
|
||||
await ConnectionsDal.__testing
|
||||
.getCollection()
|
||||
.updateOne({ _id: result._id }, { $set: data });
|
||||
return { ...result, ...data };
|
||||
}
|
||||
17
backend/__tests__/__testData__/controller-test.ts
Normal file
17
backend/__tests__/__testData__/controller-test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import request from "supertest";
|
||||
import app from "../../src/app";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { mockBearerAuthentication } from "./auth";
|
||||
import { beforeEach } from "vitest";
|
||||
|
||||
export function setup() {
|
||||
const mockApp = request(app);
|
||||
const uid = new ObjectId().toHexString();
|
||||
const mockAuth = mockBearerAuthentication(uid);
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuth.beforeEach();
|
||||
});
|
||||
|
||||
return { mockApp, uid, mockAuth };
|
||||
}
|
||||
21
backend/__tests__/__testData__/monkey-error.ts
Normal file
21
backend/__tests__/__testData__/monkey-error.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { expect } from "vitest";
|
||||
import MonkeyError from "../../src/utils/error";
|
||||
import { MatcherResult } from "../vitest";
|
||||
|
||||
export function enableMonkeyErrorExpects(): void {
|
||||
expect.extend({
|
||||
toMatchMonkeyError(
|
||||
received: MonkeyError,
|
||||
expected: MonkeyError,
|
||||
): MatcherResult {
|
||||
return {
|
||||
pass:
|
||||
received.status === expected.status &&
|
||||
received.message === expected.message,
|
||||
message: () => "MonkeyError does not match:",
|
||||
actual: { status: received.status, message: received.message },
|
||||
expected: { status: expected.status, message: expected.message },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
30
backend/__tests__/__testData__/rate-limit.ts
Normal file
30
backend/__tests__/__testData__/rate-limit.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect } from "vitest";
|
||||
import { REQUEST_MULTIPLIER } from "../../src/middlewares/rate-limit";
|
||||
import { MatcherResult, ExpectedRateLimit } from "../vitest";
|
||||
import { Test as SuperTest } from "supertest";
|
||||
|
||||
export function enableRateLimitExpects(): void {
|
||||
expect.extend({
|
||||
toBeRateLimited: async (
|
||||
received: SuperTest,
|
||||
expected: ExpectedRateLimit,
|
||||
): Promise<MatcherResult> => {
|
||||
const now = Date.now();
|
||||
const { headers } = await received.expect(200);
|
||||
|
||||
const max =
|
||||
parseInt(headers["x-ratelimit-limit"] as string) / REQUEST_MULTIPLIER;
|
||||
const windowMs =
|
||||
parseInt(headers["x-ratelimit-reset"] as string) * 1000 - now;
|
||||
|
||||
return {
|
||||
pass:
|
||||
max === expected.max && Math.abs(expected.windowMs - windowMs) < 2500,
|
||||
message: () =>
|
||||
"Rate limit max not matching or windowMs is off by more then 2500ms",
|
||||
actual: { max, windowMs },
|
||||
expected: expected,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
45
backend/__tests__/__testData__/users.ts
Normal file
45
backend/__tests__/__testData__/users.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as DB from "../../src/init/db";
|
||||
import * as UserDAL from "../../src/dal/user";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { PersonalBest } from "@monkeytype/schemas/shared";
|
||||
|
||||
export async function createUser(
|
||||
user?: Partial<UserDAL.DBUser>,
|
||||
): Promise<UserDAL.DBUser> {
|
||||
const uid = new ObjectId().toHexString();
|
||||
await UserDAL.addUser("user" + uid, uid + "@example.com", uid);
|
||||
await DB.collection("users").updateOne({ uid }, { $set: { ...user } });
|
||||
return await UserDAL.getUser(uid, "test");
|
||||
}
|
||||
|
||||
export async function createUserWithoutMigration(
|
||||
user?: Partial<UserDAL.DBUser>,
|
||||
): Promise<UserDAL.DBUser> {
|
||||
const uid = new ObjectId().toHexString();
|
||||
await UserDAL.addUser("user" + uid, uid + "@example.com", uid);
|
||||
await DB.collection("users").updateOne({ uid }, { $set: { ...user } });
|
||||
await DB.collection("users").updateOne(
|
||||
{ uid },
|
||||
{ $unset: { testActivity: "" } },
|
||||
);
|
||||
|
||||
return await UserDAL.getUser(uid, "test");
|
||||
}
|
||||
|
||||
export function pb(
|
||||
wpm: number,
|
||||
acc: number = 90,
|
||||
timestamp: number = 1,
|
||||
): PersonalBest {
|
||||
return {
|
||||
acc,
|
||||
consistency: 100,
|
||||
difficulty: "normal",
|
||||
lazyMode: false,
|
||||
language: "english",
|
||||
punctuation: false,
|
||||
raw: wpm + 1,
|
||||
wpm,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
580
backend/__tests__/api/controllers/admin.spec.ts
Normal file
580
backend/__tests__/api/controllers/admin.spec.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import * as AdminUuidDal from "../../../src/dal/admin-uids";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import * as ReportDal from "../../../src/dal/report";
|
||||
import * as LogsDal from "../../../src/dal/logs";
|
||||
import GeorgeQueue from "../../../src/queues/george-queue";
|
||||
import * as AuthUtil from "../../../src/utils/auth";
|
||||
|
||||
import { enableRateLimitExpects } from "../../__testData__/rate-limit";
|
||||
import Test from "supertest/lib/test";
|
||||
|
||||
const { mockApp, uid } = setup();
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
enableRateLimitExpects();
|
||||
|
||||
describe("AdminController", () => {
|
||||
const isAdminMock = vi.spyOn(AdminUuidDal, "isAdmin");
|
||||
const logsAddImportantLog = vi.spyOn(LogsDal, "addImportantLog");
|
||||
|
||||
beforeEach(async () => {
|
||||
isAdminMock.mockClear();
|
||||
await enableAdminEndpoints(true);
|
||||
isAdminMock.mockResolvedValue(true);
|
||||
logsAddImportantLog.mockClear().mockResolvedValue();
|
||||
});
|
||||
|
||||
describe("check for admin", () => {
|
||||
it("should succeed if user is admin", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/admin")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "OK",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(isAdminMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
it("should fail if user is no admin", async () => {
|
||||
await expectFailForNonAdmin(
|
||||
mockApp.get("/admin").set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should fail if admin endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp.get("/admin").set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should be rate limited", async () => {
|
||||
await expect(
|
||||
mockApp.get("/admin").set("Authorization", `Bearer ${uid}`),
|
||||
).toBeRateLimited({ max: 1, windowMs: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle ban", () => {
|
||||
const userBannedMock = vi.spyOn(UserDal, "setBanned");
|
||||
const georgeBannedMock = vi.spyOn(GeorgeQueue, "userBanned");
|
||||
const getUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
|
||||
beforeEach(() => {
|
||||
[userBannedMock, georgeBannedMock, getUserMock].forEach((it) =>
|
||||
it.mockClear(),
|
||||
);
|
||||
userBannedMock.mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should ban user with discordId", async () => {
|
||||
//GIVEN
|
||||
const victimUid = new ObjectId().toHexString();
|
||||
getUserMock.mockResolvedValue({
|
||||
banned: false,
|
||||
discordId: "discordId",
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: victimUid })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Ban toggled",
|
||||
data: { banned: true },
|
||||
});
|
||||
|
||||
expect(getUserMock).toHaveBeenCalledWith(victimUid, "toggle ban", [
|
||||
"banned",
|
||||
"discordId",
|
||||
]);
|
||||
expect(userBannedMock).toHaveBeenCalledWith(victimUid, true);
|
||||
expect(georgeBannedMock).toHaveBeenCalledWith("discordId", true);
|
||||
});
|
||||
it("should unban user without discordId", async () => {
|
||||
//GIVEN
|
||||
const victimUid = new ObjectId().toHexString();
|
||||
getUserMock.mockResolvedValue({
|
||||
banned: true,
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: victimUid })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Ban toggled",
|
||||
data: { banned: false },
|
||||
});
|
||||
|
||||
expect(getUserMock).toHaveBeenCalledWith(victimUid, "toggle ban", [
|
||||
"banned",
|
||||
"discordId",
|
||||
]);
|
||||
expect(userBannedMock).toHaveBeenCalledWith(victimUid, false);
|
||||
expect(georgeBannedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"uid" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: new ObjectId().toHexString(), extra: "value" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no admin", async () => {
|
||||
await expectFailForNonAdmin(
|
||||
mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: new ObjectId().toHexString() })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should fail if admin endpoints are disabled", async () => {
|
||||
//GIVEN
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: new ObjectId().toHexString() })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should be rate limited", async () => {
|
||||
//GIVEN
|
||||
const victimUid = new ObjectId().toHexString();
|
||||
getUserMock.mockResolvedValue({
|
||||
banned: false,
|
||||
discordId: "discordId",
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
await expect(
|
||||
mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: victimUid })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
).toBeRateLimited({ max: 1, windowMs: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear streak hour offset", () => {
|
||||
const clearStreakHourOffset = vi.spyOn(UserDal, "clearStreakHourOffset");
|
||||
|
||||
beforeEach(() => {
|
||||
clearStreakHourOffset.mockClear();
|
||||
clearStreakHourOffset.mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should clear streak hour offset for user", async () => {
|
||||
//GIVEN
|
||||
const victimUid = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/clearStreakHourOffset")
|
||||
.send({ uid: victimUid })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Streak hour offset cleared",
|
||||
data: null,
|
||||
});
|
||||
expect(clearStreakHourOffset).toHaveBeenCalledWith(victimUid);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/clearStreakHourOffset")
|
||||
.send({})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"uid" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/clearStreakHourOffset")
|
||||
.send({ uid: new ObjectId().toHexString(), extra: "value" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no admin", async () => {
|
||||
await expectFailForNonAdmin(
|
||||
mockApp
|
||||
.post("/admin/clearStreakHourOffset")
|
||||
.send({ uid: new ObjectId().toHexString() })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should fail if admin endpoints are disabled", async () => {
|
||||
//GIVEN
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/admin/clearStreakHourOffset")
|
||||
.send({ uid: new ObjectId().toHexString() })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should be rate limited", async () => {
|
||||
//GIVEN
|
||||
const victimUid = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
await expect(
|
||||
mockApp
|
||||
.post("/admin/clearStreakHourOffset")
|
||||
.send({ uid: victimUid })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
).toBeRateLimited({ max: 1, windowMs: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("accept reports", () => {
|
||||
const getReportsMock = vi.spyOn(ReportDal, "getReports");
|
||||
const deleteReportsMock = vi.spyOn(ReportDal, "deleteReports");
|
||||
const addToInboxMock = vi.spyOn(UserDal, "addToInbox");
|
||||
|
||||
beforeEach(() => {
|
||||
[getReportsMock, deleteReportsMock, addToInboxMock].forEach((it) =>
|
||||
it.mockClear(),
|
||||
);
|
||||
deleteReportsMock.mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should accept reports", async () => {
|
||||
//GIVEN
|
||||
const reportOne = {
|
||||
id: "1",
|
||||
reason: "one",
|
||||
} as any as ReportDal.DBReport;
|
||||
const reportTwo = {
|
||||
id: "2",
|
||||
reason: "two",
|
||||
} as any as ReportDal.DBReport;
|
||||
getReportsMock.mockResolvedValue([reportOne, reportTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({
|
||||
reports: [{ reportId: reportOne.id }, { reportId: reportTwo.id }],
|
||||
})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Reports removed and users notified.",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(addToInboxMock).toHaveBeenCalledTimes(2);
|
||||
expect(deleteReportsMock).toHaveBeenCalledWith(["1", "2"]);
|
||||
});
|
||||
it("should fail wihtout mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"reports" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with empty reports", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({ reports: [] })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"reports" Array must contain at least 1 element(s)',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({ reports: [{ reportId: "1", extra2: "value" }], extra: "value" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"reports.0" Unrecognized key(s) in object: 'extra2'`,
|
||||
"Unrecognized key(s) in object: 'extra'",
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no admin", async () => {
|
||||
await expectFailForNonAdmin(
|
||||
mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({ reports: [] })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should fail if admin endpoints are disabled", async () => {
|
||||
//GIVEN
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({ reports: [] })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should be rate limited", async () => {
|
||||
//GIVEN
|
||||
getReportsMock.mockResolvedValue([{ id: "1", reason: "one" } as any]);
|
||||
|
||||
//WHEN
|
||||
await expect(
|
||||
mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({ reports: [{ reportId: "1" }] })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
).toBeRateLimited({ max: 1, windowMs: 5000 });
|
||||
});
|
||||
});
|
||||
describe("reject reports", () => {
|
||||
const getReportsMock = vi.spyOn(ReportDal, "getReports");
|
||||
const deleteReportsMock = vi.spyOn(ReportDal, "deleteReports");
|
||||
const addToInboxMock = vi.spyOn(UserDal, "addToInbox");
|
||||
|
||||
beforeEach(() => {
|
||||
[getReportsMock, deleteReportsMock, addToInboxMock].forEach((it) => {
|
||||
it.mockClear();
|
||||
deleteReportsMock.mockResolvedValue();
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject reports", async () => {
|
||||
//GIVEN
|
||||
const reportOne = {
|
||||
id: "1",
|
||||
reason: "one",
|
||||
} as any as ReportDal.DBReport;
|
||||
const reportTwo = {
|
||||
id: "2",
|
||||
reason: "two",
|
||||
} as any as ReportDal.DBReport;
|
||||
getReportsMock.mockResolvedValue([reportOne, reportTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({
|
||||
reports: [
|
||||
{ reportId: reportOne.id, reason: "test" },
|
||||
{ reportId: reportTwo.id },
|
||||
],
|
||||
})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Reports removed and users notified.",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(addToInboxMock).toHaveBeenCalledTimes(2);
|
||||
expect(deleteReportsMock).toHaveBeenCalledWith(["1", "2"]);
|
||||
});
|
||||
it("should fail wihtout mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"reports" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with empty reports", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({ reports: [] })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"reports" Array must contain at least 1 element(s)',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({ reports: [{ reportId: "1", extra2: "value" }], extra: "value" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"reports.0" Unrecognized key(s) in object: 'extra2'`,
|
||||
"Unrecognized key(s) in object: 'extra'",
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no admin", async () => {
|
||||
await expectFailForNonAdmin(
|
||||
mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({ reports: [] })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should fail if admin endpoints are disabled", async () => {
|
||||
//GIVEN
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({ reports: [] })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should be rate limited", async () => {
|
||||
//GIVEN
|
||||
getReportsMock.mockResolvedValue([{ id: "1", reason: "one" } as any]);
|
||||
|
||||
//WHEN
|
||||
await expect(
|
||||
mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({ reports: [{ reportId: "1" }] })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
).toBeRateLimited({ max: 1, windowMs: 5000 });
|
||||
});
|
||||
});
|
||||
describe("send forgot password email", () => {
|
||||
const sendForgotPasswordEmailMock = vi.spyOn(
|
||||
AuthUtil,
|
||||
"sendForgotPasswordEmail",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
sendForgotPasswordEmailMock.mockClear();
|
||||
});
|
||||
|
||||
it("should send forgot password link", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/sendForgotPasswordEmail")
|
||||
.send({ email: "meowdec@example.com" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Password reset request email sent.",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(sendForgotPasswordEmailMock).toHaveBeenCalledWith(
|
||||
"meowdec@example.com",
|
||||
);
|
||||
});
|
||||
it("should be rate limited", async () => {
|
||||
//WHEN
|
||||
await expect(
|
||||
mockApp
|
||||
.post("/admin/sendForgotPasswordEmail")
|
||||
.send({ email: "meowdec@example.com" })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
).toBeRateLimited({ max: 1, windowMs: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
async function expectFailForNonAdmin(call: Test): Promise<void> {
|
||||
isAdminMock.mockResolvedValue(false);
|
||||
const { body } = await call.expect(403);
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
}
|
||||
async function expectFailForDisabledEndpoint(call: Test): Promise<void> {
|
||||
await enableAdminEndpoints(false);
|
||||
const { body } = await call.expect(503);
|
||||
expect(body.message).toEqual("Admin endpoints are currently disabled.");
|
||||
}
|
||||
});
|
||||
async function enableAdminEndpoints(enabled: boolean): Promise<void> {
|
||||
const mockConfig = await configuration;
|
||||
mockConfig.admin = { ...mockConfig.admin, endpointsEnabled: enabled };
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig,
|
||||
);
|
||||
}
|
||||
373
backend/__tests__/api/controllers/ape-key.spec.ts
Normal file
373
backend/__tests__/api/controllers/ape-key.spec.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import { Test as SuperTest } from "supertest";
|
||||
import * as ApeKeyDal from "../../../src/dal/ape-keys";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
|
||||
const { mockApp, uid } = setup();
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
|
||||
describe("ApeKeyController", () => {
|
||||
const getUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
|
||||
beforeEach(async () => {
|
||||
await enableApeKeysEndpoints(true);
|
||||
getUserMock.mockResolvedValue(user(uid, {}));
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getUserMock.mockClear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("get ape keys", () => {
|
||||
const getApeKeysMock = vi.spyOn(ApeKeyDal, "getApeKeys");
|
||||
|
||||
afterEach(() => {
|
||||
getApeKeysMock.mockClear();
|
||||
});
|
||||
|
||||
it("should get the users config", async () => {
|
||||
//GIVEN
|
||||
const keyOne = apeKeyDb(uid);
|
||||
const keyTwo = apeKeyDb(uid);
|
||||
getApeKeysMock.mockResolvedValue([keyOne, keyTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/ape-keys")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toHaveProperty("message", "ApeKeys retrieved");
|
||||
expect(body.data).toHaveProperty(keyOne._id.toHexString(), {
|
||||
name: keyOne.name,
|
||||
enabled: keyOne.enabled,
|
||||
createdOn: keyOne.createdOn,
|
||||
modifiedOn: keyOne.modifiedOn,
|
||||
lastUsedOn: keyOne.lastUsedOn,
|
||||
});
|
||||
expect(body.data).toHaveProperty(keyTwo._id.toHexString(), {
|
||||
name: keyTwo.name,
|
||||
enabled: keyTwo.enabled,
|
||||
createdOn: keyTwo.createdOn,
|
||||
modifiedOn: keyTwo.modifiedOn,
|
||||
lastUsedOn: keyTwo.lastUsedOn,
|
||||
});
|
||||
expect(body.data).keys([keyOne._id, keyTwo._id]);
|
||||
|
||||
expect(getApeKeysMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
it("should fail if apeKeys endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp.get("/ape-keys").set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should fail if user has no apeKey permissions", async () => {
|
||||
await expectFailForNoPermissions(
|
||||
mockApp.get("/ape-keys").set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add ape key", () => {
|
||||
const addApeKeyMock = vi.spyOn(ApeKeyDal, "addApeKey");
|
||||
const countApeKeysMock = vi.spyOn(ApeKeyDal, "countApeKeysForUser");
|
||||
|
||||
beforeEach(() => {
|
||||
countApeKeysMock.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
addApeKeyMock.mockClear();
|
||||
countApeKeysMock.mockClear();
|
||||
});
|
||||
|
||||
it("should add ape key", async () => {
|
||||
//GIVEN
|
||||
addApeKeyMock.mockResolvedValue("1");
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/ape-keys")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ name: "test", enabled: true })
|
||||
.expect(200);
|
||||
|
||||
expect(body.message).toEqual("ApeKey generated");
|
||||
expect(body.data).keys("apeKey", "apeKeyDetails", "apeKeyId");
|
||||
expect(body.data.apeKey).not.toBeNull();
|
||||
|
||||
expect(body.data.apeKeyDetails).toStrictEqual({
|
||||
createdOn: 1000,
|
||||
enabled: true,
|
||||
lastUsedOn: -1,
|
||||
modifiedOn: 1000,
|
||||
name: "test",
|
||||
});
|
||||
|
||||
expect(body.data.apeKeyId).toEqual("1");
|
||||
|
||||
expect(addApeKeyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdOn: 1000,
|
||||
enabled: true,
|
||||
lastUsedOn: -1,
|
||||
modifiedOn: 1000,
|
||||
name: "test",
|
||||
uid: uid,
|
||||
useCount: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/ape-keys")
|
||||
.send({})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"name" Required`, `"enabled" Required`],
|
||||
});
|
||||
});
|
||||
it("should fail with extra properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/ape-keys")
|
||||
.send({ name: "test", enabled: true, extra: "value" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if max apeKeys is reached", async () => {
|
||||
//GIVEN
|
||||
countApeKeysMock.mockResolvedValue(1);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/ape-keys")
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(409);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual(
|
||||
"Maximum number of ApeKeys have been generated",
|
||||
);
|
||||
});
|
||||
it("should fail if apeKeys endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/ape-keys")
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should fail if user has no apeKey permissions", async () => {
|
||||
await expectFailForNoPermissions(
|
||||
mockApp
|
||||
.post("/ape-keys")
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit ape key", () => {
|
||||
const editApeKeyMock = vi.spyOn(ApeKeyDal, "editApeKey");
|
||||
const apeKeyId = new ObjectId().toHexString();
|
||||
|
||||
afterEach(() => {
|
||||
editApeKeyMock.mockClear();
|
||||
});
|
||||
|
||||
it("should edit ape key", async () => {
|
||||
//GIVEN
|
||||
editApeKeyMock.mockResolvedValue();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "new", enabled: false })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("ApeKey updated");
|
||||
expect(editApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId, "new", false);
|
||||
});
|
||||
it("should edit ape key with single property", async () => {
|
||||
//GIVEN
|
||||
editApeKeyMock.mockResolvedValue();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "new" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("ApeKey updated");
|
||||
expect(editApeKeyMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
apeKeyId,
|
||||
"new",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
it("should fail with missing path", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.patch(`/ape-keys/`)
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(404);
|
||||
});
|
||||
it("should fail with extra properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "new", extra: "value" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail if apeKeys endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should fail if user has no apeKey permissions", async () => {
|
||||
await expectFailForNoPermissions(
|
||||
mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("delete ape key", () => {
|
||||
const deleteApeKeyMock = vi.spyOn(ApeKeyDal, "deleteApeKey");
|
||||
const apeKeyId = new ObjectId().toHexString();
|
||||
|
||||
afterEach(() => {
|
||||
deleteApeKeyMock.mockClear();
|
||||
});
|
||||
|
||||
it("should delete ape key", async () => {
|
||||
//GIVEN
|
||||
|
||||
deleteApeKeyMock.mockResolvedValue();
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.delete(`/ape-keys/${apeKeyId}`)
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("ApeKey deleted");
|
||||
expect(deleteApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId);
|
||||
});
|
||||
it("should fail with missing path", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.delete(`/ape-keys/`)
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(404);
|
||||
});
|
||||
it("should fail if apeKeys endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.delete(`/ape-keys/${apeKeyId}`)
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail if user has no apeKey permissions", async () => {
|
||||
await expectFailForNoPermissions(
|
||||
mockApp
|
||||
.delete(`/ape-keys/${apeKeyId}`)
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
async function expectFailForNoPermissions(call: SuperTest): Promise<void> {
|
||||
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
|
||||
const { body } = await call.expect(403);
|
||||
expect(body.message).toEqual(
|
||||
"You have lost access to ape keys, please contact support",
|
||||
);
|
||||
}
|
||||
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
|
||||
await enableApeKeysEndpoints(false);
|
||||
const { body } = await call.expect(503);
|
||||
expect(body.message).toEqual("ApeKeys are currently disabled.");
|
||||
}
|
||||
});
|
||||
|
||||
function apeKeyDb(
|
||||
uid: string,
|
||||
data?: Partial<ApeKeyDal.DBApeKey>,
|
||||
): ApeKeyDal.DBApeKey {
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
uid,
|
||||
hash: "hash",
|
||||
useCount: 1,
|
||||
name: "name",
|
||||
enabled: true,
|
||||
createdOn: Math.random() * Date.now(),
|
||||
lastUsedOn: Math.random() * Date.now(),
|
||||
modifiedOn: Math.random() * Date.now(),
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
async function enableApeKeysEndpoints(enabled: boolean): Promise<void> {
|
||||
const mockConfig = await configuration;
|
||||
mockConfig.apeKeys = {
|
||||
...mockConfig.apeKeys,
|
||||
endpointsEnabled: enabled,
|
||||
maxKeysPerUser: 1,
|
||||
};
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig,
|
||||
);
|
||||
}
|
||||
|
||||
function user(uid: string, data: Partial<UserDal.DBUser>): UserDal.DBUser {
|
||||
return {
|
||||
uid,
|
||||
...data,
|
||||
} as UserDal.DBUser;
|
||||
}
|
||||
133
backend/__tests__/api/controllers/config.spec.ts
Normal file
133
backend/__tests__/api/controllers/config.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import * as ConfigDal from "../../../src/dal/config";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
const { mockApp, uid } = setup();
|
||||
|
||||
describe("ConfigController", () => {
|
||||
describe("get config", () => {
|
||||
const getConfigMock = vi.spyOn(ConfigDal, "getConfig");
|
||||
|
||||
afterEach(() => {
|
||||
getConfigMock.mockClear();
|
||||
});
|
||||
|
||||
it("should get the users config", async () => {
|
||||
//GIVEN
|
||||
getConfigMock.mockResolvedValue({
|
||||
_id: new ObjectId(),
|
||||
uid: uid,
|
||||
config: { language: "english" },
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/configs")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Configuration retrieved",
|
||||
data: { language: "english" },
|
||||
});
|
||||
|
||||
expect(getConfigMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
});
|
||||
describe("update config", () => {
|
||||
const saveConfigMock = vi.spyOn(ConfigDal, "saveConfig");
|
||||
|
||||
afterEach(() => {
|
||||
saveConfigMock.mockClear();
|
||||
});
|
||||
|
||||
it("should update the users config", async () => {
|
||||
//GIVEN
|
||||
saveConfigMock.mockResolvedValue({} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/configs")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({ language: "english" })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Config updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(saveConfigMock).toHaveBeenCalledWith(uid, {
|
||||
language: "english",
|
||||
});
|
||||
});
|
||||
it("should fail with unknown config", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/configs")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({ unknownValue: "unknown" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`Unrecognized key(s) in object: 'unknownValue'`],
|
||||
});
|
||||
|
||||
expect(saveConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail with invalid configs", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/configs")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({ autoSwitchTheme: "yes", confidenceMode: "pretty" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
|
||||
`"autoSwitchTheme" Expected boolean, received string`,
|
||||
],
|
||||
});
|
||||
|
||||
expect(saveConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("delete config", () => {
|
||||
const deleteConfigMock = vi.spyOn(ConfigDal, "deleteConfig");
|
||||
|
||||
afterEach(() => {
|
||||
deleteConfigMock.mockClear();
|
||||
});
|
||||
|
||||
it("should delete the users config", async () => {
|
||||
//GIVEN
|
||||
deleteConfigMock.mockResolvedValue();
|
||||
|
||||
//WHEN
|
||||
|
||||
const { body } = await mockApp
|
||||
.delete("/configs")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Config deleted",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
});
|
||||
});
|
||||
183
backend/__tests__/api/controllers/configuration.spec.ts
Normal file
183
backend/__tests__/api/controllers/configuration.spec.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import {
|
||||
BASE_CONFIGURATION,
|
||||
CONFIGURATION_FORM_SCHEMA,
|
||||
} from "../../../src/constants/base-configuration";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import type { Configuration as ConfigurationType } from "@monkeytype/schemas/configuration";
|
||||
import * as Misc from "../../../src/utils/misc";
|
||||
import * as AdminUuids from "../../../src/dal/admin-uids";
|
||||
|
||||
const { mockApp, uid, mockAuth } = setup();
|
||||
|
||||
describe("Configuration Controller", () => {
|
||||
const isDevEnvironmentMock = vi.spyOn(Misc, "isDevEnvironment");
|
||||
|
||||
const isAdminMock = vi.spyOn(AdminUuids, "isAdmin");
|
||||
|
||||
beforeEach(() => {
|
||||
isAdminMock.mockClear();
|
||||
|
||||
isDevEnvironmentMock.mockClear();
|
||||
|
||||
isDevEnvironmentMock.mockReturnValue(true);
|
||||
isAdminMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("getConfiguration", () => {
|
||||
it("should get without authentication", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp.get("/configuration").expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Configuration retrieved",
|
||||
data: BASE_CONFIGURATION,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfigurationSchema", () => {
|
||||
it("should get without authentication on dev", async () => {
|
||||
//GIVEN
|
||||
mockAuth.noAuth();
|
||||
//WHEN
|
||||
const { body } = await mockApp.get("/configuration/schema").expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Configuration schema retrieved",
|
||||
data: CONFIGURATION_FORM_SCHEMA,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail without authentication on prod", async () => {
|
||||
//GIVEN
|
||||
isDevEnvironmentMock.mockReturnValue(false);
|
||||
|
||||
//WHEN
|
||||
await mockApp.get("/configuration/schema").expect(401);
|
||||
});
|
||||
it("should get with authentication on prod", async () => {
|
||||
//GIVEN
|
||||
isDevEnvironmentMock.mockReturnValue(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/configuration/schema")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Configuration schema retrieved",
|
||||
data: CONFIGURATION_FORM_SCHEMA,
|
||||
});
|
||||
|
||||
mockAuth.expectToHaveBeenCalled();
|
||||
});
|
||||
it("should fail with non-admin user on prod", async () => {
|
||||
//GIVEN
|
||||
isDevEnvironmentMock.mockReturnValue(false);
|
||||
isAdminMock.mockResolvedValue(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/configuration/schema")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
mockAuth.expectToHaveBeenCalled();
|
||||
expect(isAdminMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConfiguration", () => {
|
||||
const patchConfigurationMock = vi.spyOn(
|
||||
Configuration,
|
||||
"patchConfiguration",
|
||||
);
|
||||
beforeEach(() => {
|
||||
patchConfigurationMock.mockClear();
|
||||
patchConfigurationMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should update without authentication on dev", async () => {
|
||||
//GIVEN
|
||||
mockAuth.noAuth();
|
||||
const patch = {
|
||||
users: {
|
||||
premium: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as Partial<ConfigurationType>;
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/configuration")
|
||||
.send({ configuration: patch })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Configuration updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(patchConfigurationMock).toHaveBeenCalledWith(patch);
|
||||
});
|
||||
|
||||
it("should fail update without authentication on prod", async () => {
|
||||
//GIVEN
|
||||
mockAuth.noAuth();
|
||||
isDevEnvironmentMock.mockReturnValue(false);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.patch("/configuration")
|
||||
.send({ configuration: {} })
|
||||
.expect(401);
|
||||
|
||||
//THEN
|
||||
expect(patchConfigurationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should update with authentication on prod", async () => {
|
||||
//GIVEN
|
||||
isDevEnvironmentMock.mockReturnValue(false);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.patch("/configuration")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send({ configuration: {} })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(patchConfigurationMock).toHaveBeenCalled();
|
||||
mockAuth.expectToHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fail for non admin users on prod", async () => {
|
||||
//GIVEN
|
||||
isDevEnvironmentMock.mockReturnValue(false);
|
||||
isAdminMock.mockResolvedValue(false);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.patch("/configuration")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send({ configuration: {} })
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(patchConfigurationMock).not.toHaveBeenCalled();
|
||||
expect(isAdminMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
});
|
||||
});
|
||||
397
backend/__tests__/api/controllers/connections.spec.ts
Normal file
397
backend/__tests__/api/controllers/connections.spec.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import request, { Test as SuperTest } from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import { mockBearerAuthentication } from "../../__testData__/auth";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as ConnectionsDal from "../../../src/dal/connections";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
|
||||
const mockApp = request(app);
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
const uid = new ObjectId().toHexString();
|
||||
const mockAuth = mockBearerAuthentication(uid);
|
||||
|
||||
describe("ConnectionsController", () => {
|
||||
beforeEach(async () => {
|
||||
await enableConnectionsEndpoints(true);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
mockAuth.beforeEach();
|
||||
});
|
||||
|
||||
describe("get connections", () => {
|
||||
const getConnectionsMock = vi.spyOn(ConnectionsDal, "getConnections");
|
||||
|
||||
beforeEach(() => {
|
||||
getConnectionsMock.mockClear();
|
||||
});
|
||||
|
||||
it("should get for the current user", async () => {
|
||||
//GIVEN
|
||||
const friend: ConnectionsDal.DBConnection = {
|
||||
_id: new ObjectId(),
|
||||
lastModified: 42,
|
||||
initiatorUid: new ObjectId().toHexString(),
|
||||
initiatorName: "Bob",
|
||||
receiverUid: new ObjectId().toHexString(),
|
||||
receiverName: "Kevin",
|
||||
status: "pending",
|
||||
key: "key",
|
||||
};
|
||||
|
||||
getConnectionsMock.mockResolvedValue([friend]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/connections")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.data).toEqual([
|
||||
{ ...friend, _id: friend._id.toHexString(), key: undefined },
|
||||
]);
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by status", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ status: "accepted" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
status: ["accepted"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by multiple status", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ status: ["accepted", "blocked"] })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
status: ["accepted", "blocked"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by type incoming", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ type: "incoming" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
receiverUid: uid,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by type outgoing", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ type: "outgoing" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by multiple types", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ type: ["incoming", "outgoing"] })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if connections endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp.get("/connections").set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.get("/connections").expect(401);
|
||||
});
|
||||
it("should fail for unknown query parameter", async () => {
|
||||
const { body } = await mockApp
|
||||
.get("/connections")
|
||||
.query({ extra: "yes" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("create connection", () => {
|
||||
const getUserByNameMock = vi.spyOn(UserDal, "getUserByName");
|
||||
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
const createUserMock = vi.spyOn(ConnectionsDal, "create");
|
||||
|
||||
beforeEach(() => {
|
||||
[getUserByNameMock, getPartialUserMock, createUserMock].forEach((it) =>
|
||||
it.mockClear(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should create", async () => {
|
||||
//GIVEN
|
||||
const me = { uid, name: "Bob" };
|
||||
const myFriend = { uid: new ObjectId().toHexString(), name: "Kevin" };
|
||||
getUserByNameMock.mockResolvedValue(myFriend as any);
|
||||
getPartialUserMock.mockResolvedValue(me as any);
|
||||
|
||||
const result: ConnectionsDal.DBConnection = {
|
||||
_id: new ObjectId(),
|
||||
lastModified: 42,
|
||||
initiatorUid: me.uid,
|
||||
initiatorName: me.name,
|
||||
receiverUid: myFriend.uid,
|
||||
receiverName: myFriend.name,
|
||||
key: "test",
|
||||
status: "pending",
|
||||
};
|
||||
createUserMock.mockResolvedValue(result);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/connections")
|
||||
.send({ receiverName: "Kevin" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.data).toEqual({
|
||||
_id: result._id.toHexString(),
|
||||
lastModified: 42,
|
||||
initiatorUid: me.uid,
|
||||
initiatorName: me.name,
|
||||
receiverUid: myFriend.uid,
|
||||
receiverName: myFriend.name,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
expect(getUserByNameMock).toHaveBeenCalledWith(
|
||||
"Kevin",
|
||||
"create connection",
|
||||
);
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"create connection",
|
||||
["uid", "name"],
|
||||
);
|
||||
expect(createUserMock).toHaveBeenCalledWith(me, myFriend, 100);
|
||||
});
|
||||
|
||||
it("should fail if user and receiver are the same", async () => {
|
||||
//GIVEN
|
||||
const me = { uid, name: "Bob" };
|
||||
|
||||
getUserByNameMock.mockResolvedValue(me as any);
|
||||
getPartialUserMock.mockResolvedValue(me as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/connections")
|
||||
.send({ receiverName: "Bob" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(400);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You cannot be your own friend, sorry.");
|
||||
});
|
||||
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/connections")
|
||||
.send({})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"receiverName" Required`],
|
||||
});
|
||||
});
|
||||
it("should fail with extra properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/connections")
|
||||
.send({ receiverName: "1", extra: "value" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if connections endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/connections")
|
||||
.send({ receiverName: "1" })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.post("/connections").expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete connection", () => {
|
||||
const deleteByIdMock = vi.spyOn(ConnectionsDal, "deleteById");
|
||||
|
||||
beforeEach(() => {
|
||||
deleteByIdMock.mockClear().mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should delete by id", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.delete("/connections/1")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(deleteByIdMock).toHaveBeenCalledWith(uid, "1");
|
||||
});
|
||||
it("should fail if connections endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp.delete("/connections/1").set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.delete("/connections/1").expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update connection", () => {
|
||||
const updateStatusMock = vi.spyOn(ConnectionsDal, "updateStatus");
|
||||
|
||||
beforeEach(() => {
|
||||
updateStatusMock.mockClear().mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should accept", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "accepted" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "accepted");
|
||||
});
|
||||
it("should block", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "blocked" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "blocked");
|
||||
});
|
||||
|
||||
it("should fail for invalid status", async () => {
|
||||
const { body } = await mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "invalid" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"status" Invalid enum value. Expected 'accepted' | 'blocked', received 'invalid'`,
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail if connections endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "accepted" })
|
||||
.set("Authorization", `Bearer ${uid}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "accepted" })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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: SuperTest): Promise<void> {
|
||||
await enableConnectionsEndpoints(false);
|
||||
const { body } = await call.expect(503);
|
||||
expect(body.message).toEqual("Connections are not available at this time.");
|
||||
}
|
||||
56
backend/__tests__/api/controllers/dev.spec.ts
Normal file
56
backend/__tests__/api/controllers/dev.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import * as Misc from "../../../src/utils/misc";
|
||||
|
||||
const { mockApp } = setup();
|
||||
describe("DevController", () => {
|
||||
describe("generate testData", () => {
|
||||
const isDevEnvironmentMock = vi.spyOn(Misc, "isDevEnvironment");
|
||||
|
||||
beforeEach(() => {
|
||||
isDevEnvironmentMock.mockClear();
|
||||
isDevEnvironmentMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should fail on prod", async () => {
|
||||
//GIVEN
|
||||
isDevEnvironmentMock.mockReturnValue(false);
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/dev/generateData")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send({ username: "test" })
|
||||
.expect(503);
|
||||
//THEN
|
||||
expect(body.message).toEqual(
|
||||
"Development endpoints are only available in DEV mode.",
|
||||
);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/dev/generateData")
|
||||
.send({})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"username" Required`],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/dev/generateData")
|
||||
.send({ username: "Bob", extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1491
backend/__tests__/api/controllers/leaderboard.spec.ts
Normal file
1491
backend/__tests__/api/controllers/leaderboard.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
501
backend/__tests__/api/controllers/preset.spec.ts
Normal file
501
backend/__tests__/api/controllers/preset.spec.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import * as PresetDal from "../../../src/dal/preset";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
const { mockApp, uid } = setup();
|
||||
|
||||
describe("PresetController", () => {
|
||||
describe("get presets", () => {
|
||||
const getPresetsMock = vi.spyOn(PresetDal, "getPresets");
|
||||
|
||||
afterEach(() => {
|
||||
getPresetsMock.mockClear();
|
||||
});
|
||||
|
||||
it("should get the users presets", async () => {
|
||||
//GIVEN
|
||||
const presetOne = {
|
||||
_id: new ObjectId(),
|
||||
uid: uid,
|
||||
name: "test1",
|
||||
config: { language: "english" },
|
||||
};
|
||||
const presetTwo = {
|
||||
_id: new ObjectId(),
|
||||
uid: uid,
|
||||
name: "test2",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
};
|
||||
//@ts-expect-error
|
||||
getPresetsMock.mockResolvedValue([presetOne, presetTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Presets retrieved",
|
||||
data: [
|
||||
{
|
||||
_id: presetOne._id.toHexString(),
|
||||
name: "test1",
|
||||
config: { language: "english" },
|
||||
},
|
||||
{
|
||||
_id: presetTwo._id.toHexString(),
|
||||
name: "test2",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(getPresetsMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
it("should return empty array if user has no presets", async () => {
|
||||
//GIVEN
|
||||
getPresetsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Presets retrieved",
|
||||
data: [],
|
||||
});
|
||||
|
||||
expect(getPresetsMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add preset", () => {
|
||||
const addPresetMock = vi.spyOn(PresetDal, "addPreset");
|
||||
|
||||
afterEach(() => {
|
||||
addPresetMock.mockClear();
|
||||
});
|
||||
|
||||
it("should add the users full preset", async () => {
|
||||
//GIVEN
|
||||
addPresetMock.mockResolvedValue({ presetId: "1" });
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({
|
||||
name: "new",
|
||||
config: {
|
||||
language: "english",
|
||||
tags: ["one", "two"],
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset created",
|
||||
data: { presetId: "1" },
|
||||
});
|
||||
|
||||
expect(addPresetMock).toHaveBeenCalledWith(uid, {
|
||||
name: "new",
|
||||
config: { language: "english", tags: ["one", "two"] },
|
||||
});
|
||||
});
|
||||
it("should add the users partial preset", async () => {
|
||||
//GIVEN
|
||||
addPresetMock.mockResolvedValue({ presetId: "1" });
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({
|
||||
name: "new",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset created",
|
||||
data: { presetId: "1" },
|
||||
});
|
||||
|
||||
expect(addPresetMock).toHaveBeenCalledWith(uid, {
|
||||
name: "new",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
});
|
||||
});
|
||||
it("should fail for no setting groups in partial presets", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({
|
||||
name: "update",
|
||||
settingGroups: [],
|
||||
config: {},
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"settingGroups" Array must contain at least 1 element(s)`,
|
||||
],
|
||||
});
|
||||
expect(addPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should not fail with emtpy config", async () => {
|
||||
//GIVEN
|
||||
|
||||
addPresetMock.mockResolvedValue({ presetId: "1" });
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({ name: "new", config: {} })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset created",
|
||||
data: { presetId: "1" },
|
||||
});
|
||||
|
||||
expect(addPresetMock).toHaveBeenCalledWith(uid, {
|
||||
name: "new",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
it("should fail with missing mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({})
|
||||
.expect(422);
|
||||
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"name" Required`, `"config" Required`],
|
||||
});
|
||||
expect(addPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail with invalid preset", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({
|
||||
_id: "1",
|
||||
name: "update",
|
||||
extra: "extra",
|
||||
config: {
|
||||
extra: "extra",
|
||||
autoSwitchTheme: "yes",
|
||||
confidenceMode: "pretty",
|
||||
},
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
|
||||
`"config.autoSwitchTheme" Expected boolean, received string`,
|
||||
`"config" Unrecognized key(s) in object: 'extra'`,
|
||||
`Unrecognized key(s) in object: '_id', 'extra'`,
|
||||
],
|
||||
});
|
||||
|
||||
expect(addPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail with duplicate group settings in partial preset", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({
|
||||
name: "new",
|
||||
settingGroups: ["hideElements", "hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"settingGroups" No duplicates allowed.`],
|
||||
});
|
||||
|
||||
expect(addPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update preset", () => {
|
||||
const editPresetMock = vi.spyOn(PresetDal, "editPreset");
|
||||
|
||||
afterEach(() => {
|
||||
editPresetMock.mockClear();
|
||||
});
|
||||
|
||||
it("should update the users preset", async () => {
|
||||
//GIVEN
|
||||
editPresetMock.mockResolvedValue({} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({
|
||||
_id: "1",
|
||||
name: "new",
|
||||
config: {
|
||||
language: "english",
|
||||
tags: ["one", "two"],
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(editPresetMock).toHaveBeenCalledWith(uid, {
|
||||
_id: "1",
|
||||
name: "new",
|
||||
config: { language: "english", tags: ["one", "two"] },
|
||||
});
|
||||
});
|
||||
it("should update the users partial preset", async () => {
|
||||
//GIVEN
|
||||
editPresetMock.mockResolvedValue({} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({
|
||||
_id: "1",
|
||||
name: "new",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(editPresetMock).toHaveBeenCalledWith(uid, {
|
||||
_id: "1",
|
||||
name: "new",
|
||||
settingGroups: ["hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
});
|
||||
});
|
||||
it("should not fail with emtpy config", async () => {
|
||||
//GIVEN
|
||||
|
||||
editPresetMock.mockResolvedValue({} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({ _id: "1", name: "new", config: {} })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(editPresetMock).toHaveBeenCalledWith(uid, {
|
||||
_id: "1",
|
||||
name: "new",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
it("should fail with missing mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({})
|
||||
.expect(422);
|
||||
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"_id" Required`, `"name" Required`],
|
||||
});
|
||||
expect(editPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail with invalid preset", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({
|
||||
_id: "1",
|
||||
name: "update",
|
||||
extra: "extra",
|
||||
settingGroups: ["mappers"],
|
||||
config: {
|
||||
extra: "extra",
|
||||
autoSwitchTheme: "yes",
|
||||
confidenceMode: "pretty",
|
||||
},
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"settingGroups.0" Invalid enum value. Expected 'test' | 'behavior' | 'input' | 'sound' | 'caret' | 'appearance' | 'theme' | 'hideElements' | 'hidden' | 'ads', received 'mappers'`,
|
||||
`"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
|
||||
`"config.autoSwitchTheme" Expected boolean, received string`,
|
||||
`"config" Unrecognized key(s) in object: 'extra'`,
|
||||
`Unrecognized key(s) in object: 'extra'`,
|
||||
],
|
||||
});
|
||||
|
||||
expect(editPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail with duplicate group settings in partial preset", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.accept("application/json")
|
||||
.send({
|
||||
_id: "1",
|
||||
name: "new",
|
||||
settingGroups: ["hideElements", "hideElements"],
|
||||
config: {
|
||||
showKeyTips: true,
|
||||
capsLockWarning: true,
|
||||
showOutOfFocusWarning: true,
|
||||
showAverage: "off",
|
||||
},
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"settingGroups" No duplicates allowed.`],
|
||||
});
|
||||
|
||||
expect(editPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("delete config", () => {
|
||||
const deletePresetMock = vi.spyOn(PresetDal, "removePreset");
|
||||
|
||||
afterEach(() => {
|
||||
deletePresetMock.mockClear();
|
||||
});
|
||||
|
||||
it("should delete the users preset", async () => {
|
||||
//GIVEN
|
||||
deletePresetMock.mockResolvedValue();
|
||||
|
||||
//WHEN
|
||||
|
||||
const { body } = await mockApp
|
||||
.delete("/presets/1")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset deleted",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(deletePresetMock).toHaveBeenCalledWith(uid, "1");
|
||||
});
|
||||
it("should fail without preset _id", async () => {
|
||||
//GIVEN
|
||||
deletePresetMock.mockResolvedValue();
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.delete("/presets/")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(404);
|
||||
|
||||
expect(deletePresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
81
backend/__tests__/api/controllers/psa.spec.ts
Normal file
81
backend/__tests__/api/controllers/psa.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import * as PsaDal from "../../../src/dal/psa";
|
||||
import * as Prometheus from "../../../src/utils/prometheus";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
const { mockApp, uid } = setup();
|
||||
|
||||
describe("Psa Controller", () => {
|
||||
describe("get psa", () => {
|
||||
const getPsaMock = vi.spyOn(PsaDal, "get");
|
||||
const recordClientVersionMock = vi.spyOn(Prometheus, "recordClientVersion");
|
||||
|
||||
afterEach(() => {
|
||||
getPsaMock.mockClear();
|
||||
recordClientVersionMock.mockClear();
|
||||
});
|
||||
|
||||
it("get psas without authorization", async () => {
|
||||
//GIVEN
|
||||
const psaOne: PsaDal.DBPSA = {
|
||||
_id: new ObjectId(),
|
||||
message: "test2",
|
||||
date: 1000,
|
||||
level: 1,
|
||||
sticky: true,
|
||||
};
|
||||
const psaTwo: PsaDal.DBPSA = {
|
||||
_id: new ObjectId(),
|
||||
message: "test2",
|
||||
date: 2000,
|
||||
level: 2,
|
||||
sticky: false,
|
||||
};
|
||||
getPsaMock.mockResolvedValue([psaOne, psaTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp.get("/psas").expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "PSAs retrieved",
|
||||
data: [
|
||||
{
|
||||
_id: psaOne._id.toHexString(),
|
||||
date: 1000,
|
||||
level: 1,
|
||||
message: "test2",
|
||||
sticky: true,
|
||||
},
|
||||
{
|
||||
_id: psaTwo._id.toHexString(),
|
||||
date: 2000,
|
||||
level: 2,
|
||||
message: "test2",
|
||||
sticky: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(recordClientVersionMock).toHaveBeenCalledWith("unknown");
|
||||
});
|
||||
it("get psas with authorization", async () => {
|
||||
await mockApp
|
||||
.get("/psas")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it("get psas records x-client-version", async () => {
|
||||
await mockApp.get("/psas").set("x-client-version", "1.0").expect(200);
|
||||
|
||||
expect(recordClientVersionMock).toHaveBeenCalledWith("1.0");
|
||||
});
|
||||
it("get psas records client-version", async () => {
|
||||
await mockApp.get("/psas").set("client-version", "2.0").expect(200);
|
||||
|
||||
expect(recordClientVersionMock).toHaveBeenCalledWith("2.0");
|
||||
});
|
||||
});
|
||||
});
|
||||
144
backend/__tests__/api/controllers/public.spec.ts
Normal file
144
backend/__tests__/api/controllers/public.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import * as PublicDal from "../../../src/dal/public";
|
||||
|
||||
const { mockApp } = setup();
|
||||
|
||||
describe("PublicController", () => {
|
||||
describe("get speed histogram", () => {
|
||||
const getSpeedHistogramMock = vi.spyOn(PublicDal, "getSpeedHistogram");
|
||||
|
||||
afterEach(() => {
|
||||
getSpeedHistogramMock.mockClear();
|
||||
});
|
||||
|
||||
it("gets for english time 60", async () => {
|
||||
//GIVEN
|
||||
getSpeedHistogramMock.mockResolvedValue({ "0": 1, "10": 2 });
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({ language: "english", mode: "time", mode2: "60" })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Public speed histogram retrieved",
|
||||
data: { "0": 1, "10": 2 },
|
||||
});
|
||||
|
||||
expect(getSpeedHistogramMock).toHaveBeenCalledWith(
|
||||
"english",
|
||||
"time",
|
||||
"60",
|
||||
);
|
||||
});
|
||||
|
||||
it("gets for mode", async () => {
|
||||
for (const mode of ["time", "words", "quote", "zen", "custom"]) {
|
||||
const response = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({ language: "english", mode, mode2: "custom" });
|
||||
expect(response.status, "for mode " + mode).toEqual(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("gets for mode2", async () => {
|
||||
for (const mode2 of [
|
||||
"10",
|
||||
"25",
|
||||
"50",
|
||||
"100",
|
||||
"15",
|
||||
"30",
|
||||
"60",
|
||||
"120",
|
||||
"zen",
|
||||
"custom",
|
||||
]) {
|
||||
const response = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({ language: "english", mode: "words", mode2 });
|
||||
|
||||
expect(response.status, "for mode2 " + mode2).toEqual(200);
|
||||
}
|
||||
});
|
||||
it("fails for missing query", async () => {
|
||||
const { body } = await mockApp.get("/public/speedHistogram").expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: [
|
||||
'"language" Required',
|
||||
'"mode" Required',
|
||||
'"mode2" Needs to be either a number, "zen" or "custom".',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("fails for invalid query", async () => {
|
||||
const { body } = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({
|
||||
language: "en?gli.sh",
|
||||
mode: "unknownMode",
|
||||
mode2: "unknownMode2",
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: [
|
||||
'"language" Invalid enum value. Must be a supported language',
|
||||
`"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`,
|
||||
'"mode2" Needs to be a number or a number represented as a string e.g. "10".',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("fails for unknown query", async () => {
|
||||
const { body } = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({
|
||||
language: "english",
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
extra: "value",
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("get typing stats", () => {
|
||||
const getTypingStatsMock = vi.spyOn(PublicDal, "getTypingStats");
|
||||
|
||||
afterEach(() => {
|
||||
getTypingStatsMock.mockClear();
|
||||
});
|
||||
|
||||
it("gets without authentication", async () => {
|
||||
//GIVEN
|
||||
getTypingStatsMock.mockResolvedValue({
|
||||
testsCompleted: 23,
|
||||
testsStarted: 42,
|
||||
timeTyping: 1000,
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp.get("/public/typingStats").expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Public typing stats retrieved",
|
||||
data: {
|
||||
testsCompleted: 23,
|
||||
testsStarted: 42,
|
||||
timeTyping: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
896
backend/__tests__/api/controllers/quotes.spec.ts
Normal file
896
backend/__tests__/api/controllers/quotes.spec.ts
Normal file
@@ -0,0 +1,896 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import * as NewQuotesDal from "../../../src/dal/new-quotes";
|
||||
import type { DBNewQuote } from "../../../src/dal/new-quotes";
|
||||
import * as QuoteRatingsDal from "../../../src/dal/quote-ratings";
|
||||
import * as ReportDal from "../../../src/dal/report";
|
||||
import * as LogsDal from "../../../src/dal/logs";
|
||||
import * as Captcha from "../../../src/utils/captcha";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { ApproveQuote } from "@monkeytype/schemas/quotes";
|
||||
|
||||
const { mockApp, uid } = setup();
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
|
||||
describe("QuotesController", () => {
|
||||
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
const logsAddLogMock = vi.spyOn(LogsDal, "addLog");
|
||||
|
||||
beforeEach(() => {
|
||||
enableQuotes(true);
|
||||
|
||||
const user = { quoteMod: true, name: "Bob" } as any;
|
||||
getPartialUserMock.mockClear().mockResolvedValue(user);
|
||||
logsAddLogMock.mockClear().mockResolvedValue();
|
||||
});
|
||||
|
||||
describe("getQuotes", () => {
|
||||
const getQuotesMock = vi.spyOn(NewQuotesDal, "get");
|
||||
|
||||
beforeEach(() => {
|
||||
getQuotesMock.mockClear();
|
||||
getQuotesMock.mockResolvedValue([]);
|
||||
});
|
||||
it("should return quotes", async () => {
|
||||
//GIVEN
|
||||
const quoteOne: DBNewQuote = {
|
||||
_id: new ObjectId(),
|
||||
text: "test",
|
||||
source: "Bob",
|
||||
language: "english",
|
||||
submittedBy: "Kevin",
|
||||
timestamp: 1000,
|
||||
approved: true,
|
||||
};
|
||||
const quoteTwo: DBNewQuote = {
|
||||
_id: new ObjectId(),
|
||||
text: "test2",
|
||||
source: "Stuart",
|
||||
language: "english",
|
||||
submittedBy: "Kevin",
|
||||
timestamp: 2000,
|
||||
approved: false,
|
||||
};
|
||||
getQuotesMock.mockResolvedValue([quoteOne, quoteTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Quote submissions retrieved");
|
||||
expect(body.data).toEqual([
|
||||
{ ...quoteOne, _id: quoteOne._id.toHexString() },
|
||||
{
|
||||
...quoteTwo,
|
||||
_id: quoteTwo._id.toHexString(),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getQuotesMock).toHaveBeenCalledWith("all");
|
||||
});
|
||||
it("should return quotes with quoteMod", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock
|
||||
.mockClear()
|
||||
.mockResolvedValue({ quoteMod: "english" } as any);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/quotes")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
|
||||
expect(getQuotesMock).toHaveBeenCalledWith("english");
|
||||
});
|
||||
it("should fail with quoteMod false", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock
|
||||
.mockClear()
|
||||
.mockResolvedValue({ quoteMod: false } as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
|
||||
expect(getQuotesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail with quoteMod empty", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockClear().mockResolvedValue({ quoteMod: "" } as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
|
||||
expect(getQuotesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.get("/quotes").expect(401);
|
||||
});
|
||||
});
|
||||
describe("isSubmissionsEnabled", () => {
|
||||
it("should return for quotes enabled without authentication", async () => {
|
||||
//GIVEN
|
||||
enableQuotes(true);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/isSubmissionEnabled")
|
||||
.expect(200);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Quote submission enabled",
|
||||
data: { isEnabled: true },
|
||||
});
|
||||
});
|
||||
it("should return for quotes disabled without authentication", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/isSubmissionEnabled")
|
||||
.expect(200);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Quote submission enabled",
|
||||
data: { isEnabled: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("addQuote", () => {
|
||||
const addQuoteMock = vi.spyOn(NewQuotesDal, "add");
|
||||
const verifyCaptchaMock = vi.spyOn(Captcha, "verify");
|
||||
|
||||
beforeEach(() => {
|
||||
addQuoteMock.mockClear();
|
||||
addQuoteMock.mockResolvedValue({} as any);
|
||||
|
||||
verifyCaptchaMock.mockClear();
|
||||
verifyCaptchaMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should add quote", async () => {
|
||||
//GIVEN
|
||||
const newQuote = {
|
||||
text: new Array(60).fill("a").join(""),
|
||||
source: "Bob",
|
||||
language: "english",
|
||||
captcha: "captcha",
|
||||
};
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send(newQuote)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Quote submission added",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(addQuoteMock).toHaveBeenCalledWith(
|
||||
newQuote.text,
|
||||
newQuote.source,
|
||||
newQuote.language,
|
||||
uid,
|
||||
);
|
||||
|
||||
expect(verifyCaptchaMock).toHaveBeenCalledWith(newQuote.captcha);
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.post("/quotes").expect(401);
|
||||
});
|
||||
it("should fail if feature is disabled", async () => {
|
||||
//GIVEN
|
||||
enableQuotes(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(503);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual(
|
||||
"Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.",
|
||||
);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"text" Required',
|
||||
'"source" Required',
|
||||
'"language" Required',
|
||||
'"captcha" Required',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.send({
|
||||
text: new Array(60).fill("a").join(""),
|
||||
source: "Bob",
|
||||
language: "english",
|
||||
captcha: "captcha",
|
||||
extra: "value",
|
||||
})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail with invalid capture", async () => {
|
||||
//GIVEN
|
||||
verifyCaptchaMock.mockResolvedValue(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.send({
|
||||
text: new Array(60).fill("a").join(""),
|
||||
source: "Bob",
|
||||
language: "english",
|
||||
captcha: "captcha",
|
||||
})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Captcha check failed");
|
||||
});
|
||||
});
|
||||
describe("approveQuote", () => {
|
||||
const approveQuoteMock = vi.spyOn(NewQuotesDal, "approve");
|
||||
|
||||
beforeEach(() => {
|
||||
approveQuoteMock.mockClear();
|
||||
});
|
||||
|
||||
it("should approve", async () => {
|
||||
//GiVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
const quote: ApproveQuote = {
|
||||
id: 100,
|
||||
text: "text",
|
||||
source: "source",
|
||||
length: 10,
|
||||
approvedBy: "Kevin",
|
||||
};
|
||||
approveQuoteMock.mockResolvedValue({
|
||||
message: "ok",
|
||||
quote,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
quoteId,
|
||||
editText: "editedText",
|
||||
editSource: "editedSource",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "ok",
|
||||
data: quote,
|
||||
});
|
||||
|
||||
expect(approveQuoteMock).toHaveBeenCalledWith(
|
||||
quoteId,
|
||||
"editedText",
|
||||
"editedSource",
|
||||
"Bob",
|
||||
);
|
||||
});
|
||||
it("should approve with optional parameters as null", async () => {
|
||||
//GiVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
approveQuoteMock.mockResolvedValue({
|
||||
message: "ok",
|
||||
quote: {} as any,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId, editText: null, editSource: null })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "ok",
|
||||
data: {},
|
||||
});
|
||||
|
||||
expect(approveQuoteMock).toHaveBeenCalledWith(
|
||||
quoteId,
|
||||
undefined,
|
||||
undefined,
|
||||
"Bob",
|
||||
);
|
||||
});
|
||||
it("should approve without optional parameters", async () => {
|
||||
//GiVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
approveQuoteMock.mockResolvedValue({
|
||||
message: "ok",
|
||||
quote: {} as any,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "ok",
|
||||
data: {},
|
||||
});
|
||||
|
||||
expect(approveQuoteMock).toHaveBeenCalledWith(
|
||||
quoteId,
|
||||
undefined,
|
||||
undefined,
|
||||
"Bob",
|
||||
);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"quoteId" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId: new ObjectId().toHexString(), extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no quote mod", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockClear().mockResolvedValue({} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId: new ObjectId().toHexString() })
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp
|
||||
.post("/quotes/approve")
|
||||
.send({ quoteId: new ObjectId().toHexString() })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
describe("refuseQuote", () => {
|
||||
const refuseQuoteMock = vi.spyOn(NewQuotesDal, "refuse");
|
||||
|
||||
beforeEach(() => {
|
||||
refuseQuoteMock.mockClear();
|
||||
refuseQuoteMock.mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should refuse quote", async () => {
|
||||
//GIVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/reject")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Quote refused",
|
||||
data: null,
|
||||
});
|
||||
expect(refuseQuoteMock).toHaveBeenCalledWith(quoteId);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/reject")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"quoteId" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//GIVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/reject")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId, extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no quote mod", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockClear().mockResolvedValue({} as any);
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/reject")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId })
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp
|
||||
.post("/quotes/reject")
|
||||
.send({ quoteId: new ObjectId().toHexString() })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
describe("getRating", () => {
|
||||
const getRatingMock = vi.spyOn(QuoteRatingsDal, "get");
|
||||
|
||||
beforeEach(() => {
|
||||
getRatingMock.mockClear();
|
||||
});
|
||||
|
||||
it("should get", async () => {
|
||||
//GIVEN
|
||||
const quoteRating = {
|
||||
_id: new ObjectId(),
|
||||
average: 2,
|
||||
language: "english",
|
||||
quoteId: 23,
|
||||
ratings: 100,
|
||||
totalRating: 122,
|
||||
};
|
||||
getRatingMock.mockResolvedValue(quoteRating as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/rating")
|
||||
.query({ quoteId: 42, language: "english" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Rating retrieved",
|
||||
data: { ...quoteRating, _id: quoteRating._id.toHexString() },
|
||||
});
|
||||
|
||||
expect(getRatingMock).toHaveBeenCalledWith(42, "english");
|
||||
});
|
||||
it("should fail without mandatory query parameters", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ['"quoteId" Invalid input', '"language" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown query parameters", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.query({ quoteId: 42, language: "english", extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp
|
||||
.get("/quotes/rating")
|
||||
.query({ quoteId: 42, language: "english" })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
describe("submitRating", () => {
|
||||
const updateQuotesRatingsMock = vi.spyOn(UserDal, "updateQuoteRatings");
|
||||
const submitQuoteRating = vi.spyOn(QuoteRatingsDal, "submit");
|
||||
|
||||
beforeEach(() => {
|
||||
getPartialUserMock
|
||||
.mockClear()
|
||||
.mockResolvedValue({ quoteRatings: null } as any);
|
||||
|
||||
updateQuotesRatingsMock.mockClear().mockResolvedValue({} as any);
|
||||
submitQuoteRating.mockClear().mockResolvedValue();
|
||||
});
|
||||
it("should submit new rating", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
quoteId: 23,
|
||||
rating: 4,
|
||||
language: "english",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Rating submitted",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(submitQuoteRating).toHaveBeenCalledWith(23, "english", 4, false);
|
||||
|
||||
expect(updateQuotesRatingsMock).toHaveBeenCalledWith(uid, {
|
||||
english: { "23": 4 },
|
||||
});
|
||||
});
|
||||
it("should update existing rating", async () => {
|
||||
//GIVEN
|
||||
|
||||
getPartialUserMock.mockClear().mockResolvedValue({
|
||||
quoteRatings: { german: { "4": 1 }, english: { "5": 5, "23": 4 } },
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
quoteId: 23,
|
||||
rating: 2,
|
||||
language: "english",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Rating updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(submitQuoteRating).toHaveBeenCalledWith(23, "english", -2, true);
|
||||
|
||||
expect(updateQuotesRatingsMock).toHaveBeenCalledWith(uid, {
|
||||
german: { "4": 1 },
|
||||
english: { "5": 5, "23": 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("should update existing rating with same rating", async () => {
|
||||
//GIVEN
|
||||
|
||||
getPartialUserMock.mockClear().mockResolvedValue({
|
||||
quoteRatings: { german: { "4": 1 }, english: { "5": 5, "23": 4 } },
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
quoteId: 23,
|
||||
rating: 4,
|
||||
language: "english",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Rating updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(submitQuoteRating).toHaveBeenCalledWith(23, "english", 0, true);
|
||||
|
||||
expect(updateQuotesRatingsMock).toHaveBeenCalledWith(uid, {
|
||||
german: { "4": 1 },
|
||||
english: { "5": 5, "23": 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail with missing mandatory parameter", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"quoteId" Invalid input',
|
||||
'"language" Required',
|
||||
'"rating" Required',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown parameter", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId: 23, language: "english", rating: 5, extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail with zero rating", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId: 23, language: "english", rating: 0 })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"rating" Number must be greater than or equal to 1',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with rating bigger than 5", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId: 23, language: "english", rating: 6 })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"rating" Number must be less than or equal to 5'],
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail with non-integer rating", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ quoteId: 23, language: "english", rating: 2.5 })
|
||||
.expect(422);
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"rating" Expected integer, received float'],
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.post("/quotes/rating").expect(401);
|
||||
});
|
||||
});
|
||||
describe("reportQuote", () => {
|
||||
const verifyCaptchaMock = vi.spyOn(Captcha, "verify");
|
||||
const createReportMock = vi.spyOn(ReportDal, "createReport");
|
||||
|
||||
beforeEach(() => {
|
||||
enableQuoteReporting(true);
|
||||
|
||||
verifyCaptchaMock.mockClear().mockResolvedValue(true);
|
||||
createReportMock.mockClear().mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should report quote", async () => {
|
||||
//GIVEN
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
quoteId: "23", //quoteId is string on this endpoint
|
||||
quoteLanguage: "english",
|
||||
reason: "Inappropriate content",
|
||||
comment: "I don't like this.",
|
||||
captcha: "captcha",
|
||||
});
|
||||
//.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Quote reported",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(verifyCaptchaMock).toHaveBeenCalledWith("captcha");
|
||||
|
||||
expect(createReportMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "quote",
|
||||
uid,
|
||||
contentId: "english-23",
|
||||
reason: "Inappropriate content",
|
||||
comment: "I don't like this.",
|
||||
}),
|
||||
10, //configuration maxReport
|
||||
20, //configuration contentReportLimit
|
||||
);
|
||||
});
|
||||
|
||||
it("should report quote without comment", async () => {
|
||||
await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
quoteId: "23", //quoteId is string on this endpoint
|
||||
quoteLanguage: "english",
|
||||
reason: "Inappropriate content",
|
||||
captcha: "captcha",
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
it("should report quote with empty comment", async () => {
|
||||
await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
quoteId: "23", //quoteId is string on this endpoint
|
||||
quoteLanguage: "english",
|
||||
reason: "Inappropriate content",
|
||||
comment: "",
|
||||
captcha: "captcha",
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"quoteId" Invalid input',
|
||||
'"quoteLanguage" Required',
|
||||
'"reason" Required',
|
||||
'"captcha" Required',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail if feature is disabled", async () => {
|
||||
//GIVEN
|
||||
enableQuoteReporting(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(503);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Quote reporting is unavailable.");
|
||||
});
|
||||
it("should fail if user cannot report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock
|
||||
.mockClear()
|
||||
.mockResolvedValue({ canReport: false } as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function enableQuotes(enabled: boolean): Promise<void> {
|
||||
const mockConfig = await configuration;
|
||||
mockConfig.quotes = { ...mockConfig.quotes, submissionsEnabled: enabled };
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig,
|
||||
);
|
||||
}
|
||||
|
||||
async function enableQuoteReporting(enabled: boolean): Promise<void> {
|
||||
const mockConfig = await configuration;
|
||||
mockConfig.quotes.reporting = {
|
||||
...mockConfig.quotes.reporting,
|
||||
enabled,
|
||||
maxReports: 10,
|
||||
contentReportLimit: 20,
|
||||
};
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig,
|
||||
);
|
||||
}
|
||||
891
backend/__tests__/api/controllers/result.spec.ts
Normal file
891
backend/__tests__/api/controllers/result.spec.ts
Normal file
@@ -0,0 +1,891 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import * as ResultDal from "../../../src/dal/result";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import * as LogsDal from "../../../src/dal/logs";
|
||||
import * as PublicDal from "../../../src/dal/public";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { mockAuthenticateWithApeKey } from "../../__testData__/auth";
|
||||
import { enableRateLimitExpects } from "../../__testData__/rate-limit";
|
||||
import { DBResult } from "../../../src/utils/result";
|
||||
import { omit } from "../../../src/utils/misc";
|
||||
import { CompletedEvent } from "@monkeytype/schemas/results";
|
||||
|
||||
const { mockApp, uid, mockAuth } = setup();
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
enableRateLimitExpects();
|
||||
|
||||
describe("result controller test", () => {
|
||||
describe("getResults", () => {
|
||||
const resultMock = vi.spyOn(ResultDal, "getResults");
|
||||
|
||||
beforeEach(async () => {
|
||||
resultMock.mockResolvedValue([]);
|
||||
await enablePremiumFeatures(true);
|
||||
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resultMock.mockClear();
|
||||
});
|
||||
|
||||
it("should get results", async () => {
|
||||
//GIVEN
|
||||
const resultOne = givenDbResult(uid);
|
||||
const resultTwo = givenDbResult(uid);
|
||||
resultMock.mockResolvedValue([resultOne, resultTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
|
||||
expect(body.message).toEqual("Results retrieved");
|
||||
expect(body.data).toEqual([
|
||||
{ ...resultOne, _id: resultOne._id.toHexString() },
|
||||
{ ...resultTwo, _id: resultTwo._id.toHexString() },
|
||||
]);
|
||||
});
|
||||
it("should get results with ape key", async () => {
|
||||
//GIVEN
|
||||
await acceptApeKeys(true);
|
||||
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/results")
|
||||
.set("Authorization", `ApeKey ${apeKey}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
});
|
||||
it("should get latest 1000 results for regular user", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(resultMock).toHaveBeenCalledWith(uid, {
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
onOrAfterTimestamp: NaN,
|
||||
});
|
||||
});
|
||||
it("should get results filter by onOrAfterTimestamp", async () => {
|
||||
//GIVEN
|
||||
const now = Date.now();
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/results")
|
||||
.query({ onOrAfterTimestamp: now })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
|
||||
expect(resultMock).toHaveBeenCalledWith(uid, {
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
onOrAfterTimestamp: now,
|
||||
});
|
||||
});
|
||||
it("should get with limit and offset", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/results")
|
||||
.query({ limit: 250, offset: 500 })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(resultMock).toHaveBeenCalledWith(uid, {
|
||||
limit: 250,
|
||||
offset: 500,
|
||||
onOrAfterTimestamp: NaN,
|
||||
});
|
||||
});
|
||||
it("should fail exceeding max limit for regular user", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/results")
|
||||
.query({ limit: 100, offset: 1000 })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual(
|
||||
`Max results limit of ${
|
||||
(await configuration).results.limits.regularUser
|
||||
} exceeded.`,
|
||||
);
|
||||
});
|
||||
it("should get with higher max limit for premium user", async () => {
|
||||
//GIVEN
|
||||
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/results")
|
||||
.query({ limit: 800, offset: 600 })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
|
||||
expect(resultMock).toHaveBeenCalledWith(uid, {
|
||||
limit: 800,
|
||||
offset: 600,
|
||||
onOrAfterTimestamp: NaN,
|
||||
});
|
||||
});
|
||||
it("should get results if offset/limit is partly outside the max limit", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/results")
|
||||
.query({ limit: 20, offset: 990 })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
|
||||
expect(resultMock).toHaveBeenCalledWith(uid, {
|
||||
limit: 10, //limit is reduced to stay within max limit
|
||||
offset: 990,
|
||||
onOrAfterTimestamp: NaN,
|
||||
});
|
||||
});
|
||||
it("should fail exceeding 1k limit", async () => {
|
||||
//GIVEN
|
||||
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/results")
|
||||
.query({ limit: 2000 })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ['"limit" Number must be less than or equal to 1000'],
|
||||
});
|
||||
});
|
||||
it("should fail exceeding maxlimit for premium user", async () => {
|
||||
//GIVEN
|
||||
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/results")
|
||||
.query({ limit: 1000, offset: 25000 })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(422);
|
||||
//THEN
|
||||
expect(body.message).toEqual(
|
||||
`Max results limit of ${
|
||||
(await configuration).results.limits.premiumUser
|
||||
} exceeded.`,
|
||||
);
|
||||
});
|
||||
it("should get results within regular limits for premium users even if premium is globally disabled", async () => {
|
||||
//GIVEN
|
||||
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true);
|
||||
enablePremiumFeatures(false);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/results")
|
||||
.query({ limit: 100, offset: 900 })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(resultMock).toHaveBeenCalledWith(uid, {
|
||||
limit: 100,
|
||||
offset: 900,
|
||||
onOrAfterTimestamp: NaN,
|
||||
});
|
||||
});
|
||||
it("should fail exceeding max limit for premium user if premium is globally disabled", async () => {
|
||||
//GIVEN
|
||||
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true);
|
||||
enablePremiumFeatures(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/results")
|
||||
.query({ limit: 200, offset: 900 })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(503);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Premium feature disabled.");
|
||||
});
|
||||
it("should get results with regular limit as default for premium users if premium is globally disabled", async () => {
|
||||
//GIVEN
|
||||
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true);
|
||||
enablePremiumFeatures(false);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(resultMock).toHaveBeenCalledWith(uid, {
|
||||
limit: 1000, //the default limit for regular users
|
||||
offset: 0,
|
||||
onOrAfterTimestamp: NaN,
|
||||
});
|
||||
});
|
||||
it("should fail with unknown query parameters", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/results")
|
||||
.query({ extra: "value" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should be rate limited", async () => {
|
||||
await expect(
|
||||
mockApp.get("/results").set("Authorization", `Bearer ${uid}`),
|
||||
).toBeRateLimited({ max: 60, windowMs: 60 * 60 * 1000 });
|
||||
});
|
||||
it("should be rate limited for ape keys", async () => {
|
||||
//GIVEN
|
||||
await acceptApeKeys(true);
|
||||
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
|
||||
|
||||
//WHEN
|
||||
await expect(
|
||||
mockApp.get("/results").set("Authorization", `ApeKey ${apeKey}`),
|
||||
).toBeRateLimited({ max: 30, windowMs: 24 * 60 * 60 * 1000 });
|
||||
});
|
||||
});
|
||||
describe("getResultById", () => {
|
||||
const getResultMock = vi.spyOn(ResultDal, "getResult");
|
||||
|
||||
afterEach(() => {
|
||||
getResultMock.mockClear();
|
||||
});
|
||||
|
||||
it("should get result", async () => {
|
||||
//GIVEN
|
||||
const result = givenDbResult(uid);
|
||||
getResultMock.mockResolvedValue(result);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get(`/results/id/${result._id}`)
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Result retrieved");
|
||||
expect(body.data).toEqual({ ...result, _id: result._id.toHexString() });
|
||||
});
|
||||
it("should get last result with ape key", async () => {
|
||||
//GIVEN
|
||||
await acceptApeKeys(true);
|
||||
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
|
||||
const result = givenDbResult(uid);
|
||||
getResultMock.mockResolvedValue(result);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get(`/results/id/${result._id}`)
|
||||
.set("Authorization", `ApeKey ${apeKey}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
});
|
||||
it("should rate limit get result with ape key", async () => {
|
||||
//GIVEN
|
||||
const result = givenDbResult(uid, {
|
||||
charStats: undefined,
|
||||
incorrectChars: 5,
|
||||
correctChars: 12,
|
||||
});
|
||||
getResultMock.mockResolvedValue(result);
|
||||
await acceptApeKeys(true);
|
||||
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
|
||||
|
||||
//WHEN
|
||||
await expect(
|
||||
mockApp
|
||||
.get(`/results/id/${result._id}`)
|
||||
.set("Authorization", `ApeKey ${apeKey}`),
|
||||
).toBeRateLimited({ max: 60, windowMs: 60 * 60 * 1000 });
|
||||
});
|
||||
});
|
||||
describe("getLastResult", () => {
|
||||
const getLastResultMock = vi.spyOn(ResultDal, "getLastResult");
|
||||
|
||||
afterEach(() => {
|
||||
getLastResultMock.mockClear();
|
||||
});
|
||||
|
||||
it("should get last result", async () => {
|
||||
//GIVEN
|
||||
const result = givenDbResult(uid);
|
||||
getLastResultMock.mockResolvedValue(result);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/results/last")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Result retrieved");
|
||||
expect(body.data).toEqual({ ...result, _id: result._id.toHexString() });
|
||||
});
|
||||
it("should get last result with ape key", async () => {
|
||||
//GIVEN
|
||||
await acceptApeKeys(true);
|
||||
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
|
||||
const result = givenDbResult(uid);
|
||||
getLastResultMock.mockResolvedValue(result);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/results/last")
|
||||
.set("Authorization", `ApeKey ${apeKey}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
});
|
||||
it("should rate limit get last result with ape key", async () => {
|
||||
//GIVEN
|
||||
const result = givenDbResult(uid, {
|
||||
charStats: undefined,
|
||||
incorrectChars: 5,
|
||||
correctChars: 12,
|
||||
});
|
||||
getLastResultMock.mockResolvedValue(result);
|
||||
await acceptApeKeys(true);
|
||||
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
|
||||
|
||||
//WHEN
|
||||
await expect(
|
||||
mockApp.get("/results/last").set("Authorization", `ApeKey ${apeKey}`),
|
||||
).toBeRateLimited({ max: 30, windowMs: 60 * 1000 }); //should use defaultApeRateLimit
|
||||
});
|
||||
});
|
||||
describe("deleteAll", () => {
|
||||
const deleteAllMock = vi.spyOn(ResultDal, "deleteAll");
|
||||
const logToDbMock = vi.spyOn(LogsDal, "addLog");
|
||||
afterEach(() => {
|
||||
deleteAllMock.mockClear();
|
||||
logToDbMock.mockClear();
|
||||
});
|
||||
|
||||
it("should delete", async () => {
|
||||
//GIVEN
|
||||
mockAuth.modifyToken({ iat: Date.now() - 1000 });
|
||||
deleteAllMock.mockResolvedValue(undefined as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.delete("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("All results deleted");
|
||||
expect(body.data).toBeNull();
|
||||
|
||||
expect(deleteAllMock).toHaveBeenCalledWith(uid);
|
||||
expect(logToDbMock).toHaveBeenCalledWith("user_results_deleted", "", uid);
|
||||
});
|
||||
it("should fail to delete with non-fresh token", async () => {
|
||||
//GIVEN
|
||||
mockAuth.modifyToken({ iat: 0 });
|
||||
|
||||
//WHEN/THEN
|
||||
await mockApp
|
||||
.delete("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send()
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
describe("updateTags", () => {
|
||||
const getResultMock = vi.spyOn(ResultDal, "getResult");
|
||||
const updateTagsMock = vi.spyOn(ResultDal, "updateTags");
|
||||
const getUserPartialMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
const checkIfTagPbMock = vi.spyOn(UserDal, "checkIfTagPb");
|
||||
|
||||
afterEach(() => {
|
||||
[
|
||||
getResultMock,
|
||||
updateTagsMock,
|
||||
getUserPartialMock,
|
||||
checkIfTagPbMock,
|
||||
].forEach((it) => it.mockClear());
|
||||
});
|
||||
|
||||
it("should update tags", async () => {
|
||||
//GIVEN
|
||||
const result = givenDbResult(uid);
|
||||
const resultIdString = result._id.toHexString();
|
||||
const tagIds = [
|
||||
new ObjectId().toHexString(),
|
||||
new ObjectId().toHexString(),
|
||||
];
|
||||
const partialUser = { tags: [] };
|
||||
getResultMock.mockResolvedValue(result);
|
||||
updateTagsMock.mockResolvedValue({} as any);
|
||||
getUserPartialMock.mockResolvedValue(partialUser as any);
|
||||
checkIfTagPbMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/results/tags")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ resultId: resultIdString, tagIds })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Result tags updated");
|
||||
expect(body.data).toEqual({
|
||||
tagPbs: [],
|
||||
});
|
||||
|
||||
expect(updateTagsMock).toHaveBeenCalledWith(uid, resultIdString, tagIds);
|
||||
expect(getResultMock).toHaveBeenCalledWith(uid, resultIdString);
|
||||
expect(getUserPartialMock).toHaveBeenCalledWith(uid, "update tags", [
|
||||
"tags",
|
||||
]);
|
||||
expect(checkIfTagPbMock).toHaveBeenCalledWith(uid, partialUser, result);
|
||||
});
|
||||
it("should apply defaults on missing data", async () => {
|
||||
//GIVEN
|
||||
const result = givenDbResult(uid);
|
||||
const partialResult = omit(result, [
|
||||
"difficulty",
|
||||
"language",
|
||||
"funbox",
|
||||
"lazyMode",
|
||||
"punctuation",
|
||||
"numbers",
|
||||
]);
|
||||
|
||||
const resultIdString = result._id.toHexString();
|
||||
const tagIds = [
|
||||
new ObjectId().toHexString(),
|
||||
new ObjectId().toHexString(),
|
||||
];
|
||||
const partialUser = { tags: [] };
|
||||
getResultMock.mockResolvedValue(partialResult);
|
||||
updateTagsMock.mockResolvedValue({} as any);
|
||||
getUserPartialMock.mockResolvedValue(partialUser as any);
|
||||
checkIfTagPbMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/results/tags")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ resultId: resultIdString, tagIds })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Result tags updated");
|
||||
expect(body.data).toEqual({
|
||||
tagPbs: [],
|
||||
});
|
||||
|
||||
expect(updateTagsMock).toHaveBeenCalledWith(uid, resultIdString, tagIds);
|
||||
expect(getResultMock).toHaveBeenCalledWith(uid, resultIdString);
|
||||
expect(getUserPartialMock).toHaveBeenCalledWith(uid, "update tags", [
|
||||
"tags",
|
||||
]);
|
||||
expect(checkIfTagPbMock).toHaveBeenCalledWith(uid, partialUser, {
|
||||
...result,
|
||||
difficulty: "normal",
|
||||
language: "english",
|
||||
funbox: [],
|
||||
lazyMode: false,
|
||||
punctuation: false,
|
||||
numbers: false,
|
||||
});
|
||||
});
|
||||
it("should fail with missing mandatory properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/results/tags")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"tagIds" Required', '"resultId" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/results/tags")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({ extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"tagIds" Required',
|
||||
'"resultId" Required',
|
||||
"Unrecognized key(s) in object: 'extra'",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("addResult", () => {
|
||||
//TODO improve test coverage for addResult
|
||||
const insertedId = new ObjectId();
|
||||
const userGetMock = vi.spyOn(UserDal, "getUser");
|
||||
const userUpdateStreakMock = vi.spyOn(UserDal, "updateStreak");
|
||||
const userCheckIfTagPbMock = vi.spyOn(UserDal, "checkIfTagPb");
|
||||
const userCheckIfPbMock = vi.spyOn(UserDal, "checkIfPb");
|
||||
const userIncrementXpMock = vi.spyOn(UserDal, "incrementXp");
|
||||
const userUpdateTypingStatsMock = vi.spyOn(UserDal, "updateTypingStats");
|
||||
const resultAddMock = vi.spyOn(ResultDal, "addResult");
|
||||
const publicUpdateStatsMock = vi.spyOn(PublicDal, "updateStats");
|
||||
|
||||
beforeEach(async () => {
|
||||
await enableResultsSaving(true);
|
||||
await enableUsersXpGain(true);
|
||||
|
||||
[
|
||||
userGetMock,
|
||||
userUpdateStreakMock,
|
||||
userCheckIfTagPbMock,
|
||||
userCheckIfPbMock,
|
||||
userIncrementXpMock,
|
||||
userUpdateTypingStatsMock,
|
||||
resultAddMock,
|
||||
publicUpdateStatsMock,
|
||||
].forEach((it) => it.mockClear());
|
||||
|
||||
userGetMock.mockResolvedValue({ name: "bob" } as any);
|
||||
userUpdateStreakMock.mockResolvedValue(0);
|
||||
userCheckIfTagPbMock.mockResolvedValue([]);
|
||||
userCheckIfPbMock.mockResolvedValue(true);
|
||||
resultAddMock.mockResolvedValue({ insertedId });
|
||||
userIncrementXpMock.mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should add result", async () => {
|
||||
//GIVEN
|
||||
|
||||
const completedEvent = buildCompletedEvent({
|
||||
funbox: ["58008", "read_ahead_hard"],
|
||||
});
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
result: completedEvent,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(body.message).toEqual("Result saved");
|
||||
expect(body.data).toEqual({
|
||||
isPb: true,
|
||||
tagPbs: [],
|
||||
xp: 0,
|
||||
dailyXpBonus: false,
|
||||
xpBreakdown: {
|
||||
accPenalty: 28,
|
||||
base: 20,
|
||||
incomplete: 5,
|
||||
funbox: 80,
|
||||
},
|
||||
streak: 0,
|
||||
insertedId: insertedId.toHexString(),
|
||||
});
|
||||
|
||||
expect(resultAddMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
expect.objectContaining({
|
||||
acc: 86,
|
||||
afkDuration: 5,
|
||||
charStats: [100, 2, 3, 5],
|
||||
chartData: {
|
||||
err: [0, 2, 0],
|
||||
burst: [50, 55, 56],
|
||||
wpm: [1, 2, 3],
|
||||
},
|
||||
consistency: 23.5,
|
||||
incompleteTestSeconds: 2,
|
||||
isPb: true,
|
||||
keyConsistency: 12,
|
||||
keyDurationStats: {
|
||||
average: 2.67,
|
||||
sd: 2.05,
|
||||
},
|
||||
keySpacingStats: {
|
||||
average: 2,
|
||||
sd: 1.63,
|
||||
},
|
||||
mode: "time",
|
||||
mode2: "15",
|
||||
name: "bob",
|
||||
rawWpm: 99,
|
||||
restartCount: 4,
|
||||
tags: ["tagOneId", "tagTwoId"],
|
||||
testDuration: 15.1,
|
||||
uid: uid,
|
||||
wpm: 80,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(publicUpdateStatsMock).toHaveBeenCalledWith(
|
||||
4,
|
||||
15.1 + 2 - 5, //duration + incompleteTestSeconds-afk
|
||||
);
|
||||
expect(userIncrementXpMock).toHaveBeenCalledWith(uid, 0);
|
||||
expect(userUpdateTypingStatsMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
4,
|
||||
15.1 + 2 - 5, //duration + incompleteTestSeconds-afk
|
||||
);
|
||||
});
|
||||
it("should fail if result saving is disabled", async () => {
|
||||
//GIVEN
|
||||
await enableResultsSaving(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({})
|
||||
.expect(503);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Results are not being saved at this time.");
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"result" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
result: buildCompletedEvent({
|
||||
extra2: "value",
|
||||
} as any),
|
||||
extra: "value",
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"result" Unrecognized key(s) in object: 'extra2'`,
|
||||
"Unrecognized key(s) in object: 'extra'",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail wit duplicate funboxes", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/results")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.send({
|
||||
result: buildCompletedEvent({
|
||||
funbox: ["58008", "58008"],
|
||||
}),
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Duplicate funboxes");
|
||||
});
|
||||
|
||||
// it("should fail invalid properties ", async () => {
|
||||
//GIVEN
|
||||
//WHEN
|
||||
// const { body } = await mockApp
|
||||
// .post("/results")
|
||||
// .set("Authorization", `Bearer ${uid}`)
|
||||
// //TODO add all properties
|
||||
// .send({ result: { acc: 25 } })
|
||||
// .expect(422);
|
||||
//THEN
|
||||
/*
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
],
|
||||
});
|
||||
*/
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
||||
function buildCompletedEvent(result?: Partial<CompletedEvent>): CompletedEvent {
|
||||
return {
|
||||
acc: 86,
|
||||
afkDuration: 5,
|
||||
bailedOut: false,
|
||||
blindMode: false,
|
||||
charStats: [100, 2, 3, 5],
|
||||
chartData: { wpm: [1, 2, 3], burst: [50, 55, 56], err: [0, 2, 0] },
|
||||
consistency: 23.5,
|
||||
difficulty: "normal",
|
||||
funbox: [],
|
||||
hash: "hash",
|
||||
incompleteTestSeconds: 2,
|
||||
incompleteTests: [{ acc: 75, seconds: 10 }],
|
||||
keyConsistency: 12,
|
||||
keyDuration: [0, 3, 5],
|
||||
keySpacing: [0, 2, 4],
|
||||
language: "english",
|
||||
lazyMode: false,
|
||||
mode: "time",
|
||||
mode2: "15",
|
||||
numbers: false,
|
||||
punctuation: false,
|
||||
rawWpm: 99,
|
||||
restartCount: 4,
|
||||
tags: ["tagOneId", "tagTwoId"],
|
||||
testDuration: 15.1,
|
||||
timestamp: 1000,
|
||||
uid,
|
||||
wpmConsistency: 55,
|
||||
wpm: 80,
|
||||
stopOnLetter: false,
|
||||
//new required
|
||||
charTotal: 5,
|
||||
keyOverlap: 7,
|
||||
lastKeyToEnd: 9,
|
||||
startToFirstKey: 11,
|
||||
...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,
|
||||
);
|
||||
}
|
||||
function givenDbResult(uid: string, customize?: Partial<DBResult>): DBResult {
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
wpm: Math.random() * 100,
|
||||
rawWpm: Math.random() * 100,
|
||||
charStats: [
|
||||
Math.round(Math.random() * 10),
|
||||
Math.round(Math.random() * 10),
|
||||
Math.round(Math.random() * 10),
|
||||
Math.round(Math.random() * 10),
|
||||
],
|
||||
acc: 80 + Math.random() * 20, //min accuracy is 75%
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
timestamp: Math.round(Math.random() * 100),
|
||||
testDuration: 1 + Math.random() * 100,
|
||||
consistency: Math.random() * 100,
|
||||
keyConsistency: Math.random() * 100,
|
||||
uid,
|
||||
keySpacingStats: { average: Math.random() * 100, sd: Math.random() },
|
||||
keyDurationStats: { average: Math.random() * 100, sd: Math.random() },
|
||||
isPb: true,
|
||||
chartData: {
|
||||
wpm: [Math.random() * 100],
|
||||
burst: [Math.random() * 100],
|
||||
err: [Math.random() * 100],
|
||||
},
|
||||
name: "testName",
|
||||
...customize,
|
||||
};
|
||||
}
|
||||
|
||||
async function acceptApeKeys(enabled: boolean): Promise<void> {
|
||||
const mockConfig = await configuration;
|
||||
mockConfig.apeKeys = {
|
||||
...mockConfig.apeKeys,
|
||||
acceptKeys: enabled,
|
||||
};
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig,
|
||||
);
|
||||
}
|
||||
|
||||
async function enableResultsSaving(enabled: boolean): Promise<void> {
|
||||
const mockConfig = await configuration;
|
||||
mockConfig.results = { ...mockConfig.results, savingEnabled: enabled };
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig,
|
||||
);
|
||||
}
|
||||
async function enableUsersXpGain(enabled: boolean): Promise<void> {
|
||||
const mockConfig = await configuration;
|
||||
mockConfig.users.xp = { ...mockConfig.users.xp, enabled, funboxBonus: 1 };
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig,
|
||||
);
|
||||
}
|
||||
4056
backend/__tests__/api/controllers/user.spec.ts
Normal file
4056
backend/__tests__/api/controllers/user.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
81
backend/__tests__/api/controllers/webhooks.spec.ts
Normal file
81
backend/__tests__/api/controllers/webhooks.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { setup } from "../../__testData__/controller-test";
|
||||
import GeorgeQueue from "../../../src/queues/george-queue";
|
||||
import crypto from "crypto";
|
||||
|
||||
const { mockApp } = setup();
|
||||
|
||||
describe("WebhooksController", () => {
|
||||
describe("githubRelease", () => {
|
||||
const georgeSendReleaseAnnouncementMock = vi.spyOn(
|
||||
GeorgeQueue,
|
||||
"sendReleaseAnnouncement",
|
||||
);
|
||||
const timingSafeEqualMock = vi.spyOn(crypto, "timingSafeEqual");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
|
||||
|
||||
georgeSendReleaseAnnouncementMock.mockClear();
|
||||
timingSafeEqualMock.mockClear().mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should announce release", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/webhooks/githubRelease")
|
||||
.set("x-hub-signature-256", "the-signature")
|
||||
.send({ action: "published", release: { id: 1 } })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Added release announcement task to queue",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(georgeSendReleaseAnnouncementMock).toHaveBeenCalledWith("1");
|
||||
expect(timingSafeEqualMock).toHaveBeenCalledWith(
|
||||
Buffer.from(
|
||||
"sha256=ff0f3080539e9df19153f6b5b5780f66e558d61038e6cf5ecf4efdc7266a7751",
|
||||
),
|
||||
Buffer.from("the-signature"),
|
||||
);
|
||||
});
|
||||
it("should ignore non-published actions", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/webhooks/githubRelease")
|
||||
.set("x-hub-signature-256", "the-signature")
|
||||
.send({ action: "created" })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("No action taken");
|
||||
expect(georgeSendReleaseAnnouncementMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should ignore additional properties", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.post("/webhooks/githubRelease")
|
||||
.set("x-hub-signature-256", "the-signature")
|
||||
.send({
|
||||
action: "published",
|
||||
extra: "value",
|
||||
release: { id: 1, extra2: "value" },
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
it("should fail with missing releaseId", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/webhooks/githubRelease")
|
||||
.set("x-hub-signature-256", "the-signature")
|
||||
.send({ action: "published" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual('Missing property "release.id".');
|
||||
});
|
||||
});
|
||||
});
|
||||
53
backend/__tests__/init/configurations.spec.ts
Normal file
53
backend/__tests__/init/configurations.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as Configurations from "../../src/init/configuration";
|
||||
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
const mergeConfigurations = Configurations.__testing.mergeConfigurations;
|
||||
|
||||
describe("configurations", () => {
|
||||
describe("mergeConfigurations", () => {
|
||||
it("should merge configurations correctly", () => {
|
||||
//GIVEN
|
||||
const baseConfig: Configuration = {
|
||||
maintenance: false,
|
||||
dev: {
|
||||
responseSlowdownMs: 5,
|
||||
},
|
||||
quotes: {
|
||||
reporting: {
|
||||
enabled: false,
|
||||
maxReports: 5,
|
||||
},
|
||||
submissionEnabled: true,
|
||||
},
|
||||
} as any;
|
||||
const liveConfig: Partial<Configuration> = {
|
||||
maintenance: true,
|
||||
quotes: {
|
||||
reporting: {
|
||||
enabled: true,
|
||||
} as any,
|
||||
maxFavorites: 10,
|
||||
} as any,
|
||||
};
|
||||
|
||||
//WHEN
|
||||
mergeConfigurations(baseConfig, liveConfig);
|
||||
|
||||
//THEN
|
||||
expect(baseConfig).toEqual({
|
||||
maintenance: true,
|
||||
dev: {
|
||||
responseSlowdownMs: 5,
|
||||
},
|
||||
quotes: {
|
||||
reporting: {
|
||||
enabled: true,
|
||||
maxReports: 5,
|
||||
},
|
||||
submissionEnabled: true,
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
});
|
||||
});
|
||||
580
backend/__tests__/middlewares/auth.spec.ts
Normal file
580
backend/__tests__/middlewares/auth.spec.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as AuthUtils from "../../src/utils/auth";
|
||||
import * as Auth from "../../src/middlewares/auth";
|
||||
import { DecodedIdToken } from "firebase-admin/auth";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { getCachedConfiguration } from "../../src/init/configuration";
|
||||
import * as ApeKeys from "../../src/dal/ape-keys";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { hashSync } from "bcrypt";
|
||||
import MonkeyError from "../../src/utils/error";
|
||||
import * as Misc from "../../src/utils/misc";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
EndpointMetadata,
|
||||
RequestAuthenticationOptions,
|
||||
} from "@monkeytype/contracts/util/api";
|
||||
import * as Prometheus from "../../src/utils/prometheus";
|
||||
import { TsRestRequestWithContext } from "../../src/api/types";
|
||||
import { enableMonkeyErrorExpects } from "../__testData__/monkey-error";
|
||||
|
||||
enableMonkeyErrorExpects();
|
||||
const mockDecodedToken: DecodedIdToken = {
|
||||
uid: "123456789",
|
||||
email: "newuser@mail.com",
|
||||
iat: 0,
|
||||
} as DecodedIdToken;
|
||||
|
||||
vi.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken);
|
||||
|
||||
const mockApeKey = {
|
||||
_id: new ObjectId(),
|
||||
uid: "123",
|
||||
name: "test",
|
||||
hash: hashSync("key", 5),
|
||||
createdOn: Date.now(),
|
||||
modifiedOn: Date.now(),
|
||||
lastUsedOn: Date.now(),
|
||||
useCount: 0,
|
||||
enabled: true,
|
||||
};
|
||||
vi.spyOn(ApeKeys, "getApeKey").mockResolvedValue(mockApeKey);
|
||||
vi.spyOn(ApeKeys, "updateLastUsedOn").mockResolvedValue();
|
||||
const isDevModeMock = vi.spyOn(Misc, "isDevEnvironment");
|
||||
let mockRequest: Partial<TsRestRequestWithContext>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let nextFunction: NextFunction;
|
||||
|
||||
describe("middlewares/auth", () => {
|
||||
beforeEach(async () => {
|
||||
isDevModeMock.mockReturnValue(true);
|
||||
let config = await getCachedConfiguration(true);
|
||||
config.apeKeys.acceptKeys = true;
|
||||
|
||||
mockRequest = {
|
||||
baseUrl: "/api/v1",
|
||||
route: {
|
||||
path: "/",
|
||||
},
|
||||
headers: {
|
||||
authorization: "Bearer 123456789",
|
||||
},
|
||||
ctx: {
|
||||
configuration: config,
|
||||
decodedToken: {
|
||||
type: "None",
|
||||
uid: "",
|
||||
email: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockResponse = {
|
||||
json: vi.fn(),
|
||||
};
|
||||
nextFunction = vi.fn((error) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return "Next function called";
|
||||
}) as unknown as NextFunction;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
isDevModeMock.mockClear();
|
||||
});
|
||||
|
||||
describe("authenticateTsRestRequest", () => {
|
||||
const prometheusRecordAuthTimeMock = vi.spyOn(Prometheus, "recordAuthTime");
|
||||
const prometheusIncrementAuthMock = vi.spyOn(Prometheus, "incrementAuth");
|
||||
const timingSafeEqualMock = vi.spyOn(crypto, "timingSafeEqual");
|
||||
|
||||
beforeEach(() => {
|
||||
timingSafeEqualMock.mockClear().mockReturnValue(true);
|
||||
[prometheusIncrementAuthMock, prometheusRecordAuthTimeMock].forEach(
|
||||
(it) => it.mockClear(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail if token is not fresh", async () => {
|
||||
//GIVEN
|
||||
Date.now = vi.fn(() => 60001);
|
||||
const expectedError = new MonkeyError(
|
||||
401,
|
||||
"Unauthorized\nStack: This endpoint requires a fresh token",
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await expect(() =>
|
||||
authenticate({}, { requireFreshToken: true }),
|
||||
).rejects.toMatchMonkeyError(expectedError);
|
||||
|
||||
//THEN
|
||||
|
||||
expect(nextFunction).toHaveBeenLastCalledWith(
|
||||
expect.toMatchMonkeyError(expectedError),
|
||||
);
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request if token is fresh", async () => {
|
||||
//GIVEN
|
||||
Date.now = vi.fn(() => 10000);
|
||||
|
||||
//WHEN
|
||||
const result = await authenticate({}, { requireFreshToken: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe(mockDecodedToken.email);
|
||||
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
|
||||
expect(nextFunction).toHaveBeenCalledOnce();
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("Bearer");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request if apeKey is supported", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should fail with apeKey if apeKey is not supported", async () => {
|
||||
//WHEN
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: false },
|
||||
),
|
||||
).rejects.toThrow("This endpoint does not accept ApeKeys");
|
||||
|
||||
//THEN
|
||||
});
|
||||
it("should fail with apeKey if apeKeys are disabled", async () => {
|
||||
//GIVEN
|
||||
|
||||
//@ts-expect-error
|
||||
mockRequest.ctx.configuration.apeKeys.acceptKeys = false;
|
||||
|
||||
//WHEN
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: false },
|
||||
),
|
||||
).rejects.toThrow("ApeKeys are not being accepted at this time");
|
||||
|
||||
//THEN
|
||||
});
|
||||
it("should allow the request with authentation on public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate({}, { isPublic: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe(mockDecodedToken.email);
|
||||
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should allow the request without authentication on public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate({ headers: {} }, { isPublic: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("None");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request with apeKey on public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ isPublic: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow request with Uid on dev", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate({
|
||||
headers: { authorization: "Uid 123" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should allow request with Uid and email on dev", async () => {
|
||||
const result = await authenticate({
|
||||
headers: { authorization: "Uid 123|test@example.com" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe("test@example.com");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should fail request with Uid on non-dev", async () => {
|
||||
//GIVEN
|
||||
isDevModeMock.mockReturnValue(false);
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(() =>
|
||||
authenticate({ headers: { authorization: "Uid 123" } }),
|
||||
).rejects.toMatchMonkeyError(
|
||||
new MonkeyError(401, "Bearer type uid is not supported"),
|
||||
);
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await expect(() => authenticate({ headers: {} })).rejects.toThrow(
|
||||
"Unauthorized\nStack: endpoint: /api/v1 no authorization header found",
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail with empty authentication", async () => {
|
||||
await expect(() =>
|
||||
authenticate({ headers: { authorization: "" } }),
|
||||
).rejects.toThrow(
|
||||
"Unauthorized\nStack: endpoint: /api/v1 no authorization header found",
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail with missing authentication token", async () => {
|
||||
await expect(() =>
|
||||
authenticate({ headers: { authorization: "Bearer" } }),
|
||||
).rejects.toThrow(
|
||||
"Missing authentication token\nStack: authenticateWithAuthHeader",
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"Bearer",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail with unknown authentication scheme", async () => {
|
||||
await expect(() =>
|
||||
authenticate({ headers: { authorization: "unknown format" } }),
|
||||
).rejects.toThrow(
|
||||
'Unknown authentication scheme\nStack: The authentication scheme "unknown" is not implemented',
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"unknown",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should record country if provided", async () => {
|
||||
const prometheusRecordRequestCountryMock = vi.spyOn(
|
||||
Prometheus,
|
||||
"recordRequestCountry",
|
||||
);
|
||||
|
||||
await authenticate(
|
||||
{ headers: { "cf-ipcountry": "gb" } },
|
||||
{ isPublic: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
expect(prometheusRecordRequestCountryMock).toHaveBeenCalledWith(
|
||||
"gb",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should allow the request with authentation on dev public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate({}, { isPublicOnDev: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe(mockDecodedToken.email);
|
||||
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should allow the request without authentication on dev public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: {} },
|
||||
{ isPublicOnDev: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("None");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request with apeKey on dev public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: true, isPublicOnDev: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow with apeKey if apeKeys are disabled on dev public endpoint", async () => {
|
||||
//GIVEN
|
||||
|
||||
//@ts-expect-error
|
||||
mockRequest.ctx.configuration.apeKeys.acceptKeys = false;
|
||||
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: true, isPublicOnDev: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request with authentation on dev public endpoint in production", async () => {
|
||||
//WHEN
|
||||
isDevModeMock.mockReturnValue(false);
|
||||
const result = await authenticate({}, { isPublicOnDev: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe(mockDecodedToken.email);
|
||||
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should fail without authentication on dev public endpoint in production", async () => {
|
||||
//WHEN
|
||||
isDevModeMock.mockReturnValue(false);
|
||||
|
||||
//THEN
|
||||
await expect(() =>
|
||||
authenticate({ headers: {} }, { isPublicOnDev: true }),
|
||||
).rejects.toThrow("Unauthorized");
|
||||
});
|
||||
it("should allow with apeKey on dev public endpoint in production", async () => {
|
||||
//WHEN
|
||||
isDevModeMock.mockReturnValue(false);
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: true, isPublicOnDev: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow githubwebhook with header", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{
|
||||
headers: { "x-hub-signature-256": "the-signature" },
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("GithubWebhook");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("GithubWebhook");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
expect(timingSafeEqualMock).toHaveBeenCalledWith(
|
||||
Buffer.from(
|
||||
"sha256=ff0f3080539e9df19153f6b5b5780f66e558d61038e6cf5ecf4efdc7266a7751",
|
||||
),
|
||||
Buffer.from("the-signature"),
|
||||
);
|
||||
});
|
||||
it("should fail githubwebhook with mismatched signature", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
|
||||
timingSafeEqualMock.mockReturnValue(false);
|
||||
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{
|
||||
headers: { "x-hub-signature-256": "the-signature" },
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
),
|
||||
).rejects.toThrow("Github webhook signature invalid");
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail without header when endpoint is using githubwebhook", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{
|
||||
headers: {},
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
),
|
||||
).rejects.toThrow("Missing Github signature header");
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail with missing GITHUB_WEBHOOK_SECRET when endpoint is using githubwebhook", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "");
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{
|
||||
headers: { "x-hub-signature-256": "the-signature" },
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
),
|
||||
).rejects.toThrow("Missing Github Webhook Secret");
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should throw 500 if something went wrong when validating the signature when endpoint is using githubwebhook", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
|
||||
timingSafeEqualMock.mockImplementation(() => {
|
||||
throw new Error("could not validate");
|
||||
});
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{
|
||||
headers: { "x-hub-signature-256": "the-signature" },
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Failed to authenticate Github webhook: could not validate",
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function authenticate(
|
||||
request: Partial<Request>,
|
||||
authenticationOptions?: RequestAuthenticationOptions,
|
||||
): Promise<{ decodedToken: Auth.DecodedToken }> {
|
||||
const mergedRequest = {
|
||||
...mockRequest,
|
||||
...request,
|
||||
tsRestRoute: {
|
||||
metadata: { authenticationOptions } as EndpointMetadata,
|
||||
},
|
||||
} as any;
|
||||
|
||||
await Auth.authenticateTsRestRequest()(
|
||||
mergedRequest,
|
||||
mockResponse as Response,
|
||||
nextFunction,
|
||||
);
|
||||
|
||||
return { decodedToken: mergedRequest.ctx.decodedToken };
|
||||
}
|
||||
201
backend/__tests__/middlewares/configuration.spec.ts
Normal file
201
backend/__tests__/middlewares/configuration.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { RequireConfiguration } from "@monkeytype/contracts/require-configuration/index";
|
||||
import { verifyRequiredConfiguration } from "../../src/middlewares/configuration";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { Response } from "express";
|
||||
import MonkeyError from "../../src/utils/error";
|
||||
import { TsRestRequest } from "../../src/api/types";
|
||||
import { enableMonkeyErrorExpects } from "../__testData__/monkey-error";
|
||||
|
||||
enableMonkeyErrorExpects();
|
||||
describe("configuration middleware", () => {
|
||||
const handler = verifyRequiredConfiguration();
|
||||
const res: Response = {} as any;
|
||||
const next = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
next.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
//next function must only be called once
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should pass without requireConfiguration", async () => {
|
||||
//GIVEN
|
||||
const req = { tsRestRoute: { metadata: {} } } as any;
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass for enabled configuration", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ path: "maintenance" }, { maintenance: true });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass for enabled configuration with complex path", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "users.xp.streak.enabled" },
|
||||
{ users: { xp: { streak: { enabled: true } as any } as any } as any },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should fail for disabled configuration", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ path: "maintenance" }, { maintenance: false });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(503, "This endpoint is currently unavailable."),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for disabled configuration and custom message", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "maintenance", invalidMessage: "Feature not enabled." },
|
||||
{ maintenance: false },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(new MonkeyError(503, "Feature not enabled.")),
|
||||
);
|
||||
});
|
||||
it("should fail for invalid path", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ path: "invalid.path" as any }, {});
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(500, 'Invalid configuration path: "invalid.path"'),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for undefined value", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "admin.endpointsEnabled" },
|
||||
{ admin: {} as any },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
500,
|
||||
'Required configuration doesnt exist: "admin.endpointsEnabled"',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for null value", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "admin.endpointsEnabled" },
|
||||
{ admin: { endpointsEnabled: null as any } },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
500,
|
||||
'Required configuration doesnt exist: "admin.endpointsEnabled"',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for non booean value", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "admin.endpointsEnabled" },
|
||||
{ admin: { endpointsEnabled: "disabled" as any } },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
500,
|
||||
'Required configuration is not a boolean: "admin.endpointsEnabled"',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should pass for multiple configurations", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
[{ path: "maintenance" }, { path: "admin.endpointsEnabled" }],
|
||||
{ maintenance: true, admin: { endpointsEnabled: true } },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should fail for multiple configurations", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
[
|
||||
{ path: "maintenance", invalidMessage: "maintenance mode" },
|
||||
{ path: "admin.endpointsEnabled", invalidMessage: "admin disabled" },
|
||||
],
|
||||
{ maintenance: true, admin: { endpointsEnabled: false } },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(new MonkeyError(503, "admin disabled")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function givenRequest(
|
||||
requireConfiguration: RequireConfiguration | RequireConfiguration[],
|
||||
configuration: Partial<Configuration>,
|
||||
): TsRestRequest {
|
||||
return {
|
||||
tsRestRoute: { metadata: { requireConfiguration } },
|
||||
ctx: { configuration },
|
||||
} as any;
|
||||
}
|
||||
338
backend/__tests__/middlewares/permission.spec.ts
Normal file
338
backend/__tests__/middlewares/permission.spec.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Response } from "express";
|
||||
import { verifyPermissions } from "../../src/middlewares/permission";
|
||||
import { EndpointMetadata } from "@monkeytype/contracts/util/api";
|
||||
import * as Misc from "../../src/utils/misc";
|
||||
import * as AdminUids from "../../src/dal/admin-uids";
|
||||
import * as UserDal from "../../src/dal/user";
|
||||
import MonkeyError from "../../src/utils/error";
|
||||
import { DecodedToken } from "../../src/middlewares/auth";
|
||||
import { TsRestRequest } from "../../src/api/types";
|
||||
import { enableMonkeyErrorExpects } from "../__testData__/monkey-error";
|
||||
|
||||
enableMonkeyErrorExpects();
|
||||
const uid = "123456789";
|
||||
|
||||
describe("permission middleware", () => {
|
||||
const handler = verifyPermissions();
|
||||
const res: Response = {} as any;
|
||||
const next = vi.fn();
|
||||
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
const isAdminMock = vi.spyOn(AdminUids, "isAdmin");
|
||||
const isDevMock = vi.spyOn(Misc, "isDevEnvironment");
|
||||
|
||||
beforeEach(() => {
|
||||
next.mockClear();
|
||||
getPartialUserMock.mockClear().mockResolvedValue({} as any);
|
||||
isDevMock.mockClear().mockReturnValue(false);
|
||||
isAdminMock.mockClear().mockResolvedValue(false);
|
||||
});
|
||||
afterEach(() => {
|
||||
//next function must only be called once
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should bypass without requiredPermission", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({});
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should bypass with empty requiredPermission", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ requirePermission: [] });
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THE
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
describe("admin check", () => {
|
||||
const requireAdminPermission: EndpointMetadata = {
|
||||
requirePermission: "admin",
|
||||
};
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(requireAdminPermission);
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should pass without authentication if publicOnDev on dev", async () => {
|
||||
//GIVEN
|
||||
isDevMock.mockReturnValue(true);
|
||||
const req = givenRequest(
|
||||
{
|
||||
...requireAdminPermission,
|
||||
authenticationOptions: { isPublicOnDev: true },
|
||||
},
|
||||
{ uid },
|
||||
);
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should fail without authentication if publicOnDev on prod ", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{
|
||||
...requireAdminPermission,
|
||||
authenticationOptions: { isPublicOnDev: true },
|
||||
},
|
||||
{ uid },
|
||||
);
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail without admin permissions", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(requireAdminPermission, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
expect(isAdminMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
});
|
||||
describe("user checks", () => {
|
||||
it("should fetch user only once", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{
|
||||
requirePermission: ["canReport", "canManageApeKeys"],
|
||||
},
|
||||
{ uid },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(getPartialUserMock).toHaveBeenCalledOnce();
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["canReport", "canManageApeKeys"],
|
||||
);
|
||||
});
|
||||
it("should fail if authentication is missing", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({
|
||||
requirePermission: ["canReport", "canManageApeKeys"],
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
403,
|
||||
"Failed to check permissions, authentication required.",
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("quoteMod check", () => {
|
||||
const requireQuoteMod: EndpointMetadata = {
|
||||
requirePermission: "quoteMod",
|
||||
};
|
||||
|
||||
it("should pass for quoteAdmin", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ quoteMod: true } as any);
|
||||
const req = givenRequest(requireQuoteMod, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["quoteMod"],
|
||||
);
|
||||
});
|
||||
it("should pass for specific language", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ quoteMod: "english" } as any);
|
||||
const req = givenRequest(requireQuoteMod, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["quoteMod"],
|
||||
);
|
||||
});
|
||||
it("should fail for empty string", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ quoteMod: "" } as any);
|
||||
const req = givenRequest(requireQuoteMod, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for missing quoteMod", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({} as any);
|
||||
const req = givenRequest(requireQuoteMod, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("canReport check", () => {
|
||||
const requireCanReport: EndpointMetadata = {
|
||||
requirePermission: "canReport",
|
||||
};
|
||||
|
||||
it("should fail if user cannot report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ canReport: false } as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["canReport"],
|
||||
);
|
||||
});
|
||||
it("should pass if user can report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ canReport: true } as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass if canReport is not set", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({} as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe("canManageApeKeys check", () => {
|
||||
const requireCanReport: EndpointMetadata = {
|
||||
requirePermission: "canManageApeKeys",
|
||||
};
|
||||
|
||||
it("should fail if user cannot report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ canManageApeKeys: false } as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
403,
|
||||
"You have lost access to ape keys, please contact support",
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["canManageApeKeys"],
|
||||
);
|
||||
});
|
||||
it("should pass if user can report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ canManageApeKeys: true } as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass if canManageApeKeys is not set", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({} as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function givenRequest(
|
||||
metadata: EndpointMetadata,
|
||||
decodedToken?: Partial<DecodedToken>,
|
||||
): TsRestRequest {
|
||||
return { tsRestRoute: { metadata }, ctx: { decodedToken } } as any;
|
||||
}
|
||||
42
backend/__tests__/setup-common-mocks.ts
Normal file
42
backend/__tests__/setup-common-mocks.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function setupCommonMocks() {
|
||||
vi.mock("../src/utils/logger", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
error: console.error,
|
||||
warning: console.warn,
|
||||
info: console.info,
|
||||
success: console.info,
|
||||
logToDb: console.info,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("swagger-stats", () => ({
|
||||
getMiddleware:
|
||||
() =>
|
||||
(_: unknown, __: unknown, next: () => unknown): void => {
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
// TODO: better approach for this when needed
|
||||
// https://firebase.google.com/docs/rules/unit-tests#run_local_unit_tests_with_the_version_9_javascript_sdk
|
||||
vi.mock("firebase-admin", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
auth: (): unknown => ({
|
||||
verifyIdToken: (
|
||||
_token: string,
|
||||
_checkRevoked: boolean,
|
||||
): unknown /* Promise<DecodedIdToken> */ =>
|
||||
Promise.resolve({
|
||||
aud: "mockFirebaseProjectId",
|
||||
auth_time: 123,
|
||||
exp: 1000,
|
||||
uid: "mockUid",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
40
backend/__tests__/setup-tests.ts
Normal file
40
backend/__tests__/setup-tests.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { afterAll, beforeAll, afterEach, vi } from "vitest";
|
||||
import { BASE_CONFIGURATION } from "../src/constants/base-configuration";
|
||||
import { setupCommonMocks } from "./setup-common-mocks";
|
||||
import { __testing } from "../src/init/configuration";
|
||||
|
||||
process.env["MODE"] = "dev";
|
||||
process.env.TZ = "UTC";
|
||||
beforeAll(async () => {
|
||||
//don't add any configuration here, add to global-setup.ts instead.
|
||||
|
||||
vi.mock("../src/init/configuration", async (importOriginal) => {
|
||||
const orig = (await importOriginal()) as { __testing: typeof __testing };
|
||||
|
||||
return {
|
||||
__testing: orig.__testing,
|
||||
getLiveConfiguration: () => BASE_CONFIGURATION,
|
||||
getCachedConfiguration: () => BASE_CONFIGURATION,
|
||||
patchConfiguration: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/init/db", () => ({
|
||||
__esModule: true,
|
||||
getDb: () => undefined,
|
||||
collection: () => undefined,
|
||||
close: () => {
|
||||
//
|
||||
},
|
||||
}));
|
||||
|
||||
setupCommonMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
//nothing
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
12
backend/__tests__/tsconfig.json
Normal file
12
backend/__tests__/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@monkeytype/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"noImplicitAny": false,
|
||||
"strictFunctionTypes": false,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"strictPropertyInitialization": false
|
||||
},
|
||||
"files": ["vitest.d.ts"],
|
||||
"include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"]
|
||||
}
|
||||
498
backend/__tests__/utils/misc.spec.ts
Normal file
498
backend/__tests__/utils/misc.spec.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { describe, it, expect, afterAll, vi } from "vitest";
|
||||
import * as Misc from "../../src/utils/misc";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
describe("Misc Utils", () => {
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("matchesAPattern", () => {
|
||||
const testCases = [
|
||||
{
|
||||
pattern: "eng.*",
|
||||
cases: ["english", "aenglish", "en", "eng"],
|
||||
expected: [true, false, false, true],
|
||||
},
|
||||
|
||||
{
|
||||
pattern: "\\d+",
|
||||
cases: ["b", "2", "331", "1a"],
|
||||
expected: [false, true, true, false],
|
||||
},
|
||||
{
|
||||
pattern: "(hi|hello)",
|
||||
cases: ["hello", "hi", "hillo", "hi hello"],
|
||||
expected: [true, true, false, false],
|
||||
},
|
||||
{
|
||||
pattern: ".+",
|
||||
cases: ["a2", "b2", "c1", ""],
|
||||
expected: [true, true, true, false],
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
"matchesAPattern with $pattern",
|
||||
({ pattern, cases, expected }) => {
|
||||
cases.forEach((caseValue, index) => {
|
||||
expect(Misc.matchesAPattern(caseValue, pattern)).toBe(
|
||||
expected[index],
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("kogascore", () => {
|
||||
const testCases = [
|
||||
{
|
||||
wpm: 214.8,
|
||||
acc: 93.04,
|
||||
timestamp: 1653586489000,
|
||||
expectedScore: 1214800930423111,
|
||||
},
|
||||
{
|
||||
wpm: 214.8,
|
||||
acc: 93.04,
|
||||
timestamp: 1653601763000,
|
||||
expectedScore: 1214800930407837,
|
||||
},
|
||||
{
|
||||
wpm: 199.37,
|
||||
acc: 97.69,
|
||||
timestamp: 1653588809000,
|
||||
expectedScore: 1199370976920791,
|
||||
},
|
||||
{
|
||||
wpm: 196.2,
|
||||
acc: 96.07,
|
||||
timestamp: 1653591901000,
|
||||
expectedScore: 1196200960717699,
|
||||
},
|
||||
{
|
||||
wpm: 196.205,
|
||||
acc: 96.075,
|
||||
timestamp: 1653591901000,
|
||||
expectedScore: 1196210960817699,
|
||||
},
|
||||
{
|
||||
// this one is particularly important - in JS 154.39 * 100 is equal to 15438.999999999998
|
||||
// thanks floating point errors!
|
||||
wpm: 154.39,
|
||||
acc: 96.14,
|
||||
timestamp: 1740333827000,
|
||||
expectedScore: 1154390961421373,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
"kogascore with wpm:$wpm, acc:$acc, timestamp:$timestamp = $expectedScore",
|
||||
({ wpm, acc, timestamp, expectedScore }) => {
|
||||
expect(Misc.kogascore(wpm, acc, timestamp)).toBe(expectedScore);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("identity", () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: "",
|
||||
expected: "string",
|
||||
},
|
||||
{
|
||||
input: {},
|
||||
expected: "object",
|
||||
},
|
||||
{
|
||||
input: 0,
|
||||
expected: "number",
|
||||
},
|
||||
{
|
||||
input: null,
|
||||
expected: "null",
|
||||
},
|
||||
{
|
||||
input: undefined,
|
||||
expected: "undefined",
|
||||
},
|
||||
];
|
||||
it.each(testCases)(
|
||||
"identity with $input = $expected",
|
||||
({ input, expected }) => {
|
||||
expect(Misc.identity(input)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("flattenObjectDeep", () => {
|
||||
const testCases = [
|
||||
{
|
||||
obj: {
|
||||
a: {
|
||||
b: {
|
||||
c: 1,
|
||||
},
|
||||
},
|
||||
d: 2,
|
||||
e: [],
|
||||
},
|
||||
expected: {
|
||||
"a.b.c": 1,
|
||||
d: 2,
|
||||
e: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
obj: {
|
||||
a: {
|
||||
b: {
|
||||
c: 1,
|
||||
},
|
||||
},
|
||||
d: {
|
||||
e: {
|
||||
f: 2,
|
||||
g: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
"a.b.c": 1,
|
||||
"d.e.f": 2,
|
||||
"d.e.g": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
obj: {
|
||||
a: {
|
||||
b: {
|
||||
c: 1,
|
||||
d: {
|
||||
e: 2,
|
||||
f: 3,
|
||||
g: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
"a.b.c": 1,
|
||||
"a.b.d.e": 2,
|
||||
"a.b.d.f": 3,
|
||||
"a.b.d.g": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
obj: {},
|
||||
expected: {},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
"flattenObjectDeep with $obj = $expected",
|
||||
({ obj, expected }) => {
|
||||
expect(Misc.flattenObjectDeep(obj)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizeString", () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: "h̶̼͔̭͈̏́̀́͋͜ͅe̵̺̞̦̫̫͔̋́̅̅̃̀͝͝ļ̶̬̯͚͇̺͍̞̫̟͖͋̓͛̆̒̓͜ĺ̴̗̘͇̬̆͂͌̈͊͝͝ỡ̴̡̦̩̠̞̐̃͆̚͠͝",
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
input: "hello",
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
input: "hel lo",
|
||||
expected: "hel lo",
|
||||
},
|
||||
{
|
||||
input: " hel lo ",
|
||||
expected: "hel lo",
|
||||
},
|
||||
{
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
input: " \n\n\n",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
input: undefined,
|
||||
expected: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
expect(Misc.sanitizeString(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("getOrdinalNumberString", () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 0,
|
||||
output: "0th",
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
output: "1st",
|
||||
},
|
||||
{
|
||||
input: 2,
|
||||
output: "2nd",
|
||||
},
|
||||
{
|
||||
input: 3,
|
||||
output: "3rd",
|
||||
},
|
||||
{
|
||||
input: 4,
|
||||
output: "4th",
|
||||
},
|
||||
{
|
||||
input: 10,
|
||||
output: "10th",
|
||||
},
|
||||
{
|
||||
input: 11,
|
||||
output: "11th",
|
||||
},
|
||||
{
|
||||
input: 12,
|
||||
output: "12th",
|
||||
},
|
||||
{
|
||||
input: 13,
|
||||
output: "13th",
|
||||
},
|
||||
{
|
||||
input: 100,
|
||||
output: "100th",
|
||||
},
|
||||
{
|
||||
input: 101,
|
||||
output: "101st",
|
||||
},
|
||||
{
|
||||
input: 102,
|
||||
output: "102nd",
|
||||
},
|
||||
{
|
||||
input: 103,
|
||||
output: "103rd",
|
||||
},
|
||||
{
|
||||
input: 104,
|
||||
output: "104th",
|
||||
},
|
||||
{
|
||||
input: 93589423,
|
||||
output: "93589423rd",
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, output }) => {
|
||||
expect(Misc.getOrdinalNumberString(input)).toEqual(output);
|
||||
});
|
||||
});
|
||||
it("formatSeconds", () => {
|
||||
const testCases = [
|
||||
{
|
||||
seconds: 5,
|
||||
expected: "5 seconds",
|
||||
},
|
||||
{
|
||||
seconds: 65,
|
||||
expected: "1.08 minutes",
|
||||
},
|
||||
{
|
||||
seconds: Misc.HOUR_IN_SECONDS,
|
||||
expected: "1 hour",
|
||||
},
|
||||
{
|
||||
seconds: Misc.DAY_IN_SECONDS,
|
||||
expected: "1 day",
|
||||
},
|
||||
{
|
||||
seconds: Misc.WEEK_IN_SECONDS,
|
||||
expected: "1 week",
|
||||
},
|
||||
{
|
||||
seconds: Misc.YEAR_IN_SECONDS,
|
||||
expected: "1 year",
|
||||
},
|
||||
{
|
||||
seconds: 2 * Misc.YEAR_IN_SECONDS,
|
||||
expected: "2 years",
|
||||
},
|
||||
{
|
||||
seconds: 4 * Misc.YEAR_IN_SECONDS,
|
||||
expected: "4 years",
|
||||
},
|
||||
{
|
||||
seconds: 3 * Misc.WEEK_IN_SECONDS,
|
||||
expected: "3 weeks",
|
||||
},
|
||||
{
|
||||
seconds: Misc.MONTH_IN_SECONDS * 4,
|
||||
expected: "4 months",
|
||||
},
|
||||
{
|
||||
seconds: Misc.MONTH_IN_SECONDS * 11,
|
||||
expected: "11 months",
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ seconds, expected }) => {
|
||||
expect(Misc.formatSeconds(seconds)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceObjectId", () => {
|
||||
it("replaces objecId with string", () => {
|
||||
const fromDatabase = {
|
||||
_id: new ObjectId(),
|
||||
test: "test",
|
||||
number: 1,
|
||||
};
|
||||
expect(Misc.replaceObjectId(fromDatabase)).toStrictEqual({
|
||||
_id: fromDatabase._id.toHexString(),
|
||||
test: "test",
|
||||
number: 1,
|
||||
});
|
||||
});
|
||||
it("ignores null values", () => {
|
||||
expect(Misc.replaceObjectId(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceObjectIds", () => {
|
||||
it("replaces objecIds with string", () => {
|
||||
const fromDatabase = {
|
||||
_id: new ObjectId(),
|
||||
test: "test",
|
||||
number: 1,
|
||||
};
|
||||
const fromDatabase2 = {
|
||||
_id: new ObjectId(),
|
||||
test: "bob",
|
||||
number: 2,
|
||||
};
|
||||
expect(
|
||||
Misc.replaceObjectIds([fromDatabase, fromDatabase2]),
|
||||
).toStrictEqual([
|
||||
{
|
||||
_id: fromDatabase._id.toHexString(),
|
||||
test: "test",
|
||||
number: 1,
|
||||
},
|
||||
{
|
||||
_id: fromDatabase2._id.toHexString(),
|
||||
test: "bob",
|
||||
number: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("handles undefined", () => {
|
||||
expect(Misc.replaceObjectIds(undefined as any)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("omit()", () => {
|
||||
it("should omit a single key", () => {
|
||||
const input = { a: 1, b: 2, c: 3 };
|
||||
const result = Misc.omit(input, ["b"]);
|
||||
expect(result).toEqual({ a: 1, c: 3 });
|
||||
});
|
||||
|
||||
it("should omit multiple keys", () => {
|
||||
const input = { a: 1, b: 2, c: 3, d: 4 };
|
||||
const result = Misc.omit(input, ["a", "d"]);
|
||||
expect(result).toEqual({ b: 2, c: 3 });
|
||||
});
|
||||
|
||||
it("should return the same object if no keys are omitted", () => {
|
||||
const input = { x: 1, y: 2 };
|
||||
const result = Misc.omit(input, []);
|
||||
expect(result).toEqual({ x: 1, y: 2 });
|
||||
});
|
||||
|
||||
it("should not mutate the original object", () => {
|
||||
const input = { foo: "bar", baz: "qux" };
|
||||
const copy = { ...input };
|
||||
Misc.omit(input, ["baz"]);
|
||||
expect(input).toEqual(copy);
|
||||
});
|
||||
|
||||
it("should ignore keys that do not exist", () => {
|
||||
const input = { a: 1, b: 2 };
|
||||
const result = Misc.omit(input, "c" as any); // allow a non-existing key
|
||||
expect(result).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it("should work with different value types", () => {
|
||||
const input = {
|
||||
str: "hello",
|
||||
num: 123,
|
||||
bool: true,
|
||||
obj: { x: 1 },
|
||||
arr: [1, 2, 3],
|
||||
};
|
||||
const result = Misc.omit(input, ["bool", "arr"]);
|
||||
expect(result).toEqual({
|
||||
str: "hello",
|
||||
num: 123,
|
||||
obj: { x: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPlainObject", () => {
|
||||
it("should return true for plain objects", () => {
|
||||
expect(Misc.isPlainObject({})).toBe(true);
|
||||
expect(Misc.isPlainObject({ a: 1, b: 2 })).toBe(true);
|
||||
expect(Misc.isPlainObject(Object.create(Object.prototype))).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for arrays", () => {
|
||||
expect(Misc.isPlainObject([])).toBe(false);
|
||||
expect(Misc.isPlainObject([1, 2, 3])).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for null", () => {
|
||||
expect(Misc.isPlainObject(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for primitives", () => {
|
||||
expect(Misc.isPlainObject(123)).toBe(false);
|
||||
expect(Misc.isPlainObject("string")).toBe(false);
|
||||
expect(Misc.isPlainObject(true)).toBe(false);
|
||||
expect(Misc.isPlainObject(undefined)).toBe(false);
|
||||
expect(Misc.isPlainObject(Symbol("sym"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for objects with different prototypes", () => {
|
||||
// oxlint-disable-next-line no-extraneous-class
|
||||
class MyClass {}
|
||||
expect(Misc.isPlainObject(new MyClass())).toBe(false);
|
||||
expect(Misc.isPlainObject(Object.create(null))).toBe(false);
|
||||
expect(Misc.isPlainObject(new Date())).toBe(false);
|
||||
expect(Misc.isPlainObject(new Map())).toBe(false);
|
||||
expect(Misc.isPlainObject(new Set())).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for functions", () => {
|
||||
// oxlint-disable-next-line no-empty-function
|
||||
expect(Misc.isPlainObject(function () {})).toBe(false);
|
||||
// oxlint-disable-next-line no-empty-function
|
||||
expect(Misc.isPlainObject(() => {})).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
backend/__tests__/utils/monkey-mail.spec.ts
Normal file
21
backend/__tests__/utils/monkey-mail.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildMonkeyMail } from "../../src/utils/monkey-mail";
|
||||
|
||||
describe("Monkey Mail", () => {
|
||||
it("should properly create a mail object", () => {
|
||||
const mailConfig = {
|
||||
subject: "",
|
||||
body: "",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const mail = buildMonkeyMail(mailConfig) as any;
|
||||
|
||||
expect(mail.id).toBeDefined();
|
||||
expect(mail.subject).toBe("");
|
||||
expect(mail.body).toBe("");
|
||||
expect(mail.timestamp).toBeDefined();
|
||||
expect(mail.read).toBe(false);
|
||||
expect(mail.rewards).toEqual([]);
|
||||
});
|
||||
});
|
||||
213
backend/__tests__/utils/pb.spec.ts
Normal file
213
backend/__tests__/utils/pb.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as pb from "../../src/utils/pb";
|
||||
import { Mode, PersonalBests } from "@monkeytype/schemas/shared";
|
||||
import { Result } from "@monkeytype/schemas/results";
|
||||
import { FunboxName } from "@monkeytype/schemas/configs";
|
||||
|
||||
describe("Pb Utils", () => {
|
||||
describe("funboxCatGetPb", () => {
|
||||
const testCases: { funbox: FunboxName[] | undefined; expected: boolean }[] =
|
||||
[
|
||||
{
|
||||
funbox: ["plus_one"],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
funbox: [],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
funbox: undefined,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
funbox: ["nausea", "plus_one"],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
funbox: ["arrows"],
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
"canFunboxGetPb with $funbox = $expected",
|
||||
({ funbox, expected }) => {
|
||||
const result = pb.canFunboxGetPb({ funbox } as any);
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("checkAndUpdatePb", () => {
|
||||
it("should update personal best", () => {
|
||||
const userPbs: PersonalBests = {
|
||||
time: {},
|
||||
words: {},
|
||||
custom: {},
|
||||
quote: {},
|
||||
zen: {},
|
||||
};
|
||||
const result = {
|
||||
difficulty: "normal",
|
||||
language: "english",
|
||||
punctuation: false,
|
||||
lazyMode: false,
|
||||
acc: 100,
|
||||
consistency: 100,
|
||||
rawWpm: 100,
|
||||
wpm: 110,
|
||||
numbers: false,
|
||||
mode: "time",
|
||||
mode2: "15",
|
||||
} as unknown as Result<Mode>;
|
||||
|
||||
const run = pb.checkAndUpdatePb(
|
||||
userPbs,
|
||||
{} as pb.LbPersonalBests,
|
||||
result,
|
||||
);
|
||||
|
||||
expect(run.isPb).toBe(true);
|
||||
expect(run.personalBests.time?.["15"]?.[0]).not.toBe(undefined);
|
||||
expect(run.lbPersonalBests).not.toBe({});
|
||||
});
|
||||
it("should not override default pb when saving numbers test", () => {
|
||||
const userPbs: PersonalBests = {
|
||||
time: {
|
||||
"15": [
|
||||
{
|
||||
acc: 100,
|
||||
consistency: 100,
|
||||
difficulty: "normal",
|
||||
lazyMode: false,
|
||||
language: "english",
|
||||
numbers: false,
|
||||
punctuation: false,
|
||||
raw: 100,
|
||||
timestamp: 0,
|
||||
wpm: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
words: {},
|
||||
custom: {},
|
||||
quote: {},
|
||||
zen: {},
|
||||
};
|
||||
const result = {
|
||||
difficulty: "normal",
|
||||
language: "english",
|
||||
punctuation: false,
|
||||
lazyMode: false,
|
||||
acc: 100,
|
||||
consistency: 100,
|
||||
rawWpm: 100,
|
||||
wpm: 110,
|
||||
numbers: true,
|
||||
mode: "time",
|
||||
mode2: "15",
|
||||
} as unknown as Result<Mode>;
|
||||
|
||||
const run = pb.checkAndUpdatePb(userPbs, undefined, result);
|
||||
|
||||
expect(run.isPb).toBe(true);
|
||||
|
||||
expect(run.personalBests.time?.["15"]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ numbers: false, wpm: 100 }),
|
||||
expect.objectContaining({ numbers: true, wpm: 110 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("updateLeaderboardPersonalBests", () => {
|
||||
const userPbs: PersonalBests = {
|
||||
time: {
|
||||
"15": [
|
||||
{
|
||||
acc: 100,
|
||||
consistency: 100,
|
||||
difficulty: "normal",
|
||||
lazyMode: false,
|
||||
language: "english",
|
||||
numbers: false,
|
||||
punctuation: false,
|
||||
raw: 100,
|
||||
timestamp: 0,
|
||||
wpm: 100,
|
||||
},
|
||||
{
|
||||
acc: 100,
|
||||
consistency: 100,
|
||||
difficulty: "normal",
|
||||
lazyMode: false,
|
||||
language: "spanish",
|
||||
numbers: false,
|
||||
punctuation: false,
|
||||
raw: 100,
|
||||
timestamp: 0,
|
||||
wpm: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
words: {},
|
||||
custom: {},
|
||||
quote: {},
|
||||
zen: {},
|
||||
};
|
||||
it("should update leaderboard personal bests if they dont exist or the structure is incomplete", () => {
|
||||
const lbpbstartingvalues = [
|
||||
undefined,
|
||||
{},
|
||||
{ time: {} },
|
||||
{ time: { "15": {} } },
|
||||
{ time: { "15": { english: {} } } },
|
||||
];
|
||||
|
||||
const result15 = {
|
||||
mode: "time",
|
||||
mode2: "15",
|
||||
} as unknown as Result<Mode>;
|
||||
|
||||
for (const lbPb of lbpbstartingvalues) {
|
||||
const lbPbPb = pb.updateLeaderboardPersonalBests(
|
||||
userPbs,
|
||||
structuredClone(lbPb) as pb.LbPersonalBests,
|
||||
result15,
|
||||
);
|
||||
|
||||
expect(lbPbPb).toEqual({
|
||||
time: {
|
||||
"15": {
|
||||
english: {
|
||||
acc: 100,
|
||||
consistency: 100,
|
||||
difficulty: "normal",
|
||||
lazyMode: false,
|
||||
language: "english",
|
||||
numbers: false,
|
||||
punctuation: false,
|
||||
raw: 100,
|
||||
timestamp: 0,
|
||||
wpm: 100,
|
||||
},
|
||||
spanish: {
|
||||
acc: 100,
|
||||
consistency: 100,
|
||||
difficulty: "normal",
|
||||
lazyMode: false,
|
||||
language: "spanish",
|
||||
numbers: false,
|
||||
punctuation: false,
|
||||
raw: 100,
|
||||
timestamp: 0,
|
||||
wpm: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
220
backend/__tests__/utils/result.spec.ts
Normal file
220
backend/__tests__/utils/result.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { replaceLegacyValues, DBResult } from "../../src/utils/result";
|
||||
|
||||
describe("Result Utils", () => {
|
||||
describe("replaceLegacyValues", () => {
|
||||
describe("legacy charStats conversion", () => {
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
"should convert correctChars and incorrectChars to charStats",
|
||||
correctChars: 95,
|
||||
incorrectChars: 5,
|
||||
expectedCharStats: [95, 5, 0, 0],
|
||||
},
|
||||
{
|
||||
description: "should handle zero values for legacy chars",
|
||||
correctChars: 0,
|
||||
incorrectChars: 0,
|
||||
expectedCharStats: [0, 0, 0, 0],
|
||||
},
|
||||
{
|
||||
description: "should handle large values for legacy chars",
|
||||
correctChars: 9999,
|
||||
incorrectChars: 1234,
|
||||
expectedCharStats: [9999, 1234, 0, 0],
|
||||
},
|
||||
])(
|
||||
"$description",
|
||||
({ correctChars, incorrectChars, expectedCharStats }) => {
|
||||
const resultWithLegacyChars: DBResult = {
|
||||
correctChars,
|
||||
incorrectChars,
|
||||
} as any;
|
||||
|
||||
const result = replaceLegacyValues(resultWithLegacyChars);
|
||||
|
||||
expect(result.charStats).toEqual(expectedCharStats);
|
||||
expect(result.correctChars).toBeUndefined();
|
||||
expect(result.incorrectChars).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("should prioritise charStats when legacy data exists", () => {
|
||||
const resultWithBothFormats: DBResult = {
|
||||
charStats: [80, 4, 2, 1],
|
||||
correctChars: 95,
|
||||
incorrectChars: 5,
|
||||
} as any;
|
||||
|
||||
const result = replaceLegacyValues(resultWithBothFormats);
|
||||
|
||||
// Should convert legacy values and overwrite existing charStats
|
||||
expect(result.charStats).toEqual([80, 4, 2, 1]);
|
||||
// Legacy values should be removed after conversion
|
||||
expect(result.correctChars).toBeUndefined();
|
||||
expect(result.incorrectChars).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
"should not convert when only one legacy property is present",
|
||||
input: { correctChars: 95 },
|
||||
expectedCharStats: undefined,
|
||||
expectedCorrectChars: 95,
|
||||
expectedIncorrectChars: undefined,
|
||||
},
|
||||
{
|
||||
description: "should not convert when only incorrectChars is present",
|
||||
input: { incorrectChars: 5 },
|
||||
expectedCharStats: undefined,
|
||||
expectedCorrectChars: undefined,
|
||||
expectedIncorrectChars: 5,
|
||||
},
|
||||
])(
|
||||
"$description",
|
||||
({
|
||||
input,
|
||||
expectedCharStats,
|
||||
expectedCorrectChars,
|
||||
expectedIncorrectChars,
|
||||
}) => {
|
||||
const result = replaceLegacyValues(input as any);
|
||||
|
||||
// Should not convert since both properties are required
|
||||
expect(result.charStats).toBe(expectedCharStats);
|
||||
expect(result.correctChars).toBe(expectedCorrectChars);
|
||||
expect(result.incorrectChars).toBe(expectedIncorrectChars);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("legacy funbox conversion", () => {
|
||||
it.each([
|
||||
{
|
||||
description: "should convert string funbox to array",
|
||||
input: "memory#mirror",
|
||||
expected: ["memory", "mirror"],
|
||||
},
|
||||
{
|
||||
description: "should convert single funbox string to array",
|
||||
input: "memory",
|
||||
expected: ["memory"],
|
||||
},
|
||||
{
|
||||
description: "should convert 'none' funbox to empty array",
|
||||
input: "none",
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
description: "should handle complex funbox combinations",
|
||||
input: "memory#mirror#arrows#58008",
|
||||
expected: ["memory", "mirror", "arrows", "58008"],
|
||||
},
|
||||
])("$description", ({ input, expected }) => {
|
||||
const resultWithStringFunbox: DBResult = {
|
||||
funbox: input as any,
|
||||
} as any;
|
||||
|
||||
const result = replaceLegacyValues(resultWithStringFunbox);
|
||||
|
||||
expect(result.funbox).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy chartData conversion", () => {
|
||||
it("should convert chartData with 'raw' property to 'burst'", () => {
|
||||
const resultWithLegacyChartData: DBResult = {
|
||||
chartData: {
|
||||
wpm: [50, 55, 60],
|
||||
raw: [52, 57, 62],
|
||||
err: [1, 0, 2],
|
||||
} as any,
|
||||
} as any;
|
||||
|
||||
const result = replaceLegacyValues(resultWithLegacyChartData);
|
||||
|
||||
expect(result.chartData).toEqual({
|
||||
wpm: [50, 55, 60],
|
||||
burst: [52, 57, 62],
|
||||
err: [1, 0, 2],
|
||||
});
|
||||
});
|
||||
|
||||
it("should not convert chartData when it's 'toolong'", () => {
|
||||
const resultWithToolongChartData: DBResult = {
|
||||
chartData: "toolong",
|
||||
} as any;
|
||||
|
||||
const result = replaceLegacyValues(resultWithToolongChartData);
|
||||
|
||||
expect(result.chartData).toBe("toolong");
|
||||
});
|
||||
|
||||
it("should not convert chartData when it doesn't have 'raw' property", () => {
|
||||
const resultWithModernChartData: DBResult = {
|
||||
chartData: {
|
||||
wpm: [50, 55, 60],
|
||||
burst: [52, 57, 62],
|
||||
err: [1, 0, 2],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = replaceLegacyValues(resultWithModernChartData);
|
||||
|
||||
expect(result.chartData).toEqual({
|
||||
wpm: [50, 55, 60],
|
||||
burst: [52, 57, 62],
|
||||
err: [1, 0, 2],
|
||||
});
|
||||
});
|
||||
|
||||
it("should not convert chartData when it's undefined", () => {
|
||||
const resultWithoutChartData: DBResult = {} as any;
|
||||
|
||||
const result = replaceLegacyValues(resultWithoutChartData);
|
||||
|
||||
expect(result.chartData).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert all legacy data at once", () => {
|
||||
const resultWithAllLegacy: DBResult = {
|
||||
correctChars: 100,
|
||||
incorrectChars: 8,
|
||||
funbox: "memory#mirror" as any,
|
||||
chartData: {
|
||||
wpm: [50, 55, 60],
|
||||
raw: [52, 57, 62],
|
||||
err: [1, 0, 2],
|
||||
} as any,
|
||||
} as any;
|
||||
|
||||
const result = replaceLegacyValues(resultWithAllLegacy);
|
||||
|
||||
expect(result.charStats).toEqual([100, 8, 0, 0]);
|
||||
expect(result.correctChars).toBeUndefined();
|
||||
expect(result.incorrectChars).toBeUndefined();
|
||||
expect(result.funbox).toEqual(["memory", "mirror"]);
|
||||
expect(result.chartData).toEqual({
|
||||
wpm: [50, 55, 60],
|
||||
burst: [52, 57, 62],
|
||||
err: [1, 0, 2],
|
||||
});
|
||||
});
|
||||
|
||||
describe("no legacy values", () => {
|
||||
it("should return result unchanged when no legacy values present", () => {
|
||||
const modernResult: DBResult = {
|
||||
charStats: [95, 5, 2, 1],
|
||||
funbox: ["memory"],
|
||||
} as any;
|
||||
|
||||
const result = replaceLegacyValues(modernResult);
|
||||
|
||||
expect(result).toEqual(modernResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
55
backend/__tests__/utils/validation.spec.ts
Normal file
55
backend/__tests__/utils/validation.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as Validation from "../../src/utils/validation";
|
||||
|
||||
describe("Validation", () => {
|
||||
it("isTestTooShort", () => {
|
||||
const testCases = [
|
||||
{
|
||||
result: {
|
||||
mode: "time",
|
||||
mode2: 10,
|
||||
customText: undefined,
|
||||
testDuration: 10,
|
||||
bailedOut: false,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
result: {
|
||||
mode: "time",
|
||||
mode2: 15,
|
||||
customText: undefined,
|
||||
testDuration: 15,
|
||||
bailedOut: false,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
result: {
|
||||
mode: "time",
|
||||
mode2: 0,
|
||||
customText: undefined,
|
||||
testDuration: 20,
|
||||
bailedOut: false,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
result: {
|
||||
mode: "time",
|
||||
mode2: 0,
|
||||
customText: undefined,
|
||||
testDuration: 2,
|
||||
bailedOut: false,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
expect(Validation.isTestTooShort(testCase.result as any)).toBe(
|
||||
testCase.expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
32
backend/__tests__/vitest.d.ts
vendored
Normal file
32
backend/__tests__/vitest.d.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Assertion, AsymmetricMatchersContaining } from "vitest";
|
||||
import type { Test as SuperTest } from "supertest";
|
||||
import MonkeyError from "../src/utils/error";
|
||||
|
||||
type ExpectedRateLimit = {
|
||||
/** max calls */
|
||||
max: number;
|
||||
/** window in milliseconds. Needs to be within 2500ms */
|
||||
windowMs: number;
|
||||
};
|
||||
interface RestRequestMatcher<R = Supertest> {
|
||||
toBeRateLimited: (expected: ExpectedRateLimit) => RestRequestMatcher<R>;
|
||||
}
|
||||
interface ThrowMatcher {
|
||||
toMatchMonkeyError: (expected: {
|
||||
status: number;
|
||||
message: string;
|
||||
}) => MatcherResult;
|
||||
}
|
||||
|
||||
declare module "vitest" {
|
||||
interface Assertion<T = any> extends RestRequestMatcher<T>, ThrowMatcher {}
|
||||
interface AsymmetricMatchersContaining
|
||||
extends RestRequestMatcher, ThrowMatcher {}
|
||||
}
|
||||
|
||||
interface MatcherResult {
|
||||
pass: boolean;
|
||||
message: () => string;
|
||||
actual?: unknown;
|
||||
expected?: unknown;
|
||||
}
|
||||
33
backend/__tests__/workers/later-worker.spec.ts
Normal file
33
backend/__tests__/workers/later-worker.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as LaterWorker from "../../src/workers/later-worker";
|
||||
const calculateXpReward = LaterWorker.__testing.calculateXpReward;
|
||||
|
||||
describe("later-worker", () => {
|
||||
describe("calculateXpReward", () => {
|
||||
it("should return the correct XP reward for a given rank", () => {
|
||||
//GIVEN
|
||||
const xpRewardBrackets = [
|
||||
{ minRank: 1, maxRank: 1, minReward: 100, maxReward: 100 },
|
||||
{ minRank: 2, maxRank: 10, minReward: 50, maxReward: 90 },
|
||||
];
|
||||
|
||||
//WHEN / THEN
|
||||
expect(calculateXpReward(xpRewardBrackets, 5)).toBe(75);
|
||||
expect(calculateXpReward(xpRewardBrackets, 11)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return the highest XP reward if brackets overlap", () => {
|
||||
//GIVEN
|
||||
const xpRewardBrackets = [
|
||||
{ minRank: 1, maxRank: 5, minReward: 900, maxReward: 1000 },
|
||||
{ minRank: 2, maxRank: 20, minReward: 50, maxReward: 90 },
|
||||
];
|
||||
|
||||
//WHEN
|
||||
const reward = calculateXpReward(xpRewardBrackets, 5);
|
||||
|
||||
//THEN
|
||||
expect(reward).toBe(900);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user