Skip to content

Commit

Permalink
handle managed installs
Browse files Browse the repository at this point in the history
  • Loading branch information
infiton committed Apr 14, 2024
1 parent cfbb99b commit 290f0f9
Show file tree
Hide file tree
Showing 7 changed files with 326 additions and 28 deletions.
1 change: 1 addition & 0 deletions packages/api-client-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"globby": "^11.0.4",
"gql-tag": "^1.0.1",
"nock": "^13.3.1",
"p-retry": "^4.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tiny-graphql-query-compiler": "workspace:*",
Expand Down
24 changes: 23 additions & 1 deletion packages/api-client-core/spec/mockUrqlClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { Client, GraphQLRequest, OperationContext, OperationResult, Operati
import { createRequest, makeErrorResult } from "@urql/core";
import type { DocumentNode, ExecutionResult, OperationDefinitionNode } from "graphql";
import type { SubscribePayload, Client as SubscriptionClient, Sink as SubscriptionSink } from "graphql-ws";
import { find, findLast } from "lodash";
import { defaults, find, findLast } from "lodash";
import pRetry from "p-retry";
import { act } from "react-dom/test-utils";
import type { Sink, Source, Subject } from "wonka";
import { filter, makeSubject, pipe, subscribe, take, toPromise } from "wonka";
Expand Down Expand Up @@ -36,6 +37,11 @@ export type MockOperationFn = jest.Mock & {
* One should ensure the appropriate `executeXYZ` call has been made by urql, then call this function.
*/
pushResponse: (key: string, response: Omit<OperationResult, "operation">) => void;
/**
*
* Waits for a subject to be created for a given key. This is useful for ensuring waiting in a test for a query or mutation to be run
*/
waitForSubject: (key: string) => Promise<void>;
};

export type MockFetchFn = jest.Mock & {
Expand Down Expand Up @@ -115,6 +121,22 @@ const newMockOperationFn = (assertions?: (request: GraphQLRequest) => void) => {
});
};

fn.waitForSubject = async (key: string, options?: pRetry.Options) => {
await pRetry(
() => {
if (subjects[key]) {
return;
}
throw new Error(`No mock client subject started for key ${key}, options are ${Object.keys(subjects).join(", ")}`);
},
defaults(options, {
attempts: 20,
minTimeout: 10,
maxTimeout: 250,
})
);
};

return fn;
};

Expand Down
2 changes: 1 addition & 1 deletion packages/react-shopify-app-bridge/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gadgetinc/react-shopify-app-bridge",
"version": "0.14.2",
"version": "0.15.0",
"files": [
"README.md",
"dist/**/*"
Expand Down
225 changes: 208 additions & 17 deletions packages/react-shopify-app-bridge/spec/Provider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AnyClient } from "@gadgetinc/api-client-core";
import { GadgetConnection } from "@gadgetinc/api-client-core";
import * as AppBridgeReact from "@shopify/app-bridge-react";
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";
import { act, render } from "@testing-library/react";
import React from "react";
import { mockUrqlClient } from "../../api-client-core/spec/mockUrqlClient.js";
import { AppType, Provider } from "../src/Provider.js";
Expand Down Expand Up @@ -36,6 +36,12 @@ describe("GadgetProvider", () => {
embedded: false,
mobile: false,
},
config: {
apiKey: mockApiKey,
shop: "example.myshopify.com",
locale: "en",
},
idToken: () => Promise.resolve("mock-id-token"),
};

useAppBridgeMock = jest.spyOn(AppBridgeReact, "useAppBridge").mockImplementation(() => window.shopify);
Expand Down Expand Up @@ -79,23 +85,41 @@ describe("GadgetProvider", () => {
}
});

