From df63b0552d14f00d44feea3b3f65cf990598a9b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:41:04 +0000 Subject: [PATCH 1/7] Initial plan From 2a132b15cc8c38f4ee77638161335d14a66e42c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:54:51 +0000 Subject: [PATCH 2/7] feat(fetchProduct): implement request batching with smart query selection - Add batching mechanism using requestAnimationFrame to collect product requests - Create new GraphQL query `getProductsByHandles.graphql` for batch fetching - Update `getProductByHandle.graphql` with additional fields (handle, image metadata) - Implement smart query selection (single vs batch) - Add comprehensive test suite with 10 new tests for batching functionality - Create reusable test helper `test/helpers/productHandlers.ts` - Remove unused `getProductsByIds.graphql` file - All 376 tests passing with full lint and typecheck compliance Co-authored-by: timowestnosto <13622115+timowestnosto@users.noreply.github.com> --- src/shopify/graphql/fetchProduct.ts | 126 +++++- .../graphql/getProductByHandle.graphql | 19 +- .../graphql/getProductsByHandles.graphql | 88 ++++ src/shopify/graphql/getProductsByIds.graphql | 50 --- test/helpers/productHandlers.ts | 76 ++++ test/shopify/fetchProduct.spec.tsx | 384 ++++++++++++++++++ 6 files changed, 687 insertions(+), 56 deletions(-) create mode 100644 src/shopify/graphql/getProductsByHandles.graphql delete mode 100644 src/shopify/graphql/getProductsByIds.graphql create mode 100644 test/helpers/productHandlers.ts create mode 100644 test/shopify/fetchProduct.spec.tsx diff --git a/src/shopify/graphql/fetchProduct.ts b/src/shopify/graphql/fetchProduct.ts index c6e1314b..1a5ca9f5 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,118 @@ 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) { + // Build query string to match products by handle + 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() + + // Create a map of handle to product + const productsByHandle = new Map() + if (responseData.data?.products?.nodes) { + responseData.data.products.nodes.forEach((productNode: { handle: string }) => { + const product = flattenResponse({ data: { product: productNode } }) + productsByHandle.set(productNode.handle, product) + }) + } + + // Resolve or reject each request + 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 + + // Get unique handles from pending requests + const handles = Array.from(state.pendingRequests.keys()) + const requestsMap = new Map(state.pendingRequests) + state.pendingRequests.clear() + + if (handles.length === 0) { + return + } + + try { + // Use fetchSingle for single product requests + 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) { + // Reject all pending requests with the 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) => { + // Get or create array for this handle + const requests = state.pendingRequests.get(handle) || [] + requests.push({ handle, resolve, reject }) + state.pendingRequests.set(handle, requests) + + // Schedule flush if not already scheduled + 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..ffcabb42 --- /dev/null +++ b/test/helpers/productHandlers.ts @@ -0,0 +1,76 @@ +import { addHandlers } from "../msw.setup" +import { http, HttpResponse } from "msw" +import type { ShopifyProduct } from "@/shopify/graphql/types" +import { getApiUrl } from "@/shopify/graphql/constants" + +type ProductWithHandle = ShopifyProduct & { handle: string } + +export function addProductHandlers(products: (ShopifyProduct & { handle: string })[]) { + const graphqlPath = getApiUrl().pathname + const productsByHandle = products.reduce( + (acc, product) => { + const handle = (product as ProductWithHandle).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 } + } + + // Check if this is a single product query + 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 } + } + } + }) + } + + // Check if this is a batch query with products query + if (url.pathname === graphqlPath && body.variables.query) { + // Parse the query string to extract handles + const handleMatches = body.variables.query.match(/handle:([^\s)]+)/g) + const requestedHandles = handleMatches ? handleMatches.map(m => m.replace("handle:", "")) : [] + + 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..472a767e --- /dev/null +++ b/test/shopify/fetchProduct.spec.tsx @@ -0,0 +1,384 @@ +/** @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" + +describe("fetchProduct", () => { + beforeEach(() => { + clearProductCache() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mockProduct1: 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: [] + } + + const mockProduct2: 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: [] + } + + const mockProduct3: 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: [] + } + + 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 } + } + + // Check if this is a single product query + 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) + } + }) + } + + // Check if this is a batch query with products query + if (body.query.includes("ProductsByHandles") && body.variables.query) { + // Parse the query string to extract handles + const handleMatches = body.variables.query.match(/handle:([^\s)]+)/g) + const requestedHandles = handleMatches ? handleMatches.map(m => m.replace("handle:", "")) : [] + + 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": mockProduct1 + }) + + 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": mockProduct1, + "product-2": mockProduct2, + "product-3": mockProduct3 + }) + + // 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": mockProduct1 + }) + + // 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": mockProduct1 + }) + + 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": mockProduct1 + }) + + // 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": mockProduct1, + "product-2": mockProduct2 + // 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": mockProduct1, + "product-2": mockProduct2 + }) + + // 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}`] = { + ...mockProduct1, + 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": mockProduct1 + }) + + // 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) + }) +}) From 8d289ebf00aa25bb9cdc2ea70f52a58d9bd94362 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:53:58 +0000 Subject: [PATCH 3/7] refactor(test): move mock products to centralized mock/products.ts Move mockProduct1, mockProduct2, and mockProduct3 from fetchProduct.spec.tsx to src/mock/products.ts as mockFetchProduct1, mockFetchProduct2, and mockFetchProduct3. This follows the testing conventions documented in testing.instructions.md. Co-authored-by: timowestnosto <13622115+timowestnosto@users.noreply.github.com> --- src/mock/products.ts | 93 ++++++++++++++++++++++ test/shopify/fetchProduct.spec.tsx | 120 ++++------------------------- 2 files changed, 107 insertions(+), 106 deletions(-) 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/test/shopify/fetchProduct.spec.tsx b/test/shopify/fetchProduct.spec.tsx index 472a767e..6bc2f70f 100644 --- a/test/shopify/fetchProduct.spec.tsx +++ b/test/shopify/fetchProduct.spec.tsx @@ -5,6 +5,7 @@ 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(() => { @@ -15,99 +16,6 @@ describe("fetchProduct", () => { vi.restoreAllMocks() }) - const mockProduct1: 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: [] - } - - const mockProduct2: 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: [] - } - - const mockProduct3: 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: [] - } - function wrapProduct(product: ShopifyProduct, handle: string) { return { ...product, @@ -162,7 +70,7 @@ describe("fetchProduct", () => { it("should fetch a single product", async () => { setupBatchProductHandler({ - "product-1": mockProduct1 + "product-1": mockFetchProduct1 }) const product = await fetchProduct("product-1") @@ -175,9 +83,9 @@ describe("fetchProduct", () => { const fetchSpy = vi.spyOn(global, "fetch") setupBatchProductHandler({ - "product-1": mockProduct1, - "product-2": mockProduct2, - "product-3": mockProduct3 + "product-1": mockFetchProduct1, + "product-2": mockFetchProduct2, + "product-3": mockFetchProduct3 }) // Request multiple products at the same time @@ -206,7 +114,7 @@ describe("fetchProduct", () => { const fetchSpy = vi.spyOn(global, "fetch") setupBatchProductHandler({ - "product-1": mockProduct1 + "product-1": mockFetchProduct1 }) // Request the same product multiple times @@ -230,7 +138,7 @@ describe("fetchProduct", () => { const fetchSpy = vi.spyOn(global, "fetch") setupBatchProductHandler({ - "product-1": mockProduct1 + "product-1": mockFetchProduct1 }) const product = await fetchProduct("product-1") @@ -251,7 +159,7 @@ describe("fetchProduct", () => { const fetchSpy = vi.spyOn(global, "fetch") setupBatchProductHandler({ - "product-1": mockProduct1 + "product-1": mockFetchProduct1 }) // First request @@ -271,8 +179,8 @@ describe("fetchProduct", () => { it("should handle errors for individual products in batch", async () => { setupBatchProductHandler({ - "product-1": mockProduct1, - "product-2": mockProduct2 + "product-1": mockFetchProduct1, + "product-2": mockFetchProduct2 // product-3 missing - will return null from server }) @@ -307,8 +215,8 @@ describe("fetchProduct", () => { const fetchSpy = vi.spyOn(global, "fetch") setupBatchProductHandler({ - "product-1": mockProduct1, - "product-2": mockProduct2 + "product-1": mockFetchProduct1, + "product-2": mockFetchProduct2 }) // First batch @@ -332,7 +240,7 @@ describe("fetchProduct", () => { const products: Record = {} for (let i = 1; i <= 10; i++) { products[`product-${i}`] = { - ...mockProduct1, + ...mockFetchProduct1, id: `gid://shopify/Product/${i}`, title: `Product ${i}` } @@ -363,7 +271,7 @@ describe("fetchProduct", () => { const fetchSpy = vi.spyOn(global, "fetch") setupBatchProductHandler({ - "product-1": mockProduct1 + "product-1": mockFetchProduct1 }) // First request From d536b70f60c620e09b25453e70a73363dfd6cca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20Westk=C3=A4mper?= Date: Thu, 20 Nov 2025 19:56:50 +0200 Subject: [PATCH 4/7] Update test/helpers/productHandlers.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/helpers/productHandlers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/productHandlers.ts b/test/helpers/productHandlers.ts index ffcabb42..3ca34a07 100644 --- a/test/helpers/productHandlers.ts +++ b/test/helpers/productHandlers.ts @@ -3,13 +3,13 @@ import { http, HttpResponse } from "msw" import type { ShopifyProduct } from "@/shopify/graphql/types" import { getApiUrl } from "@/shopify/graphql/constants" -type ProductWithHandle = ShopifyProduct & { handle: string } + export function addProductHandlers(products: (ShopifyProduct & { handle: string })[]) { const graphqlPath = getApiUrl().pathname const productsByHandle = products.reduce( (acc, product) => { - const handle = (product as ProductWithHandle).handle + const handle = product.handle if (handle) { acc[handle] = product } From 00449babdaf9b4b8f0039281780ea3fc36606f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20Westk=C3=A4mper?= Date: Thu, 20 Nov 2025 19:58:05 +0200 Subject: [PATCH 5/7] Update src/shopify/graphql/fetchProduct.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/shopify/graphql/fetchProduct.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shopify/graphql/fetchProduct.ts b/src/shopify/graphql/fetchProduct.ts index 1a5ca9f5..d08126d1 100644 --- a/src/shopify/graphql/fetchProduct.ts +++ b/src/shopify/graphql/fetchProduct.ts @@ -78,7 +78,7 @@ async function fetchBatch(handles: string[], requestsMap: Map() if (responseData.data?.products?.nodes) { - responseData.data.products.nodes.forEach((productNode: { handle: string }) => { + responseData.data.products.nodes.forEach((productNode: ShopifyProduct) => { const product = flattenResponse({ data: { product: productNode } }) productsByHandle.set(productNode.handle, product) }) From 22457f954929288053a4cdac7ea0f50c24000970 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:09:59 +0000 Subject: [PATCH 6/7] refactor: remove unnecessary comments and improve type safety - Remove inline comments that restate code logic per coding guidelines - Improve type annotation for productNode to include handle field - All tests passing with improved code clarity Co-authored-by: timowestnosto <13622115+timowestnosto@users.noreply.github.com> --- src/shopify/graphql/fetchProduct.ts | 10 +--------- test/helpers/productHandlers.ts | 5 ----- test/shopify/fetchProduct.spec.tsx | 3 --- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/shopify/graphql/fetchProduct.ts b/src/shopify/graphql/fetchProduct.ts index d08126d1..eb10faec 100644 --- a/src/shopify/graphql/fetchProduct.ts +++ b/src/shopify/graphql/fetchProduct.ts @@ -46,7 +46,6 @@ async function fetchSingle(handle: string): Promise { } async function fetchBatch(handles: string[], requestsMap: Map) { - // Build query string to match products by handle const queryString = handles.map(h => `handle:${h}`).join(" OR ") const response = await fetch(getApiUrl().href, { @@ -75,16 +74,14 @@ async function fetchBatch(handles: string[], requestsMap: Map() if (responseData.data?.products?.nodes) { - responseData.data.products.nodes.forEach((productNode: ShopifyProduct) => { + responseData.data.products.nodes.forEach((productNode: ShopifyProduct & { handle: string }) => { const product = flattenResponse({ data: { product: productNode } }) productsByHandle.set(productNode.handle, product) }) } - // Resolve or reject each request for (const handle of handles) { const requests = requestsMap.get(handle) if (!requests) continue @@ -102,7 +99,6 @@ async function fetchBatch(handles: string[], requestsMap: Map requests) .forEach(request => request.reject(error instanceof Error ? error : new Error(String(error)))) @@ -133,12 +127,10 @@ async function flush() { function requestProduct(handle: string): Promise { return new Promise((resolve, reject) => { - // Get or create array for this handle const requests = state.pendingRequests.get(handle) || [] requests.push({ handle, resolve, reject }) state.pendingRequests.set(handle, requests) - // Schedule flush if not already scheduled if (state.scheduledFlush === null) { state.scheduledFlush = requestAnimationFrame(() => flush()) } diff --git a/test/helpers/productHandlers.ts b/test/helpers/productHandlers.ts index 3ca34a07..527be691 100644 --- a/test/helpers/productHandlers.ts +++ b/test/helpers/productHandlers.ts @@ -3,8 +3,6 @@ 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( @@ -26,7 +24,6 @@ export function addProductHandlers(products: (ShopifyProduct & { handle: string variables: { query?: string; handle?: string; first?: number; language?: string; country?: string } } - // Check if this is a single product query if (url.pathname === graphqlPath && body.variables.handle && !body.variables.query) { const handle = body.variables.handle const product = productsByHandle[handle] @@ -46,9 +43,7 @@ export function addProductHandlers(products: (ShopifyProduct & { handle: string }) } - // Check if this is a batch query with products query if (url.pathname === graphqlPath && body.variables.query) { - // Parse the query string to extract handles const handleMatches = body.variables.query.match(/handle:([^\s)]+)/g) const requestedHandles = handleMatches ? handleMatches.map(m => m.replace("handle:", "")) : [] diff --git a/test/shopify/fetchProduct.spec.tsx b/test/shopify/fetchProduct.spec.tsx index 6bc2f70f..29b24ba2 100644 --- a/test/shopify/fetchProduct.spec.tsx +++ b/test/shopify/fetchProduct.spec.tsx @@ -34,7 +34,6 @@ describe("fetchProduct", () => { variables: { query?: string; handle?: string; first?: number } } - // Check if this is a single product query if (body.query.includes("ProductByHandle") && body.variables.handle) { const handle = body.variables.handle const product = products[handle] @@ -50,9 +49,7 @@ describe("fetchProduct", () => { }) } - // Check if this is a batch query with products query if (body.query.includes("ProductsByHandles") && body.variables.query) { - // Parse the query string to extract handles const handleMatches = body.variables.query.match(/handle:([^\s)]+)/g) const requestedHandles = handleMatches ? handleMatches.map(m => m.replace("handle:", "")) : [] From 5f4fdd018432a200125a4e4c8ed7119fb9450d38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 19:30:56 +0000 Subject: [PATCH 7/7] fix(fetchProduct): use phrase queries for handle search Wrap handles in quotes to create phrase queries (handle:"product-name") instead of token queries (handle:product-name). This ensures exact matching for handles with special characters like hyphens, spaces, etc. Updated regex patterns in test helpers to match quoted handle syntax. Co-authored-by: timowestnosto <13622115+timowestnosto@users.noreply.github.com> --- src/shopify/graphql/fetchProduct.ts | 2 +- test/helpers/productHandlers.ts | 4 ++-- test/shopify/fetchProduct.spec.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/shopify/graphql/fetchProduct.ts b/src/shopify/graphql/fetchProduct.ts index eb10faec..fd9f5f2d 100644 --- a/src/shopify/graphql/fetchProduct.ts +++ b/src/shopify/graphql/fetchProduct.ts @@ -46,7 +46,7 @@ async function fetchSingle(handle: string): Promise { } async function fetchBatch(handles: string[], requestsMap: Map) { - const queryString = handles.map(h => `handle:${h}`).join(" OR ") + const queryString = handles.map(h => `handle:"${h}"`).join(" OR ") const response = await fetch(getApiUrl().href, { headers: { diff --git a/test/helpers/productHandlers.ts b/test/helpers/productHandlers.ts index 527be691..aa64c2d3 100644 --- a/test/helpers/productHandlers.ts +++ b/test/helpers/productHandlers.ts @@ -44,8 +44,8 @@ export function addProductHandlers(products: (ShopifyProduct & { handle: string } if (url.pathname === graphqlPath && body.variables.query) { - const handleMatches = body.variables.query.match(/handle:([^\s)]+)/g) - const requestedHandles = handleMatches ? handleMatches.map(m => m.replace("handle:", "")) : [] + const handleMatches = body.variables.query.match(/handle:"([^"]+)"/g) + const requestedHandles = handleMatches ? handleMatches.map(m => m.replace(/handle:"([^"]+)"/, "$1")) : [] const nodes = requestedHandles .map(handle => { diff --git a/test/shopify/fetchProduct.spec.tsx b/test/shopify/fetchProduct.spec.tsx index 29b24ba2..33d60f8b 100644 --- a/test/shopify/fetchProduct.spec.tsx +++ b/test/shopify/fetchProduct.spec.tsx @@ -50,8 +50,8 @@ describe("fetchProduct", () => { } if (body.query.includes("ProductsByHandles") && body.variables.query) { - const handleMatches = body.variables.query.match(/handle:([^\s)]+)/g) - const requestedHandles = handleMatches ? handleMatches.map(m => m.replace("handle:", "")) : [] + 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))