diff --git a/backend/__tests__/__integration__/services/weekly-xp-leaderboard.spec.ts b/backend/__tests__/__integration__/services/weekly-xp-leaderboard.spec.ts index 1b572b3341d2..970f38143ca9 100644 --- a/backend/__tests__/__integration__/services/weekly-xp-leaderboard.spec.ts +++ b/backend/__tests__/__integration__/services/weekly-xp-leaderboard.spec.ts @@ -48,22 +48,25 @@ describe("Weekly XP Leaderboards", () => { const results = await lb.getResults(0, 10, leaderboardsConfig, true); //THEN - expect(results).toEqual([ - { - ...user1, - rank: 1, - timeTypedSeconds: 10, - totalXp: 150, - isPremium: false, - }, - { - ...user2, - rank: 2, - timeTypedSeconds: 7, - totalXp: 100, - isPremium: true, - }, - ]); + expect(results).toEqual({ + count: 2, + entries: [ + { + ...user1, + rank: 1, + timeTypedSeconds: 10, + totalXp: 150, + isPremium: false, + }, + { + ...user2, + rank: 2, + timeTypedSeconds: 7, + totalXp: 100, + isPremium: true, + }, + ], + }); }); }); @@ -77,10 +80,13 @@ describe("Weekly XP Leaderboards", () => { const results = await lb.getResults(0, 10, leaderboardsConfig, true); //THEN - expect(results).toEqual([ - { rank: 1, totalXp: 150, ...user1 }, - { rank: 2, totalXp: 100, ...user2 }, - ]); + expect(results).toEqual({ + count: 2, + entries: [ + { rank: 1, totalXp: 150, ...user1 }, + { rank: 2, totalXp: 100, ...user2 }, + ], + }); }); it("gets results for page", async () => { @@ -94,10 +100,13 @@ describe("Weekly XP Leaderboards", () => { const results = await lb.getResults(1, 2, leaderboardsConfig, true); //THEN - expect(results).toEqual([ - { rank: 3, totalXp: 50, ...user3 }, - { rank: 4, totalXp: 25, ...user4 }, - ]); + expect(results).toEqual({ + count: 4, + entries: [ + { rank: 3, totalXp: 50, ...user3 }, + { rank: 4, totalXp: 25, ...user4 }, + ], + }); }); it("gets results without premium", async () => { @@ -109,10 +118,73 @@ describe("Weekly XP Leaderboards", () => { const results = await lb.getResults(0, 10, leaderboardsConfig, false); //THEN - expect(results).toEqual([ - { rank: 1, totalXp: 150, ...user1, isPremium: undefined }, - { rank: 2, totalXp: 100, ...user2, isPremium: undefined }, + expect(results).toEqual({ + count: 2, + entries: [ + { rank: 1, totalXp: 150, ...user1, isPremium: undefined }, + { rank: 2, totalXp: 100, ...user2, isPremium: undefined }, + ], + }); + }); + + it("gets results for friends only", async () => { + //GIVEN + const _user1 = await givenResult(100); + const user2 = await givenResult(75); + const _user3 = await givenResult(50); + const user4 = await givenResult(25); + + //WHEN + const results = await lb.getResults(0, 5, leaderboardsConfig, true, [ + user2.uid, + user4.uid, + new ObjectId().toHexString(), + ]); + + //THEN + expect(results).toEqual({ + count: 2, + entries: [ + { rank: 2, friendsRank: 1, totalXp: 75, ...user2 }, + { rank: 4, friendsRank: 2, totalXp: 25, ...user4 }, + ], + }); + }); + + it("gets results for friends only with page", async () => { + //GIVEN + const user1 = await givenResult(100); + const user2 = await givenResult(75); + const _user3 = await givenResult(50); + const user4 = await givenResult(25); + const _user5 = await givenResult(5); + + //WHEN + const results = await lb.getResults(1, 2, leaderboardsConfig, true, [ + user1.uid, + user2.uid, + user4.uid, + new ObjectId().toHexString(), ]); + + //THEN + expect(results).toEqual({ + count: 3, + entries: [{ rank: 4, friendsRank: 3, totalXp: 25, ...user4 }], + }); + }); + + it("should return empty list if no friends", async () => { + //GIVEN + + //WHEN + const results = await lb.getResults(0, 5, leaderboardsConfig, true, []); + + //THEN + expect(results).toEqual({ + count: 0, + entries: [], + }); }); }); @@ -127,18 +199,30 @@ describe("Weekly XP Leaderboards", () => { //THEN expect(rank).toEqual({ rank: 2, totalXp: 100, ...user1 }); }); - }); - describe("getCount", () => { - it("gets count", async () => { + it("should return null for unknown user", async () => { + expect(await lb.getRank("decoy", leaderboardsConfig)).toBeNull(); + expect( + await lb.getRank("decoy", leaderboardsConfig, ["unknown", "unknown2"]) + ).toBeNull(); + }); + + it("gets rank for friends", async () => { //GIVEN - await givenResult(100); - await givenResult(150); + const user1 = await givenResult(50); + const user2 = await givenResult(60); + const _user3 = await givenResult(70); - //WHEN - const count = await lb.getCount(); - //THEN - expect(count).toEqual(2); + const friends = [user1.uid, user2.uid, "decoy"]; + + //WHEN / THEN + expect( + await lb.getRank(user2.uid, leaderboardsConfig, friends) + ).toEqual({ rank: 2, friendsRank: 1, totalXp: 60, ...user2 }); + + expect( + await lb.getRank(user1.uid, leaderboardsConfig, friends) + ).toEqual({ rank: 3, friendsRank: 2, totalXp: 50, ...user1 }); }); }); @@ -154,9 +238,10 @@ describe("Weekly XP Leaderboards", () => { ); //THEN expect(await lb.getRank(cheater.uid, leaderboardsConfig)).toBeNull(); - expect(await lb.getResults(0, 50, leaderboardsConfig, true)).toEqual([ - { rank: 1, totalXp: 1000, ...validUser }, - ]); + expect(await lb.getResults(0, 50, leaderboardsConfig, true)).toEqual({ + count: 1, + entries: [{ rank: 1, totalXp: 1000, ...validUser }], + }); }); async function givenResult( diff --git a/backend/__tests__/__integration__/utils/daily-leaderboards.spec.ts b/backend/__tests__/__integration__/utils/daily-leaderboards.spec.ts index 7f5a7efdbe87..8cd725566248 100644 --- a/backend/__tests__/__integration__/utils/daily-leaderboards.spec.ts +++ b/backend/__tests__/__integration__/utils/daily-leaderboards.spec.ts @@ -120,10 +120,14 @@ describe("Daily Leaderboards", () => { true ); //THEN - expect(results).toEqual([ - { rank: 1, ...bestResult }, - { rank: 2, ...user2 }, - ]); + expect(results).toEqual({ + count: 2, + minWpm: 20, + entries: [ + { rank: 1, ...bestResult }, + { rank: 2, ...user2 }, + ], + }); }); it("limits max amount of results", async () => { @@ -136,7 +140,10 @@ describe("Daily Leaderboards", () => { .fill(0) .map(() => givenResult({ wpm: 20 + Math.random() * 100 })) ); - expect(await lb.getCount()).toEqual(maxResults); + expect( + await lb.getResults(0, 5, dailyLeaderboardsConfig, true) + ).toEqual(expect.objectContaining({ count: maxResults })); + expect(await lb.getRank(bob.uid, dailyLeaderboardsConfig)).toEqual({ rank: maxResults, ...bob, @@ -147,7 +154,9 @@ describe("Daily Leaderboards", () => { //THEN //max count is still the same, but bob is no longer on the leaderboard - expect(await lb.getCount()).toEqual(maxResults); + expect( + await lb.getResults(0, 5, dailyLeaderboardsConfig, true) + ).toEqual(expect.objectContaining({ count: maxResults })); expect(await lb.getRank(bob.uid, dailyLeaderboardsConfig)).toBeNull(); }); }); @@ -166,11 +175,15 @@ describe("Daily Leaderboards", () => { true ); //THEN - expect(results).toEqual([ - { rank: 1, ...user2 }, - { rank: 2, ...user1 }, - { rank: 3, ...user3 }, - ]); + expect(results).toEqual({ + count: 3, + minWpm: 40, + entries: [ + { rank: 1, ...user2 }, + { rank: 2, ...user1 }, + { rank: 3, ...user3 }, + ], + }); }); it("gets result for page", async () => { //GIVEN @@ -188,10 +201,14 @@ describe("Daily Leaderboards", () => { true ); //THEN - expect(results).toEqual([ - { rank: 3, ...user4 }, - { rank: 4, ...user3 }, - ]); + expect(results).toEqual({ + count: 5, + minWpm: 20, + entries: [ + { rank: 3, ...user4 }, + { rank: 4, ...user3 }, + ], + }); }); it("gets result without premium", async () => { @@ -208,57 +225,140 @@ describe("Daily Leaderboards", () => { false ); //THEN - expect(results).toEqual([ - { rank: 1, ...user2, isPremium: undefined }, - { rank: 2, ...user1, isPremium: undefined }, - { rank: 3, ...user3, isPremium: undefined }, - ]); + expect(results).toEqual({ + count: 3, + minWpm: 40, + entries: [ + { rank: 1, ...user2, isPremium: undefined }, + { rank: 2, ...user1, isPremium: undefined }, + { rank: 3, ...user3, isPremium: undefined }, + ], + }); }); - }); - describe("minWPm", () => { - it("gets min wpm", async () => { + it("should get for friends only", async () => { //GIVEN - await givenResult({ wpm: 50 }); - await givenResult({ wpm: 60 }); + const _user1 = await givenResult({ wpm: 90 }); + const user2 = await givenResult({ wpm: 80 }); + const _user3 = await givenResult({ wpm: 70 }); + const user4 = await givenResult({ wpm: 60 }); + const _user5 = await givenResult({ wpm: 50 }); //WHEN - const minWpm = await lb.getMinWpm(dailyLeaderboardsConfig); + const results = await lb.getResults( + 0, + 5, + dailyLeaderboardsConfig, + true, + [user2.uid, user4.uid, new ObjectId().toHexString()] + ); //THEN - expect(minWpm).toEqual(50); + expect(results).toEqual({ + count: 2, + minWpm: 60, + entries: [ + { rank: 2, friendsRank: 1, ...user2 }, + { rank: 4, friendsRank: 2, ...user4 }, + ], + }); }); - }); - describe("getRank", () => { - it("gets rank", async () => { + it("should get for friends only with page", async () => { //GIVEN - const user1 = await givenResult({ wpm: 50 }); - const _user2 = await givenResult({ wpm: 60 }); + const user1 = await givenResult({ wpm: 105 }); + const user2 = await givenResult({ wpm: 100 }); + const _user3 = await givenResult({ wpm: 95 }); + const user4 = await givenResult({ wpm: 90 }); + const _user5 = await givenResult({ wpm: 70 }); //WHEN - const rank = await lb.getRank(user1.uid, dailyLeaderboardsConfig); + + const results = await lb.getResults( + 1, + 2, + dailyLeaderboardsConfig, + true, + [user1.uid, user2.uid, user4.uid, new ObjectId().toHexString()] + ); + //THEN - expect(rank).toEqual({ rank: 2, ...user1 }); + expect(results).toEqual({ + count: 3, + minWpm: 90, + entries: [{ rank: 4, friendsRank: 3, ...user4 }], + }); }); - }); - describe("getCount", () => { - it("gets count", async () => { + it("should return empty list if no friends", async () => { //GIVEN - await givenResult({ wpm: 50 }); - await givenResult({ wpm: 60 }); //WHEN - const count = await lb.getCount(); + const results = await lb.getResults( + 0, + 5, + dailyLeaderboardsConfig, + true, + [] + ); //THEN - expect(count).toEqual(2); + expect(results).toEqual({ + count: 0, + minWpm: 0, + entries: [], + }); + }); + }); + + describe("getRank", () => { + it("gets rank", async () => { + //GIVEN + const user1 = await givenResult({ wpm: 50 }); + const user2 = await givenResult({ wpm: 60 }); + + //WHEN / THEN + expect(await lb.getRank(user1.uid, dailyLeaderboardsConfig)).toEqual({ + rank: 2, + ...user1, + }); + expect(await lb.getRank(user2.uid, dailyLeaderboardsConfig)).toEqual({ + rank: 1, + ...user2, + }); + }); + + it("should return null for unknown user", async () => { + expect(await lb.getRank("decoy", dailyLeaderboardsConfig)).toBeNull(); + expect( + await lb.getRank("decoy", dailyLeaderboardsConfig, [ + "unknown", + "unknown2", + ]) + ).toBeNull(); + }); + + it("gets rank for friends", async () => { + //GIVEN + const user1 = await givenResult({ wpm: 50 }); + const user2 = await givenResult({ wpm: 60 }); + const _user3 = await givenResult({ wpm: 70 }); + const friends = [user1.uid, user2.uid, "decoy"]; + + //WHEN / THEN + expect( + await lb.getRank(user2.uid, dailyLeaderboardsConfig, friends) + ).toEqual({ rank: 2, friendsRank: 1, ...user2 }); + + expect( + await lb.getRank(user1.uid, dailyLeaderboardsConfig, friends) + ).toEqual({ rank: 3, friendsRank: 2, ...user1 }); }); }); it("purgeUserFromDailyLeaderboards", async () => { //GIVEN const cheater = await givenResult({ wpm: 50 }); - const validUser = await givenResult(); + const user1 = await givenResult({ wpm: 60 }); + const user2 = await givenResult({ wpm: 40 }); //WHEN await DailyLeaderboards.purgeUserFromDailyLeaderboards( @@ -268,7 +368,14 @@ describe("Daily Leaderboards", () => { //THEN expect(await lb.getRank(cheater.uid, dailyLeaderboardsConfig)).toBeNull(); expect(await lb.getResults(0, 50, dailyLeaderboardsConfig, true)).toEqual( - [{ rank: 1, ...validUser }] + { + count: 2, + minWpm: 40, + entries: [ + { rank: 1, ...user1 }, + { rank: 2, ...user2 }, + ], + } ); }); diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index e80f0de60bcd..d06b906f33cb 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -477,18 +477,22 @@ describe("Loaderboard Controller", () => { DailyLeaderboards, "getDailyLeaderboard" ); + const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids"); + const getResultMock = vi.fn(); beforeEach(async () => { - getDailyLeaderboardMock.mockClear(); + [getDailyLeaderboardMock, getFriendsUidsMock, getResultMock].forEach( + (it) => it.mockClear() + ); vi.useFakeTimers(); vi.setSystemTime(1722606812000); await dailyLeaderboardEnabled(true); getDailyLeaderboardMock.mockReturnValue({ - getResults: () => Promise.resolve([]), - getCount: () => Promise.resolve(0), - getMinWpm: () => Promise.resolve(0), + getResults: getResultMock, } as any); + + getResultMock.mockResolvedValue(null); }); afterEach(() => { @@ -528,20 +532,11 @@ describe("Loaderboard Controller", () => { ], }; - const getResultMock = vi.fn(); - getResultMock.mockResolvedValue(resultData); - - const getCountMock = vi.fn(); - getCountMock.mockResolvedValue(2); - - const getMinWpmMock = vi.fn(); - getMinWpmMock.mockResolvedValue(10); - - getDailyLeaderboardMock.mockReturnValue({ - getResults: getResultMock, - getCount: getCountMock, - getMinWpm: getMinWpmMock, - } as any); + getResultMock.mockResolvedValue({ + count: 2, + minWpm: 10, + entries: resultData, + }); //WHEN const { body } = await mockApp @@ -568,7 +563,13 @@ describe("Loaderboard Controller", () => { -1 ); - expect(getResultMock).toHaveBeenCalledWith(0, 50, lbConf, premiumEnabled); + expect(getResultMock).toHaveBeenCalledWith( + 0, + 50, + lbConf, + premiumEnabled, + undefined + ); }); it("should get for english time 60 for yesterday", async () => { @@ -612,20 +613,7 @@ describe("Loaderboard Controller", () => { const page = 2; const pageSize = 25; - const getResultMock = vi.fn(); - getResultMock.mockResolvedValue([]); - - const getCountMock = vi.fn(); - getCountMock.mockResolvedValue(0); - - const getMinWpmMock = vi.fn(); - getMinWpmMock.mockResolvedValue(0); - - getDailyLeaderboardMock.mockReturnValue({ - getResults: getResultMock, - getCount: getCountMock, - getMinWpm: getMinWpmMock, - } as any); + getResultMock.mockResolvedValue({ entries: [] }); //WHEN const { body } = await mockApp @@ -662,7 +650,50 @@ describe("Loaderboard Controller", () => { page, pageSize, lbConf, - premiumEnabled + premiumEnabled, + undefined + ); + }); + + it("should get for friends", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + const premiumEnabled = (await configuration).users.premium.enabled; + await enableConnectionsFeature(true); + const friends = [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]; + getFriendsUidsMock.mockResolvedValue(friends); + + //WHEN + await mockApp + .get("/leaderboards/daily") + .set("Authorization", `Bearer ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + friendsOnly: true, + }) + .expect(200); + + //THEN + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getResultMock).toHaveBeenCalledWith( + 0, + 50, + lbConf, + premiumEnabled, + friends ); }); @@ -711,6 +742,7 @@ describe("Loaderboard Controller", () => { expect(response.status, "for mode2 " + mode2).toEqual(200); } }); + it("fails for missing query", async () => { const { body } = await mockApp.get("/leaderboards").expect(422); @@ -723,6 +755,7 @@ describe("Loaderboard Controller", () => { ], }); }); + it("fails for invalid query", async () => { const { body } = await mockApp .get("/leaderboards/daily") @@ -783,16 +816,21 @@ describe("Loaderboard Controller", () => { DailyLeaderboards, "getDailyLeaderboard" ); + const getRankMock = vi.fn(); + const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids"); beforeEach(async () => { - getDailyLeaderboardMock.mockClear(); - vi.useFakeTimers(); - vi.setSystemTime(1722606812000); - await dailyLeaderboardEnabled(true); + [getDailyLeaderboardMock, getRankMock, getFriendsUidsMock].forEach((it) => + it.mockClear() + ); getDailyLeaderboardMock.mockReturnValue({ - getRank: () => Promise.resolve({} as any), + getRank: getRankMock, } as any); + + vi.useFakeTimers(); + vi.setSystemTime(1722606812000); + await dailyLeaderboardEnabled(true); }); afterEach(() => { @@ -825,11 +863,7 @@ describe("Loaderboard Controller", () => { }, }; - const getRankMock = vi.fn(); getRankMock.mockResolvedValue(rankData); - getDailyLeaderboardMock.mockReturnValue({ - getRank: getRankMock, - } as any); //WHEN const { body } = await mockApp @@ -852,8 +886,43 @@ describe("Loaderboard Controller", () => { -1 ); - expect(getRankMock).toHaveBeenCalledWith(uid, lbConf); + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf, undefined); }); + + it("should get for english time 60 friends only", async () => { + //GIVEN + await enableConnectionsFeature(true); + const lbConf = (await configuration).dailyLeaderboards; + getRankMock.mockResolvedValue({}); + const friends = ["friendOne", "friendTwo"]; + getFriendsUidsMock.mockResolvedValue(friends); + + //WHEN + await mockApp + .get("/leaderboards/daily/rank") + .set("Authorization", `Bearer ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + friendsOnly: true, + }) + .expect(200); + + //THEN + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf, friends); + expect(getFriendsUidsMock).toHaveBeenCalledWith(uid); + }); + it("fails if daily leaderboards are disabled", async () => { await dailyLeaderboardEnabled(false); @@ -866,6 +935,7 @@ describe("Loaderboard Controller", () => { "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 @@ -875,6 +945,7 @@ describe("Loaderboard Controller", () => { expect(response.status, "for mode " + mode).toEqual(200); } }); + it("should get for mode2", async () => { for (const mode2 of allModes) { const response = await mockApp @@ -885,6 +956,7 @@ describe("Loaderboard Controller", () => { expect(response.status, "for mode2 " + mode2).toEqual(200); } }); + it("fails for missing query", async () => { const { body } = await mockApp .get("/leaderboards/daily/rank") @@ -900,6 +972,7 @@ describe("Loaderboard Controller", () => { ], }); }); + it("fails for invalid query", async () => { const { body } = await mockApp .get("/leaderboards/daily/rank") @@ -920,6 +993,7 @@ describe("Loaderboard Controller", () => { ], }); }); + it("fails for unknown query", async () => { const { body } = await mockApp .get("/leaderboards/daily/rank") @@ -937,6 +1011,7 @@ describe("Loaderboard Controller", () => { validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); + it("fails while leaderboard is missing", async () => { //GIVEN getDailyLeaderboardMock.mockReturnValue(null); @@ -960,17 +1035,22 @@ describe("Loaderboard Controller", () => { describe("get xp weekly leaderboard", () => { const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get"); + const getResultMock = vi.fn(); + const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids"); beforeEach(async () => { - getXpWeeklyLeaderboardMock.mockClear(); + [getXpWeeklyLeaderboardMock, getResultMock, getFriendsUidsMock].forEach( + (it) => it.mockClear() + ); vi.useFakeTimers(); vi.setSystemTime(1722606812000); await weeklyLeaderboardEnabled(true); getXpWeeklyLeaderboardMock.mockReturnValue({ - getResults: () => Promise.resolve([]), - getCount: () => Promise.resolve(0), + getResults: getResultMock, } as any); + + getResultMock.mockResolvedValue(null); }); afterEach(() => { @@ -1004,16 +1084,7 @@ describe("Loaderboard Controller", () => { }, ]; - const getResultMock = vi.fn(); - getResultMock.mockResolvedValue(resultData); - - const getCountMock = vi.fn(); - getCountMock.mockResolvedValue(2); - - getXpWeeklyLeaderboardMock.mockReturnValue({ - getResults: getResultMock, - getCount: getCountMock, - } as any); + getResultMock.mockResolvedValue({ count: 2, entries: resultData }); //WHEN const { body } = await mockApp @@ -1033,7 +1104,13 @@ describe("Loaderboard Controller", () => { expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); - expect(getResultMock).toHaveBeenCalledWith(0, 50, lbConf, false); + expect(getResultMock).toHaveBeenCalledWith( + 0, + 50, + lbConf, + false, + undefined + ); }); it("should get for last week", async () => { @@ -1070,17 +1147,6 @@ describe("Loaderboard Controller", () => { const page = 2; const pageSize = 25; - const getResultMock = vi.fn(); - getResultMock.mockResolvedValue([]); - - const getCountMock = vi.fn(); - getCountMock.mockResolvedValue(0); - - getXpWeeklyLeaderboardMock.mockReturnValue({ - getResults: getResultMock, - getCount: getCountMock, - } as any); - //WHEN const { body } = await mockApp .get("/leaderboards/xp/weekly") @@ -1102,7 +1168,49 @@ describe("Loaderboard Controller", () => { expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); - expect(getResultMock).toHaveBeenCalledWith(page, pageSize, lbConf, false); + expect(getResultMock).toHaveBeenCalledWith( + page, + pageSize, + lbConf, + false, + undefined + ); + }); + + it("should get for friends", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + await enableConnectionsFeature(true); + const page = 2; + const pageSize = 25; + const friends = [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]; + getFriendsUidsMock.mockResolvedValue(friends); + + //WHEN + await mockApp + .get("/leaderboards/xp/weekly") + .set("Authorization", `Bearer ${uid}`) + .query({ + page, + pageSize, + friendsOnly: true, + }) + .expect(200); + + //THEN + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); + + expect(getResultMock).toHaveBeenCalledWith( + page, + pageSize, + lbConf, + false, + friends + ); }); it("fails if daily leaderboards are disabled", async () => { @@ -1128,6 +1236,7 @@ describe("Loaderboard Controller", () => { validationErrors: ['"weeksBefore" Invalid literal value, expected 1'], }); }); + it("fails for unknown query", async () => { const { body } = await mockApp .get("/leaderboards/xp/weekly") @@ -1141,6 +1250,7 @@ describe("Loaderboard Controller", () => { validationErrors: ["Unrecognized key(s) in object: 'extra'"], }); }); + it("fails while leaderboard is missing", async () => { //GIVEN getXpWeeklyLeaderboardMock.mockReturnValue(null); @@ -1154,12 +1264,21 @@ describe("Loaderboard Controller", () => { describe("get xp weekly leaderboard rank", () => { const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get"); + const getRankMock = vi.fn(); + const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids"); beforeEach(async () => { - getXpWeeklyLeaderboardMock.mockClear(); + [getXpWeeklyLeaderboardMock, getRankMock, getFriendsUidsMock].forEach( + (it) => it.mockClear() + ); + await weeklyLeaderboardEnabled(true); vi.useFakeTimers(); vi.setSystemTime(1722606812000); + + getXpWeeklyLeaderboardMock.mockReturnValue({ + getRank: getRankMock, + } as any); }); it("fails withouth authentication", async () => { @@ -1180,11 +1299,8 @@ describe("Loaderboard Controller", () => { discordAvatar: "discordAvatar", lastActivityTimestamp: 1000, }; - const getRankMock = vi.fn(); + getRankMock.mockResolvedValue(resultData); - getXpWeeklyLeaderboardMock.mockReturnValue({ - getRank: getRankMock, - } as any); //WHEN const { body } = await mockApp @@ -1200,28 +1316,13 @@ describe("Loaderboard Controller", () => { expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); - expect(getRankMock).toHaveBeenCalledWith(uid, lbConf); + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf, undefined); }); it("should get for last week", 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, - }; - const getRankMock = vi.fn(); - getRankMock.mockResolvedValue(resultData); - getXpWeeklyLeaderboardMock.mockReturnValue({ - getRank: getRankMock, - } as any); + getRankMock.mockResolvedValue({}); //WHEN const { body } = await mockApp @@ -1233,7 +1334,7 @@ describe("Loaderboard Controller", () => { //THEN expect(body).toEqual({ message: "Weekly xp leaderboard rank retrieved", - data: resultData, + data: {}, }); expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith( @@ -1241,8 +1342,35 @@ describe("Loaderboard Controller", () => { 1721606400000 ); - expect(getRankMock).toHaveBeenCalledWith(uid, lbConf); + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf, undefined); + }); + + it("should get for friendsOnly", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + await enableConnectionsFeature(true); + getRankMock.mockResolvedValue({}); + const friends = ["friendOne", "friendTwo"]; + getFriendsUidsMock.mockResolvedValue(friends); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly/rank") + .query({ friendsOnly: true }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Weekly xp leaderboard rank retrieved", + data: {}, + }); + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); + + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf, friends); }); + it("fails if daily leaderboards are disabled", async () => { await weeklyLeaderboardEnabled(false); diff --git a/backend/__tests__/services/weeky-xp-leaderboard.spec.ts b/backend/__tests__/services/weeky-xp-leaderboard.spec.ts deleted file mode 100644 index 5fc554bcecd9..000000000000 --- a/backend/__tests__/services/weeky-xp-leaderboard.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect } from "vitest"; -import * as WeeklyXpLeaderboard from "../../src/services/weekly-xp-leaderboard"; - -const weeklyXpLeaderboardConfig = { - enabled: true, - expirationTimeInDays: 15, - xpRewardBrackets: [], -}; - -describe("Weekly Xp Leaderboard", () => { - it("should properly consider config", () => { - const weeklyXpLeaderboard = WeeklyXpLeaderboard.get( - weeklyXpLeaderboardConfig - ); - expect(weeklyXpLeaderboard).toBeInstanceOf( - WeeklyXpLeaderboard.WeeklyXpLeaderboard - ); - - weeklyXpLeaderboardConfig.enabled = false; - - const weeklyXpLeaderboardNull = WeeklyXpLeaderboard.get( - weeklyXpLeaderboardConfig - ); - expect(weeklyXpLeaderboardNull).toBeNull(); - }); - - // TODO: Setup Redis mock and test the rest of this -}); diff --git a/backend/package.json b/backend/package.json index 6bd26e3c7b9c..30e90af5e4bd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -84,7 +84,7 @@ "@types/swagger-stats": "0.95.11", "@types/ua-parser-js": "0.7.36", "@types/uuid": "10.0.0", - "@vitest/coverage-v8": "4.0.4", + "@vitest/coverage-v8": "4.0.8", "concurrently": "8.2.2", "eslint": "8.57.1", "eslint-watch": "8.0.0", @@ -95,6 +95,6 @@ "testcontainers": "11.4.0", "tsx": "4.16.2", "typescript": "5.5.4", - "vitest": "4.0.4" + "vitest": "4.0.8" } } diff --git a/backend/redis-scripts/get-rank.lua b/backend/redis-scripts/get-rank.lua new file mode 100644 index 000000000000..fe15f6694879 --- /dev/null +++ b/backend/redis-scripts/get-rank.lua @@ -0,0 +1,51 @@ +-- Helper to split CSV string into a list +local function split_csv(csv) + local result = {} + for user_id in string.gmatch(csv, '([^,]+)') do + table.insert(result, user_id) + end + return result +end + +local redis_call = redis.call +local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2] + +local user_id = ARGV[1] +local include_scores = ARGV[2] +local user_ids_csv = ARGV[3] + +local rank = nil +local friendsRank = nil +local result = {} +local score = '' + + +-- filtered leaderboard +if user_ids_csv ~= "" then + + local filtered_user_ids = split_csv(user_ids_csv) + local scored_users = {} + for _, user_id in ipairs(filtered_user_ids) do + local score = redis_call('ZSCORE', leaderboard_scores_key, user_id) + if score then + local number_score = tonumber(score) + table.insert(scored_users, {user_id = user_id, score = number_score}) + end + end + table.sort(scored_users, function(a, b) return a.score > b.score end) + + for i = 1, #scored_users do + if scored_users[i].user_id == user_id then + friendsRank = i - 1 + end + end + +end + +rank = redis_call('ZREVRANK', leaderboard_scores_key, user_id) +if (include_scores == "true") then + score = redis_call('ZSCORE', leaderboard_scores_key, user_id) +end +result = redis_call('HGET', leaderboard_results_key, user_id) + +return {rank, score, result, friendsRank} \ No newline at end of file diff --git a/backend/redis-scripts/get-results.lua b/backend/redis-scripts/get-results.lua index 3897517c3002..14cded2309b5 100644 --- a/backend/redis-scripts/get-results.lua +++ b/backend/redis-scripts/get-results.lua @@ -1,24 +1,84 @@ +-- Helper to split CSV string into a list +local function split_csv(csv) + local result = {} + for user_id in string.gmatch(csv, '([^,]+)') do + table.insert(result, user_id) + end + return result +end + local redis_call = redis.call local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2] local min_rank = tonumber(ARGV[1]) local max_rank = tonumber(ARGV[2]) local include_scores = ARGV[3] +local user_ids_csv = ARGV[4] local results = {} local scores = {} -local scores_in_range = redis_call('ZRANGE', leaderboard_scores_key, min_rank, max_rank, 'REV') +local ranks = {} +local count = nil +local min_score = {user_id = nil, score = nil} + -for _, user_id in ipairs(scores_in_range) do - local result_data = redis_call('HGET', leaderboard_results_key, user_id) +-- filtered leaderboard +if user_ids_csv ~= "" then - if (include_scores == "true") then - scores[#scores + 1] = redis_call('ZSCORE', leaderboard_scores_key, user_id) + local filtered_user_ids = split_csv(user_ids_csv) + local scored_users = {} + for _, user_id in ipairs(filtered_user_ids) do + local score = redis_call('ZSCORE', leaderboard_scores_key, user_id) + if score then + local number_score = tonumber(score) + table.insert(scored_users, {user_id = user_id, score = number_score}) + end end + table.sort(scored_users, function(a, b) return a.score > b.score end) + + + if #scored_users > 0 then + min_score = {scored_users[#scored_users].user_id, scored_users[#scored_users].score} + end + count = #scored_users + + for i = min_rank + 1, math.min(max_rank + 1, #scored_users) do + local entry = scored_users[i] + local user_id = entry.user_id + local score = tostring(entry.score) + + local result_data = redis_call('HGET', leaderboard_results_key, user_id) + + if result_data ~= nil then + results[#results + 1] = result_data + + local global_rank = redis_call('ZREVRANK', leaderboard_scores_key, user_id) + ranks[#ranks + 1] = global_rank or -1 -- -1 if not found + end + + if include_scores == "true" then + scores[#scores + 1] = score + end + + end + +else +-- global leaderboard + local scores_in_range = redis_call('ZRANGE', leaderboard_scores_key, min_rank, max_rank, 'REV') + min_score = redis_call('ZRANGE', leaderboard_scores_key, 0, 0, 'WITHSCORES') + count = redis_call('ZCARD', leaderboard_scores_key) + + for _, user_id in ipairs(scores_in_range) do + local result_data = redis_call('HGET', leaderboard_results_key, user_id) + + if (include_scores == "true") then + scores[#scores + 1] = redis_call('ZSCORE', leaderboard_scores_key, user_id) + end - if (result_data ~= nil) then - results[#results + 1] = result_data + if (result_data ~= nil) then + results[#results + 1] = result_data + end end end -return {results, scores} \ No newline at end of file +return {results, scores, count, min_score, ranks} \ No newline at end of file diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 201da3def097..ae7c47a74d16 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -137,7 +137,15 @@ function getDailyLeaderboardWithError( export async function getDailyLeaderboard( req: MonkeyRequest ): Promise { - const { page, pageSize } = req.query; + const { page, pageSize, friendsOnly } = req.query; + const { uid } = req.ctx.decodedToken; + const connectionsConfig = req.ctx.configuration.connections; + + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + connectionsConfig + ); const dailyLeaderboard = getDailyLeaderboardWithError( req.query, @@ -148,19 +156,14 @@ export async function getDailyLeaderboard( page, pageSize, req.ctx.configuration.dailyLeaderboards, - req.ctx.configuration.users.premium.enabled - ); - - const minWpm = await dailyLeaderboard.getMinWpm( - req.ctx.configuration.dailyLeaderboards + req.ctx.configuration.users.premium.enabled, + friendUids ); - const count = await dailyLeaderboard.getCount(); - return new MonkeyResponse("Daily leaderboard retrieved", { - entries: results, - minWpm, - count, + entries: results?.entries ?? [], + count: results?.count ?? 0, + minWpm: results?.minWpm ?? 0, pageSize, }); } @@ -168,7 +171,15 @@ export async function getDailyLeaderboard( export async function getDailyLeaderboardRank( req: MonkeyRequest ): Promise { + const { friendsOnly } = req.query; const { uid } = req.ctx.decodedToken; + const connectionsConfig = req.ctx.configuration.connections; + + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + connectionsConfig + ); const dailyLeaderboard = getDailyLeaderboardWithError( req.query, @@ -177,7 +188,8 @@ export async function getDailyLeaderboardRank( const rank = await dailyLeaderboard.getRank( uid, - req.ctx.configuration.dailyLeaderboards + req.ctx.configuration.dailyLeaderboards, + friendUids ); return new MonkeyResponse("Daily leaderboard rank retrieved", rank); @@ -200,10 +212,19 @@ function getWeeklyXpLeaderboardWithError( return weeklyXpLeaderboard; } -export async function getWeeklyXpLeaderboardResults( +export async function getWeeklyXpLeaderboard( req: MonkeyRequest ): Promise { - const { page, pageSize, weeksBefore } = req.query; + const { page, pageSize, weeksBefore, friendsOnly } = req.query; + + const { uid } = req.ctx.decodedToken; + const connectionsConfig = req.ctx.configuration.connections; + + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + connectionsConfig + ); const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError( req.ctx.configuration.leaderboards.weeklyXp, @@ -213,14 +234,13 @@ export async function getWeeklyXpLeaderboardResults( page, pageSize, req.ctx.configuration.leaderboards.weeklyXp, - req.ctx.configuration.users.premium.enabled + req.ctx.configuration.users.premium.enabled, + friendUids ); - const count = await weeklyXpLeaderboard.getCount(); - return new MonkeyResponse("Weekly xp leaderboard retrieved", { - entries: results, - count, + entries: results?.entries ?? [], + count: results?.count ?? 0, pageSize, }); } @@ -228,7 +248,15 @@ export async function getWeeklyXpLeaderboardResults( export async function getWeeklyXpLeaderboardRank( req: MonkeyRequest ): Promise { + const { friendsOnly } = req.query; const { uid } = req.ctx.decodedToken; + const connectionsConfig = req.ctx.configuration.connections; + + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + connectionsConfig + ); const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError( req.ctx.configuration.leaderboards.weeklyXp, @@ -236,7 +264,8 @@ export async function getWeeklyXpLeaderboardRank( ); const rankEntry = await weeklyXpLeaderboard.getRank( uid, - req.ctx.configuration.leaderboards.weeklyXp + req.ctx.configuration.leaderboards.weeklyXp, + friendUids ); return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry); diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index 1533fd562d67..a9da7a143df4 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -23,7 +23,7 @@ export default s.router(leaderboardsContract, { }, getWeeklyXp: { handler: async (r) => - callController(LeaderboardController.getWeeklyXpLeaderboardResults)(r), + callController(LeaderboardController.getWeeklyXpLeaderboard)(r), }, getWeeklyXpRank: { handler: async (r) => diff --git a/backend/src/init/redis.ts b/backend/src/init/redis.ts index 8b816d3aa904..dc09f29aec93 100644 --- a/backend/src/init/redis.ts +++ b/backend/src/init/redis.ts @@ -33,8 +33,19 @@ export type RedisConnectionWithCustomMethods = Redis & { resultsKey: string, minRank: number, maxRank: number, - withScores: string - ) => Promise<[string[], string[]]>; + withScores: string, + userIds: string + ) => Promise< + [string[], string[], string, [string, string | number], string[]] + >; //entries, scores(optional), count, min_score(optiona)[uid, score], ranks(optional) + getRank: ( + keyCount: number, + scoresKey: string, + resultsKey: string, + uid: string, + withScores: string, + userIds: string + ) => Promise<[number, string, string, number]>; //rank, score(optional), entry json, friendsRank(optional) purgeResults: ( keyCount: number, uid: string, diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index 3d2bcfaba517..f93dd568a871 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -11,7 +11,6 @@ import { getCurrentWeekTimestamp } from "@monkeytype/util/date-and-time"; import MonkeyError from "../utils/error"; import { omit } from "lodash"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; -import { tryCatchSync } from "@monkeytype/util/trycatch"; export type AddResultOpts = { entry: RedisXpLeaderboardEntry; @@ -121,31 +120,41 @@ export class WeeklyXpLeaderboard { page: number, pageSize: number, weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"], - premiumFeaturesEnabled: boolean - ): Promise { + premiumFeaturesEnabled: boolean, + userIds?: string[] + ): Promise<{ + entries: XpLeaderboardEntry[]; + count: number; + } | null> { const connection = RedisClient.getConnection(); if (!connection || !weeklyXpLeaderboardConfig.enabled) { - return []; + return null; } if (page < 0 || pageSize < 0) { throw new MonkeyError(500, "Invalid page or pageSize"); } + if (userIds?.length === 0) { + return { entries: [], count: 0 }; + } + + const isFriends = userIds !== undefined; const minRank = page * pageSize; const maxRank = minRank + pageSize - 1; const { weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey } = this.getThisWeeksXpLeaderboardKeys(); - const [results, scores] = (await connection.getResults( + const [results, scores, count, _, ranks] = await connection.getResults( 2, weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey, minRank, maxRank, - "true" - )) as string[][]; + "true", + userIds?.join(",") ?? "" + ); if (results === undefined) { throw new Error( @@ -159,7 +168,7 @@ export class WeeklyXpLeaderboard { ); } - const resultsWithRanks: XpLeaderboardEntry[] = results.map( + let resultsWithRanks: XpLeaderboardEntry[] = results.map( (resultJSON: string, index: number) => { try { const parsed = parseJsonWithSchema( @@ -176,7 +185,10 @@ export class WeeklyXpLeaderboard { return { ...parsed, - rank: minRank + index + 1, + rank: isFriends + ? new Number(ranks[index]).valueOf() + 1 + : minRank + index + 1, + friendsRank: isFriends ? minRank + index + 1 : undefined, totalXp: parseInt(scoreValue, 10), }; } catch (error) { @@ -190,70 +202,55 @@ export class WeeklyXpLeaderboard { ); if (!premiumFeaturesEnabled) { - return resultsWithRanks.map((it) => omit(it, "isPremium")); + resultsWithRanks = resultsWithRanks.map((it) => omit(it, "isPremium")); } - return resultsWithRanks; + return { entries: resultsWithRanks, count: parseInt(count) }; } public async getRank( uid: string, - weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"] + weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"], + userIds?: string[] ): Promise { const connection = RedisClient.getConnection(); if (!connection || !weeklyXpLeaderboardConfig.enabled) { throw new Error("Redis connection is unavailable"); } + if (userIds?.length === 0) { + return null; + } const { weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey } = this.getThisWeeksXpLeaderboardKeys(); - const [[, rank], [, totalXp], [, _count], [, result]] = (await connection - .multi() - .zrevrank(weeklyXpLeaderboardScoresKey, uid) - .zscore(weeklyXpLeaderboardScoresKey, uid) - .zcard(weeklyXpLeaderboardScoresKey) - .hget(weeklyXpLeaderboardResultsKey, uid) - .exec()) as [ - [null, number | null], - [null, string | null], - [null, number | null], - [null, string | null] - ]; + const [rank, score, result, friendsRank] = await connection.getRank( + 2, + weeklyXpLeaderboardScoresKey, + weeklyXpLeaderboardResultsKey, + uid, + "true", + userIds?.join(",") ?? "" + ); if (rank === null || result === null) { return null; } - const { data: parsed, error } = tryCatchSync(() => - parseJsonWithSchema(result, RedisXpLeaderboardEntrySchema) - ); - - if (error) { + try { + return { + ...parseJsonWithSchema(result ?? "null", RedisXpLeaderboardEntrySchema), + rank: rank + 1, + friendsRank: friendsRank !== undefined ? friendsRank + 1 : undefined, + totalXp: parseInt(score, 10), + }; + } catch (error) { throw new Error( `Failed to parse leaderboard entry: ${ error instanceof Error ? error.message : String(error) }` ); } - - return { - ...parsed, - rank: rank + 1, - totalXp: parseInt(totalXp as string, 10), - }; - } - - public async getCount(): Promise { - const connection = RedisClient.getConnection(); - if (!connection) { - throw new Error("Redis connection is unavailable"); - } - - const { weeklyXpLeaderboardScoresKey } = - this.getThisWeeksXpLeaderboardKeys(); - - return connection.zcard(weeklyXpLeaderboardScoresKey); } } diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index c0b5aa57ef2e..9b3289a6c754 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -112,31 +112,48 @@ export class DailyLeaderboard { page: number, pageSize: number, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], - premiumFeaturesEnabled: boolean - ): Promise { + premiumFeaturesEnabled: boolean, + userIds?: string[] + ): Promise<{ + entries: LeaderboardEntry[]; + count: number; + minWpm: number; + } | null> { const connection = RedisClient.getConnection(); if (!connection || !dailyLeaderboardsConfig.enabled) { - return []; + return null; } if (page < 0 || pageSize < 0) { throw new MonkeyError(500, "Invalid page or pageSize"); } + if (userIds?.length === 0) { + return { entries: [], count: 0, minWpm: 0 }; + } + + const isFriends = userIds !== undefined; const minRank = page * pageSize; const maxRank = minRank + pageSize - 1; const { leaderboardScoresKey, leaderboardResultsKey } = this.getTodaysLeaderboardKeys(); - const [results, _] = await connection.getResults( - 2, - leaderboardScoresKey, - leaderboardResultsKey, - minRank, - maxRank, - "false" - ); + const [results, _, count, [_uid, minScore], ranks] = + await connection.getResults( + 2, + leaderboardScoresKey, + leaderboardResultsKey, + minRank, + maxRank, + "false", + userIds?.join(",") ?? "" + ); + + const minWpm = + minScore !== undefined + ? parseInt(minScore.toString().slice(1, 6)) / 100 + : 0; if (results === undefined) { throw new Error( @@ -144,7 +161,7 @@ export class DailyLeaderboard { ); } - const resultsWithRanks: LeaderboardEntry[] = results.map( + let resultsWithRanks: LeaderboardEntry[] = results.map( (resultJSON, index) => { try { const parsed = parseJsonWithSchema( @@ -154,7 +171,10 @@ export class DailyLeaderboard { return { ...parsed, - rank: minRank + index + 1, + rank: isFriends + ? new Number(ranks[index]).valueOf() + 1 + : minRank + index + 1, + friendsRank: isFriends ? minRank + index + 1 : undefined, }; } catch (error) { throw new Error( @@ -167,61 +187,38 @@ export class DailyLeaderboard { ); if (!premiumFeaturesEnabled) { - return resultsWithRanks.map((it) => omit(it, "isPremium")); + resultsWithRanks = resultsWithRanks.map((it) => omit(it, "isPremium")); } - return resultsWithRanks; - } - - public async getMinWpm( - dailyLeaderboardsConfig: Configuration["dailyLeaderboards"] - ): Promise { - const connection = RedisClient.getConnection(); - if (!connection || !dailyLeaderboardsConfig.enabled) { - return 0; - } - - const { leaderboardScoresKey } = this.getTodaysLeaderboardKeys(); - - const [_uid, minScore] = (await connection.zrange( - leaderboardScoresKey, - 0, - 0, - "WITHSCORES" - )) as [string, string]; - - const minWpm = - minScore !== undefined ? parseInt(minScore?.slice(1, 6)) / 100 : 0; - - return minWpm; + return { entries: resultsWithRanks, count: parseInt(count), minWpm }; } public async getRank( uid: string, - dailyLeaderboardsConfig: Configuration["dailyLeaderboards"] + dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], + userIds?: string[] ): Promise { const connection = RedisClient.getConnection(); if (!connection || !dailyLeaderboardsConfig.enabled) { throw new Error("Redis connection is unavailable"); } + if (userIds?.length === 0) { + return null; + } const { leaderboardScoresKey, leaderboardResultsKey } = this.getTodaysLeaderboardKeys(); - const redisExecResult = (await connection - .multi() - .zrevrank(leaderboardScoresKey, uid) - .zcard(leaderboardScoresKey) - .hget(leaderboardResultsKey, uid) - .exec()) as [ - [null, number | null], - [null, number | null], - [null, string | null] - ]; - - const [[, rank], [, _count], [, result]] = redisExecResult; + const [rank, _score, result, friendsRank] = await connection.getRank( + 2, + leaderboardScoresKey, + leaderboardResultsKey, + uid, + "false", + userIds?.join(",") ?? "" + ); - if (rank === null) { + if (rank === null || rank === undefined) { return null; } @@ -232,6 +229,7 @@ export class DailyLeaderboard { RedisDailyLeaderboardEntrySchema ), rank: rank + 1, + friendsRank: friendsRank !== undefined ? friendsRank + 1 : undefined, }; } catch (error) { throw new Error( @@ -241,17 +239,6 @@ export class DailyLeaderboard { ); } } - - public async getCount(): Promise { - const connection = RedisClient.getConnection(); - if (!connection) { - throw new Error("Redis connection is unavailable"); - } - - const { leaderboardScoresKey } = this.getTodaysLeaderboardKeys(); - - return connection.zcard(leaderboardScoresKey); - } } export async function purgeUserFromDailyLeaderboards( diff --git a/backend/src/workers/later-worker.ts b/backend/src/workers/later-worker.ts index 2ca2ea8f31dc..57c8e34f4447 100644 --- a/backend/src/workers/later-worker.ts +++ b/backend/src/workers/later-worker.ts @@ -45,7 +45,7 @@ async function handleDailyLeaderboardResults( false ); - if (results.length === 0) { + if (results === null || results.entries.length === 0) { return; } @@ -55,7 +55,7 @@ async function handleDailyLeaderboardResults( mail: MonkeyMail[]; }[] = []; - results.forEach((entry) => { + results.entries.forEach((entry) => { const rank = entry.rank ?? maxResults; const wpm = Math.round(entry.wpm); @@ -96,7 +96,7 @@ async function handleDailyLeaderboardResults( await addToInboxBulk(mailEntries, inboxConfig); } - const topResults = results.slice( + const topResults = results.entries.slice( 0, dailyLeaderboardsConfig.topResultsToAnnounce ); @@ -136,7 +136,7 @@ async function handleWeeklyXpLeaderboardResults( false ); - if (allResults.length === 0) { + if (allResults === null || allResults.entries.length === 0) { return; } @@ -145,7 +145,7 @@ async function handleWeeklyXpLeaderboardResults( mail: MonkeyMail[]; }[] = []; - allResults.forEach((entry) => { + allResults?.entries.forEach((entry) => { const { uid, name, rank = maxRankToGet, totalXp, timeTypedSeconds } = entry; const xp = Math.round(totalXp); diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 8b3d7bcdca31..ed73f2e84b0b 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -38,11 +38,7 @@ export const projects: UserWorkspaceConfig[] = [ groupOrder: 2, }, pool: "threads", - poolOptions: { - threads: { - singleThread: true, - }, - }, + maxWorkers: 1, }, }, ]; diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index a65217e23879..968f9be8431e 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -27,7 +27,8 @@ Below is a set of general guidelines for different types of changes. ### Pull Request Naming Guidelines -We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for our pull request titles (and commit messages on the master branch). Please follow the guidelines below when naming pull requests. +We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for our pull request titles (and commit messages on the master branch) and also include the author name at the end inside parenthesis. Please follow the guidelines below when naming pull requests. + For types, we use the following: @@ -44,6 +45,12 @@ For types, we use the following: - `revert`: Reverts a previous commit - `chore`: Other changes that don't apply to any of the above +#### Examples + +- `feat: add new feature (@github_username)` +- `impr(quotes): add english quotes (@username)` +- `fix(leaderboard): show user rank correctly (@user1, @user2, @user3)` + ### Theme Guidelines diff --git a/frontend/package.json b/frontend/package.json index 9461dadb6888..332b82401604 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,7 +55,7 @@ "@types/object-hash": "3.0.6", "@types/subset-font": "1.4.3", "@types/throttle-debounce": "5.0.2", - "@vitest/coverage-v8": "4.0.4", + "@vitest/coverage-v8": "4.0.8", "autoprefixer": "10.4.20", "concurrently": "8.2.2", "dotenv": "16.4.5", @@ -63,7 +63,7 @@ "eslint-plugin-compat": "6.0.2", "firebase-tools": "13.15.1", "fontawesome-subset": "4.4.0", - "happy-dom": "20.0.2", + "happy-dom": "20.0.10", "madge": "8.0.0", "magic-string": "0.30.17", "normalize.css": "8.0.1", @@ -82,7 +82,7 @@ "vite-plugin-inspect": "11.3.3", "vite-plugin-minify": "2.1.0", "vite-plugin-pwa": "1.1.0", - "vitest": "4.0.4" + "vitest": "4.0.8" }, "dependencies": { "@date-fns/utc": "1.2.0", diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html index 8b232477b7fe..945469999a86 100644 --- a/frontend/src/html/pages/settings.html +++ b/frontend/src/html/pages/settings.html @@ -1469,7 +1469,9 @@ random themes are not saved to your config. If set to 'favorite' only favorite themes will be randomized. If set to 'light' or 'dark', only presets with light or dark background colors will be randomized, - respectively. If set to 'custom', custom themes will be randomized. + respectively. If set to 'auto' dark or light themes are used, depending + on your system theme. If set to 'custom', custom themes will be + randomized.
@@ -1477,6 +1479,7 @@ +
diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 631f5f44194f..2b51f5751973 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -1276,14 +1276,6 @@ - -