test("can render an embedded app type", () => {
test("can render an embedded app type", async () => {
const { container } = render(
<Provider api={mockApiClient} shopifyApiKey={mockApiKey} type={AppType.Embedded}>
<span>hello world</span>
</Provider>
);

mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", {
data: {
currentSession: {
shop: null,
act(() => {
mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", {
data: {
currentSession: {
shop: null,
},
},
},
stale: false,
hasNext: false,
stale: false,
hasNext: false,
});
});

if (isInstallRequest) {
await act(async () => {
await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange");

mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", {
data: {
shopifyInstallByTokenExchange: {
success: false,
},
},
stale: false,
hasNext: false,
});
});
}

if (isInstallRequest) {
expect(mockOpen).toHaveBeenCalledWith(
"https://test-app.gadget.app/api/connections/auth/shopify?shop=example.myshopify.com&hmac=abcdefg&host=abcdfg",
Expand Down Expand Up @@ -151,7 +175,7 @@ describe("GadgetProvider", () => {
window.shopify.environment.mobile = false;
});

test("can render an embedded app type in embedded context", () => {
test("can render an embedded app type in embedded context", async () => {
// @ts-expect-error mock
jest.spyOn(window, "self", "get").mockImplementation(() => ({}));

Expand All @@ -161,16 +185,34 @@ describe("GadgetProvider", () => {
</Provider>
);

mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", {
data: {
currentSession: {
shop: null,
act(() => {
mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", {
data: {
currentSession: {
shop: null,
},
},
},
stale: false,
hasNext: false,
stale: false,
hasNext: false,
});
});

if (isInstallRequest) {
await act(async () => {
await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange");

mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", {
data: {
shopifyInstallByTokenExchange: {
success: false,
},
},
stale: false,
hasNext: false,
});
});
}

if (isInstallRequest) {
expect(mockOpen).toHaveBeenCalledWith(
"https://test-app.gadget.app/api/connections/auth/shopify?shop=example.myshopify.com&hmac=abcdefg&host=abcdfg",
Expand Down Expand Up @@ -211,6 +253,155 @@ describe("GadgetProvider", () => {
window.shopify.environment.embedded = false;
});

if (isInstallRequest) {
test("can render an embedded app type that's already authenticated but needs reauthentication via token exchange", async () => {
window.shopify.environment.embedded = true;

render(
<Provider api={mockApiClient} shopifyApiKey={mockApiKey} type={AppType.Embedded}>
<span>hello world</span>
</Provider>
);

act(() => {
mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", {
data: {
currentSession: {
shop: {
id: "123",
},
},
shopifyConnection: {
requiresReauthentication: true,
},
},
stale: false,
hasNext: false,
});
});

await act(async () => {
await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange");

mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", {
data: {
shopifyInstallByTokenExchange: {
success: true,
},
},
stale: false,
hasNext: false,
});
});

expect(mockOpen).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();

window.shopify.environment.embedded = false;
});

test("can render an embedded app type that's already authenticated but needs reauthentication via redirect", async () => {
window.shopify.environment.embedded = true;

render(
<Provider api={mockApiClient} shopifyApiKey={mockApiKey} type={AppType.Embedded}>
<span>hello world</span>
</Provider>
);

act(() => {
mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", {
data: {
currentSession: {
shop: {
id: "123",
},
},
shopifyConnection: {
requiresReauthentication: true,
},
},
stale: false,
hasNext: false,
});
});

await act(async () => {
await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange");

mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", {
data: {
shopifyInstallByTokenExchange: {
success: false,
},
},
stale: false,
hasNext: false,
});
});

expect(mockOpen).toHaveBeenCalledWith(
"https://test-app.gadget.app/api/connections/auth/shopify?shop=example.myshopify.com&hmac=abcdefg&host=abcdfg",
"_top"
);
expect(mockNavigate).not.toHaveBeenCalled();

window.shopify.environment.embedded = false;
});

test("doesn't redirect an shopify managed installation for an embedded app type that's already authenticated but needs reauthentication", async () => {
window.shopify.environment.embedded = true;

render(
<Provider api={mockApiClient} shopifyApiKey={mockApiKey} type={AppType.Embedded}>
<span>hello world</span>
</Provider>
);

act(() => {
mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", {
data: {
currentSession: {
shop: {
id: "123",
},
},
shopifyConnection: {
requiresReauthentication: true,
connectedApps: [
{
apiKey: mockApiKey,
shopifyManagedInstallation: true,
},
],
},
},
stale: false,
hasNext: false,
});
});

await act(async () => {
await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange");

mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", {
data: {
shopifyInstallByTokenExchange: {
success: false,
},
},
stale: false,
hasNext: false,
});
});

expect(mockOpen).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();

window.shopify.environment.embedded = false;
});
}

test("should throw if embedded app type is in embedded context and shopify global is not defined", () => {
// @ts-expect-error mock
const windowSelf = jest.spyOn(window, "self", "get").mockImplementation(() => ({}));
Expand Down
6 changes: 6 additions & 0 deletions packages/react-shopify-app-bridge/spec/useGadget.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ describe("useGadget", () => {
embedded: false,
mobile: false,
},
config: {
apiKey: mockApiKey,
shop: "example.myshopify.com",
locale: "en",
},
idToken: () => Promise.resolve("mock-id-token"),
};

useAppBridgeMock = jest.spyOn(AppBridgeReact, "useAppBridge").mockImplementation(() => window.shopify);
Expand Down
Loading

0 comments on commit 290f0f9

Please sign in to comment.