diff --git a/README.md b/README.md index fc9b44e..80237b5 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Click the function names to open their complete docs on the docs site. ### Leaderboard +- [`getGameLeaderboards()`](https://api-docs.retroachievements.org/v1/get-game-leaderboards.html) - Get a given game's list of leaderboards. - [`getLeaderboardEntries()`](https://api-docs.retroachievements.org/v1/get-leaderboard-entries.html) - Get a given leaderboard's entries. - [`getUserGameLeaderboards()`](https://api-docs.retroachievements.org/v1/get-user-game-leaderboards.html) - Get a user's list of leaderboards for a given game. diff --git a/src/leaderboard/getGameLeaderboards.test.ts b/src/leaderboard/getGameLeaderboards.test.ts new file mode 100644 index 0000000..5ac4fc1 --- /dev/null +++ b/src/leaderboard/getGameLeaderboards.test.ts @@ -0,0 +1,152 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +import { apiBaseUrl } from "../utils/internal"; +import { buildAuthorization } from "../utils/public"; +import { getGameLeaderboards } from "./getGameLeaderboards"; +import type { GameLeaderboards, GetGameLeaderboardsResponse } from "./models"; + +const server = setupServer(); + +describe("Function: getGameLeaderboards", () => { + // MSW Setup + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it("is defined #sanity", () => { + // ASSERT + expect(getGameLeaderboards).toBeDefined(); + }); + + it("using defaults, retrieves the list of game leaderboards for the given game id", async () => { + // ARRANGE + const authorization = buildAuthorization({ + username: "mockUserName", + webApiKey: "mockWebApiKey", + }); + + const mockResponse = mockGetGameLeaderboardsResponse; + + server.use( + http.get(`${apiBaseUrl}/API_GetGameLeaderboards.php`, (info) => { + const url = new URL(info.request.url); + expect(url.searchParams.get("i")).toBe(mockGameId); + expect(url.searchParams.has("c")).toBeFalsy(); + expect(url.searchParams.has("o")).toBeFalsy(); + return HttpResponse.json(mockResponse); + }) + ); + + // ACT + const response = await getGameLeaderboards(authorization, { + gameId: mockGameId, + }); + expect(response).toEqual(mockGameLeaderboardsValue); + }); + + it.each([{ offset: 1, count: 1 }, { offset: 5 }, { count: 20 }])( + "calls the 'Game Leaderboards' endpoint with a given offset ($offset) and/or count ($count)", + async ({ offset: mockOffset, count: mockCount }) => { + // ARRANGE + const authorization = buildAuthorization({ + username: "mockUserName", + webApiKey: "mockWebApiKey", + }); + + server.use( + http.get(`${apiBaseUrl}/API_GetGameLeaderboards.php`, (info) => { + const url = new URL(info.request.url); + const c = url.searchParams.get("c"); + const o = url.searchParams.get("o"); + expect(url.searchParams.get("i")).toBe(mockGameId); + expect(String(c)).toEqual(String(mockCount ?? null)); + expect(String(o)).toEqual(String(mockOffset ?? null)); + return HttpResponse.json(mockGetGameLeaderboardsResponse); + }) + ); + + // ACT + await getGameLeaderboards(authorization, { + gameId: mockGameId, + offset: mockOffset, + count: mockCount, + }); + } + ); + + it.each([ + { status: 503, statusText: "The API is currently down" }, + { status: 422, statusText: "HTTP Error: Status 422 Unprocessable Entity" }, + ])( + "given the API returns a $status, throws an error", + async ({ status, statusText }) => { + // ARRANGE + const authorization = buildAuthorization({ + username: "mockUserName", + webApiKey: "mockWebApiKey", + }); + + const mockResponse = `${statusText}`; + + server.use( + http.get(`${apiBaseUrl}/API_GetGameLeaderboards.php`, () => + HttpResponse.json(mockResponse, { status, statusText }) + ) + ); + + // ASSERT + await expect( + getGameLeaderboards(authorization, { gameId: mockGameId }) + ).rejects.toThrow(); + } + ); +}); + +const mockGameId = "14402"; + +const mockGetGameLeaderboardsResponse: GetGameLeaderboardsResponse = { + Count: 1, + Total: 1, + Results: [ + { + ID: 1234, + RankAsc: false, + Title: "South Island Conqueror", + Description: "Complete the game with the highest score possible.", + Format: "VALUE", + Author: "Example", + AuthorULID: "0123456789ABCDEFGHIJKLMNO", + State: "active", + TopEntry: { + User: "TopExample", + ULID: "ONMLKJIHGFEDCBA9876543210", + Score: 98_765, + FormattedScore: "98,765", + }, + }, + ], +}; + +const mockGameLeaderboardsValue: GameLeaderboards = { + count: 1, + total: 1, + results: [ + { + id: 1234, + rankAsc: false, + title: "South Island Conqueror", + description: "Complete the game with the highest score possible.", + format: "VALUE", + author: "Example", + authorUlid: "0123456789ABCDEFGHIJKLMNO", + state: "active", + topEntry: { + user: "TopExample", + ulid: "ONMLKJIHGFEDCBA9876543210", + score: 98_765, + formattedScore: "98,765", + }, + }, + ], +}; diff --git a/src/leaderboard/getGameLeaderboards.ts b/src/leaderboard/getGameLeaderboards.ts new file mode 100644 index 0000000..334211a --- /dev/null +++ b/src/leaderboard/getGameLeaderboards.ts @@ -0,0 +1,91 @@ +import type { ID } from "../utils/internal"; +import { + apiBaseUrl, + buildRequestUrl, + call, + serializeProperties, +} from "../utils/internal"; +import type { AuthObject } from "../utils/public"; +import type { GameLeaderboards, GetGameLeaderboardsResponse } from "./models"; + +/** + * A call to this function will retrieve a list of leaderboards for a + * given game ID. + * + * @param authorization An object containing your username and webApiKey. + * This can be constructed with `buildAuthorization()`. + * + * @param payload.gameId The ID of the game to retrieve leaderboards for. + * + * @param payload.offset The number of entries to skip. The API will default + * to 0 if the parameter is not specified. + * + * @param payload.count The number of entries to return. The API will + * default to 100 if the parameter is not specified. The max number + * of entries that can be returned is 500. + * + * @example + * ``` + * const gameLeaderboards = await getGameLeaderboards( + * authorization, + * { gameId: 14402 } + * ); + * ``` + * + * @returns An object containing a list of leaderboards that were created + * for the specified game. + * ```json + * { + * "count": 1, + * "total": 1, + * "results": [ + * { + * "id": 1234, + * "rankAsc": false, + * "title": "South Island Conqueror", + * "description": "Complete the game with the highest score possible.", + * "format": "VALUE", + * "author": "Example", + * "authorUlid": "0123456789ABCDEFGHIJKLMNO", + * "state": "active", + * "topEntry" : { + * "user": "TopExample", + * "ulid": "ONMLKJIHGFEDCBA9876543210", + * "score": 98765, + * "formattedScore": "98,765" + * } + * } + * ] + * } + * ``` + * + * @throws If the API was given invalid parameters (422) or if the + * API is currently down (503). + */ +export const getGameLeaderboards = async ( + authorization: AuthObject, + payload: { gameId: ID; offset?: number; count?: number } +): Promise => { + const queryParams: Record = {}; + queryParams.i = payload.gameId; + if (payload.offset !== null && payload.offset !== undefined) { + queryParams.o = payload.offset; + } + if (payload.count !== null && payload.count !== undefined) { + queryParams.c = payload.count; + } + + const url = buildRequestUrl( + apiBaseUrl, + "/API_GetGameLeaderboards.php", + authorization, + queryParams + ); + + const rawResponse = await call({ url }); + + return serializeProperties(rawResponse, { + shouldCastToNumbers: ["ID", "Score"], + shouldMapToBooleans: ["RankAsc"], + }); +}; diff --git a/src/leaderboard/index.ts b/src/leaderboard/index.ts index 12f1205..bab6e58 100644 --- a/src/leaderboard/index.ts +++ b/src/leaderboard/index.ts @@ -1,3 +1,4 @@ +export * from "./getGameLeaderboards"; export * from "./getLeaderboardEntries"; export * from "./getUserGameLeaderboards"; export * from "./models"; diff --git a/src/leaderboard/models/game-leaderboards.model.ts b/src/leaderboard/models/game-leaderboards.model.ts new file mode 100644 index 0000000..eab9242 --- /dev/null +++ b/src/leaderboard/models/game-leaderboards.model.ts @@ -0,0 +1,20 @@ +export interface GameLeaderboards { + count: number; + total: number; + results: Array<{ + id: number; + rankAsc: boolean; + title: string; + description: string; + format: string; + author: string; + authorUlid: string; + state: "active" | "disabled" | "unpublished"; + topEntry: { + user: string; + ulid: string; + score: number; + formattedScore: string; + }; + }>; +} diff --git a/src/leaderboard/models/get-game-leaderboards-response.model.ts b/src/leaderboard/models/get-game-leaderboards-response.model.ts new file mode 100644 index 0000000..20a2d70 --- /dev/null +++ b/src/leaderboard/models/get-game-leaderboards-response.model.ts @@ -0,0 +1,20 @@ +export interface GetGameLeaderboardsResponse { + Count: number; + Total: number; + Results: Array<{ + ID: number; + RankAsc: boolean; + Title: string; + Description: string; + Format: string; + Author: string; + AuthorULID: string; + State: "active" | "disabled" | "unpublished"; + TopEntry: { + User: string; + ULID: string; + Score: number; + FormattedScore: string; + }; + }>; +} diff --git a/src/leaderboard/models/index.ts b/src/leaderboard/models/index.ts index 190c102..43af032 100644 --- a/src/leaderboard/models/index.ts +++ b/src/leaderboard/models/index.ts @@ -1,3 +1,5 @@ +export * from "./game-leaderboards.model"; +export * from "./get-game-leaderboards-response.model"; export * from "./get-leaderboard-entries-response.model"; export * from "./get-user-game-leaderboards-response.model"; export * from "./leaderboard-entries.model";