diff --git a/src/mock/products.ts b/src/mock/products.ts index 8967d659..e27f7fd8 100644 --- a/src/mock/products.ts +++ b/src/mock/products.ts @@ -722,3 +722,96 @@ export const mockSimpleCardProduct: ShopifyProduct = { } ] } + +export const mockFetchProduct1: ShopifyProduct = { + id: "gid://shopify/Product/1", + title: "Product 1", + vendor: "Vendor 1", + description: "Description 1", + encodedVariantExistence: "", + onlineStoreUrl: "/products/product-1", + availableForSale: true, + adjacentVariants: [], + images: [ + { + altText: "Image 1", + height: 400, + width: 400, + thumbhash: null, + url: "https://example.com/image1.jpg" + } + ], + featuredImage: { + altText: "Image 1", + height: 400, + width: 400, + thumbhash: null, + url: "https://example.com/image1.jpg" + }, + options: [], + price: { currencyCode: "USD", amount: "10.00" }, + compareAtPrice: null, + variants: [] +} + +export const mockFetchProduct2: ShopifyProduct = { + id: "gid://shopify/Product/2", + title: "Product 2", + vendor: "Vendor 2", + description: "Description 2", + encodedVariantExistence: "", + onlineStoreUrl: "/products/product-2", + availableForSale: true, + adjacentVariants: [], + images: [ + { + altText: "Image 2", + height: 400, + width: 400, + thumbhash: null, + url: "https://example.com/image2.jpg" + } + ], + featuredImage: { + altText: "Image 2", + height: 400, + width: 400, + thumbhash: null, + url: "https://example.com/image2.jpg" + }, + options: [], + price: { currencyCode: "USD", amount: "20.00" }, + compareAtPrice: null, + variants: [] +} + +export const mockFetchProduct3: ShopifyProduct = { + id: "gid://shopify/Product/3", + title: "Product 3", + vendor: "Vendor 3", + description: "Description 3", + encodedVariantExistence: "", + onlineStoreUrl: "/products/product-3", + availableForSale: true, + adjacentVariants: [], + images: [ + { + altText: "Image 3", + height: 400, + width: 400, + thumbhash: null, + url: "https://example.com/image3.jpg" + } + ], + featuredImage: { + altText: "Image 3", + height: 400, + width: 400, + thumbhash: null, + url: "https://example.com/image3.jpg" + }, + options: [], + price: { currencyCode: "USD", amount: "30.00" }, + compareAtPrice: null, + variants: [] +} diff --git a/src/shopify/graphql/fetchProduct.ts b/src/shopify/graphql/fetchProduct.ts index c6e1314b..fd9f5f2d 100644 --- a/src/shopify/graphql/fetchProduct.ts +++ b/src/shopify/graphql/fetchProduct.ts @@ -1,9 +1,22 @@ import { flattenResponse } from "./utils" +import getProductsByHandles from "@/shopify/graphql/getProductsByHandles.graphql?raw" import getProductByHandle from "@/shopify/graphql/getProductByHandle.graphql?raw" import { getApiUrl } from "./constants" import { cached } from "@/utils/cached" +import { ShopifyProduct } from "./types" -export const [fetchProduct, clearProductCache] = cached(async (handle: string) => { +type PendingRequest = { + handle: string + resolve: (product: ShopifyProduct) => void + reject: (error: Error) => void +} + +const state = { + pendingRequests: new Map(), + scheduledFlush: null as number | null +} + +async function fetchSingle(handle: string): Promise { const response = await fetch(getApiUrl().href, { headers: { "Content-Type": "application/json" @@ -18,9 +31,110 @@ export const [fetchProduct, clearProductCache] = cached(async (handle: string) = } }) }) + if (!response.ok) { throw new Error(`Failed to fetch product data: ${response.status} ${response.statusText}`) } + const responseData = await response.json() + + if (!responseData.data?.product) { + throw new Error(`Product not found: ${handle}`) + } + return flattenResponse(responseData) -}) +} + +async function fetchBatch(handles: string[], requestsMap: Map) { + const queryString = handles.map(h => `handle:"${h}"`).join(" OR ") + + const response = await fetch(getApiUrl().href, { + headers: { + "Content-Type": "application/json" + }, + method: "POST", + body: JSON.stringify({ + query: getProductsByHandles, + variables: { + language: window.Shopify?.locale?.toUpperCase() || "EN", + country: window.Shopify?.country || "US", + query: queryString, + first: handles.length + } + }) + }) + + if (!response.ok) { + const error = new Error(`Failed to fetch product data: ${response.status} ${response.statusText}`) + Array.from(requestsMap.values()) + .flatMap(requests => requests) + .forEach(request => request.reject(error)) + return + } + + const responseData = await response.json() + + const productsByHandle = new Map() + if (responseData.data?.products?.nodes) { + responseData.data.products.nodes.forEach((productNode: ShopifyProduct & { handle: string }) => { + const product = flattenResponse({ data: { product: productNode } }) + productsByHandle.set(productNode.handle, product) + }) + } + + for (const handle of handles) { + const requests = requestsMap.get(handle) + if (!requests) continue + + const product = productsByHandle.get(handle) + if (product) { + requests.forEach(request => request.resolve(product)) + } else { + const error = new Error(`Product not found: ${handle}`) + requests.forEach(request => request.reject(error)) + } + } +} + +async function flush() { + state.scheduledFlush = null + + const handles = Array.from(state.pendingRequests.keys()) + const requestsMap = new Map(state.pendingRequests) + state.pendingRequests.clear() + + if (handles.length === 0) { + return + } + + try { + if (handles.length === 1) { + const handle = handles[0] + const requests = requestsMap.get(handle) + if (requests) { + const product = await fetchSingle(handle) + requests.forEach(request => request.resolve(product)) + } + } else { + await fetchBatch(handles, requestsMap) + } + } catch (error) { + Array.from(requestsMap.values()) + .flatMap(requests => requests) + .forEach(request => request.reject(error instanceof Error ? error : new Error(String(error)))) + } +} + +function requestProduct(handle: string): Promise { + return new Promise((resolve, reject) => { + const requests = state.pendingRequests.get(handle) || [] + requests.push({ handle, resolve, reject }) + state.pendingRequests.set(handle, requests) + + if (state.scheduledFlush === null) { + state.scheduledFlush = requestAnimationFrame(() => flush()) + } + }) +} + +export const [fetchProduct, clearProductCache] = cached(requestProduct) diff --git a/src/shopify/graphql/getProductByHandle.graphql b/src/shopify/graphql/getProductByHandle.graphql index 83384081..9acfe1bc 100644 --- a/src/shopify/graphql/getProductByHandle.graphql +++ b/src/shopify/graphql/getProductByHandle.graphql @@ -1,7 +1,12 @@ -query ProductByHandle($country: CountryCode!, $language: LanguageCode!, $handle: String!) +query ProductByHandle( + $country: CountryCode! + $language: LanguageCode! + $handle: String! +) @inContext(country: $country, language: $language) { product(handle: $handle) { id + handle title vendor description @@ -11,14 +16,18 @@ query ProductByHandle($country: CountryCode!, $language: LanguageCode!, $handle: images(first: 10) { nodes { url - width + altText height + width + thumbhash } } featuredImage { url - width + altText height + width + thumbhash } adjacentVariants { ...VariantFragment @@ -52,8 +61,10 @@ fragment VariantFragment on ProductVariant { id image { url - width + altText height + width + thumbhash } price { currencyCode diff --git a/src/shopify/graphql/getProductsByHandles.graphql b/src/shopify/graphql/getProductsByHandles.graphql new file mode 100644 index 00000000..ce881ae8 --- /dev/null +++ b/src/shopify/graphql/getProductsByHandles.graphql @@ -0,0 +1,88 @@ +query ProductsByHandles( + $country: CountryCode! + $language: LanguageCode! + $query: String! + $first: Int! +) +@inContext(country: $country, language: $language) { + products(first: $first, query: $query) { + nodes { + id + handle + title + vendor + description + encodedVariantExistence + onlineStoreUrl + availableForSale + images(first: 10) { + nodes { + url + altText + height + width + thumbhash + } + } + featuredImage { + url + altText + height + width + thumbhash + } + adjacentVariants { + ...VariantFragment + } + options(first: 50) { + name + optionValues { + firstSelectableVariant { + ...VariantFragment + } + name + swatch { + color + image { + alt + id + mediaContentType + previewImage { + url + } + } + } + } + } + } + } +} + +fragment VariantFragment on ProductVariant { + availableForSale + title + id + image { + url + altText + height + width + thumbhash + } + price { + currencyCode + amount + } + compareAtPrice { + currencyCode + amount + } + product { + id + onlineStoreUrl + } + selectedOptions { + name + value + } +} diff --git a/src/shopify/graphql/getProductsByIds.graphql b/src/shopify/graphql/getProductsByIds.graphql deleted file mode 100644 index d834d009..00000000 --- a/src/shopify/graphql/getProductsByIds.graphql +++ /dev/null @@ -1,50 +0,0 @@ -query ProductsByIds($country: CountryCode!, $ids: [ID!]!) @inContext(country: $country) { - nodes(ids: $ids) { - ... on Product { - id - title - vendor - description - encodedVariantExistence - onlineStoreUrl - availableForSale - images(first: 10) { - nodes { - altText - height - width - thumbhash - url - } - } - featuredImage { - altText - height - width - thumbhash - url - } - options(first: 50) { - name - optionValues { - name - swatch { - color - image { - alt - id - mediaContentType - previewImage { - url - width - height - altText - thumbhash - } - } - } - } - } - } - } -} \ No newline at end of file diff --git a/test/helpers/productHandlers.ts b/test/helpers/productHandlers.ts new file mode 100644 index 00000000..aa64c2d3 --- /dev/null +++ b/test/helpers/productHandlers.ts @@ -0,0 +1,71 @@ +import { addHandlers } from "../msw.setup" +import { http, HttpResponse } from "msw" +import type { ShopifyProduct } from "@/shopify/graphql/types" +import { getApiUrl } from "@/shopify/graphql/constants" + +export function addProductHandlers(products: (ShopifyProduct & { handle: string })[]) { + const graphqlPath = getApiUrl().pathname + const productsByHandle = products.reduce( + (acc, product) => { + const handle = product.handle + if (handle) { + acc[handle] = product + } + return acc + }, + {} as Record + ) + + addHandlers( + http.post(graphqlPath, async ({ request }) => { + const url = new URL(request.url) + const body = (await request.json()) as { + query: string + variables: { query?: string; handle?: string; first?: number; language?: string; country?: string } + } + + if (url.pathname === graphqlPath && body.variables.handle && !body.variables.query) { + const handle = body.variables.handle + const product = productsByHandle[handle] + + if (!product) { + return HttpResponse.json({ data: { product: null } }) + } + + return HttpResponse.json({ + data: { + product: { + ...product, + handle, + images: { nodes: product.images } + } + } + }) + } + + if (url.pathname === graphqlPath && body.variables.query) { + const handleMatches = body.variables.query.match(/handle:"([^"]+)"/g) + const requestedHandles = handleMatches ? handleMatches.map(m => m.replace(/handle:"([^"]+)"/, "$1")) : [] + + const nodes = requestedHandles + .map(handle => { + const product = productsByHandle[handle] + if (!product) { + return null + } + return { + ...product, + handle, + images: { nodes: product.images } + } + }) + .filter((p): p is NonNullable => p !== null) + + return HttpResponse.json({ data: { products: { nodes } } }) + } + + console.error("Unmatched GraphQL request:", { pathname: url.pathname, variables: body.variables, graphqlPath }) + return HttpResponse.json({ errors: [{ message: "Invalid query" }] }, { status: 400 }) + }) + ) +} diff --git a/test/shopify/fetchProduct.spec.tsx b/test/shopify/fetchProduct.spec.tsx new file mode 100644 index 00000000..33d60f8b --- /dev/null +++ b/test/shopify/fetchProduct.spec.tsx @@ -0,0 +1,289 @@ +/** @jsx createElement */ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest" +import { fetchProduct, clearProductCache } from "@/shopify/graphql/fetchProduct" +import { addHandlers } from "../msw.setup" +import { http, HttpResponse } from "msw" +import { getApiUrl } from "@/shopify/graphql/constants" +import type { ShopifyProduct } from "@/shopify/graphql/types" +import { mockFetchProduct1, mockFetchProduct2, mockFetchProduct3 } from "@/mock/products" + +describe("fetchProduct", () => { + beforeEach(() => { + clearProductCache() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function wrapProduct(product: ShopifyProduct, handle: string) { + return { + ...product, + handle, + images: { nodes: product.images } + } + } + + function setupBatchProductHandler(products: Record) { + const graphqlPath = getApiUrl().pathname + + addHandlers( + http.post(graphqlPath, async ({ request }) => { + const body = (await request.json()) as { + query: string + variables: { query?: string; handle?: string; first?: number } + } + + if (body.query.includes("ProductByHandle") && body.variables.handle) { + const handle = body.variables.handle + const product = products[handle] + + if (!product) { + return HttpResponse.json({ data: { product: null } }) + } + + return HttpResponse.json({ + data: { + product: wrapProduct(product, handle) + } + }) + } + + if (body.query.includes("ProductsByHandles") && body.variables.query) { + const handleMatches = body.variables.query.match(/handle:"([^"]+)"/g) + const requestedHandles = handleMatches ? handleMatches.map(m => m.replace(/handle:"([^"]+)"/, "$1")) : [] + + const nodes = requestedHandles + .map(handle => (products[handle] ? wrapProduct(products[handle], handle) : null)) + .filter((p): p is ReturnType => p !== null) + + return HttpResponse.json({ data: { products: { nodes } } }) + } + + return HttpResponse.json({ errors: [{ message: "Invalid query" }] }, { status: 400 }) + }) + ) + } + + it("should fetch a single product", async () => { + setupBatchProductHandler({ + "product-1": mockFetchProduct1 + }) + + const product = await fetchProduct("product-1") + + expect(product.id).toBe("gid://shopify/Product/1") + expect(product.title).toBe("Product 1") + }) + + it("should batch multiple product requests into a single GraphQL call", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + + setupBatchProductHandler({ + "product-1": mockFetchProduct1, + "product-2": mockFetchProduct2, + "product-3": mockFetchProduct3 + }) + + // Request multiple products at the same time + const promises = [fetchProduct("product-1"), fetchProduct("product-2"), fetchProduct("product-3")] + + const [product1, product2, product3] = await Promise.all(promises) + + // Verify products were fetched correctly + expect(product1.title).toBe("Product 1") + expect(product2.title).toBe("Product 2") + expect(product3.title).toBe("Product 3") + + // Wait for any pending requests to complete + await new Promise(resolve => setTimeout(resolve, 50)) + + // Verify only one fetch call was made (batch request) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + // Verify the batch query was used + const fetchCall = fetchSpy.mock.calls[0] + const requestBody = JSON.parse(fetchCall[1]?.body as string) + expect(requestBody.query).toContain("ProductsByHandles") + }) + + it("should deduplicate requests for the same product handle", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + + setupBatchProductHandler({ + "product-1": mockFetchProduct1 + }) + + // Request the same product multiple times + const promises = [fetchProduct("product-1"), fetchProduct("product-1"), fetchProduct("product-1")] + + const [product1, product2, product3] = await Promise.all(promises) + + // All should return the same product + expect(product1.title).toBe("Product 1") + expect(product2.title).toBe("Product 1") + expect(product3.title).toBe("Product 1") + + // Wait for any pending requests + await new Promise(resolve => setTimeout(resolve, 50)) + + // Should only make one request even with deduplication + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it("should handle single product request using single product query", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + + setupBatchProductHandler({ + "product-1": mockFetchProduct1 + }) + + const product = await fetchProduct("product-1") + + expect(product.title).toBe("Product 1") + + // Wait for any pending requests + await new Promise(resolve => setTimeout(resolve, 50)) + + // Should use single product query for single product + expect(fetchSpy).toHaveBeenCalledTimes(1) + const fetchCall = fetchSpy.mock.calls[0] + const requestBody = JSON.parse(fetchCall[1]?.body as string) + expect(requestBody.query).toContain("ProductByHandle") + }) + + it("should use cache for subsequent requests", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + + setupBatchProductHandler({ + "product-1": mockFetchProduct1 + }) + + // First request + const product1 = await fetchProduct("product-1") + expect(product1.title).toBe("Product 1") + + // Second request should use cache + const product2 = await fetchProduct("product-1") + expect(product2.title).toBe("Product 1") + + // Wait for any pending requests + await new Promise(resolve => setTimeout(resolve, 50)) + + // Should only fetch once due to caching + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it("should handle errors for individual products in batch", async () => { + setupBatchProductHandler({ + "product-1": mockFetchProduct1, + "product-2": mockFetchProduct2 + // product-3 missing - will return null from server + }) + + const promises = [fetchProduct("product-1"), fetchProduct("product-2"), fetchProduct("product-3")] + + // First two should succeed, third should fail + const results = await Promise.allSettled(promises) + + expect(results[0].status).toBe("fulfilled") + expect((results[0] as PromiseFulfilledResult).value.title).toBe("Product 1") + + expect(results[1].status).toBe("fulfilled") + expect((results[1] as PromiseFulfilledResult).value.title).toBe("Product 2") + + expect(results[2].status).toBe("rejected") + expect((results[2] as PromiseRejectedResult).reason.message).toContain("Product not found") + }) + + it("should handle network errors", async () => { + const graphqlPath = getApiUrl().pathname + + addHandlers( + http.post(graphqlPath, () => { + return HttpResponse.json({ error: "Network error" }, { status: 500 }) + }) + ) + + await expect(fetchProduct("product-1")).rejects.toThrow("Failed to fetch product data") + }) + + it("should batch requests made in separate frames differently", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + + setupBatchProductHandler({ + "product-1": mockFetchProduct1, + "product-2": mockFetchProduct2 + }) + + // First batch + const product1Promise = fetchProduct("product-1") + + // Wait for the first batch to complete + await product1Promise + await new Promise(resolve => setTimeout(resolve, 50)) + + // Second batch in a different frame + const product2Promise = fetchProduct("product-2") + await product2Promise + + // Should have made two separate requests (one for each frame) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + it("should handle up to 10 products in a single batch", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + + const products: Record = {} + for (let i = 1; i <= 10; i++) { + products[`product-${i}`] = { + ...mockFetchProduct1, + id: `gid://shopify/Product/${i}`, + title: `Product ${i}` + } + } + + setupBatchProductHandler(products) + + const promises = Object.keys(products).map(handle => fetchProduct(handle)) + const results = await Promise.all(promises) + + // Verify all products were fetched + expect(results).toHaveLength(10) + for (let i = 0; i < 10; i++) { + expect(results[i].title).toBe(`Product ${i + 1}`) + } + + // Wait for any pending requests + await new Promise(resolve => setTimeout(resolve, 50)) + + // Should make only one batch request + expect(fetchSpy).toHaveBeenCalledTimes(1) + const fetchCall = fetchSpy.mock.calls[0] + const requestBody = JSON.parse(fetchCall[1]?.body as string) + expect(requestBody.query).toContain("ProductsByHandles") + }) + + it("should clear cache when clearProductCache is called", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + + setupBatchProductHandler({ + "product-1": mockFetchProduct1 + }) + + // First request + await fetchProduct("product-1") + + // Clear cache + clearProductCache() + + // Second request should fetch again + await fetchProduct("product-1") + + // Wait for any pending requests + await new Promise(resolve => setTimeout(resolve, 50)) + + // Should fetch twice + expect(fetchSpy.mock.calls.length).toBeGreaterThanOrEqual(2) + }) +})