Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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;
}
Comment thread
wescopeland marked this conversation as resolved.

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;
};
Comment thread
stiefeljackal marked this conversation as resolved.
Outdated
}>;
}
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