This commit is contained in:
5
backend/.gitignore
vendored
Normal file
5
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
lastId.txt
|
||||
log_success.txt
|
||||
log_failed.txt
|
||||
build
|
||||
worker.*
|
||||
16
backend/.oxlintrc.json
Normal file
16
backend/.oxlintrc.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"ignorePatterns": ["node_modules", "__migration__", "dist", ".turbo"],
|
||||
"extends": [
|
||||
"../packages/oxlint-config/index.jsonc",
|
||||
"../packages/oxlint-config/plugin.jsonc"
|
||||
// "@monkeytype/oxlint-config"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/**/*.ts"],
|
||||
"rules": {
|
||||
"import/no-cycle": "off" //todo: fix cycles and turn this on
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
3
backend/.vscode/settings.json
vendored
Normal file
3
backend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"oxc.fmt.configPath": "../.oxfmtrc-editor.json"
|
||||
}
|
||||
258
backend/__migration__/testActivity.ts
Normal file
258
backend/__migration__/testActivity.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import "dotenv/config";
|
||||
import * as DB from "../src/init/db";
|
||||
import { Collection, Db } from "mongodb";
|
||||
|
||||
import readlineSync from "readline-sync";
|
||||
import { DBUser } from "../src/dal/user";
|
||||
import { DBResult } from "../src/utils/result";
|
||||
|
||||
const batchSize = 50;
|
||||
|
||||
let appRunning = true;
|
||||
let db: Db | undefined;
|
||||
let userCollection: Collection<DBUser>;
|
||||
let resultCollection: Collection<DBResult>;
|
||||
|
||||
const filter = { testActivity: { $exists: false } };
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log("\nshutting down...");
|
||||
appRunning = false;
|
||||
});
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
console.log(
|
||||
`Connecting to database ${process.env["DB_NAME"]} on ${process.env["DB_URI"]}...`,
|
||||
);
|
||||
|
||||
if (!readlineSync.keyInYN("Ready to start migration?")) {
|
||||
appRunning = false;
|
||||
}
|
||||
|
||||
if (appRunning) {
|
||||
await DB.connect();
|
||||
console.log("Connected to database");
|
||||
db = DB.getDb();
|
||||
if (db === undefined) {
|
||||
throw Error("db connection failed");
|
||||
}
|
||||
|
||||
await migrate();
|
||||
}
|
||||
|
||||
console.log(`\nMigration ${appRunning ? "done" : "aborted"}.`);
|
||||
} catch (e) {
|
||||
console.log("error occured:", { e });
|
||||
} finally {
|
||||
await DB.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrate(): Promise<void> {
|
||||
userCollection = DB.collection("users");
|
||||
resultCollection = DB.collection("results");
|
||||
|
||||
console.log("Creating index on users collection...");
|
||||
const t1 = Date.now();
|
||||
await userCollection.createIndex({ uid: 1 }, { unique: true });
|
||||
console.log("Index created in", Date.now() - t1, "ms");
|
||||
await migrateResults();
|
||||
}
|
||||
|
||||
async function migrateResults(): Promise<void> {
|
||||
const allUsersCount = await userCollection.countDocuments(filter);
|
||||
if (allUsersCount === 0) {
|
||||
console.log("No users to migrate.");
|
||||
return;
|
||||
} else {
|
||||
console.log("Users to migrate:", allUsersCount);
|
||||
}
|
||||
|
||||
console.log(`Migrating ~${allUsersCount} users using batchSize=${batchSize}`);
|
||||
|
||||
let count = 0;
|
||||
const start = new Date().valueOf();
|
||||
let uids: string[] = [];
|
||||
do {
|
||||
const t0 = Date.now();
|
||||
console.log("Fetching users to migrate...");
|
||||
const t1 = Date.now();
|
||||
uids = await getUsersToMigrate(batchSize);
|
||||
console.log("Fetched", uids.length, "users in", Date.now() - t1, "ms");
|
||||
console.log("Users to migrate:", uids.join(","));
|
||||
|
||||
//migrate
|
||||
const t2 = Date.now();
|
||||
await migrateUsers(uids);
|
||||
console.log("Migrated", uids.length, "users in", Date.now() - t2, "ms");
|
||||
const t3 = Date.now();
|
||||
await handleUsersWithNoResults(uids);
|
||||
console.log("Handled users with no results in", Date.now() - t3, "ms");
|
||||
|
||||
//progress tracker
|
||||
count += uids.length;
|
||||
updateProgress(allUsersCount, count, start, Date.now() - t0);
|
||||
} while (uids.length > 0 && appRunning);
|
||||
|
||||
if (appRunning) updateProgress(100, 100, start, 0);
|
||||
}
|
||||
|
||||
async function getUsersToMigrate(limit: number): Promise<string[]> {
|
||||
return (
|
||||
await userCollection
|
||||
.find(filter, { limit })
|
||||
.project({ uid: 1, _id: 0 })
|
||||
.toArray()
|
||||
).map((it) => it["uid"]);
|
||||
}
|
||||
|
||||
async function migrateUsers(uids: string[]): Promise<void> {
|
||||
await resultCollection
|
||||
.aggregate(
|
||||
[
|
||||
{
|
||||
$match: {
|
||||
uid: { $in: uids },
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
timestamp: -1,
|
||||
uid: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
date: {
|
||||
$toDate: "$timestamp",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceWith: {
|
||||
uid: "$uid",
|
||||
year: {
|
||||
$year: "$date",
|
||||
},
|
||||
day: {
|
||||
$dayOfYear: "$date",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
uid: "$uid",
|
||||
year: "$year",
|
||||
day: "$day",
|
||||
},
|
||||
count: {
|
||||
$sum: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
uid: "$_id.uid",
|
||||
year: "$_id.year",
|
||||
},
|
||||
days: {
|
||||
$addToSet: {
|
||||
day: "$_id.day",
|
||||
tests: "$count",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceWith: {
|
||||
uid: "$_id.uid",
|
||||
days: {
|
||||
$function: {
|
||||
lang: "js",
|
||||
args: ["$days", "$_id.year"],
|
||||
body: `function (days, year) {
|
||||
var max = Math.max(
|
||||
...days.map((it) => it.day)
|
||||
)-1;
|
||||
var arr = new Array(max).fill(null);
|
||||
for (day of days) {
|
||||
arr[day.day-1] = day.tests;
|
||||
}
|
||||
let result = {};
|
||||
result[year] = arr;
|
||||
return result;
|
||||
}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$uid",
|
||||
testActivity: {
|
||||
$mergeObjects: "$days",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
uid: "$_id",
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
$merge: {
|
||||
into: "users",
|
||||
on: "uid",
|
||||
whenMatched: "merge",
|
||||
whenNotMatched: "discard",
|
||||
},
|
||||
},
|
||||
],
|
||||
{ allowDiskUse: true },
|
||||
)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
async function handleUsersWithNoResults(uids: string[]): Promise<void> {
|
||||
await userCollection.updateMany(
|
||||
{
|
||||
$and: [{ uid: { $in: uids } }, filter],
|
||||
},
|
||||
{ $set: { testActivity: {} } },
|
||||
);
|
||||
}
|
||||
|
||||
function updateProgress(
|
||||
all: number,
|
||||
current: number,
|
||||
start: number,
|
||||
previousBatchSizeTime: number,
|
||||
): void {
|
||||
const percentage = (current / all) * 100;
|
||||
const timeLeft = Math.round(
|
||||
(((new Date().valueOf() - start) / percentage) * (100 - percentage)) / 1000,
|
||||
);
|
||||
|
||||
process.stdout.clearLine?.(0);
|
||||
process.stdout.cursorTo?.(0);
|
||||
process.stdout.write(
|
||||
`Previous batch took ${Math.round(previousBatchSizeTime)}ms (~${
|
||||
previousBatchSizeTime / batchSize
|
||||
}ms per user) ${Math.round(
|
||||
percentage,
|
||||
)}% done, estimated time left ${timeLeft} seconds.`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
24
backend/docker/compose.db-only.yml
Normal file
24
backend/docker/compose.db-only.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
name: monkeytype
|
||||
version: "3.8"
|
||||
services:
|
||||
redis:
|
||||
container_name: monkeytype-redis
|
||||
image: redis:6.2.6
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "${DOCKER_REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
mongodb:
|
||||
container_name: monkeytype-mongodb
|
||||
image: mongo:5.0.13
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "${DOCKER_DB_PORT:-27017}:27017"
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
redis-data:
|
||||
49
backend/docker/compose.yml
Normal file
49
backend/docker/compose.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
name: monkeytype
|
||||
version: "3.8"
|
||||
services:
|
||||
redis:
|
||||
container_name: monkeytype-redis
|
||||
image: redis:6.2.6
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "${DOCKER_REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
mongodb:
|
||||
container_name: monkeytype-mongodb
|
||||
image: mongo:5.0.13
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "${DOCKER_DB_PORT:-27017}:27017"
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
|
||||
api-server:
|
||||
container_name: monkeytype-api-server
|
||||
build:
|
||||
dockerfile_inline: |
|
||||
FROM node:24.11.0
|
||||
RUN npm i -g pnpm@10.28.1
|
||||
RUN mkdir /pnpm-store && chown -R 1000:1000 /pnpm-store
|
||||
user: "node" ##this works as long as your local user has uid=1000
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
- redis
|
||||
- mongodb
|
||||
environment:
|
||||
- DB_URI=mongodb://mongodb:27017
|
||||
- REDIS_URI=redis://redis:6379
|
||||
ports:
|
||||
- "${DOCKER_SERVER_PORT:-5005}:5005"
|
||||
volumes:
|
||||
- ../../:/monkeytype
|
||||
entrypoint: 'bash -c "echo starting, this may take a while... \
|
||||
&& cd /monkeytype \
|
||||
&& pnpm config set store-dir /pnpm-store
|
||||
&& pnpm i \
|
||||
&& npm run dev-be"'
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
redis-data:
|
||||
137
backend/email-templates/reset-password.html
Normal file
137
backend/email-templates/reset-password.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-style>
|
||||
@import
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css";
|
||||
.btn table{ width: 100%; } .btn a{ width: 100%; padding: 10px 0
|
||||
!important;}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#323437">
|
||||
<mj-wrapper padding="20px 20px 200px 20px">
|
||||
<mj-section padding="0px" padding-bottom="20px">
|
||||
<mj-column width="600px">
|
||||
<mj-image
|
||||
width="200px"
|
||||
src="https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/images/mtfulllogo.png?raw=true"
|
||||
href="monkeytype.com"
|
||||
align="left"
|
||||
></mj-image>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding="0px">
|
||||
<mj-column background-color="#2c2e31" border-radius="8px">
|
||||
<mj-spacer></mj-spacer>
|
||||
|
||||
<mj-text color="#d1d0c5" font-size="20px" font-family="sans-serif">
|
||||
Hey, {{name}}
|
||||
</mj-text>
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Nobody likes being locked out of their account. We're coming to your
|
||||
rescue - just click the button below to get started. If you didn't
|
||||
request a password reset, you can safely ignore this email.
|
||||
</mj-text>
|
||||
|
||||
<mj-button
|
||||
align="left"
|
||||
background-color="#e2b714"
|
||||
color="#323437"
|
||||
font-size="16px"
|
||||
line-height="32px"
|
||||
css-class="btn"
|
||||
href="{{passwordResetLink}}"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Reset your password
|
||||
</mj-button>
|
||||
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
padding-bottom="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Cheers,
|
||||
</mj-text>
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
padding-top="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Monkeytype Team
|
||||
</mj-text>
|
||||
|
||||
<mj-divider border-color="#323437"></mj-divider>
|
||||
|
||||
<mj-text
|
||||
color="#646669"
|
||||
font-size="12px"
|
||||
padding-bottom="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Alternatively, you can copy and paste the link below into your
|
||||
browser:
|
||||
</mj-text>
|
||||
<mj-text color="#646669" font-size="12px" font-family="sans-serif">
|
||||
{{passwordResetLink}}
|
||||
</mj-text>
|
||||
|
||||
<mj-spacer></mj-spacer>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-bottom="6px" padding-top="20px">
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://github.com/monkeytypegame/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-github"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://x.com/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-twitter"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://discord.com/invite/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-discord"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-top="0">
|
||||
<mj-column>
|
||||
<mj-text align="center" color="#646669" background-color="#323437">
|
||||
monkeytype.com
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
137
backend/email-templates/verification.html
Normal file
137
backend/email-templates/verification.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-style>
|
||||
@import
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css";
|
||||
.btn table{ width: 100%; } .btn a{ width: 100%; padding: 10px 0
|
||||
!important;}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#323437">
|
||||
<mj-wrapper padding="20px 20px 200px 20px">
|
||||
<mj-section padding="0px" padding-bottom="20px">
|
||||
<mj-column width="600px">
|
||||
<mj-image
|
||||
width="200px"
|
||||
src="https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/images/mtfulllogo.png?raw=true"
|
||||
href="monkeytype.com"
|
||||
align="left"
|
||||
></mj-image>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding="0px">
|
||||
<mj-column background-color="#2c2e31" border-radius="8px">
|
||||
<mj-spacer></mj-spacer>
|
||||
|
||||
<mj-text color="#d1d0c5" font-size="20px" font-family="sans-serif">
|
||||
Hey, {{name}}
|
||||
</mj-text>
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Thanks for joining Monkeytype! We just need one more thing from you
|
||||
- a quick confirmation of your email address and you'll be all set.
|
||||
Click the button below to get started:
|
||||
</mj-text>
|
||||
|
||||
<mj-button
|
||||
align="left"
|
||||
background-color="#e2b714"
|
||||
color="#323437"
|
||||
font-size="16px"
|
||||
line-height="32px"
|
||||
css-class="btn"
|
||||
href="{{verificationLink}}"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Verify
|
||||
</mj-button>
|
||||
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
padding-bottom="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Cheers,
|
||||
</mj-text>
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
padding-top="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Monkeytype Team
|
||||
</mj-text>
|
||||
|
||||
<mj-divider border-color="#323437"></mj-divider>
|
||||
|
||||
<mj-text
|
||||
color="#646669"
|
||||
font-size="12px"
|
||||
padding-bottom="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Alternatively, you can copy and paste the link below into your
|
||||
browser:
|
||||
</mj-text>
|
||||
<mj-text color="#646669" font-size="12px" font-family="sans-serif">
|
||||
{{verificationLink}}
|
||||
</mj-text>
|
||||
|
||||
<mj-spacer></mj-spacer>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-bottom="6px" padding-top="20px">
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://github.com/monkeytypegame/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-github"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://x.com/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-twitter"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://discord.com/invite/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-discord"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-top="0">
|
||||
<mj-column>
|
||||
<mj-text align="center" color="#646669" background-color="#323437">
|
||||
monkeytype.com
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
25
backend/example.env
Normal file
25
backend/example.env
Normal file
@@ -0,0 +1,25 @@
|
||||
DB_NAME=monkeytype
|
||||
DOCKER_SERVER_PORT=5005
|
||||
DOCKER_DB_PORT=27017
|
||||
DB_URI=mongodb://localhost:27017
|
||||
DOCKER_REDIS_PORT=6379
|
||||
REDIS_URI=redis://localhost:6379
|
||||
LOG_FOLDER_PATH=./logs/
|
||||
# Default log file max size is 10 MB
|
||||
LOG_FILE_MAX_SIZE=10485760
|
||||
MODE=dev
|
||||
RECAPTCHA_SECRET=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
|
||||
# You can also use the format mongodb://username:password@host:port or
|
||||
# uncomment the following lines if you want to define them separately
|
||||
# DB_USERNAME=
|
||||
# DB_PASSWORD=
|
||||
# DB_AUTH_MECHANISM="SCRAM-SHA-256"
|
||||
# DB_AUTH_SOURCE=admin
|
||||
|
||||
# You can get a testing email address over at
|
||||
# https://ethereal.email/create
|
||||
#
|
||||
# EMAIL_PORT=587
|
||||
# EMAIL_HOST=smtp.ethereal.email
|
||||
# EMAIL_USER=
|
||||
# EMAIL_PASS=
|
||||
96
backend/package.json
Normal file
96
backend/package.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "@monkeytype/backend",
|
||||
"version": "1.14.3",
|
||||
"private": true,
|
||||
"license": "GPL-3.0",
|
||||
"scripts": {
|
||||
"lint": "oxlint . --type-aware --type-check",
|
||||
"lint-fast": "oxlint .",
|
||||
"build": "npm run gen-docs && tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
"clean": "tsc --build --clean",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"start": "node ./dist/server.js",
|
||||
"test": "vitest run --project=unit",
|
||||
"integration-test": "vitest run --project=integration --project=integration-isolated",
|
||||
"test-coverage": "vitest run --coverage",
|
||||
"dev": "concurrently -p none \"tsx watch --clear-screen=false --inspect ./src/server.ts\" \"tsc --preserveWatchOutput --noEmit --watch\" \"esw src/ -w --ext .ts --cache --color\"",
|
||||
"docker-db-only": "docker compose --env-file .env -f docker/compose.db-only.yml up",
|
||||
"docker": "docker compose --env-file .env -f docker/compose.yml up",
|
||||
"gen-docs": "tsx scripts/openapi.ts dist/static/api/openapi.json && redocly build-docs -o dist/static/api/internal.html internal@v2 && redocly bundle -o dist/static/api/public.json public-filter && redocly build-docs -o dist/static/api/public.html public@v2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@date-fns/utc": "1.2.0",
|
||||
"@monkeytype/contracts": "workspace:*",
|
||||
"@monkeytype/funbox": "workspace:*",
|
||||
"@monkeytype/schemas": "workspace:*",
|
||||
"@monkeytype/util": "workspace:*",
|
||||
"@ts-rest/core": "3.52.1",
|
||||
"@ts-rest/express": "3.52.1",
|
||||
"@ts-rest/open-api": "3.52.1",
|
||||
"bcrypt": "5.1.1",
|
||||
"bullmq": "1.91.1",
|
||||
"chalk": "4.1.2",
|
||||
"cors": "2.8.5",
|
||||
"cron": "2.3.0",
|
||||
"date-fns": "3.6.0",
|
||||
"dotenv": "16.4.5",
|
||||
"etag": "1.8.1",
|
||||
"express": "5.2.0",
|
||||
"express-rate-limit": "7.5.1",
|
||||
"firebase-admin": "12.0.0",
|
||||
"helmet": "4.6.0",
|
||||
"ioredis": "4.28.5",
|
||||
"lru-cache": "7.10.1",
|
||||
"mjml": "4.15.0",
|
||||
"mongodb": "6.3.0",
|
||||
"mustache": "4.2.0",
|
||||
"nodemailer": "8.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
"prom-client": "15.1.3",
|
||||
"rate-limiter-flexible": "5.0.3",
|
||||
"simple-git": "3.32.3",
|
||||
"string-similarity": "4.0.4",
|
||||
"swagger-stats": "0.99.7",
|
||||
"ua-parser-js": "0.7.33",
|
||||
"uuid": "10.0.0",
|
||||
"winston": "3.6.0",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@monkeytype/oxlint-config": "workspace:*",
|
||||
"@monkeytype/typescript-config": "workspace:*",
|
||||
"@redocly/cli": "2.24.1",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cors": "2.8.12",
|
||||
"@types/cron": "1.7.3",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/ioredis": "4.28.10",
|
||||
"@types/mjml": "4.7.4",
|
||||
"@types/mustache": "4.2.2",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/nodemailer": "6.4.15",
|
||||
"@types/object-hash": "3.0.6",
|
||||
"@types/readline-sync": "1.4.8",
|
||||
"@types/string-similarity": "4.0.2",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/swagger-stats": "0.95.11",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"concurrently": "8.2.2",
|
||||
"openapi3-ts": "2.0.2",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"readline-sync": "1.4.10",
|
||||
"supertest": "7.1.4",
|
||||
"testcontainers": "11.11.0",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.0",
|
||||
"yaml": "2.8.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0 <25"
|
||||
}
|
||||
}
|
||||
61
backend/private/index.html
Normal file
61
backend/private/index.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>API Server Configuration</title>
|
||||
<link href="style.css" rel="stylesheet" type="text/css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="header">
|
||||
<div class="header-container">
|
||||
<div id="logo">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
style="isolation: isolate"
|
||||
viewBox="-680 -1030 300 180"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M -430 -910 L -430 -910 C -424.481 -910 -420 -905.519 -420 -900 L -420 -900 C -420 -894.481 -424.481 -890 -430 -890 L -430 -890 C -435.519 -890 -440 -894.481 -440 -900 L -440 -900 C -440 -905.519 -435.519 -910 -430 -910 Z"
|
||||
></path>
|
||||
<path
|
||||
d=" M -570 -910 L -510 -910 C -504.481 -910 -500 -905.519 -500 -900 L -500 -900 C -500 -894.481 -504.481 -890 -510 -890 L -570 -890 C -575.519 -890 -580 -894.481 -580 -900 L -580 -900 C -580 -905.519 -575.519 -910 -570 -910 Z "
|
||||
></path>
|
||||
<path
|
||||
d="M -590 -970 L -590 -970 C -584.481 -970 -580 -965.519 -580 -960 L -580 -940 C -580 -934.481 -584.481 -930 -590 -930 L -590 -930 C -595.519 -930 -600 -934.481 -600 -940 L -600 -960 C -600 -965.519 -595.519 -970 -590 -970 Z"
|
||||
></path>
|
||||
<path
|
||||
d=" M -639.991 -960.515 C -639.72 -976.836 -626.385 -990 -610 -990 L -610 -990 C -602.32 -990 -595.31 -987.108 -590 -982.355 C -584.69 -987.108 -577.68 -990 -570 -990 L -570 -990 C -553.615 -990 -540.28 -976.836 -540.009 -960.515 C -540.001 -960.345 -540 -960.172 -540 -960 L -540 -960 L -540 -940 C -540 -934.481 -544.481 -930 -550 -930 L -550 -930 C -555.519 -930 -560 -934.481 -560 -940 L -560 -960 L -560 -960 C -560 -965.519 -564.481 -970 -570 -970 C -575.519 -970 -580 -965.519 -580 -960 L -580 -960 L -580 -960 L -580 -940 C -580 -934.481 -584.481 -930 -590 -930 L -590 -930 C -595.519 -930 -600 -934.481 -600 -940 L -600 -960 L -600 -960 L -600 -960 L -600 -960 L -600 -960 L -600 -960 L -600 -960 L -600 -960 C -600 -965.519 -604.481 -970 -610 -970 C -615.519 -970 -620 -965.519 -620 -960 L -620 -960 L -620 -940 C -620 -934.481 -624.481 -930 -630 -930 L -630 -930 C -635.519 -930 -640 -934.481 -640 -940 L -640 -960 L -640 -960 C -640 -960.172 -639.996 -960.344 -639.991 -960.515 Z "
|
||||
></path>
|
||||
<path
|
||||
d=" M -460 -930 L -460 -900 C -460 -894.481 -464.481 -890 -470 -890 L -470 -890 C -475.519 -890 -480 -894.481 -480 -900 L -480 -930 L -508.82 -930 C -514.99 -930 -520 -934.481 -520 -940 L -520 -940 C -520 -945.519 -514.99 -950 -508.82 -950 L -431.18 -950 C -425.01 -950 -420 -945.519 -420 -940 L -420 -940 C -420 -934.481 -425.01 -930 -431.18 -930 L -460 -930 Z "
|
||||
></path>
|
||||
<path
|
||||
d="M -470 -990 L -430 -990 C -424.481 -990 -420 -985.519 -420 -980 L -420 -980 C -420 -974.481 -424.481 -970 -430 -970 L -470 -970 C -475.519 -970 -480 -974.481 -480 -980 L -480 -980 C -480 -985.519 -475.519 -990 -470 -990 Z"
|
||||
></path>
|
||||
<path
|
||||
d=" M -630 -910 L -610 -910 C -604.481 -910 -600 -905.519 -600 -900 L -600 -900 C -600 -894.481 -604.481 -890 -610 -890 L -630 -890 C -635.519 -890 -640 -894.481 -640 -900 L -640 -900 C -640 -905.519 -635.519 -910 -630 -910 Z "
|
||||
></path>
|
||||
<path
|
||||
d=" M -515 -990 L -510 -990 C -504.481 -990 -500 -985.519 -500 -980 L -500 -980 C -500 -974.481 -504.481 -970 -510 -970 L -515 -970 C -520.519 -970 -525 -974.481 -525 -980 L -525 -980 C -525 -985.519 -520.519 -990 -515 -990 Z "
|
||||
></path>
|
||||
<path
|
||||
d=" M -660 -910 L -680 -910 L -680 -980 C -680 -1007.596 -657.596 -1030 -630 -1030 L -430 -1030 C -402.404 -1030 -380 -1007.596 -380 -980 L -380 -900 C -380 -872.404 -402.404 -850 -430 -850 L -630 -850 C -657.596 -850 -680 -872.404 -680 -900 L -680 -920 L -660 -920 L -660 -900 C -660 -883.443 -646.557 -870 -630 -870 L -430 -870 C -413.443 -870 -400 -883.443 -400 -900 L -400 -980 C -400 -996.557 -413.443 -1010 -430 -1010 L -630 -1010 C -646.557 -1010 -660 -996.557 -660 -980 L -660 -910 Z "
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>API Server Configuration</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div id="root">
|
||||
<span id="form-loader" class="loader"></span>
|
||||
</div>
|
||||
<div id="save">Save Changes</div>
|
||||
<div id="export">Export Configuration</div>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
305
backend/private/script.js
Normal file
305
backend/private/script.js
Normal file
@@ -0,0 +1,305 @@
|
||||
let state = {};
|
||||
let schema = {};
|
||||
|
||||
const buildLabel = (elementType, text, hintText) => {
|
||||
const labelElement = document.createElement("label");
|
||||
labelElement.innerHTML = text;
|
||||
labelElement.style.fontWeight = elementType === "group" ? "bold" : "lighter";
|
||||
|
||||
if (hintText) {
|
||||
const hintElement = document.createElement("span");
|
||||
hintElement.classList.add("tooltip");
|
||||
hintElement.innerHTML = " ⓘ";
|
||||
|
||||
const hintTextElement = document.createElement("span");
|
||||
hintTextElement.classList.add("tooltip-text");
|
||||
hintTextElement.innerHTML = hintText;
|
||||
|
||||
hintElement.appendChild(hintTextElement);
|
||||
|
||||
labelElement.appendChild(hintElement);
|
||||
}
|
||||
|
||||
return labelElement;
|
||||
};
|
||||
|
||||
const buildNumberInput = (schema, parentState, key) => {
|
||||
const input = document.createElement("input");
|
||||
input.classList.add("base-input");
|
||||
input.type = "number";
|
||||
input.value = parentState[key];
|
||||
|
||||
const min = schema.min ?? 0;
|
||||
input.min = min;
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
const normalizedValue = parseFloat(input.value, 10);
|
||||
parentState[key] = Math.max(normalizedValue, min);
|
||||
});
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
const buildBooleanInput = (parentState, key) => {
|
||||
const input = document.createElement("input");
|
||||
input.classList.add("base-input");
|
||||
input.type = "checkbox";
|
||||
input.checked = parentState[key] ?? false;
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
parentState[key] = input.checked;
|
||||
});
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
const buildStringInput = (parentState, key) => {
|
||||
const input = document.createElement("input");
|
||||
input.classList.add("base-input");
|
||||
input.type = "text";
|
||||
input.value = parentState[key] ?? "";
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
parentState[key] = input.value;
|
||||
});
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
const defaultValueForType = (type) => {
|
||||
switch (type) {
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "string":
|
||||
return "";
|
||||
case "array":
|
||||
return [];
|
||||
case "object":
|
||||
return {};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const arrayFormElementDecorator = (childElement, parentState, index) => {
|
||||
const decoratedElement = document.createElement("div");
|
||||
decoratedElement.classList.add("array-form-element-decorator");
|
||||
|
||||
const removeButton = document.createElement("button");
|
||||
removeButton.innerHTML = "X";
|
||||
removeButton.classList.add("array-input", "array-input-delete", "button");
|
||||
removeButton.addEventListener("click", () => {
|
||||
parentState.splice(index, 1);
|
||||
rerender();
|
||||
});
|
||||
|
||||
decoratedElement.appendChild(childElement);
|
||||
decoratedElement.appendChild(removeButton);
|
||||
|
||||
return decoratedElement;
|
||||
};
|
||||
|
||||
const buildArrayInput = (schema, parentState) => {
|
||||
const itemType = schema.items.type;
|
||||
const inputControlsDiv = document.createElement("div");
|
||||
inputControlsDiv.classList.add("array-input-controls");
|
||||
|
||||
const addButton = document.createElement("button");
|
||||
addButton.innerHTML = "Add One";
|
||||
addButton.classList.add("array-input", "button");
|
||||
addButton.addEventListener("click", () => {
|
||||
parentState.push(defaultValueForType(itemType));
|
||||
rerender();
|
||||
});
|
||||
|
||||
const removeButton = document.createElement("button");
|
||||
removeButton.innerHTML = "Delete All";
|
||||
removeButton.classList.add("array-input", "array-input-delete", "button");
|
||||
removeButton.addEventListener("click", () => {
|
||||
parentState.splice(0, parentState.length);
|
||||
rerender();
|
||||
});
|
||||
|
||||
inputControlsDiv.appendChild(addButton);
|
||||
inputControlsDiv.appendChild(removeButton);
|
||||
|
||||
return inputControlsDiv;
|
||||
};
|
||||
|
||||
const buildUnknownInput = () => {
|
||||
const disclaimer = document.createElement("div");
|
||||
disclaimer.innerHTML = `<i class="unknown-input">This configuration is not yet supported</i>`;
|
||||
|
||||
return disclaimer;
|
||||
};
|
||||
|
||||
const render = (state, schema) => {
|
||||
const build = (
|
||||
schema,
|
||||
state,
|
||||
parentState,
|
||||
currentKey = "",
|
||||
path = "configuration",
|
||||
) => {
|
||||
const parent = document.createElement("div");
|
||||
parent.classList.add("form-element");
|
||||
|
||||
const { type, label, hint, fields, items } = schema;
|
||||
|
||||
if (label) {
|
||||
parent.appendChild(buildLabel(type, label, hint));
|
||||
}
|
||||
|
||||
parent.id = path;
|
||||
|
||||
if (type === "object") {
|
||||
const entries = Object.entries(fields);
|
||||
entries.forEach(([key, value]) => {
|
||||
state[key] ??= defaultValueForType(value.type);
|
||||
|
||||
const childElement = build(
|
||||
value,
|
||||
state[key],
|
||||
state,
|
||||
key,
|
||||
`${path}.${key}`,
|
||||
);
|
||||
parent.appendChild(childElement);
|
||||
});
|
||||
} else if (type === "array") {
|
||||
const arrayInputControls = buildArrayInput(schema, state);
|
||||
parent.appendChild(arrayInputControls);
|
||||
|
||||
if (state && state.length > 0) {
|
||||
state.forEach((element, index) => {
|
||||
const childElement = build(
|
||||
items,
|
||||
element,
|
||||
state,
|
||||
index,
|
||||
`${path}[${index}]`,
|
||||
);
|
||||
|
||||
const decoratedChildElement = arrayFormElementDecorator(
|
||||
childElement,
|
||||
state,
|
||||
index,
|
||||
);
|
||||
parent.appendChild(decoratedChildElement);
|
||||
});
|
||||
}
|
||||
} else if (type === "number") {
|
||||
parent.appendChild(buildNumberInput(schema, parentState, currentKey));
|
||||
parent.classList.add("input-label");
|
||||
} else if (type === "string") {
|
||||
parent.appendChild(buildStringInput(parentState, currentKey));
|
||||
parent.classList.add("input-label");
|
||||
} else if (type === "boolean") {
|
||||
parent.appendChild(buildBooleanInput(parentState, currentKey));
|
||||
parent.classList.add("input-label");
|
||||
} else {
|
||||
parent.appendChild(buildUnknownInput());
|
||||
}
|
||||
|
||||
return parent;
|
||||
};
|
||||
|
||||
return build(schema, state, state);
|
||||
};
|
||||
|
||||
function rerender() {
|
||||
const root = document.querySelector("#root");
|
||||
root.innerHTML = "";
|
||||
root?.append(render(state, schema));
|
||||
}
|
||||
|
||||
window.onload = async () => {
|
||||
const [schemaResponse, dataResponse] = await Promise.all([
|
||||
fetch("/configuration/schema"),
|
||||
fetch("/configuration"),
|
||||
]);
|
||||
|
||||
const [schemaResponseJson, dataResponseJson] = await Promise.all([
|
||||
schemaResponse.json(),
|
||||
dataResponse.json(),
|
||||
]);
|
||||
|
||||
if (schemaResponse.status !== 200 || dataResponse.status !== 200) {
|
||||
const root = document.querySelector("#root");
|
||||
let html = "";
|
||||
if (schemaResponse.status !== 200) {
|
||||
html += `<i class="unknown-input">Error fetching configuration schema: ${schemaResponseJson.message}</i>`;
|
||||
}
|
||||
if (dataResponse.status !== 200) {
|
||||
html += `<i class="unknown-input">Error fetching configuration data: ${dataResponseJson.message}</i>`;
|
||||
}
|
||||
root.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: formSchema } = schemaResponseJson;
|
||||
const { data: initialData } = dataResponseJson;
|
||||
|
||||
state = initialData;
|
||||
schema = formSchema;
|
||||
|
||||
rerender();
|
||||
|
||||
const saveButton = document.querySelector("#save");
|
||||
|
||||
saveButton?.addEventListener("click", async () => {
|
||||
if (saveButton.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveButton.innerHTML = "Saving...";
|
||||
saveButton.disabled = true;
|
||||
const response = await fetch("/configuration", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
configuration: state,
|
||||
}),
|
||||
});
|
||||
if (response.status === 200) {
|
||||
saveButton.innerHTML = "Saved!";
|
||||
saveButton.classList.add("good");
|
||||
} else {
|
||||
saveButton.innerHTML = "Failed!";
|
||||
saveButton.classList.add("bad");
|
||||
}
|
||||
setTimeout(() => {
|
||||
saveButton.innerHTML = "Save Changes";
|
||||
saveButton.classList.remove("good");
|
||||
saveButton.classList.remove("bad");
|
||||
saveButton.disabled = false;
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
const exportButton = document.querySelector("#export");
|
||||
|
||||
exportButton.addEventListener("click", async () => {
|
||||
download(
|
||||
"backend-configuration.json",
|
||||
JSON.stringify({ configuration: state }),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
function download(filename, text) {
|
||||
let element = document.createElement("a");
|
||||
element.setAttribute(
|
||||
"href",
|
||||
"data:text/plain;charset=utf-8," + encodeURIComponent(text),
|
||||
);
|
||||
element.setAttribute("download", filename);
|
||||
|
||||
element.style.display = "none";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
204
backend/private/style.css
Normal file
204
backend/private/style.css
Normal file
@@ -0,0 +1,204 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono&display=swap");
|
||||
|
||||
:root {
|
||||
--roundness: 0.5rem;
|
||||
--bg-color: #323437;
|
||||
--main-color: #e2b714;
|
||||
--caret-color: #e2b714;
|
||||
--sub-color: #646669;
|
||||
--sub-alt-color: #2c2e31;
|
||||
--text-color: #d1d0c5;
|
||||
--error-color: #ca4754;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Roboto Mono", monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
#header {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
padding: 1rem 0;
|
||||
max-width: 60rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#logo {
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
display: grid;
|
||||
width: 3rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
#header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
#logo path {
|
||||
fill: var(--main-color);
|
||||
}
|
||||
|
||||
#root {
|
||||
padding: 2rem;
|
||||
background-color: #fff;
|
||||
max-width: 60rem;
|
||||
margin: 0rem auto;
|
||||
border-radius: var(--roundness);
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: var(--bg-color);
|
||||
color: #fff;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-right: 1rem;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
}
|
||||
|
||||
.array-input {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.array-input-delete {
|
||||
margin-left: 1rem;
|
||||
background-color: #d84b4b;
|
||||
}
|
||||
|
||||
#save,
|
||||
#export {
|
||||
position: fixed;
|
||||
right: 6rem;
|
||||
bottom: 3rem;
|
||||
background-color: var(--sub-alt-color);
|
||||
color: var(--text-color);
|
||||
font-style: bold;
|
||||
border-radius: 3px;
|
||||
padding: 1rem 2rem;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 125px;
|
||||
text-align: center;
|
||||
transition: 0.125s;
|
||||
}
|
||||
|
||||
#export {
|
||||
bottom: 9rem;
|
||||
}
|
||||
|
||||
#save:hover,
|
||||
#export:hover {
|
||||
background-color: var(--text-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
#save.good {
|
||||
background-color: var(--main-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
#save.bad {
|
||||
background-color: var(--error-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.base-input {
|
||||
margin: 0.5rem;
|
||||
border: 1px solid #767676;
|
||||
border-radius: calc(var(--roundness) / 2);
|
||||
background-color: #fff;
|
||||
transition: all 0.2s ease-in-out;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
display: inline-block;
|
||||
accent-color: var(--main-color);
|
||||
color: white;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.form-element {
|
||||
padding-left: 3rem;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-left: #64666955 0.5px solid;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.array-form-element-decorator {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
#root > .form-element:first-child {
|
||||
border-left: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.unknown-input {
|
||||
color: #d84b4b;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: auto;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 5px solid var(--bg-color);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip-text {
|
||||
display: none;
|
||||
color: var(--text-color);
|
||||
background-color: var(--sub-alt-color);
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
padding: 10px;
|
||||
border-radius: var(--roundness);
|
||||
}
|
||||
19
backend/redis-scripts/add-result-increment.lua
Normal file
19
backend/redis-scripts/add-result-increment.lua
Normal file
@@ -0,0 +1,19 @@
|
||||
local redis_call = redis.call
|
||||
local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2]
|
||||
|
||||
local leaderboard_expiration_time = ARGV[1]
|
||||
local user_id = ARGV[2]
|
||||
local xp_gained = tonumber(ARGV[3])
|
||||
local user_data = ARGV[4]
|
||||
|
||||
redis_call('ZINCRBY', leaderboard_scores_key, xp_gained, user_id)
|
||||
redis_call('HSET', leaderboard_results_key, user_id, user_data)
|
||||
|
||||
local number_of_results = redis_call('ZCARD', leaderboard_scores_key)
|
||||
|
||||
if (number_of_results == 1) then
|
||||
redis_call('EXPIREAT', leaderboard_scores_key, leaderboard_expiration_time)
|
||||
redis_call('EXPIREAT', leaderboard_results_key, leaderboard_expiration_time)
|
||||
end
|
||||
|
||||
return redis_call('ZREVRANK', leaderboard_scores_key, user_id)
|
||||
38
backend/redis-scripts/add-result.lua
Normal file
38
backend/redis-scripts/add-result.lua
Normal file
@@ -0,0 +1,38 @@
|
||||
local redis_call = redis.call
|
||||
local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2]
|
||||
|
||||
local max_results = tonumber(ARGV[1])
|
||||
local leaderboard_expiration_time = ARGV[2]
|
||||
local user_id = ARGV[3]
|
||||
local result_score = ARGV[4]
|
||||
local result_data = ARGV[5]
|
||||
|
||||
local number_of_results_changed = redis_call('ZADD', leaderboard_scores_key, 'GT', 'CH', result_score, user_id)
|
||||
|
||||
if (number_of_results_changed == 1) then
|
||||
redis_call('HSET', leaderboard_results_key, user_id, result_data)
|
||||
end
|
||||
|
||||
local number_of_results = redis_call('ZCARD', leaderboard_scores_key)
|
||||
|
||||
local removed_user_id = nil
|
||||
|
||||
if (number_of_results > max_results) then
|
||||
local user_with_lowest_score = redis_call('ZPOPMIN', leaderboard_scores_key)
|
||||
removed_user_id = user_with_lowest_score[1]
|
||||
|
||||
if (removed_user_id ~= nil) then
|
||||
redis_call('HDEL', leaderboard_results_key, removed_user_id)
|
||||
end
|
||||
end
|
||||
|
||||
if (number_of_results == 1) then -- Indicates that this is the first score of the day, set the leaderboard keys to expire at specified time
|
||||
redis_call('EXPIREAT', leaderboard_scores_key, leaderboard_expiration_time)
|
||||
redis_call('EXPIREAT', leaderboard_results_key, leaderboard_expiration_time)
|
||||
end
|
||||
|
||||
if (number_of_results_changed == 1 and removed_user_id ~= user_id) then
|
||||
return redis_call('ZREVRANK', leaderboard_scores_key, user_id)
|
||||
end
|
||||
|
||||
return nil
|
||||
51
backend/redis-scripts/get-rank.lua
Normal file
51
backend/redis-scripts/get-rank.lua
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Helper to split CSV string into a list
|
||||
local function split_csv(csv)
|
||||
local result = {}
|
||||
for user_id in string.gmatch(csv, '([^,]+)') do
|
||||
table.insert(result, user_id)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local redis_call = redis.call
|
||||
local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2]
|
||||
|
||||
local user_id = ARGV[1]
|
||||
local include_scores = ARGV[2]
|
||||
local user_ids_csv = ARGV[3]
|
||||
|
||||
local rank = nil
|
||||
local friendsRank = nil
|
||||
local result = {}
|
||||
local score = ''
|
||||
|
||||
|
||||
-- filtered leaderboard
|
||||
if user_ids_csv ~= "" then
|
||||
|
||||
local filtered_user_ids = split_csv(user_ids_csv)
|
||||
local scored_users = {}
|
||||
for _, user_id in ipairs(filtered_user_ids) do
|
||||
local score = redis_call('ZSCORE', leaderboard_scores_key, user_id)
|
||||
if score then
|
||||
local number_score = tonumber(score)
|
||||
table.insert(scored_users, {user_id = user_id, score = number_score})
|
||||
end
|
||||
end
|
||||
table.sort(scored_users, function(a, b) return a.score > b.score end)
|
||||
|
||||
for i = 1, #scored_users do
|
||||
if scored_users[i].user_id == user_id then
|
||||
friendsRank = i - 1
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
rank = redis_call('ZREVRANK', leaderboard_scores_key, user_id)
|
||||
if (include_scores == "true") then
|
||||
score = redis_call('ZSCORE', leaderboard_scores_key, user_id)
|
||||
end
|
||||
result = redis_call('HGET', leaderboard_results_key, user_id)
|
||||
|
||||
return {rank, score, result, friendsRank}
|
||||
84
backend/redis-scripts/get-results.lua
Normal file
84
backend/redis-scripts/get-results.lua
Normal file
@@ -0,0 +1,84 @@
|
||||
-- Helper to split CSV string into a list
|
||||
local function split_csv(csv)
|
||||
local result = {}
|
||||
for user_id in string.gmatch(csv, '([^,]+)') do
|
||||
table.insert(result, user_id)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local redis_call = redis.call
|
||||
local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2]
|
||||
|
||||
local min_rank = tonumber(ARGV[1])
|
||||
local max_rank = tonumber(ARGV[2])
|
||||
local include_scores = ARGV[3]
|
||||
local user_ids_csv = ARGV[4]
|
||||
|
||||
local results = {}
|
||||
local scores = {}
|
||||
local ranks = {}
|
||||
local count = nil
|
||||
local min_score = {user_id = nil, score = nil}
|
||||
|
||||
|
||||
-- filtered leaderboard
|
||||
if user_ids_csv ~= "" then
|
||||
|
||||
local filtered_user_ids = split_csv(user_ids_csv)
|
||||
local scored_users = {}
|
||||
for _, user_id in ipairs(filtered_user_ids) do
|
||||
local score = redis_call('ZSCORE', leaderboard_scores_key, user_id)
|
||||
if score then
|
||||
local number_score = tonumber(score)
|
||||
table.insert(scored_users, {user_id = user_id, score = number_score})
|
||||
end
|
||||
end
|
||||
table.sort(scored_users, function(a, b) return a.score > b.score end)
|
||||
|
||||
|
||||
if #scored_users > 0 then
|
||||
min_score = {scored_users[#scored_users].user_id, scored_users[#scored_users].score}
|
||||
end
|
||||
count = #scored_users
|
||||
|
||||
for i = min_rank + 1, math.min(max_rank + 1, #scored_users) do
|
||||
local entry = scored_users[i]
|
||||
local user_id = entry.user_id
|
||||
local score = tostring(entry.score)
|
||||
|
||||
local result_data = redis_call('HGET', leaderboard_results_key, user_id)
|
||||
|
||||
if result_data ~= nil then
|
||||
results[#results + 1] = result_data
|
||||
|
||||
local global_rank = redis_call('ZREVRANK', leaderboard_scores_key, user_id)
|
||||
ranks[#ranks + 1] = global_rank or -1 -- -1 if not found
|
||||
end
|
||||
|
||||
if include_scores == "true" then
|
||||
scores[#scores + 1] = score
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
else
|
||||
-- global leaderboard
|
||||
local scores_in_range = redis_call('ZRANGE', leaderboard_scores_key, min_rank, max_rank, 'REV')
|
||||
min_score = redis_call('ZRANGE', leaderboard_scores_key, 0, 0, 'WITHSCORES')
|
||||
count = redis_call('ZCARD', leaderboard_scores_key)
|
||||
|
||||
for _, user_id in ipairs(scores_in_range) do
|
||||
local result_data = redis_call('HGET', leaderboard_results_key, user_id)
|
||||
|
||||
if (include_scores == "true") then
|
||||
scores[#scores + 1] = redis_call('ZSCORE', leaderboard_scores_key, user_id)
|
||||
end
|
||||
|
||||
if (result_data ~= nil) then
|
||||
results[#results + 1] = result_data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {results, scores, count, min_score, ranks}
|
||||
23
backend/redis-scripts/purge-results.lua
Normal file
23
backend/redis-scripts/purge-results.lua
Normal file
@@ -0,0 +1,23 @@
|
||||
local redis_call = redis.call
|
||||
local string_match = string.match
|
||||
|
||||
local user_id = ARGV[1]
|
||||
local leaderboards_namespace = ARGV[2]
|
||||
|
||||
local current_cursor = '0'
|
||||
local match_pattern = leaderboards_namespace .. '*'
|
||||
|
||||
repeat
|
||||
local result = redis_call('SCAN', current_cursor, 'MATCH', match_pattern)
|
||||
local next_cursor, matched_keys = result[1], result[2]
|
||||
|
||||
for _, key in ipairs(matched_keys) do
|
||||
if (string_match(key, 'results')) then
|
||||
redis_call('HDEL', key, user_id)
|
||||
elseif (string_match(key, 'scores')) then
|
||||
redis_call('ZREM', key, user_id)
|
||||
end
|
||||
end
|
||||
|
||||
current_cursor = next_cursor
|
||||
until (current_cursor == '0')
|
||||
46
backend/redocly.yaml
Normal file
46
backend/redocly.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
extends:
|
||||
- recommended
|
||||
|
||||
apis:
|
||||
internal@v2:
|
||||
root: dist/static/api/openapi.json
|
||||
public-filter:
|
||||
root: dist/static/api/openapi.json
|
||||
decorators:
|
||||
filter-in:
|
||||
property: x-public
|
||||
value: yes
|
||||
public@v2:
|
||||
root: dist/static/api/public.json
|
||||
|
||||
openapi:
|
||||
theme:
|
||||
logo:
|
||||
gutter: "2rem"
|
||||
colors:
|
||||
primary:
|
||||
main: "#e2b714"
|
||||
border:
|
||||
dark: "#e2b714"
|
||||
light: "#e2b714"
|
||||
error:
|
||||
main: "#da3333"
|
||||
success:
|
||||
main: "#009400"
|
||||
text:
|
||||
primary: "#646669"
|
||||
secondary: "#d1d0c5"
|
||||
warning:
|
||||
main: "#FF00FF"
|
||||
http:
|
||||
delete: "#da3333"
|
||||
post: "#004D94"
|
||||
patch: "#af8d0f"
|
||||
get: "#009400"
|
||||
sidebar:
|
||||
backgroundColor: "#323437"
|
||||
textColor: "#d1d0c5"
|
||||
activeTextColor: "#e2b714"
|
||||
rightPanel:
|
||||
backgroundColor: "#323437"
|
||||
textColor: "#d1d0c5"
|
||||
309
backend/scripts/openapi.ts
Normal file
309
backend/scripts/openapi.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { generateOpenApi } from "@ts-rest/open-api";
|
||||
import { contract } from "@monkeytype/contracts/index";
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { EndpointMetadata, PermissionId } from "@monkeytype/contracts/util/api";
|
||||
import type { OpenAPIObject, OperationObject } from "openapi3-ts";
|
||||
import {
|
||||
RateLimitIds,
|
||||
getLimits,
|
||||
RateLimiterId,
|
||||
Window,
|
||||
} from "@monkeytype/contracts/rate-limit/index";
|
||||
import { formatDuration } from "date-fns";
|
||||
|
||||
type SecurityRequirementObject = {
|
||||
[name: string]: string[];
|
||||
};
|
||||
|
||||
export function getOpenApi(): OpenAPIObject {
|
||||
const openApiDocument = generateOpenApi(
|
||||
contract,
|
||||
{
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "Monkeytype API",
|
||||
description:
|
||||
"Documentation for the endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.",
|
||||
version: "2.0.0",
|
||||
termsOfService: "https://monkeytype.com/terms-of-service",
|
||||
contact: {
|
||||
name: "Support",
|
||||
email: "support@monkeytype.com",
|
||||
},
|
||||
"x-logo": {
|
||||
url: "https://monkeytype.com/images/mtfulllogo.png",
|
||||
},
|
||||
license: {
|
||||
name: "GPL-3.0",
|
||||
url: "https://www.gnu.org/licenses/gpl-3.0.html",
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "https://api.monkeytype.com",
|
||||
description: "Production server",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
BearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
},
|
||||
ApeKey: {
|
||||
type: "http",
|
||||
scheme: "ApeKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: "users",
|
||||
description: "User account data.",
|
||||
"x-displayName": "Users",
|
||||
"x-public": "yes",
|
||||
},
|
||||
{
|
||||
name: "configs",
|
||||
description:
|
||||
"User specific configs like test settings, theme or tags.",
|
||||
"x-displayName": "User configs",
|
||||
"x-public": "no",
|
||||
},
|
||||
{
|
||||
name: "presets",
|
||||
description: "User specific configuration presets.",
|
||||
"x-displayName": "User presets",
|
||||
"x-public": "no",
|
||||
},
|
||||
{
|
||||
name: "results",
|
||||
description: "User test results",
|
||||
"x-displayName": "Test results",
|
||||
"x-public": "yes",
|
||||
},
|
||||
{
|
||||
name: "ape-keys",
|
||||
description: "Ape keys provide access to certain API endpoints.",
|
||||
"x-displayName": "Ape Keys",
|
||||
"x-public": "no",
|
||||
},
|
||||
{
|
||||
name: "public",
|
||||
description: "Public endpoints such as typing stats.",
|
||||
"x-displayName": "Public",
|
||||
"x-public": "yes",
|
||||
},
|
||||
{
|
||||
name: "leaderboards",
|
||||
description: "All-time and daily leaderboards of the fastest typers.",
|
||||
"x-displayName": "Leaderboards",
|
||||
},
|
||||
{
|
||||
name: "connections",
|
||||
description: "Connections between users.",
|
||||
"x-displayName": "Connections",
|
||||
"x-public": "no",
|
||||
},
|
||||
{
|
||||
name: "psas",
|
||||
description: "Public service announcements.",
|
||||
"x-displayName": "PSAs",
|
||||
"x-public": "yes",
|
||||
},
|
||||
{
|
||||
name: "quotes",
|
||||
description: "Quote ratings and new quote submissions",
|
||||
"x-displayName": "Quotes",
|
||||
"x-public": "yes",
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
description:
|
||||
"Various administrative endpoints. Require user to have admin permissions.",
|
||||
"x-displayName": "Admin",
|
||||
"x-public": "no",
|
||||
},
|
||||
{
|
||||
name: "configuration",
|
||||
description: "Server configuration",
|
||||
"x-displayName": "Server configuration",
|
||||
"x-public": "yes",
|
||||
},
|
||||
{
|
||||
name: "development",
|
||||
description:
|
||||
"Development related endpoints. Only available on dev environment",
|
||||
"x-displayName": "Development",
|
||||
"x-public": "no",
|
||||
},
|
||||
{
|
||||
name: "webhooks",
|
||||
description: "Endpoints for incoming webhooks.",
|
||||
"x-displayName": "Webhooks",
|
||||
"x-public": "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
jsonQuery: true,
|
||||
setOperationId: "concatenated-path",
|
||||
operationMapper: (operation, route) => {
|
||||
const metadata = route.metadata as EndpointMetadata;
|
||||
if (!operation.description?.trim()?.endsWith(".")) {
|
||||
operation.description += ".";
|
||||
}
|
||||
operation.description += "\n\n";
|
||||
|
||||
addAuth(operation, metadata);
|
||||
addRateLimit(operation, metadata);
|
||||
addRequiredConfiguration(operation, metadata);
|
||||
addTags(operation, metadata);
|
||||
return operation;
|
||||
},
|
||||
},
|
||||
);
|
||||
return openApiDocument;
|
||||
}
|
||||
|
||||
function addAuth(
|
||||
operation: OperationObject,
|
||||
metadata: EndpointMetadata | undefined,
|
||||
): void {
|
||||
const auth = metadata?.authenticationOptions ?? {};
|
||||
const permissions = getRequiredPermissions(metadata) ?? [];
|
||||
const security: SecurityRequirementObject[] = [];
|
||||
if (!auth.isPublic && !auth.isPublicOnDev) {
|
||||
security.push({ BearerAuth: permissions });
|
||||
|
||||
if (auth.acceptApeKeys === true) {
|
||||
security.push({ ApeKey: permissions });
|
||||
}
|
||||
}
|
||||
|
||||
const includeInPublic = auth.isPublic === true || auth.acceptApeKeys === true;
|
||||
operation["x-public"] = includeInPublic ? "yes" : "no";
|
||||
operation.security = security;
|
||||
|
||||
if (permissions.length !== 0) {
|
||||
operation.description += `**Required permissions:** ${permissions.join(
|
||||
", ",
|
||||
)}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredPermissions(
|
||||
metadata: EndpointMetadata | undefined,
|
||||
): PermissionId[] | undefined {
|
||||
if (metadata === undefined || metadata.requirePermission === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(metadata.requirePermission)) {
|
||||
return metadata.requirePermission;
|
||||
}
|
||||
return [metadata.requirePermission];
|
||||
}
|
||||
|
||||
function addTags(
|
||||
operation: OperationObject,
|
||||
metadata: EndpointMetadata | undefined,
|
||||
): void {
|
||||
if (metadata === undefined || metadata.openApiTags === undefined) return;
|
||||
operation.tags = Array.isArray(metadata.openApiTags)
|
||||
? metadata.openApiTags
|
||||
: [metadata.openApiTags];
|
||||
}
|
||||
|
||||
function addRateLimit(
|
||||
operation: OperationObject,
|
||||
metadata: EndpointMetadata | undefined,
|
||||
): void {
|
||||
if (metadata === undefined || metadata.rateLimit === undefined) return;
|
||||
// oxlint-disable-next-line no-unsafe-assignment
|
||||
const okResponse = operation.responses["200"];
|
||||
if (okResponse === undefined) return;
|
||||
|
||||
operation.description += getRateLimitDescription(metadata.rateLimit);
|
||||
|
||||
// oxlint-disable-next-line no-unsafe-assignment no-unsafe-member-access
|
||||
okResponse["headers"] = {
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
...okResponse["headers"],
|
||||
"x-ratelimit-limit": {
|
||||
schema: { type: "integer" },
|
||||
description: "The number of allowed requests in the current period",
|
||||
},
|
||||
"x-ratelimit-remaining": {
|
||||
schema: { type: "integer" },
|
||||
description: "The number of remaining requests in the current period",
|
||||
},
|
||||
"x-ratelimit-reset": {
|
||||
schema: { type: "integer" },
|
||||
description: "The timestamp of the start of the next period",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getRateLimitDescription(limit: RateLimiterId | RateLimitIds): string {
|
||||
const limits = getLimits(limit);
|
||||
|
||||
let result = `**Rate limit:** This operation can be called up to ${
|
||||
limits.limiter.max
|
||||
} times ${formatWindow(limits.limiter.window)} for regular users`;
|
||||
|
||||
if (limits.apeKeyLimiter !== undefined) {
|
||||
result += ` and up to ${limits.apeKeyLimiter.max} times ${formatWindow(
|
||||
limits.apeKeyLimiter.window,
|
||||
)} with ApeKeys`;
|
||||
}
|
||||
|
||||
return result + ".\n\n";
|
||||
}
|
||||
|
||||
function formatWindow(window: Window): string {
|
||||
if (typeof window === "number") {
|
||||
const seconds = Math.floor(window / 1000);
|
||||
const duration = formatDuration({
|
||||
hours: Math.floor(seconds / 3600),
|
||||
minutes: Math.floor(seconds / 60) % 60,
|
||||
seconds: seconds % 60,
|
||||
});
|
||||
|
||||
return `every ${duration}`;
|
||||
}
|
||||
return "per " + window;
|
||||
}
|
||||
|
||||
function addRequiredConfiguration(
|
||||
operation: OperationObject,
|
||||
metadata: EndpointMetadata | undefined,
|
||||
): void {
|
||||
if (metadata === undefined || metadata.requireConfiguration === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
//@ts-expect-error somehow path doesnt exist
|
||||
operation.description += `**Required configuration:** This operation can only be called if the [configuration](#tag/configuration/operation/configuration.get) for \`${metadata.requireConfiguration.path}\` is \`true\`.\n\n`;
|
||||
}
|
||||
|
||||
//detect if we run this as a main
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 1) {
|
||||
console.error("Provide filename.");
|
||||
process.exit(1);
|
||||
}
|
||||
const outFile = args[0] as string;
|
||||
|
||||
//create directories if needed
|
||||
const lastSlash = outFile.lastIndexOf("/");
|
||||
if (lastSlash > 1) {
|
||||
const dir = outFile.substring(0, lastSlash);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const openapi = getOpenApi();
|
||||
writeFileSync(args[0] as string, JSON.stringify(openapi, null, 2));
|
||||
}
|
||||
7
backend/scripts/tsconfig.json
Normal file
7
backend/scripts/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@monkeytype/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES6"
|
||||
},
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
31
backend/src/anticheat/index.ts
Normal file
31
backend/src/anticheat/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const hasAnticheatImplemented = process.env["BYPASS_ANTICHEAT"] === "true";
|
||||
|
||||
import { CompletedEvent, KeyStats } from "@monkeytype/schemas/results";
|
||||
import Logger from "../utils/logger";
|
||||
|
||||
export function implemented(): boolean {
|
||||
if (hasAnticheatImplemented) {
|
||||
Logger.warning("BYPASS_ANTICHEAT is enabled! Running without anti-cheat.");
|
||||
}
|
||||
return hasAnticheatImplemented;
|
||||
}
|
||||
|
||||
export function validateResult(
|
||||
_result: object,
|
||||
_version: string,
|
||||
_uaStringifiedObject: string,
|
||||
_lbOptOut: boolean,
|
||||
): boolean {
|
||||
Logger.warning("No anticheat module found, result will not be validated.");
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateKeys(
|
||||
_result: CompletedEvent,
|
||||
_keySpacingStats: KeyStats,
|
||||
_keyDurationStats: KeyStats,
|
||||
_uid: string,
|
||||
): boolean {
|
||||
Logger.warning("No anticheat module found, key data will not be validated.");
|
||||
return true;
|
||||
}
|
||||
148
backend/src/api/controllers/admin.ts
Normal file
148
backend/src/api/controllers/admin.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { buildMonkeyMail } from "../../utils/monkey-mail";
|
||||
import * as UserDAL from "../../dal/user";
|
||||
import * as ReportDAL from "../../dal/report";
|
||||
import GeorgeQueue from "../../queues/george-queue";
|
||||
import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth";
|
||||
import {
|
||||
AcceptReportsRequest,
|
||||
ClearStreakHourOffsetRequest,
|
||||
RejectReportsRequest,
|
||||
SendForgotPasswordEmailRequest,
|
||||
ToggleBanRequest,
|
||||
ToggleBanResponse,
|
||||
} from "@monkeytype/contracts/admin";
|
||||
import MonkeyError, { getErrorMessage } from "../../utils/error";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { addImportantLog } from "../../dal/logs";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function test(_req: MonkeyRequest): Promise<MonkeyResponse> {
|
||||
return new MonkeyResponse("OK", null);
|
||||
}
|
||||
|
||||
export async function toggleBan(
|
||||
req: MonkeyRequest<undefined, ToggleBanRequest>,
|
||||
): Promise<ToggleBanResponse> {
|
||||
const { uid } = req.body;
|
||||
|
||||
const user = await UserDAL.getPartialUser(uid, "toggle ban", [
|
||||
"banned",
|
||||
"discordId",
|
||||
]);
|
||||
const discordId = user.discordId;
|
||||
const discordIdIsValid = discordId !== undefined && discordId !== "";
|
||||
|
||||
await UserDAL.setBanned(uid, !user.banned);
|
||||
if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, !user.banned);
|
||||
|
||||
void addImportantLog("user_ban_toggled", { banned: !user.banned }, uid);
|
||||
|
||||
return new MonkeyResponse(`Ban toggled`, {
|
||||
banned: !user.banned,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearStreakHourOffset(
|
||||
req: MonkeyRequest<undefined, ClearStreakHourOffsetRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.body;
|
||||
|
||||
await UserDAL.clearStreakHourOffset(uid);
|
||||
void addImportantLog("admin_streak_hour_offset_cleared_by", {}, uid);
|
||||
|
||||
return new MonkeyResponse("Streak hour offset cleared", null);
|
||||
}
|
||||
|
||||
export async function acceptReports(
|
||||
req: MonkeyRequest<undefined, AcceptReportsRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
await handleReports(
|
||||
req.body.reports.map((it) => ({ ...it })),
|
||||
true,
|
||||
req.ctx.configuration.users.inbox,
|
||||
);
|
||||
return new MonkeyResponse("Reports removed and users notified.", null);
|
||||
}
|
||||
|
||||
export async function rejectReports(
|
||||
req: MonkeyRequest<undefined, RejectReportsRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
await handleReports(
|
||||
req.body.reports.map((it) => ({ ...it })),
|
||||
false,
|
||||
req.ctx.configuration.users.inbox,
|
||||
);
|
||||
return new MonkeyResponse("Reports removed and users notified.", null);
|
||||
}
|
||||
|
||||
export async function handleReports(
|
||||
reports: { reportId: string; reason?: string }[],
|
||||
accept: boolean,
|
||||
inboxConfig: Configuration["users"]["inbox"],
|
||||
): Promise<void> {
|
||||
const reportIds = reports.map(({ reportId }) => reportId);
|
||||
|
||||
const reportsFromDb = await ReportDAL.getReports(reportIds);
|
||||
const reportById = new Map(reportsFromDb.map((it) => [it.id, it]));
|
||||
|
||||
const existingReportIds = new Set(reportsFromDb.map((report) => report.id));
|
||||
const missingReportIds = reportIds.filter(
|
||||
(reportId) => !existingReportIds.has(reportId),
|
||||
);
|
||||
|
||||
if (missingReportIds.length > 0) {
|
||||
throw new MonkeyError(
|
||||
404,
|
||||
`Reports not found for some IDs ${missingReportIds.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
await ReportDAL.deleteReports(reportIds);
|
||||
|
||||
for (const { reportId, reason } of reports) {
|
||||
try {
|
||||
const report = reportById.get(reportId);
|
||||
if (!report) {
|
||||
throw new MonkeyError(404, `Report not found for ID: ${reportId}`);
|
||||
}
|
||||
|
||||
let mailBody = "";
|
||||
if (accept) {
|
||||
mailBody = `Your report regarding ${report.type} ${
|
||||
report.contentId
|
||||
} (${report.reason.toLowerCase()}) has been approved. Thank you.`;
|
||||
} else {
|
||||
mailBody = `Sorry, but your report regarding ${report.type} ${
|
||||
report.contentId
|
||||
} (${report.reason.toLowerCase()}) has been denied. ${
|
||||
reason !== undefined ? `\nReason: ${reason}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
const mailSubject = accept ? "Report approved" : "Report denied";
|
||||
const mail = buildMonkeyMail({
|
||||
subject: mailSubject,
|
||||
body: mailBody,
|
||||
});
|
||||
await UserDAL.addToInbox(report.uid, [mail], inboxConfig);
|
||||
} catch (e) {
|
||||
if (e instanceof MonkeyError) {
|
||||
throw new MonkeyError(e.status, e.message);
|
||||
} else {
|
||||
throw new MonkeyError(
|
||||
500,
|
||||
"Error handling reports: " + getErrorMessage(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendForgotPasswordEmail(
|
||||
req: MonkeyRequest<undefined, SendForgotPasswordEmailRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { email } = req.body;
|
||||
await authSendForgotPasswordEmail(email);
|
||||
return new MonkeyResponse("Password reset request email sent.", null);
|
||||
}
|
||||
95
backend/src/api/controllers/ape-key.ts
Normal file
95
backend/src/api/controllers/ape-key.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { hash } from "bcrypt";
|
||||
import * as ApeKeysDAL from "../../dal/ape-keys";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { base64UrlEncode, omit } from "../../utils/misc";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
import {
|
||||
AddApeKeyRequest,
|
||||
AddApeKeyResponse,
|
||||
ApeKeyParams,
|
||||
EditApeKeyRequest,
|
||||
GetApeKeyResponse,
|
||||
} from "@monkeytype/contracts/ape-keys";
|
||||
import { ApeKey } from "@monkeytype/schemas/ape-keys";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
function cleanApeKey(apeKey: ApeKeysDAL.DBApeKey): ApeKey {
|
||||
return omit(apeKey, ["hash", "_id", "uid", "useCount"]);
|
||||
}
|
||||
|
||||
export async function getApeKeys(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetApeKeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const apeKeys = await ApeKeysDAL.getApeKeys(uid);
|
||||
const cleanedKeys: Record<string, ApeKey> = Object.fromEntries(
|
||||
apeKeys.map((item) => [item._id.toHexString(), cleanApeKey(item)]),
|
||||
);
|
||||
|
||||
return new MonkeyResponse("ApeKeys retrieved", cleanedKeys);
|
||||
}
|
||||
|
||||
export async function generateApeKey(
|
||||
req: MonkeyRequest<undefined, AddApeKeyRequest>,
|
||||
): Promise<AddApeKeyResponse> {
|
||||
const { name, enabled } = req.body;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { maxKeysPerUser, apeKeyBytes, apeKeySaltRounds } =
|
||||
req.ctx.configuration.apeKeys;
|
||||
|
||||
const currentNumberOfApeKeys = await ApeKeysDAL.countApeKeysForUser(uid);
|
||||
|
||||
if (currentNumberOfApeKeys >= maxKeysPerUser) {
|
||||
throw new MonkeyError(409, "Maximum number of ApeKeys have been generated");
|
||||
}
|
||||
|
||||
const apiKey = randomBytes(apeKeyBytes).toString("base64url");
|
||||
const saltyHash = await hash(apiKey, apeKeySaltRounds);
|
||||
|
||||
const apeKey: ApeKeysDAL.DBApeKey = {
|
||||
_id: new ObjectId(),
|
||||
name,
|
||||
enabled,
|
||||
uid,
|
||||
hash: saltyHash,
|
||||
createdOn: Date.now(),
|
||||
modifiedOn: Date.now(),
|
||||
lastUsedOn: -1,
|
||||
useCount: 0,
|
||||
};
|
||||
|
||||
const apeKeyId = await ApeKeysDAL.addApeKey(apeKey);
|
||||
|
||||
return new MonkeyResponse("ApeKey generated", {
|
||||
apeKey: base64UrlEncode(`${apeKeyId}.${apiKey}`),
|
||||
apeKeyId,
|
||||
apeKeyDetails: cleanApeKey(apeKey),
|
||||
});
|
||||
}
|
||||
|
||||
export async function editApeKey(
|
||||
req: MonkeyRequest<undefined, EditApeKeyRequest, ApeKeyParams>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { apeKeyId } = req.params;
|
||||
const { name, enabled } = req.body;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ApeKeysDAL.editApeKey(uid, apeKeyId, name, enabled);
|
||||
|
||||
return new MonkeyResponse("ApeKey updated", null);
|
||||
}
|
||||
|
||||
export async function deleteApeKey(
|
||||
req: MonkeyRequest<undefined, undefined, ApeKeyParams>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { apeKeyId } = req.params;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ApeKeysDAL.deleteApeKey(uid, apeKeyId);
|
||||
|
||||
return new MonkeyResponse("ApeKey deleted", null);
|
||||
}
|
||||
34
backend/src/api/controllers/config.ts
Normal file
34
backend/src/api/controllers/config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { PartialConfig } from "@monkeytype/schemas/configs";
|
||||
import * as ConfigDAL from "../../dal/config";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { GetConfigResponse } from "@monkeytype/contracts/configs";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function getConfig(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetConfigResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const data = (await ConfigDAL.getConfig(uid))?.config ?? null;
|
||||
|
||||
return new MonkeyResponse("Configuration retrieved", data);
|
||||
}
|
||||
|
||||
export async function saveConfig(
|
||||
req: MonkeyRequest<undefined, PartialConfig>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const config = req.body;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ConfigDAL.saveConfig(uid, config);
|
||||
|
||||
return new MonkeyResponse("Config updated", null);
|
||||
}
|
||||
|
||||
export async function deleteConfig(
|
||||
req: MonkeyRequest,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ConfigDAL.deleteConfig(uid);
|
||||
return new MonkeyResponse("Config deleted", null);
|
||||
}
|
||||
39
backend/src/api/controllers/configuration.ts
Normal file
39
backend/src/api/controllers/configuration.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as Configuration from "../../init/configuration";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { CONFIGURATION_FORM_SCHEMA } from "../../constants/base-configuration";
|
||||
import {
|
||||
ConfigurationSchemaResponse,
|
||||
GetConfigurationResponse,
|
||||
PatchConfigurationRequest,
|
||||
} from "@monkeytype/contracts/configuration";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function getConfiguration(
|
||||
_req: MonkeyRequest,
|
||||
): Promise<GetConfigurationResponse> {
|
||||
const currentConfiguration = await Configuration.getCachedConfiguration(true);
|
||||
return new MonkeyResponse("Configuration retrieved", currentConfiguration);
|
||||
}
|
||||
|
||||
export async function getSchema(
|
||||
_req: MonkeyRequest,
|
||||
): Promise<ConfigurationSchemaResponse> {
|
||||
return new MonkeyResponse(
|
||||
"Configuration schema retrieved",
|
||||
CONFIGURATION_FORM_SCHEMA,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateConfiguration(
|
||||
req: MonkeyRequest<undefined, PatchConfigurationRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { configuration } = req.body;
|
||||
const success = await Configuration.patchConfiguration(configuration);
|
||||
|
||||
if (!success) {
|
||||
throw new MonkeyError(500, "Configuration update failed");
|
||||
}
|
||||
|
||||
return new MonkeyResponse("Configuration updated", null);
|
||||
}
|
||||
85
backend/src/api/controllers/connections.ts
Normal file
85
backend/src/api/controllers/connections.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
CreateConnectionRequest,
|
||||
CreateConnectionResponse,
|
||||
GetConnectionsQuery,
|
||||
GetConnectionsResponse,
|
||||
IdPathParams,
|
||||
UpdateConnectionRequest,
|
||||
} from "@monkeytype/contracts/connections";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import * as ConnectionsDal from "../../dal/connections";
|
||||
import * as UserDal from "../../dal/user";
|
||||
import { replaceObjectId, omit } from "../../utils/misc";
|
||||
import MonkeyError from "../../utils/error";
|
||||
|
||||
import { Connection } from "@monkeytype/schemas/connections";
|
||||
|
||||
function convert(db: ConnectionsDal.DBConnection): Connection {
|
||||
return replaceObjectId(omit(db, ["key"]));
|
||||
}
|
||||
export async function getConnections(
|
||||
req: MonkeyRequest<GetConnectionsQuery>,
|
||||
): Promise<GetConnectionsResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { status, type } = req.query;
|
||||
|
||||
const results = await ConnectionsDal.getConnections({
|
||||
initiatorUid:
|
||||
type === undefined || type.includes("outgoing") ? uid : undefined,
|
||||
receiverUid:
|
||||
type === undefined || type?.includes("incoming") ? uid : undefined,
|
||||
status: status,
|
||||
});
|
||||
|
||||
return new MonkeyResponse("Connections retrieved", results.map(convert));
|
||||
}
|
||||
|
||||
export async function createConnection(
|
||||
req: MonkeyRequest<undefined, CreateConnectionRequest>,
|
||||
): Promise<CreateConnectionResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { receiverName } = req.body;
|
||||
const { maxPerUser } = req.ctx.configuration.connections;
|
||||
|
||||
const receiver = await UserDal.getUserByName(
|
||||
receiverName,
|
||||
"create connection",
|
||||
);
|
||||
|
||||
if (uid === receiver.uid) {
|
||||
throw new MonkeyError(400, "You cannot be your own friend, sorry.");
|
||||
}
|
||||
|
||||
const initiator = await UserDal.getPartialUser(uid, "create connection", [
|
||||
"uid",
|
||||
"name",
|
||||
]);
|
||||
|
||||
const result = await ConnectionsDal.create(initiator, receiver, maxPerUser);
|
||||
|
||||
return new MonkeyResponse("Connection created", convert(result));
|
||||
}
|
||||
|
||||
export async function deleteConnection(
|
||||
req: MonkeyRequest<undefined, undefined, IdPathParams>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { id } = req.params;
|
||||
|
||||
await ConnectionsDal.deleteById(uid, id);
|
||||
|
||||
return new MonkeyResponse("Connection deleted", null);
|
||||
}
|
||||
|
||||
export async function updateConnection(
|
||||
req: MonkeyRequest<undefined, UpdateConnectionRequest, IdPathParams>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
await ConnectionsDal.updateStatus(uid, id, status);
|
||||
|
||||
return new MonkeyResponse("Connection updated", null);
|
||||
}
|
||||
433
backend/src/api/controllers/dev.ts
Normal file
433
backend/src/api/controllers/dev.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import * as UserDal from "../../dal/user";
|
||||
import FirebaseAdmin from "../../init/firebase-admin";
|
||||
import Logger from "../../utils/logger";
|
||||
import * as DateUtils from "date-fns";
|
||||
import { UTCDate } from "@date-fns/utc";
|
||||
import * as ResultDal from "../../dal/result";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as LeaderboardDal from "../../dal/leaderboards";
|
||||
import MonkeyError from "../../utils/error";
|
||||
|
||||
import { Mode, PersonalBest, PersonalBests } from "@monkeytype/schemas/shared";
|
||||
import {
|
||||
AddDebugInboxItemRequest,
|
||||
GenerateDataRequest,
|
||||
GenerateDataResponse,
|
||||
} from "@monkeytype/contracts/dev";
|
||||
import { buildMonkeyMail } from "../../utils/monkey-mail";
|
||||
import { roundTo2 } from "@monkeytype/util/numbers";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { DBResult } from "../../utils/result";
|
||||
import { LbPersonalBests } from "../../utils/pb";
|
||||
import { Language } from "@monkeytype/schemas/languages";
|
||||
|
||||
const CREATE_RESULT_DEFAULT_OPTIONS = {
|
||||
firstTestTimestamp: DateUtils.startOfDay(new UTCDate(Date.now())).valueOf(),
|
||||
lastTestTimestamp: DateUtils.endOfDay(new UTCDate(Date.now())).valueOf(),
|
||||
minTestsPerDay: 0,
|
||||
maxTestsPerDay: 50,
|
||||
};
|
||||
|
||||
export async function createTestData(
|
||||
req: MonkeyRequest<undefined, GenerateDataRequest>,
|
||||
): Promise<GenerateDataResponse> {
|
||||
const { username, createUser } = req.body;
|
||||
const user = await getOrCreateUser(username, "password", createUser);
|
||||
|
||||
const { uid, email } = user;
|
||||
|
||||
await createTestResults(user, req.body);
|
||||
await updateUser(uid);
|
||||
await updateLeaderboard();
|
||||
|
||||
return new MonkeyResponse("test data created", { uid, email });
|
||||
}
|
||||
|
||||
export async function addDebugInboxItem(
|
||||
req: MonkeyRequest<undefined, AddDebugInboxItemRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { rewardType } = req.body;
|
||||
const inboxConfig = req.ctx.configuration.users.inbox;
|
||||
|
||||
const rewards =
|
||||
rewardType === "xp"
|
||||
? [{ type: "xp" as const, item: 1000 }]
|
||||
: rewardType === "badge"
|
||||
? [{ type: "badge" as const, item: { id: 1 } }]
|
||||
: [];
|
||||
|
||||
const body =
|
||||
rewardType === "xp"
|
||||
? "Here is your 1000 XP reward for debugging."
|
||||
: rewardType === "badge"
|
||||
? "Here is your Developer badge reward."
|
||||
: "A debug inbox item with no reward.";
|
||||
|
||||
const mail = buildMonkeyMail({
|
||||
subject: "Debug Inbox Item",
|
||||
body,
|
||||
rewards,
|
||||
});
|
||||
|
||||
await UserDal.addToInbox(uid, [mail], inboxConfig);
|
||||
return new MonkeyResponse("Debug inbox item added", null);
|
||||
}
|
||||
|
||||
async function getOrCreateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
createUser = false,
|
||||
): Promise<UserDal.DBUser> {
|
||||
const existingUser = await UserDal.findByName(username);
|
||||
|
||||
if (existingUser !== undefined && existingUser !== null) {
|
||||
return existingUser;
|
||||
} else if (!createUser) {
|
||||
throw new MonkeyError(404, `User ${username} does not exist.`);
|
||||
}
|
||||
|
||||
const email = username + "@example.com";
|
||||
Logger.success("create user " + username);
|
||||
const { uid } = await FirebaseAdmin().auth().createUser({
|
||||
displayName: username,
|
||||
password: password,
|
||||
email,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
await UserDal.addUser(username, email, uid);
|
||||
return UserDal.getUser(uid, "getOrCreateUser");
|
||||
}
|
||||
|
||||
async function createTestResults(
|
||||
user: UserDal.DBUser,
|
||||
configOptions: GenerateDataRequest,
|
||||
): Promise<void> {
|
||||
const config = {
|
||||
...CREATE_RESULT_DEFAULT_OPTIONS,
|
||||
...configOptions,
|
||||
};
|
||||
const start = toDate(config.firstTestTimestamp);
|
||||
const end = toDate(config.lastTestTimestamp);
|
||||
|
||||
const days = DateUtils.eachDayOfInterval({
|
||||
start,
|
||||
end,
|
||||
}).map((day) => ({
|
||||
timestamp: DateUtils.startOfDay(day),
|
||||
amount: Math.round(random(config.minTestsPerDay, config.maxTestsPerDay)),
|
||||
}));
|
||||
|
||||
for (const day of days) {
|
||||
Logger.success(
|
||||
`User ${user.name} insert ${day.amount} results on ${new Date(
|
||||
day.timestamp,
|
||||
)}`,
|
||||
);
|
||||
const results = createArray(day.amount, () =>
|
||||
createResult(user, day.timestamp),
|
||||
);
|
||||
if (results.length > 0) {
|
||||
await ResultDal.getResultCollection().insertMany(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toDate(value: number): Date {
|
||||
return new UTCDate(value);
|
||||
}
|
||||
|
||||
function random(min: number, max: number): number {
|
||||
return roundTo2(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
function createResult(
|
||||
user: UserDal.DBUser,
|
||||
timestamp: Date, //evil, we modify this value
|
||||
): DBResult {
|
||||
const mode: Mode = randomValue(["time", "words"]);
|
||||
const mode2: number =
|
||||
mode === "time"
|
||||
? randomValue([15, 30, 60, 120])
|
||||
: randomValue([10, 25, 50, 100]);
|
||||
const testDuration = mode2;
|
||||
|
||||
timestamp = DateUtils.addSeconds(timestamp, testDuration);
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
uid: user.uid,
|
||||
wpm: random(80, 120),
|
||||
rawWpm: random(80, 120),
|
||||
charStats: [131, 0, 0, 0],
|
||||
acc: random(80, 100),
|
||||
language: "english",
|
||||
mode: mode as Mode,
|
||||
mode2: mode2 as unknown as never,
|
||||
timestamp: timestamp.valueOf(),
|
||||
testDuration: testDuration,
|
||||
consistency: random(80, 100),
|
||||
keyConsistency: 33.18,
|
||||
chartData: {
|
||||
wpm: createArray(testDuration, () => random(80, 120)),
|
||||
burst: createArray(testDuration, () => random(80, 120)),
|
||||
err: createArray(testDuration, () => (Math.random() < 0.1 ? 1 : 0)),
|
||||
},
|
||||
keySpacingStats: {
|
||||
average: 113.88,
|
||||
sd: 77.3,
|
||||
},
|
||||
keyDurationStats: {
|
||||
average: 107.13,
|
||||
sd: 39.86,
|
||||
},
|
||||
isPb: Math.random() < 0.1,
|
||||
name: user.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateUser(uid: string): Promise<void> {
|
||||
//update timetyping and completedTests
|
||||
const stats = await ResultDal.getResultCollection()
|
||||
.aggregate([
|
||||
{
|
||||
$match: {
|
||||
uid,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
language: "$language",
|
||||
mode: "$mode",
|
||||
mode2: "$mode2",
|
||||
},
|
||||
timeTyping: {
|
||||
$sum: "$testDuration",
|
||||
},
|
||||
completedTests: {
|
||||
$count: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
.toArray();
|
||||
|
||||
const timeTyping = stats.reduce((a, c) => (a + c["timeTyping"]) as number, 0);
|
||||
const completedTests = stats.reduce(
|
||||
(a, c) => (a + c["completedTests"]) as number,
|
||||
0,
|
||||
);
|
||||
|
||||
//update PBs
|
||||
const lbPersonalBests: LbPersonalBests = {
|
||||
time: {
|
||||
15: {},
|
||||
60: {},
|
||||
},
|
||||
};
|
||||
|
||||
const personalBests: PersonalBests = {
|
||||
time: {},
|
||||
custom: {},
|
||||
words: {},
|
||||
zen: {},
|
||||
quote: {},
|
||||
};
|
||||
const modes = stats.map(
|
||||
(it) =>
|
||||
it["_id"] as {
|
||||
language: Language;
|
||||
mode: "time" | "custom" | "words" | "quote" | "zen";
|
||||
mode2: `${number}` | "custom" | "zen";
|
||||
},
|
||||
);
|
||||
|
||||
for (const mode of modes) {
|
||||
const best = (await ResultDal.getResultCollection().findOne(
|
||||
{
|
||||
uid,
|
||||
language: mode.language,
|
||||
mode: mode.mode,
|
||||
mode2: mode.mode2,
|
||||
},
|
||||
{ sort: { wpm: -1, timestamp: 1 } },
|
||||
)) as DBResult;
|
||||
|
||||
personalBests[mode.mode] ??= {};
|
||||
if (personalBests[mode.mode][mode.mode2] === undefined) {
|
||||
personalBests[mode.mode][mode.mode2] = [];
|
||||
}
|
||||
|
||||
const entry = {
|
||||
acc: best.acc,
|
||||
consistency: best.consistency,
|
||||
difficulty: best.difficulty ?? "normal",
|
||||
lazyMode: best.lazyMode,
|
||||
language: mode.language,
|
||||
punctuation: best.punctuation,
|
||||
raw: best.rawWpm,
|
||||
wpm: best.wpm,
|
||||
numbers: best.numbers,
|
||||
timestamp: best.timestamp,
|
||||
} as PersonalBest;
|
||||
|
||||
(personalBests[mode.mode][mode.mode2] as PersonalBest[]).push(entry);
|
||||
|
||||
if (mode.mode === "time") {
|
||||
if (lbPersonalBests[mode.mode][mode.mode2] === undefined) {
|
||||
lbPersonalBests[mode.mode][mode.mode2] = {};
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
lbPersonalBests[mode.mode][mode.mode2][mode.language] = entry;
|
||||
}
|
||||
|
||||
//update testActivity
|
||||
await updateTestActicity(uid);
|
||||
}
|
||||
|
||||
//update the user
|
||||
await UserDal.getUsersCollection().updateOne(
|
||||
{ uid },
|
||||
{
|
||||
$set: {
|
||||
timeTyping: timeTyping,
|
||||
completedTests: completedTests,
|
||||
startedTests: Math.round(completedTests * 1.25),
|
||||
personalBests: personalBests,
|
||||
lbPersonalBests: lbPersonalBests,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function updateLeaderboard(): Promise<void> {
|
||||
await LeaderboardDal.update("time", "15", "english");
|
||||
await LeaderboardDal.update("time", "60", "english");
|
||||
}
|
||||
|
||||
function randomValue<T>(values: T[]): T {
|
||||
const rnd = Math.round(Math.random() * (values.length - 1));
|
||||
return values[rnd] as T;
|
||||
}
|
||||
|
||||
function createArray<T>(size: number, builder: () => T): T[] {
|
||||
return new Array(size).fill(0).map(() => builder());
|
||||
}
|
||||
|
||||
async function updateTestActicity(uid: string): Promise<void> {
|
||||
await ResultDal.getResultCollection()
|
||||
.aggregate(
|
||||
[
|
||||
{
|
||||
$match: {
|
||||
uid,
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
timestamp: -1,
|
||||
uid: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
date: {
|
||||
$toDate: "$timestamp",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceWith: {
|
||||
uid: "$uid",
|
||||
year: {
|
||||
$year: "$date",
|
||||
},
|
||||
day: {
|
||||
$dayOfYear: "$date",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
uid: "$uid",
|
||||
year: "$year",
|
||||
day: "$day",
|
||||
},
|
||||
count: {
|
||||
$sum: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
uid: "$_id.uid",
|
||||
year: "$_id.year",
|
||||
},
|
||||
days: {
|
||||
$addToSet: {
|
||||
day: "$_id.day",
|
||||
tests: "$count",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceWith: {
|
||||
uid: "$_id.uid",
|
||||
days: {
|
||||
$function: {
|
||||
lang: "js",
|
||||
args: ["$days", "$_id.year"],
|
||||
body: `function (days, year) {
|
||||
var max = Math.max(
|
||||
...days.map((it) => it.day)
|
||||
)-1;
|
||||
var arr = new Array(max).fill(null);
|
||||
for (day of days) {
|
||||
arr[day.day-1] = day.tests;
|
||||
}
|
||||
let result = {};
|
||||
result[year] = arr;
|
||||
return result;
|
||||
}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$uid",
|
||||
testActivity: {
|
||||
$mergeObjects: "$days",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
uid: "$_id",
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
$merge: {
|
||||
into: "users",
|
||||
on: "uid",
|
||||
whenMatched: "merge",
|
||||
whenNotMatched: "discard",
|
||||
},
|
||||
},
|
||||
],
|
||||
{ allowDiskUse: true },
|
||||
)
|
||||
.toArray();
|
||||
}
|
||||
294
backend/src/api/controllers/leaderboard.ts
Normal file
294
backend/src/api/controllers/leaderboard.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import * as LeaderboardsDAL from "../../dal/leaderboards";
|
||||
import * as ConnectionsDal from "../../dal/connections";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import * as DailyLeaderboards from "../../utils/daily-leaderboards";
|
||||
import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
|
||||
import {
|
||||
DailyLeaderboardQuery,
|
||||
GetDailyLeaderboardQuery,
|
||||
GetDailyLeaderboardRankQuery,
|
||||
GetDailyLeaderboardResponse,
|
||||
GetLeaderboardDailyRankResponse,
|
||||
GetLeaderboardQuery,
|
||||
GetLeaderboardRankQuery,
|
||||
GetLeaderboardRankResponse,
|
||||
GetLeaderboardResponse,
|
||||
GetWeeklyXpLeaderboardQuery,
|
||||
GetWeeklyXpLeaderboardRankQuery,
|
||||
GetWeeklyXpLeaderboardRankResponse,
|
||||
GetWeeklyXpLeaderboardResponse,
|
||||
} from "@monkeytype/contracts/leaderboards";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import {
|
||||
getCurrentDayTimestamp,
|
||||
getCurrentWeekTimestamp,
|
||||
MILLISECONDS_IN_DAY,
|
||||
} from "@monkeytype/util/date-and-time";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { omit } from "../../utils/misc";
|
||||
|
||||
export async function getLeaderboard(
|
||||
req: MonkeyRequest<GetLeaderboardQuery>,
|
||||
): Promise<GetLeaderboardResponse> {
|
||||
const { language, mode, mode2, page, pageSize, friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
if (
|
||||
mode !== "time" ||
|
||||
(mode2 !== "15" && mode2 !== "60") ||
|
||||
language !== "english"
|
||||
) {
|
||||
throw new MonkeyError(404, "There is no leaderboard for this mode");
|
||||
}
|
||||
|
||||
const friendsOnlyUid = getFriendsOnlyUid(uid, friendsOnly, connectionsConfig);
|
||||
|
||||
const leaderboard = await LeaderboardsDAL.get(
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
page,
|
||||
pageSize,
|
||||
req.ctx.configuration.users.premium.enabled,
|
||||
friendsOnlyUid,
|
||||
);
|
||||
|
||||
if (leaderboard === false) {
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Leaderboard is currently updating. Please try again in a few seconds.",
|
||||
);
|
||||
}
|
||||
|
||||
const count = await LeaderboardsDAL.getCount(
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
friendsOnlyUid,
|
||||
);
|
||||
const normalizedLeaderboard = leaderboard.map((it) => omit(it, ["_id"]));
|
||||
|
||||
return new MonkeyResponse("Leaderboard retrieved", {
|
||||
count,
|
||||
entries: normalizedLeaderboard,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRankFromLeaderboard(
|
||||
req: MonkeyRequest<GetLeaderboardRankQuery>,
|
||||
): Promise<GetLeaderboardRankResponse> {
|
||||
const { language, mode, mode2, friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const data = await LeaderboardsDAL.getRank(
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
uid,
|
||||
getFriendsOnlyUid(uid, friendsOnly, connectionsConfig) !== undefined,
|
||||
);
|
||||
if (data === false) {
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Leaderboard is currently updating. Please try again in a few seconds.",
|
||||
);
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return new MonkeyResponse("Rank retrieved", null);
|
||||
}
|
||||
|
||||
return new MonkeyResponse("Rank retrieved", omit(data, ["_id"]));
|
||||
}
|
||||
|
||||
function getDailyLeaderboardWithError(
|
||||
{ language, mode, mode2, daysBefore }: DailyLeaderboardQuery,
|
||||
config: Configuration["dailyLeaderboards"],
|
||||
): DailyLeaderboards.DailyLeaderboard {
|
||||
const customTimestamp =
|
||||
daysBefore === undefined
|
||||
? -1
|
||||
: getCurrentDayTimestamp() - daysBefore * MILLISECONDS_IN_DAY;
|
||||
|
||||
const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard(
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
config,
|
||||
customTimestamp,
|
||||
);
|
||||
if (!dailyLeaderboard) {
|
||||
throw new MonkeyError(404, "There is no daily leaderboard for this mode");
|
||||
}
|
||||
|
||||
return dailyLeaderboard;
|
||||
}
|
||||
|
||||
export async function getDailyLeaderboard(
|
||||
req: MonkeyRequest<GetDailyLeaderboardQuery>,
|
||||
): Promise<GetDailyLeaderboardResponse> {
|
||||
const { page, pageSize, friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig,
|
||||
);
|
||||
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
);
|
||||
|
||||
const results = await dailyLeaderboard.getResults(
|
||||
page,
|
||||
pageSize,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
req.ctx.configuration.users.premium.enabled,
|
||||
friendUids,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard retrieved", {
|
||||
entries: results?.entries ?? [],
|
||||
count: results?.count ?? 0,
|
||||
minWpm: results?.minWpm ?? 0,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDailyLeaderboardRank(
|
||||
req: MonkeyRequest<GetDailyLeaderboardRankQuery>,
|
||||
): Promise<GetLeaderboardDailyRankResponse> {
|
||||
const { friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig,
|
||||
);
|
||||
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
);
|
||||
|
||||
const rank = await dailyLeaderboard.getRank(
|
||||
uid,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
friendUids,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard rank retrieved", rank);
|
||||
}
|
||||
|
||||
function getWeeklyXpLeaderboardWithError(
|
||||
config: Configuration["leaderboards"]["weeklyXp"],
|
||||
weeksBefore?: number,
|
||||
): WeeklyXpLeaderboard.WeeklyXpLeaderboard {
|
||||
const customTimestamp =
|
||||
weeksBefore === undefined
|
||||
? -1
|
||||
: getCurrentWeekTimestamp() - weeksBefore * MILLISECONDS_IN_DAY * 7;
|
||||
|
||||
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(config, customTimestamp);
|
||||
if (!weeklyXpLeaderboard) {
|
||||
throw new MonkeyError(404, "XP leaderboard for this week not found.");
|
||||
}
|
||||
|
||||
return weeklyXpLeaderboard;
|
||||
}
|
||||
|
||||
export async function getWeeklyXpLeaderboard(
|
||||
req: MonkeyRequest<GetWeeklyXpLeaderboardQuery>,
|
||||
): Promise<GetWeeklyXpLeaderboardResponse> {
|
||||
const { page, pageSize, weeksBefore, friendsOnly } = req.query;
|
||||
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig,
|
||||
);
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
weeksBefore,
|
||||
);
|
||||
const results = await weeklyXpLeaderboard.getResults(
|
||||
page,
|
||||
pageSize,
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
req.ctx.configuration.users.premium.enabled,
|
||||
friendUids,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard retrieved", {
|
||||
entries: results?.entries ?? [],
|
||||
count: results?.count ?? 0,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWeeklyXpLeaderboardRank(
|
||||
req: MonkeyRequest<GetWeeklyXpLeaderboardRankQuery>,
|
||||
): Promise<GetWeeklyXpLeaderboardRankResponse> {
|
||||
const { friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig,
|
||||
);
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
req.query.weeksBefore,
|
||||
);
|
||||
const rankEntry = await weeklyXpLeaderboard.getRank(
|
||||
uid,
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
friendUids,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry);
|
||||
}
|
||||
|
||||
async function getFriendsUids(
|
||||
uid: string,
|
||||
friendsOnly: boolean,
|
||||
friendsConfig: Configuration["connections"],
|
||||
): Promise<string[] | undefined> {
|
||||
if (uid !== "" && friendsOnly) {
|
||||
if (!friendsConfig.enabled) {
|
||||
throw new MonkeyError(503, "This feature is currently unavailable.");
|
||||
}
|
||||
return await ConnectionsDal.getFriendsUids(uid);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFriendsOnlyUid(
|
||||
uid: string,
|
||||
friendsOnly: boolean | undefined,
|
||||
friendsConfig: Configuration["connections"],
|
||||
): string | undefined {
|
||||
if (uid !== "" && friendsOnly === true) {
|
||||
if (!friendsConfig.enabled) {
|
||||
throw new MonkeyError(503, "This feature is currently unavailable.");
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
57
backend/src/api/controllers/preset.ts
Normal file
57
backend/src/api/controllers/preset.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
AddPresetRequest,
|
||||
AddPresetResponse,
|
||||
DeletePresetsParams,
|
||||
GetPresetResponse,
|
||||
} from "@monkeytype/contracts/presets";
|
||||
import * as PresetDAL from "../../dal/preset";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { replaceObjectId } from "../../utils/misc";
|
||||
import { EditPresetRequest } from "@monkeytype/schemas/presets";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function getPresets(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetPresetResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const data = (await PresetDAL.getPresets(uid))
|
||||
.map((preset) => ({
|
||||
...preset,
|
||||
uid: undefined,
|
||||
}))
|
||||
.map((it) => replaceObjectId(it));
|
||||
|
||||
return new MonkeyResponse("Presets retrieved", data);
|
||||
}
|
||||
|
||||
export async function addPreset(
|
||||
req: MonkeyRequest<undefined, AddPresetRequest>,
|
||||
): Promise<AddPresetResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const data = await PresetDAL.addPreset(uid, req.body);
|
||||
|
||||
return new MonkeyResponse("Preset created", data);
|
||||
}
|
||||
|
||||
export async function editPreset(
|
||||
req: MonkeyRequest<undefined, EditPresetRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await PresetDAL.editPreset(uid, req.body);
|
||||
|
||||
return new MonkeyResponse("Preset updated", null);
|
||||
}
|
||||
|
||||
export async function removePreset(
|
||||
req: MonkeyRequest<undefined, undefined, DeletePresetsParams>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { presetId } = req.params;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await PresetDAL.removePreset(uid, presetId);
|
||||
|
||||
return new MonkeyResponse("Preset deleted", null);
|
||||
}
|
||||
16
backend/src/api/controllers/psa.ts
Normal file
16
backend/src/api/controllers/psa.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { GetPsaResponse } from "@monkeytype/contracts/psas";
|
||||
import * as PsaDAL from "../../dal/psa";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { replaceObjectIds } from "../../utils/misc";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { PSA } from "@monkeytype/schemas/psas";
|
||||
import { cacheWithTTL } from "../../utils/ttl-cache";
|
||||
|
||||
//cache for one minute
|
||||
const cache = cacheWithTTL<PSA[]>(1 * 60 * 1000, async () => {
|
||||
return replaceObjectIds(await PsaDAL.get());
|
||||
});
|
||||
|
||||
export async function getPsas(_req: MonkeyRequest): Promise<GetPsaResponse> {
|
||||
return new MonkeyResponse("PSAs retrieved", (await cache()) ?? []);
|
||||
}
|
||||
23
backend/src/api/controllers/public.ts
Normal file
23
backend/src/api/controllers/public.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
GetSpeedHistogramQuery,
|
||||
GetSpeedHistogramResponse,
|
||||
GetTypingStatsResponse,
|
||||
} from "@monkeytype/contracts/public";
|
||||
import * as PublicDAL from "../../dal/public";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function getSpeedHistogram(
|
||||
req: MonkeyRequest<GetSpeedHistogramQuery>,
|
||||
): Promise<GetSpeedHistogramResponse> {
|
||||
const { language, mode, mode2 } = req.query;
|
||||
const data = await PublicDAL.getSpeedHistogram(language, mode, mode2);
|
||||
return new MonkeyResponse("Public speed histogram retrieved", data);
|
||||
}
|
||||
|
||||
export async function getTypingStats(
|
||||
_req: MonkeyRequest,
|
||||
): Promise<GetTypingStatsResponse> {
|
||||
const data = await PublicDAL.getTypingStats();
|
||||
return new MonkeyResponse("Public typing stats retrieved", data);
|
||||
}
|
||||
165
backend/src/api/controllers/quote.ts
Normal file
165
backend/src/api/controllers/quote.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getPartialUser, updateQuoteRatings } from "../../dal/user";
|
||||
import * as ReportDAL from "../../dal/report";
|
||||
import * as NewQuotesDAL from "../../dal/new-quotes";
|
||||
import * as QuoteRatingsDAL from "../../dal/quote-ratings";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { verify } from "../../utils/captcha";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { addLog } from "../../dal/logs";
|
||||
import {
|
||||
AddQuoteRatingRequest,
|
||||
AddQuoteRequest,
|
||||
ApproveQuoteRequest,
|
||||
ApproveQuoteResponse,
|
||||
GetQuoteRatingQuery,
|
||||
GetQuoteRatingResponse,
|
||||
GetQuotesResponse,
|
||||
IsSubmissionEnabledResponse,
|
||||
RejectQuoteRequest,
|
||||
ReportQuoteRequest,
|
||||
} from "@monkeytype/contracts/quotes";
|
||||
import { replaceObjectId, replaceObjectIds } from "../../utils/misc";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { Language } from "@monkeytype/schemas/languages";
|
||||
|
||||
async function verifyCaptcha(captcha: string): Promise<void> {
|
||||
if (!(await verify(captcha))) {
|
||||
throw new MonkeyError(422, "Captcha check failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQuotes(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetQuotesResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const quoteMod = (await getPartialUser(uid, "get quotes", ["quoteMod"]))
|
||||
.quoteMod;
|
||||
const quoteModLanguage = quoteMod === true ? "all" : (quoteMod as Language);
|
||||
|
||||
const data = await NewQuotesDAL.get(quoteModLanguage);
|
||||
return new MonkeyResponse(
|
||||
"Quote submissions retrieved",
|
||||
replaceObjectIds(data),
|
||||
);
|
||||
}
|
||||
|
||||
export async function isSubmissionEnabled(
|
||||
req: MonkeyRequest,
|
||||
): Promise<IsSubmissionEnabledResponse> {
|
||||
const { submissionsEnabled } = req.ctx.configuration.quotes;
|
||||
return new MonkeyResponse(
|
||||
"Quote submission " + (submissionsEnabled ? "enabled" : "disabled"),
|
||||
{ isEnabled: submissionsEnabled },
|
||||
);
|
||||
}
|
||||
|
||||
export async function addQuote(
|
||||
req: MonkeyRequest<undefined, AddQuoteRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { text, source, language, captcha } = req.body;
|
||||
|
||||
await verifyCaptcha(captcha);
|
||||
|
||||
await NewQuotesDAL.add(text, source, language, uid);
|
||||
return new MonkeyResponse("Quote submission added", null);
|
||||
}
|
||||
|
||||
export async function approveQuote(
|
||||
req: MonkeyRequest<undefined, ApproveQuoteRequest>,
|
||||
): Promise<ApproveQuoteResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { quoteId, editText, editSource } = req.body;
|
||||
|
||||
const { name } = await getPartialUser(uid, "approve quote", ["name"]);
|
||||
|
||||
if (!name) {
|
||||
throw new MonkeyError(500, "Missing name field");
|
||||
}
|
||||
|
||||
const data = await NewQuotesDAL.approve(quoteId, editText, editSource, name);
|
||||
void addLog("system_quote_approved", data, uid);
|
||||
|
||||
return new MonkeyResponse(data.message, data.quote);
|
||||
}
|
||||
|
||||
export async function refuseQuote(
|
||||
req: MonkeyRequest<undefined, RejectQuoteRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { quoteId } = req.body;
|
||||
|
||||
await NewQuotesDAL.refuse(quoteId);
|
||||
return new MonkeyResponse("Quote refused", null);
|
||||
}
|
||||
|
||||
export async function getRating(
|
||||
req: MonkeyRequest<GetQuoteRatingQuery>,
|
||||
): Promise<GetQuoteRatingResponse> {
|
||||
const { quoteId, language } = req.query;
|
||||
|
||||
const data = await QuoteRatingsDAL.get(quoteId, language);
|
||||
|
||||
return new MonkeyResponse("Rating retrieved", replaceObjectId(data));
|
||||
}
|
||||
|
||||
export async function submitRating(
|
||||
req: MonkeyRequest<undefined, AddQuoteRatingRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { quoteId, rating, language } = req.body;
|
||||
|
||||
const user = await getPartialUser(uid, "submit rating", ["quoteRatings"]);
|
||||
|
||||
const userQuoteRatings = user.quoteRatings ?? {};
|
||||
const currentRating = userQuoteRatings[language]?.[quoteId] ?? 0;
|
||||
|
||||
const newRating = rating - currentRating;
|
||||
const shouldUpdateRating = currentRating !== 0;
|
||||
|
||||
await QuoteRatingsDAL.submit(
|
||||
quoteId,
|
||||
language,
|
||||
newRating,
|
||||
shouldUpdateRating,
|
||||
);
|
||||
|
||||
userQuoteRatings[language] ??= {};
|
||||
userQuoteRatings[language][quoteId] = rating;
|
||||
|
||||
await updateQuoteRatings(uid, userQuoteRatings);
|
||||
|
||||
const responseMessage = `Rating ${
|
||||
shouldUpdateRating ? "updated" : "submitted"
|
||||
}`;
|
||||
return new MonkeyResponse(responseMessage, null);
|
||||
}
|
||||
|
||||
export async function reportQuote(
|
||||
req: MonkeyRequest<undefined, ReportQuoteRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const {
|
||||
reporting: { maxReports, contentReportLimit },
|
||||
} = req.ctx.configuration.quotes;
|
||||
|
||||
const { quoteId, quoteLanguage, reason, comment, captcha } = req.body;
|
||||
|
||||
await verifyCaptcha(captcha);
|
||||
|
||||
const newReport: ReportDAL.DBReport = {
|
||||
_id: new ObjectId(),
|
||||
id: uuidv4(),
|
||||
type: "quote",
|
||||
timestamp: new Date().getTime(),
|
||||
uid,
|
||||
contentId: `${quoteLanguage}-${quoteId}`,
|
||||
reason,
|
||||
comment: comment ?? "",
|
||||
};
|
||||
|
||||
await ReportDAL.createReport(newReport, maxReports, contentReportLimit);
|
||||
|
||||
return new MonkeyResponse("Quote reported", null);
|
||||
}
|
||||
831
backend/src/api/controllers/result.ts
Normal file
831
backend/src/api/controllers/result.ts
Normal file
@@ -0,0 +1,831 @@
|
||||
import * as ResultDAL from "../../dal/result";
|
||||
import * as PublicDAL from "../../dal/public";
|
||||
import {
|
||||
isDevEnvironment,
|
||||
omit,
|
||||
replaceObjectId,
|
||||
replaceObjectIds,
|
||||
} from "../../utils/misc";
|
||||
import objectHash from "object-hash";
|
||||
import Logger from "../../utils/logger";
|
||||
import "dotenv/config";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { isTestTooShort } from "../../utils/validation";
|
||||
import {
|
||||
implemented as anticheatImplemented,
|
||||
validateResult,
|
||||
validateKeys,
|
||||
} from "../../anticheat/index";
|
||||
import MonkeyStatusCodes from "../../constants/monkey-status-codes";
|
||||
import {
|
||||
incrementResult,
|
||||
incrementDailyLeaderboard,
|
||||
} from "../../utils/prometheus";
|
||||
import GeorgeQueue from "../../queues/george-queue";
|
||||
import { getDailyLeaderboard } from "../../utils/daily-leaderboards";
|
||||
import AutoRoleList from "../../constants/auto-roles";
|
||||
import * as UserDAL from "../../dal/user";
|
||||
import { buildMonkeyMail } from "../../utils/monkey-mail";
|
||||
import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { canFunboxGetPb } from "../../utils/pb";
|
||||
import { buildDbResult } from "../../utils/result";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { addImportantLog, addLog } from "../../dal/logs";
|
||||
import {
|
||||
AddResultRequest,
|
||||
AddResultResponse,
|
||||
GetLastResultResponse,
|
||||
GetResultByIdPath,
|
||||
GetResultByIdResponse,
|
||||
GetResultsQuery,
|
||||
GetResultsResponse,
|
||||
UpdateResultTagsRequest,
|
||||
UpdateResultTagsResponse,
|
||||
} from "@monkeytype/contracts/results";
|
||||
import {
|
||||
CompletedEvent,
|
||||
KeyStats,
|
||||
PostResultResponse,
|
||||
XpBreakdown,
|
||||
} from "@monkeytype/schemas/results";
|
||||
import {
|
||||
isSafeNumber,
|
||||
mapRange,
|
||||
roundTo2,
|
||||
stdDev,
|
||||
} from "@monkeytype/util/numbers";
|
||||
import {
|
||||
getCurrentDayTimestamp,
|
||||
getStartOfDayTimestamp,
|
||||
} from "@monkeytype/util/date-and-time";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { getFunbox, checkCompatibility } from "@monkeytype/funbox";
|
||||
import { tryCatch } from "@monkeytype/util/trycatch";
|
||||
import { getCachedConfiguration } from "../../init/configuration";
|
||||
|
||||
try {
|
||||
if (!anticheatImplemented()) throw new Error("undefined");
|
||||
Logger.success("Anticheat module loaded");
|
||||
} catch (e) {
|
||||
if (isDevEnvironment()) {
|
||||
Logger.warning(
|
||||
"No anticheat module found. Continuing in dev mode, results will not be validated.",
|
||||
);
|
||||
} else {
|
||||
Logger.error(
|
||||
"No anticheat module found. To continue in dev mode, add MODE=dev to your .env file in the backend directory",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResults(
|
||||
req: MonkeyRequest<GetResultsQuery>,
|
||||
): Promise<GetResultsResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled;
|
||||
const { onOrAfterTimestamp = NaN, offset = 0 } = req.query;
|
||||
const userHasPremium = await UserDAL.checkIfUserIsPremium(uid);
|
||||
|
||||
const maxLimit =
|
||||
premiumFeaturesEnabled && userHasPremium
|
||||
? req.ctx.configuration.results.limits.premiumUser
|
||||
: req.ctx.configuration.results.limits.regularUser;
|
||||
|
||||
let limit =
|
||||
req.query.limit ??
|
||||
Math.min(req.ctx.configuration.results.maxBatchSize, maxLimit);
|
||||
|
||||
//check if premium features are disabled and current call exceeds the limit for regular users
|
||||
if (
|
||||
userHasPremium &&
|
||||
!premiumFeaturesEnabled &&
|
||||
limit + offset > req.ctx.configuration.results.limits.regularUser
|
||||
) {
|
||||
throw new MonkeyError(503, "Premium feature disabled.");
|
||||
}
|
||||
|
||||
if (limit + offset > maxLimit) {
|
||||
if (offset < maxLimit) {
|
||||
//batch is partly in the allowed ranged. Set the limit to the max allowed and return partly results.
|
||||
limit = maxLimit - offset;
|
||||
} else {
|
||||
throw new MonkeyError(422, `Max results limit of ${maxLimit} exceeded.`);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await ResultDAL.getResults(uid, {
|
||||
onOrAfterTimestamp,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
void addLog(
|
||||
"user_results_requested",
|
||||
{
|
||||
limit,
|
||||
offset,
|
||||
onOrAfterTimestamp,
|
||||
isPremium: userHasPremium,
|
||||
},
|
||||
uid,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Results retrieved", replaceObjectIds(results));
|
||||
}
|
||||
|
||||
export async function getResultById(
|
||||
req: MonkeyRequest<undefined, undefined, GetResultByIdPath>,
|
||||
): Promise<GetResultByIdResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { resultId } = req.params;
|
||||
|
||||
const result = await ResultDAL.getResult(uid, resultId);
|
||||
return new MonkeyResponse("Result retrieved", replaceObjectId(result));
|
||||
}
|
||||
|
||||
export async function getLastResult(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetLastResultResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const result = await ResultDAL.getLastResult(uid);
|
||||
return new MonkeyResponse("Result retrieved", replaceObjectId(result));
|
||||
}
|
||||
|
||||
export async function deleteAll(req: MonkeyRequest): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ResultDAL.deleteAll(uid);
|
||||
void addLog("user_results_deleted", "", uid);
|
||||
return new MonkeyResponse("All results deleted", null);
|
||||
}
|
||||
|
||||
export async function updateTags(
|
||||
req: MonkeyRequest<undefined, UpdateResultTagsRequest>,
|
||||
): Promise<UpdateResultTagsResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { tagIds, resultId } = req.body;
|
||||
|
||||
await ResultDAL.updateTags(uid, resultId, tagIds);
|
||||
const result = await ResultDAL.getResult(uid, resultId);
|
||||
|
||||
result.difficulty ??= "normal";
|
||||
result.language ??= "english";
|
||||
result.funbox ??= [];
|
||||
result.lazyMode ??= false;
|
||||
result.punctuation ??= false;
|
||||
result.numbers ??= false;
|
||||
|
||||
const user = await UserDAL.getPartialUser(uid, "update tags", ["tags"]);
|
||||
const tagPbs = await UserDAL.checkIfTagPb(uid, user, result);
|
||||
return new MonkeyResponse("Result tags updated", {
|
||||
tagPbs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function addResult(
|
||||
req: MonkeyRequest<undefined, AddResultRequest>,
|
||||
): Promise<AddResultResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const user = await UserDAL.getUser(uid, "add result");
|
||||
|
||||
if (user.needsToChangeName) {
|
||||
throw new MonkeyError(
|
||||
403,
|
||||
"Please change your name before submitting a result",
|
||||
);
|
||||
}
|
||||
|
||||
const completedEvent = req.body.result;
|
||||
completedEvent.uid = uid;
|
||||
|
||||
if (isTestTooShort(completedEvent)) {
|
||||
const status = MonkeyStatusCodes.TEST_TOO_SHORT;
|
||||
throw new MonkeyError(status.code, status.message);
|
||||
}
|
||||
|
||||
if (user.lbOptOut !== true && completedEvent.acc < 75) {
|
||||
throw new MonkeyError(400, "Accuracy too low");
|
||||
}
|
||||
|
||||
const resulthash = completedEvent.hash;
|
||||
if (req.ctx.configuration.results.objectHashCheckEnabled) {
|
||||
const objectToHash = omit(completedEvent, ["hash"]);
|
||||
const serverhash = objectHash(objectToHash);
|
||||
if (serverhash !== resulthash) {
|
||||
void addLog(
|
||||
"incorrect_result_hash",
|
||||
{
|
||||
serverhash,
|
||||
resulthash,
|
||||
result: completedEvent,
|
||||
},
|
||||
uid,
|
||||
);
|
||||
const status = MonkeyStatusCodes.RESULT_HASH_INVALID;
|
||||
throw new MonkeyError(status.code, "Incorrect result hash");
|
||||
}
|
||||
} else {
|
||||
Logger.warning("Object hash check is disabled, skipping hash check");
|
||||
}
|
||||
|
||||
if (completedEvent.funbox.length !== new Set(completedEvent.funbox).size) {
|
||||
throw new MonkeyError(400, "Duplicate funboxes");
|
||||
}
|
||||
|
||||
if (!checkCompatibility(completedEvent.funbox)) {
|
||||
throw new MonkeyError(400, "Impossible funbox combination");
|
||||
}
|
||||
|
||||
let keySpacingStats: KeyStats | undefined = undefined;
|
||||
if (
|
||||
completedEvent.keySpacing !== "toolong" &&
|
||||
completedEvent.keySpacing.length > 0
|
||||
) {
|
||||
keySpacingStats = {
|
||||
average:
|
||||
completedEvent.keySpacing.reduce(
|
||||
(previous, current) => (current += previous),
|
||||
) / completedEvent.keySpacing.length,
|
||||
sd: stdDev(completedEvent.keySpacing),
|
||||
};
|
||||
}
|
||||
|
||||
let keyDurationStats: KeyStats | undefined = undefined;
|
||||
if (
|
||||
completedEvent.keyDuration !== "toolong" &&
|
||||
completedEvent.keyDuration.length > 0
|
||||
) {
|
||||
keyDurationStats = {
|
||||
average:
|
||||
completedEvent.keyDuration.reduce(
|
||||
(previous, current) => (current += previous),
|
||||
) / completedEvent.keyDuration.length,
|
||||
sd: stdDev(completedEvent.keyDuration),
|
||||
};
|
||||
}
|
||||
|
||||
if (user.suspicious && completedEvent.testDuration <= 120) {
|
||||
await addImportantLog("suspicious_user_result", completedEvent, uid);
|
||||
}
|
||||
|
||||
if (
|
||||
completedEvent.mode === "time" &&
|
||||
(completedEvent.mode2 === "60" || completedEvent.mode2 === "15") &&
|
||||
completedEvent.wpm > 250 &&
|
||||
user.lbOptOut !== true
|
||||
) {
|
||||
await addImportantLog("highwpm_user_result", completedEvent, uid);
|
||||
}
|
||||
|
||||
if (anticheatImplemented()) {
|
||||
if (
|
||||
!validateResult(
|
||||
completedEvent,
|
||||
((req.raw.headers["x-client-version"] as string) ||
|
||||
req.raw.headers["client-version"]) as string,
|
||||
JSON.stringify(new UAParser(req.raw.headers["user-agent"]).getResult()),
|
||||
user.lbOptOut === true,
|
||||
)
|
||||
) {
|
||||
const status = MonkeyStatusCodes.RESULT_DATA_INVALID;
|
||||
throw new MonkeyError(status.code, "Result data doesn't make sense");
|
||||
} else if (isDevEnvironment()) {
|
||||
Logger.success("Result data validated");
|
||||
}
|
||||
} else {
|
||||
if (!isDevEnvironment()) {
|
||||
throw new Error("No anticheat module found");
|
||||
}
|
||||
Logger.warning(
|
||||
"No anticheat module found. Continuing in dev mode, results will not be validated.",
|
||||
);
|
||||
}
|
||||
|
||||
//dont use - result timestamp is unreliable, can be changed by system time and stuff
|
||||
// if (result.timestamp > Math.round(Date.now() / 1000) * 1000 + 10) {
|
||||
// log(
|
||||
// "time_traveler",
|
||||
// {
|
||||
// resultTimestamp: result.timestamp,
|
||||
// serverTimestamp: Math.round(Date.now() / 1000) * 1000 + 10,
|
||||
// },
|
||||
// uid
|
||||
// );
|
||||
// return res.status(400).json({ message: "Time traveler detected" });
|
||||
|
||||
const { data: lastResultTimestamp } = await tryCatch(
|
||||
ResultDAL.getLastResultTimestamp(uid),
|
||||
);
|
||||
|
||||
//convert result test duration to miliseconds
|
||||
completedEvent.timestamp = Math.floor(Date.now() / 1000) * 1000;
|
||||
|
||||
//check if now is earlier than last result plus duration (-1 second as a buffer)
|
||||
const testDurationMilis = completedEvent.testDuration * 1000;
|
||||
const incompleteTestsMilis = completedEvent.incompleteTestSeconds * 1000;
|
||||
const earliestPossible =
|
||||
(lastResultTimestamp ?? 0) + testDurationMilis + incompleteTestsMilis;
|
||||
const nowNoMilis = Math.floor(Date.now() / 1000) * 1000;
|
||||
if (
|
||||
isSafeNumber(lastResultTimestamp) &&
|
||||
nowNoMilis < earliestPossible - 1000
|
||||
) {
|
||||
void addLog(
|
||||
"invalid_result_spacing",
|
||||
{
|
||||
lastTimestamp: lastResultTimestamp,
|
||||
earliestPossible,
|
||||
now: nowNoMilis,
|
||||
testDuration: testDurationMilis,
|
||||
difference: nowNoMilis - earliestPossible,
|
||||
},
|
||||
uid,
|
||||
);
|
||||
const status = MonkeyStatusCodes.RESULT_SPACING_INVALID;
|
||||
throw new MonkeyError(status.code, "Invalid result spacing");
|
||||
}
|
||||
|
||||
//check keyspacing and duration here for bots
|
||||
if (
|
||||
completedEvent.mode === "time" &&
|
||||
completedEvent.wpm > 130 &&
|
||||
completedEvent.testDuration < 122 &&
|
||||
(user.verified === false || user.verified === undefined) &&
|
||||
user.lbOptOut !== true
|
||||
) {
|
||||
if (!keySpacingStats || !keyDurationStats) {
|
||||
const status = MonkeyStatusCodes.MISSING_KEY_DATA;
|
||||
throw new MonkeyError(status.code, "Missing key data");
|
||||
}
|
||||
if (completedEvent.keyOverlap === undefined) {
|
||||
throw new MonkeyError(400, "Old key data format");
|
||||
}
|
||||
if (anticheatImplemented()) {
|
||||
if (
|
||||
!validateKeys(completedEvent, keySpacingStats, keyDurationStats, uid)
|
||||
) {
|
||||
//autoban
|
||||
const autoBanConfig = req.ctx.configuration.users.autoBan;
|
||||
if (autoBanConfig.enabled) {
|
||||
const didUserGetBanned = await UserDAL.recordAutoBanEvent(
|
||||
uid,
|
||||
autoBanConfig.maxCount,
|
||||
autoBanConfig.maxHours,
|
||||
);
|
||||
if (didUserGetBanned) {
|
||||
const mail = buildMonkeyMail({
|
||||
subject: "Banned",
|
||||
body: "Your account has been automatically banned for triggering the anticheat system. If you believe this is a mistake, please contact support.",
|
||||
});
|
||||
await UserDAL.addToInbox(
|
||||
uid,
|
||||
[mail],
|
||||
req.ctx.configuration.users.inbox,
|
||||
);
|
||||
user.banned = true;
|
||||
}
|
||||
}
|
||||
const status = MonkeyStatusCodes.BOT_DETECTED;
|
||||
throw new MonkeyError(status.code, "Possible bot detected");
|
||||
}
|
||||
} else {
|
||||
if (!isDevEnvironment()) {
|
||||
throw new Error("No anticheat module found");
|
||||
}
|
||||
Logger.warning(
|
||||
"No anticheat module found. Continuing in dev mode, results will not be validated.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.ctx.configuration.users.lastHashesCheck.enabled) {
|
||||
let lastHashes = user.lastReultHashes ?? [];
|
||||
if (lastHashes.includes(resulthash)) {
|
||||
void addLog(
|
||||
"duplicate_result",
|
||||
{
|
||||
lastHashes,
|
||||
resulthash,
|
||||
result: completedEvent,
|
||||
},
|
||||
uid,
|
||||
);
|
||||
const status = MonkeyStatusCodes.DUPLICATE_RESULT;
|
||||
throw new MonkeyError(status.code, "Duplicate result");
|
||||
} else {
|
||||
lastHashes.unshift(resulthash);
|
||||
const maxHashes = req.ctx.configuration.users.lastHashesCheck.maxHashes;
|
||||
if (lastHashes.length > maxHashes) {
|
||||
lastHashes = lastHashes.slice(0, maxHashes);
|
||||
}
|
||||
await UserDAL.updateLastHashes(uid, lastHashes);
|
||||
}
|
||||
}
|
||||
|
||||
if (keyDurationStats) {
|
||||
keyDurationStats.average = roundTo2(keyDurationStats.average);
|
||||
keyDurationStats.sd = roundTo2(keyDurationStats.sd);
|
||||
}
|
||||
if (keySpacingStats) {
|
||||
keySpacingStats.average = roundTo2(keySpacingStats.average);
|
||||
keySpacingStats.sd = roundTo2(keySpacingStats.sd);
|
||||
}
|
||||
|
||||
let isPb = false;
|
||||
let tagPbs: string[] = [];
|
||||
|
||||
if (!completedEvent.bailedOut) {
|
||||
[isPb, tagPbs] = await Promise.all([
|
||||
UserDAL.checkIfPb(uid, user, completedEvent),
|
||||
UserDAL.checkIfTagPb(uid, user, completedEvent),
|
||||
]);
|
||||
}
|
||||
|
||||
if (completedEvent.mode === "time" && completedEvent.mode2 === "60") {
|
||||
void UserDAL.incrementBananas(uid, completedEvent.wpm);
|
||||
if (
|
||||
isPb &&
|
||||
user.discordId !== undefined &&
|
||||
user.discordId !== "" &&
|
||||
user.lbOptOut !== true
|
||||
) {
|
||||
void GeorgeQueue.updateDiscordRole(user.discordId, completedEvent.wpm);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
completedEvent.challenge !== null &&
|
||||
completedEvent.challenge !== undefined &&
|
||||
AutoRoleList.includes(completedEvent.challenge) &&
|
||||
user.discordId !== undefined &&
|
||||
user.discordId !== ""
|
||||
) {
|
||||
void GeorgeQueue.awardChallenge(user.discordId, completedEvent.challenge);
|
||||
} else {
|
||||
delete completedEvent.challenge;
|
||||
}
|
||||
|
||||
const afk = completedEvent.afkDuration ?? 0;
|
||||
const totalDurationTypedSeconds =
|
||||
completedEvent.testDuration + completedEvent.incompleteTestSeconds - afk;
|
||||
void UserDAL.updateTypingStats(
|
||||
uid,
|
||||
completedEvent.restartCount,
|
||||
totalDurationTypedSeconds,
|
||||
);
|
||||
void PublicDAL.updateStats(
|
||||
completedEvent.restartCount,
|
||||
totalDurationTypedSeconds,
|
||||
);
|
||||
|
||||
const dailyLeaderboardsConfig = req.ctx.configuration.dailyLeaderboards;
|
||||
const dailyLeaderboard = getDailyLeaderboard(
|
||||
completedEvent.language,
|
||||
completedEvent.mode,
|
||||
completedEvent.mode2,
|
||||
dailyLeaderboardsConfig,
|
||||
);
|
||||
|
||||
let dailyLeaderboardRank = -1;
|
||||
|
||||
const stopOnLetterTriggered =
|
||||
completedEvent.stopOnLetter && completedEvent.acc < 100;
|
||||
|
||||
const minTimeTyping = (await getCachedConfiguration(true)).leaderboards
|
||||
.minTimeTyping;
|
||||
|
||||
const userEligibleForLeaderboard =
|
||||
user.banned !== true &&
|
||||
user.lbOptOut !== true &&
|
||||
(isDevEnvironment() || (user.timeTyping ?? 0) > minTimeTyping);
|
||||
|
||||
const validResultCriteria =
|
||||
canFunboxGetPb(completedEvent) &&
|
||||
!completedEvent.bailedOut &&
|
||||
userEligibleForLeaderboard &&
|
||||
!stopOnLetterTriggered;
|
||||
|
||||
const selectedBadgeId = user.inventory?.badges?.find((b) => b.selected)?.id;
|
||||
const isPremium =
|
||||
(await UserDAL.checkIfUserIsPremium(user.uid, user)) || undefined;
|
||||
|
||||
if (dailyLeaderboard && validResultCriteria) {
|
||||
incrementDailyLeaderboard(
|
||||
completedEvent.mode,
|
||||
completedEvent.mode2,
|
||||
completedEvent.language,
|
||||
);
|
||||
dailyLeaderboardRank = await dailyLeaderboard.addResult(
|
||||
{
|
||||
name: user.name,
|
||||
wpm: completedEvent.wpm,
|
||||
raw: completedEvent.rawWpm,
|
||||
acc: completedEvent.acc,
|
||||
consistency: completedEvent.consistency,
|
||||
timestamp: completedEvent.timestamp,
|
||||
uid,
|
||||
discordAvatar: user.discordAvatar,
|
||||
discordId: user.discordId,
|
||||
badgeId: selectedBadgeId,
|
||||
isPremium,
|
||||
},
|
||||
dailyLeaderboardsConfig,
|
||||
);
|
||||
if (
|
||||
dailyLeaderboardRank >= 1 &&
|
||||
dailyLeaderboardRank <= 10 &&
|
||||
completedEvent.testDuration <= 120
|
||||
) {
|
||||
const now = Date.now();
|
||||
const reset = getCurrentDayTimestamp();
|
||||
const limit = 6 * 60 * 60 * 1000;
|
||||
if (now - reset >= limit) {
|
||||
await addLog("daily_leaderboard_top_10_result", completedEvent, uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const streak = await UserDAL.updateStreak(uid, completedEvent.timestamp);
|
||||
const badgeWaitingInInbox = (
|
||||
user.inbox?.flatMap((i) =>
|
||||
(i.rewards ?? []).map((r) => (r.type === "badge" ? r.item.id : null)),
|
||||
) ?? []
|
||||
).includes(14);
|
||||
|
||||
const shouldGetBadge =
|
||||
streak >= 365 &&
|
||||
user.inventory?.badges?.find((b) => b.id === 14) === undefined &&
|
||||
!badgeWaitingInInbox;
|
||||
|
||||
if (shouldGetBadge) {
|
||||
const mail = buildMonkeyMail({
|
||||
subject: "Badge",
|
||||
body: "Congratulations for reaching a 365 day streak! You have been awarded a special badge. Now, go touch some grass.",
|
||||
rewards: [
|
||||
{
|
||||
type: "badge",
|
||||
item: {
|
||||
id: 14,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await UserDAL.addToInbox(uid, [mail], req.ctx.configuration.users.inbox);
|
||||
}
|
||||
|
||||
const xpGained = await calculateXp(
|
||||
completedEvent,
|
||||
req.ctx.configuration.users.xp,
|
||||
lastResultTimestamp,
|
||||
user.xp ?? 0,
|
||||
streak,
|
||||
);
|
||||
|
||||
if (xpGained.xp < 0) {
|
||||
throw new MonkeyError(
|
||||
500,
|
||||
"Calculated XP is negative",
|
||||
JSON.stringify({
|
||||
xpGained,
|
||||
result: completedEvent,
|
||||
}),
|
||||
uid,
|
||||
);
|
||||
}
|
||||
|
||||
const weeklyXpLeaderboardConfig = req.ctx.configuration.leaderboards.weeklyXp;
|
||||
let weeklyXpLeaderboardRank = -1;
|
||||
|
||||
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(
|
||||
weeklyXpLeaderboardConfig,
|
||||
);
|
||||
if (userEligibleForLeaderboard && xpGained.xp > 0 && weeklyXpLeaderboard) {
|
||||
weeklyXpLeaderboardRank = await weeklyXpLeaderboard.addResult(
|
||||
weeklyXpLeaderboardConfig,
|
||||
{
|
||||
entry: {
|
||||
uid,
|
||||
name: user.name,
|
||||
discordAvatar: user.discordAvatar,
|
||||
discordId: user.discordId,
|
||||
badgeId: selectedBadgeId,
|
||||
lastActivityTimestamp: Date.now(),
|
||||
isPremium,
|
||||
timeTypedSeconds: totalDurationTypedSeconds,
|
||||
},
|
||||
xpGained: xpGained.xp,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const dbresult = buildDbResult(completedEvent, user.name, isPb);
|
||||
if (keySpacingStats !== undefined) {
|
||||
dbresult.keySpacingStats = keySpacingStats;
|
||||
}
|
||||
if (keyDurationStats !== undefined) {
|
||||
dbresult.keyDurationStats = keyDurationStats;
|
||||
}
|
||||
|
||||
const addedResult = await ResultDAL.addResult(uid, dbresult);
|
||||
|
||||
await UserDAL.incrementXp(uid, xpGained.xp);
|
||||
await UserDAL.incrementTestActivity(user, completedEvent.timestamp);
|
||||
|
||||
if (isPb) {
|
||||
void addLog(
|
||||
"user_new_pb",
|
||||
`${completedEvent.mode + " " + completedEvent.mode2} ${
|
||||
completedEvent.wpm
|
||||
} ${completedEvent.acc}% ${completedEvent.rawWpm} ${
|
||||
completedEvent.consistency
|
||||
}% (${addedResult.insertedId})`,
|
||||
uid,
|
||||
);
|
||||
}
|
||||
|
||||
const data: PostResultResponse = {
|
||||
isPb,
|
||||
tagPbs,
|
||||
insertedId: addedResult.insertedId.toHexString(),
|
||||
xp: xpGained.xp,
|
||||
dailyXpBonus: xpGained.dailyBonus ?? false,
|
||||
xpBreakdown: xpGained.breakdown ?? {},
|
||||
streak,
|
||||
};
|
||||
|
||||
if (dailyLeaderboardRank !== -1) {
|
||||
data.dailyLeaderboardRank = dailyLeaderboardRank;
|
||||
}
|
||||
|
||||
if (weeklyXpLeaderboardRank !== -1) {
|
||||
data.weeklyXpLeaderboardRank = weeklyXpLeaderboardRank;
|
||||
}
|
||||
|
||||
incrementResult(completedEvent, dbresult.isPb);
|
||||
|
||||
return new MonkeyResponse("Result saved", data);
|
||||
}
|
||||
|
||||
type XpResult = {
|
||||
xp: number;
|
||||
dailyBonus?: boolean;
|
||||
breakdown?: XpBreakdown;
|
||||
};
|
||||
|
||||
async function calculateXp(
|
||||
result: CompletedEvent,
|
||||
xpConfiguration: Configuration["users"]["xp"],
|
||||
lastResultTimestamp: number | null,
|
||||
currentTotalXp: number,
|
||||
streak: number,
|
||||
): Promise<XpResult> {
|
||||
const {
|
||||
mode,
|
||||
acc,
|
||||
testDuration,
|
||||
incompleteTestSeconds,
|
||||
incompleteTests,
|
||||
afkDuration,
|
||||
charStats,
|
||||
punctuation,
|
||||
numbers,
|
||||
funbox: resultFunboxes,
|
||||
} = result;
|
||||
|
||||
const {
|
||||
enabled,
|
||||
gainMultiplier,
|
||||
maxDailyBonus,
|
||||
minDailyBonus,
|
||||
funboxBonus: funboxBonusConfiguration,
|
||||
} = xpConfiguration;
|
||||
|
||||
if (mode === "zen" || !enabled) {
|
||||
return {
|
||||
xp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const breakdown: XpBreakdown = {};
|
||||
|
||||
const baseXp = Math.round((testDuration - afkDuration) * 2);
|
||||
breakdown.base = baseXp;
|
||||
|
||||
let modifier = 1;
|
||||
|
||||
const correctedEverything = charStats
|
||||
.slice(1)
|
||||
.every((charStat: number) => charStat === 0);
|
||||
|
||||
if (acc === 100) {
|
||||
modifier += 0.5;
|
||||
breakdown.fullAccuracy = Math.round(baseXp * 0.5);
|
||||
} else if (correctedEverything) {
|
||||
// corrected everything bonus
|
||||
modifier += 0.25;
|
||||
breakdown.corrected = Math.round(baseXp * 0.25);
|
||||
}
|
||||
|
||||
if (mode === "quote") {
|
||||
// real sentences bonus
|
||||
modifier += 0.5;
|
||||
breakdown.quote = Math.round(baseXp * 0.5);
|
||||
} else {
|
||||
// punctuation bonus
|
||||
if (punctuation) {
|
||||
modifier += 0.4;
|
||||
breakdown.punctuation = Math.round(baseXp * 0.4);
|
||||
}
|
||||
if (numbers) {
|
||||
modifier += 0.1;
|
||||
breakdown.numbers = Math.round(baseXp * 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
if (funboxBonusConfiguration > 0 && resultFunboxes.length !== 0) {
|
||||
const funboxModifier = resultFunboxes.reduce((sum, funboxName) => {
|
||||
const funbox = getFunbox(funboxName);
|
||||
const difficultyLevel = funbox?.difficultyLevel ?? 0;
|
||||
return sum + Math.max(difficultyLevel * funboxBonusConfiguration, 0);
|
||||
}, 0);
|
||||
|
||||
if (funboxModifier > 0) {
|
||||
modifier += funboxModifier;
|
||||
breakdown.funbox = Math.round(baseXp * funboxModifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (xpConfiguration.streak.enabled) {
|
||||
const streakModifier = parseFloat(
|
||||
mapRange(
|
||||
streak,
|
||||
0,
|
||||
xpConfiguration.streak.maxStreakDays,
|
||||
0,
|
||||
xpConfiguration.streak.maxStreakMultiplier,
|
||||
true,
|
||||
).toFixed(1),
|
||||
);
|
||||
|
||||
if (streakModifier > 0) {
|
||||
modifier += streakModifier;
|
||||
breakdown.streak = Math.round(baseXp * streakModifier);
|
||||
}
|
||||
}
|
||||
|
||||
let incompleteXp = 0;
|
||||
if (incompleteTests !== undefined && incompleteTests.length > 0) {
|
||||
incompleteTests.forEach((it: { acc: number; seconds: number }) => {
|
||||
let mod = (it.acc - 50) / 50;
|
||||
if (mod < 0) mod = 0;
|
||||
incompleteXp += Math.round(it.seconds * mod);
|
||||
});
|
||||
breakdown.incomplete = incompleteXp;
|
||||
} else if (incompleteTestSeconds && incompleteTestSeconds > 0) {
|
||||
incompleteXp = Math.round(incompleteTestSeconds);
|
||||
breakdown.incomplete = incompleteXp;
|
||||
}
|
||||
|
||||
const accuracyModifier = (acc - 50) / 50;
|
||||
|
||||
let dailyBonus = 0;
|
||||
if (isSafeNumber(lastResultTimestamp)) {
|
||||
const lastResultDay = getStartOfDayTimestamp(lastResultTimestamp);
|
||||
const today = getCurrentDayTimestamp();
|
||||
if (lastResultDay !== today) {
|
||||
const proportionalXp = Math.round(currentTotalXp * 0.05);
|
||||
dailyBonus = Math.max(
|
||||
Math.min(maxDailyBonus, proportionalXp),
|
||||
minDailyBonus,
|
||||
);
|
||||
breakdown.daily = dailyBonus;
|
||||
}
|
||||
}
|
||||
|
||||
const xpWithModifiers = Math.round(baseXp * modifier);
|
||||
|
||||
const xpAfterAccuracy = Math.round(xpWithModifiers * accuracyModifier);
|
||||
breakdown.accPenalty = xpWithModifiers - xpAfterAccuracy;
|
||||
|
||||
const totalXp =
|
||||
Math.round((xpAfterAccuracy + incompleteXp) * gainMultiplier) + dailyBonus;
|
||||
|
||||
if (gainMultiplier > 1) {
|
||||
// breakdown.push([
|
||||
// "configMultiplier",
|
||||
// Math.round((xpAfterAccuracy + incompleteXp) * (gainMultiplier - 1)),
|
||||
// ]);
|
||||
breakdown.configMultiplier = gainMultiplier;
|
||||
}
|
||||
|
||||
const isAwardingDailyBonus = dailyBonus > 0;
|
||||
|
||||
return {
|
||||
xp: totalXp,
|
||||
dailyBonus: isAwardingDailyBonus,
|
||||
breakdown,
|
||||
};
|
||||
}
|
||||
1310
backend/src/api/controllers/user.ts
Normal file
1310
backend/src/api/controllers/user.ts
Normal file
File diff suppressed because it is too large
Load Diff
22
backend/src/api/controllers/webhooks.ts
Normal file
22
backend/src/api/controllers/webhooks.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { PostGithubReleaseRequest } from "@monkeytype/contracts/webhooks";
|
||||
import GeorgeQueue from "../../queues/george-queue";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function githubRelease(
|
||||
req: MonkeyRequest<undefined, PostGithubReleaseRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const action = req.body.action;
|
||||
|
||||
if (action === "published") {
|
||||
const releaseId = req.body.release?.id;
|
||||
if (releaseId === undefined) {
|
||||
throw new MonkeyError(422, 'Missing property "release.id".');
|
||||
}
|
||||
|
||||
await GeorgeQueue.sendReleaseAnnouncement(releaseId);
|
||||
return new MonkeyResponse("Added release announcement task to queue", null);
|
||||
}
|
||||
return new MonkeyResponse("No action taken", null);
|
||||
}
|
||||
30
backend/src/api/routes/admin.ts
Normal file
30
backend/src/api/routes/admin.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// import joi from "joi";
|
||||
|
||||
import * as AdminController from "../controllers/admin";
|
||||
import { adminContract } from "@monkeytype/contracts/admin";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(adminContract, {
|
||||
test: {
|
||||
handler: async (r) => callController(AdminController.test)(r),
|
||||
},
|
||||
toggleBan: {
|
||||
handler: async (r) => callController(AdminController.toggleBan)(r),
|
||||
},
|
||||
clearStreakHourOffset: {
|
||||
handler: async (r) =>
|
||||
callController(AdminController.clearStreakHourOffset)(r),
|
||||
},
|
||||
acceptReports: {
|
||||
handler: async (r) => callController(AdminController.acceptReports)(r),
|
||||
},
|
||||
rejectReports: {
|
||||
handler: async (r) => callController(AdminController.rejectReports)(r),
|
||||
},
|
||||
sendForgotPasswordEmail: {
|
||||
handler: async (r) =>
|
||||
callController(AdminController.sendForgotPasswordEmail)(r),
|
||||
},
|
||||
});
|
||||
20
backend/src/api/routes/ape-keys.ts
Normal file
20
backend/src/api/routes/ape-keys.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as ApeKeyController from "../controllers/ape-key";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(apeKeysContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(ApeKeyController.getApeKeys)(r),
|
||||
},
|
||||
add: {
|
||||
handler: async (r) => callController(ApeKeyController.generateApeKey)(r),
|
||||
},
|
||||
save: {
|
||||
handler: async (r) => callController(ApeKeyController.editApeKey)(r),
|
||||
},
|
||||
delete: {
|
||||
handler: async (r) => callController(ApeKeyController.deleteApeKey)(r),
|
||||
},
|
||||
});
|
||||
18
backend/src/api/routes/configs.ts
Normal file
18
backend/src/api/routes/configs.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { configsContract } from "@monkeytype/contracts/configs";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as ConfigController from "../controllers/config";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
|
||||
export default s.router(configsContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(ConfigController.getConfig)(r),
|
||||
},
|
||||
save: {
|
||||
handler: async (r) => callController(ConfigController.saveConfig)(r),
|
||||
},
|
||||
delete: {
|
||||
handler: async (r) => callController(ConfigController.deleteConfig)(r),
|
||||
},
|
||||
});
|
||||
20
backend/src/api/routes/configuration.ts
Normal file
20
backend/src/api/routes/configuration.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { configurationContract } from "@monkeytype/contracts/configuration";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as ConfigurationController from "../controllers/configuration";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
|
||||
export default s.router(configurationContract, {
|
||||
get: {
|
||||
handler: async (r) =>
|
||||
callController(ConfigurationController.getConfiguration)(r),
|
||||
},
|
||||
update: {
|
||||
handler: async (r) =>
|
||||
callController(ConfigurationController.updateConfiguration)(r),
|
||||
},
|
||||
getSchema: {
|
||||
handler: async (r) => callController(ConfigurationController.getSchema)(r),
|
||||
},
|
||||
});
|
||||
25
backend/src/api/routes/connections.ts
Normal file
25
backend/src/api/routes/connections.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { connectionsContract } from "@monkeytype/contracts/connections";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
import * as ConnectionsController from "../controllers/connections";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(connectionsContract, {
|
||||
get: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.getConnections)(r),
|
||||
},
|
||||
create: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.createConnection)(r),
|
||||
},
|
||||
delete: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.deleteConnection)(r),
|
||||
},
|
||||
update: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.updateConnection)(r),
|
||||
},
|
||||
});
|
||||
19
backend/src/api/routes/dev.ts
Normal file
19
backend/src/api/routes/dev.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { devContract } from "@monkeytype/contracts/dev";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
|
||||
import * as DevController from "../controllers/dev";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
import { onlyAvailableOnDev } from "../../middlewares/utility";
|
||||
|
||||
const s = initServer();
|
||||
|
||||
export default s.router(devContract, {
|
||||
generateData: {
|
||||
middleware: [onlyAvailableOnDev()],
|
||||
handler: async (r) => callController(DevController.createTestData)(r),
|
||||
},
|
||||
addDebugInboxItem: {
|
||||
middleware: [onlyAvailableOnDev()],
|
||||
handler: async (r) => callController(DevController.addDebugInboxItem)(r),
|
||||
},
|
||||
});
|
||||
34
backend/src/api/routes/docs.ts
Normal file
34
backend/src/api/routes/docs.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Response, Router } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const root = __dirname + "/../../../dist/static";
|
||||
|
||||
router.use("/internal", (req, res) => {
|
||||
setCsp(res);
|
||||
res.sendFile("api/internal.html", { root });
|
||||
});
|
||||
|
||||
router.use("/internal.json", (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.sendFile("api/openapi.json", { root });
|
||||
});
|
||||
|
||||
router.use(["/public", "/"], (req, res) => {
|
||||
setCsp(res);
|
||||
res.sendFile("api/public.html", { root });
|
||||
});
|
||||
|
||||
router.use("/public.json", (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.sendFile("api/public.json", { root });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
function setCsp(res: Response): void {
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' monkeytype.com cdn.redocly.com data:;object-src 'none';script-src 'self' cdn.redocly.com 'unsafe-inline'; worker-src blob: data;script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests",
|
||||
);
|
||||
}
|
||||
195
backend/src/api/routes/index.ts
Normal file
195
backend/src/api/routes/index.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { contract } from "@monkeytype/contracts/index";
|
||||
import psas from "./psas";
|
||||
import publicStats from "./public";
|
||||
import users from "./users";
|
||||
import { join } from "path";
|
||||
import quotes from "./quotes";
|
||||
import results from "./results";
|
||||
import presets from "./presets";
|
||||
import apeKeys from "./ape-keys";
|
||||
import admin from "./admin";
|
||||
import docs from "./docs";
|
||||
import webhooks from "./webhooks";
|
||||
import dev from "./dev";
|
||||
import configs from "./configs";
|
||||
import configuration from "./configuration";
|
||||
import { version } from "../../version";
|
||||
import leaderboards from "./leaderboards";
|
||||
import connections from "./connections";
|
||||
import addSwaggerMiddlewares from "./swagger";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import {
|
||||
Application,
|
||||
IRouter,
|
||||
NextFunction,
|
||||
Response,
|
||||
static as expressStatic,
|
||||
} from "express";
|
||||
import { isDevEnvironment } from "../../utils/misc";
|
||||
import { getLiveConfiguration } from "../../init/configuration";
|
||||
import Logger from "../../utils/logger";
|
||||
import { createExpressEndpoints, initServer } from "@ts-rest/express";
|
||||
import { ZodIssue } from "zod";
|
||||
import { MonkeyValidationError } from "@monkeytype/contracts/util/api";
|
||||
import { authenticateTsRestRequest } from "../../middlewares/auth";
|
||||
import { rateLimitRequest } from "../../middlewares/rate-limit";
|
||||
import { verifyPermissions } from "../../middlewares/permission";
|
||||
import { verifyRequiredConfiguration } from "../../middlewares/configuration";
|
||||
import { ExpressRequestWithContext } from "../types";
|
||||
|
||||
const pathOverride = process.env["API_PATH_OVERRIDE"];
|
||||
const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : "";
|
||||
const APP_START_TIME = Date.now();
|
||||
|
||||
const API_ROUTE_MAP = {
|
||||
"/docs": docs,
|
||||
};
|
||||
|
||||
const s = initServer();
|
||||
const router = s.router(contract, {
|
||||
admin,
|
||||
apeKeys,
|
||||
configs,
|
||||
presets,
|
||||
psas,
|
||||
public: publicStats,
|
||||
leaderboards,
|
||||
results,
|
||||
configuration,
|
||||
dev,
|
||||
users,
|
||||
quotes,
|
||||
webhooks,
|
||||
connections,
|
||||
});
|
||||
|
||||
export function addApiRoutes(app: Application): void {
|
||||
applyDevApiRoutes(app);
|
||||
applyApiRoutes(app);
|
||||
applyTsRestApiRoutes(app);
|
||||
|
||||
app.use((req, res) => {
|
||||
res
|
||||
.status(404)
|
||||
.json(
|
||||
new MonkeyResponse(
|
||||
`Unknown request URL (${req.method}: ${req.path})`,
|
||||
null,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function applyTsRestApiRoutes(app: IRouter): void {
|
||||
createExpressEndpoints(contract, router, app, {
|
||||
jsonQuery: true,
|
||||
requestValidationErrorHandler(err, req, res, _next) {
|
||||
let message: string | undefined = undefined;
|
||||
let validationErrors: string[] | undefined = undefined;
|
||||
|
||||
if (err.pathParams?.issues !== undefined) {
|
||||
message = "Invalid path parameter schema";
|
||||
validationErrors = err.pathParams.issues.map(prettyErrorMessage);
|
||||
} else if (err.query?.issues !== undefined) {
|
||||
message = "Invalid query schema";
|
||||
validationErrors = err.query.issues.map(prettyErrorMessage);
|
||||
} else if (err.body?.issues !== undefined) {
|
||||
message = "Invalid request data schema";
|
||||
validationErrors = err.body.issues.map(prettyErrorMessage);
|
||||
} else if (err.headers?.issues !== undefined) {
|
||||
message = "Invalid header schema";
|
||||
validationErrors = err.headers.issues.map(prettyErrorMessage);
|
||||
} else {
|
||||
Logger.error(
|
||||
`Unknown validation error for ${req.method} ${
|
||||
req.path
|
||||
}: ${JSON.stringify(err)}`,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json({ message: "Unknown validation error. Contact support." });
|
||||
return;
|
||||
}
|
||||
|
||||
res
|
||||
.status(422)
|
||||
.json({ message, validationErrors } as MonkeyValidationError);
|
||||
},
|
||||
globalMiddleware: [
|
||||
authenticateTsRestRequest(),
|
||||
rateLimitRequest(),
|
||||
verifyRequiredConfiguration(),
|
||||
verifyPermissions(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function prettyErrorMessage(issue: ZodIssue | undefined): string {
|
||||
if (issue === undefined) return "";
|
||||
const path = issue.path.length > 0 ? `"${issue.path.join(".")}" ` : "";
|
||||
return `${path}${issue.message}`;
|
||||
}
|
||||
|
||||
function applyDevApiRoutes(app: Application): void {
|
||||
if (isDevEnvironment()) {
|
||||
//disable csp to allow assets to load from unsecured http
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Content-Security-Policy", "");
|
||||
next();
|
||||
});
|
||||
app.use("/configure", expressStatic(join(__dirname, "../../../private")));
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
const slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs;
|
||||
if (slowdown > 0) {
|
||||
Logger.info(
|
||||
`Simulating ${slowdown}ms delay for ${req.method} ${req.path}`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, slowdown));
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyApiRoutes(app: Application): void {
|
||||
addSwaggerMiddlewares(app);
|
||||
|
||||
app.use(
|
||||
(
|
||||
req: ExpressRequestWithContext,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void => {
|
||||
if (req.path.startsWith("/configuration")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const inMaintenance =
|
||||
process.env["MAINTENANCE"] === "true" ||
|
||||
req.ctx.configuration.maintenance;
|
||||
|
||||
if (inMaintenance) {
|
||||
res.status(503).json({ message: "Server is down for maintenance" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.status(200).json(
|
||||
new MonkeyResponse("ok", {
|
||||
uptime: Date.now() - APP_START_TIME,
|
||||
version,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (const [route, mapRouter] of Object.entries(API_ROUTE_MAP)) {
|
||||
const apiRoute = `${BASE_ROUTE}${route}`;
|
||||
app.use(apiRoute, mapRouter);
|
||||
}
|
||||
}
|
||||
32
backend/src/api/routes/leaderboards.ts
Normal file
32
backend/src/api/routes/leaderboards.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as LeaderboardController from "../controllers/leaderboard";
|
||||
import { leaderboardsContract } from "@monkeytype/contracts/leaderboards";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(leaderboardsContract, {
|
||||
get: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getLeaderboard)(r),
|
||||
},
|
||||
getRank: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getRankFromLeaderboard)(r),
|
||||
},
|
||||
getDaily: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getDailyLeaderboard)(r),
|
||||
},
|
||||
getDailyRank: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getDailyLeaderboardRank)(r),
|
||||
},
|
||||
getWeeklyXp: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getWeeklyXpLeaderboard)(r),
|
||||
},
|
||||
getWeeklyXpRank: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r),
|
||||
},
|
||||
});
|
||||
20
backend/src/api/routes/presets.ts
Normal file
20
backend/src/api/routes/presets.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { presetsContract } from "@monkeytype/contracts/presets";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as PresetController from "../controllers/preset";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(presetsContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(PresetController.getPresets)(r),
|
||||
},
|
||||
add: {
|
||||
handler: async (r) => callController(PresetController.addPreset)(r),
|
||||
},
|
||||
save: {
|
||||
handler: async (r) => callController(PresetController.editPreset)(r),
|
||||
},
|
||||
delete: {
|
||||
handler: async (r) => callController(PresetController.removePreset)(r),
|
||||
},
|
||||
});
|
||||
13
backend/src/api/routes/psas.ts
Normal file
13
backend/src/api/routes/psas.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { psasContract } from "@monkeytype/contracts/psas";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as PsaController from "../controllers/psa";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
import { recordClientVersion } from "../../middlewares/utility";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(psasContract, {
|
||||
get: {
|
||||
middleware: [recordClientVersion()],
|
||||
handler: async (r) => callController(PsaController.getPsas)(r),
|
||||
},
|
||||
});
|
||||
14
backend/src/api/routes/public.ts
Normal file
14
backend/src/api/routes/public.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { publicContract } from "@monkeytype/contracts/public";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as PublicController from "../controllers/public";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(publicContract, {
|
||||
getSpeedHistogram: {
|
||||
handler: async (r) => callController(PublicController.getSpeedHistogram)(r),
|
||||
},
|
||||
getTypingStats: {
|
||||
handler: async (r) => callController(PublicController.getTypingStats)(r),
|
||||
},
|
||||
});
|
||||
33
backend/src/api/routes/quotes.ts
Normal file
33
backend/src/api/routes/quotes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { quotesContract } from "@monkeytype/contracts/quotes";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as QuoteController from "../controllers/quote";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(quotesContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(QuoteController.getQuotes)(r),
|
||||
},
|
||||
isSubmissionEnabled: {
|
||||
handler: async (r) =>
|
||||
callController(QuoteController.isSubmissionEnabled)(r),
|
||||
},
|
||||
add: {
|
||||
handler: async (r) => callController(QuoteController.addQuote)(r),
|
||||
},
|
||||
approveSubmission: {
|
||||
handler: async (r) => callController(QuoteController.approveQuote)(r),
|
||||
},
|
||||
rejectSubmission: {
|
||||
handler: async (r) => callController(QuoteController.refuseQuote)(r),
|
||||
},
|
||||
getRating: {
|
||||
handler: async (r) => callController(QuoteController.getRating)(r),
|
||||
},
|
||||
addRating: {
|
||||
handler: async (r) => callController(QuoteController.submitRating)(r),
|
||||
},
|
||||
report: {
|
||||
handler: async (r) => callController(QuoteController.reportQuote)(r),
|
||||
},
|
||||
});
|
||||
26
backend/src/api/routes/results.ts
Normal file
26
backend/src/api/routes/results.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { resultsContract } from "@monkeytype/contracts/results";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as ResultController from "../controllers/result";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(resultsContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(ResultController.getResults)(r),
|
||||
},
|
||||
getById: {
|
||||
handler: async (r) => callController(ResultController.getResultById)(r),
|
||||
},
|
||||
add: {
|
||||
handler: async (r) => callController(ResultController.addResult)(r),
|
||||
},
|
||||
updateTags: {
|
||||
handler: async (r) => callController(ResultController.updateTags)(r),
|
||||
},
|
||||
deleteAll: {
|
||||
handler: async (r) => callController(ResultController.deleteAll)(r),
|
||||
},
|
||||
getLast: {
|
||||
handler: async (r) => callController(ResultController.getLastResult)(r),
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user