import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { setup } from "../../__testData__/controller-test"; import * as Configuration from "../../../src/init/configuration"; import * as ResultDal from "../../../src/dal/result"; import * as UserDal from "../../../src/dal/user"; import * as LogsDal from "../../../src/dal/logs"; import * as PublicDal from "../../../src/dal/public"; import { ObjectId } from "mongodb"; import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; import { enableRateLimitExpects } from "../../__testData__/rate-limit"; import { DBResult } from "../../../src/utils/result"; import { omit } from "../../../src/utils/misc"; import { CompletedEvent } from "@monkeytype/schemas/results"; const { mockApp, uid, mockAuth } = setup(); const configuration = Configuration.getCachedConfiguration(); enableRateLimitExpects(); describe("result controller test", () => { describe("getResults", () => { const resultMock = vi.spyOn(ResultDal, "getResults"); beforeEach(async () => { resultMock.mockResolvedValue([]); await enablePremiumFeatures(true); vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); }); afterEach(() => { resultMock.mockClear(); }); it("should get results", async () => { //GIVEN const resultOne = givenDbResult(uid); const resultTwo = givenDbResult(uid); resultMock.mockResolvedValue([resultOne, resultTwo]); //WHEN const { body } = await mockApp .get("/results") .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(body.message).toEqual("Results retrieved"); expect(body.data).toEqual([ { ...resultOne, _id: resultOne._id.toHexString() }, { ...resultTwo, _id: resultTwo._id.toHexString() }, ]); }); it("should get results with ape key", async () => { //GIVEN await acceptApeKeys(true); const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); //WHEN await mockApp .get("/results") .set("Authorization", `ApeKey ${apeKey}`) .send() .expect(200); }); it("should get latest 1000 results for regular user", async () => { //WHEN await mockApp .get("/results") .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(resultMock).toHaveBeenCalledWith(uid, { limit: 1000, offset: 0, onOrAfterTimestamp: NaN, }); }); it("should get results filter by onOrAfterTimestamp", async () => { //GIVEN const now = Date.now(); //WHEN await mockApp .get("/results") .query({ onOrAfterTimestamp: now }) .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(resultMock).toHaveBeenCalledWith(uid, { limit: 1000, offset: 0, onOrAfterTimestamp: now, }); }); it("should get with limit and offset", async () => { //WHEN await mockApp .get("/results") .query({ limit: 250, offset: 500 }) .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(resultMock).toHaveBeenCalledWith(uid, { limit: 250, offset: 500, onOrAfterTimestamp: NaN, }); }); it("should fail exceeding max limit for regular user", async () => { //WHEN const { body } = await mockApp .get("/results") .query({ limit: 100, offset: 1000 }) .set("Authorization", `Bearer ${uid}`) .send() .expect(422); //THEN expect(body.message).toEqual( `Max results limit of ${ (await configuration).results.limits.regularUser } exceeded.`, ); }); it("should get with higher max limit for premium user", async () => { //GIVEN vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); //WHEN await mockApp .get("/results") .query({ limit: 800, offset: 600 }) .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(resultMock).toHaveBeenCalledWith(uid, { limit: 800, offset: 600, onOrAfterTimestamp: NaN, }); }); it("should get results if offset/limit is partly outside the max limit", async () => { //WHEN await mockApp .get("/results") .query({ limit: 20, offset: 990 }) .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(resultMock).toHaveBeenCalledWith(uid, { limit: 10, //limit is reduced to stay within max limit offset: 990, onOrAfterTimestamp: NaN, }); }); it("should fail exceeding 1k limit", async () => { //GIVEN vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); //WHEN const { body } = await mockApp .get("/results") .query({ limit: 2000 }) .set("Authorization", `Bearer ${uid}`) .send() .expect(422); //THEN expect(body).toEqual({ message: "Invalid query schema", validationErrors: ['"limit" Number must be less than or equal to 1000'], }); }); it("should fail exceeding maxlimit for premium user", async () => { //GIVEN vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); //WHEN const { body } = await mockApp .get("/results") .query({ limit: 1000, offset: 25000 }) .set("Authorization", `Bearer ${uid}`) .send() .expect(422); //THEN expect(body.message).toEqual( `Max results limit of ${ (await configuration).results.limits.premiumUser } exceeded.`, ); }); it("should get results within regular limits for premium users even if premium is globally disabled", async () => { //GIVEN vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); enablePremiumFeatures(false); //WHEN await mockApp .get("/results") .query({ limit: 100, offset: 900 }) .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(resultMock).toHaveBeenCalledWith(uid, { limit: 100, offset: 900, onOrAfterTimestamp: NaN, }); }); it("should fail exceeding max limit for premium user if premium is globally disabled", async () => { //GIVEN vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); enablePremiumFeatures(false); //WHEN const { body } = await mockApp .get("/results") .query({ limit: 200, offset: 900 }) .set("Authorization", `Bearer ${uid}`) .send() .expect(503); //THEN expect(body.message).toEqual("Premium feature disabled."); }); it("should get results with regular limit as default for premium users if premium is globally disabled", async () => { //GIVEN vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); enablePremiumFeatures(false); //WHEN await mockApp .get("/results") .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(resultMock).toHaveBeenCalledWith(uid, { limit: 1000, //the default limit for regular users offset: 0, onOrAfterTimestamp: NaN, }); }); it("should fail with unknown query parameters", async () => { //WHEN const { body } = await mockApp .get("/results") .query({ extra: "value" }) .set("Authorization", `Bearer ${uid}`) .send() .expect(422); //THEN expect(body).toEqual({ message: "Invalid query schema", validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); it("should be rate limited", async () => { await expect( mockApp.get("/results").set("Authorization", `Bearer ${uid}`), ).toBeRateLimited({ max: 60, windowMs: 60 * 60 * 1000 }); }); it("should be rate limited for ape keys", async () => { //GIVEN await acceptApeKeys(true); const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); //WHEN await expect( mockApp.get("/results").set("Authorization", `ApeKey ${apeKey}`), ).toBeRateLimited({ max: 30, windowMs: 24 * 60 * 60 * 1000 }); }); }); describe("getResultById", () => { const getResultMock = vi.spyOn(ResultDal, "getResult"); afterEach(() => { getResultMock.mockClear(); }); it("should get result", async () => { //GIVEN const result = givenDbResult(uid); getResultMock.mockResolvedValue(result); //WHEN const { body } = await mockApp .get(`/results/id/${result._id}`) .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(body.message).toEqual("Result retrieved"); expect(body.data).toEqual({ ...result, _id: result._id.toHexString() }); }); it("should get last result with ape key", async () => { //GIVEN await acceptApeKeys(true); const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); const result = givenDbResult(uid); getResultMock.mockResolvedValue(result); //WHEN await mockApp .get(`/results/id/${result._id}`) .set("Authorization", `ApeKey ${apeKey}`) .send() .expect(200); }); it("should rate limit get result with ape key", async () => { //GIVEN const result = givenDbResult(uid, { charStats: undefined, incorrectChars: 5, correctChars: 12, }); getResultMock.mockResolvedValue(result); await acceptApeKeys(true); const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); //WHEN await expect( mockApp .get(`/results/id/${result._id}`) .set("Authorization", `ApeKey ${apeKey}`), ).toBeRateLimited({ max: 60, windowMs: 60 * 60 * 1000 }); }); }); describe("getLastResult", () => { const getLastResultMock = vi.spyOn(ResultDal, "getLastResult"); afterEach(() => { getLastResultMock.mockClear(); }); it("should get last result", async () => { //GIVEN const result = givenDbResult(uid); getLastResultMock.mockResolvedValue(result); //WHEN const { body } = await mockApp .get("/results/last") .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(body.message).toEqual("Result retrieved"); expect(body.data).toEqual({ ...result, _id: result._id.toHexString() }); }); it("should get last result with ape key", async () => { //GIVEN await acceptApeKeys(true); const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); const result = givenDbResult(uid); getLastResultMock.mockResolvedValue(result); //WHEN await mockApp .get("/results/last") .set("Authorization", `ApeKey ${apeKey}`) .send() .expect(200); }); it("should rate limit get last result with ape key", async () => { //GIVEN const result = givenDbResult(uid, { charStats: undefined, incorrectChars: 5, correctChars: 12, }); getLastResultMock.mockResolvedValue(result); await acceptApeKeys(true); const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); //WHEN await expect( mockApp.get("/results/last").set("Authorization", `ApeKey ${apeKey}`), ).toBeRateLimited({ max: 30, windowMs: 60 * 1000 }); //should use defaultApeRateLimit }); }); describe("deleteAll", () => { const deleteAllMock = vi.spyOn(ResultDal, "deleteAll"); const logToDbMock = vi.spyOn(LogsDal, "addLog"); afterEach(() => { deleteAllMock.mockClear(); logToDbMock.mockClear(); }); it("should delete", async () => { //GIVEN mockAuth.modifyToken({ iat: Date.now() - 1000 }); deleteAllMock.mockResolvedValue(undefined as any); //WHEN const { body } = await mockApp .delete("/results") .set("Authorization", `Bearer ${uid}`) .send() .expect(200); //THEN expect(body.message).toEqual("All results deleted"); expect(body.data).toBeNull(); expect(deleteAllMock).toHaveBeenCalledWith(uid); expect(logToDbMock).toHaveBeenCalledWith("user_results_deleted", "", uid); }); it("should fail to delete with non-fresh token", async () => { //GIVEN mockAuth.modifyToken({ iat: 0 }); //WHEN/THEN await mockApp .delete("/results") .set("Authorization", `Bearer ${uid}`) .send() .expect(401); }); }); describe("updateTags", () => { const getResultMock = vi.spyOn(ResultDal, "getResult"); const updateTagsMock = vi.spyOn(ResultDal, "updateTags"); const getUserPartialMock = vi.spyOn(UserDal, "getPartialUser"); const checkIfTagPbMock = vi.spyOn(UserDal, "checkIfTagPb"); afterEach(() => { [ getResultMock, updateTagsMock, getUserPartialMock, checkIfTagPbMock, ].forEach((it) => it.mockClear()); }); it("should update tags", async () => { //GIVEN const result = givenDbResult(uid); const resultIdString = result._id.toHexString(); const tagIds = [ new ObjectId().toHexString(), new ObjectId().toHexString(), ]; const partialUser = { tags: [] }; getResultMock.mockResolvedValue(result); updateTagsMock.mockResolvedValue({} as any); getUserPartialMock.mockResolvedValue(partialUser as any); checkIfTagPbMock.mockResolvedValue([]); //WHEN const { body } = await mockApp .patch("/results/tags") .set("Authorization", `Bearer ${uid}`) .send({ resultId: resultIdString, tagIds }) .expect(200); //THEN expect(body.message).toEqual("Result tags updated"); expect(body.data).toEqual({ tagPbs: [], }); expect(updateTagsMock).toHaveBeenCalledWith(uid, resultIdString, tagIds); expect(getResultMock).toHaveBeenCalledWith(uid, resultIdString); expect(getUserPartialMock).toHaveBeenCalledWith(uid, "update tags", [ "tags", ]); expect(checkIfTagPbMock).toHaveBeenCalledWith(uid, partialUser, result); }); it("should apply defaults on missing data", async () => { //GIVEN const result = givenDbResult(uid); const partialResult = omit(result, [ "difficulty", "language", "funbox", "lazyMode", "punctuation", "numbers", ]); const resultIdString = result._id.toHexString(); const tagIds = [ new ObjectId().toHexString(), new ObjectId().toHexString(), ]; const partialUser = { tags: [] }; getResultMock.mockResolvedValue(partialResult); updateTagsMock.mockResolvedValue({} as any); getUserPartialMock.mockResolvedValue(partialUser as any); checkIfTagPbMock.mockResolvedValue([]); //WHEN const { body } = await mockApp .patch("/results/tags") .set("Authorization", `Bearer ${uid}`) .send({ resultId: resultIdString, tagIds }) .expect(200); //THEN expect(body.message).toEqual("Result tags updated"); expect(body.data).toEqual({ tagPbs: [], }); expect(updateTagsMock).toHaveBeenCalledWith(uid, resultIdString, tagIds); expect(getResultMock).toHaveBeenCalledWith(uid, resultIdString); expect(getUserPartialMock).toHaveBeenCalledWith(uid, "update tags", [ "tags", ]); expect(checkIfTagPbMock).toHaveBeenCalledWith(uid, partialUser, { ...result, difficulty: "normal", language: "english", funbox: [], lazyMode: false, punctuation: false, numbers: false, }); }); it("should fail with missing mandatory properties", async () => { //GIVEN //WHEN const { body } = await mockApp .patch("/results/tags") .set("Authorization", `Bearer ${uid}`) .send({}) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"tagIds" Required', '"resultId" Required'], }); }); it("should fail with unknown properties", async () => { //GIVEN //WHEN const { body } = await mockApp .patch("/results/tags") .set("Authorization", `Bearer ${uid}`) .send({ extra: "value" }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ '"tagIds" Required', '"resultId" Required', "Unrecognized key(s) in object: 'extra'", ], }); }); }); describe("addResult", () => { //TODO improve test coverage for addResult const insertedId = new ObjectId(); const userGetMock = vi.spyOn(UserDal, "getUser"); const userUpdateStreakMock = vi.spyOn(UserDal, "updateStreak"); const userCheckIfTagPbMock = vi.spyOn(UserDal, "checkIfTagPb"); const userCheckIfPbMock = vi.spyOn(UserDal, "checkIfPb"); const userIncrementXpMock = vi.spyOn(UserDal, "incrementXp"); const userUpdateTypingStatsMock = vi.spyOn(UserDal, "updateTypingStats"); const resultAddMock = vi.spyOn(ResultDal, "addResult"); const publicUpdateStatsMock = vi.spyOn(PublicDal, "updateStats"); beforeEach(async () => { await enableResultsSaving(true); await enableUsersXpGain(true); [ userGetMock, userUpdateStreakMock, userCheckIfTagPbMock, userCheckIfPbMock, userIncrementXpMock, userUpdateTypingStatsMock, resultAddMock, publicUpdateStatsMock, ].forEach((it) => it.mockClear()); userGetMock.mockResolvedValue({ name: "bob" } as any); userUpdateStreakMock.mockResolvedValue(0); userCheckIfTagPbMock.mockResolvedValue([]); userCheckIfPbMock.mockResolvedValue(true); resultAddMock.mockResolvedValue({ insertedId }); userIncrementXpMock.mockResolvedValue(); }); it("should add result", async () => { //GIVEN const completedEvent = buildCompletedEvent({ funbox: ["58008", "read_ahead_hard"], }); //WHEN const { body } = await mockApp .post("/results") .set("Authorization", `Bearer ${uid}`) .send({ result: completedEvent, }) .expect(200); expect(body.message).toEqual("Result saved"); expect(body.data).toEqual({ isPb: true, tagPbs: [], xp: 0, dailyXpBonus: false, xpBreakdown: { accPenalty: 28, base: 20, incomplete: 5, funbox: 80, }, streak: 0, insertedId: insertedId.toHexString(), }); expect(resultAddMock).toHaveBeenCalledWith( uid, expect.objectContaining({ acc: 86, afkDuration: 5, charStats: [100, 2, 3, 5], chartData: { err: [0, 2, 0], burst: [50, 55, 56], wpm: [1, 2, 3], }, consistency: 23.5, incompleteTestSeconds: 2, isPb: true, keyConsistency: 12, keyDurationStats: { average: 2.67, sd: 2.05, }, keySpacingStats: { average: 2, sd: 1.63, }, mode: "time", mode2: "15", name: "bob", rawWpm: 99, restartCount: 4, tags: ["tagOneId", "tagTwoId"], testDuration: 15.1, uid: uid, wpm: 80, }), ); expect(publicUpdateStatsMock).toHaveBeenCalledWith( 4, 15.1 + 2 - 5, //duration + incompleteTestSeconds-afk ); expect(userIncrementXpMock).toHaveBeenCalledWith(uid, 0); expect(userUpdateTypingStatsMock).toHaveBeenCalledWith( uid, 4, 15.1 + 2 - 5, //duration + incompleteTestSeconds-afk ); }); it("should fail if result saving is disabled", async () => { //GIVEN await enableResultsSaving(false); //WHEN const { body } = await mockApp .post("/results") .set("Authorization", `Bearer ${uid}`) .send({}) .expect(503); //THEN expect(body.message).toEqual("Results are not being saved at this time."); }); it("should fail without mandatory properties", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/results") .set("Authorization", `Bearer ${uid}`) .send({}) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: ['"result" Required'], }); }); it("should fail with unknown properties", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/results") .set("Authorization", `Bearer ${uid}`) .send({ result: buildCompletedEvent({ extra2: "value", } as any), extra: "value", }) .expect(422); //THEN expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ `"result" Unrecognized key(s) in object: 'extra2'`, "Unrecognized key(s) in object: 'extra'", ], }); }); it("should fail wit duplicate funboxes", async () => { //GIVEN //WHEN const { body } = await mockApp .post("/results") .set("Authorization", `Bearer ${uid}`) .send({ result: buildCompletedEvent({ funbox: ["58008", "58008"], }), }) .expect(400); //THEN expect(body.message).toEqual("Duplicate funboxes"); }); // it("should fail invalid properties ", async () => { //GIVEN //WHEN // const { body } = await mockApp // .post("/results") // .set("Authorization", `Bearer ${uid}`) // //TODO add all properties // .send({ result: { acc: 25 } }) // .expect(422); //THEN /* expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ ], }); */ // }); }); }); function buildCompletedEvent(result?: Partial): CompletedEvent { return { acc: 86, afkDuration: 5, bailedOut: false, blindMode: false, charStats: [100, 2, 3, 5], chartData: { wpm: [1, 2, 3], burst: [50, 55, 56], err: [0, 2, 0] }, consistency: 23.5, difficulty: "normal", funbox: [], hash: "hash", incompleteTestSeconds: 2, incompleteTests: [{ acc: 75, seconds: 10 }], keyConsistency: 12, keyDuration: [0, 3, 5], keySpacing: [0, 2, 4], language: "english", lazyMode: false, mode: "time", mode2: "15", numbers: false, punctuation: false, rawWpm: 99, restartCount: 4, tags: ["tagOneId", "tagTwoId"], testDuration: 15.1, timestamp: 1000, uid, wpmConsistency: 55, wpm: 80, stopOnLetter: false, //new required charTotal: 5, keyOverlap: 7, lastKeyToEnd: 9, startToFirstKey: 11, ...result, }; } async function enablePremiumFeatures(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.users.premium = { ...mockConfig.users.premium, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } function givenDbResult(uid: string, customize?: Partial): DBResult { return { _id: new ObjectId(), wpm: Math.random() * 100, rawWpm: Math.random() * 100, charStats: [ Math.round(Math.random() * 10), Math.round(Math.random() * 10), Math.round(Math.random() * 10), Math.round(Math.random() * 10), ], acc: 80 + Math.random() * 20, //min accuracy is 75% mode: "time", mode2: "60", timestamp: Math.round(Math.random() * 100), testDuration: 1 + Math.random() * 100, consistency: Math.random() * 100, keyConsistency: Math.random() * 100, uid, keySpacingStats: { average: Math.random() * 100, sd: Math.random() }, keyDurationStats: { average: Math.random() * 100, sd: Math.random() }, isPb: true, chartData: { wpm: [Math.random() * 100], burst: [Math.random() * 100], err: [Math.random() * 100], }, name: "testName", ...customize, }; } async function acceptApeKeys(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.apeKeys = { ...mockConfig.apeKeys, acceptKeys: enabled, }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function enableResultsSaving(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.results = { ...mockConfig.results, savingEnabled: enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); } async function enableUsersXpGain(enabled: boolean): Promise { const mockConfig = await configuration; mockConfig.users.xp = { ...mockConfig.users.xp, enabled, funboxBonus: 1 }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig, ); }