diff --git a/backend/__tests__/__testData__/auth.ts b/backend/__tests__/__testData__/auth.ts new file mode 100644 index 000000000000..3a6c1f6046de --- /dev/null +++ b/backend/__tests__/__testData__/auth.ts @@ -0,0 +1,37 @@ +import { Configuration } from "@monkeytype/shared-types"; +import { randomBytes } from "crypto"; +import { hash } from "bcrypt"; +import { ObjectId } from "mongodb"; +import { base64UrlEncode } from "../../src/utils/misc"; +import * as ApeKeyDal from "../../src/dal/ape-keys"; + +export async function mockAuthenticateWithApeKey( + uid: string, + config: Configuration +): Promise { + if (!config.apeKeys.acceptKeys) + throw Error("config.apeKeys.acceptedKeys needs to be set to true"); + const { apeKeyBytes, apeKeySaltRounds } = config.apeKeys; + + const apiKey = randomBytes(apeKeyBytes).toString("base64url"); + const saltyHash = await hash(apiKey, apeKeySaltRounds); + + const apeKey: MonkeyTypes.ApeKeyDB = { + _id: new ObjectId(), + name: "bob", + enabled: true, + uid, + hash: saltyHash, + createdOn: Date.now(), + modifiedOn: Date.now(), + lastUsedOn: -1, + useCount: 0, + }; + + const apeKeyId = new ObjectId().toHexString(); + + vi.spyOn(ApeKeyDal, "getApeKey").mockResolvedValue(apeKey); + vi.spyOn(ApeKeyDal, "updateLastUsedOn").mockResolvedValue(); + + return base64UrlEncode(`${apeKeyId}.${apiKey}`); +} diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index 00fc042a8232..c2f7bef451b8 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -1,35 +1,1117 @@ +import _ from "lodash"; +import { ObjectId } from "mongodb"; import request from "supertest"; import app from "../../../src/app"; +import * as LeaderboardDal from "../../../src/dal/leaderboards"; +import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards"; +import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard"; import * as Configuration from "../../../src/init/configuration"; +import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; +import { + LeaderboardEntry, + XpLeaderboardEntry, + XpLeaderboardRank, +} from "@monkeytype/contracts/schemas/leaderboards"; const mockApp = request(app); +const configuration = Configuration.getCachedConfiguration(); +const uid = new ObjectId().toHexString(); -describe("leaderboards controller test", () => { - it("GET /leaderboards/xp/weekly", async () => { - const configSpy = vi - .spyOn(Configuration, "getCachedConfiguration") - .mockResolvedValue({ - leaderboards: { - weeklyXp: { - enabled: true, - expirationTimeInDays: 15, - xpRewardBrackets: [], - }, +const allModes = [ + "10", + "25", + "50", + "100", + "15", + "30", + "60", + "120", + "zen", + "custom", +]; + +describe("Loaderboard Controller", () => { + describe("get leaderboard", () => { + const getLeaderboardMock = vi.spyOn(LeaderboardDal, "get"); + + beforeEach(() => { + getLeaderboardMock.mockReset(); + }); + + it("should get for english time 60", async () => { + //GIVEN + + const resultData = [ + { + wpm: 20, + acc: 90, + timestamp: 1000, + raw: 92, + consistency: 80, + uid: "user1", + name: "user1", + discordId: "discordId", + discordAvatar: "discordAvatar", + rank: 1, + badgeId: 1, + isPremium: true, + }, + { + wpm: 10, + acc: 80, + timestamp: 1200, + raw: 82, + uid: "user2", + name: "user2", + rank: 2, + }, + ]; + const mockData = resultData.map((it) => ({ ...it, _id: new ObjectId() })); + getLeaderboardMock.mockResolvedValue(mockData); + + //WHEN + + const { body } = await mockApp + .get("/leaderboards") + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Leaderboard retrieved", + data: resultData, + }); + + expect(getLeaderboardMock).toHaveBeenCalledWith( + "time", + "60", + "english", + 0, + 50 + ); + }); + + it("should get for english time 60 with skip and limit", async () => { + //GIVEN + getLeaderboardMock.mockResolvedValue([]); + const skip = 23; + const limit = 42; + + //WHEN + + const { body } = await mockApp + .get("/leaderboards") + .query({ + language: "english", + mode: "time", + mode2: "60", + skip, + limit, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Leaderboard retrieved", + data: [], + }); + + expect(getLeaderboardMock).toHaveBeenCalledWith( + "time", + "60", + "english", + skip, + limit + ); + }); + + it("should get for mode", async () => { + getLeaderboardMock.mockResolvedValue([]); + for (const mode of ["time", "words", "quote", "zen", "custom"]) { + const response = await mockApp + .get("/leaderboards") + .query({ language: "english", mode, mode2: "custom" }); + expect(response.status, "for mode " + mode).toEqual(200); + } + }); + + it("should get for mode2", async () => { + getLeaderboardMock.mockResolvedValue([]); + for (const mode2 of allModes) { + const response = await mockApp.get("/leaderboards").query({ + language: "english", + mode: "words", + mode2, + }); + + expect(response.status, "for mode2 " + mode2).toEqual(200); + } + }); + it("fails for missing query", async () => { + const { body } = await mockApp.get("/leaderboards").expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Required', + '"mode" Required', + '"mode2" Needs to be either a number, "zen" or "custom."', + ], + }); + }); + it("fails for invalid query", async () => { + const { body } = await mockApp + .get("/leaderboards") + .query({ + language: "en?gli.sh", + mode: "unknownMode", + mode2: "unknownMode2", + skip: -1, + limit: 100, + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Can only contain letters [a-zA-Z0-9_+]', + `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`, + '"mode2" Needs to be a number or a number represented as a string e.g. "10".', + '"skip" Number must be greater than or equal to 0', + '"limit" Number must be less than or equal to 50', + ], + }); + }); + it("fails for unknown query", async () => { + const { body } = await mockApp + .get("/leaderboards") + .query({ + language: "english", + mode: "time", + mode2: "60", + extra: "value", + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("fails while leaderboard is updating", async () => { + //GIVEN + getLeaderboardMock.mockResolvedValue(false); + + //WHEN + const { body } = await mockApp + .get("/leaderboards") + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .expect(503); + + expect(body.message).toEqual( + "Leaderboard is currently updating. Please try again in a few seconds." + ); + }); + }); + + describe("get rank", () => { + const getLeaderboardRankMock = vi.spyOn(LeaderboardDal, "getRank"); + + afterEach(() => { + getLeaderboardRankMock.mockReset(); + }); + + it("fails withouth authentication", async () => { + await mockApp + .get("/leaderboards/rank") + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(401); + }); + + it("should get for english time 60", async () => { + //GIVEN + + const entryId = new ObjectId(); + const resultEntry = { + _id: entryId.toHexString(), + wpm: 10, + acc: 80, + timestamp: 1200, + raw: 82, + uid: "user2", + name: "user2", + rank: 2, + }; + getLeaderboardRankMock.mockResolvedValue({ + count: 1000, + rank: 50, + entry: resultEntry, + }); + + //WHEN + + const { body } = await mockApp + .get("/leaderboards/rank") + .query({ language: "english", mode: "time", mode2: "60" }) + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Rank retrieved", + data: { + count: 1000, + rank: 50, + entry: resultEntry, + }, + }); + + expect(getLeaderboardRankMock).toHaveBeenCalledWith( + "time", + "60", + "english", + uid + ); + }); + it("should get with ape key", async () => { + await acceptApeKeys(true); + const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); + + await mockApp + .get("/leaderboards/rank") + .query({ language: "english", mode: "time", mode2: "60" }) + .set("authorization", "ApeKey " + apeKey) + .expect(200); + }); + it("should get for mode", async () => { + getLeaderboardRankMock.mockResolvedValue({} as any); + for (const mode of ["time", "words", "quote", "zen", "custom"]) { + const response = await mockApp + .get("/leaderboards/rank") + .set("authorization", `Uid ${uid}`) + .query({ language: "english", mode, mode2: "custom" }); + expect(response.status, "for mode " + mode).toEqual(200); + } + }); + + it("should get for mode2", async () => { + getLeaderboardRankMock.mockResolvedValue({} as any); + for (const mode2 of allModes) { + const response = await mockApp + .get("/leaderboards/rank") + .set("authorization", `Uid ${uid}`) + .query({ language: "english", mode: "words", mode2 }); + + expect(response.status, "for mode2 " + mode2).toEqual(200); + } + }); + it("fails for missing query", async () => { + const { body } = await mockApp + .get("/leaderboards/rank") + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Required', + '"mode" Required', + '"mode2" Needs to be either a number, "zen" or "custom."', + ], + }); + }); + it("fails for invalid query", async () => { + const { body } = await mockApp + .get("/leaderboards/rank") + .query({ + language: "en?gli.sh", + mode: "unknownMode", + mode2: "unknownMode2", + }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Can only contain letters [a-zA-Z0-9_+]', + `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`, + '"mode2" Needs to be a number or a number represented as a string e.g. "10".', + ], + }); + }); + it("fails for unknown query", async () => { + const { body } = await mockApp + .get("/leaderboards/rank") + .query({ + language: "english", + mode: "time", + mode2: "60", + extra: "value", + }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("fails while leaderboard is updating", async () => { + //GIVEN + getLeaderboardRankMock.mockResolvedValue(false); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/rank") + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .set("authorization", `Uid ${uid}`) + .expect(503); + + expect(body.message).toEqual( + "Leaderboard is currently updating. Please try again in a few seconds." + ); + }); + }); + + describe("get daily leaderboard", () => { + const getDailyLeaderboardMock = vi.spyOn( + DailyLeaderboards, + "getDailyLeaderboard" + ); + + beforeEach(async () => { + getDailyLeaderboardMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(1722606812000); + await dailyLeaderboardEnabled(true); + + getDailyLeaderboardMock.mockReturnValue({ + getResults: () => Promise.resolve([]), + } as any); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should get for english time 60", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + const premiumEnabled = (await configuration).users.premium.enabled; + + const resultData: LeaderboardEntry[] = [ + { + name: "user1", + rank: 1, + wpm: 20, + acc: 90, + timestamp: 1000, + raw: 92, + consistency: 80, + uid: "user1", + discordId: "discordId", + discordAvatar: "discordAvatar", + }, + { + wpm: 10, + rank: 2, + acc: 80, + timestamp: 1200, + raw: 82, + consistency: 72, + uid: "user2", + name: "user2", + }, + ]; + + const getResultMock = vi.fn(); + getResultMock.mockResolvedValue(resultData); + getDailyLeaderboardMock.mockReturnValue({ + getResults: getResultMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Daily leaderboard retrieved", + data: resultData, + }); + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getResultMock).toHaveBeenCalledWith(0, 49, lbConf, premiumEnabled); + }); + + it("should get for english time 60 for yesterday", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "english", + mode: "time", + mode2: "60", + daysBefore: 1, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Daily leaderboard retrieved", + data: [], + }); + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + 1722470400000 + ); + }); + it("should get for english time 60 with skip and limit", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + const premiumEnabled = (await configuration).users.premium.enabled; + const limit = 23; + const skip = 42; + + const getResultMock = vi.fn(); + getResultMock.mockResolvedValue([]); + getDailyLeaderboardMock.mockReturnValue({ + getResults: getResultMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "english", + mode: "time", + mode2: "60", + skip, + limit, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Daily leaderboard retrieved", + data: [], + }); + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getResultMock).toHaveBeenCalledWith( + skip, + skip + limit - 1, + lbConf, + premiumEnabled + ); + }); + + it("fails for daysBefore not one", async () => { + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "english", + mode: "time", + mode2: "60", + daysBefore: 2, + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ['"daysBefore" Invalid literal value, expected 1'], + }); + }); + + it("fails if daily leaderboards are disabled", async () => { + await dailyLeaderboardEnabled(false); + + const { body } = await mockApp.get("/leaderboards/daily").expect(503); + + expect(body.message).toEqual( + "Daily leaderboards are not available at this time." + ); + }); + + it("should get for mode", async () => { + for (const mode of ["time", "words", "quote", "zen", "custom"]) { + const response = await mockApp + .get("/leaderboards/daily") + .query({ language: "english", mode, mode2: "custom" }); + expect(response.status, "for mode " + mode).toEqual(200); + } + }); + + it("should get for mode2", async () => { + for (const mode2 of allModes) { + const response = await mockApp + .get("/leaderboards/daily") + .query({ language: "english", mode: "words", mode2 }); + + expect(response.status, "for mode2 " + mode2).toEqual(200); + } + }); + it("fails for missing query", async () => { + const { body } = await mockApp.get("/leaderboards").expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Required', + '"mode" Required', + '"mode2" Needs to be either a number, "zen" or "custom."', + ], + }); + }); + it("fails for invalid query", async () => { + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "en?gli.sh", + mode: "unknownMode", + mode2: "unknownMode2", + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Can only contain letters [a-zA-Z0-9_+]', + `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`, + '"mode2" Needs to be a number or a number represented as a string e.g. "10".', + ], + }); + }); + it("fails for unknown query", async () => { + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "english", + mode: "time", + mode2: "60", + extra: "value", + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("fails while leaderboard is missing", async () => { + //GIVEN + getDailyLeaderboardMock.mockReturnValue(null); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .expect(404); + + expect(body.message).toEqual( + "There is no daily leaderboard for this mode" + ); + }); + }); + + describe("get daily leaderboard rank", () => { + const getDailyLeaderboardMock = vi.spyOn( + DailyLeaderboards, + "getDailyLeaderboard" + ); + + beforeEach(async () => { + getDailyLeaderboardMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(1722606812000); + await dailyLeaderboardEnabled(true); + + getDailyLeaderboardMock.mockReturnValue({ + getRank: () => Promise.resolve({} as any), + } as any); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("fails withouth authentication", async () => { + await mockApp + .get("/leaderboards/daily/rank") + + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(401); + }); + it("should get for english time 60", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + const rankData = { + min: 100, + count: 1000, + rank: 12, + entry: { + wpm: 10, + rank: 2, + acc: 80, + timestamp: 1200, + raw: 82, + consistency: 72, + uid: "user2", + name: "user2", }, + }; + + const getRankMock = vi.fn(); + getRankMock.mockResolvedValue(rankData); + getDailyLeaderboardMock.mockReturnValue({ + getRank: getRankMock, } as any); - const response = await mockApp - .get("/leaderboards/xp/weekly") - .set({ - Accept: "application/json", - }) - .expect(200); + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Daily leaderboard rank retrieved", + data: rankData, + }); + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf); + }); + it("fails if daily leaderboards are disabled", async () => { + await dailyLeaderboardEnabled(false); + + const { body } = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .expect(503); + + expect(body.message).toEqual( + "Daily leaderboards are not available at this time." + ); + }); + it("should get for mode", async () => { + for (const mode of ["time", "words", "quote", "zen", "custom"]) { + const response = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .query({ language: "english", mode, mode2: "custom" }); + expect(response.status, "for mode " + mode).toEqual(200); + } + }); + it("should get for mode2", async () => { + for (const mode2 of allModes) { + const response = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .query({ language: "english", mode: "words", mode2 }); + + expect(response.status, "for mode2 " + mode2).toEqual(200); + } + }); + it("fails for missing query", async () => { + const { body } = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Required', + '"mode" Required', + '"mode2" Needs to be either a number, "zen" or "custom."', + ], + }); + }); + it("fails for invalid query", async () => { + const { body } = await mockApp + .get("/leaderboards/daily/rank") + .query({ + language: "en?gli.sh", + mode: "unknownMode", + mode2: "unknownMode2", + }) + .set("authorization", `Uid ${uid}`) + .expect(422); - expect(response.body).toEqual({ - message: "Weekly xp leaderboard retrieved", - data: [], + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Can only contain letters [a-zA-Z0-9_+]', + `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`, + '"mode2" Needs to be a number or a number represented as a string e.g. "10".', + ], + }); }); + it("fails for unknown query", async () => { + const { body } = await mockApp + .get("/leaderboards/daily/rank") + .query({ + language: "english", + mode: "time", + mode2: "60", + extra: "value", + }) + .set("authorization", `Uid ${uid}`) + .expect(422); - configSpy.mockRestore(); + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("fails while leaderboard is missing", async () => { + //GIVEN + getDailyLeaderboardMock.mockReturnValue(null); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .expect(404); + + expect(body.message).toEqual( + "There is no daily leaderboard for this mode" + ); + }); + }); + + describe("get xp weekly leaderboard", () => { + const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get"); + + beforeEach(async () => { + getXpWeeklyLeaderboardMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(1722606812000); + await weeklyLeaderboardEnabled(true); + + getXpWeeklyLeaderboardMock.mockReturnValue({ + getResults: () => Promise.resolve([]), + } as any); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should get", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + + const resultData: XpLeaderboardEntry[] = [ + { + totalXp: 100, + rank: 1, + timeTypedSeconds: 100, + uid: "user1", + name: "user1", + discordId: "discordId", + discordAvatar: "discordAvatar", + lastActivityTimestamp: 1000, + }, + { + totalXp: 75, + rank: 2, + timeTypedSeconds: 200, + uid: "user2", + name: "user2", + discordId: "discordId2", + discordAvatar: "discordAvatar2", + lastActivityTimestamp: 2000, + }, + ]; + + const getResultMock = vi.fn(); + getResultMock.mockResolvedValue(resultData); + getXpWeeklyLeaderboardMock.mockReturnValue({ + getResults: getResultMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({}) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Weekly xp leaderboard retrieved", + data: resultData, + }); + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); + + expect(getResultMock).toHaveBeenCalledWith(0, 49, lbConf); + }); + + it("should get for last week", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({ + weeksBefore: 1, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Weekly xp leaderboard retrieved", + data: [], + }); + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith( + lbConf, + 1721606400000 + ); + }); + + it("should get with skip and limit", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + const limit = 23; + const skip = 42; + + const getResultMock = vi.fn(); + getResultMock.mockResolvedValue([]); + getXpWeeklyLeaderboardMock.mockReturnValue({ + getResults: getResultMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({ + skip, + limit, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Weekly xp leaderboard retrieved", + data: [], + }); + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); + + expect(getResultMock).toHaveBeenCalledWith( + skip, + skip + limit - 1, + lbConf + ); + }); + + it("fails if daily leaderboards are disabled", async () => { + await weeklyLeaderboardEnabled(false); + + const { body } = await mockApp.get("/leaderboards/xp/weekly").expect(503); + + expect(body.message).toEqual( + "Weekly XP leaderboards are not available at this time." + ); + }); + + it("fails for weeksBefore not one", async () => { + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({ + weeksBefore: 2, + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ['"weeksBefore" Invalid literal value, expected 1'], + }); + }); + it("fails for unknown query", async () => { + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({ + extra: "value", + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("fails while leaderboard is missing", async () => { + //GIVEN + getXpWeeklyLeaderboardMock.mockReturnValue(null); + + //WHEN + const { body } = await mockApp.get("/leaderboards/xp/weekly").expect(404); + + expect(body.message).toEqual("XP leaderboard for this week not found."); + }); + }); + + describe("get xp weekly leaderboard rank", () => { + const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get"); + + beforeEach(async () => { + getXpWeeklyLeaderboardMock.mockReset(); + await weeklyLeaderboardEnabled(true); + }); + + it("fails withouth authentication", async () => { + await mockApp.get("/leaderboards/xp/weekly/rank").expect(401); + }); + + it("should get", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + + const resultData: XpLeaderboardRank = { + totalXp: 100, + rank: 1, + count: 100, + timeTypedSeconds: 100, + uid: "user1", + name: "user1", + discordId: "discordId", + discordAvatar: "discordAvatar", + lastActivityTimestamp: 1000, + }; + const getRankMock = vi.fn(); + getRankMock.mockResolvedValue(resultData); + getXpWeeklyLeaderboardMock.mockReturnValue({ + getRank: getRankMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly/rank") + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Weekly xp leaderboard rank retrieved", + data: resultData, + }); + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); + + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf); + }); + it("fails if daily leaderboards are disabled", async () => { + await weeklyLeaderboardEnabled(false); + + const { body } = await mockApp + .get("/leaderboards/xp/weekly/rank") + .set("authorization", `Uid ${uid}`) + .expect(503); + + expect(body.message).toEqual( + "Weekly XP leaderboards are not available at this time." + ); + }); + + it("fails while leaderboard is missing", async () => { + //GIVEN + getXpWeeklyLeaderboardMock.mockReturnValue(null); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly/rank") + .set("authorization", `Uid ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .expect(404); + + expect(body.message).toEqual("XP leaderboard for this week not found."); + }); }); }); + +async function acceptApeKeys(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + apeKeys: { acceptKeys: enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} + +async function dailyLeaderboardEnabled(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + dailyLeaderboards: { enabled: enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} +async function weeklyLeaderboardEnabled(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + leaderboards: { weeklyXp: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/__tests__/api/controllers/public.spec.ts b/backend/__tests__/api/controllers/public.spec.ts index accc90a9acb1..9d9960e05bbd 100644 --- a/backend/__tests__/api/controllers/public.spec.ts +++ b/backend/__tests__/api/controllers/public.spec.ts @@ -89,9 +89,9 @@ describe("PublicController", () => { expect(body).toEqual({ message: "Invalid query schema", validationErrors: [ - '"language" Invalid', + '"language" Can only contain letters [a-zA-Z0-9_+]', `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`, - '"mode2" Needs to be either a number, "zen" or "custom."', + '"mode2" Needs to be a number or a number represented as a string e.g. "10".', ], }); }); diff --git a/backend/__tests__/dal/leaderboards.spec.ts b/backend/__tests__/dal/leaderboards.spec.ts index 910c37794ce8..f96822a9bd3a 100644 --- a/backend/__tests__/dal/leaderboards.spec.ts +++ b/backend/__tests__/dal/leaderboards.spec.ts @@ -4,6 +4,8 @@ import * as UserDal from "../../src/dal/user"; import * as LeaderboardsDal from "../../src/dal/leaderboards"; import * as PublicDal from "../../src/dal/public"; import * as Configuration from "../../src/init/configuration"; +import type { DBLeaderboardEntry } from "../../src/dal/leaderboards"; +import type { PersonalBest } from "@monkeytype/contracts/schemas/shared"; const configuration = Configuration.getCachedConfiguration(); import * as DB from "../../src/init/db"; @@ -29,7 +31,9 @@ describe("LeaderboardsDal", () => { //THEN expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty("uid", applicableUser.uid); + expect( + (result as LeaderboardsDal.DBLeaderboardEntry[])[0] + ).toHaveProperty("uid", applicableUser.uid); }); it("should create leaderboard time english 15", async () => { @@ -46,7 +50,7 @@ describe("LeaderboardsDal", () => { "15", "english", 0 - )) as SharedTypes.LeaderboardEntry[]; + )) as DBLeaderboardEntry[]; //THEN const lb = result.map((it) => _.omit(it, ["_id"])); @@ -72,7 +76,7 @@ describe("LeaderboardsDal", () => { "60", "english", 0 - )) as SharedTypes.LeaderboardEntry[]; + )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN const lb = result.map((it) => _.omit(it, ["_id"])); @@ -98,7 +102,7 @@ describe("LeaderboardsDal", () => { "60", "english", 0 - )) as SharedTypes.LeaderboardEntry[]; + )) as DBLeaderboardEntry[]; //THEN expect(lb[0]).not.toHaveProperty("discordId"); @@ -121,7 +125,7 @@ describe("LeaderboardsDal", () => { "15", "english", 0 - )) as SharedTypes.LeaderboardEntry[]; + )) as DBLeaderboardEntry[]; //THEN expect(lb[0]).not.toHaveProperty("consistency"); @@ -183,7 +187,7 @@ describe("LeaderboardsDal", () => { "15", "english", 0 - )) as SharedTypes.LeaderboardEntry[]; + )) as DBLeaderboardEntry[]; //THEN const lb = result.map((it) => _.omit(it, ["_id"])); @@ -219,7 +223,7 @@ describe("LeaderboardsDal", () => { "15", "english", 0 - )) as SharedTypes.LeaderboardEntry[]; + )) as DBLeaderboardEntry[]; //THEN const lb = result.map((it) => _.omit(it, ["_id"])); @@ -251,7 +255,7 @@ describe("LeaderboardsDal", () => { "15", "english", 0 - )) as SharedTypes.LeaderboardEntry[]; + )) as DBLeaderboardEntry[]; //THEN expect(result[0]?.isPremium).toBeUndefined(); @@ -263,8 +267,10 @@ function expectedLbEntry( time: string, { rank, user, badgeId, isPremium }: ExpectedLbEntry ) { - const lbBest: SharedTypes.PersonalBest = - user.lbPersonalBests?.time[time].english; + // @ts-expect-error + const lbBest: PersonalBest = + // @ts-expect-error + user.lbPersonalBests?.time[Number.parseInt(time)].english; return { rank, @@ -308,10 +314,10 @@ async function createUser( } function lbBests( - pb15?: SharedTypes.PersonalBest, - pb60?: SharedTypes.PersonalBest + pb15?: PersonalBest, + pb60?: PersonalBest ): MonkeyTypes.LbPersonalBests { - const result = { time: {} }; + const result: MonkeyTypes.LbPersonalBests = { time: {} }; if (pb15) result.time["15"] = { english: pb15 }; if (pb60) result.time["60"] = { english: pb60 }; return result; @@ -321,7 +327,7 @@ function pb( wpm: number, acc: number = 90, timestamp: number = 1 -): SharedTypes.PersonalBest { +): PersonalBest { return { acc, consistency: 100, @@ -335,7 +341,7 @@ function pb( }; } -function premium(expirationDeltaSeconds) { +function premium(expirationDeltaSeconds: number) { return { premium: { startTimestamp: 0, diff --git a/backend/package.json b/backend/package.json index cf0d4edea2d8..1e65bd1504e2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -65,7 +65,7 @@ "@monkeytype/eslint-config": "workspace:*", "@monkeytype/shared-types": "workspace:*", "@monkeytype/typescript-config": "workspace:*", - "@redocly/cli": "1.18.1", + "@redocly/cli": "1.19.0", "@types/bcrypt": "5.0.2", "@types/cors": "2.8.12", "@types/cron": "1.7.3", @@ -85,7 +85,7 @@ "@types/swagger-ui-express": "4.1.3", "@types/ua-parser-js": "0.7.36", "@types/uuid": "10.0.0", - "@vitest/coverage-v8": "1.6.0", + "@vitest/coverage-v8": "2.0.5", "concurrently": "8.2.2", "eslint": "8.57.0", "eslint-watch": "8.0.0", @@ -95,7 +95,7 @@ "supertest": "6.2.3", "tsx": "4.16.2", "typescript": "5.5.4", - "vitest": "1.6.0", + "vitest": "2.0.5", "vitest-mongodb": "1.0.0" } } diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index b5140de6c76c..38a1e327c6d1 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -71,6 +71,11 @@ export function getOpenApi(): OpenAPIObject { description: "Public endpoints such as typing stats.", "x-displayName": "public", }, + { + name: "leaderboards", + description: "All-time and daily leaderboards of the fastest typers.", + "x-displayName": "Leaderboards", + }, { name: "psas", description: "Public service announcements.", diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index bed10e239727..17aa0a8100f4 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -4,85 +4,81 @@ import { MILLISECONDS_IN_DAY, getCurrentWeekTimestamp, } from "../../utils/misc"; -import { MonkeyResponse } from "../../utils/monkey-response"; +import { MonkeyResponse2 } from "../../utils/monkey-response"; import * as LeaderboardsDAL from "../../dal/leaderboards"; import MonkeyError from "../../utils/error"; import * as DailyLeaderboards from "../../utils/daily-leaderboards"; import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; +import { + GetDailyLeaderboardQuery, + GetDailyLeaderboardRankQuery, + GetLeaderboardDailyRankResponse, + GetLeaderboardQuery, + GetLeaderboardRankResponse, + GetLeaderboardResponse as GetLeaderboardResponse, + GetWeeklyXpLeaderboardQuery, + GetWeeklyXpLeaderboardRankResponse, + GetWeeklyXpLeaderboardResponse, + LanguageAndModeQuery, +} from "@monkeytype/contracts/leaderboards"; +import { Configuration } from "@monkeytype/shared-types"; export async function getLeaderboard( - req: MonkeyTypes.Request -): Promise { - const { language, mode, mode2, skip, limit = 50 } = req.query; - const { uid } = req.ctx.decodedToken; - - const queryLimit = Math.min(parseInt(limit as string, 10), 50); + req: MonkeyTypes.Request2 +): Promise { + const { language, mode, mode2, skip = 0, limit = 50 } = req.query; const leaderboard = await LeaderboardsDAL.get( - mode as string, - mode2 as string, - language as string, - parseInt(skip as string, 10), - queryLimit + mode, + mode2, + language, + skip, + limit ); if (leaderboard === false) { - return new MonkeyResponse( - "Leaderboard is currently updating. Please try again in a few seconds.", - null, - 503 + throw new MonkeyError( + 503, + "Leaderboard is currently updating. Please try again in a few seconds." ); } - const normalizedLeaderboard = _.map(leaderboard, (entry) => { - return uid && entry.uid === uid - ? entry - : _.omit(entry, ["_id", "difficulty", "language"]); - }); + const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"])); - return new MonkeyResponse("Leaderboard retrieved", normalizedLeaderboard); + return new MonkeyResponse2("Leaderboard retrieved", normalizedLeaderboard); } export async function getRankFromLeaderboard( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { language, mode, mode2 } = req.query; const { uid } = req.ctx.decodedToken; - const data = await LeaderboardsDAL.getRank( - mode as string, - mode2 as string, - language as string, - uid - ); + const data = await LeaderboardsDAL.getRank(mode, mode2, language, uid); if (data === false) { - return new MonkeyResponse( - "Leaderboard is currently updating. Please try again in a few seconds.", - null, - 503 + throw new MonkeyError( + 503, + "Leaderboard is currently updating. Please try again in a few seconds." ); } - return new MonkeyResponse("Rank retrieved", data); + return new MonkeyResponse2("Rank retrieved", data); } function getDailyLeaderboardWithError( - req: MonkeyTypes.Request + { language, mode, mode2, daysBefore }: GetDailyLeaderboardRankQuery, + config: Configuration["dailyLeaderboards"] ): DailyLeaderboards.DailyLeaderboard { - const { language, mode, mode2, daysBefore } = req.query; - - const normalizedDayBefore = parseInt(daysBefore as string, 10); - const currentDayTimestamp = getCurrentDayTimestamp(); - const dayBeforeTimestamp = - currentDayTimestamp - normalizedDayBefore * MILLISECONDS_IN_DAY; - - const customTimestamp = _.isNil(daysBefore) ? -1 : dayBeforeTimestamp; + const customTimestamp = + daysBefore === undefined + ? -1 + : getCurrentDayTimestamp() - daysBefore * MILLISECONDS_IN_DAY; const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard( - language as string, - mode as string, - mode2 as string, - req.ctx.configuration.dailyLeaderboards, + language, + mode, + mode2, + config, customTimestamp ); if (!dailyLeaderboard) { @@ -93,14 +89,17 @@ function getDailyLeaderboardWithError( } export async function getDailyLeaderboard( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { skip = 0, limit = 50 } = req.query; - const dailyLeaderboard = getDailyLeaderboardWithError(req); + const dailyLeaderboard = getDailyLeaderboardWithError( + req.query, + req.ctx.configuration.dailyLeaderboards + ); - const minRank = parseInt(skip as string, 10); - const maxRank = minRank + parseInt(limit as string, 10) - 1; + const minRank = skip; + const maxRank = minRank + limit - 1; const topResults = await dailyLeaderboard.getResults( minRank, @@ -109,40 +108,37 @@ export async function getDailyLeaderboard( req.ctx.configuration.users.premium.enabled ); - return new MonkeyResponse("Daily leaderboard retrieved", topResults); + return new MonkeyResponse2("Daily leaderboard retrieved", topResults); } export async function getDailyLeaderboardRank( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; - const dailyLeaderboard = getDailyLeaderboardWithError(req); + const dailyLeaderboard = getDailyLeaderboardWithError( + req.query, + req.ctx.configuration.dailyLeaderboards + ); const rank = await dailyLeaderboard.getRank( uid, req.ctx.configuration.dailyLeaderboards ); - return new MonkeyResponse("Daily leaderboard rank retrieved", rank); + return new MonkeyResponse2("Daily leaderboard rank retrieved", rank); } function getWeeklyXpLeaderboardWithError( - req: MonkeyTypes.Request + { weeksBefore }: GetWeeklyXpLeaderboardQuery, + config: Configuration["leaderboards"]["weeklyXp"] ): WeeklyXpLeaderboard.WeeklyXpLeaderboard { - const { weeksBefore } = req.query; - - const normalizedWeeksBefore = parseInt(weeksBefore as string, 10); - const currentWeekTimestamp = getCurrentWeekTimestamp(); - const weekBeforeTimestamp = - currentWeekTimestamp - normalizedWeeksBefore * MILLISECONDS_IN_DAY * 7; - - const customTimestamp = _.isNil(weeksBefore) ? -1 : weekBeforeTimestamp; + const customTimestamp = + weeksBefore === undefined + ? -1 + : getCurrentWeekTimestamp() - weeksBefore * MILLISECONDS_IN_DAY * 7; - const weeklyXpLeaderboard = WeeklyXpLeaderboard.get( - req.ctx.configuration.leaderboards.weeklyXp, - customTimestamp - ); + const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(config, customTimestamp); if (!weeklyXpLeaderboard) { throw new MonkeyError(404, "XP leaderboard for this week not found."); } @@ -151,33 +147,39 @@ function getWeeklyXpLeaderboardWithError( } export async function getWeeklyXpLeaderboardResults( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { skip = 0, limit = 50 } = req.query; - const minRank = parseInt(skip as string, 10); - const maxRank = minRank + parseInt(limit as string, 10) - 1; + const minRank = skip; + const maxRank = minRank + limit - 1; - const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req); + const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError( + req.query, + req.ctx.configuration.leaderboards.weeklyXp + ); const results = await weeklyXpLeaderboard.getResults( minRank, maxRank, req.ctx.configuration.leaderboards.weeklyXp ); - return new MonkeyResponse("Weekly xp leaderboard retrieved", results); + return new MonkeyResponse2("Weekly xp leaderboard retrieved", results); } export async function getWeeklyXpLeaderboardRank( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; - const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req); + const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError( + {}, + req.ctx.configuration.leaderboards.weeklyXp + ); const rankEntry = await weeklyXpLeaderboard.getRank( uid, req.ctx.configuration.leaderboards.weeklyXp ); - return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry); + return new MonkeyResponse2("Weekly xp leaderboard rank retrieved", rankEntry); } diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 659a541804cc..86eec0bc533e 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -42,7 +42,6 @@ const APP_START_TIME = Date.now(); const API_ROUTE_MAP = { "/users": users, "/results": results, - "/leaderboards": leaderboards, "/quotes": quotes, "/webhooks": webhooks, "/docs": docs, @@ -56,6 +55,7 @@ const router = s.router(contract, { presets, psas, public: publicStats, + leaderboards, }); export function addApiRoutes(app: Application): void { @@ -123,7 +123,9 @@ function applyDevApiRoutes(app: Application): void { app.use(async (req, res, next) => { const slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs; if (slowdown > 0) { - Logger.info(`Simulating ${slowdown}ms delay for ${req.path}`); + Logger.info( + `Simulating ${slowdown}ms delay for ${req.method} ${req.path}` + ); await new Promise((resolve) => setTimeout(resolve, slowdown)); } next(); diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index 9b2705e3aec0..08fffee8eb12 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -1,41 +1,11 @@ -import joi from "joi"; -import { Router } from "express"; -import * as RateLimit from "../../middlewares/rate-limit"; +import { initServer } from "@ts-rest/express"; import { withApeRateLimiter } from "../../middlewares/ape-rate-limit"; -import { authenticateRequest } from "../../middlewares/auth"; -import * as LeaderboardController from "../controllers/leaderboard"; import { validate } from "../../middlewares/configuration"; -import { validateRequest } from "../../middlewares/validation"; -import { asyncHandler } from "../../middlewares/utility"; - -const BASE_LEADERBOARD_VALIDATION_SCHEMA = { - language: joi - .string() - .max(50) - .pattern(/^[a-zA-Z0-9_+]+$/) - .required(), - mode: joi - .string() - .valid("time", "words", "quote", "zen", "custom") - .required(), - mode2: joi - .string() - .regex(/^(\d)+|custom|zen/) - .required(), -}; - -const LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT = { - ...BASE_LEADERBOARD_VALIDATION_SCHEMA, - skip: joi.number().min(0), - limit: joi.number().min(0).max(50), -}; - -const DAILY_LEADERBOARD_VALIDATION_SCHEMA = { - ...LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT, - daysBefore: joi.number().min(1).max(1), -}; +import * as RateLimit from "../../middlewares/rate-limit"; +import * as LeaderboardController from "../controllers/leaderboard"; -const router = Router(); +import { leaderboardsContract } from "@monkeytype/contracts/leaderboards"; +import { callController } from "../ts-rest-adapter"; const requireDailyLeaderboardsEnabled = validate({ criteria: (configuration) => { @@ -44,58 +14,6 @@ const requireDailyLeaderboardsEnabled = validate({ invalidMessage: "Daily leaderboards are not available at this time.", }); -router.get( - "/", - authenticateRequest({ isPublic: true }), - withApeRateLimiter(RateLimit.leaderboardsGet), - validateRequest({ - query: LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT, - }), - asyncHandler(LeaderboardController.getLeaderboard) -); - -router.get( - "/rank", - authenticateRequest({ acceptApeKeys: true }), - withApeRateLimiter(RateLimit.leaderboardsGet), - validateRequest({ - query: BASE_LEADERBOARD_VALIDATION_SCHEMA, - }), - asyncHandler(LeaderboardController.getRankFromLeaderboard) -); - -router.get( - "/daily", - requireDailyLeaderboardsEnabled, - authenticateRequest({ isPublic: true }), - RateLimit.leaderboardsGet, - validateRequest({ - query: DAILY_LEADERBOARD_VALIDATION_SCHEMA, - }), - asyncHandler(LeaderboardController.getDailyLeaderboard) -); - -router.get( - "/daily/rank", - requireDailyLeaderboardsEnabled, - authenticateRequest(), - RateLimit.leaderboardsGet, - validateRequest({ - query: DAILY_LEADERBOARD_VALIDATION_SCHEMA, - }), - asyncHandler(LeaderboardController.getDailyLeaderboardRank) -); - -const BASE_XP_LEADERBOARD_VALIDATION_SCHEMA = { - skip: joi.number().min(0), - limit: joi.number().min(0).max(50), -}; - -const WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA = { - ...BASE_XP_LEADERBOARD_VALIDATION_SCHEMA, - weeksBefore: joi.number().min(1).max(1), -}; - const requireWeeklyXpLeaderboardEnabled = validate({ criteria: (configuration) => { return configuration.leaderboards.weeklyXp.enabled; @@ -103,23 +21,36 @@ const requireWeeklyXpLeaderboardEnabled = validate({ invalidMessage: "Weekly XP leaderboards are not available at this time.", }); -router.get( - "/xp/weekly", - requireWeeklyXpLeaderboardEnabled, - authenticateRequest({ isPublic: true }), - withApeRateLimiter(RateLimit.leaderboardsGet), - validateRequest({ - query: WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA, - }), - asyncHandler(LeaderboardController.getWeeklyXpLeaderboardResults) -); - -router.get( - "/xp/weekly/rank", - requireWeeklyXpLeaderboardEnabled, - authenticateRequest(), - withApeRateLimiter(RateLimit.leaderboardsGet), - asyncHandler(LeaderboardController.getWeeklyXpLeaderboardRank) -); - -export default router; +const s = initServer(); +export default s.router(leaderboardsContract, { + get: { + middleware: [RateLimit.leaderboardsGet], + handler: async (r) => + callController(LeaderboardController.getLeaderboard)(r), + }, + getRank: { + middleware: [withApeRateLimiter(RateLimit.leaderboardsGet)], + handler: async (r) => + callController(LeaderboardController.getRankFromLeaderboard)(r), + }, + getDaily: { + middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet], + handler: async (r) => + callController(LeaderboardController.getDailyLeaderboard)(r), + }, + getDailyRank: { + middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet], + handler: async (r) => + callController(LeaderboardController.getDailyLeaderboardRank)(r), + }, + getWeeklyXp: { + middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet], + handler: async (r) => + callController(LeaderboardController.getWeeklyXpLeaderboardResults)(r), + }, + getWeeklyXpRank: { + middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet], + handler: async (r) => + callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r), + }, +}); diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 18a84508ac56..7868b4031aee 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -4,8 +4,27 @@ import { performance } from "perf_hooks"; import { setLeaderboard } from "../utils/prometheus"; import { isDevEnvironment } from "../utils/misc"; import { getCachedConfiguration } from "../init/configuration"; -import { LeaderboardEntry } from "@monkeytype/shared-types"; + import { addLog } from "./logs"; +import { Collection } from "mongodb"; +import { + LeaderboardEntry, + LeaderboardRank, +} from "@monkeytype/contracts/schemas/leaderboards"; +import { omit } from "lodash"; + +export type DBLeaderboardEntry = LeaderboardEntry & { + _id: ObjectId; +}; + +export const getCollection = (key: { + language: string; + mode: string; + mode2: string; +}): Collection => + db.collection( + `leaderboards.${key.language}.${key.mode}.${key.mode2}` + ); export async function get( mode: string, @@ -13,14 +32,13 @@ export async function get( language: string, skip: number, limit = 50 -): Promise { +): Promise { //if (leaderboardUpdating[`${language}_${mode}_${mode2}`]) return false; if (limit > 50 || limit <= 0) limit = 50; if (skip < 0) skip = 0; try { - const preset = await db - .collection(`leaderboards.${language}.${mode}.${mode2}`) + const preset = await getCollection({ language, mode, mode2 }) .find() .sort({ rank: 1 }) .skip(skip) @@ -31,8 +49,9 @@ export async function get( .premium.enabled; if (!premiumFeaturesEnabled) { - preset.forEach((it) => (it.isPremium = undefined)); + return preset.map((it) => omit(it, "isPremium")); } + return preset; } catch (e) { if (e.error === 175) { @@ -43,30 +62,26 @@ export async function get( } } -type GetRankResponse = { - count: number; - rank: number | null; - entry: LeaderboardEntry | null; -}; - export async function getRank( mode: string, mode2: string, language: string, uid: string -): Promise { +): Promise { try { - const entry = await db - .collection(`leaderboards.${language}.${mode}.${mode2}`) - .findOne({ uid }); - const count = await db - .collection(`leaderboards.${language}.${mode}.${mode2}`) - .estimatedDocumentCount(); + const entry = await getCollection({ language, mode, mode2 }).findOne({ + uid, + }); + const count = await getCollection({ + language, + mode, + mode2, + }).estimatedDocumentCount(); return { count, - rank: entry ? entry.rank : null, - entry, + rank: entry?.rank, + entry: entry !== null ? entry : undefined, }; } catch (e) { if (e.error === 175) { diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 893de43c7b00..a294417f6e07 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -21,7 +21,6 @@ import { CustomTheme, DBResult, MonkeyMail, - ResultFilters, UserInventory, UserProfileDetails, UserQuoteRatings, @@ -33,6 +32,7 @@ import { PersonalBest, } from "@monkeytype/contracts/schemas/shared"; import { addImportantLog } from "./logs"; +import { ResultFilters } from "@monkeytype/contracts/schemas/users"; const SECONDS_PER_HOUR = 3600; diff --git a/backend/src/documentation/internal-swagger.json b/backend/src/documentation/internal-swagger.json index a999ae0da4cd..7bcd21a78aab 100644 --- a/backend/src/documentation/internal-swagger.json +++ b/backend/src/documentation/internal-swagger.json @@ -23,10 +23,6 @@ "name": "users", "description": "User data and related operations" }, - { - "name": "leaderboards", - "description": "Leaderboard data" - }, { "name": "results", "description": "Result data and related operations" @@ -412,78 +408,6 @@ } } }, - "/leaderboards": { - "get": { - "tags": ["leaderboards"], - "summary": "Gets a leaderboard", - "parameters": [ - { - "in": "query", - "name": "language", - "type": "string" - }, - { - "in": "query", - "name": "mode", - "type": "string" - }, - { - "in": "query", - "name": "mode2", - "type": "string" - }, - { - "in": "query", - "name": "skip", - "type": "number" - }, - { - "in": "query", - "name": "limit", - "type": "number" - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/leaderboards/rank": { - "get": { - "tags": ["leaderboards"], - "summary": "Gets a user's rank from a leaderboard", - "parameters": [ - { - "in": "query", - "name": "language", - "type": "string" - }, - { - "in": "query", - "name": "mode", - "type": "string" - }, - { - "in": "query", - "name": "mode2", - "type": "string" - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, "/results": { "get": { "tags": ["results"], diff --git a/backend/src/documentation/public-swagger.json b/backend/src/documentation/public-swagger.json index f77795b0e176..c5a2cdc903cd 100644 --- a/backend/src/documentation/public-swagger.json +++ b/backend/src/documentation/public-swagger.json @@ -20,10 +20,6 @@ "name": "users", "description": "User data and related operations" }, - { - "name": "leaderboards", - "description": "Leaderboard data and related operations" - }, { "name": "results", "description": "User results data and related operations" @@ -201,97 +197,6 @@ } } } - }, - "/leaderboards": { - "get": { - "tags": ["leaderboards"], - "summary": "Gets global leaderboard data", - "parameters": [ - { - "name": "language", - "in": "query", - "description": "The leaderboard's language (i.e., english)", - "required": true, - "type": "string" - }, - { - "name": "mode", - "in": "query", - "description": "The primary mode (i.e., time)", - "required": true, - "type": "string" - }, - { - "name": "mode2", - "in": "query", - "description": "The secondary mode (i.e., 60)", - "required": true, - "type": "string" - }, - { - "name": "skip", - "in": "query", - "description": "How many leaderboard entries to skip", - "required": false, - "type": "number", - "minimum": 0 - }, - { - "name": "limit", - "in": "query", - "description": "How many leaderboard entries to request", - "required": false, - "type": "number", - "minimum": 0, - "maximum": 50 - } - ], - "responses": { - "200": { - "description": "", - "schema": { - "$ref": "#/definitions/LeaderboardEntry" - } - } - } - } - }, - "/leaderboards/rank": { - "get": { - "tags": ["leaderboards"], - "summary": "Gets your qualifying rank from a leaderboard", - "parameters": [ - { - "name": "language", - "in": "query", - "description": "The leaderboard's language (i.e., english)", - "required": true, - "type": "string" - }, - { - "name": "mode", - "in": "query", - "description": "The primary mode (i.e., time)", - "required": true, - "type": "string" - }, - { - "name": "mode2", - "in": "query", - "description": "The secondary mode (i.e., 60)", - "required": true, - "type": "string" - } - ], - "responses": { - "200": { - "description": "", - "schema": { - "$ref": "#/definitions/LeaderboardEntry" - } - } - } - } } }, "definitions": { @@ -606,70 +511,6 @@ } } }, - "LeaderboardEntry": { - "type": "object", - "properties": { - "uid": { - "type": "string", - "example": "6226b17aebc27a4a8d1ce04b" - }, - "acc": { - "type": "number", - "format": "double", - "example": 97.96 - }, - "consistency": { - "type": "number", - "format": "double", - "example": 83.29 - }, - "lazyMode": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "Miodec" - }, - "punctuation": { - "type": "boolean", - "example": false - }, - "rank": { - "type": "integer", - "example": 3506 - }, - "raw": { - "type": "number", - "format": "double", - "example": 145.18 - }, - "wpm": { - "type": "number", - "format": "double", - "example": 141.18 - }, - "timestamp": { - "type": "integer", - "example": 1644438189583 - }, - "discordId": { - "type": "string", - "example": "974761412044437307" - }, - "discordAvatar": { - "type": "string", - "example": "6226b17aebc27a4a8d1ce04b" - }, - "badgeIds": { - "type": "array", - "items": { - "type": "integer", - "example": 1 - } - } - } - }, "Results": { "type": "array", "items": { diff --git a/backend/src/jobs/update-leaderboards.ts b/backend/src/jobs/update-leaderboards.ts index 2310bd76448e..e6e219c07c49 100644 --- a/backend/src/jobs/update-leaderboards.ts +++ b/backend/src/jobs/update-leaderboards.ts @@ -2,20 +2,21 @@ import { CronJob } from "cron"; import GeorgeQueue from "../queues/george-queue"; import * as LeaderboardsDAL from "../dal/leaderboards"; import { getCachedConfiguration } from "../init/configuration"; -import { LeaderboardEntry } from "@monkeytype/shared-types"; const CRON_SCHEDULE = "30 14/15 * * * *"; const RECENT_AGE_MINUTES = 10; const RECENT_AGE_MILLISECONDS = RECENT_AGE_MINUTES * 60 * 1000; -async function getTop10(leaderboardTime: string): Promise { +async function getTop10( + leaderboardTime: string +): Promise { return (await LeaderboardsDAL.get( "time", leaderboardTime, "english", 0, 10 - )) as LeaderboardEntry[]; //can do that because gettop10 will not be called during an update + )) as LeaderboardsDAL.DBLeaderboardEntry[]; //can do that because gettop10 will not be called during an update } async function updateLeaderboardAndNotifyChanges( diff --git a/backend/src/queues/george-queue.ts b/backend/src/queues/george-queue.ts index d1d15831029b..fb85733b7652 100644 --- a/backend/src/queues/george-queue.ts +++ b/backend/src/queues/george-queue.ts @@ -1,5 +1,4 @@ -import { LeaderboardEntry } from "@monkeytype/shared-types"; -import { type LbEntryWithRank } from "../utils/daily-leaderboards"; +import { LeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards"; import { MonkeyQueue } from "./monkey-queue"; const QUEUE_NAME = "george-tasks"; @@ -62,7 +61,7 @@ class GeorgeQueue extends MonkeyQueue { } async announceLeaderboardUpdate( - newRecords: LeaderboardEntry[], + newRecords: Omit[], leaderboardId: string ): Promise { const taskName = "announceLeaderboardUpdate"; @@ -90,7 +89,7 @@ class GeorgeQueue extends MonkeyQueue { async announceDailyLeaderboardTopResults( leaderboardId: string, leaderboardTimestamp: number, - topResults: LbEntryWithRank[] + topResults: LeaderboardEntry[] ): Promise { const taskName = "announceDailyLeaderboardTopResults"; diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index 8ab2785fc3d0..80ee397828c8 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -2,25 +2,21 @@ import { Configuration } from "@monkeytype/shared-types"; import * as RedisClient from "../init/redis"; import LaterQueue from "../queues/later-queue"; import { getCurrentWeekTimestamp } from "../utils/misc"; - -type InternalWeeklyXpLeaderboardEntry = { - uid: string; - name: string; - discordAvatar?: string; - discordId?: string; - badgeId?: number; - lastActivityTimestamp: number; -}; - -type WeeklyXpLeaderboardEntry = { - totalXp: number; - rank: number; - count?: number; - timeTypedSeconds: number; -} & InternalWeeklyXpLeaderboardEntry; +import { + XpLeaderboardEntry, + XpLeaderboardRank, +} from "@monkeytype/contracts/schemas/leaderboards"; type AddResultOpts = { - entry: InternalWeeklyXpLeaderboardEntry; + entry: Pick< + XpLeaderboardEntry, + | "uid" + | "name" + | "discordId" + | "discordAvatar" + | "badgeId" + | "lastActivityTimestamp" + >; xpGained: number; timeTypedSeconds: number; }; @@ -123,7 +119,7 @@ export class WeeklyXpLeaderboard { minRank: number, maxRank: number, weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"] - ): Promise { + ): Promise { const connection = RedisClient.getConnection(); if (!connection || !weeklyXpLeaderboardConfig.enabled) { return []; @@ -154,10 +150,10 @@ export class WeeklyXpLeaderboard { ); } - const resultsWithRanks: WeeklyXpLeaderboardEntry[] = results.map( + const resultsWithRanks: XpLeaderboardEntry[] = results.map( (resultJSON: string, index: number) => { //TODO parse with zod? - const parsed = JSON.parse(resultJSON) as WeeklyXpLeaderboardEntry; + const parsed = JSON.parse(resultJSON) as XpLeaderboardEntry; return { ...parsed, @@ -173,7 +169,7 @@ export class WeeklyXpLeaderboard { public async getRank( uid: string, weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"] - ): Promise { + ): Promise { const connection = RedisClient.getConnection(); if (!connection || !weeklyXpLeaderboardConfig.enabled) { return null; @@ -201,7 +197,7 @@ export class WeeklyXpLeaderboard { //TODO parse with zod? const parsed = JSON.parse(result ?? "null") as Omit< - WeeklyXpLeaderboardEntry, + XpLeaderboardEntry, "rank" | "count" | "totalXp" >; diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index 3ad1773a09eb..e013d8d94412 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -1,33 +1,13 @@ -import _ from "lodash"; +import _, { omit } from "lodash"; import * as RedisClient from "../init/redis"; import LaterQueue from "../queues/later-queue"; import { getCurrentDayTimestamp, matchesAPattern, kogascore } from "./misc"; import { Configuration, ValidModeRule } from "@monkeytype/shared-types"; - -type DailyLeaderboardEntry = { - uid: string; - name: string; - wpm: number; - raw: number; - acc: number; - consistency: number; - timestamp: number; - discordAvatar?: string; - discordId?: string; - badgeId?: number; - isPremium?: boolean; -}; - -type GetRankResponse = { - minWpm: number; - count: number; - rank: number | null; - entry: DailyLeaderboardEntry | null; -}; - -export type LbEntryWithRank = { - rank: number; -} & DailyLeaderboardEntry; +import { + DailyLeaderboardRank, + LeaderboardEntry, +} from "@monkeytype/contracts/schemas/leaderboards"; +import MonkeyError from "./error"; const dailyLeaderboardNamespace = "monkeytype:dailyleaderboard"; const scoresNamespace = `${dailyLeaderboardNamespace}:scores`; @@ -68,7 +48,7 @@ export class DailyLeaderboard { } public async addResult( - entry: DailyLeaderboardEntry, + entry: Omit, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"] ): Promise { const connection = RedisClient.getConnection(); @@ -127,7 +107,7 @@ export class DailyLeaderboard { maxRank: number, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], premiumFeaturesEnabled: boolean - ): Promise { + ): Promise { const connection = RedisClient.getConnection(); if (!connection || !dailyLeaderboardsConfig.enabled) { return []; @@ -152,10 +132,10 @@ export class DailyLeaderboard { ); } - const resultsWithRanks: LbEntryWithRank[] = results.map( + const resultsWithRanks: LeaderboardEntry[] = results.map( (resultJSON, index) => { // TODO: parse with zod? - const parsed = JSON.parse(resultJSON) as LbEntryWithRank; + const parsed = JSON.parse(resultJSON) as LeaderboardEntry; return { ...parsed, @@ -165,7 +145,7 @@ export class DailyLeaderboard { ); if (!premiumFeaturesEnabled) { - resultsWithRanks.forEach((it) => (it.isPremium = undefined)); + return resultsWithRanks.map((it) => omit(it, "isPremium")); } return resultsWithRanks; @@ -174,10 +154,10 @@ export class DailyLeaderboard { public async getRank( uid: string, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"] - ): Promise { + ): Promise { const connection = RedisClient.getConnection(); if (!connection || !dailyLeaderboardsConfig.enabled) { - return null; + throw new MonkeyError(500, "Redis connnection is unavailable"); } const { leaderboardScoresKey, leaderboardResultsKey } = @@ -198,8 +178,6 @@ export class DailyLeaderboard { return { minWpm, count: count ?? 0, - rank: null, - entry: null, }; } diff --git a/backend/vitest.config.js b/backend/vitest.config.js index aacbb1a01883..641f8f5a983e 100644 --- a/backend/vitest.config.js +++ b/backend/vitest.config.js @@ -6,7 +6,6 @@ export default defineConfig({ environment: "node", globalSetup: "__tests__/global-setup.ts", setupFiles: ["__tests__/setup-tests.ts"], - pool: "forks", coverage: { include: ["**/*.ts"], diff --git a/frontend/__tests__/elements/account/result-filters.spec.ts b/frontend/__tests__/elements/account/result-filters.spec.ts new file mode 100644 index 000000000000..f5d1bd15dc25 --- /dev/null +++ b/frontend/__tests__/elements/account/result-filters.spec.ts @@ -0,0 +1,73 @@ +import defaultResultFilters from "../../../src/ts/constants/default-result-filters"; +import { mergeWithDefaultFilters } from "../../../src/ts/elements/account/result-filters"; + +describe("result-filters.ts", () => { + describe("mergeWithDefaultFilters", () => { + it("should merge with default filters correctly", () => { + const tests = [ + { + input: { + pb: { + no: false, + yes: false, + }, + }, + expected: () => { + const expected = defaultResultFilters; + expected.pb.no = false; + expected.pb.yes = false; + return expected; + }, + }, + { + input: { + words: { + "10": false, + }, + }, + expected: () => { + const expected = defaultResultFilters; + expected.words["10"] = false; + return expected; + }, + }, + { + input: { + blah: true, + }, + expected: () => { + return defaultResultFilters; + }, + }, + { + input: 1, + expected: () => { + return defaultResultFilters; + }, + }, + { + input: null, + expected: () => { + return defaultResultFilters; + }, + }, + { + input: undefined, + expected: () => { + return defaultResultFilters; + }, + }, + { + input: {}, + expected: () => { + return defaultResultFilters; + }, + }, + ]; + tests.forEach((test) => { + const merged = mergeWithDefaultFilters(test.input as any); + expect(merged).toEqual(test.expected()); + }); + }); + }); +}); diff --git a/frontend/__tests__/test/misc.spec.ts b/frontend/__tests__/test/misc.spec.ts index 3f86c02a08f9..9239d3f35d0f 100644 --- a/frontend/__tests__/test/misc.spec.ts +++ b/frontend/__tests__/test/misc.spec.ts @@ -1,8 +1,11 @@ +import { isObject } from "../../src/ts/utils/misc"; import { getLanguageDisplayString, removeLanguageSize, } from "../../src/ts/utils/strings"; +//todo this file is in the wrong place + describe("misc.ts", () => { describe("getLanguageDisplayString", () => { it("should return correctly formatted strings", () => { @@ -72,4 +75,47 @@ describe("misc.ts", () => { }); }); }); + describe("isObject", () => { + it("should correctly identify objects", () => { + const tests = [ + { + input: {}, + expected: true, + }, + { + input: { a: 1 }, + expected: true, + }, + { + input: [], + expected: false, + }, + { + input: [1, 2, 3], + expected: false, + }, + { + input: "string", + expected: false, + }, + { + input: 1, + expected: false, + }, + { + input: null, + expected: false, + }, + { + input: undefined, + expected: false, + }, + ]; + + tests.forEach((test) => { + const result = isObject(test.input); + expect(result).toBe(test.expected); + }); + }); + }); }); diff --git a/frontend/__tests__/utils/local-storage-with-schema.spec.ts b/frontend/__tests__/utils/local-storage-with-schema.spec.ts new file mode 100644 index 000000000000..75db29071dcf --- /dev/null +++ b/frontend/__tests__/utils/local-storage-with-schema.spec.ts @@ -0,0 +1,126 @@ +import { z } from "zod"; +import { LocalStorageWithSchema } from "../../src/ts/utils/local-storage-with-schema"; + +describe("local-storage-with-schema.ts", () => { + describe("LocalStorageWithSchema", () => { + const objectSchema = z.object({ + punctuation: z.boolean(), + mode: z.enum(["words", "time"]), + fontSize: z.number(), + }); + + const defaultObject: z.infer = { + punctuation: true, + mode: "words", + fontSize: 16, + }; + + const ls = new LocalStorageWithSchema({ + key: "config", + schema: objectSchema, + fallback: defaultObject, + }); + + const getItemMock = vi.fn(); + const setItemMock = vi.fn(); + const removeItemMock = vi.fn(); + + vi.stubGlobal("localStorage", { + getItem: getItemMock, + setItem: setItemMock, + removeItem: removeItemMock, + }); + + afterEach(() => { + getItemMock.mockReset(); + setItemMock.mockReset(); + removeItemMock.mockReset(); + }); + + it("should save to localStorage if schema is correct and return true", () => { + const res = ls.set(defaultObject); + + expect(localStorage.setItem).toHaveBeenCalledWith( + "config", + JSON.stringify(defaultObject) + ); + expect(res).toBe(true); + }); + + it("should fail to save to localStorage if schema is incorrect and return false", () => { + const obj = { + hi: "hello", + }; + + const res = ls.set(obj as any); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + expect(res).toBe(false); + }); + + it("should revert to the fallback value if localstorage is null", () => { + getItemMock.mockReturnValue(null); + + const res = ls.get(); + + expect(localStorage.getItem).toHaveBeenCalledWith("config"); + expect(res).toEqual(defaultObject); + }); + + it("should revert to the fallback value and remove if localstorage json is malformed", () => { + getItemMock.mockReturnValue("badjson"); + + const res = ls.get(); + + expect(localStorage.getItem).toHaveBeenCalledWith("config"); + expect(localStorage.removeItem).toHaveBeenCalledWith("config"); + expect(res).toEqual(defaultObject); + }); + + it("should get from localStorage", () => { + getItemMock.mockReturnValue(JSON.stringify(defaultObject)); + + const res = ls.get(); + + expect(localStorage.getItem).toHaveBeenCalledWith("config"); + expect(res).toEqual(defaultObject); + }); + + it("should revert to fallback value if no migrate function and schema failed", () => { + getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" })); + const ls = new LocalStorageWithSchema({ + key: "config", + schema: objectSchema, + fallback: defaultObject, + }); + + const res = ls.get(); + + expect(localStorage.getItem).toHaveBeenCalledWith("config"); + expect(res).toEqual(defaultObject); + }); + + it("should migrate (when function is provided) if schema failed", () => { + getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" })); + + const migrated = { + punctuation: false, + mode: "time", + fontSize: 1, + }; + const ls = new LocalStorageWithSchema({ + key: "config", + schema: objectSchema, + fallback: defaultObject, + migrate: () => { + return migrated; + }, + }); + + const res = ls.get(); + + expect(localStorage.getItem).toHaveBeenCalledWith("config"); + expect(res).toEqual(migrated); + }); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 6805987e268c..ec993b2e643a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,7 +42,7 @@ "@types/object-hash": "2.2.1", "@types/subset-font": "1.4.3", "@types/throttle-debounce": "2.1.0", - "@vitest/coverage-v8": "1.6.0", + "@vitest/coverage-v8": "2.0.5", "ajv": "8.12.0", "autoprefixer": "10.4.20", "concurrently": "8.2.2", @@ -66,7 +66,7 @@ "vite-plugin-html-inject": "1.1.2", "vite-plugin-inspect": "0.8.3", "vite-plugin-pwa": "0.20.0", - "vitest": "1.6.0" + "vitest": "2.0.5" }, "dependencies": { "@date-fns/utc": "1.2.0", diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index 609c791b99ad..2e31c23f9bec 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -1,4 +1,3 @@ -import Leaderboards from "./leaderboards"; import Quotes from "./quotes"; import Results from "./results"; import Users from "./users"; @@ -6,7 +5,6 @@ import Configuration from "./configuration"; import Dev from "./dev"; export default { - Leaderboards, Quotes, Results, Users, diff --git a/frontend/src/ts/ape/endpoints/leaderboards.ts b/frontend/src/ts/ape/endpoints/leaderboards.ts deleted file mode 100644 index 14570ff37d5e..000000000000 --- a/frontend/src/ts/ape/endpoints/leaderboards.ts +++ /dev/null @@ -1,53 +0,0 @@ -const BASE_PATH = "/leaderboards"; - -export default class Leaderboards { - constructor(private httpClient: Ape.HttpClient) { - this.httpClient = httpClient; - } - - async get( - query: Ape.Leaderboards.QueryWithPagination - ): Ape.EndpointResponse { - const { - language, - mode, - mode2, - isDaily, - skip = 0, - limit = 50, - daysBefore, - } = query; - const includeDaysBefore = (isDaily ?? false) && (daysBefore ?? 0) > 0; - - const searchQuery = { - language, - mode, - mode2, - skip: Math.max(skip, 0), - limit: Math.max(Math.min(limit, 50), 0), - ...(includeDaysBefore && { daysBefore }), - }; - - const endpointPath = `${BASE_PATH}/${isDaily ? "daily" : ""}`; - - return await this.httpClient.get(endpointPath, { searchQuery }); - } - - async getRank( - query: Ape.Leaderboards.Query - ): Ape.EndpointResponse { - const { language, mode, mode2, isDaily, daysBefore } = query; - const includeDaysBefore = (isDaily ?? false) && (daysBefore ?? 0) > 0; - - const searchQuery = { - language, - mode, - mode2, - ...(includeDaysBefore && { daysBefore }), - }; - - const endpointPath = `${BASE_PATH}${isDaily ? "/daily" : ""}/rank`; - - return await this.httpClient.get(endpointPath, { searchQuery }); - } -} diff --git a/frontend/src/ts/ape/endpoints/users.ts b/frontend/src/ts/ape/endpoints/users.ts index fab02dfd57d6..fda651de7361 100644 --- a/frontend/src/ts/ape/endpoints/users.ts +++ b/frontend/src/ts/ape/endpoints/users.ts @@ -1,12 +1,12 @@ import { CountByYearAndDay, CustomTheme, - ResultFilters, UserProfile, UserProfileDetails, UserTag, } from "@monkeytype/shared-types"; import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared"; +import { ResultFilters } from "@monkeytype/contracts/schemas/users"; const BASE_PATH = "/users"; diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 46ddac7b3aef..4244f6aa4c51 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -17,7 +17,6 @@ const Ape = { users: new endpoints.Users(httpClient), results: new endpoints.Results(httpClient), quotes: new endpoints.Quotes(httpClient), - leaderboards: new endpoints.Leaderboards(httpClient), configuration: new endpoints.Configuration(httpClient), dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)), }; diff --git a/frontend/src/ts/ape/types/leaderboards.d.ts b/frontend/src/ts/ape/types/leaderboards.d.ts deleted file mode 100644 index 02f95db35a74..000000000000 --- a/frontend/src/ts/ape/types/leaderboards.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -// for some reason when using the dot notaion, the types are not being recognized as used -declare namespace Ape.Leaderboards { - type Query = { - language: string; - mode: Config.Mode; - mode2: string; - isDaily?: boolean; - daysBefore?: number; - }; - - type QueryWithPagination = { - skip?: number; - limit?: number; - } & Query; - - type GetLeaderboard = LeaderboardEntry[]; - - type GetRank = { - minWpm: number; - count: number; - rank: number | null; - entry: import("@monkeytype/shared-types").LeaderboardEntry | null; - }; -} diff --git a/frontend/src/ts/commandline/lists/sound-on-error.ts b/frontend/src/ts/commandline/lists/sound-on-error.ts index 4022e6fe5483..c04f017fba43 100644 --- a/frontend/src/ts/commandline/lists/sound-on-error.ts +++ b/frontend/src/ts/commandline/lists/sound-on-error.ts @@ -17,6 +17,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = { id: "setPlaySoundOnError1", display: "damage", configValue: "1", + hover: (): void => { + void SoundController.previewError("1"); + }, exec: (): void => { UpdateConfig.setPlaySoundOnError("1"); void SoundController.playError(); @@ -26,6 +29,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = { id: "setPlaySoundOnError2", display: "triangle", configValue: "2", + hover: (): void => { + void SoundController.previewError("2"); + }, exec: (): void => { UpdateConfig.setPlaySoundOnError("2"); void SoundController.playError(); @@ -35,6 +41,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = { id: "setPlaySoundOnError3", display: "square", configValue: "3", + hover: (): void => { + void SoundController.previewError("3"); + }, exec: (): void => { UpdateConfig.setPlaySoundOnError("3"); void SoundController.playError(); @@ -44,6 +53,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = { id: "setPlaySoundOnError3", display: "punch miss", configValue: "4", + hover: (): void => { + void SoundController.previewError("4"); + }, exec: (): void => { UpdateConfig.setPlaySoundOnError("4"); void SoundController.playError(); diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts index 7f92b705e2c7..553d8c014099 100644 --- a/frontend/src/ts/config.ts +++ b/frontend/src/ts/config.ts @@ -16,13 +16,35 @@ import { canSetConfigWithCurrentFunboxes, canSetFunboxWithConfig, } from "./test/funbox/funbox-validation"; -import { isDevEnvironment, reloadAfter, typedKeys } from "./utils/misc"; +import { + isDevEnvironment, + isObject, + reloadAfter, + typedKeys, +} from "./utils/misc"; import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs"; import { Config } from "@monkeytype/contracts/schemas/configs"; import { roundTo1 } from "./utils/numbers"; import { Mode, ModeSchema } from "@monkeytype/contracts/schemas/shared"; +import { Language, LanguageSchema } from "@monkeytype/contracts/schemas/util"; +import { LocalStorageWithSchema } from "./utils/local-storage-with-schema"; +import { mergeWithDefaultConfig } from "./utils/config"; + +const configLS = new LocalStorageWithSchema({ + key: "config", + schema: ConfigSchemas.ConfigSchema, + fallback: DefaultConfig, + migrate: (value, _issues) => { + if (!isObject(value)) { + return DefaultConfig; + } -export let localStorageConfig: Config; + const configWithoutLegacyValues = replaceLegacyValues(value); + const merged = mergeWithDefaultConfig(configWithoutLegacyValues); + + return merged; + }, +}); let loadDone: (value?: unknown) => void; @@ -47,29 +69,25 @@ function saveToLocalStorage( noDbCheck = false ): void { if (nosave) return; - - const localToSave = config; - - const localToSaveStringified = JSON.stringify(localToSave); - window.localStorage.setItem("config", localToSaveStringified); + configLS.set(config); if (!noDbCheck) { //@ts-expect-error this is fine configToSend[key] = config[key]; saveToDatabase(); } + const localToSaveStringified = JSON.stringify(config); ConfigEvent.dispatch("saveToLocalStorage", localToSaveStringified); } export function saveFullConfigToLocalStorage(noDbCheck = false): void { console.log("saving full config to localStorage"); - const save = config; - const stringified = JSON.stringify(save); - window.localStorage.setItem("config", stringified); + configLS.set(config); if (!noDbCheck) { AccountButton.loading(true); - void DB.saveConfig(save); + void DB.saveConfig(config); AccountButton.loading(false); } + const stringified = JSON.stringify(config); ConfigEvent.dispatch("saveToLocalStorage", stringified); } @@ -1565,12 +1583,8 @@ export function setCustomThemeColors( return true; } -export function setLanguage( - language: ConfigSchemas.Language, - nosave?: boolean -): boolean { - if (!isConfigValueValid("language", language, ConfigSchemas.LanguageSchema)) - return false; +export function setLanguage(language: Language, nosave?: boolean): boolean { + if (!isConfigValueValid("language", language, LanguageSchema)) return false; config.language = language; void AnalyticsController.log("changedLanguage", { language }); @@ -1980,8 +1994,6 @@ export async function apply( ConfigEvent.dispatch("fullConfigChange"); - configToApply = replaceLegacyValues(configToApply); - const configObj = configToApply as Config; (Object.keys(DefaultConfig) as (keyof Config)[]).forEach((configKey) => { if (configObj[configKey] === undefined) { @@ -2098,33 +2110,19 @@ export async function reset(): Promise { export async function loadFromLocalStorage(): Promise { console.log("loading localStorage config"); - const newConfigString = window.localStorage.getItem("config"); - let newConfig: Config; - if ( - newConfigString !== undefined && - newConfigString !== null && - newConfigString !== "" - ) { - try { - newConfig = JSON.parse(newConfigString); - } catch (e) { - newConfig = {} as Config; - } + const newConfig = configLS.get(); + if (newConfig === undefined) { + await reset(); + } else { await apply(newConfig); - localStorageConfig = newConfig; saveFullConfigToLocalStorage(true); - } else { - await reset(); } - // TestLogic.restart(false, true); loadDone(); } -function replaceLegacyValues( - configToApply: ConfigSchemas.PartialConfig | MonkeyTypes.ConfigChanges -): ConfigSchemas.Config | MonkeyTypes.ConfigChanges { - const configObj = configToApply as ConfigSchemas.Config; - +export function replaceLegacyValues( + configObj: ConfigSchemas.PartialConfig +): ConfigSchemas.PartialConfig { //@ts-expect-error if (configObj.quickTab === true) { configObj.quickRestart = "tab"; @@ -2162,7 +2160,7 @@ function replaceLegacyValues( if (configObj.showLiveWpm === true) { let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini"; if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") { - val = configObj.timerStyle; + val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle; } configObj.liveSpeedStyle = val; } @@ -2171,7 +2169,7 @@ function replaceLegacyValues( if (configObj.showLiveBurst === true) { let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini"; if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") { - val = configObj.timerStyle; + val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle; } configObj.liveBurstStyle = val; } @@ -2180,7 +2178,7 @@ function replaceLegacyValues( if (configObj.showLiveAcc === true) { let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini"; if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") { - val = configObj.timerStyle; + val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle; } configObj.liveAccStyle = val; } diff --git a/frontend/src/ts/constants/default-config.ts b/frontend/src/ts/constants/default-config.ts index dccf7854182e..a43c0577ad24 100644 --- a/frontend/src/ts/constants/default-config.ts +++ b/frontend/src/ts/constants/default-config.ts @@ -3,7 +3,7 @@ import { CustomThemeColors, } from "@monkeytype/contracts/schemas/configs"; -export default { +const obj = { theme: "serika_dark", themeLight: "serika", themeDark: "serika_dark", @@ -101,3 +101,5 @@ export default { tapeMode: "off", maxLineWidth: 0, } as Config; + +export default JSON.parse(JSON.stringify(obj)) as Config; diff --git a/frontend/src/ts/constants/default-result-filters.ts b/frontend/src/ts/constants/default-result-filters.ts new file mode 100644 index 000000000000..71ff08662380 --- /dev/null +++ b/frontend/src/ts/constants/default-result-filters.ts @@ -0,0 +1,66 @@ +import { ResultFilters } from "@monkeytype/contracts/schemas/users"; + +const object: ResultFilters = { + _id: "default-result-filters-id", + name: "default result filters", + pb: { + no: true, + yes: true, + }, + difficulty: { + normal: true, + expert: true, + master: true, + }, + mode: { + words: true, + time: true, + quote: true, + zen: true, + custom: true, + }, + words: { + "10": true, + "25": true, + "50": true, + "100": true, + custom: true, + }, + time: { + "15": true, + "30": true, + "60": true, + "120": true, + custom: true, + }, + quoteLength: { + short: true, + medium: true, + long: true, + thicc: true, + }, + punctuation: { + on: true, + off: true, + }, + numbers: { + on: true, + off: true, + }, + date: { + last_day: false, + last_week: false, + last_month: false, + last_3months: false, + all: true, + }, + tags: { + none: true, + }, + language: {}, + funbox: { + none: true, + }, +}; + +export default JSON.parse(JSON.stringify(object)) as ResultFilters; diff --git a/frontend/src/ts/controllers/account-controller.ts b/frontend/src/ts/controllers/account-controller.ts index 555b99bcd8ee..ca2e85d53dc1 100644 --- a/frontend/src/ts/controllers/account-controller.ts +++ b/frontend/src/ts/controllers/account-controller.ts @@ -47,6 +47,7 @@ import { navigate } from "./route-controller"; import { getHtmlByUserFlags } from "./user-flag-controller"; import { FirebaseError } from "firebase/app"; import * as PSA from "../elements/psa"; +import defaultResultFilters from "../constants/default-result-filters"; export const gmailProvider = new GoogleAuthProvider(); export const githubProvider = new GithubAuthProvider(); @@ -135,10 +136,10 @@ async function getDataAndInit(): Promise { .then((values) => { const [languages, funboxes] = values; languages.forEach((language) => { - ResultFilters.defaultResultFilters.language[language] = true; + defaultResultFilters.language[language] = true; }); funboxes.forEach((funbox) => { - ResultFilters.defaultResultFilters.funbox[funbox.name] = true; + defaultResultFilters.funbox[funbox.name] = true; }); // filters = defaultResultFilters; void ResultFilters.load(); @@ -166,7 +167,7 @@ async function getDataAndInit(): Promise { const areConfigsEqual = JSON.stringify(Config) === JSON.stringify(snapshot.config); - if (UpdateConfig.localStorageConfig === undefined || !areConfigsEqual) { + if (Config === undefined || !areConfigsEqual) { console.log( "no local config or local and db configs are different - applying db" ); diff --git a/frontend/src/ts/controllers/sound-controller.ts b/frontend/src/ts/controllers/sound-controller.ts index 04a08777fc21..8ac913893b40 100644 --- a/frontend/src/ts/controllers/sound-controller.ts +++ b/frontend/src/ts/controllers/sound-controller.ts @@ -404,6 +404,20 @@ export async function previewClick(val: string): Promise { safeClickSounds[val][0].sounds[0].play(); } +export async function previewError(val: string): Promise { + if (errorSounds === null) await initErrorSound(); + + const safeErrorSounds = errorSounds as ErrorSounds; + + const errorSoundIds = Object.keys(safeErrorSounds); + if (!errorSoundIds.includes(val)) return; + + //@ts-expect-error + errorClickSounds[val][0].sounds[0].seek(0); + //@ts-expect-error + errorClickSounds[val][0].sounds[0].play(); +} + let currentCode = "KeyA"; $(document).on("keydown", (event) => { diff --git a/frontend/src/ts/controllers/tag-controller.ts b/frontend/src/ts/controllers/tag-controller.ts index e934095d536d..a6d3fbbbfdcc 100644 --- a/frontend/src/ts/controllers/tag-controller.ts +++ b/frontend/src/ts/controllers/tag-controller.ts @@ -1,17 +1,25 @@ +import { z } from "zod"; import * as DB from "../db"; import * as ModesNotice from "../elements/modes-notice"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import { IdSchema } from "@monkeytype/contracts/schemas/util"; + +const activeTagsLS = new LocalStorageWithSchema({ + key: "activeTags", + schema: z.array(IdSchema), + fallback: [], +}); export function saveActiveToLocalStorage(): void { const tags: string[] = []; - try { - DB.getSnapshot()?.tags?.forEach((tag) => { - if (tag.active === true) { - tags.push(tag._id); - } - }); - window.localStorage.setItem("activeTags", JSON.stringify(tags)); - } catch (e) {} + DB.getSnapshot()?.tags?.forEach((tag) => { + if (tag.active === true) { + tags.push(tag._id); + } + }); + + activeTagsLS.set(tags); } export function clear(nosave = false): void { @@ -61,18 +69,9 @@ export function toggle(tagid: string, nosave = false): void { } export function loadActiveFromLocalStorage(): void { - let newTags: string[] | string = window.localStorage.getItem( - "activeTags" - ) as string; - if (newTags != undefined && newTags !== "") { - try { - newTags = JSON.parse(newTags) ?? []; - } catch (e) { - newTags = []; - } - (newTags as string[]).forEach((ntag) => { - toggle(ntag, true); - }); - saveActiveToLocalStorage(); + const newTags = activeTagsLS.get(); + for (const tag of newTags) { + toggle(tag, true); } + saveActiveToLocalStorage(); } diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index 0f89864fc27b..4a51bdd36102 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -8,8 +8,49 @@ import Ape from "../../ape/index"; import * as Loader from "../loader"; // @ts-expect-error TODO: update slim-select import SlimSelect from "slim-select"; -import { ResultFilters } from "@monkeytype/shared-types"; import { QuoteLength } from "@monkeytype/contracts/schemas/configs"; +import { + ResultFilters, + ResultFiltersSchema, + ResultFiltersGroup, + ResultFiltersGroupItem, +} from "@monkeytype/contracts/schemas/users"; +import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema"; +import defaultResultFilters from "../../constants/default-result-filters"; + +export function mergeWithDefaultFilters( + filters: Partial +): ResultFilters { + try { + const merged = {} as ResultFilters; + for (const groupKey of Misc.typedKeys(defaultResultFilters)) { + if (groupKey === "_id" || groupKey === "name") { + merged[groupKey] = filters[groupKey] ?? defaultResultFilters[groupKey]; + } else { + // @ts-expect-error i cant figure this out + merged[groupKey] = { + ...defaultResultFilters[groupKey], + ...filters[groupKey], + }; + } + } + return merged; + } catch (e) { + return defaultResultFilters; + } +} + +const resultFiltersLS = new LocalStorageWithSchema({ + key: "resultFilters", + schema: ResultFiltersSchema, + fallback: defaultResultFilters, + migrate: (unknown, _issues) => { + if (!Misc.isObject(unknown)) { + return defaultResultFilters; + } + return mergeWithDefaultFilters(unknown as ResultFilters); + }, +}); type Option = { id: string; @@ -31,118 +72,18 @@ type Option = { const groupsUsingSelect = ["language", "funbox", "tags"]; const groupSelects: Partial> = {}; -export const defaultResultFilters: ResultFilters = { - _id: "default-result-filters-id", - name: "default result filters", - pb: { - no: true, - yes: true, - }, - difficulty: { - normal: true, - expert: true, - master: true, - }, - mode: { - words: true, - time: true, - quote: true, - zen: true, - custom: true, - }, - words: { - "10": true, - "25": true, - "50": true, - "100": true, - custom: true, - }, - time: { - "15": true, - "30": true, - "60": true, - "120": true, - custom: true, - }, - quoteLength: { - short: true, - medium: true, - long: true, - thicc: true, - }, - punctuation: { - on: true, - off: true, - }, - numbers: { - on: true, - off: true, - }, - date: { - last_day: false, - last_week: false, - last_month: false, - last_3months: false, - all: true, - }, - tags: { - none: true, - }, - language: {}, - funbox: { - none: true, - }, -}; - // current activated filter let filters = defaultResultFilters; function save(): void { - window.localStorage.setItem("resultFilters", JSON.stringify(filters)); + resultFiltersLS.set(filters); } export async function load(): Promise { try { - const newResultFilters = window.localStorage.getItem("resultFilters") ?? ""; - - if (!newResultFilters) { - filters = defaultResultFilters; - } else { - const newFiltersObject = JSON.parse(newResultFilters); - - let reset = false; - for (const key of Object.keys(defaultResultFilters)) { - if (reset) break; - if (newFiltersObject[key] === undefined) { - reset = true; - break; - } - - if ( - typeof defaultResultFilters[ - key as keyof typeof defaultResultFilters - ] === "object" - ) { - for (const subKey of Object.keys( - defaultResultFilters[key as keyof typeof defaultResultFilters] - )) { - if (newFiltersObject[key][subKey] === undefined) { - reset = true; - break; - } - } - } - } - - if (reset) { - filters = defaultResultFilters; - } else { - filters = newFiltersObject; - } - } + const filters = resultFiltersLS.get(); const newTags: Record = { none: false }; - Object.keys(defaultResultFilters.tags).forEach((tag) => { if (filters.tags[tag] !== undefined) { newTags[tag] = filters.tags[tag]; @@ -152,7 +93,6 @@ export async function load(): Promise { }); filters.tags = newTags; - // await updateFilterPresets(); save(); } catch { console.log("error in loading result filters"); @@ -288,7 +228,7 @@ function getFilters(): ResultFilters { return filters; } -function getGroup(group: G): ResultFilters[G] { +function getGroup(group: G): ResultFilters[G] { return filters[group]; } @@ -296,22 +236,22 @@ function getGroup(group: G): ResultFilters[G] { // filters[group][filter] = value; // } -export function getFilter( +export function getFilter( group: G, - filter: MonkeyTypes.Filter -): ResultFilters[G][MonkeyTypes.Filter] { + filter: ResultFiltersGroupItem +): ResultFilters[G][ResultFiltersGroupItem] { return filters[group][filter]; } -function setFilter( - group: keyof ResultFilters, - filter: MonkeyTypes.Filter, +function setFilter( + group: G, + filter: ResultFiltersGroupItem, value: boolean ): void { - filters[group][filter as keyof typeof filters[typeof group]] = value as never; + filters[group][filter] = value as typeof filters[G][typeof filter]; } -function setAllFilters(group: keyof ResultFilters, value: boolean): void { +function setAllFilters(group: ResultFiltersGroup, value: boolean): void { Object.keys(getGroup(group)).forEach((filter) => { filters[group][filter as keyof typeof filters[typeof group]] = value as never; @@ -330,7 +270,7 @@ export function reset(): void { } type AboveChartDisplay = Partial< - Record + Record >; export function updateActive(): void { @@ -352,7 +292,10 @@ export function updateActive(): void { if (groupAboveChartDisplay === undefined) continue; - const filterValue = getFilter(group, filter); + const filterValue = getFilter( + group, + filter as ResultFiltersGroupItem + ); if (filterValue === true) { groupAboveChartDisplay.array?.push(filter); } else { @@ -392,7 +335,7 @@ export function updateActive(): void { for (const [id, select] of Object.entries(groupSelects)) { const ss = select; - const group = getGroup(id as keyof ResultFilters); + const group = getGroup(id as ResultFiltersGroup); const everythingSelected = Object.values(group).every((v) => v === true); const newData = ss.store.getData(); @@ -436,7 +379,7 @@ export function updateActive(): void { }, 0); } - function addText(group: keyof ResultFilters): string { + function addText(group: ResultFiltersGroup): string { let ret = ""; ret += "
"; if (group === "difficulty") { @@ -534,9 +477,9 @@ export function updateActive(): void { }, 0); } -function toggle( +function toggle( group: G, - filter: MonkeyTypes.Filter + filter: ResultFiltersGroupItem ): void { // user is changing the filters -> current filter is no longer a filter preset deSelectFilterPreset(); @@ -548,7 +491,7 @@ function toggle( const currentValue = filters[group][filter] as unknown as boolean; const newValue = !currentValue; filters[group][filter] = - newValue as unknown as ResultFilters[G][MonkeyTypes.Filter]; + newValue as ResultFilters[G][ResultFiltersGroupItem]; save(); } catch (e) { Notifications.add( @@ -567,8 +510,10 @@ $( ).on("click", "button", (e) => { const group = $(e.target) .parents(".buttons") - .attr("group") as keyof ResultFilters; - const filter = $(e.target).attr("filter") as MonkeyTypes.Filter; + .attr("group") as ResultFiltersGroup; + const filter = $(e.target).attr("filter") as ResultFiltersGroupItem< + typeof group + >; if ($(e.target).hasClass("allFilters")) { Misc.typedKeys(getFilters()).forEach((group) => { // id and name field do not correspond to any ui elements, no need to update @@ -594,8 +539,8 @@ $( } else if ($(e.target).is("button")) { if (e.shiftKey) { setAllFilters(group, false); - filters[group][filter as keyof typeof filters[typeof group]] = - true as never; + filters[group][filter] = + true as ResultFilters[typeof group][typeof filter]; } else { toggle(group, filter); // filters[group][filter] = !filters[group][filter]; @@ -658,7 +603,7 @@ $(".pageAccount .topFilters button.currentConfigFilter").on("click", () => { filters.words.custom = true; } } else if (Config.mode === "quote") { - const filterName: MonkeyTypes.Filter<"quoteLength">[] = [ + const filterName: ResultFiltersGroupItem<"quoteLength">[] = [ "short", "medium", "long", @@ -689,7 +634,7 @@ $(".pageAccount .topFilters button.currentConfigFilter").on("click", () => { } if (Config.funbox === "none") { - filters.funbox.none = true; + filters.funbox["none"] = true; } else { for (const f of Config.funbox.split("#")) { filters.funbox[f] = true; @@ -718,7 +663,7 @@ $(".pageAccount .topFilters button.toggleAdvancedFilters").on("click", () => { }); function adjustScrollposition( - group: keyof ResultFilters, + group: ResultFiltersGroup, topItem: number = 0 ): void { const slimSelect = groupSelects[group]; @@ -730,7 +675,7 @@ function adjustScrollposition( } function selectBeforeChangeFn( - group: keyof ResultFilters, + group: ResultFiltersGroup, selectedOptions: Option[], oldSelectedOptions: Option[] ): void | boolean { @@ -767,7 +712,11 @@ function selectBeforeChangeFn( break; } - setFilter(group, selectedOption.value, true); + setFilter( + group, + selectedOption.value as ResultFiltersGroupItem, + true + ); } updateActive(); @@ -987,7 +936,7 @@ $(".group.presetFilterButtons .filterBtns").on( function verifyResultFiltersStructure(filterIn: ResultFilters): ResultFilters { const filter = deepCopyFilter(filterIn); Object.entries(defaultResultFilters).forEach((entry) => { - const key = entry[0] as keyof ResultFilters; + const key = entry[0] as ResultFiltersGroup; const value = entry[1]; if (filter[key] === undefined) { // @ts-expect-error key and value is based on default filter so this is safe to ignore diff --git a/frontend/src/ts/elements/leaderboards.ts b/frontend/src/ts/elements/leaderboards.ts index 20c91578fe85..02c0810cf518 100644 --- a/frontend/src/ts/elements/leaderboards.ts +++ b/frontend/src/ts/elements/leaderboards.ts @@ -17,7 +17,11 @@ import Format from "../utils/format"; // @ts-expect-error TODO: update slim-select import SlimSelect from "slim-select"; import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; -import { LeaderboardEntry } from "@monkeytype/shared-types"; +import { + LeaderboardEntry, + LeaderboardRank, +} from "@monkeytype/contracts/schemas/leaderboards"; +import { Mode } from "@monkeytype/contracts/schemas/shared"; const wrapperId = "leaderboardsWrapper"; @@ -35,7 +39,9 @@ let currentData: { }; let currentRank: { - [_key in LbKey]: Ape.Leaderboards.GetRank | Record; + [_key in LbKey]: + | (LeaderboardRank & { minWpm?: number }) //Daily LB rank has minWpm + | Record; } = { "15": {}, "60": {}, @@ -425,10 +431,10 @@ function updateYesterdayButton(): void { } } -function getDailyLeaderboardQuery(): { isDaily: boolean; daysBefore: number } { +function getDailyLeaderboardQuery(): { isDaily: boolean; daysBefore?: 1 } { const isDaily = currentTimeRange === "daily"; const isViewingDailyAndButtonIsActive = isDaily && showingYesterday; - const daysBefore = isViewingDailyAndButtonIsActive ? 1 : 0; + const daysBefore = isViewingDailyAndButtonIsActive ? 1 : undefined; return { isDaily, @@ -443,57 +449,59 @@ async function update(): Promise { showLoader("15"); showLoader("60"); - const timeModes = ["15", "60"]; + const { isDaily, daysBefore } = getDailyLeaderboardQuery(); + const requestData = isDaily + ? Ape.leaderboards.getDaily + : Ape.leaderboards.get; + const requestRank = isDaily + ? Ape.leaderboards.getDailyRank + : Ape.leaderboards.getRank; - const lbDataRequests = timeModes.map(async (mode2) => { - return Ape.leaderboards.get({ - language: currentLanguage, - mode: "time", - mode2, - ...getDailyLeaderboardQuery(), - }); - }); + const baseQuery = { + language: currentLanguage, + mode: "time" as Mode, + daysBefore, + }; - const lbRankRequests: Promise< - Ape.HttpClientResponse - >[] = []; - if (isAuthenticated()) { - lbRankRequests.push( - ...timeModes.map(async (mode2) => { - return Ape.leaderboards.getRank({ - language: currentLanguage, - mode: "time", - mode2, - ...getDailyLeaderboardQuery(), - }); - }) - ); - } + const fallbackResponse = { status: 200, body: { message: "", data: null } }; + + const lbRank15Request = isAuthenticated() + ? requestRank({ query: { ...baseQuery, mode2: "15" } }) + : fallbackResponse; - const responses = await Promise.all(lbDataRequests); - const rankResponses = await Promise.all(lbRankRequests); + const lbRank60Request = isAuthenticated() + ? requestRank({ query: { ...baseQuery, mode2: "60" } }) + : fallbackResponse; + const [lb15Data, lb60Data, lb15Rank, lb60Rank] = await Promise.all([ + requestData({ query: { ...baseQuery, mode2: "15" } }), + requestData({ query: { ...baseQuery, mode2: "60" } }), + lbRank15Request, + lbRank60Request, + ]); + + if ( + lb15Data.status !== 200 || + lb60Data.status !== 200 || + lb15Rank.status !== 200 || + lb60Rank.status !== 200 + ) { + const failedResponses = [lb15Data, lb60Data, lb15Rank, lb60Rank].filter( + (it) => it.status !== 200 + ); - const failedResponses = [ - ...(responses.filter((response) => response.status !== 200) ?? []), - ...(rankResponses.filter((response) => response.status !== 200) ?? []), - ]; - if (failedResponses.length > 0) { hideLoader("15"); hideLoader("60"); Notifications.add( - "Failed to load leaderboards: " + failedResponses[0]?.message, + "Failed to load leaderboards: " + failedResponses[0]?.body.message, -1 ); return; } - const [lb15Data, lb60Data] = responses.map((response) => response.data); - const [lb15Rank, lb60Rank] = rankResponses.map((response) => response.data); - - if (lb15Data !== undefined && lb15Data !== null) currentData["15"] = lb15Data; - if (lb60Data !== undefined && lb60Data !== null) currentData["60"] = lb60Data; - if (lb15Rank !== undefined && lb15Rank !== null) currentRank["15"] = lb15Rank; - if (lb60Rank !== undefined && lb60Rank !== null) currentRank["60"] = lb60Rank; + if (lb15Data.body.data !== null) currentData["15"] = lb15Data.body.data; + if (lb60Data.body.data !== null) currentData["60"] = lb60Data.body.data; + if (lb15Rank.body.data !== null) currentRank["15"] = lb15Rank.body.data; + if (lb60Rank.body.data !== null) currentRank["60"] = lb60Rank.body.data; const leaderboardKeys: LbKey[] = ["15", "60"]; @@ -541,21 +549,34 @@ async function requestMore(lb: LbKey, prepend = false): Promise { skipVal = 0; } - const response = await Ape.leaderboards.get({ - language: currentLanguage, - mode: "time", - mode2: lb, - skip: skipVal, - limit: limitVal, - ...getDailyLeaderboardQuery(), + const { isDaily, daysBefore } = getDailyLeaderboardQuery(); + + const requestData = isDaily + ? Ape.leaderboards.getDaily + : Ape.leaderboards.get; + + const response = await requestData({ + query: { + language: currentLanguage, + mode: "time", + mode2: lb, + skip: skipVal, + limit: limitVal, + daysBefore, + }, }); - const data = response.data; - if (response.status !== 200 || data === null || data.length === 0) { + if ( + response.status !== 200 || + response.body.data === null || + response.body.data.length === 0 + ) { hideLoader(lb); requesting[lb] = false; return; } + const data = response.body.data; + if (prepend) { currentData[lb].unshift(...data); } else { @@ -582,14 +603,21 @@ async function requestMore(lb: LbKey, prepend = false): Promise { async function requestNew(lb: LbKey, skip: number): Promise { showLoader(lb); - const response = await Ape.leaderboards.get({ - language: currentLanguage, - mode: "time", - mode2: lb, - skip, - ...getDailyLeaderboardQuery(), + const { isDaily, daysBefore } = getDailyLeaderboardQuery(); + + const requestData = isDaily + ? Ape.leaderboards.getDaily + : Ape.leaderboards.get; + + const response = await requestData({ + query: { + language: currentLanguage, + mode: "time", + mode2: lb, + skip, + daysBefore, + }, }); - const data = response.data; if (response.status === 503) { Notifications.add( @@ -602,10 +630,16 @@ async function requestNew(lb: LbKey, skip: number): Promise { clearBody(lb); currentData[lb] = []; currentAvatars[lb] = []; - if (response.status !== 200 || data === null || data.length === 0) { + if ( + response.status !== 200 || + response.body.data === null || + response.body.data.length === 0 + ) { hideLoader(lb); return; } + + const data = response.body.data; currentData[lb] = data; await fillTable(lb); @@ -618,7 +652,7 @@ async function requestNew(lb: LbKey, skip: number): Promise { } async function getAvatarUrls( - data: Ape.Leaderboards.GetLeaderboard + data: LeaderboardEntry[] ): Promise<(string | null)[]> { return Promise.allSettled( data.map(async (entry) => diff --git a/frontend/src/ts/elements/merch-banner.ts b/frontend/src/ts/elements/merch-banner.ts new file mode 100644 index 000000000000..bcb10b7aeee0 --- /dev/null +++ b/frontend/src/ts/elements/merch-banner.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import * as Notifications from "./notifications"; + +const closed = new LocalStorageWithSchema({ + key: "merchBannerClosed", + schema: z.boolean(), + fallback: false, +}); + +export function showIfNotClosedBefore(): void { + if (!closed.get()) { + Notifications.addBanner( + `Check out our merchandise, available at monkeytype.store`, + 1, + "./images/merch2.png", + false, + () => { + closed.set(true); + }, + true + ); + } +} diff --git a/frontend/src/ts/elements/psa.ts b/frontend/src/ts/elements/psa.ts index 324e2a863fc9..3c185ec36e2a 100644 --- a/frontend/src/ts/elements/psa.ts +++ b/frontend/src/ts/elements/psa.ts @@ -5,24 +5,28 @@ import * as Notifications from "./notifications"; import { format } from "date-fns/format"; import * as Alerts from "./alerts"; import { PSA } from "@monkeytype/contracts/schemas/psas"; +import { z } from "zod"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import { IdSchema } from "@monkeytype/contracts/schemas/util"; + +const confirmedPSAs = new LocalStorageWithSchema({ + key: "confirmedPSAs", + schema: z.array(IdSchema), + fallback: [], +}); function clearMemory(): void { - window.localStorage.setItem("confirmedPSAs", JSON.stringify([])); + confirmedPSAs.set([]); } function getMemory(): string[] { - //TODO verify with zod? - return ( - (JSON.parse( - window.localStorage.getItem("confirmedPSAs") ?? "[]" - ) as string[]) ?? [] - ); + return confirmedPSAs.get(); } function setMemory(id: string): void { const list = getMemory(); list.push(id); - window.localStorage.setItem("confirmedPSAs", JSON.stringify(list)); + confirmedPSAs.set(list); } async function getLatest(): Promise { diff --git a/frontend/src/ts/modals/cookies.ts b/frontend/src/ts/modals/cookies.ts index 868c2a148d2e..2f0c7fcd32ec 100644 --- a/frontend/src/ts/modals/cookies.ts +++ b/frontend/src/ts/modals/cookies.ts @@ -4,29 +4,30 @@ import { isPopupVisible } from "../utils/misc"; import * as AdController from "../controllers/ad-controller"; import AnimatedModal from "../utils/animated-modal"; import { focusWords } from "../test/test-ui"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import { z } from "zod"; -type Accepted = { - security: boolean; - analytics: boolean; -}; +const AcceptedSchema = z.object({ + security: z.boolean(), + analytics: z.boolean(), +}); +type Accepted = z.infer; -function getAcceptedObject(): Accepted | null { - const acceptedCookies = localStorage.getItem("acceptedCookies") ?? ""; - if (acceptedCookies) { - //TODO verify with zod? - return JSON.parse(acceptedCookies) as Accepted; - } else { - return null; - } -} +const acceptedCookiesLS = new LocalStorageWithSchema({ + key: "acceptedCookies", + schema: AcceptedSchema, + fallback: { + security: false, + analytics: false, + }, +}); function setAcceptedObject(obj: Accepted): void { - localStorage.setItem("acceptedCookies", JSON.stringify(obj)); + acceptedCookiesLS.set(obj); } export function check(): void { - const accepted = getAcceptedObject(); - if (accepted === null) { + if (acceptedCookiesLS.get() === undefined) { show(); } } diff --git a/frontend/src/ts/modals/save-custom-text.ts b/frontend/src/ts/modals/save-custom-text.ts index 728327537503..47aa5a1d641e 100644 --- a/frontend/src/ts/modals/save-custom-text.ts +++ b/frontend/src/ts/modals/save-custom-text.ts @@ -58,7 +58,7 @@ function updateIndicatorAndButton(): void { if (!val) { indicator?.hide(); - $("#saveCustomTextModal button.save").addClass("disabled"); + $("#saveCustomTextModal button.save").prop("disabled", true); } else { const names = CustomText.getCustomTextNames(checkbox); if (names.includes(val)) { diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index d0181e08f4d6..e692f5b93ac8 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -34,6 +34,7 @@ import { Mode2Custom, PersonalBests, } from "@monkeytype/contracts/schemas/shared"; +import { ResultFiltersGroupItem } from "@monkeytype/contracts/schemas/users"; let filterDebug = false; //toggle filterdebug @@ -386,7 +387,7 @@ async function fillContent(): Promise { return; } - let puncfilter: MonkeyTypes.Filter<"punctuation"> = "off"; + let puncfilter: ResultFiltersGroupItem<"punctuation"> = "off"; if (result.punctuation) { puncfilter = "on"; } @@ -397,7 +398,7 @@ async function fillContent(): Promise { return; } - let numfilter: MonkeyTypes.Filter<"numbers"> = "off"; + let numfilter: ResultFiltersGroupItem<"numbers"> = "off"; if (result.numbers) { numfilter = "on"; } diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/ready.ts index 8af4be4e034e..bfd5f583f71c 100644 --- a/frontend/src/ts/ready.ts +++ b/frontend/src/ts/ready.ts @@ -1,7 +1,7 @@ import Config from "./config"; import * as Misc from "./utils/misc"; import * as MonkeyPower from "./elements/monkey-power"; -import * as Notifications from "./elements/notifications"; +import * as MerchBanner from "./elements/merch-banner"; import * as CookiesModal from "./modals/cookies"; import * as ConnectionState from "./states/connection"; import * as FunboxList from "./test/funbox/funbox-list"; @@ -18,21 +18,7 @@ $((): void => { //this line goes back to pretty much the beginning of the project and im pretty sure its here //to make sure the initial theme application doesnt animate the background color $("body").css("transition", "background .25s, transform .05s"); - const merchBannerClosed = - window.localStorage.getItem("merchbannerclosed") === "true"; - if (!merchBannerClosed) { - Notifications.addBanner( - `Check out our merchandise, available at monkeytype.store`, - 1, - "./images/merch2.png", - false, - () => { - window.localStorage.setItem("merchbannerclosed", "true"); - }, - true - ); - } - + MerchBanner.showIfNotClosedBefore(); setTimeout(() => { FunboxList.get(Config.funbox).forEach((it) => it.functions?.applyGlobalCSS?.() diff --git a/frontend/src/ts/states/arabic-lazy-mode.ts b/frontend/src/ts/states/arabic-lazy-mode.ts index 3043899780eb..590fd50e55ea 100644 --- a/frontend/src/ts/states/arabic-lazy-mode.ts +++ b/frontend/src/ts/states/arabic-lazy-mode.ts @@ -1,7 +1,16 @@ +import { z } from "zod"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; + +const ls = new LocalStorageWithSchema({ + key: "prefersArabicLazyMode", + schema: z.boolean(), + fallback: true, +}); + export function get(): boolean { - return (localStorage.getItem("prefersArabicLazyMode") ?? "true") === "true"; + return ls.get(); } export function set(value: boolean): void { - localStorage.setItem("prefersArabicLazyMode", value ? "true" : "false"); + ls.set(value); } diff --git a/frontend/src/ts/states/version.ts b/frontend/src/ts/states/version.ts index 76aedc4a1bfb..a0406b3077af 100644 --- a/frontend/src/ts/states/version.ts +++ b/frontend/src/ts/states/version.ts @@ -1,16 +1,22 @@ +import { z } from "zod"; import { getLatestReleaseFromGitHub } from "../utils/json-data"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; -const LOCALSTORAGE_KEY = "lastSeenVersion"; +const memoryLS = new LocalStorageWithSchema({ + key: "lastSeenVersion", + schema: z.string(), + fallback: "", +}); let version: null | string = null; let isVersionNew: null | boolean = null; function setMemory(v: string): void { - window.localStorage.setItem(LOCALSTORAGE_KEY, v); + memoryLS.set(v); } function getMemory(): string { - return window.localStorage.getItem(LOCALSTORAGE_KEY) ?? ""; + return memoryLS.get(); } async function check(): Promise { diff --git a/frontend/src/ts/test/custom-text.ts b/frontend/src/ts/test/custom-text.ts index 4a60f21067a5..3b428070b38c 100644 --- a/frontend/src/ts/test/custom-text.ts +++ b/frontend/src/ts/test/custom-text.ts @@ -4,6 +4,36 @@ import { CustomTextLimitMode, CustomTextMode, } from "@monkeytype/shared-types"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import { z } from "zod"; + +//zod schema for an object with string keys and string values +const CustomTextObjectSchema = z.record(z.string(), z.string()); +type CustomTextObject = z.infer; + +const CustomTextLongObjectSchema = z.record( + z.string(), + z.object({ text: z.string(), progress: z.number() }) +); +type CustomTextLongObject = z.infer; + +const customTextLS = new LocalStorageWithSchema({ + key: "customText", + schema: CustomTextObjectSchema, + fallback: {}, +}); +//todo maybe add migrations here? +const customTextLongLS = new LocalStorageWithSchema({ + key: "customTextLong", + schema: CustomTextLongObjectSchema, + fallback: {}, +}); + +// function setLocalStorage(data: CustomTextObject): void { +// window.localStorage.setItem("customText", JSON.stringify(data)); +// } + +// function setLocalStorageLong(data: CustomTextLongObject): void { let text: string[] = [ "The", @@ -79,10 +109,6 @@ export function getData(): CustomTextData { }; } -type CustomTextObject = Record; - -type CustomTextLongObject = Record; - export function getCustomText(name: string, long = false): string[] { if (long) { const customTextLong = getLocalStorageLong(); @@ -169,23 +195,19 @@ export function setCustomTextLongProgress( } function getLocalStorage(): CustomTextObject { - return JSON.parse( - window.localStorage.getItem("customText") ?? "{}" - ) as CustomTextObject; + return customTextLS.get(); } function getLocalStorageLong(): CustomTextLongObject { - return JSON.parse( - window.localStorage.getItem("customTextLong") ?? "{}" - ) as CustomTextLongObject; + return customTextLongLS.get(); } function setLocalStorage(data: CustomTextObject): void { - window.localStorage.setItem("customText", JSON.stringify(data)); + customTextLS.set(data); } function setLocalStorageLong(data: CustomTextLongObject): void { - window.localStorage.setItem("customTextLong", JSON.stringify(data)); + customTextLongLS.set(data); } export function getCustomTextNames(long = false): string[] { diff --git a/frontend/src/ts/types/types.d.ts b/frontend/src/ts/types/types.d.ts index a44320cbf84c..de4bfa6fd188 100644 --- a/frontend/src/ts/types/types.d.ts +++ b/frontend/src/ts/types/types.d.ts @@ -202,12 +202,6 @@ declare namespace MonkeyTypes { }; }; - type Leaderboards = { - time: { - [_key in 15 | 60]: import("@monkeytype/shared-types").LeaderboardEntry[]; - }; - }; - type QuoteRatings = Record>; type UserTag = import("@monkeytype/shared-types").UserTag & { @@ -236,7 +230,7 @@ declare namespace MonkeyTypes { inboxUnreadSize: number; streak: number; maxStreak: number; - filterPresets: import("@monkeytype/shared-types").ResultFilters[]; + filterPresets: import("@monkeytype/contracts/schemas/users").ResultFilters[]; isPremium: boolean; streakHourOffset?: number; config: import("@monkeytype/contracts/schemas/configs").Config; @@ -250,15 +244,6 @@ declare namespace MonkeyTypes { testActivityByYear?: { [key: string]: TestActivityCalendar }; }; - type Group< - G extends keyof import("@monkeytype/shared-types").ResultFilters = keyof import("@monkeytype/shared-types").ResultFilters - > = G extends G ? import("@monkeytype/shared-types").ResultFilters[G] : never; - - type Filter = - G extends keyof import("@monkeytype/shared-types").ResultFilters - ? keyof import("@monkeytype/shared-types").ResultFilters[G] - : never; - type TimerStats = { dateNow: number; now: number; diff --git a/frontend/src/ts/utils/local-storage-with-schema.ts b/frontend/src/ts/utils/local-storage-with-schema.ts new file mode 100644 index 000000000000..f196a2030c90 --- /dev/null +++ b/frontend/src/ts/utils/local-storage-with-schema.ts @@ -0,0 +1,66 @@ +import { ZodIssue } from "zod"; + +export class LocalStorageWithSchema { + private key: string; + private schema: Zod.Schema; + private fallback: T; + private migrate?: (value: unknown, zodIssues: ZodIssue[]) => T; + + constructor(options: { + key: string; + schema: Zod.Schema; + fallback: T; + migrate?: (value: unknown, zodIssues: ZodIssue[]) => T; + }) { + this.key = options.key; + this.schema = options.schema; + this.fallback = options.fallback; + this.migrate = options.migrate; + } + + public get(): T { + const value = window.localStorage.getItem(this.key); + + if (value === null) { + return this.fallback; + } + + let jsonParsed; + try { + jsonParsed = JSON.parse(value); + } catch (e) { + console.error( + `Value from localStorage ${this.key} was not a valid JSON, using fallback`, + e + ); + window.localStorage.removeItem(this.key); + return this.fallback; + } + + const schemaParsed = this.schema.safeParse(jsonParsed); + + if (schemaParsed.success) { + return schemaParsed.data; + } + + console.error( + `Value from localStorage ${this.key} failed schema validation, migrating`, + schemaParsed.error + ); + const newValue = + this.migrate?.(jsonParsed, schemaParsed.error.issues) ?? this.fallback; + window.localStorage.setItem(this.key, JSON.stringify(newValue)); + return newValue; + } + + public set(data: T): boolean { + try { + const parsed = this.schema.parse(data); + window.localStorage.setItem(this.key, JSON.stringify(parsed)); + return true; + } catch (e) { + console.error(`Failed to set ${this.key} in localStorage`, e); + return false; + } + } +} diff --git a/frontend/src/ts/utils/logger.ts b/frontend/src/ts/utils/logger.ts index 448583deb3fb..71783f2454f4 100644 --- a/frontend/src/ts/utils/logger.ts +++ b/frontend/src/ts/utils/logger.ts @@ -1,10 +1,18 @@ +import { z } from "zod"; +import { LocalStorageWithSchema } from "./local-storage-with-schema"; import { isDevEnvironment } from "./misc"; const nativeLog = console.log; const nativeWarn = console.warn; const nativeError = console.error; -let debugLogs = localStorage.getItem("debugLogs") === "true"; +const debugLogsLS = new LocalStorageWithSchema({ + key: "debugLogs", + schema: z.boolean(), + fallback: false, +}); + +let debugLogs = debugLogsLS.get(); if (isDevEnvironment()) { debugLogs = true; @@ -14,7 +22,7 @@ if (isDevEnvironment()) { export function toggleDebugLogs(): void { debugLogs = !debugLogs; info(`Debug logs ${debugLogs ? "enabled" : "disabled"}`); - localStorage.setItem("debugLogs", debugLogs.toString()); + debugLogsLS.set(debugLogs); } function info(...args: unknown[]): void { diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 2e2939b4ed9d..f27a6605e815 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -676,4 +676,8 @@ export function updateTitle(title?: string): void { } } +export function isObject(obj: unknown): obj is Record { + return typeof obj === "object" && !Array.isArray(obj) && obj !== null; +} + // DO NOT ALTER GLOBAL OBJECTSONSTRUCTOR, IT WILL BREAK RESULT HASHES diff --git a/frontend/static/languages/english_1k.json b/frontend/static/languages/english_1k.json index c0d999dbdcb8..cdcd0100bff0 100644 --- a/frontend/static/languages/english_1k.json +++ b/frontend/static/languages/english_1k.json @@ -1001,6 +1001,7 @@ "shell", "neck", "program", - "public" + "public", + "universe" ] } diff --git a/frontend/static/languages/filipino.json b/frontend/static/languages/filipino.json index f7b4c26b46df..798f2130628f 100644 --- a/frontend/static/languages/filipino.json +++ b/frontend/static/languages/filipino.json @@ -84,7 +84,7 @@ "kasama", "taon", "mahal", - "makita", + "kita", "ninyo", "ngunit", "marami", @@ -107,7 +107,7 @@ "panahon", "ayaw", "buong", - "bayan", + "pasok", "ulit", "tungkol", "tama", @@ -125,8 +125,8 @@ "totoo", "tunay", "amin", - "laging", - "gawin", + "lagi", + "gawa", "kanina", "mundo", "dati", @@ -139,7 +139,7 @@ "sina", "daan", "ganito", - "sige", + "diyan", "lugar", "laban", "bukas", @@ -160,7 +160,7 @@ "labas", "umalis", "rin", - "unti", + "kaunti", "ayon", "uri", "kaysa", @@ -168,30 +168,30 @@ "maliit", "malaki", "dulo", - "sumunod", + "sinabi", "kamay", "luma", "tulong", "saka", "tingnan", "kabila", - "gamitin", + "gamit", "kunin", "pagitan", - "tumayo", + "ilagay", "mukha", "pangkat", "maayos", - "subalit", + "malapit", "salita", "susunod", - "simulan", - "buksan", - "mahaba", + "simula", + "isip", + "tagal", "kay", "mataas", "takbo", - "higit", + "sobra", "likod", "ilalim", "naging", diff --git a/frontend/static/quotes/german.json b/frontend/static/quotes/german.json index 4112bd060005..1ef408acf1c1 100644 --- a/frontend/static/quotes/german.json +++ b/frontend/static/quotes/german.json @@ -3300,6 +3300,24 @@ "source": "Oscar Wilde", "length": 124, "id": 575 + }, + { + "text": "Die Welt wird von deinem Beispiel verändert, nicht von deiner Meinung.", + "source": "Paulo Coelho", + "length": 70, + "id": 576 + }, + { + "text": "Die Einzige Macht, die du auf diesem Planeten hast, ist die Macht deiner Entscheidungen.", + "source": "Paulo Coelho", + "length": 88, + "id": 577 + }, + { + "text": "Eines Tages wirdst du erwachen, und es wird keine Zeit dafür geben, zu machen, was du schon immer machen wolltest. Mach es lieber jetzt.", + "source": "Paulo Coelho", + "length": 136, + "id": 578 } ] } diff --git a/package.json b/package.json index 90caf020601d..ec2f41dce0fc 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "devDependencies": { "@commitlint/cli": "17.7.1", "@commitlint/config-conventional": "17.7.0", - "@vitest/coverage-v8": "1.6.0", + "@vitest/coverage-v8": "2.0.5", "@monkeytype/release": "workspace:*", "conventional-changelog": "4.0.0", "husky": "8.0.1", @@ -67,7 +67,7 @@ "only-allow": "1.2.1", "prettier": "2.5.1", "turbo": "2.0.9", - "vitest": "1.6.0" + "vitest": "2.0.5" }, "lint-staged": { "*.{json,scss,css,html}": [ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1224a0e19041..0f74696398b6 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,6 +5,7 @@ import { configsContract } from "./configs"; import { presetsContract } from "./presets"; import { psasContract } from "./psas"; import { publicContract } from "./public"; +import { leaderboardsContract } from "./leaderboards"; const c = initContract(); @@ -15,4 +16,5 @@ export const contract = c.router({ presets: presetsContract, psas: psasContract, public: publicContract, + leaderboards: leaderboardsContract, }); diff --git a/packages/contracts/src/leaderboards.ts b/packages/contracts/src/leaderboards.ts new file mode 100644 index 000000000000..d5c0aba0ad13 --- /dev/null +++ b/packages/contracts/src/leaderboards.ts @@ -0,0 +1,174 @@ +import { z } from "zod"; +import { + CommonResponses, + EndpointMetadata, + responseWithData, + responseWithNullableData, +} from "./schemas/api"; +import { + DailyLeaderboardRankSchema, + LeaderboardEntrySchema, + LeaderboardRankSchema, + XpLeaderboardEntrySchema, + XpLeaderboardRankSchema, +} from "./schemas/leaderboards"; +import { LanguageSchema } from "./schemas/util"; +import { Mode2Schema, ModeSchema } from "./schemas/shared"; +import { initContract } from "@ts-rest/core"; + +export const LanguageAndModeQuerySchema = z.object({ + language: LanguageSchema, + mode: ModeSchema, + mode2: Mode2Schema, +}); +export type LanguageAndModeQuery = z.infer; +const PaginationQuerySchema = z.object({ + skip: z.number().int().nonnegative().optional(), + limit: z.number().int().nonnegative().max(50).optional(), +}); + +export const GetLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge( + PaginationQuerySchema +); +export type GetLeaderboardQuery = z.infer; +export const GetLeaderboardResponseSchema = responseWithData( + z.array(LeaderboardEntrySchema) +); +export type GetLeaderboardResponse = z.infer< + typeof GetLeaderboardResponseSchema +>; + +export const GetLeaderboardRankResponseSchema = responseWithData( + LeaderboardRankSchema +); +export type GetLeaderboardRankResponse = z.infer< + typeof GetLeaderboardRankResponseSchema +>; + +export const GetDailyLeaderboardRankQuerySchema = + LanguageAndModeQuerySchema.extend({ + daysBefore: z.literal(1).optional(), + }); +export type GetDailyLeaderboardRankQuery = z.infer< + typeof GetDailyLeaderboardRankQuerySchema +>; + +export const GetDailyLeaderboardQuerySchema = + GetDailyLeaderboardRankQuerySchema.merge(PaginationQuerySchema); +export type GetDailyLeaderboardQuery = z.infer< + typeof GetDailyLeaderboardQuerySchema +>; + +export const GetLeaderboardDailyRankResponseSchema = responseWithData( + DailyLeaderboardRankSchema +); +export type GetLeaderboardDailyRankResponse = z.infer< + typeof GetLeaderboardDailyRankResponseSchema +>; + +export const GetWeeklyXpLeaderboardQuerySchema = PaginationQuerySchema.extend({ + weeksBefore: z.literal(1).optional(), +}); +export type GetWeeklyXpLeaderboardQuery = z.infer< + typeof GetWeeklyXpLeaderboardQuerySchema +>; + +export const GetWeeklyXpLeaderboardResponseSchema = responseWithData( + z.array(XpLeaderboardEntrySchema) +); +export type GetWeeklyXpLeaderboardResponse = z.infer< + typeof GetWeeklyXpLeaderboardResponseSchema +>; + +export const GetWeeklyXpLeaderboardRankResponseSchema = + responseWithNullableData(XpLeaderboardRankSchema.partial()); +export type GetWeeklyXpLeaderboardRankResponse = z.infer< + typeof GetWeeklyXpLeaderboardRankResponseSchema +>; + +const c = initContract(); +export const leaderboardsContract = c.router( + { + get: { + summary: "get leaderboard", + description: "Get all-time leaderboard.", + method: "GET", + path: "", + query: GetLeaderboardQuerySchema.strict(), + responses: { + 200: GetLeaderboardResponseSchema, + }, + metadata: { + authenticationOptions: { isPublic: true }, + } as EndpointMetadata, + }, + getRank: { + summary: "get leaderboard rank", + description: + "Get the rank of the current user on the all-time leaderboard", + method: "GET", + path: "/rank", + query: LanguageAndModeQuerySchema.strict(), + responses: { + 200: GetLeaderboardRankResponseSchema, + }, + metadata: { + authenticationOptions: { acceptApeKeys: true }, + } as EndpointMetadata, + }, + getDaily: { + summary: "get daily leaderboard", + description: "Get daily leaderboard.", + method: "GET", + path: "/daily", + query: GetDailyLeaderboardQuerySchema.strict(), + responses: { + 200: GetLeaderboardResponseSchema, + }, + metadata: { + authenticationOptions: { isPublic: true }, + } as EndpointMetadata, + }, + getDailyRank: { + summary: "get daily leaderboard rank", + description: "Get the rank of the current user on the daily leaderboard", + method: "GET", + path: "/daily/rank", + query: GetDailyLeaderboardRankQuerySchema.strict(), + responses: { + 200: GetLeaderboardDailyRankResponseSchema, + }, + }, + getWeeklyXp: { + summary: "get weekly xp leaderboard", + description: "Get weekly xp leaderboard", + method: "GET", + path: "/xp/weekly", + query: GetWeeklyXpLeaderboardQuerySchema.strict(), + responses: { + 200: GetWeeklyXpLeaderboardResponseSchema, + }, + metadata: { + authenticationOptions: { isPublic: true }, + } as EndpointMetadata, + }, + getWeeklyXpRank: { + summary: "get weekly xp leaderboard rank", + description: + "Get the rank of the current user on the weekly xp leaderboard", + method: "GET", + path: "/xp/weekly/rank", + responses: { + 200: GetWeeklyXpLeaderboardRankResponseSchema, + }, + }, + }, + { + pathPrefix: "/leaderboards", + strictStatusCodes: true, + metadata: { + openApiTags: "leaderboards", + } as EndpointMetadata, + commonResponses: CommonResponses, + } +); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index 305d6100aa8c..4795f68286a9 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -6,7 +6,8 @@ export type OpenApiTag = | "ape-keys" | "admin" | "psas" - | "public"; + | "public" + | "leaderboards"; export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */ diff --git a/packages/contracts/src/schemas/configs.ts b/packages/contracts/src/schemas/configs.ts index 8579a14921ee..0465b57f366f 100644 --- a/packages/contracts/src/schemas/configs.ts +++ b/packages/contracts/src/schemas/configs.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { token } from "./util"; +import { LanguageSchema, token } from "./util"; import * as Shared from "./shared"; export const SmoothCaretSchema = z.enum(["off", "slow", "medium", "fast"]); @@ -262,12 +262,6 @@ export type FontFamily = z.infer; export const ThemeNameSchema = token().max(50); export type ThemeName = z.infer; -export const LanguageSchema = z - .string() - .max(50) - .regex(/^[a-zA-Z0-9_+]+$/); -export type Language = z.infer; - export const KeymapLayoutSchema = z .string() .max(50) diff --git a/packages/contracts/src/schemas/leaderboards.ts b/packages/contracts/src/schemas/leaderboards.ts new file mode 100644 index 000000000000..305b5b7b311d --- /dev/null +++ b/packages/contracts/src/schemas/leaderboards.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export const LeaderboardEntrySchema = z.object({ + wpm: z.number().nonnegative(), + acc: z.number().nonnegative().min(0).max(100), + timestamp: z.number().int().nonnegative(), + raw: z.number().nonnegative(), + consistency: z.number().nonnegative().optional(), + uid: z.string(), + name: z.string(), + discordId: z.string().optional(), + discordAvatar: z.string().optional(), + rank: z.number().nonnegative().int(), + badgeId: z.number().int().optional(), + isPremium: z.boolean().optional(), +}); +export type LeaderboardEntry = z.infer; + +export const LeaderboardRankSchema = z.object({ + count: z.number().int().nonnegative(), + rank: z.number().int().nonnegative().optional(), + entry: LeaderboardEntrySchema.optional(), +}); +export type LeaderboardRank = z.infer; + +export const DailyLeaderboardRankSchema = LeaderboardRankSchema.extend({ + minWpm: z.number().nonnegative(), +}); +export type DailyLeaderboardRank = z.infer; + +export const XpLeaderboardEntrySchema = z.object({ + uid: z.string(), + name: z.string(), + discordId: z.string().optional(), + discordAvatar: z.string().optional(), + badgeId: z.number().int().optional(), + lastActivityTimestamp: z.number().int().nonnegative(), + timeTypedSeconds: z.number().nonnegative(), + rank: z.number().nonnegative().int(), + totalXp: z.number().nonnegative().int(), +}); +export type XpLeaderboardEntry = z.infer; + +export const XpLeaderboardRankSchema = XpLeaderboardEntrySchema.extend({ + count: z.number().int().nonnegative(), +}); +export type XpLeaderboardRank = z.infer; diff --git a/packages/contracts/src/schemas/users.ts b/packages/contracts/src/schemas/users.ts index bf1bfabc2363..43d05832144d 100644 --- a/packages/contracts/src/schemas/users.ts +++ b/packages/contracts/src/schemas/users.ts @@ -1 +1,62 @@ -//tbd +import { z } from "zod"; +import { IdSchema } from "./util"; +import { ModeSchema } from "./shared"; + +export const ResultFiltersSchema = z.object({ + _id: IdSchema, + name: z.string(), + pb: z.object({ + no: z.boolean(), + yes: z.boolean(), + }), + difficulty: z.object({ + normal: z.boolean(), + expert: z.boolean(), + master: z.boolean(), + }), + mode: z.record(ModeSchema, z.boolean()), + words: z.object({ + "10": z.boolean(), + "25": z.boolean(), + "50": z.boolean(), + "100": z.boolean(), + custom: z.boolean(), + }), + time: z.object({ + "15": z.boolean(), + "30": z.boolean(), + "60": z.boolean(), + "120": z.boolean(), + custom: z.boolean(), + }), + quoteLength: z.object({ + short: z.boolean(), + medium: z.boolean(), + long: z.boolean(), + thicc: z.boolean(), + }), + punctuation: z.object({ + on: z.boolean(), + off: z.boolean(), + }), + numbers: z.object({ + on: z.boolean(), + off: z.boolean(), + }), + date: z.object({ + last_day: z.boolean(), + last_week: z.boolean(), + last_month: z.boolean(), + last_3months: z.boolean(), + all: z.boolean(), + }), + tags: z.record(z.boolean()), + language: z.record(z.boolean()), + funbox: z.record(z.boolean()), +}); +export type ResultFilters = z.infer; + +export type ResultFiltersGroup = keyof ResultFilters; + +export type ResultFiltersGroupItem = + keyof ResultFilters[T]; diff --git a/packages/contracts/src/schemas/util.ts b/packages/contracts/src/schemas/util.ts index 25d9a4b77b2e..be640d31851e 100644 --- a/packages/contracts/src/schemas/util.ts +++ b/packages/contracts/src/schemas/util.ts @@ -1,13 +1,12 @@ import { z, ZodString } from "zod"; export const StringNumberSchema = z - - .custom<`${number}`>((val) => { - if (typeof val === "number") val = val.toString(); - return typeof val === "string" ? /^\d+$/.test(val) : false; - }, 'Needs to be a number or a number represented as a string e.g. "10".') - .transform(String); - + .string() + .regex( + /^\d+$/, + 'Needs to be a number or a number represented as a string e.g. "10".' + ) + .or(z.number().transform(String)); export type StringNumber = z.infer; export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/); @@ -21,5 +20,5 @@ export type Tag = z.infer; export const LanguageSchema = z .string() .max(50) - .regex(/^[a-zA-Z0-9_+]+$/); + .regex(/^[a-zA-Z0-9_+]+$/, "Can only contain letters [a-zA-Z0-9_+]"); export type Language = z.infer; diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js index 37ffb2691125..d2f59e23bc2d 100644 --- a/packages/eslint-config/index.js +++ b/packages/eslint-config/index.js @@ -91,7 +91,12 @@ module.exports = { "error", { ignoreArrowShorthand: true }, ], - "@typescript-eslint/explicit-function-return-type": ["error"], + "@typescript-eslint/explicit-function-return-type": [ + "error", + { + allowExpressions: true, + }, + ], "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/no-empty-function": "error", "@typescript-eslint/no-unused-vars": [ diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index d069ecd3ca02..d10093d2041a 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -240,83 +240,6 @@ export type CustomTextDataWithTextLen = Omit & { textLen: number; }; -export type ResultFilters = { - _id: string; - name: string; - pb: { - no: boolean; - yes: boolean; - }; - difficulty: { - normal: boolean; - expert: boolean; - master: boolean; - }; - mode: { - words: boolean; - time: boolean; - quote: boolean; - zen: boolean; - custom: boolean; - }; - words: { - "10": boolean; - "25": boolean; - "50": boolean; - "100": boolean; - custom: boolean; - }; - time: { - "15": boolean; - "30": boolean; - "60": boolean; - "120": boolean; - custom: boolean; - }; - quoteLength: { - short: boolean; - medium: boolean; - long: boolean; - thicc: boolean; - }; - punctuation: { - on: boolean; - off: boolean; - }; - numbers: { - on: boolean; - off: boolean; - }; - date: { - last_day: boolean; - last_week: boolean; - last_month: boolean; - last_3months: boolean; - all: boolean; - }; - tags: Record; - language: Record; - funbox: { - none?: boolean; - } & Record; -}; - -export type LeaderboardEntry = { - _id: string; - wpm: number; - acc: number; - timestamp: number; - raw: number; - consistency?: number; - uid: string; - name: string; - discordId?: string; - discordAvatar?: string; - rank: number; - badgeId?: number; - isPremium?: boolean; -}; - export type PostResultResponse = { isPb: boolean; tagPbs: string[]; @@ -408,7 +331,7 @@ export type User = { verified?: boolean; needsToChangeName?: boolean; quoteMod?: boolean | string; - resultFilterPresets?: ResultFilters[]; + resultFilterPresets?: import("@monkeytype/contracts/schemas/users").ResultFilters[]; testActivity?: TestActivity; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d9ccbb0e4a4..0a47701b0d72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: workspace:* version: link:packages/release '@vitest/coverage-v8': - specifier: 1.6.0 - version: 1.6.0(vitest@1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) + specifier: 2.0.5 + version: 2.0.5(vitest@2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) conventional-changelog: specifier: 4.0.0 version: 4.0.0 @@ -42,8 +42,8 @@ importers: specifier: 2.0.9 version: 2.0.9 vitest: - specifier: 1.6.0 - version: 1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + specifier: 2.0.5 + version: 2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) backend: dependencies: @@ -172,8 +172,8 @@ importers: specifier: workspace:* version: link:../packages/typescript-config '@redocly/cli': - specifier: 1.18.1 - version: 1.18.1(encoding@0.1.13)(enzyme@3.11.0) + specifier: 1.19.0 + version: 1.19.0(encoding@0.1.13)(enzyme@3.11.0) '@types/bcrypt': specifier: 5.0.2 version: 5.0.2 @@ -232,8 +232,8 @@ importers: specifier: 10.0.0 version: 10.0.0 '@vitest/coverage-v8': - specifier: 1.6.0 - version: 1.6.0(vitest@1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) + specifier: 2.0.5 + version: 2.0.5(vitest@2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) concurrently: specifier: 8.2.2 version: 8.2.2 @@ -262,8 +262,8 @@ importers: specifier: 5.5.4 version: 5.5.4 vitest: - specifier: 1.6.0 - version: 1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + specifier: 2.0.5 + version: 2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) vitest-mongodb: specifier: 1.0.0 version: 1.0.0 @@ -389,8 +389,8 @@ importers: specifier: 2.1.0 version: 2.1.0 '@vitest/coverage-v8': - specifier: 1.6.0 - version: 1.6.0(vitest@1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) + specifier: 2.0.5 + version: 2.0.5(vitest@2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) ajv: specifier: 8.12.0 version: 8.12.0 @@ -461,8 +461,8 @@ importers: specifier: 0.20.0 version: 0.20.0(vite@5.1.7(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3))(workbox-build@7.1.1)(workbox-window@7.1.0) vitest: - specifier: 1.6.0 - version: 1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + specifier: 2.0.5 + version: 2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) packages/contracts: dependencies: @@ -2107,10 +2107,6 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -2327,16 +2323,16 @@ packages: '@redocly/ajv@8.11.0': resolution: {integrity: sha512-9GWx27t7xWhDIR02PA18nzBdLcKQRgc46xNQvjFkrYk4UOmvKhJ/dawwiX0cCOeetN5LcaaiqQbVOWYK62SGHw==} - '@redocly/cli@1.18.1': - resolution: {integrity: sha512-+bRKj46R9wvTzMdnoYfMueJ9/ek0NprEsQNowV7XcHgOXifeFFikRtBFcpkwqCNxaQ/nWAJn4LHZaFcssbcHow==} + '@redocly/cli@1.19.0': + resolution: {integrity: sha512-ev6J0eD+quprvW9PVCl9JmRFZbj6cuK+mnYPAjcrPvesy2RF752fflcpgQjGnyFaGb1Cj+DiwDi3dYr3EAp04A==} engines: {node: '>=14.19.0', npm: '>=7.0.0'} hasBin: true '@redocly/config@0.7.0': resolution: {integrity: sha512-6GKxTo/9df0654Mtivvr4lQnMOp+pRj9neVywmI5+BwfZLTtkJnj2qB3D6d8FHTr4apsNOf6zTa5FojX0Evh4g==} - '@redocly/openapi-core@1.18.1': - resolution: {integrity: sha512-y2ZR3aaVF80XRVoFP0Dp2z5DeCOilPTuS7V4HnHIYZdBTfsqzjkO169h5JqAaifnaLsLBhe3YArdgLb7W7wW6Q==} + '@redocly/openapi-core@1.19.0': + resolution: {integrity: sha512-ezK6qr80sXvjDgHNrk/zmRs9vwpIAeHa0T/qmo96S+ib4ThQ5a8f3qjwEqxMeVxkxCTbkaY9sYSJKOxv4ejg5w==} engines: {node: '>=14.19.0', npm: '>=7.0.0'} '@rollup/plugin-babel@5.3.1': @@ -2477,9 +2473,6 @@ packages: '@sideway/pinpoint@2.0.0': resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@snyk/github-codeowners@1.1.0': resolution: {integrity: sha512-lGFf08pbkEac0NYgVf4hdANpAgApRjNByLXB+WBip3qj1iendOIyAwP2GKkKbQMNVy2r1xxDf0ssfWscoiC+Vw==} engines: {node: '>=8.10'} @@ -2803,25 +2796,28 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@vitest/coverage-v8@1.6.0': - resolution: {integrity: sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==} + '@vitest/coverage-v8@2.0.5': + resolution: {integrity: sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==} peerDependencies: - vitest: 1.6.0 + vitest: 2.0.5 + + '@vitest/expect@2.0.5': + resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} - '@vitest/expect@1.6.0': - resolution: {integrity: sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==} + '@vitest/pretty-format@2.0.5': + resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} - '@vitest/runner@1.6.0': - resolution: {integrity: sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==} + '@vitest/runner@2.0.5': + resolution: {integrity: sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==} - '@vitest/snapshot@1.6.0': - resolution: {integrity: sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==} + '@vitest/snapshot@2.0.5': + resolution: {integrity: sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==} - '@vitest/spy@1.6.0': - resolution: {integrity: sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==} + '@vitest/spy@2.0.5': + resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} - '@vitest/utils@1.6.0': - resolution: {integrity: sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==} + '@vitest/utils@2.0.5': + resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} @@ -2935,10 +2931,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -3094,8 +3086,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} assign-symbols@1.0.0: resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} @@ -3405,9 +3398,9 @@ packages: resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} hasBin: true - chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} + chai@5.1.1: + resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==} + engines: {node: '>=12'} chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} @@ -3444,8 +3437,9 @@ packages: chartjs-plugin-trendline@1.0.2: resolution: {integrity: sha512-1yaWvaW3WvaikITgrc6JEyvWZWDN9Opjz65fCkgQr/dRdKuXcYzOMl45jylPiJyC9dWWL6HCYiL2HuwItjI8RQ==} - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -3682,9 +3676,6 @@ packages: engines: {node: ^14.13.0 || >=16.0.0} hasBin: true - confbox@0.1.7: - resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} - config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -3976,8 +3967,8 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} - deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} deep-equal-in-any-order@2.0.6: @@ -4166,10 +4157,6 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -5877,9 +5864,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-tokens@9.0.0: - resolution: {integrity: sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==} - js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -6097,10 +6081,6 @@ packages: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} engines: {node: '>=4'} - local-pkg@0.5.0: - resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} - engines: {node: '>=14'} - locate-path@2.0.0: resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} engines: {node: '>=4'} @@ -6235,8 +6215,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.1.1: + resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==} lower-case@1.1.4: resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} @@ -6613,9 +6593,6 @@ packages: engines: {node: '>=10'} hasBin: true - mlly@1.7.1: - resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} - mobx-react-lite@4.0.7: resolution: {integrity: sha512-RjwdseshK9Mg8On5tyJZHtGD+J78ZnCnRaxeQDSiciKVQDUbfZcXhmld0VMxAwvcTnPEHZySGGewm467Fcpreg==} peerDependencies: @@ -7114,10 +7091,6 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-limit@5.0.0: - resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} - engines: {node: '>=18'} - p-locate@2.0.0: resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} engines: {node: '>=4'} @@ -7301,8 +7274,9 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -7389,9 +7363,6 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} - pkg-types@1.1.3: - resolution: {integrity: sha512-+JrgthZG6m3ckicaOB74TwQ+tBWsFl3qVQg7mN8ulwSOElJ7gBhKzj2VkCPnZ4NlF6kEquYU+RIYNVAvzd54UA==} - plugin-error@2.0.1: resolution: {integrity: sha512-zMakqvIDyY40xHOvzXka0kUvf40nYIuwRE8dWhti2WtjQZ31xAgBZBhxsK7vK3QbRXS1Xms/LO7B5cuAsfB2Gg==} engines: {node: '>=10.13.0'} @@ -7484,10 +7455,6 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - pretty-hrtime@1.0.3: resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} engines: {node: '>= 0.8'} @@ -8464,9 +8431,6 @@ packages: resolution: {integrity: sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==} engines: {node: '>=14.16'} - strip-literal@2.1.0: - resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==} - strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} @@ -8589,9 +8553,9 @@ packages: engines: {node: '>=10'} hasBin: true - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} text-decoder@1.1.1: resolution: {integrity: sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==} @@ -8645,12 +8609,16 @@ packages: tinybench@2.8.0: resolution: {integrity: sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==} - tinypool@0.8.4: - resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + tinypool@1.0.0: + resolution: {integrity: sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} - tinyspy@2.2.1: - resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + tinyspy@3.0.0: + resolution: {integrity: sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==} engines: {node: '>=14.0.0'} tmp@0.0.33: @@ -8836,10 +8804,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} - type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} @@ -8911,9 +8875,6 @@ packages: ua-parser-js@0.7.33: resolution: {integrity: sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw==} - ufo@1.5.4: - resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} - uglify-js@3.19.1: resolution: {integrity: sha512-y/2wiW+ceTYR2TSSptAhfnEtpLaQ4Ups5zrjB2d3kuVxHj16j/QJwPl5PvuGy9uARb39J0+iKxcRPvtpsx4A4A==} engines: {node: '>=0.8.0'} @@ -9135,8 +9096,8 @@ packages: resolution: {integrity: sha512-JdUu5viGyw7K1HMstqaAN7y1rnNz93srGeF7FJgFCzM7NL1nH/QlpywDA296qv/KjPPPsq60mOJhtXddikVKSA==} hasBin: true - vite-node@1.6.0: - resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} + vite-node@2.0.5: + resolution: {integrity: sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -9230,15 +9191,15 @@ packages: vitest-mongodb@1.0.0: resolution: {integrity: sha512-IG39uQ4JpJf62rx9H0FUYwluXVQI5/Am6yrD9dE92SwJnFsJwpN4AynZkBbDuedvqFzG2GWK6mzzwU3vq28N0w==} - vitest@1.6.0: - resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} + vitest@2.0.5: + resolution: {integrity: sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.0 - '@vitest/ui': 1.6.0 + '@vitest/browser': 2.0.5 + '@vitest/ui': 2.0.5 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -9570,10 +9531,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-queue@1.1.1: - resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} - engines: {node: '>=12.20'} - zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -11420,10 +11377,6 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -11653,9 +11606,9 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 - '@redocly/cli@1.18.1(encoding@0.1.13)(enzyme@3.11.0)': + '@redocly/cli@1.19.0(encoding@0.1.13)(enzyme@3.11.0)': dependencies: - '@redocly/openapi-core': 1.18.1(encoding@0.1.13) + '@redocly/openapi-core': 1.19.0(encoding@0.1.13) abort-controller: 3.0.0 chokidar: 3.6.0 colorette: 1.4.0 @@ -11684,7 +11637,7 @@ snapshots: '@redocly/config@0.7.0': {} - '@redocly/openapi-core@1.18.1(encoding@0.1.13)': + '@redocly/openapi-core@1.19.0(encoding@0.1.13)': dependencies: '@redocly/ajv': 8.11.0 '@redocly/config': 0.7.0 @@ -11806,8 +11759,6 @@ snapshots: '@sideway/pinpoint@2.0.0': {} - '@sinclair/typebox@0.27.8': {} - '@snyk/github-codeowners@1.1.0': dependencies: commander: 4.1.1 @@ -12172,7 +12123,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitest/coverage-v8@1.6.0(vitest@1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3))': + '@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -12183,15 +12134,14 @@ snapshots: istanbul-reports: 3.1.7 magic-string: 0.30.11 magicast: 0.3.4 - picocolors: 1.0.1 std-env: 3.7.0 - strip-literal: 2.1.0 - test-exclude: 6.0.0 - vitest: 1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@1.6.0(vitest@1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3))': + '@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -12202,42 +12152,45 @@ snapshots: istanbul-reports: 3.1.7 magic-string: 0.30.11 magicast: 0.3.4 - picocolors: 1.0.1 std-env: 3.7.0 - strip-literal: 2.1.0 - test-exclude: 6.0.0 - vitest: 1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) transitivePeerDependencies: - supports-color - '@vitest/expect@1.6.0': + '@vitest/expect@2.0.5': dependencies: - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - chai: 4.5.0 + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.1.1 + tinyrainbow: 1.2.0 - '@vitest/runner@1.6.0': + '@vitest/pretty-format@2.0.5': dependencies: - '@vitest/utils': 1.6.0 - p-limit: 5.0.0 + tinyrainbow: 1.2.0 + + '@vitest/runner@2.0.5': + dependencies: + '@vitest/utils': 2.0.5 pathe: 1.1.2 - '@vitest/snapshot@1.6.0': + '@vitest/snapshot@2.0.5': dependencies: + '@vitest/pretty-format': 2.0.5 magic-string: 0.30.11 pathe: 1.1.2 - pretty-format: 29.7.0 - '@vitest/spy@1.6.0': + '@vitest/spy@2.0.5': dependencies: - tinyspy: 2.2.1 + tinyspy: 3.0.0 - '@vitest/utils@1.6.0': + '@vitest/utils@2.0.5': dependencies: - diff-sequences: 29.6.3 + '@vitest/pretty-format': 2.0.5 estree-walker: 3.0.3 - loupe: 2.3.7 - pretty-format: 29.7.0 + loupe: 3.1.1 + tinyrainbow: 1.2.0 JSONStream@1.3.5: dependencies: @@ -12342,8 +12295,6 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - ansi-styles@6.2.1: {} ansi-wrap@0.1.0: {} @@ -12513,7 +12464,7 @@ snapshots: asap@2.0.6: {} - assertion-error@1.1.0: {} + assertion-error@2.0.1: {} assign-symbols@1.0.0: {} @@ -12887,15 +12838,13 @@ snapshots: ansicolors: 0.3.2 redeyed: 2.1.1 - chai@4.5.0: + chai@5.1.1: dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.1 + pathval: 2.0.0 chalk@2.4.2: dependencies: @@ -12926,9 +12875,7 @@ snapshots: chartjs-plugin-trendline@1.0.2: {} - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 + check-error@2.1.1: {} cheerio-select@2.1.0: dependencies: @@ -13202,8 +13149,6 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 - confbox@0.1.7: {} - config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -13493,9 +13438,7 @@ snapshots: decode-uri-component@0.2.2: {} - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 + deep-eql@5.0.2: {} deep-equal-in-any-order@2.0.6: dependencies: @@ -13696,8 +13639,6 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 - diff-sequences@29.6.3: {} - diff@4.0.2: {} dir-glob@3.0.1: @@ -16015,8 +15956,6 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.0: {} - js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -16284,11 +16223,6 @@ snapshots: pify: 3.0.0 strip-bom: 3.0.0 - local-pkg@0.5.0: - dependencies: - mlly: 1.7.1 - pkg-types: 1.1.3 - locate-path@2.0.0: dependencies: p-locate: 2.0.0 @@ -16402,7 +16336,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@2.3.7: + loupe@3.1.1: dependencies: get-func-name: 2.0.2 @@ -17004,13 +16938,6 @@ snapshots: mkdirp@2.1.6: {} - mlly@1.7.1: - dependencies: - acorn: 8.12.1 - pathe: 1.1.2 - pkg-types: 1.1.3 - ufo: 1.5.4 - mobx-react-lite@4.0.7(mobx@6.13.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: mobx: 6.13.1 @@ -17564,10 +17491,6 @@ snapshots: dependencies: yocto-queue: 0.1.0 - p-limit@5.0.0: - dependencies: - yocto-queue: 1.1.1 - p-locate@2.0.0: dependencies: p-limit: 1.3.0 @@ -17734,7 +17657,7 @@ snapshots: pathe@1.1.2: {} - pathval@1.1.1: {} + pathval@2.0.0: {} pend@1.2.0: {} @@ -17811,12 +17734,6 @@ snapshots: dependencies: find-up: 4.1.0 - pkg-types@1.1.3: - dependencies: - confbox: 0.1.7 - mlly: 1.7.1 - pathe: 1.1.2 - plugin-error@2.0.1: dependencies: ansi-colors: 1.1.0 @@ -17925,12 +17842,6 @@ snapshots: pretty-bytes@6.1.1: {} - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - pretty-hrtime@1.0.3: {} pretty-ms@7.0.1: @@ -18231,7 +18142,7 @@ snapshots: redoc@2.1.5(core-js@3.37.1)(encoding@0.1.13)(enzyme@3.11.0)(mobx@6.13.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@cfaester/enzyme-adapter-react-18': 0.8.0(enzyme@3.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@redocly/openapi-core': 1.18.1(encoding@0.1.13) + '@redocly/openapi-core': 1.19.0(encoding@0.1.13) classnames: 2.5.1 core-js: 3.37.1 decko: 1.2.0 @@ -19039,10 +18950,6 @@ snapshots: strip-json-comments@5.0.1: {} - strip-literal@2.1.0: - dependencies: - js-tokens: 9.0.0 - strnum@1.0.5: optional: true @@ -19257,11 +19164,11 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - test-exclude@6.0.0: + test-exclude@7.0.1: dependencies: '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 + glob: 10.4.5 + minimatch: 9.0.5 text-decoder@1.1.1: dependencies: @@ -19310,9 +19217,11 @@ snapshots: tinybench@2.8.0: {} - tinypool@0.8.4: {} + tinypool@1.0.0: {} + + tinyrainbow@1.2.0: {} - tinyspy@2.2.1: {} + tinyspy@3.0.0: {} tmp@0.0.33: dependencies: @@ -19484,8 +19393,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@4.1.0: {} - type-fest@0.16.0: {} type-fest@0.18.1: {} @@ -19551,8 +19458,6 @@ snapshots: ua-parser-js@0.7.33: {} - ufo@1.5.4: {} - uglify-js@3.19.1: {} unbox-primitive@1.0.2: @@ -19828,12 +19733,12 @@ snapshots: - rollup - supports-color - vite-node@1.6.0(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3): + vite-node@2.0.5(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3): dependencies: cac: 6.7.14 debug: 4.3.6(supports-color@5.5.0) pathe: 1.1.2 - picocolors: 1.0.1 + tinyrainbow: 1.2.0 vite: 5.1.7(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3) transitivePeerDependencies: - '@types/node' @@ -19845,12 +19750,12 @@ snapshots: - supports-color - terser - vite-node@1.6.0(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3): + vite-node@2.0.5(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3): dependencies: cac: 6.7.14 debug: 4.3.6(supports-color@5.5.0) pathe: 1.1.2 - picocolors: 1.0.1 + tinyrainbow: 1.2.0 vite: 5.1.7(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) transitivePeerDependencies: - '@types/node' @@ -19950,27 +19855,26 @@ snapshots: - snappy - supports-color - vitest@1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3): + vitest@2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3): dependencies: - '@vitest/expect': 1.6.0 - '@vitest/runner': 1.6.0 - '@vitest/snapshot': 1.6.0 - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - acorn-walk: 8.3.3 - chai: 4.5.0 + '@ampproject/remapping': 2.3.0 + '@vitest/expect': 2.0.5 + '@vitest/pretty-format': 2.0.5 + '@vitest/runner': 2.0.5 + '@vitest/snapshot': 2.0.5 + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.1.1 debug: 4.3.6(supports-color@5.5.0) execa: 8.0.1 - local-pkg: 0.5.0 magic-string: 0.30.11 pathe: 1.1.2 - picocolors: 1.0.1 std-env: 3.7.0 - strip-literal: 2.1.0 tinybench: 2.8.0 - tinypool: 0.8.4 + tinypool: 1.0.0 + tinyrainbow: 1.2.0 vite: 5.1.7(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3) - vite-node: 1.6.0(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3) + vite-node: 2.0.5(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.14.11 @@ -19984,27 +19888,26 @@ snapshots: - supports-color - terser - vitest@1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3): + vitest@2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3): dependencies: - '@vitest/expect': 1.6.0 - '@vitest/runner': 1.6.0 - '@vitest/snapshot': 1.6.0 - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - acorn-walk: 8.3.3 - chai: 4.5.0 + '@ampproject/remapping': 2.3.0 + '@vitest/expect': 2.0.5 + '@vitest/pretty-format': 2.0.5 + '@vitest/runner': 2.0.5 + '@vitest/snapshot': 2.0.5 + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.1.1 debug: 4.3.6(supports-color@5.5.0) execa: 8.0.1 - local-pkg: 0.5.0 magic-string: 0.30.11 pathe: 1.1.2 - picocolors: 1.0.1 std-env: 3.7.0 - strip-literal: 2.1.0 tinybench: 2.8.0 - tinypool: 0.8.4 + tinypool: 1.0.0 + tinyrainbow: 1.2.0 vite: 5.1.7(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) - vite-node: 1.6.0(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) + vite-node: 2.0.5(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.5.1 @@ -20444,8 +20347,6 @@ snapshots: yocto-queue@0.1.0: {} - yocto-queue@1.1.1: {} - zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2