Skip to content
Merged
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 @@ -129,6 +129,7 @@ Click the function names to open their complete docs on the docs site.
- [`getUserTrophyProfileSummary()`](https://psn-api.achievements.app/api-docs/user-trophies#getusertrophyprofilesummary) - Retrieve an overall summary of the number of trophies earned for a user broken down by type.
- [`getUserTrophiesForSpecificTitle()`](https://psn-api.achievements.app/api-docs/user-trophies#getUserTrophiesForSpecificTitle) - Retrieve a summary of the trophies earned by a user for specific titles.
- [`getRecentlyPlayedGames()`](https://psn-api.achievements.app/api-docs/users#getrecentlyplayedgames) - Retrieve a list of recently played games for the user associated with the access token provided to this function.
- [`getPurchasedGames()`](https://psn-api.achievements.app/api-docs/users#getpurchasedgames) - Retrieve purchased games for the user associated with the access token. Returns only PS4 and PS5 games.
- [`getUserPlayedGames()`](https://psn-api.achievements.app/api-docs/users#getuserplayedgames) - Retrieve a list of played games and playtime info (ordered by recency) associated with a user (either from token or external if privacy settings allow).

## Examples
Expand Down
203 changes: 203 additions & 0 deletions src/graphql/getPurchasedGames.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import nock from "nock";

import type { AuthorizationPayload, PurchasedGamesResponse } from "../models";
import { getPurchasedGames } from "./getPurchasedGames";
import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL";

const accessToken = "mockAccessToken";

describe("Function: getPurchasedGames", () => {
afterEach(() => {
nock.cleanAll();
});

it("is defined #sanity", () => {
// ASSERT
expect(getPurchasedGames).toBeDefined();
});

it("retrieves purchased games for the user", async () => {
// ARRANGE
const mockAuthorization: AuthorizationPayload = {
accessToken
};

const mockResponse: PurchasedGamesResponse = {
data: {
purchasedTitlesRetrieve: {
__typename: "GameList",
games: [
{
__typename: "GameLibraryTitle",
conceptId: "203715",
entitlementId: "EP2002-CUSA01433_00-ROCKETLEAGUEEU01",
image: {
__typename: "Media",
url: "https://image.api.playstation.com/gs2-sec/appkgo/prod/CUSA01433_00/7/i_5c5e430a49994f22df5fd81f446ead7b6ae45027af490b415fe4e744a9918e4c/i/icon0.png"
},
isActive: true,
isDownloadable: true,
isPreOrder: false,
membership: "NONE",
name: "Rocket League®",
platform: "PS4",
productId: "EP2002-CUSA01433_00-ROCKETLEAGUEEU01",
titleId: "CUSA01433_00"
}
]
}
}
};

const baseUrlObj = new URL(GRAPHQL_BASE_URL);
const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
const basePath = baseUrlObj.pathname;

// ... we need to use a nock matcher to verify the query parameters ...
const expectedVariables = JSON.stringify({
isActive: true,
platform: ["ps4", "ps5"],
size: 24,
start: 0,
sortBy: "ACTIVE_DATE",
sortDirection: "desc"
});
const expectedExtensions = JSON.stringify({
persistedQuery: {
version: 1,
sha256Hash:
"827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168"
}
});

const mockScope = nock(baseUrl)
.get(basePath)
.query((params) => {
expect(params.operationName).toEqual("getPurchasedGameList");
expect(params.variables).toEqual(expectedVariables);
expect(params.extensions).toEqual(expectedExtensions);
return true;
})
.matchHeader("authorization", `Bearer ${accessToken}`)
.reply(200, mockResponse);

// ACT
const response = await getPurchasedGames(mockAuthorization);

// ASSERT
expect(response).toEqual(mockResponse);
expect(mockScope.isDone()).toBeTruthy();
});

it("retrieves purchased games with custom options", async () => {
// ARRANGE
const mockAuthorization: AuthorizationPayload = {
accessToken
};

const mockResponse: PurchasedGamesResponse = {
data: {
purchasedTitlesRetrieve: {
__typename: "GameList",
games: []
}
}
};

const baseUrlObj = new URL(GRAPHQL_BASE_URL);
const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
const basePath = baseUrlObj.pathname;

const expectedVariables = JSON.stringify({
isActive: false,
platform: ["ps4", "ps5"],
size: 50,
start: 0,
sortBy: "ACTIVE_DATE",
sortDirection: "desc"
});
const expectedExtensions = JSON.stringify({
persistedQuery: {
version: 1,
sha256Hash:
"827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168"
}
});

const mockScope = nock(baseUrl)
.get(basePath)
.query((params) => {
expect(params.operationName).toEqual("getPurchasedGameList");
expect(params.variables).toEqual(expectedVariables);
expect(params.extensions).toEqual(expectedExtensions);
return true;
})
.matchHeader("authorization", `Bearer ${accessToken}`)
.reply(200, mockResponse);

// ACT
const response = await getPurchasedGames(mockAuthorization, {
isActive: false,
size: 50,
sortBy: "ACTIVE_DATE"
});

// ASSERT
expect(response).toEqual(mockResponse);
expect(mockScope.isDone()).toBeTruthy();
});

it("throws an error if response data is null", async () => {
// ARRANGE
const mockAuthorization: AuthorizationPayload = {
accessToken
};

const mockErrorResponse = {
data: null
};

const baseUrlObj = new URL(GRAPHQL_BASE_URL);
const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
const basePath = baseUrlObj.pathname;

nock(baseUrl)
.get(basePath)
.query(true)
.matchHeader("authorization", `Bearer ${accessToken}`)
.reply(200, mockErrorResponse);

// ASSERT
await expect(getPurchasedGames(mockAuthorization)).rejects.toThrowError(
JSON.stringify(mockErrorResponse)
);
});

it("throws an error if purchasedTitlesRetrieve is null", async () => {
// ARRANGE
const mockAuthorization: AuthorizationPayload = {
accessToken
};

const mockErrorResponse = {
data: {
purchasedTitlesRetrieve: null
}
};

const baseUrlObj = new URL(GRAPHQL_BASE_URL);
const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
const basePath = baseUrlObj.pathname;

nock(baseUrl)
.get(basePath)
.query(true)
.matchHeader("authorization", `Bearer ${accessToken}`)
.reply(200, mockErrorResponse);

// ASSERT
await expect(getPurchasedGames(mockAuthorization)).rejects.toThrowError(
JSON.stringify(mockErrorResponse)
);
});
});
76 changes: 76 additions & 0 deletions src/graphql/getPurchasedGames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { AuthorizationPayload, PurchasedGamesResponse } from "../models";
import { Membership } from "../models/membership.model";
import { call } from "../utils/call";
import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL";
import { getPurchasedGameListHash } from "./operationHashes";

type GetPurchasedGamesOptions = {
isActive: boolean;
platform: ("ps4" | "ps5")[];
size: number;
start: number;
sortBy: "ACTIVE_DATE";
sortDirection: "asc" | "desc";
membership: Membership;
};

/**
* A call to this function will retrieve purchased games for the user associated
* with the npsso token provided to this module during initialisation.
*
* This endpoint returns only PS4 and PS5 games.
*
* @param authorization An object containing your access token, typically retrieved with `exchangeAccessCodeForAuthTokens()`.
* @param options Optional parameters to filter and sort purchased games.
*/
export const getPurchasedGames = async (
authorization: AuthorizationPayload,
options: Partial<GetPurchasedGamesOptions> = {}
): Promise<PurchasedGamesResponse> => {
const url = new URL(GRAPHQL_BASE_URL);

const {
isActive = true,
platform = ["ps4", "ps5"],
size = 24,
start = 0,
sortBy = "ACTIVE_DATE",
sortDirection = "desc",
...restOptions
} = options;

url.searchParams.set("operationName", "getPurchasedGameList");
url.searchParams.set(
"variables",
JSON.stringify({
isActive,
platform,
size,
start,
sortBy,
sortDirection,
...restOptions
})
);
url.searchParams.set(
"extensions",
JSON.stringify({
persistedQuery: {
version: 1,
sha256Hash: getPurchasedGameListHash
}
})
);

const response = await call<PurchasedGamesResponse>(
{ url: url.toString() },
authorization
);

// The GraphQL queries can return non-truthy values.
if (!response.data || !response.data.purchasedTitlesRetrieve) {
throw new Error(JSON.stringify(response));
}

return response;
};
1 change: 1 addition & 0 deletions src/graphql/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./getPurchasedGames";
export * from "./getRecentlyPlayedGames";
3 changes: 3 additions & 0 deletions src/graphql/operationHashes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@
// "query getUserGameList($categories: String, $limit: Int, $orderBy: String, $subscriptionService: SubscriptionService) {\n gameLibraryTitlesRetrieve(categories: $categories, limit: $limit, orderBy: $orderBy, subscriptionService: $subscriptionService) {\n __typename\n games {\n __typename\n conceptId\n entitlementId\n image {\n __typename\n url\n }\n isActive\n lastPlayedDateTime\n name\n platform\n productId\n subscriptionService\n titleId\n }\n }\n}\n"
export const getUserGameListHash =
"e780a6d8b921ef0c59ec01ea5c5255671272ca0d819edb61320914cf7a78b3ae";

export const getPurchasedGameListHash =
"827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168";
2 changes: 2 additions & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ export * from "./authorization-payload.model";
export * from "./basic-presence-response.model";
export * from "./call-valid-headers.model";
export * from "./get-user-friends-account-ids-response.model";
export * from "./membership.model";
export * from "./profile-from-account-id-response.model";
export * from "./profile-from-user-name-response.model";
export * from "./purchased-games-response.model";
export * from "./rarest-thin-trophy.model";
export * from "./recently-played-games-response.model";
export * from "./shareable-profile-link-response.model";
Expand Down
1 change: 1 addition & 0 deletions src/models/membership.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type Membership = "NONE" | "PS_PLUS";
52 changes: 52 additions & 0 deletions src/models/purchased-games-response.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Membership } from "./membership.model";
import { TitlePlatform } from "./title-platform.model";

export interface PurchasedGame {
/** GraphQL object type/schema */
__typename: "GameLibraryTitle";

/** Unique concept identifier for the game */
conceptId: string | null;
Comment thread
wescopeland marked this conversation as resolved.

/** Unique entitlement identifier */
entitlementId: string;

/** Contains a url to a game icon file */
image: {
__typename: "Media";
url: string;
};

/** Whether the game is currently active */
isActive: boolean;

/** Whether the game is downloadable */
isDownloadable: boolean;

/** Whether the game is a pre-order */
isPreOrder: boolean;

/** The membership level associated with this game */
membership: Membership;

/** The name of the game */
name: string;

/** The platform this game is available on */
platform: TitlePlatform;

/** Unique product identifier */
productId: string;

/** Unique title identifier */
titleId: string;
}

export interface PurchasedGamesResponse {
data: {
purchasedTitlesRetrieve: {
__typename: "GameList";
games: PurchasedGame[];
};
};
}
2 changes: 1 addition & 1 deletion src/models/user-devices-response.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface AccountDevicesResponse {
*/
deviceType: string;

/**
/**
* The activation type.
* @example "PRIMARY" | "PSN_GAME_V3"
*/
Expand Down
Loading