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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
152 changes: 152 additions & 0 deletions src/leaderboard/getGameLeaderboards.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<html><body>${statusText}</body></html>`;

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",
},
},
],
};
91 changes: 91 additions & 0 deletions src/leaderboard/getGameLeaderboards.ts
Original file line number Diff line number Diff line change
@@ -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<GameLeaderboards> => {
const queryParams: Record<string, string | number> = {};
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<GetGameLeaderboardsResponse>({ url });

return serializeProperties(rawResponse, {
shouldCastToNumbers: ["ID", "Score"],
shouldMapToBooleans: ["RankAsc"],
});
};
1 change: 1 addition & 0 deletions src/leaderboard/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./getGameLeaderboards";
export * from "./getLeaderboardEntries";
export * from "./getUserGameLeaderboards";
export * from "./models";
20 changes: 20 additions & 0 deletions src/leaderboard/models/game-leaderboards.model.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}>;
}
20 changes: 20 additions & 0 deletions src/leaderboard/models/get-game-leaderboards-response.model.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}>;
}
2 changes: 2 additions & 0 deletions src/leaderboard/models/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down