Skip to content

Commit

Permalink
Merge pull request #908 from Shopify/kos/switch_auth_to_offline_tokens
Browse files Browse the repository at this point in the history
Switch to offline session tokens
  • Loading branch information
mkevinosullivan committed Jul 8, 2022
2 parents 591c8c0 + 47486a7 commit 6222da0
Show file tree
Hide file tree
Showing 8 changed files with 328 additions and 49 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Release history

## Unreleased

- ⚠️ [Breaking] Use offline access tokens to interact with the Admin API, remove GraphQL proxy since it doesn't work with offline tokens. ([#908](https://github.com/Shopify/shopify-app-template-node/pull/908))

## 1.0.0 (June 20, 2022)

- New Shopify App Template for Node, with React frontend, for use with CLI 3
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ This template combines a number of third party open-source tools:
- [Vitest](https://vitest.dev/) tests the express backend.
- [Vite](https://vitejs.dev/) builds the [React](https://reactjs.org/) frontend.
- [React Router](https://reactrouter.com/) is used for routing. We wrap this with file-based routing.
- [React Query](https://react-query.tanstack.com/) queries the GraphQL Admin API.
- [React Query](https://react-query.tanstack.com/) queries the Admin API.

The following Shopify tools complement these third-party tools to ease app development:

- [Shopify API library](https://github.com/Shopify/shopify-node-api) adds OAuth to the Express backend. This lets users install the app and grant scope permissions.
- [App Bridge React](https://shopify.dev/apps/tools/app-bridge/getting-started/using-react) adds authentication to API requests in the frontend and renders components outside of the App’s iFrame.
- [Polaris React](https://polaris.shopify.com/) is a powerful design system and component library that helps developers build high quality, consistent experiences for Shopify merchants.
- [Custom hooks](https://github.com/Shopify/shopify-frontend-template-react/tree/main/hooks) make authenticated requests to the GraphQL Admin API.
- [Custom hooks](https://github.com/Shopify/shopify-frontend-template-react/tree/main/hooks) make authenticated requests to the Admin API.
- [File-based routing](https://github.com/Shopify/shopify-frontend-template-react/blob/main/Routes.jsx) makes creating new pages easier.

## Getting started
Expand Down
34 changes: 0 additions & 34 deletions web/__tests__/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,40 +275,6 @@ describe("shopify-app-template-node server", async () => {
});
});

describe("graphql proxy", () => {
const proxy = vi.spyOn(Shopify.Utils, "graphqlProxy");

test("graphql proxy is called & responds with body", async () => {
const body = {
data: {
test: "test",
},
};
proxy.mockImplementationOnce(() => ({
body,
}));

const response = await request(app).post("/api/graphql").send({
query: "{hello}",
});

expect(proxy).toHaveBeenCalledTimes(1);
expect(response.status).toEqual(200);
expect(response.body).toEqual(body);
});

test("returns a 500 error if graphql proxy fails", async () => {
proxy.mockImplementationOnce(() => {
throw new Error("test 500 response");
});

const response = await request(app).post("/api/graphql");

expect(response.status).toEqual(500);
expect(response.text).toContain("test 500 response");
});
});

describe("with billing enabled", async () => {
const { app: appWithBilling } = await serve(process.cwd(), false, {
required: true,
Expand Down
2 changes: 1 addition & 1 deletion web/helpers/__tests__/ensure-billing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import ensureBilling, { BillingInterval } from "../ensure-billing";
const SHOPIFY_CHARGE_NAME = "Shopify app test billing";

describe("ensureBilling", async () => {
const session = new Shopify.Session.Session("1", "test-shop", "state", true);
const session = new Shopify.Session.Session("1", "test-shop", "state", false);
session.scope = Shopify.Context.SCOPES;
session.accessToken = "access-token";
session.expires = null;
Expand Down
186 changes: 186 additions & 0 deletions web/helpers/__tests__/product-creator.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { Shopify } from "@shopify/shopify-api";
import { describe, expect, test, vi } from "vitest";

import productCreator, { DEFAULT_PRODUCTS_COUNT } from "../product-creator";


describe("productCreator", async () => {
const session = new Shopify.Session.Session("1", "test-shop", "state", false);
session.scope = Shopify.Context.SCOPES;
session.accessToken = "access-token";
session.expires = null;

describe("defaults", async () => {
test(`successfully creates ${DEFAULT_PRODUCTS_COUNT} products`, async () => {
const spy = mockGraphQLMutations(
[...Array(DEFAULT_PRODUCTS_COUNT).keys()].map(() => {
return generateProductCreateSuccessResponse();
}
));
await productCreator(session);

expect(spy).toHaveBeenCalledTimes(DEFAULT_PRODUCTS_COUNT);
for (let i = 0; i < DEFAULT_PRODUCTS_COUNT; i++) {
expect(spy).toHaveBeenNthCalledWith(
i+1,
expect.objectContaining({
data: expect.objectContaining({
query: expect.stringContaining("mutation populateProduct"),
}),
})
);
}
});

test(`fails to create ${DEFAULT_PRODUCTS_COUNT} products`, async () => {
const spy = mockGraphQLMutations([
PRODUCT_CREATE_ERROR_RESPONSE,
PRODUCT_CREATE_ERROR_RESPONSE,
PRODUCT_CREATE_ERROR_RESPONSE,
PRODUCT_CREATE_ERROR_RESPONSE,
PRODUCT_CREATE_ERROR_RESPONSE,
]);
await productCreator(session);

expect(spy).toHaveBeenCalledTimes(DEFAULT_PRODUCTS_COUNT);
for (let i = 0; i < DEFAULT_PRODUCTS_COUNT; i++) {
expect(spy).toHaveBeenNthCalledWith(
i+1,
expect.objectContaining({
data: expect.objectContaining({
query: expect.stringContaining("mutation populateProduct"),
}),
})
);
}
});
});

describe("count = 2", async () => {
const count = 2;

test(`successfully creates ${count} products`, async () => {
const spy = mockGraphQLMutations(
[...Array(count).keys()].map(() => {
return generateProductCreateSuccessResponse();
}
));
await productCreator(session, count);

expect(spy).toHaveBeenCalledTimes(count);
for (let i = 0; i < count; i++) {
expect(spy).toHaveBeenNthCalledWith(
i+1,
expect.objectContaining({
data: expect.objectContaining({
query: expect.stringContaining("mutation populateProduct"),
}),
})
);
}
});

test(`fails to create ${count} products`, async () => {
const spy = mockGraphQLMutations([
PRODUCT_CREATE_ERROR_RESPONSE,
PRODUCT_CREATE_ERROR_RESPONSE,
]);
await productCreator(session, count);

expect(spy).toHaveBeenCalledTimes(count);
for (let i = 0; i < count; i++) {
expect(spy).toHaveBeenNthCalledWith(
i+1,
expect.objectContaining({
data: expect.objectContaining({
query: expect.stringContaining("mutation populateProduct"),
}),
})
);
}
});
});
});

function mockGraphQLMutations(queryResponses) {
let queryIndex = 0;
const querySpy = vi.fn().mockImplementation(() => {
return queryResponses[queryIndex++];
});

vi.spyOn(Shopify.Clients, "Graphql").mockImplementation(() => {
return {
query: querySpy,
};
});

return querySpy;
}

function generateProductCreateSuccessResponse() {
const gid = [...Array(13).keys()].map(() => { return Math.floor(Math.random() * 10) }).join("");
let response = PRODUCT_CREATE_SUCCESS_RESPONSE;
response.body.data.productCreate.product.id = `gid://shopify/Product/${gid}`;
return response;
}

const PRODUCT_CREATE_SUCCESS_RESPONSE = {
body: {
data: {
productCreate: {
product: {
id: "gid://shopify/Product/7422520950945"
}
}
},
extensions: {
cost: {
requestedQueryCost: 10,
actualQueryCost: 10,
throttleStatus: {
maximumAvailable: 1000,
currentlyAvailable: 990,
restoreRate: 50
}
}
}
}
}

const PRODUCT_CREATE_ERROR_RESPONSE = {
body: {
data: {
productCreate: null
},
errors: [
{
message: "Access denied for productCreate field. Required access: `write_products` access scope.",
locations: [
{
line: 2,
column: 3
}
],
path: [
"productCreate"
],
extensions: {
code: "ACCESS_DENIED",
documentation: "https://shopify.dev/api/usage/access-scopes",
requiredAccess: "`write_products` access scope."
}
}
],
extensions: {
cost: {
requestedQueryCost: 10,
actualQueryCost: 10,
throttleStatus: {
maximumAvailable: 1000,
currentlyAvailable: 990,
restoreRate: 50
}
}
}
}
}
108 changes: 108 additions & 0 deletions web/helpers/product-creator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Shopify } from "@shopify/shopify-api";

const ADJECTIVES = [
"autumn",
"hidden",
"bitter",
"misty",
"silent",
"empty",
"dry",
"dark",
"summer",
"icy",
"delicate",
"quiet",
"white",
"cool",
"spring",
"winter",
"patient",
"twilight",
"dawn",
"crimson",
"wispy",
"weathered",
"blue",
"billowing",
"broken",
"cold",
"damp",
"falling",
"frosty",
"green",
"long",
]

const NOUNS = [
"waterfall",
"river",
"breeze",
"moon",
"rain",
"wind",
"sea",
"morning",
"snow",
"lake",
"sunset",
"pine",
"shadow",
"leaf",
"dawn",
"glitter",
"forest",
"hill",
"cloud",
"meadow",
"sun",
"glade",
"bird",
"brook",
"butterfly",
"bush",
"dew",
"dust",
"field",
"fire",
"flower",
]

export const DEFAULT_PRODUCTS_COUNT = 5;
const CREATE_PRODUCTS_MUTATION = `
mutation populateProduct($input: ProductInput!) {
productCreate(input: $input) {
product {
id
}
}
}
`

export default async function productCreator(session, count = DEFAULT_PRODUCTS_COUNT) {
const client = new Shopify.Clients.Graphql(session.shop, session.accessToken);

for (let i = 0; i < count; i++) {
await client.query({
data: {
query: CREATE_PRODUCTS_MUTATION,
variables: {
input: {
title: `${randomTitle()}`,
variants: [{ price: randomPrice() }],
},
},
},
});
}
}

function randomTitle() {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
return `${adjective} ${noun}`;
}

function randomPrice() {
return Math.round((Math.random() * 10 + Number.EPSILON) * 100) / 100;
}
Loading

0 comments on commit 6222da0

Please sign in to comment.