Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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