@decocms/apps-shopify 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +48 -0
- package/src/actions/cart/addItems.ts +36 -0
- package/src/actions/cart/updateCoupons.ts +31 -0
- package/src/actions/cart/updateItems.ts +31 -0
- package/src/actions/user/signIn.ts +40 -0
- package/src/actions/user/signUp.ts +31 -0
- package/src/client.ts +58 -0
- package/src/index.ts +43 -0
- package/src/init.ts +39 -0
- package/src/loaders/ProductDetailsPage.ts +31 -0
- package/src/loaders/ProductList.ts +102 -0
- package/src/loaders/ProductListingPage.ts +172 -0
- package/src/loaders/RelatedProducts.ts +42 -0
- package/src/loaders/cart.ts +74 -0
- package/src/loaders/shop.ts +35 -0
- package/src/loaders/user.ts +39 -0
- package/src/mod.ts +67 -0
- package/src/registry.ts +9 -0
- package/src/utils/__tests__/graphqlOperationName.test.ts +80 -0
- package/src/utils/__tests__/instrumentedFetch.test.ts +84 -0
- package/src/utils/__tests__/operationRouter.test.ts +56 -0
- package/src/utils/__tests__/transform.test.ts +216 -0
- package/src/utils/admin/admin.ts +57 -0
- package/src/utils/admin/queries.ts +29 -0
- package/src/utils/cart.ts +32 -0
- package/src/utils/cookies.ts +82 -0
- package/src/utils/enums.ts +438 -0
- package/src/utils/graphql.ts +69 -0
- package/src/utils/graphqlOperationName.ts +67 -0
- package/src/utils/instrumentedFetch.ts +55 -0
- package/src/utils/operationRouter.ts +70 -0
- package/src/utils/storefront/queries.ts +525 -0
- package/src/utils/transform.ts +430 -0
- package/src/utils/types.ts +191 -0
- package/src/utils/user.ts +23 -0
- package/src/utils/utils.ts +159 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Product } from "@decocms/apps-commerce/types";
|
|
2
|
+
import { getShopifyClient } from "../client";
|
|
3
|
+
import { GetProduct, ProductRecommendations } from "../utils/storefront/queries";
|
|
4
|
+
import { type ProductShopify, toProduct } from "../utils/transform";
|
|
5
|
+
import type { Metafield } from "../utils/types";
|
|
6
|
+
|
|
7
|
+
export interface Props {
|
|
8
|
+
slug: string;
|
|
9
|
+
count?: number;
|
|
10
|
+
metafields?: Metafield[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default async function relatedProductsLoader(
|
|
14
|
+
props: Props,
|
|
15
|
+
url?: URL,
|
|
16
|
+
): Promise<Product[] | null> {
|
|
17
|
+
const client = getShopifyClient();
|
|
18
|
+
const { slug, count = 10, metafields = [] } = props;
|
|
19
|
+
|
|
20
|
+
const splitted = slug?.split("-") ?? [];
|
|
21
|
+
const maybeSkuId = Number(splitted[splitted.length - 1]);
|
|
22
|
+
const handle = splitted.slice(0, maybeSkuId ? -1 : undefined).join("-");
|
|
23
|
+
|
|
24
|
+
const productData = await client.query<{ product?: ProductShopify }>(GetProduct, {
|
|
25
|
+
handle,
|
|
26
|
+
identifiers: metafields,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!productData?.product) return [];
|
|
30
|
+
|
|
31
|
+
const data = await client.query<{
|
|
32
|
+
productRecommendations?: ProductShopify[];
|
|
33
|
+
}>(ProductRecommendations, { productId: productData.product.id, identifiers: metafields });
|
|
34
|
+
|
|
35
|
+
if (!data?.productRecommendations) return [];
|
|
36
|
+
|
|
37
|
+
const baseUrl = url ?? new URL("https://localhost");
|
|
38
|
+
|
|
39
|
+
return data.productRecommendations
|
|
40
|
+
.map((p) => toProduct(p, p.variants.nodes[0], baseUrl))
|
|
41
|
+
.slice(0, count);
|
|
42
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getShopifyClient } from "../client";
|
|
2
|
+
import { getCartCookie, setCartCookie } from "../utils/cart";
|
|
3
|
+
import { CreateCart, GetCart } from "../utils/storefront/queries";
|
|
4
|
+
|
|
5
|
+
export interface CartLine {
|
|
6
|
+
id: string;
|
|
7
|
+
quantity: number;
|
|
8
|
+
merchandise: {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
image?: { url: string; altText?: string | null } | null;
|
|
12
|
+
product: { title: string; handle: string; onlineStoreUrl?: string | null };
|
|
13
|
+
price: { amount: string; currencyCode: string };
|
|
14
|
+
};
|
|
15
|
+
discountAllocations?: Array<{
|
|
16
|
+
code?: string;
|
|
17
|
+
discountedAmount?: { amount: string; currencyCode: string };
|
|
18
|
+
}>;
|
|
19
|
+
cost?: {
|
|
20
|
+
totalAmount: { amount: string; currencyCode: string };
|
|
21
|
+
subtotalAmount: { amount: string; currencyCode: string };
|
|
22
|
+
amountPerQuantity?: { amount: string; currencyCode: string };
|
|
23
|
+
compareAtAmountPerQuantity?: { amount: string; currencyCode: string } | null;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ShopifyCart {
|
|
28
|
+
id: string;
|
|
29
|
+
checkoutUrl: string;
|
|
30
|
+
totalQuantity: number;
|
|
31
|
+
lines: { nodes: CartLine[] };
|
|
32
|
+
cost: {
|
|
33
|
+
totalTaxAmount?: { amount: string; currencyCode: string };
|
|
34
|
+
subtotalAmount: { amount: string; currencyCode: string };
|
|
35
|
+
totalAmount: { amount: string; currencyCode: string };
|
|
36
|
+
checkoutChargeAmount?: { amount: string; currencyCode: string };
|
|
37
|
+
};
|
|
38
|
+
discountCodes?: Array<{ applicable: boolean; code: string }>;
|
|
39
|
+
discountAllocations?: Array<{
|
|
40
|
+
discountedAmount: { amount: string; currencyCode: string };
|
|
41
|
+
}>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function getCart(
|
|
45
|
+
requestHeaders: Headers,
|
|
46
|
+
responseHeaders?: Headers,
|
|
47
|
+
): Promise<ShopifyCart | null> {
|
|
48
|
+
const client = getShopifyClient();
|
|
49
|
+
const maybeCartId = getCartCookie(requestHeaders);
|
|
50
|
+
|
|
51
|
+
const cartId =
|
|
52
|
+
maybeCartId ??
|
|
53
|
+
(await client
|
|
54
|
+
.query<{ payload?: { cart?: { id: string } } }>(CreateCart)
|
|
55
|
+
.then((data) => data.payload?.cart?.id));
|
|
56
|
+
|
|
57
|
+
if (!cartId) throw new Error("Missing cart id");
|
|
58
|
+
|
|
59
|
+
const cart = await client
|
|
60
|
+
.query<{ cart?: ShopifyCart }>(GetCart, { id: decodeURIComponent(cartId) })
|
|
61
|
+
.then((data) => data.cart ?? null);
|
|
62
|
+
|
|
63
|
+
if (responseHeaders) {
|
|
64
|
+
setCartCookie(responseHeaders, cartId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return cart;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function createCart(): Promise<string | null> {
|
|
71
|
+
const client = getShopifyClient();
|
|
72
|
+
const data = await client.query<{ payload?: { cart?: { id: string } } }>(CreateCart);
|
|
73
|
+
return data?.payload?.cart?.id ?? null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getShopifyClient } from "../client";
|
|
2
|
+
import { GetShopInfo } from "../utils/storefront/queries";
|
|
3
|
+
import type { Metafield } from "../utils/types";
|
|
4
|
+
|
|
5
|
+
export interface Shop {
|
|
6
|
+
name: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
privacyPolicy?: { title: string; body: string };
|
|
9
|
+
refundPolicy?: { title: string; body: string };
|
|
10
|
+
shippingPolicy?: { title: string; body: string };
|
|
11
|
+
subscriptionPolicy?: { title: string; body: string };
|
|
12
|
+
termsOfService?: { title: string; body: string };
|
|
13
|
+
metafields?: Array<{
|
|
14
|
+
description?: string | null;
|
|
15
|
+
key: string;
|
|
16
|
+
namespace: string;
|
|
17
|
+
type: string;
|
|
18
|
+
value: string;
|
|
19
|
+
reference?: { image?: { url: string } } | null;
|
|
20
|
+
references?: { edges: Array<{ node: { image?: { url: string } } }> } | null;
|
|
21
|
+
} | null>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Props {
|
|
25
|
+
metafields?: Metafield[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default async function shopLoader(props?: Props): Promise<Shop> {
|
|
29
|
+
const client = getShopifyClient();
|
|
30
|
+
const metafields = props?.metafields || [];
|
|
31
|
+
|
|
32
|
+
const data = await client.query<{ shop: Shop }>(GetShopInfo, { identifiers: metafields });
|
|
33
|
+
|
|
34
|
+
return data.shop;
|
|
35
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getShopifyClient } from "../client";
|
|
2
|
+
import { FetchCustomerInfo } from "../utils/storefront/queries";
|
|
3
|
+
import { getUserCookie } from "../utils/user";
|
|
4
|
+
|
|
5
|
+
export interface ShopifyUser {
|
|
6
|
+
"@id": string;
|
|
7
|
+
email: string;
|
|
8
|
+
givenName: string;
|
|
9
|
+
familyName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default async function userLoader(requestHeaders: Headers): Promise<ShopifyUser | null> {
|
|
13
|
+
const client = getShopifyClient();
|
|
14
|
+
const customerAccessToken = getUserCookie(requestHeaders);
|
|
15
|
+
|
|
16
|
+
if (!customerAccessToken) return null;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const data = await client.query<{
|
|
20
|
+
customer?: {
|
|
21
|
+
id: string;
|
|
22
|
+
email?: string | null;
|
|
23
|
+
firstName?: string | null;
|
|
24
|
+
lastName?: string | null;
|
|
25
|
+
};
|
|
26
|
+
}>(FetchCustomerInfo, { customerAccessToken });
|
|
27
|
+
|
|
28
|
+
if (!data.customer) return null;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
"@id": data.customer.id,
|
|
32
|
+
email: data.customer.email ?? "",
|
|
33
|
+
givenName: data.customer.firstName ?? "",
|
|
34
|
+
familyName: data.customer.lastName ?? "",
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/mod.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopify app module — standard autoconfig contract.
|
|
3
|
+
*
|
|
4
|
+
* Exports `configure` following the AppModContract pattern.
|
|
5
|
+
* The framework's `autoconfigApps()` calls these generically.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import * as shopifyApp from "@decocms/apps/shopify/mod";
|
|
10
|
+
*
|
|
11
|
+
* const app = await shopifyApp.configure(blocks["deco-shopify"], resolveSecret);
|
|
12
|
+
* if (app) {
|
|
13
|
+
* // app.manifest, app.state are available
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AppDefinition, ResolveSecretFn } from "@decocms/apps-commerce/app-types";
|
|
19
|
+
import { configureShopify, type ShopifyConfig } from "./client";
|
|
20
|
+
import manifest from "./manifest.gen";
|
|
21
|
+
|
|
22
|
+
// -------------------------------------------------------------------------
|
|
23
|
+
// State
|
|
24
|
+
// -------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface ShopifyState {
|
|
27
|
+
config: ShopifyConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// -------------------------------------------------------------------------
|
|
31
|
+
// Configure
|
|
32
|
+
// -------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Configure the Shopify app from CMS block data.
|
|
36
|
+
* Returns an AppDefinition or null if required fields are missing.
|
|
37
|
+
*/
|
|
38
|
+
export async function configure(
|
|
39
|
+
block: Record<string, unknown>,
|
|
40
|
+
resolveSecret: ResolveSecretFn,
|
|
41
|
+
): Promise<AppDefinition<ShopifyState> | null> {
|
|
42
|
+
if (!block?.storeName) return null;
|
|
43
|
+
|
|
44
|
+
const storefrontAccessToken =
|
|
45
|
+
(await resolveSecret(block.storefrontAccessToken, "SHOPIFY_STOREFRONT_TOKEN")) ??
|
|
46
|
+
(typeof block.storefrontAccessToken === "string" ? block.storefrontAccessToken : null);
|
|
47
|
+
|
|
48
|
+
if (!storefrontAccessToken) return null;
|
|
49
|
+
|
|
50
|
+
const config: ShopifyConfig = {
|
|
51
|
+
storeName: block.storeName as string,
|
|
52
|
+
storefrontAccessToken,
|
|
53
|
+
publicUrl: block.publicUrl as string | undefined,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Bridge: maintain global singleton for backward compat
|
|
57
|
+
configureShopify(config);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
name: "shopify",
|
|
61
|
+
manifest,
|
|
62
|
+
state: { config },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Placeholder preview for CMS editor — evolves when admin supports it. */
|
|
67
|
+
export const preview = undefined;
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AppRegistryEntry } from "@decocms/apps-commerce/registry";
|
|
2
|
+
|
|
3
|
+
export const SHOPIFY_REGISTRY_ENTRY: AppRegistryEntry = {
|
|
4
|
+
blockKey: "deco-shopify",
|
|
5
|
+
module: () => import("./mod"),
|
|
6
|
+
displayName: "Shopify",
|
|
7
|
+
category: "commerce",
|
|
8
|
+
description: "Shopify Storefront API commerce integration",
|
|
9
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { extractGraphqlOperationName } from "../graphqlOperationName";
|
|
3
|
+
|
|
4
|
+
describe("extractGraphqlOperationName", () => {
|
|
5
|
+
it("returns the explicit name when provided, regardless of body content", () => {
|
|
6
|
+
expect(extractGraphqlOperationName("query Whatever { x }", "ForcedName")).toBe("ForcedName");
|
|
7
|
+
expect(extractGraphqlOperationName("", "Override")).toBe("Override");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("extracts a single named query", () => {
|
|
11
|
+
expect(
|
|
12
|
+
extractGraphqlOperationName("query ProductBySlug($slug: String!) { product { id } }"),
|
|
13
|
+
).toBe("ProductBySlug");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("extracts a single named mutation", () => {
|
|
17
|
+
expect(
|
|
18
|
+
extractGraphqlOperationName("mutation CartLinesAdd($cartId: ID!) { cartLinesAdd { } }"),
|
|
19
|
+
).toBe("CartLinesAdd");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("extracts a single named subscription", () => {
|
|
23
|
+
expect(extractGraphqlOperationName("subscription OrderEvents { orderUpdated { id } }")).toBe(
|
|
24
|
+
"OrderEvents",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns undefined for anonymous operations", () => {
|
|
29
|
+
expect(extractGraphqlOperationName("{ product { id } }")).toBeUndefined();
|
|
30
|
+
expect(extractGraphqlOperationName("query { product { id } }")).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns undefined when document has more than one named operation (caller must disambiguate)", () => {
|
|
34
|
+
const multi = `
|
|
35
|
+
query OpA { a }
|
|
36
|
+
query OpB { b }
|
|
37
|
+
`;
|
|
38
|
+
expect(extractGraphqlOperationName(multi)).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("ignores the words query/mutation/subscription inside string literals", () => {
|
|
42
|
+
const docWithStringy = `query RealName { thing(arg: "this query mutation subscription is a string") }`;
|
|
43
|
+
expect(extractGraphqlOperationName(docWithStringy)).toBe("RealName");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("ignores the words inside block strings (triple-quoted)", () => {
|
|
47
|
+
const doc = `
|
|
48
|
+
"""
|
|
49
|
+
This block string mentions query Inner and mutation Inner2.
|
|
50
|
+
"""
|
|
51
|
+
query OuterReal { x }
|
|
52
|
+
`;
|
|
53
|
+
expect(extractGraphqlOperationName(doc)).toBe("OuterReal");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ignores the words inside # comments", () => {
|
|
57
|
+
const doc = `
|
|
58
|
+
# query CommentedOut { x }
|
|
59
|
+
query Active { y }
|
|
60
|
+
`;
|
|
61
|
+
expect(extractGraphqlOperationName(doc)).toBe("Active");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns undefined on an empty / nullish body", () => {
|
|
65
|
+
expect(extractGraphqlOperationName("")).toBeUndefined();
|
|
66
|
+
expect(extractGraphqlOperationName(" \n\t ")).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles a real-world Shopify storefront query shape", () => {
|
|
70
|
+
const doc = `
|
|
71
|
+
query ProductDetails($handle: String!, $country: CountryCode!) @inContext(country: $country) {
|
|
72
|
+
product(handle: $handle) {
|
|
73
|
+
id
|
|
74
|
+
title
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
expect(extractGraphqlOperationName(doc)).toBe("ProductDetails");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the pre-wired Shopify fetch factory. Same wiring
|
|
3
|
+
* assertions as `vtex/utils/__tests__/instrumentedFetch.test.ts`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { configureMeter, type MeterAdapter } from "@decocms/blocks/sdk/observability";
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { createShopifyFetch } from "../instrumentedFetch";
|
|
9
|
+
|
|
10
|
+
type Labels = Record<string, string | number | boolean>;
|
|
11
|
+
|
|
12
|
+
function captureHistogram(): {
|
|
13
|
+
calls: { name: string; value: number; attrs: Labels }[];
|
|
14
|
+
meter: MeterAdapter;
|
|
15
|
+
} {
|
|
16
|
+
const calls: { name: string; value: number; attrs: Labels }[] = [];
|
|
17
|
+
const meter: MeterAdapter = {
|
|
18
|
+
counterInc: vi.fn(),
|
|
19
|
+
gaugeSet: vi.fn(),
|
|
20
|
+
histogramRecord: (name, value, attrs) => {
|
|
21
|
+
calls.push({ name, value, attrs: attrs ?? {} });
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return { calls, meter };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("createShopifyFetch", () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
configureMeter({
|
|
31
|
+
counterInc: () => {},
|
|
32
|
+
gaugeSet: () => {},
|
|
33
|
+
histogramRecord: () => {},
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("emits http.client.request.duration with provider=shopify on success", async () => {
|
|
38
|
+
const { calls, meter } = captureHistogram();
|
|
39
|
+
configureMeter(meter);
|
|
40
|
+
|
|
41
|
+
const baseFetch = vi.fn(async () => new Response("{}", { status: 200 }));
|
|
42
|
+
const fetchFn = createShopifyFetch({ baseFetch: baseFetch as typeof fetch });
|
|
43
|
+
|
|
44
|
+
await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { method: "POST" });
|
|
45
|
+
|
|
46
|
+
expect(calls).toHaveLength(1);
|
|
47
|
+
expect(calls[0].name).toBe("http.client.request.duration");
|
|
48
|
+
expect(calls[0].attrs).toMatchObject({
|
|
49
|
+
provider: "shopify",
|
|
50
|
+
operation: "storefront.graphql",
|
|
51
|
+
status_class: "2xx",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("honors init.operation (used by the GraphQL client to stamp <OperationName>)", async () => {
|
|
56
|
+
const { calls, meter } = captureHistogram();
|
|
57
|
+
configureMeter(meter);
|
|
58
|
+
|
|
59
|
+
const baseFetch = vi.fn(async () => new Response("{}", { status: 200 }));
|
|
60
|
+
const fetchFn = createShopifyFetch({ baseFetch: baseFetch as typeof fetch });
|
|
61
|
+
|
|
62
|
+
await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
operation: "ProductBySlug",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(calls[0].attrs.operation).toBe("ProductBySlug");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("skips histogram emission when disableHistogram is true", async () => {
|
|
71
|
+
const { calls, meter } = captureHistogram();
|
|
72
|
+
configureMeter(meter);
|
|
73
|
+
|
|
74
|
+
const baseFetch = vi.fn(async () => new Response("{}", { status: 200 }));
|
|
75
|
+
const fetchFn = createShopifyFetch({
|
|
76
|
+
baseFetch: baseFetch as typeof fetch,
|
|
77
|
+
disableHistogram: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { method: "POST" });
|
|
81
|
+
|
|
82
|
+
expect(calls).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shopifyOperationRouter } from "../operationRouter";
|
|
3
|
+
|
|
4
|
+
describe("shopifyOperationRouter", () => {
|
|
5
|
+
const store = "https://acme.myshopify.com";
|
|
6
|
+
|
|
7
|
+
it("recognizes the storefront GraphQL endpoint", () => {
|
|
8
|
+
expect(shopifyOperationRouter(`${store}/api/2025-04/graphql.json`, "POST")).toBe(
|
|
9
|
+
"storefront.graphql",
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("recognizes the admin GraphQL endpoint", () => {
|
|
14
|
+
expect(shopifyOperationRouter(`${store}/admin/api/2025-04/graphql.json`, "POST")).toBe(
|
|
15
|
+
"admin.graphql",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("maps admin REST product / order / customer / inventory endpoints", () => {
|
|
20
|
+
expect(shopifyOperationRouter(`${store}/admin/api/2025-04/products.json`, "GET")).toBe(
|
|
21
|
+
"admin.products",
|
|
22
|
+
);
|
|
23
|
+
expect(shopifyOperationRouter(`${store}/admin/api/2025-04/orders/123.json`, "GET")).toBe(
|
|
24
|
+
"admin.orders",
|
|
25
|
+
);
|
|
26
|
+
expect(shopifyOperationRouter(`${store}/admin/api/2025-04/customers.json`, "GET")).toBe(
|
|
27
|
+
"admin.customers",
|
|
28
|
+
);
|
|
29
|
+
expect(shopifyOperationRouter(`${store}/admin/api/2025-04/inventory_levels.json`, "GET")).toBe(
|
|
30
|
+
"admin.inventory",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("maps storefront checkout + cart endpoints", () => {
|
|
35
|
+
expect(shopifyOperationRouter(`${store}/api/2025-04/checkouts/abc.json`, "POST")).toBe(
|
|
36
|
+
"storefront.checkout",
|
|
37
|
+
);
|
|
38
|
+
expect(shopifyOperationRouter(`${store}/cart.js`, "GET")).toBe("storefront.cart");
|
|
39
|
+
expect(shopifyOperationRouter(`${store}/cart/add.js`, "POST")).toBe("storefront.cart");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns undefined for unrecognized paths", () => {
|
|
43
|
+
expect(shopifyOperationRouter(`${store}/random/path`, "GET")).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not throw on unparseable URLs", () => {
|
|
47
|
+
expect(shopifyOperationRouter("not-a-url", "GET")).toBeUndefined();
|
|
48
|
+
expect(shopifyOperationRouter("/api/2025-04/graphql.json", "POST")).toBe("storefront.graphql");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("is case-insensitive on method (no behavioral impact today; future-proofing)", () => {
|
|
52
|
+
expect(shopifyOperationRouter(`${store}/api/2025-04/graphql.json`, "post")).toBe(
|
|
53
|
+
"storefront.graphql",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|