Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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