From 5559405047b50cb06b9bf6d36d28f5499d6ff675 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 12 Sep 2025 16:14:22 +0200 Subject: [PATCH 01/10] refactor: remove lodash omit from backend (@fehmer) --- backend/__tests__/utils/misc.spec.ts | 91 +++++++++++++++++----- backend/src/api/controllers/ape-key.ts | 9 ++- backend/src/api/controllers/leaderboard.ts | 4 +- backend/src/api/controllers/user.ts | 8 +- backend/src/dal/leaderboards.ts | 3 +- backend/src/dal/user.ts | 2 + backend/src/init/configuration.ts | 8 +- backend/src/utils/misc.ts | 16 +++- 8 files changed, 103 insertions(+), 38 deletions(-) diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index 5e03db98b6f0..ea467dd27e96 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterAll, vi } from "vitest"; import _ from "lodash"; -import * as misc from "../../src/utils/misc"; +import * as Misc from "../../src/utils/misc"; import { ObjectId } from "mongodb"; describe("Misc Utils", () => { @@ -32,7 +32,7 @@ describe("Misc Utils", () => { _.each(testCases, (testCase, pattern) => { const { cases, expected } = testCase; _.each(cases, (caseValue, index) => { - expect(misc.matchesAPattern(caseValue, pattern)).toBe(expected[index]); + expect(Misc.matchesAPattern(caseValue, pattern)).toBe(expected[index]); }); }); }); @@ -80,7 +80,7 @@ describe("Misc Utils", () => { ]; _.each(testCases, ({ wpm, acc, timestamp, expectedScore }) => { - expect(misc.kogascore(wpm, acc, timestamp)).toBe(expectedScore); + expect(Misc.kogascore(wpm, acc, timestamp)).toBe(expectedScore); }); }); @@ -109,7 +109,7 @@ describe("Misc Utils", () => { ]; _.each(testCases, ({ input, expected }) => { - expect(misc.identity(input)).toEqual(expected); + expect(Misc.identity(input)).toEqual(expected); }); }); @@ -178,7 +178,7 @@ describe("Misc Utils", () => { ]; _.each(testCases, ({ obj, expected }) => { - expect(misc.flattenObjectDeep(obj)).toEqual(expected); + expect(Misc.flattenObjectDeep(obj)).toEqual(expected); }); }); @@ -215,7 +215,7 @@ describe("Misc Utils", () => { ]; testCases.forEach(({ input, expected }) => { - expect(misc.sanitizeString(input)).toEqual(expected); + expect(Misc.sanitizeString(input)).toEqual(expected); }); }); @@ -284,7 +284,7 @@ describe("Misc Utils", () => { ]; testCases.forEach(({ input, output }) => { - expect(misc.getOrdinalNumberString(input)).toEqual(output); + expect(Misc.getOrdinalNumberString(input)).toEqual(output); }); }); it("formatSeconds", () => { @@ -298,45 +298,45 @@ describe("Misc Utils", () => { expected: "1.08 minutes", }, { - seconds: misc.HOUR_IN_SECONDS, + seconds: Misc.HOUR_IN_SECONDS, expected: "1 hour", }, { - seconds: misc.DAY_IN_SECONDS, + seconds: Misc.DAY_IN_SECONDS, expected: "1 day", }, { - seconds: misc.WEEK_IN_SECONDS, + seconds: Misc.WEEK_IN_SECONDS, expected: "1 week", }, { - seconds: misc.YEAR_IN_SECONDS, + seconds: Misc.YEAR_IN_SECONDS, expected: "1 year", }, { - seconds: 2 * misc.YEAR_IN_SECONDS, + seconds: 2 * Misc.YEAR_IN_SECONDS, expected: "2 years", }, { - seconds: 4 * misc.YEAR_IN_SECONDS, + seconds: 4 * Misc.YEAR_IN_SECONDS, expected: "4 years", }, { - seconds: 3 * misc.WEEK_IN_SECONDS, + seconds: 3 * Misc.WEEK_IN_SECONDS, expected: "3 weeks", }, { - seconds: misc.MONTH_IN_SECONDS * 4, + seconds: Misc.MONTH_IN_SECONDS * 4, expected: "4 months", }, { - seconds: misc.MONTH_IN_SECONDS * 11, + seconds: Misc.MONTH_IN_SECONDS * 11, expected: "11 months", }, ]; testCases.forEach(({ seconds, expected }) => { - expect(misc.formatSeconds(seconds)).toBe(expected); + expect(Misc.formatSeconds(seconds)).toBe(expected); }); }); @@ -347,14 +347,14 @@ describe("Misc Utils", () => { test: "test", number: 1, }; - expect(misc.replaceObjectId(fromDatabase)).toStrictEqual({ + expect(Misc.replaceObjectId(fromDatabase)).toStrictEqual({ _id: fromDatabase._id.toHexString(), test: "test", number: 1, }); }); it("ignores null values", () => { - expect(misc.replaceObjectId(null)).toBeNull(); + expect(Misc.replaceObjectId(null)).toBeNull(); }); }); @@ -371,7 +371,7 @@ describe("Misc Utils", () => { number: 2, }; expect( - misc.replaceObjectIds([fromDatabase, fromDatabase2]) + Misc.replaceObjectIds([fromDatabase, fromDatabase2]) ).toStrictEqual([ { _id: fromDatabase._id.toHexString(), @@ -386,7 +386,56 @@ describe("Misc Utils", () => { ]); }); it("handles undefined", () => { - expect(misc.replaceObjectIds(undefined as any)).toBeUndefined(); + 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 }, + }); }); }); }); diff --git a/backend/src/api/controllers/ape-key.ts b/backend/src/api/controllers/ape-key.ts index 828d7c3a0455..6baa1f6e4284 100644 --- a/backend/src/api/controllers/ape-key.ts +++ b/backend/src/api/controllers/ape-key.ts @@ -1,10 +1,9 @@ -import _ from "lodash"; 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 } from "../../utils/misc"; +import { base64UrlEncode, omit } from "../../utils/misc"; import { ObjectId } from "mongodb"; import { @@ -18,7 +17,7 @@ import { ApeKey } from "@monkeytype/schemas/ape-keys"; import { MonkeyRequest } from "../types"; function cleanApeKey(apeKey: ApeKeysDAL.DBApeKey): ApeKey { - return _.omit(apeKey, "hash", "_id", "uid", "useCount"); + return omit(apeKey, "hash", "_id", "uid", "useCount") as ApeKey; } export async function getApeKeys( @@ -27,7 +26,9 @@ export async function getApeKeys( const { uid } = req.ctx.decodedToken; const apeKeys = await ApeKeysDAL.getApeKeys(uid); - const cleanedKeys = _(apeKeys).keyBy("_id").mapValues(cleanApeKey).value(); + const cleanedKeys: Record = Object.fromEntries( + apeKeys.map((item) => [String(item._id), cleanApeKey(item)]) + ); return new MonkeyResponse("ApeKeys retrieved", cleanedKeys); } diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index e754e38babf2..12c4e620139a 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as LeaderboardsDAL from "../../dal/leaderboards"; import * as ConnectionsDal from "../../dal/connections"; @@ -27,6 +26,7 @@ import { MILLISECONDS_IN_DAY, } from "@monkeytype/util/date-and-time"; import { MonkeyRequest } from "../types"; +import { omit } from "../../utils/misc"; export async function getLeaderboard( req: MonkeyRequest @@ -68,7 +68,7 @@ export async function getLeaderboard( language, friendsOnlyUid ); - const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"])); + const normalizedLeaderboard = leaderboard.map((it) => omit(it, "_id")); return new MonkeyResponse("Leaderboard retrieved", { count, diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 93b7b5f188a0..22177c3a5f8f 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -9,6 +9,7 @@ import * as DiscordUtils from "../../utils/discord"; import { buildAgentLog, getFrontendUrl, + omit, replaceObjectId, replaceObjectIds, sanitizeString, @@ -516,7 +517,8 @@ type RelevantUserInfo = Omit< >; function getRelevantUserInfo(user: UserDAL.DBUser): RelevantUserInfo { - return _.omit(user, [ + return omit( + user, "bananas", "lbPersonalBests", "inbox", @@ -527,8 +529,8 @@ function getRelevantUserInfo(user: UserDAL.DBUser): RelevantUserInfo { "note", "ips", "testActivity", - "suspicious", - ]) as RelevantUserInfo; + "suspicious" + ) as RelevantUserInfo; } export async function getUser(req: MonkeyRequest): Promise { diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 573d6c9308eb..054774cff089 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -2,7 +2,7 @@ import * as db from "../init/db"; import Logger from "../utils/logger"; import { performance } from "perf_hooks"; import { setLeaderboard } from "../utils/prometheus"; -import { isDevEnvironment } from "../utils/misc"; +import { isDevEnvironment, omit } from "../utils/misc"; import { getCachedConfiguration, getLiveConfiguration, @@ -11,7 +11,6 @@ import { import { addLog } from "./logs"; import { Collection, Document, ObjectId } from "mongodb"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; -import { omit } from "lodash"; import { DBUser, getUsersCollection } from "./user"; import MonkeyError from "../utils/error"; import { aggregateWithAcceptedConnections } from "./connections"; diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index a96bdc67a90e..4211ace520bc 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -56,11 +56,13 @@ export type DBUser = Omit< inbox?: MonkeyMail[]; ips?: string[]; canReport?: boolean; + nameHistory?: string[]; lastNameChange?: number; canManageApeKeys?: boolean; bananas?: number; testActivity?: CountByYearAndDay; suspicious?: boolean; + note?: string; }; const SECONDS_PER_HOUR = 3600; diff --git a/backend/src/init/configuration.ts b/backend/src/init/configuration.ts index 6fc5e218c861..521f45977658 100644 --- a/backend/src/init/configuration.ts +++ b/backend/src/init/configuration.ts @@ -2,7 +2,7 @@ import _ from "lodash"; import * as db from "./db"; import { ObjectId } from "mongodb"; import Logger from "../utils/logger"; -import { identity } from "../utils/misc"; +import { identity, omit } from "../utils/misc"; import { BASE_CONFIGURATION } from "../constants/base-configuration"; import { Configuration } from "@monkeytype/schemas/configuration"; import { addLog } from "../dal/logs"; @@ -81,9 +81,9 @@ export async function getLiveConfiguration(): Promise { const liveConfiguration = await configurationCollection.findOne(); if (liveConfiguration) { - const baseConfiguration = _.cloneDeep(BASE_CONFIGURATION); + const baseConfiguration = structuredClone(BASE_CONFIGURATION); - const liveConfigurationWithoutId = _.omit( + const liveConfigurationWithoutId = omit( liveConfiguration, "_id" ) as Configuration; @@ -129,7 +129,7 @@ export async function patchConfiguration( configurationUpdates: PartialConfiguration ): Promise { try { - const currentConfiguration = _.cloneDeep(configuration); + const currentConfiguration = structuredClone(configuration); mergeConfigurations(currentConfiguration, configurationUpdates); await db diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index 44101d1c6a10..692bcf1b7a38 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -1,6 +1,6 @@ import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; import { roundTo2 } from "@monkeytype/util/numbers"; -import _, { omit } from "lodash"; +import _ from "lodash"; import uaparser from "ua-parser-js"; import { MonkeyRequest } from "../api/types"; import { ObjectId } from "mongodb"; @@ -220,8 +220,8 @@ export function replaceObjectId( return null; } const result = { + ...data, _id: data._id.toString(), - ...omit(data, "_id"), } as T & { _id: string }; return result; } @@ -240,3 +240,15 @@ export function replaceObjectIds( export type WithObjectId = Omit & { _id: ObjectId; }; + +export function omit( + obj: T, + ...keys: K[] +): Omit { + const result = { ...obj }; + for (const key of keys) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete result[key]; + } + return result; +} From c737b303c7610be3af2464b0a47090c0b0e5790d Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 22 Sep 2025 16:40:49 +0530 Subject: [PATCH 02/10] wip --- .../dal/leaderboards.isolated.spec.ts | 19 +++--- .../__integration__/dal/preset.spec.ts | 1 - .../__integration__/dal/user.spec.ts | 12 ++-- .../__tests__/api/controllers/admin.spec.ts | 7 +- .../__tests__/api/controllers/ape-key.spec.ts | 10 +-- .../api/controllers/leaderboard.spec.ts | 22 ++++--- .../__tests__/api/controllers/quotes.spec.ts | 16 +++-- .../__tests__/api/controllers/result.spec.ts | 24 +++---- .../__tests__/api/controllers/user.spec.ts | 64 ++++++++----------- backend/__tests__/utils/pb.spec.ts | 4 +- backend/src/api/controllers/ape-key.ts | 2 +- backend/src/api/controllers/result.ts | 3 +- backend/src/api/routes/index.ts | 5 +- backend/src/constants/monkey-status-codes.ts | 6 +- backend/src/dal/preset.ts | 3 +- backend/src/middlewares/permission.ts | 1 - backend/src/middlewares/rate-limit.ts | 1 - backend/src/middlewares/utility.ts | 1 - backend/src/services/weekly-xp-leaderboard.ts | 2 +- backend/src/utils/daily-leaderboards.ts | 3 +- backend/src/utils/validation.ts | 1 - backend/src/workers/email-worker.ts | 1 - 22 files changed, 97 insertions(+), 111 deletions(-) diff --git a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts index 5b37a0877755..8db78956bb70 100644 --- a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts +++ b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts @@ -1,5 +1,4 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import _ from "lodash"; +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"; @@ -12,6 +11,7 @@ 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 () => { @@ -60,7 +60,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => omit(it, "_id")); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: rank1 }), @@ -87,7 +87,8 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + + const lb = results.map((it) => omit(it, "_id")); expect(lb).toEqual([ expectedLbEntry("60", { rank: 1, user: rank1 }), @@ -201,7 +202,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => _.omit(it, ["_id"])); + const lb = result.map((it) => omit(it, "_id")); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: noBadge }), @@ -240,7 +241,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => _.omit(it, ["_id"])); + const lb = result.map((it) => omit(it, "_id")); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: noPremium }), @@ -298,7 +299,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => omit(it, "_id")); expect(lb).toEqual([ expectedLbEntry("60", { rank: 3, user: rank3 }), @@ -337,7 +338,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => omit(it, "_id")); expect(lb).toEqual([ expectedLbEntry("60", { rank: 1, user: rank1, friendsRank: 1 }), @@ -376,7 +377,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => omit(it, "_id")); expect(lb).toEqual([ expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 3 }), diff --git a/backend/__tests__/__integration__/dal/preset.spec.ts b/backend/__tests__/__integration__/dal/preset.spec.ts index 3b087b8886cd..74ecedce8354 100644 --- a/backend/__tests__/__integration__/dal/preset.spec.ts +++ b/backend/__tests__/__integration__/dal/preset.spec.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { ObjectId } from "mongodb"; import * as PresetDal from "../../../src/dal/preset"; -import _ from "lodash"; describe("PresetDal", () => { describe("readPreset", () => { diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index c6f84fa85333..f5d264d0f77c 100644 --- a/backend/__tests__/__integration__/dal/user.spec.ts +++ b/backend/__tests__/__integration__/dal/user.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import _ from "lodash"; + import * as UserDAL from "../../../src/dal/user"; import * as UserTestData from "../../__testData__/users"; import { createConnection as createFriend } from "../../__testData__/connections"; @@ -236,9 +236,13 @@ describe("UserDal", () => { // then const updatedUser = (await UserDAL.getUser(testUser.uid, "test")) ?? {}; - expect(_.values(updatedUser.personalBests).filter(_.isEmpty)).toHaveLength( - 5 - ); + expect(updatedUser.personalBests).toStrictEqual({ + time: {}, + words: {}, + quote: {}, + custom: {}, + zen: {}, + }); }); it("autoBan should automatically ban after configured anticheat triggers", async () => { diff --git a/backend/__tests__/api/controllers/admin.spec.ts b/backend/__tests__/api/controllers/admin.spec.ts index 2db8391c6458..b1b04c3dd39b 100644 --- a/backend/__tests__/api/controllers/admin.spec.ts +++ b/backend/__tests__/api/controllers/admin.spec.ts @@ -8,7 +8,7 @@ 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 _ from "lodash"; + import { enableRateLimitExpects } from "../../__testData__/rate-limit"; const { mockApp, uid } = setup(); @@ -570,9 +570,8 @@ describe("AdminController", () => { } }); async function enableAdminEndpoints(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - admin: { endpointsEnabled: enabled }, - }); + const mockConfig = await configuration; + mockConfig.admin = { ...mockConfig.admin, endpointsEnabled: enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/ape-key.spec.ts b/backend/__tests__/api/controllers/ape-key.spec.ts index 60aff1724e32..330ae4832576 100644 --- a/backend/__tests__/api/controllers/ape-key.spec.ts +++ b/backend/__tests__/api/controllers/ape-key.spec.ts @@ -5,7 +5,6 @@ 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"; -import _ from "lodash"; const { mockApp, uid } = setup(); const configuration = Configuration.getCachedConfiguration(); @@ -354,9 +353,12 @@ function apeKeyDb( } async function enableApeKeysEndpoints(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - apeKeys: { endpointsEnabled: enabled, maxKeysPerUser: 1 }, - }); + const mockConfig = await configuration; + mockConfig.apeKeys = { + ...mockConfig.apeKeys, + endpointsEnabled: enabled, + maxKeysPerUser: 1, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index 12fd86ea25ba..54a2f56a8513 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { setup } from "../../__testData__/controller-test"; -import _ from "lodash"; import { ObjectId } from "mongodb"; import * as LeaderboardDal from "../../../src/dal/leaderboards"; import * as ConnectionsDal from "../../../src/dal/connections"; @@ -1422,9 +1421,8 @@ describe("Loaderboard Controller", () => { }); async function acceptApeKeys(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - apeKeys: { acceptKeys: enabled }, - }); + const mockConfig = await configuration; + mockConfig.apeKeys = { ...mockConfig.apeKeys, acceptKeys: enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -1432,18 +1430,22 @@ async function acceptApeKeys(enabled: boolean): Promise { } async function dailyLeaderboardEnabled(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - dailyLeaderboards: { enabled: enabled }, - }); + const mockConfig = await configuration; + mockConfig.dailyLeaderboards = { + ...mockConfig.dailyLeaderboards, + enabled: enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } async function weeklyLeaderboardEnabled(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - leaderboards: { weeklyXp: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.leaderboards.weeklyXp = { + ...mockConfig.leaderboards.weeklyXp, + enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/quotes.spec.ts b/backend/__tests__/api/controllers/quotes.spec.ts index e2663ebdd334..1f9d6c0ebff0 100644 --- a/backend/__tests__/api/controllers/quotes.spec.ts +++ b/backend/__tests__/api/controllers/quotes.spec.ts @@ -9,7 +9,6 @@ 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 _ from "lodash"; import { ApproveQuote } from "@monkeytype/schemas/quotes"; const { mockApp, uid } = setup(); @@ -874,9 +873,8 @@ describe("QuotesController", () => { }); async function enableQuotes(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - quotes: { submissionsEnabled: enabled }, - }); + const mockConfig = await configuration; + mockConfig.quotes = { ...mockConfig.quotes, submissionsEnabled: enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -884,9 +882,13 @@ async function enableQuotes(enabled: boolean): Promise { } async function enableQuoteReporting(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - quotes: { reporting: { enabled, maxReports: 10, contentReportLimit: 20 } }, - }); + const mockConfig = await configuration; + mockConfig.quotes.reporting = { + ...mockConfig.quotes.reporting, + enabled, + maxReports: 10, + contentReportLimit: 20, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index 758fc6c9ce7d..ed0558508f8f 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { setup } from "../../__testData__/controller-test"; -import _, { omit } from "lodash"; import * as Configuration from "../../../src/init/configuration"; import * as ResultDal from "../../../src/dal/result"; import * as UserDal from "../../../src/dal/user"; @@ -10,6 +9,7 @@ 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"; const { mockApp, uid, mockAuth } = setup(); const configuration = Configuration.getCachedConfiguration(); @@ -824,10 +824,9 @@ describe("result controller test", () => { }); }); -async function enablePremiumFeatures(premium: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { premium: { enabled: premium } }, - }); +async function enablePremiumFeatures(enabled: boolean): Promise { + const mockConfig = await configuration; + mockConfig.users.premium = { ...mockConfig.users.premium, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -857,7 +856,7 @@ function givenDbResult(uid: string, customize?: Partial): DBResult { isPb: true, chartData: { wpm: [Math.random() * 100], - raw: [Math.random() * 100], + burst: [Math.random() * 100], err: [Math.random() * 100], }, name: "testName", @@ -866,9 +865,11 @@ function givenDbResult(uid: string, customize?: Partial): DBResult { } async function acceptApeKeys(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - apeKeys: { acceptKeys: enabled }, - }); + const mockConfig = await configuration; + mockConfig.apeKeys = { + ...mockConfig.apeKeys, + acceptKeys: enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -876,9 +877,8 @@ async function acceptApeKeys(enabled: boolean): Promise { } async function enableResultsSaving(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - results: { savingEnabled: enabled }, - }); + const mockConfig = await configuration; + mockConfig.results = { ...mockConfig.results, savingEnabled: enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 61bd8a31aa22..2ac5eb546724 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -31,7 +31,6 @@ import { ObjectId } from "mongodb"; import { PersonalBest } from "@monkeytype/schemas/shared"; import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; import { randomUUID } from "node:crypto"; -import _ from "lodash"; import { MonkeyMail, UserStreak } from "@monkeytype/schemas/users"; import MonkeyError, { isFirebaseError } from "../../../src/utils/error"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; @@ -3965,31 +3964,18 @@ function fillYearWithDay(days: number): number[] { return result; } -async function enablePremiumFeatures(premium: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { premium: { enabled: premium } }, - }); - - vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( - mockConfig - ); -} - -// eslint-disable-next-line no-unused-vars -async function enableAdminFeatures(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - admin: { endpointsEnabled: enabled }, - }); +async function enablePremiumFeatures(enabled: boolean): Promise { + const mockConfig = await configuration; + mockConfig.users.premium = { ...mockConfig.users.premium, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } -async function enableSignup(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { signUp: enabled }, - }); +async function enableSignup(signUp: boolean): Promise { + const mockConfig = await configuration; + mockConfig.users = { ...mockConfig.users, signUp }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -3997,9 +3983,11 @@ async function enableSignup(enabled: boolean): Promise { } async function enableDiscordIntegration(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { discordIntegration: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.users.discordIntegration = { + ...mockConfig.users.discordIntegration, + enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -4007,19 +3995,20 @@ async function enableDiscordIntegration(enabled: boolean): Promise { } async function enableResultFilterPresets(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - results: { filterPresets: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.results.filterPresets = { + ...mockConfig.results.filterPresets, + enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } -async function acceptApeKeys(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - apeKeys: { acceptKeys: enabled }, - }); +async function acceptApeKeys(acceptKeys: boolean): Promise { + const mockConfig = await configuration; + mockConfig.apeKeys = { ...mockConfig.apeKeys, acceptKeys }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -4027,18 +4016,16 @@ async function acceptApeKeys(enabled: boolean): Promise { } async function enableProfiles(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { profiles: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.users.profiles = { ...mockConfig.users.profiles, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } async function enableInbox(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { inbox: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.users.inbox = { ...mockConfig.users.inbox, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -4046,9 +4033,8 @@ async function enableInbox(enabled: boolean): Promise { } async function enableReporting(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - quotes: { reporting: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.quotes.reporting = { ...mockConfig.quotes.reporting, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/utils/pb.spec.ts b/backend/__tests__/utils/pb.spec.ts index 3d969e4e1879..92b31ded295c 100644 --- a/backend/__tests__/utils/pb.spec.ts +++ b/backend/__tests__/utils/pb.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from "vitest"; -import _ from "lodash"; 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"; +import _ from "lodash"; describe("Pb Utils", () => { it("funboxCatGetPb", () => { @@ -175,7 +175,7 @@ describe("Pb Utils", () => { for (const lbPb of lbpbstartingvalues) { const lbPbPb = pb.updateLeaderboardPersonalBests( userPbs, - _.cloneDeep(lbPb) as pb.LbPersonalBests, + structuredClone(lbPb) as pb.LbPersonalBests, result15 ); diff --git a/backend/src/api/controllers/ape-key.ts b/backend/src/api/controllers/ape-key.ts index 6baa1f6e4284..c6e39595ad7b 100644 --- a/backend/src/api/controllers/ape-key.ts +++ b/backend/src/api/controllers/ape-key.ts @@ -27,7 +27,7 @@ export async function getApeKeys( const apeKeys = await ApeKeysDAL.getApeKeys(uid); const cleanedKeys: Record = Object.fromEntries( - apeKeys.map((item) => [String(item._id), cleanApeKey(item)]) + apeKeys.map((item) => [item._id.toHexString(), cleanApeKey(item)]) ); return new MonkeyResponse("ApeKeys retrieved", cleanedKeys); diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index e46760dd80c4..1c924a848c24 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -2,6 +2,7 @@ import * as ResultDAL from "../../dal/result"; import * as PublicDAL from "../../dal/public"; import { isDevEnvironment, + omit, replaceObjectId, replaceObjectIds, } from "../../utils/misc"; @@ -26,7 +27,7 @@ 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 _, { omit } from "lodash"; +import _ from "lodash"; import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; import { UAParser } from "ua-parser-js"; import { canFunboxGetPb } from "../../utils/pb"; diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index aed51685ec7d..f7ef62e3b6b3 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -24,7 +24,6 @@ import { IRouter, NextFunction, Response, - Router, static as expressStatic, } from "express"; import { isDevEnvironment } from "../../utils/misc"; @@ -190,8 +189,8 @@ function applyApiRoutes(app: Application): void { ); }); - _.each(API_ROUTE_MAP, (router: Router, route) => { + for (const [route, router] of Object.entries(API_ROUTE_MAP)) { const apiRoute = `${BASE_ROUTE}${route}`; app.use(apiRoute, router); - }); + } } diff --git a/backend/src/constants/monkey-status-codes.ts b/backend/src/constants/monkey-status-codes.ts index b8ae0952c077..4d24e8b4d6fb 100644 --- a/backend/src/constants/monkey-status-codes.ts +++ b/backend/src/constants/monkey-status-codes.ts @@ -1,5 +1,3 @@ -import _ from "lodash"; - type Status = { code: number; message: string; @@ -71,8 +69,8 @@ const statuses: Statuses = { }, }; -const CUSTOM_STATUS_CODES = new Set( - _.map(statuses, (status: Status) => status.code) +const CUSTOM_STATUS_CODES = new Set( + Object.values(statuses).map((status) => status.code) ); export function isCustomCode(code: number): boolean { diff --git a/backend/src/dal/preset.ts b/backend/src/dal/preset.ts index aae2d3c42dfb..a08bfd28ed29 100644 --- a/backend/src/dal/preset.ts +++ b/backend/src/dal/preset.ts @@ -2,8 +2,7 @@ import MonkeyError from "../utils/error"; import * as db from "../init/db"; import { ObjectId, type Filter, Collection, type WithId } from "mongodb"; import { EditPresetRequest, Preset } from "@monkeytype/schemas/presets"; -import { omit } from "lodash"; -import { WithObjectId } from "../utils/misc"; +import { WithObjectId, omit } from "../utils/misc"; const MAX_PRESETS = 10; diff --git a/backend/src/middlewares/permission.ts b/backend/src/middlewares/permission.ts index 4fdcdaddc42b..59787afcd56d 100644 --- a/backend/src/middlewares/permission.ts +++ b/backend/src/middlewares/permission.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import MonkeyError from "../utils/error"; import type { Response, NextFunction } from "express"; import { DBUser, getPartialUser } from "../dal/user"; diff --git a/backend/src/middlewares/rate-limit.ts b/backend/src/middlewares/rate-limit.ts index 14340ee20780..77e04905bd9a 100644 --- a/backend/src/middlewares/rate-limit.ts +++ b/backend/src/middlewares/rate-limit.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import MonkeyError from "../utils/error"; import type { Response, NextFunction, Request } from "express"; import { RateLimiterMemory } from "rate-limiter-flexible"; diff --git a/backend/src/middlewares/utility.ts b/backend/src/middlewares/utility.ts index 8964f9de5d32..26c6577a2eb0 100644 --- a/backend/src/middlewares/utility.ts +++ b/backend/src/middlewares/utility.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import type { Request, Response, NextFunction, RequestHandler } from "express"; import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus"; import { isDevEnvironment } from "../utils/misc"; diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index f93dd568a871..9e090dfd9e1e 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -9,8 +9,8 @@ import { } from "@monkeytype/schemas/leaderboards"; import { getCurrentWeekTimestamp } from "@monkeytype/util/date-and-time"; import MonkeyError from "../utils/error"; -import { omit } from "lodash"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; +import { omit } from "../utils/misc"; export type AddResultOpts = { entry: RedisXpLeaderboardEntry; diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index 9b3289a6c754..09183d9411c3 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -1,7 +1,6 @@ -import _, { omit } from "lodash"; import * as RedisClient from "../init/redis"; import LaterQueue from "../queues/later-queue"; -import { matchesAPattern, kogascore } from "./misc"; +import { matchesAPattern, kogascore, omit } from "./misc"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; import { Configuration, diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 66bc2b4a52aa..eb5bd94e434a 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { CompletedEvent } from "@monkeytype/schemas/results"; export function isTestTooShort(result: CompletedEvent): boolean { diff --git a/backend/src/workers/email-worker.ts b/backend/src/workers/email-worker.ts index a4f7362d75bf..59a8daccccf2 100644 --- a/backend/src/workers/email-worker.ts +++ b/backend/src/workers/email-worker.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import IORedis from "ioredis"; import { Worker, Job, type ConnectionOptions } from "bullmq"; import Logger from "../utils/logger"; From 06a99b91c0a908f7b0df16e5df96c8f7e1889020 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 22 Sep 2025 16:58:36 +0530 Subject: [PATCH 03/10] tests --- backend/__tests__/utils/misc.spec.ts | 69 +++++++++++++++++----------- backend/__tests__/utils/pb.spec.ts | 20 ++++---- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index ea467dd27e96..66ee797d7b81 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -1,5 +1,4 @@ import { describe, it, expect, afterAll, vi } from "vitest"; -import _ from "lodash"; import * as Misc from "../../src/utils/misc"; import { ObjectId } from "mongodb"; @@ -8,36 +7,44 @@ describe("Misc Utils", () => { vi.useRealTimers(); }); - it("matchesAPattern", () => { - const testCases = { - "eng.*": { + describe("matchesAPattern", () => { + const testCases = [ + { + pattern: "eng.*", cases: ["english", "aenglish", "en", "eng"], expected: [true, false, false, true], }, - "\\d+": { + { + pattern: "\\d+", cases: ["b", "2", "331", "1a"], expected: [false, true, true, false], }, - "(hi|hello)": { + { + pattern: "(hi|hello)", cases: ["hello", "hi", "hillo", "hi hello"], expected: [true, true, false, false], }, - ".+": { + { + pattern: ".+", cases: ["a2", "b2", "c1", ""], expected: [true, true, true, false], }, - }; + ]; - _.each(testCases, (testCase, pattern) => { - const { cases, expected } = testCase; - _.each(cases, (caseValue, index) => { - expect(Misc.matchesAPattern(caseValue, pattern)).toBe(expected[index]); - }); - }); + it.each(testCases)( + "matchesAPattern with $pattern", + ({ pattern, cases, expected }) => { + cases.forEach((caseValue, index) => { + expect(Misc.matchesAPattern(caseValue, pattern)).toBe( + expected[index] + ); + }); + } + ); }); - it("kogascore", () => { + describe("kogascore", () => { const testCases = [ { wpm: 214.8, @@ -79,12 +86,15 @@ describe("Misc Utils", () => { }, ]; - _.each(testCases, ({ wpm, acc, timestamp, expectedScore }) => { - expect(Misc.kogascore(wpm, acc, timestamp)).toBe(expectedScore); - }); + it.each(testCases)( + "kogascore with wpm:$wpm, acc:$acc, timestamp:$timestamp = $expectedScore", + ({ wpm, acc, timestamp, expectedScore }) => { + expect(Misc.kogascore(wpm, acc, timestamp)).toBe(expectedScore); + } + ); }); - it("identity", () => { + describe("identity", () => { const testCases = [ { input: "", @@ -107,13 +117,15 @@ describe("Misc Utils", () => { expected: "undefined", }, ]; - - _.each(testCases, ({ input, expected }) => { - expect(Misc.identity(input)).toEqual(expected); - }); + it.each(testCases)( + "identity with $input = $expected", + ({ input, expected }) => { + expect(Misc.identity(input)).toBe(expected); + } + ); }); - it("flattenObjectDeep", () => { + describe("flattenObjectDeep", () => { const testCases = [ { obj: { @@ -177,9 +189,12 @@ describe("Misc Utils", () => { }, ]; - _.each(testCases, ({ obj, expected }) => { - expect(Misc.flattenObjectDeep(obj)).toEqual(expected); - }); + it.each(testCases)( + "flattenObjectDeep with $obj = $expected", + ({ obj, expected }) => { + expect(Misc.flattenObjectDeep(obj)).toEqual(expected); + } + ); }); it("sanitizeString", () => { diff --git a/backend/__tests__/utils/pb.spec.ts b/backend/__tests__/utils/pb.spec.ts index 92b31ded295c..b8400368d465 100644 --- a/backend/__tests__/utils/pb.spec.ts +++ b/backend/__tests__/utils/pb.spec.ts @@ -3,10 +3,9 @@ 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"; -import _ from "lodash"; describe("Pb Utils", () => { - it("funboxCatGetPb", () => { + describe("funboxCatGetPb", () => { const testCases: { funbox: FunboxName[] | undefined; expected: boolean }[] = [ { @@ -31,16 +30,15 @@ describe("Pb Utils", () => { }, ]; - _.each(testCases, (testCase) => { - const { funbox, expected } = testCase; - //@ts-ignore ignore because this expects a whole result object - const result = pb.canFunboxGetPb({ - funbox, - }); - - expect(result).toBe(expected); - }); + 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 = { From c99cbb041a37f28519cb2f594b42c7ac7a477028 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 22 Sep 2025 17:00:14 +0530 Subject: [PATCH 04/10] trigger From 4474ff0882b843019e47fa33bfb15c8e14b4afd6 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 22 Sep 2025 18:29:26 +0530 Subject: [PATCH 05/10] remove lodash from routes, quote, result, user, apekeys --- .../__integration__/dal/ape-keys.spec.ts | 120 ++++++++++++-- .../__tests__/api/controllers/result.spec.ts | 156 +++++++++--------- .../__tests__/api/controllers/user.spec.ts | 2 +- backend/src/api/controllers/quote.ts | 6 +- backend/src/api/controllers/result.ts | 10 +- backend/src/api/controllers/user.ts | 22 ++- backend/src/api/routes/index.ts | 1 - backend/src/dal/ape-keys.ts | 9 +- 8 files changed, 213 insertions(+), 113 deletions(-) diff --git a/backend/__tests__/__integration__/dal/ape-keys.spec.ts b/backend/__tests__/__integration__/dal/ape-keys.spec.ts index 153a857abe42..f2506cc8afd2 100644 --- a/backend/__tests__/__integration__/dal/ape-keys.spec.ts +++ b/backend/__tests__/__integration__/dal/ape-keys.spec.ts @@ -1,23 +1,107 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { ObjectId } from "mongodb"; -import { addApeKey } from "../../../src/dal/ape-keys"; +import { + addApeKey, + DBApeKey, + editApeKey, + getApeKey, + updateLastUsedOn, +} from "../../../src/dal/ape-keys"; describe("ApeKeysDal", () => { - it("should be able to add a new ape key", async () => { - const apeKey = { - _id: new ObjectId(), - uid: "123", - name: "test", - hash: "12345", - createdOn: Date.now(), - modifiedOn: Date.now(), - lastUsedOn: Date.now(), - useCount: 0, - enabled: true, - }; - - const apeKeyId = await addApeKey(apeKey); - - expect(apeKeyId).toBe(apeKey._id.toHexString()); + 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 { + return { + _id: new ObjectId(), + uid: "123", + name: "test", + hash: "12345", + createdOn: Date.now(), + modifiedOn: Date.now(), + lastUsedOn: Date.now(), + useCount: 0, + enabled: true, + ...overrides, + }; +} diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index ed0558508f8f..5c754002280e 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -10,6 +10,7 @@ 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(); @@ -588,6 +589,7 @@ describe("result controller test", () => { beforeEach(async () => { await enableResultsSaving(true); + await enableUsersXpGain(true); [ userGetMock, @@ -611,48 +613,15 @@ describe("result controller test", () => { 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: { - 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: completedEvent, }) .expect(200); @@ -662,7 +631,12 @@ describe("result controller test", () => { tagPbs: [], xp: 0, dailyXpBonus: false, - xpBreakdown: {}, + xpBreakdown: { + accPenalty: 28, + base: 20, + incomplete: 5, + funbox: 80, + }, streak: 0, insertedId: insertedId.toHexString(), }); @@ -751,44 +725,9 @@ describe("result controller test", () => { .post("/results") .set("Authorization", `Bearer ${uid}`) .send({ - result: { - 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: buildCompletedEvent({ extra2: "value", - }, + } as any), extra: "value", }) .expect(422); @@ -803,6 +742,24 @@ describe("result controller test", () => { }); }); + 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 @@ -824,6 +781,47 @@ describe("result controller test", () => { }); }); +function buildCompletedEvent(result?: Partial): 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 { const mockConfig = await configuration; mockConfig.users.premium = { ...mockConfig.users.premium, enabled }; @@ -884,3 +882,11 @@ async function enableResultsSaving(enabled: boolean): Promise { mockConfig ); } +async function enableUsersXpGain(enabled: boolean): Promise { + const mockConfig = await configuration; + mockConfig.users.xp = { ...mockConfig.users.xp, enabled, funboxBonus: 1 }; + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 2ac5eb546724..f60c59c8fb26 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -3412,7 +3412,7 @@ describe("user controller test", () => { await enableInbox(true); }); - it("shold get inbox", async () => { + it("should get inbox", async () => { //GIVEN const mailOne: MonkeyMail = { id: randomUUID(), diff --git a/backend/src/api/controllers/quote.ts b/backend/src/api/controllers/quote.ts index 6862c811df1a..eeba776b60f6 100644 --- a/backend/src/api/controllers/quote.ts +++ b/backend/src/api/controllers/quote.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { v4 as uuidv4 } from "uuid"; import { getPartialUser, updateQuoteRatings } from "../../dal/user"; import * as ReportDAL from "../../dal/report"; @@ -126,7 +125,10 @@ export async function submitRating( shouldUpdateRating ); - _.setWith(userQuoteRatings, `[${language}][${quoteId}]`, rating, Object); + if (!userQuoteRatings[language]) { + userQuoteRatings[language] = {}; + } + userQuoteRatings[language][quoteId] = rating; await updateQuoteRatings(uid, userQuoteRatings); diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 1c924a848c24..ec1020299114 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -27,7 +27,6 @@ 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 _ from "lodash"; import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; import { UAParser } from "ua-parser-js"; import { canFunboxGetPb } from "../../utils/pb"; @@ -244,7 +243,7 @@ export async function addResult( Logger.warning("Object hash check is disabled, skipping hash check"); } - if (completedEvent.funbox.length !== _.uniq(completedEvent.funbox).length) { + if (new Set(completedEvent.funbox).size !== completedEvent.funbox.length) { throw new MonkeyError(400, "Duplicate funboxes"); } @@ -759,11 +758,12 @@ async function calculateXp( } if (funboxBonusConfiguration > 0 && resultFunboxes.length !== 0) { - const funboxModifier = _.sumBy(resultFunboxes, (funboxName) => { + const funboxModifier = resultFunboxes.reduce((sum, funboxName) => { const funbox = getFunbox(funboxName); const difficultyLevel = funbox?.difficultyLevel ?? 0; - return Math.max(difficultyLevel * funboxBonusConfiguration, 0); - }); + return sum + Math.max(difficultyLevel * funboxBonusConfiguration, 0); + }, 0); + if (funboxModifier > 0) { modifier += funboxModifier; breakdown.funbox = Math.round(baseXp * funboxModifier); diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 22177c3a5f8f..148694255f98 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import * as UserDAL from "../../dal/user"; import MonkeyError, { getErrorMessage, @@ -585,7 +584,7 @@ export async function getUser(req: MonkeyRequest): Promise { let inboxUnreadSize = 0; if (req.ctx.configuration.users.inbox.enabled) { - inboxUnreadSize = _.filter(userInfo.inbox, { read: false }).length; + inboxUnreadSize = userInfo.inbox?.filter((mail) => !mail.read).length ?? 0; } if (!userInfo.name) { @@ -936,8 +935,18 @@ export async function getProfile( lbOptOut, } = user; - const validTimePbs = _.pick(personalBests?.time, "15", "30", "60", "120"); - const validWordsPbs = _.pick(personalBests?.words, "10", "25", "50", "100"); + const validTimePbs = { + "15": personalBests?.time?.["15"], + "30": personalBests?.time?.["30"], + "60": personalBests?.time?.["60"], + "120": personalBests?.time?.["120"], + }; + const validWordsPbs = { + "10": personalBests?.words?.["10"], + "25": personalBests?.words?.["25"], + "50": personalBests?.words?.["50"], + "100": personalBests?.words?.["100"], + }; const typingStats = { completedTests, @@ -1019,10 +1028,7 @@ export async function updateProfile( const profileDetailsUpdates: Partial = { bio: sanitizeString(bio), keyboard: sanitizeString(keyboard), - socialProfiles: _.mapValues( - socialProfiles, - sanitizeString - ) as UserProfileDetails["socialProfiles"], + socialProfiles: socialProfiles ?? {}, showActivityOnPublicProfile, }; diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index f7ef62e3b6b3..fec7fbc969a8 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { contract } from "@monkeytype/contracts/index"; import psas from "./psas"; import publicStats from "./public"; diff --git a/backend/src/dal/ape-keys.ts b/backend/src/dal/ape-keys.ts index 78a1153a17c4..742fd029b4da 100644 --- a/backend/src/dal/ape-keys.ts +++ b/backend/src/dal/ape-keys.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import * as db from "../init/db"; import { type Filter, @@ -52,8 +51,12 @@ async function updateApeKey( const updateResult = await getApeKeysCollection().updateOne( getApeKeyFilter(uid, keyId), { - $inc: { useCount: _.has(updates, "lastUsedOn") ? 1 : 0 }, - $set: _.pickBy(updates, (value) => !_.isNil(value)), + $inc: { useCount: "lastUsedOn" in updates ? 1 : 0 }, + $set: Object.fromEntries( + Object.entries(updates).filter( + ([_, value]) => value !== null && value !== undefined + ) + ), } ); From 36d150f4233355357a028bb2586058749c358827 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 22 Sep 2025 20:37:58 +0530 Subject: [PATCH 06/10] remove lodash from config, result, user, config, redis --- .../__integration__/dal/config.spec.ts | 42 +++++++++++++ .../__integration__/dal/result.spec.ts | 2 +- .../__integration__/dal/user.spec.ts | 16 ++++- backend/__tests__/utils/misc.spec.ts | 42 +++++++++++++ backend/src/api/controllers/user.ts | 35 +++++++---- backend/src/dal/config.ts | 60 +++++++++---------- backend/src/dal/result.ts | 31 +++++----- backend/src/dal/user.ts | 13 ++-- backend/src/init/configuration.ts | 12 ++-- backend/src/init/redis.ts | 7 ++- backend/src/utils/misc.ts | 11 +++- backend/src/utils/pb.ts | 9 +-- backend/src/workers/later-worker.ts | 46 +++++++------- 13 files changed, 218 insertions(+), 108 deletions(-) create mode 100644 backend/__tests__/__integration__/dal/config.spec.ts diff --git a/backend/__tests__/__integration__/dal/config.spec.ts b/backend/__tests__/__integration__/dal/config.spec.ts new file mode 100644 index 000000000000..bab22b49f96c --- /dev/null +++ b/backend/__tests__/__integration__/dal/config.spec.ts @@ -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(); + }); + }); +}); diff --git a/backend/__tests__/__integration__/dal/result.spec.ts b/backend/__tests__/__integration__/dal/result.spec.ts index 4d893656d307..c3bf95760fd8 100644 --- a/backend/__tests__/__integration__/dal/result.spec.ts +++ b/backend/__tests__/__integration__/dal/result.spec.ts @@ -50,7 +50,7 @@ async function createDummyData( tags: [], consistency: 100, keyConsistency: 100, - chartData: { wpm: [], raw: [], err: [] }, + chartData: { wpm: [], burst: [], err: [] }, uid, keySpacingStats: { average: 0, sd: 0 }, keyDurationStats: { average: 0, sd: 0 }, diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index f5d264d0f77c..13f275a11c88 100644 --- a/backend/__tests__/__integration__/dal/user.spec.ts +++ b/backend/__tests__/__integration__/dal/user.spec.ts @@ -1181,7 +1181,6 @@ describe("UserDal", () => { discordId: "discordId", discordAvatar: "discordAvatar", }); - //when await UserDAL.linkDiscord(uid, "newId", "newAvatar"); @@ -1190,6 +1189,21 @@ describe("UserDal", () => { expect(read.discordId).toEqual("newId"); expect(read.discordAvatar).toEqual("newAvatar"); }); + it("should update without avatar", async () => { + //given + const { uid } = await UserTestData.createUser({ + discordId: "discordId", + discordAvatar: "discordAvatar", + }); + + //when + await UserDAL.linkDiscord(uid, "newId"); + + //then + const read = await UserDAL.getUser(uid, "read"); + expect(read.discordId).toEqual("newId"); + expect(read.discordAvatar).toEqual("discordAvatar"); + }); }); describe("unlinkDiscord", () => { it("throws for nonexisting user", async () => { diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index 66ee797d7b81..cbeb8249f0c5 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -453,4 +453,46 @@ describe("Misc Utils", () => { }); }); }); + + 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); + }); + }); }); diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 148694255f98..f7deb8d4d5c4 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -92,6 +92,7 @@ import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; import { MonkeyRequest } from "../types"; import { tryCatch } from "@monkeytype/util/trycatch"; import * as ConnectionsDal from "../../dal/connections"; +import { PersonalBest } from "@monkeytype/schemas/shared"; async function verifyCaptcha(captcha: string): Promise { const { data: verified, error } = await tryCatch(verify(captcha)); @@ -935,19 +936,31 @@ export async function getProfile( lbOptOut, } = user; - const validTimePbs = { - "15": personalBests?.time?.["15"], - "30": personalBests?.time?.["30"], - "60": personalBests?.time?.["60"], - "120": personalBests?.time?.["120"], - }; - const validWordsPbs = { - "10": personalBests?.words?.["10"], - "25": personalBests?.words?.["25"], - "50": personalBests?.words?.["50"], - "100": personalBests?.words?.["100"], + const extractValid = ( + src: Record, + validKeys: string[] + ): Record => { + return validKeys.reduce((obj, key) => { + if (src?.[key] !== undefined) { + obj[key] = src[key]; + } + return obj; + }, {}); }; + const validTimePbs = extractValid(personalBests.time, [ + "15", + "30", + "60", + "120", + ]); + const validWordsPbs = extractValid(personalBests.words, [ + "10", + "25", + "50", + "100", + ]); + const typingStats = { completedTests, startedTests, diff --git a/backend/src/dal/config.ts b/backend/src/dal/config.ts index e94ef0216750..5b9a2044e811 100644 --- a/backend/src/dal/config.ts +++ b/backend/src/dal/config.ts @@ -1,51 +1,47 @@ import { Collection, ObjectId, UpdateResult } from "mongodb"; import * as db from "../init/db"; -import _ from "lodash"; import { Config, PartialConfig } from "@monkeytype/schemas/configs"; -const configLegacyProperties = [ - "swapEscAndTab", - "quickTab", - "chartStyle", - "chartAverage10", - "chartAverage100", - "alwaysShowCPM", - "resultFilters", - "chartAccuracy", - "liveSpeed", - "extraTestColor", - "savedLayout", - "showTimerBar", - "showDiscordDot", - "maxConfidence", - "capsLockBackspace", - "showAvg", - "enableAds", -]; - -type DBConfig = { +const configLegacyProperties: Record = { + "config.swapEscAndTab": "", + "config.quickTab": "", + "config.chartStyle": "", + "config.chartAverage10": "", + "config.chartAverage100": "", + "config.alwaysShowCPM": "", + "config.resultFilters": "", + "config.chartAccuracy": "", + "config.liveSpeed": "", + "config.extraTestColor": "", + "config.savedLayout": "", + "config.showTimerBar": "", + "config.showDiscordDot": "", + "config.maxConfidence": "", + "config.capsLockBackspace": "", + "config.showAvg": "", + "config.enableAds": "", +}; + +export type DBConfig = { _id: ObjectId; uid: string; config: PartialConfig; }; -// Export for use in tests -export const getConfigCollection = (): Collection => +const getConfigCollection = (): Collection => db.collection("configs"); export async function saveConfig( uid: string, config: Partial ): Promise { - const configChanges = _.mapKeys(config, (_value, key) => `config.${key}`); - - const unset = _.fromPairs( - _.map(configLegacyProperties, (key) => [`config.${key}`, ""]) - ) as Record; + const configChanges = Object.fromEntries( + Object.entries(config).map(([key, value]) => [`config.${key}`, value]) + ); return await getConfigCollection().updateOne( { uid }, - { $set: configChanges, $unset: unset }, + { $set: configChanges, $unset: configLegacyProperties }, { upsert: true } ); } @@ -58,3 +54,7 @@ export async function getConfig(uid: string): Promise { export async function deleteConfig(uid: string): Promise { await getConfigCollection().deleteOne({ uid }); } + +export const __testing = { + getConfigCollection, +}; diff --git a/backend/src/dal/result.ts b/backend/src/dal/result.ts index 4c6aa6b07f83..1cab092e226d 100644 --- a/backend/src/dal/result.ts +++ b/backend/src/dal/result.ts @@ -1,7 +1,7 @@ -import _ from "lodash"; import { Collection, type DeleteResult, + Filter, ObjectId, type UpdateResult, } from "mongodb"; @@ -111,24 +111,21 @@ export async function getResults( opts?: GetResultsOpts ): Promise { const { onOrAfterTimestamp, offset, limit } = opts ?? {}; + + const condition: Filter = { uid }; + if (onOrAfterTimestamp !== undefined && !isNaN(onOrAfterTimestamp)) { + condition.timestamp = { $gte: onOrAfterTimestamp }; + } + let query = getResultCollection() - .find( - { - uid, - ...(!_.isNil(onOrAfterTimestamp) && - !_.isNaN(onOrAfterTimestamp) && { - timestamp: { $gte: onOrAfterTimestamp }, - }), + .find(condition, { + projection: { + chartData: 0, + keySpacingStats: 0, + keyDurationStats: 0, + name: 0, }, - { - projection: { - chartData: 0, - keySpacingStats: 0, - keyDurationStats: 0, - name: 0, - }, - } - ) + }) .sort({ timestamp: -1 }); if (limit !== undefined) { diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 4211ace520bc..132a2508b0d1 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -9,7 +9,7 @@ import { type UpdateFilter, type Filter, } from "mongodb"; -import { flattenObjectDeep, WithObjectId } from "../utils/misc"; +import { flattenObjectDeep, isPlainObject, WithObjectId } from "../utils/misc"; import { getCachedConfiguration } from "../init/configuration"; import { getDayOfYear } from "date-fns"; import { UTCDate } from "@date-fns/utc"; @@ -605,10 +605,10 @@ export async function linkDiscord( discordId: string, discordAvatar?: string ): Promise { - const updates: Partial = _.pickBy( - { discordId, discordAvatar }, - _.identity - ); + const updates: Partial = { discordId }; + if (discordAvatar !== undefined && discordAvatar !== null) + updates.discordAvatar = discordAvatar; + await updateUser({ uid }, { $set: updates }, { stack: "link discord" }); } @@ -911,8 +911,7 @@ export async function updateProfile( ): Promise { const profileUpdates = _.omitBy( flattenObjectDeep(profileDetailUpdates, "profileDetails"), - (value) => - value === undefined || (_.isPlainObject(value) && _.isEmpty(value)) + (value) => value === undefined || (isPlainObject(value) && _.isEmpty(value)) ); const updates = { diff --git a/backend/src/init/configuration.ts b/backend/src/init/configuration.ts index 521f45977658..49910a2f784b 100644 --- a/backend/src/init/configuration.ts +++ b/backend/src/init/configuration.ts @@ -2,7 +2,7 @@ import _ from "lodash"; import * as db from "./db"; import { ObjectId } from "mongodb"; import Logger from "../utils/logger"; -import { identity, omit } from "../utils/misc"; +import { identity, isPlainObject, omit } from "../utils/misc"; import { BASE_CONFIGURATION } from "../constants/base-configuration"; import { Configuration } from "@monkeytype/schemas/configuration"; import { addLog } from "../dal/logs"; @@ -26,22 +26,18 @@ function mergeConfigurations( baseConfiguration: Configuration, liveConfiguration: PartialConfiguration ): void { - if ( - !_.isPlainObject(baseConfiguration) || - !_.isPlainObject(liveConfiguration) - ) { + if (!isPlainObject(baseConfiguration) || !isPlainObject(liveConfiguration)) { return; } function merge(base: object, source: object): void { const commonKeys = _.intersection(_.keys(base), _.keys(source)); - commonKeys.forEach((key) => { const baseValue = base[key] as object; const sourceValue = source[key] as object; - const isBaseValueObject = _.isPlainObject(baseValue); - const isSourceValueObject = _.isPlainObject(sourceValue); + const isBaseValueObject = isPlainObject(baseValue); + const isSourceValueObject = isPlainObject(sourceValue); if (isBaseValueObject && isSourceValueObject) { merge(baseValue, sourceValue); diff --git a/backend/src/init/redis.ts b/backend/src/init/redis.ts index dc09f29aec93..af238eef1b9b 100644 --- a/backend/src/init/redis.ts +++ b/backend/src/init/redis.ts @@ -1,5 +1,4 @@ import fs from "fs"; -import _ from "lodash"; import { join } from "path"; import IORedis, { Redis } from "ioredis"; import Logger from "../utils/logger"; @@ -61,10 +60,14 @@ const REDIS_SCRIPTS_DIRECTORY_PATH = join(__dirname, "../../redis-scripts"); function loadScripts(client: IORedis.Redis): void { const scriptFiles = fs.readdirSync(REDIS_SCRIPTS_DIRECTORY_PATH); + const toCamelCase = (kebab: string): string => { + return kebab.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()); + }; + scriptFiles.forEach((scriptFile) => { const scriptPath = join(REDIS_SCRIPTS_DIRECTORY_PATH, scriptFile); const scriptSource = fs.readFileSync(scriptPath, "utf-8"); - const scriptName = _.camelCase(scriptFile.split(".")[0]); + const scriptName = toCamelCase(scriptFile.split(".")[0] as string); client.defineCommand(scriptName, { lua: scriptSource, diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index 692bcf1b7a38..5973a6c02d79 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -1,6 +1,5 @@ import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; import { roundTo2 } from "@monkeytype/util/numbers"; -import _ from "lodash"; import uaparser from "ua-parser-js"; import { MonkeyRequest } from "../api/types"; import { ObjectId } from "mongodb"; @@ -97,7 +96,7 @@ export function flattenObjectDeep( const newPrefix = prefix.length > 0 ? `${prefix}.${key}` : key; - if (_.isPlainObject(value)) { + if (isPlainObject(value)) { const flattened = flattenObjectDeep(value as Record); const flattenedKeys = Object.keys(flattened); @@ -252,3 +251,11 @@ export function omit( } return result; } + +export function isPlainObject(value: unknown): boolean { + return ( + value !== null && + typeof value === "object" && + Object.getPrototypeOf(value) === Object.prototype + ); +} diff --git a/backend/src/utils/pb.ts b/backend/src/utils/pb.ts index 80897febb6cb..027ff09d87d1 100644 --- a/backend/src/utils/pb.ts +++ b/backend/src/utils/pb.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { Mode, PersonalBest, PersonalBests } from "@monkeytype/schemas/shared"; import { Result as ResultType } from "@monkeytype/schemas/results"; import { getFunbox } from "@monkeytype/funbox"; @@ -46,7 +45,7 @@ export function checkAndUpdatePb( (userPb[mode][mode2] as PersonalBest[]).push(buildPersonalBest(result)); } - if (!_.isNil(lbPersonalBests)) { + if (lbPersonalBests !== undefined && lbPersonalBests !== null) { const newLbPb = updateLeaderboardPersonalBests( userPb, lbPersonalBests, @@ -186,9 +185,11 @@ export function updateLeaderboardPersonalBests( } } ); - _.each(bestForEveryLanguage, (pb: PersonalBest, language: string) => { + Object.entries(bestForEveryLanguage).forEach(([language, pb]) => { const languageDoesNotExist = lbPb[mode][mode2]?.[language] === undefined; - const languageIsEmpty = _.isEmpty(lbPb[mode][mode2]?.[language]); + const languageIsEmpty = + lbPb[mode][mode2]?.[language] && + Object.keys(lbPb[mode][mode2][language]).length === 0; if ( (languageDoesNotExist || diff --git a/backend/src/workers/later-worker.ts b/backend/src/workers/later-worker.ts index 57c8e34f4447..da8b43c32030 100644 --- a/backend/src/workers/later-worker.ts +++ b/backend/src/workers/later-worker.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import IORedis from "ioredis"; import { Worker, Job, type ConnectionOptions } from "bullmq"; import Logger from "../utils/logger"; @@ -17,6 +16,7 @@ import { recordTimeToCompleteJob } from "../utils/prometheus"; import { WeeklyXpLeaderboard } from "../services/weekly-xp-leaderboard"; import { MonkeyMail } from "@monkeytype/schemas/users"; import { isSafeNumber, mapRange } from "@monkeytype/util/numbers"; +import { RewardBracket } from "@monkeytype/schemas/configuration"; async function handleDailyLeaderboardResults( ctx: LaterTaskContexts["daily-leaderboard-results"] @@ -61,18 +61,7 @@ async function handleDailyLeaderboardResults( const placementString = getOrdinalNumberString(rank); - const xpReward = _(xpRewardBrackets) - .filter((bracket) => rank >= bracket.minRank && rank <= bracket.maxRank) - .map((bracket) => - mapRange( - rank, - bracket.minRank, - bracket.maxRank, - bracket.maxReward, - bracket.minReward - ) - ) - .max(); + const xpReward = calculateXpReward(xpRewardBrackets, rank); if (!isSafeNumber(xpReward)) return; @@ -151,18 +140,7 @@ async function handleWeeklyXpLeaderboardResults( const xp = Math.round(totalXp); const placementString = getOrdinalNumberString(rank); - const xpReward = _(xpRewardBrackets) - .filter((bracket) => rank >= bracket.minRank && rank <= bracket.maxRank) - .map((bracket) => - mapRange( - rank, - bracket.minRank, - bracket.maxRank, - bracket.maxReward, - bracket.minReward - ) - ) - .max(); + const xpReward = calculateXpReward(xpRewardBrackets, rank); if (!isSafeNumber(xpReward)) return; @@ -208,6 +186,24 @@ async function jobHandler(job: Job>): Promise { Logger.success(`Job: ${taskName} - completed in ${elapsed}ms`); } +function calculateXpReward( + xpRewardBrackets: RewardBracket[], + rank: number +): number | undefined { + const rewards = xpRewardBrackets + .filter((bracket) => rank >= bracket.minRank && rank <= bracket.maxRank) + .map((bracket) => + mapRange( + rank, + bracket.minRank, + bracket.maxRank, + bracket.maxReward, + bracket.minReward + ) + ); + return rewards.length ? Math.max(...rewards) : undefined; +} + export default (redisConnection?: IORedis.Redis): Worker => { const worker = new Worker(LaterQueue.queueName, jobHandler, { autorun: false, From fb2a18fac20a974062163268f1055d253df70fbf Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 23 Sep 2025 15:20:48 +0530 Subject: [PATCH 07/10] remove last lodash usage --- .../__integration__/dal/user.spec.ts | 152 +++++++++++------- backend/__tests__/init/configurations.spec.ts | 53 ++++++ backend/__tests__/setup-tests.ts | 16 +- .../__tests__/workers/later-worker.spec.ts | 33 ++++ backend/package.json | 2 - backend/src/dal/result.ts | 6 +- backend/src/dal/user.ts | 13 +- backend/src/init/configuration.ts | 10 +- backend/src/workers/later-worker.ts | 4 + pnpm-lock.yaml | 11 -- 10 files changed, 215 insertions(+), 85 deletions(-) create mode 100644 backend/__tests__/init/configurations.spec.ts create mode 100644 backend/__tests__/workers/later-worker.spec.ts diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index 13f275a11c88..5bc19e779195 100644 --- a/backend/__tests__/__integration__/dal/user.spec.ts +++ b/backend/__tests__/__integration__/dal/user.spec.ts @@ -625,97 +625,129 @@ describe("UserDal", () => { }); }); - it("updateProfile should appropriately handle multiple profile updates", async () => { - const uid = new ObjectId().toHexString(); - await UserDAL.addUser("test name", "test email", uid); + describe("updateProfile", () => { + it("updateProfile should appropriately handle multiple profile updates", async () => { + const uid = new ObjectId().toHexString(); + await UserDAL.addUser("test name", "test email", uid); - await UserDAL.updateProfile( - uid, - { + await UserDAL.updateProfile( + uid, + { + bio: "test bio", + }, + { + badges: [], + } + ); + + const user = await UserDAL.getUser(uid, "test add result filters"); + expect(user.profileDetails).toStrictEqual({ bio: "test bio", - }, - { + }); + expect(user.inventory).toStrictEqual({ badges: [], - } - ); + }); - const user = await UserDAL.getUser(uid, "test add result filters"); - expect(user.profileDetails).toStrictEqual({ - bio: "test bio", - }); - expect(user.inventory).toStrictEqual({ - badges: [], - }); + await UserDAL.updateProfile( + uid, + { + keyboard: "test keyboard", + socialProfiles: { + twitter: "test twitter", + }, + }, + { + badges: [ + { + id: 1, + selected: true, + }, + ], + } + ); - await UserDAL.updateProfile( - uid, - { + const updatedUser = await UserDAL.getUser(uid, "test add result filters"); + expect(updatedUser.profileDetails).toStrictEqual({ + bio: "test bio", keyboard: "test keyboard", socialProfiles: { twitter: "test twitter", }, - }, - { + }); + expect(updatedUser.inventory).toStrictEqual({ badges: [ { id: 1, selected: true, }, ], - } - ); + }); - const updatedUser = await UserDAL.getUser(uid, "test add result filters"); - expect(updatedUser.profileDetails).toStrictEqual({ - bio: "test bio", - keyboard: "test keyboard", - socialProfiles: { - twitter: "test twitter", - }, - }); - expect(updatedUser.inventory).toStrictEqual({ - badges: [ + await UserDAL.updateProfile( + uid, { - id: 1, - selected: true, + bio: "test bio 2", + socialProfiles: { + github: "test github", + website: "test website", + }, }, - ], - }); + { + badges: [ + { + id: 1, + }, + ], + } + ); - await UserDAL.updateProfile( - uid, - { + const updatedUser2 = await UserDAL.getUser( + uid, + "test add result filters" + ); + expect(updatedUser2.profileDetails).toStrictEqual({ bio: "test bio 2", + keyboard: "test keyboard", socialProfiles: { + twitter: "test twitter", github: "test github", website: "test website", }, - }, - { + }); + expect(updatedUser2.inventory).toStrictEqual({ badges: [ { id: 1, }, ], - } - ); - - const updatedUser2 = await UserDAL.getUser(uid, "test add result filters"); - expect(updatedUser2.profileDetails).toStrictEqual({ - bio: "test bio 2", - keyboard: "test keyboard", - socialProfiles: { - twitter: "test twitter", - github: "test github", - website: "test website", - }, + }); }); - expect(updatedUser2.inventory).toStrictEqual({ - badges: [ - { - id: 1, + it("should omit undefined or empty object values", async () => { + //GIVEN + const givenUser = await UserTestData.createUser({ + profileDetails: { + bio: "test bio", + keyboard: "test keyboard", + socialProfiles: { + twitter: "test twitter", + github: "test github", + }, }, - ], + }); + + //WHEN + await UserDAL.updateProfile(givenUser.uid, { + bio: undefined, //ignored + keyboard: "updates", + socialProfiles: {}, //ignored + }); + + //THEN + const read = await UserDAL.getUser(givenUser.uid, "read"); + expect(read.profileDetails).toStrictEqual({ + ...givenUser.profileDetails, + keyboard: "updates", + }); }); }); diff --git a/backend/__tests__/init/configurations.spec.ts b/backend/__tests__/init/configurations.spec.ts new file mode 100644 index 000000000000..949171f75fb0 --- /dev/null +++ b/backend/__tests__/init/configurations.spec.ts @@ -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 = { + 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); + }); + }); +}); diff --git a/backend/__tests__/setup-tests.ts b/backend/__tests__/setup-tests.ts index 183a1c377032..0c9c8b7c514f 100644 --- a/backend/__tests__/setup-tests.ts +++ b/backend/__tests__/setup-tests.ts @@ -1,17 +1,23 @@ 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", () => ({ - getLiveConfiguration: () => BASE_CONFIGURATION, - getCachedConfiguration: () => BASE_CONFIGURATION, - patchConfiguration: vi.fn(), - })); + 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, diff --git a/backend/__tests__/workers/later-worker.spec.ts b/backend/__tests__/workers/later-worker.spec.ts new file mode 100644 index 000000000000..a63b0457df17 --- /dev/null +++ b/backend/__tests__/workers/later-worker.spec.ts @@ -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); + }); + }); +}); diff --git a/backend/package.json b/backend/package.json index 30e90af5e4bd..b15709cfc3dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,7 +45,6 @@ "firebase-admin": "12.0.0", "helmet": "4.6.0", "ioredis": "4.28.5", - "lodash": "4.17.21", "lru-cache": "7.10.1", "mjml": "4.15.0", "mongodb": "6.3.0", @@ -72,7 +71,6 @@ "@types/cron": "1.7.3", "@types/express": "5.0.3", "@types/ioredis": "4.28.10", - "@types/lodash": "4.14.178", "@types/mjml": "4.7.4", "@types/mustache": "4.2.2", "@types/node": "24.9.1", diff --git a/backend/src/dal/result.ts b/backend/src/dal/result.ts index 1cab092e226d..580b7737e363 100644 --- a/backend/src/dal/result.ts +++ b/backend/src/dal/result.ts @@ -113,7 +113,11 @@ export async function getResults( const { onOrAfterTimestamp, offset, limit } = opts ?? {}; const condition: Filter = { uid }; - if (onOrAfterTimestamp !== undefined && !isNaN(onOrAfterTimestamp)) { + if ( + onOrAfterTimestamp !== undefined && + onOrAfterTimestamp !== null && + !isNaN(onOrAfterTimestamp) + ) { condition.timestamp = { $gte: onOrAfterTimestamp }; } diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 132a2508b0d1..8d100012723f 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { canFunboxGetPb, checkAndUpdatePb, LbPersonalBests } from "../utils/pb"; import * as db from "../init/db"; import MonkeyError from "../utils/error"; @@ -909,9 +908,15 @@ export async function updateProfile( profileDetailUpdates: Partial, inventory?: UserInventory ): Promise { - const profileUpdates = _.omitBy( - flattenObjectDeep(profileDetailUpdates, "profileDetails"), - (value) => value === undefined || (isPlainObject(value) && _.isEmpty(value)) + let profileUpdates = flattenObjectDeep( + Object.fromEntries( + Object.entries(profileDetailUpdates).filter( + ([_, value]) => + value !== undefined && + !(isPlainObject(value) && Object.keys(value).length === 0) + ) + ), + "profileDetails" ); const updates = { diff --git a/backend/src/init/configuration.ts b/backend/src/init/configuration.ts index 49910a2f784b..64413e2c83b7 100644 --- a/backend/src/init/configuration.ts +++ b/backend/src/init/configuration.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import * as db from "./db"; import { ObjectId } from "mongodb"; import Logger from "../utils/logger"; @@ -31,7 +30,10 @@ function mergeConfigurations( } function merge(base: object, source: object): void { - const commonKeys = _.intersection(_.keys(base), _.keys(source)); + const baseKeys = Object.keys(base); + const sourceKeys = Object.keys(source); + const commonKeys = baseKeys.filter((key) => sourceKeys.includes(key)); + commonKeys.forEach((key) => { const baseValue = base[key] as object; const sourceValue = source[key] as object; @@ -162,3 +164,7 @@ export async function updateFromConfigurationFile(): Promise { await patchConfiguration(data.configuration); } } + +export const __testing = { + mergeConfigurations, +}; diff --git a/backend/src/workers/later-worker.ts b/backend/src/workers/later-worker.ts index da8b43c32030..5b390e3d6513 100644 --- a/backend/src/workers/later-worker.ts +++ b/backend/src/workers/later-worker.ts @@ -216,3 +216,7 @@ export default (redisConnection?: IORedis.Redis): Worker => { }); return worker; }; + +export const __testing = { + calculateXpReward, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68bf7dbe3015..3845a70ac09e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,9 +116,6 @@ importers: ioredis: specifier: 4.28.5 version: 4.28.5 - lodash: - specifier: 4.17.21 - version: 4.17.21 lru-cache: specifier: 7.10.1 version: 7.10.1 @@ -192,9 +189,6 @@ importers: '@types/ioredis': specifier: 4.28.10 version: 4.28.10 - '@types/lodash': - specifier: 4.14.178 - version: 4.14.178 '@types/mjml': specifier: 4.7.4 version: 4.7.4 @@ -3219,9 +3213,6 @@ packages: '@types/jsonwebtoken@9.0.6': resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} - '@types/lodash@4.14.178': - resolution: {integrity: sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==} - '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} @@ -12701,8 +12692,6 @@ snapshots: dependencies: '@types/node': 24.9.1 - '@types/lodash@4.14.178': {} - '@types/long@4.0.2': {} '@types/methods@1.1.4': {} From 3379c9499762efd079e2c39c32f3b4fa97f70469 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 13 Nov 2025 12:11:18 +0100 Subject: [PATCH 08/10] fix --- backend/src/api/controllers/user.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index f7deb8d4d5c4..827f6beead6a 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -1041,7 +1041,12 @@ export async function updateProfile( const profileDetailsUpdates: Partial = { bio: sanitizeString(bio), keyboard: sanitizeString(keyboard), - socialProfiles: socialProfiles ?? {}, + socialProfiles: Object.fromEntries( + Object.entries(socialProfiles ?? {}).map(([key, value]) => [ + key, + sanitizeString(value), + ]) + ), showActivityOnPublicProfile, }; From dd1549eeabca4312e46bfb3c57c77f5eacba49e5 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sat, 15 Nov 2025 14:51:23 +0100 Subject: [PATCH 09/10] review comments --- .../dal/leaderboards.isolated.spec.ts | 16 ++++++++-------- .../api/controllers/connections.spec.ts | 7 +++---- .../api/controllers/leaderboard.spec.ts | 6 ++---- backend/__tests__/api/controllers/result.spec.ts | 7 +++---- backend/__tests__/api/controllers/user.spec.ts | 6 +++--- backend/__tests__/utils/misc.spec.ts | 10 +++++----- backend/src/api/controllers/ape-key.ts | 2 +- backend/src/api/controllers/connections.ts | 6 +++--- backend/src/api/controllers/leaderboard.ts | 7 +++++-- backend/src/api/controllers/result.ts | 2 +- backend/src/api/controllers/user.ts | 7 +++---- backend/src/dal/leaderboards.ts | 4 ++-- backend/src/dal/preset.ts | 2 +- backend/src/init/configuration.ts | 12 +++++------- backend/src/init/redis.ts | 7 ++----- backend/src/services/weekly-xp-leaderboard.ts | 2 +- backend/src/utils/daily-leaderboards.ts | 2 +- backend/src/utils/misc.ts | 2 +- packages/util/__test__/strings.spec.ts | 14 ++++++++++++++ packages/util/src/strings.ts | 3 +++ 20 files changed, 67 insertions(+), 57 deletions(-) create mode 100644 packages/util/__test__/strings.spec.ts create mode 100644 packages/util/src/strings.ts diff --git a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts index 8db78956bb70..cfa3a4750120 100644 --- a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts +++ b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts @@ -60,7 +60,8 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => omit(it, "_id")); + + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: rank1 }), @@ -87,8 +88,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - - const lb = results.map((it) => omit(it, "_id")); + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("60", { rank: 1, user: rank1 }), @@ -202,7 +202,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => omit(it, "_id")); + const lb = result.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: noBadge }), @@ -241,7 +241,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => omit(it, "_id")); + const lb = result.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: noPremium }), @@ -299,7 +299,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => omit(it, "_id")); + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("60", { rank: 3, user: rank3 }), @@ -338,7 +338,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => omit(it, "_id")); + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("60", { rank: 1, user: rank1, friendsRank: 1 }), @@ -377,7 +377,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => omit(it, "_id")); + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 3 }), diff --git a/backend/__tests__/api/controllers/connections.spec.ts b/backend/__tests__/api/controllers/connections.spec.ts index a13e64de2bfe..4f0514bfc8ca 100644 --- a/backend/__tests__/api/controllers/connections.spec.ts +++ b/backend/__tests__/api/controllers/connections.spec.ts @@ -4,7 +4,6 @@ import app from "../../../src/app"; import { mockBearerAuthentication } from "../../__testData__/auth"; import * as Configuration from "../../../src/init/configuration"; import { ObjectId } from "mongodb"; -import _ from "lodash"; import * as ConnectionsDal from "../../../src/dal/connections"; import * as UserDal from "../../../src/dal/user"; @@ -383,14 +382,14 @@ describe("ConnectionsController", () => { }); async function enableConnectionsEndpoints(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - connections: { enabled }, - }); + const mockConfig = await configuration; + mockConfig.connections = { ...mockConfig.connections, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } + async function expectFailForDisabledEndpoint(call: SuperTest): Promise { await enableConnectionsEndpoints(false); const { body } = await call.expect(503); diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index 54a2f56a8513..3c1477cb59b1 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -1451,11 +1451,9 @@ async function weeklyLeaderboardEnabled(enabled: boolean): Promise { mockConfig ); } - async function enableConnectionsFeature(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - connections: { enabled: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.connections = { ...mockConfig.connections, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index 5c754002280e..2a2a493d15cc 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -489,15 +489,14 @@ describe("result controller test", () => { it("should apply defaults on missing data", async () => { //GIVEN const result = givenDbResult(uid); - const partialResult = omit( - result, + const partialResult = omit(result, [ "difficulty", "language", "funbox", "lazyMode", "punctuation", - "numbers" - ); + "numbers", + ]); const resultIdString = result._id.toHexString(); const tagIds = [ diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index f60c59c8fb26..e2f8eb9639d7 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -4042,14 +4042,14 @@ async function enableReporting(enabled: boolean): Promise { } async function enableConnectionsEndpoints(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - connections: { enabled }, - }); + const mockConfig = await configuration; + mockConfig.connections = { ...mockConfig.connections, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } + async function expectFailForDisabledEndpoint(call: SuperTest): Promise { await enableConnectionsEndpoints(false); const { body } = await call.expect(503); diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index cbeb8249f0c5..976402d0318f 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -408,26 +408,26 @@ describe("Misc Utils", () => { describe("omit()", () => { it("should omit a single key", () => { const input = { a: 1, b: 2, c: 3 }; - const result = Misc.omit(input, "b"); + 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"); + 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); + 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"); + Misc.omit(input, ["baz"]); expect(input).toEqual(copy); }); @@ -445,7 +445,7 @@ describe("Misc Utils", () => { obj: { x: 1 }, arr: [1, 2, 3], }; - const result = Misc.omit(input, "bool", "arr"); + const result = Misc.omit(input, ["bool", "arr"]); expect(result).toEqual({ str: "hello", num: 123, diff --git a/backend/src/api/controllers/ape-key.ts b/backend/src/api/controllers/ape-key.ts index c6e39595ad7b..51b73deab9fd 100644 --- a/backend/src/api/controllers/ape-key.ts +++ b/backend/src/api/controllers/ape-key.ts @@ -17,7 +17,7 @@ import { ApeKey } from "@monkeytype/schemas/ape-keys"; import { MonkeyRequest } from "../types"; function cleanApeKey(apeKey: ApeKeysDAL.DBApeKey): ApeKey { - return omit(apeKey, "hash", "_id", "uid", "useCount") as ApeKey; + return omit(apeKey, ["hash", "_id", "uid", "useCount"]) as ApeKey; } export async function getApeKeys( diff --git a/backend/src/api/controllers/connections.ts b/backend/src/api/controllers/connections.ts index 906f98665440..d9ee5ed0fc72 100644 --- a/backend/src/api/controllers/connections.ts +++ b/backend/src/api/controllers/connections.ts @@ -10,13 +10,13 @@ import { MonkeyRequest } from "../types"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as ConnectionsDal from "../../dal/connections"; import * as UserDal from "../../dal/user"; -import { replaceObjectId } from "../../utils/misc"; +import { replaceObjectId, omit } from "../../utils/misc"; import MonkeyError from "../../utils/error"; -import { omit } from "lodash"; + import { Connection } from "@monkeytype/schemas/connections"; function convert(db: ConnectionsDal.DBConnection): Connection { - return replaceObjectId(omit(db, "key")); + return replaceObjectId(omit(db, ["key"])); } export async function getConnections( req: MonkeyRequest diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 12c4e620139a..0e1fe7e414fa 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -68,7 +68,7 @@ export async function getLeaderboard( language, friendsOnlyUid ); - const normalizedLeaderboard = leaderboard.map((it) => omit(it, "_id")); + const normalizedLeaderboard = leaderboard.map((it) => omit(it, ["_id"])); return new MonkeyResponse("Leaderboard retrieved", { count, @@ -98,7 +98,10 @@ export async function getRankFromLeaderboard( ); } - return new MonkeyResponse("Rank retrieved", _.omit(data, "_id")); + return new MonkeyResponse( + "Rank retrieved", + omit(data as LeaderboardsDAL.DBLeaderboardEntry, ["_id"]) + ); } function getDailyLeaderboardWithError( diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index ec1020299114..4ccbc90d3d62 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -224,7 +224,7 @@ export async function addResult( const resulthash = completedEvent.hash; if (req.ctx.configuration.results.objectHashCheckEnabled) { - const objectToHash = omit(completedEvent, "hash"); + const objectToHash = omit(completedEvent, ["hash"]); const serverhash = objectHash(objectToHash); if (serverhash !== resulthash) { void addLog( diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 827f6beead6a..bba8a68d441a 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -517,8 +517,7 @@ type RelevantUserInfo = Omit< >; function getRelevantUserInfo(user: UserDAL.DBUser): RelevantUserInfo { - return omit( - user, + return omit(user, [ "bananas", "lbPersonalBests", "inbox", @@ -529,8 +528,8 @@ function getRelevantUserInfo(user: UserDAL.DBUser): RelevantUserInfo { "note", "ips", "testActivity", - "suspicious" - ) as RelevantUserInfo; + "suspicious", + ]) as RelevantUserInfo; } export async function getUser(req: MonkeyRequest): Promise { diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 054774cff089..01302bde1ea8 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -80,7 +80,7 @@ export async function get( .toArray(); } if (!premiumFeaturesEnabled) { - leaderboard = leaderboard.map((it) => omit(it, "isPremium")); + leaderboard = leaderboard.map((it) => omit(it, ["isPremium"])); } return leaderboard; @@ -134,7 +134,7 @@ export async function getRank( language: string, uid: string, friendsOnly: boolean = false -): Promise { +): Promise { try { if (!friendsOnly) { const entry = await getCollection({ language, mode, mode2 }).findOne({ diff --git a/backend/src/dal/preset.ts b/backend/src/dal/preset.ts index a08bfd28ed29..89b0095b4f38 100644 --- a/backend/src/dal/preset.ts +++ b/backend/src/dal/preset.ts @@ -61,7 +61,7 @@ export async function editPreset( uid: string, preset: EditPresetRequest ): Promise { - const update: Partial> = omit(preset, "_id"); + const update: Partial> = omit(preset, ["_id"]); if ( preset.config === undefined || preset.config === null || diff --git a/backend/src/init/configuration.ts b/backend/src/init/configuration.ts index 64413e2c83b7..d08e393ac60e 100644 --- a/backend/src/init/configuration.ts +++ b/backend/src/init/configuration.ts @@ -14,6 +14,7 @@ import { join } from "path"; import { existsSync, readFileSync } from "fs"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; import { z } from "zod"; +import { intersect } from "@monkeytype/util/arrays"; const CONFIG_UPDATE_INTERVAL = 10 * 60 * 1000; // 10 Minutes const SERVER_CONFIG_FILE_PATH = join( @@ -30,9 +31,7 @@ function mergeConfigurations( } function merge(base: object, source: object): void { - const baseKeys = Object.keys(base); - const sourceKeys = Object.keys(source); - const commonKeys = baseKeys.filter((key) => sourceKeys.includes(key)); + const commonKeys = intersect(Object.keys(base), Object.keys(source), true); commonKeys.forEach((key) => { const baseValue = base[key] as object; @@ -81,10 +80,9 @@ export async function getLiveConfiguration(): Promise { if (liveConfiguration) { const baseConfiguration = structuredClone(BASE_CONFIGURATION); - const liveConfigurationWithoutId = omit( - liveConfiguration, - "_id" - ) as Configuration; + const liveConfigurationWithoutId = omit(liveConfiguration, [ + "_id", + ]) as Configuration; mergeConfigurations(baseConfiguration, liveConfigurationWithoutId); await pushConfiguration(baseConfiguration); diff --git a/backend/src/init/redis.ts b/backend/src/init/redis.ts index af238eef1b9b..3eeeed8e9257 100644 --- a/backend/src/init/redis.ts +++ b/backend/src/init/redis.ts @@ -4,6 +4,7 @@ import IORedis, { Redis } from "ioredis"; import Logger from "../utils/logger"; import { isDevEnvironment } from "../utils/misc"; import { getErrorMessage } from "../utils/error"; +import { kebabToCamelCase } from "@monkeytype/util/strings"; // Define Redis connection with custom methods for type safety export type RedisConnectionWithCustomMethods = Redis & { @@ -60,14 +61,10 @@ const REDIS_SCRIPTS_DIRECTORY_PATH = join(__dirname, "../../redis-scripts"); function loadScripts(client: IORedis.Redis): void { const scriptFiles = fs.readdirSync(REDIS_SCRIPTS_DIRECTORY_PATH); - const toCamelCase = (kebab: string): string => { - return kebab.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()); - }; - scriptFiles.forEach((scriptFile) => { const scriptPath = join(REDIS_SCRIPTS_DIRECTORY_PATH, scriptFile); const scriptSource = fs.readFileSync(scriptPath, "utf-8"); - const scriptName = toCamelCase(scriptFile.split(".")[0] as string); + const scriptName = kebabToCamelCase(scriptFile.split(".")[0] as string); client.defineCommand(scriptName, { lua: scriptSource, diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index 9e090dfd9e1e..f6bad83a0584 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -202,7 +202,7 @@ export class WeeklyXpLeaderboard { ); if (!premiumFeaturesEnabled) { - resultsWithRanks = resultsWithRanks.map((it) => omit(it, "isPremium")); + resultsWithRanks = resultsWithRanks.map((it) => omit(it, ["isPremium"])); } return { entries: resultsWithRanks, count: parseInt(count) }; diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index 09183d9411c3..bc05e61bf571 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -186,7 +186,7 @@ export class DailyLeaderboard { ); if (!premiumFeaturesEnabled) { - resultsWithRanks = resultsWithRanks.map((it) => omit(it, "isPremium")); + resultsWithRanks = resultsWithRanks.map((it) => omit(it, ["isPremium"])); } return { entries: resultsWithRanks, count: parseInt(count), minWpm }; diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index 5973a6c02d79..8a48e0fbd61d 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -242,7 +242,7 @@ export type WithObjectId = Omit & { export function omit( obj: T, - ...keys: K[] + keys: K[] ): Omit { const result = { ...obj }; for (const key of keys) { diff --git a/packages/util/__test__/strings.spec.ts b/packages/util/__test__/strings.spec.ts new file mode 100644 index 000000000000..6b39b0ccae2e --- /dev/null +++ b/packages/util/__test__/strings.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { kebabToCamelCase } from "../src/strings"; + +describe("strings", () => { + describe("kebabToCamelCase", () => { + it("should convert kebab case to camel case", () => { + expect(kebabToCamelCase("hello-world")).toEqual("helloWorld"); + expect(kebabToCamelCase("helloWorld")).toEqual("helloWorld"); + expect( + kebabToCamelCase("one-two-three-four-five-six-seven-eight-nine-ten") + ).toEqual("oneTwoThreeFourFiveSixSevenEightNineTen"); + }); + }); +}); diff --git a/packages/util/src/strings.ts b/packages/util/src/strings.ts new file mode 100644 index 000000000000..35d53794eac5 --- /dev/null +++ b/packages/util/src/strings.ts @@ -0,0 +1,3 @@ +export function kebabToCamelCase(kebab: string): string { + return kebab.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()); +} From d8aedefbc72f60b8062bf3b3b107c39c79688f66 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sat, 15 Nov 2025 14:59:41 +0100 Subject: [PATCH 10/10] cleanup --- backend/src/api/controllers/ape-key.ts | 2 +- backend/src/api/controllers/result.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/api/controllers/ape-key.ts b/backend/src/api/controllers/ape-key.ts index 51b73deab9fd..d687f207ac86 100644 --- a/backend/src/api/controllers/ape-key.ts +++ b/backend/src/api/controllers/ape-key.ts @@ -17,7 +17,7 @@ import { ApeKey } from "@monkeytype/schemas/ape-keys"; import { MonkeyRequest } from "../types"; function cleanApeKey(apeKey: ApeKeysDAL.DBApeKey): ApeKey { - return omit(apeKey, ["hash", "_id", "uid", "useCount"]) as ApeKey; + return omit(apeKey, ["hash", "_id", "uid", "useCount"]); } export async function getApeKeys( diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 4ccbc90d3d62..05cc53536749 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -243,7 +243,7 @@ export async function addResult( Logger.warning("Object hash check is disabled, skipping hash check"); } - if (new Set(completedEvent.funbox).size !== completedEvent.funbox.length) { + if (completedEvent.funbox.length !== new Set(completedEvent.funbox).size) { throw new MonkeyError(400, "Duplicate funboxes"); }