Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,62 @@ describe("LeaderboardsDal", () => {
}),
);
});
it("should get the next WPM", async () => {
//GIVEN
await createUser(lbBests(undefined, pb(105)), { name: "One" });
await createUser(lbBests(undefined, pb(100)), { name: "Two" });
const me = await createUser(lbBests(undefined, pb(95)), { name: "Me" });
await createUser(lbBests(undefined, pb(90)), { name: "Three" });
await LeaderboardsDal.update("time", "60", "english");

//WHEN / THEN
expect(
await LeaderboardsDal.getNextWpm("time", "60", "english", me.uid),
).toBe(100);
expect(
await LeaderboardsDal.getNextWpm(
"time",
"60",
"english",
"missing-uid",
),
).toBeNull();
});

it("uses the highest-ranked entry when duplicate entries exist", async () => {
//GIVEN
await createUser(lbBests(undefined, pb(105)), { name: "One" });
await createUser(lbBests(undefined, pb(100)), { name: "Two" });
const me = await createUser(lbBests(undefined, pb(95)), { name: "Me" });
await createUser(lbBests(undefined, pb(90)), { name: "Three" });
await LeaderboardsDal.update("time", "60", "english");

const currentEntry = await LeaderboardsDal.getRank(
"time",
"60",
"english",
me.uid,
);
if (currentEntry === null || currentEntry === false) {
throw new Error("Expected current leaderboard entry");
}

await LeaderboardsDal.getCollection({
language: "english",
mode: "time",
mode2: "60",
}).insertOne({
...currentEntry,
_id: new ObjectId(),
rank: currentEntry.rank + 1,
wpm: currentEntry.wpm - 5,
});

//WHEN / THEN
expect(
await LeaderboardsDal.getNextWpm("time", "60", "english", me.uid),
).toBe(100);
});
it("should get for friends only", async () => {
//GIVEN
const friendOne = await createUser(lbBests(undefined, pb(105)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,35 @@ describe("Daily Leaderboards", () => {
});
});

describe("getNextWpm", () => {
it("returns the lowest higher WPM across paginated results", async () => {
const paginatedConfig = { ...dailyLeaderboardsConfig, maxResults: 105 };
// oxlint-disable-next-line no-non-null-assertion
const paginatedLb = DailyLeaderboards.getDailyLeaderboard(
"english",
"time",
"60",
paginatedConfig,
)!;
const current = await givenResult(
{ wpm: 80 },
paginatedLb,
paginatedConfig,
);

await Promise.all(
Array.from({ length: 100 }, async (_, index) =>
givenResult({ wpm: 100 + index }, paginatedLb, paginatedConfig),
),
);
await givenResult({ wpm: 90 }, paginatedLb, paginatedConfig);

expect(await paginatedLb.getNextWpm(current.uid, paginatedConfig)).toBe(
90,
);
});
});

it("purgeUserFromDailyLeaderboards", async () => {
//GIVEN
const cheater = await givenResult({ wpm: 50 });
Expand Down Expand Up @@ -381,6 +410,8 @@ describe("Daily Leaderboards", () => {

async function givenResult(
entry?: Partial<RedisDailyLeaderboardEntry>,
leaderboard = lb,
config = dailyLeaderboardsConfig,
): Promise<RedisDailyLeaderboardEntry> {
const uid = new ObjectId().toHexString();
const result = {
Expand All @@ -397,7 +428,7 @@ describe("Daily Leaderboards", () => {
isPremium: false,
...entry,
};
await lb.addResult(result, dailyLeaderboardsConfig);
await leaderboard.addResult(result, config);
return result;
}
});
Expand Down
108 changes: 108 additions & 0 deletions backend/__tests__/api/controllers/leaderboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,54 @@ describe("Loaderboard Controller", () => {
});
});

describe("get next leaderboard WPM", () => {
const getNextWpmMock = vi.spyOn(LeaderboardDal, "getNextWpm");

beforeEach(() => {
getNextWpmMock.mockReset();
getNextWpmMock.mockResolvedValue(null);
});

it("requires authentication", async () => {
await mockApp
.get("/leaderboards/next")
.query({ language: "english", mode: "time", mode2: "60" })
.expect(401);
});

it("returns the next distinct WPM", async () => {
getNextWpmMock.mockResolvedValue(105.25);

const { body } = await mockApp
.get("/leaderboards/next")
.set("Authorization", `Bearer ${uid}`)
.query({ language: "english", mode: "time", mode2: "60" })
.expect(200);

expect(body.data).toEqual({ next: 105.25 });
expect(getNextWpmMock).toHaveBeenCalledWith("time", "60", "english", uid);
});

it("returns null for unsupported modes and missing entries", async () => {
const unsupported = await mockApp
.get("/leaderboards/next")
.set("Authorization", `Bearer ${uid}`)
.query({ language: "spanish", mode: "time", mode2: "60" })
.expect(200);

expect(unsupported.body.data).toBeNull();
expect(getNextWpmMock).not.toHaveBeenCalled();

const missing = await mockApp
.get("/leaderboards/next")
.set("Authorization", `Bearer ${uid}`)
.query({ language: "english", mode: "time", mode2: "60" })
.expect(200);

expect(missing.body.data).toBeNull();
});
});

describe("get daily leaderboard", () => {
const getDailyLeaderboardMock = vi.spyOn(
DailyLeaderboards,
Expand Down Expand Up @@ -1054,6 +1102,66 @@ describe("Loaderboard Controller", () => {
});
});

describe("get next daily leaderboard WPM", () => {
const getDailyLeaderboardMock = vi.spyOn(
DailyLeaderboards,
"getDailyLeaderboard",
);
const getNextWpmMock = vi.fn();

beforeEach(async () => {
getDailyLeaderboardMock.mockReset();
getNextWpmMock.mockReset();
getNextWpmMock.mockResolvedValue(null);
getDailyLeaderboardMock.mockReturnValue({
getNextWpm: getNextWpmMock,
} as any);
await dailyLeaderboardEnabled(true);
});

it("requires authentication", async () => {
await mockApp
.get("/leaderboards/daily/next")
.query({ language: "english", mode: "time", mode2: "60" })
.expect(401);
});

it("returns the next daily WPM or null", async () => {
getNextWpmMock.mockResolvedValue(99.5);

const { body } = await mockApp
.get("/leaderboards/daily/next")
.set("Authorization", `Bearer ${uid}`)
.query({ language: "english", mode: "time", mode2: "60" })
.expect(200);

expect(body.data).toEqual({ next: 99.5 });
expect(getNextWpmMock).toHaveBeenCalledWith(
uid,
(await configuration).dailyLeaderboards,
);

getDailyLeaderboardMock.mockReturnValue(null);
const unavailable = await mockApp
.get("/leaderboards/daily/next")
.set("Authorization", `Bearer ${uid}`)
.query({ language: "spanish", mode: "time", mode2: "60" })
.expect(200);

expect(unavailable.body.data).toBeNull();
});

it("is unavailable when daily leaderboards are disabled", async () => {
await dailyLeaderboardEnabled(false);

await mockApp
.get("/leaderboards/daily/next")
.set("Authorization", `Bearer ${uid}`)
.query({ language: "english", mode: "time", mode2: "60" })
.expect(503);
});
});

describe("get xp weekly leaderboard", () => {
const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get");
const getResultMock = vi.fn();
Expand Down
4 changes: 4 additions & 0 deletions backend/__tests__/utils/misc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ describe("Misc Utils", () => {
});
},
);

it("returns false for an invalid pattern", () => {
expect(Misc.matchesAPattern("english", "[")).toBe(false);
});
});

describe("kogascore", () => {
Expand Down
58 changes: 58 additions & 0 deletions backend/src/api/controllers/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
GetLeaderboardRankQuery,
GetLeaderboardRankResponse,
GetLeaderboardResponse,
NextLeaderboardWpmResponse,
GetWeeklyXpLeaderboardQuery,
GetWeeklyXpLeaderboardRankQuery,
GetWeeklyXpLeaderboardRankResponse,
Expand Down Expand Up @@ -105,6 +106,35 @@ export async function getRankFromLeaderboard(
return new MonkeyResponse("Rank retrieved", omit(data, ["_id"]));
}

export async function getNextLeaderboardWpm(
req: MonkeyRequest<GetLeaderboardRankQuery>,
): Promise<NextLeaderboardWpmResponse> {
const { language, mode, mode2 } = req.query;
let nextWpm: number | null | false = null;

if (
language === "english" &&
mode === "time" &&
["15", "60"].includes(mode2)
) {
try {
nextWpm = await LeaderboardsDAL.getNextWpm(
mode,
mode2,
language,
req.ctx.decodedToken.uid,
);
} catch {
nextWpm = null;
}
}

return new MonkeyResponse(
"Next leaderboard WPM retrieved",
nextWpm === false || nextWpm === null ? null : { next: nextWpm },
);
}

function getDailyLeaderboardWithError(
{ language, mode, mode2, daysBefore }: DailyLeaderboardQuery,
config: Configuration["dailyLeaderboards"],
Expand Down Expand Up @@ -189,6 +219,34 @@ export async function getDailyLeaderboardRank(
return new MonkeyResponse("Daily leaderboard rank retrieved", rank);
}

export async function getNextDailyLeaderboardWpm(
req: MonkeyRequest<GetLeaderboardRankQuery>,
): Promise<NextLeaderboardWpmResponse> {
const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard(
req.query.language,
req.query.mode,
req.query.mode2,
req.ctx.configuration.dailyLeaderboards,
);

if (dailyLeaderboard === null) {
return new MonkeyResponse("Next daily leaderboard WPM retrieved", null);
}

try {
const nextWpm = await dailyLeaderboard.getNextWpm(
req.ctx.decodedToken.uid,
req.ctx.configuration.dailyLeaderboards,
);
return new MonkeyResponse(
"Next daily leaderboard WPM retrieved",
nextWpm === null ? null : { next: nextWpm },
);
} catch {
return new MonkeyResponse("Next daily leaderboard WPM retrieved", null);
}
}

function getWeeklyXpLeaderboardWithError(
config: Configuration["leaderboards"]["weeklyXp"],
weeksBefore?: number,
Expand Down
8 changes: 8 additions & 0 deletions backend/src/api/routes/leaderboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export default s.router(leaderboardsContract, {
handler: async (r) =>
callController(LeaderboardController.getRankFromLeaderboard)(r),
},
getNext: {
handler: async (r) =>
callController(LeaderboardController.getNextLeaderboardWpm)(r),
},
getDaily: {
handler: async (r) =>
callController(LeaderboardController.getDailyLeaderboard)(r),
Expand All @@ -21,6 +25,10 @@ export default s.router(leaderboardsContract, {
handler: async (r) =>
callController(LeaderboardController.getDailyLeaderboardRank)(r),
},
getDailyNext: {
handler: async (r) =>
callController(LeaderboardController.getNextDailyLeaderboardWpm)(r),
},
getWeeklyXp: {
handler: async (r) =>
callController(LeaderboardController.getWeeklyXpLeaderboard)(r),
Expand Down
Loading
Loading