diff --git a/.changeset/sample-products-before-connection.md b/.changeset/sample-products-before-connection.md new file mode 100644 index 00000000..2d60aa06 --- /dev/null +++ b/.changeset/sample-products-before-connection.md @@ -0,0 +1,6 @@ +--- +"@godaddy/gd-commerce-storefront": minor +"@godaddy/gd-commerce-server": minor +--- + +Support storefronts that are not connected to a store yet. `Catalog sampleProducts` shows built-in sample products and a local sample cart only while the server reports `unbound`, and the components switch to the connected store automatically, without source changes. Unknown state, empty live catalogs, and failures never show samples. Add optional `CommerceConfiguration.readConnectionState()` to the server config contract, and reject live operations until the state is `ready`. Existing successful config responses and live cart behavior are unchanged. diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md index eb867c1f..c2abc97b 100644 --- a/packages/commerce-server/README.md +++ b/packages/commerce-server/README.md @@ -57,3 +57,24 @@ Configure `checkoutReturnUrls` on the router using trusted deployment settings. Without this policy, HTTP checkout returns 503 before creating a session. Invalid request destinations return 400. This applies to both router presets that expose checkout. Trusted in-process callers of `createCheckoutSession()` own their return URLs and must construct or validate them server-side. A return from hosted checkout is not proof of payment. The order-status route uses the authorized Orders REST API, which supports completed orders, and returns its payment status (for example `PAID` or `PENDING`; `unknown` if absent). The server OAuth client must be granted `commerce.order:read`. The helper verifies the returned order ID, store, and channel and returns a limited summary without customer contact data. Hosts must authenticate callers and authorize access to each requested order before exposing this route. + +### Connection state + +Hosts can implement the optional synchronous `CommerceConfiguration.readConnectionState()` +method. Its return type is exported as `CommerceConnectionState` (`unbound | connecting | ready`). +Returning `undefined` keeps the existing behavior. Throw when the state is unknown or cannot be +read. The default environment configuration does not implement this method, so missing +credentials never count as `unbound`. + +`GET /config` returns `{ state: 'unbound' }` or `{ state: 'connecting' }` without reading store +credentials. `ready` includes the usual opaque `cartScope` and `currencyCode` after `read()` +succeeds. Without this method, the successful response shape is unchanged. All responses are +uncached; a malformed state or unavailable configuration returns 503. Data and checkout routes, +and the in-process checkout and order helpers, reject any state other than `ready` before +upstream calls. + +The host owns the connection lifecycle. Report `ready` only after the store connection is +complete. Lost configuration is an error, never evidence of `unbound`. After a store connects, +report `unbound` again only after it is deliberately disconnected. The storefront package owns +the generic sample products and local sample cart; this server never creates sample Commerce +records or simulates checkout. diff --git a/packages/commerce-server/src/index.ts b/packages/commerce-server/src/index.ts index fd69b46b..f7e8aac8 100644 --- a/packages/commerce-server/src/index.ts +++ b/packages/commerce-server/src/index.ts @@ -7,6 +7,7 @@ export type { CheckoutReturnUrls } from './lib/commerce/checkout-return-urls'; export { type CommerceConfig, type CommerceConfiguration, + type CommerceConnectionState, createRuntimeCommerceConfiguration, type RuntimeCommerceConfigurationOptions, readCommerceConfig, diff --git a/packages/commerce-server/src/lib/commerce/config.ts b/packages/commerce-server/src/lib/commerce/config.ts index 19381526..e7a29654 100644 --- a/packages/commerce-server/src/lib/commerce/config.ts +++ b/packages/commerce-server/src/lib/commerce/config.ts @@ -22,7 +22,11 @@ export interface CommerceConfig { owner?: string; } +export type CommerceConnectionState = 'unbound' | 'connecting' | 'ready'; + export interface CommerceConfiguration { + /** Authoritative host state. Missing support preserves legacy live/error behavior. */ + readConnectionState?(): CommerceConnectionState | undefined; read(): CommerceConfig; readCheckout(): CommerceCheckoutConfiguration; } @@ -105,5 +109,17 @@ export function commerceConfigurationForResponse(res: Response): CommerceConfigu } export function readCommerceConfigForResponse(res: Response): CommerceConfig { - return commerceConfigurationForResponse(res).read(); + const configuration = commerceConfigurationForResponse(res); + assertCommerceConnectionReady(configuration); + return configuration.read(); +} + +/** Nonready and invalid connection states must never use leftover credentials. */ +export function assertCommerceConnectionReady(configuration: CommerceConfiguration): void { + const state = configuration.readConnectionState?.(); + if (state !== undefined && state !== 'ready') { + throw new Error( + 'Commerce store connection is not ready. Complete the store connection before continuing.', + ); + } } diff --git a/packages/commerce-server/src/lib/commerce/create-checkout-session.ts b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts index a57e8bbc..6cb9d36d 100644 --- a/packages/commerce-server/src/lib/commerce/create-checkout-session.ts +++ b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts @@ -25,7 +25,11 @@ import { DEFAULT_CHECKOUT_PAYMENT_METHODS, getOAuthAccessToken, } from './checkout-subgraph'; -import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config'; +import { + assertCommerceConnectionReady, + type CommerceConfiguration, + createRuntimeCommerceConfiguration, +} from './config'; import { gqlRequest } from './gql'; export interface CreateCheckoutSessionParams { @@ -175,6 +179,7 @@ export async function createCheckoutSession( throw new Error('createCheckoutSession: exactly one of draftOrderId, skuId, or lineItemData is required'); } + assertCommerceConnectionReady(configuration); const { storeId, channelId, diff --git a/packages/commerce-server/src/lib/commerce/get-order-status.ts b/packages/commerce-server/src/lib/commerce/get-order-status.ts index a01c66a3..a6e8b57c 100644 --- a/packages/commerce-server/src/lib/commerce/get-order-status.ts +++ b/packages/commerce-server/src/lib/commerce/get-order-status.ts @@ -3,7 +3,11 @@ * Unlike the storefront cart API, this endpoint includes completed orders. */ import { authorizationHeaders, getOAuthAccessToken } from './checkout-subgraph'; -import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config'; +import { + assertCommerceConnectionReady, + type CommerceConfiguration, + createRuntimeCommerceConfiguration, +} from './config'; import type { Money } from './gql'; export interface CommerceOrderStatus { @@ -43,6 +47,7 @@ export async function getOrderStatus( throw new Error('getOrderStatus: a valid orderId is required'); } + assertCommerceConnectionReady(configuration); const { storeId, channelId, clientId, clientSecret, apiBaseUrl, currencyCode } = configuration.read(); const token = await getOAuthAccessToken({ clientId, diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts index bcd07e95..266647fb 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -380,3 +380,85 @@ describe('Commerce router mounting', (): void => { } }); }); + +describe('host-reported connection state', () => { + it.each(['unbound', 'connecting'] as const)( + 'reports %s without credentials or upstream calls', + async (state) => { + const res = response(); + const read = vi.fn(() => { + throw new Error('No credentials'); + }); + res.locals.commerceConfiguration = { ...configuration, read, readConnectionState: () => state }; + await configHandler({} as Request, res as unknown as Response); + expect(res.json).toHaveBeenCalledWith({ state }); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(read).not.toHaveBeenCalled(); + }, + ); + + it('retains legacy config and validates an explicitly ready connection', async () => { + for (const state of [undefined, 'ready'] as const) { + const res = response(); + res.locals.commerceConfiguration = { ...configuration, readConnectionState: () => state }; + await configHandler({} as Request, res as unknown as Response); + expect(res.json).toHaveBeenCalledWith({ + ...(state ? { state } : {}), + cartScope: getCommerceCartScope(binding), + currencyCode: 'USD', + }); + } + const res = response(); + res.locals.commerceConfiguration = { + ...configuration, + readConnectionState: () => 'ready', + read: () => { + throw new Error('Lost configuration'); + }, + }; + await configHandler({} as Request, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(503); + }); + + it.each(['invalid', 'throws'])('rejects %s host state rather than granting samples', async (state) => { + const res = response(); + res.locals.commerceConfiguration = { + ...configuration, + readConnectionState: () => { + if (state === 'throws') throw new Error('Host read failed'); + return state as 'ready'; + }, + }; + await configHandler({} as Request, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(503); + }); + + it.each([ + createCart, + readCart, + addItem, + updateItem, + deleteItem, + applyDiscount, + checkout, + readProducts, + readProduct, + readSku, + ])('blocks nonready handlers even with leftover credentials', async (handler) => { + vi.clearAllMocks(); + const res = response(); + res.locals.commerceConfiguration = { ...configuration, readConnectionState: () => 'connecting' }; + await handler( + { + headers: {}, + query: {}, + params: { id: 'cart-1', itemId: 'item-1' }, + body: { skuId: 'sku-1', name: 'Product', quantity: 1 }, + } as unknown as Request, + res as unknown as Response, + ); + expect(res.status).toHaveBeenCalled(); + expect(gqlRequest).not.toHaveBeenCalled(); + expect(createCheckoutSession).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/commerce-server/src/server/api/commerce/checkout/POST.ts b/packages/commerce-server/src/server/api/commerce/checkout/POST.ts index 42dd56c0..b76fdc92 100644 --- a/packages/commerce-server/src/server/api/commerce/checkout/POST.ts +++ b/packages/commerce-server/src/server/api/commerce/checkout/POST.ts @@ -39,7 +39,11 @@ import type { Request, Response } from 'express'; import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; import type { CheckoutReturnUrlValidator } from '@/lib/commerce/checkout-return-urls'; -import { type CommerceConfig, commerceConfigurationForResponse } from '@/lib/commerce/config'; +import { + assertCommerceConnectionReady, + type CommerceConfig, + commerceConfigurationForResponse, +} from '@/lib/commerce/config'; import { type CreateCheckoutSessionParams, @@ -76,6 +80,7 @@ export default async function handler(req: Request, res: Response): Promise +
+ + +``` + +`Catalog.sampleProducts` defaults to `false`. It allows six built-in sample products +(Product 1–6, $10–$60 USD) **only** when `/api/commerce/config` reports `state: 'unbound'`. +When a store is connected, the server reports a different state and the components show real +products. You do not need to change props or the component tree. The prop has no effect while +a store is connected. Real empty catalogs and errors never fall back to samples. + +The sample cart supports adding items, changing quantities (1–999), removing items, and an +example subtotal. It uses the existing drawer and theme, keeps its items during navigation under +the shared provider, and resets on reload, unmount, or any change away from `unbound`. It uses no +live cart storage and makes no product, cart, checkout, or upstream Commerce requests. +Configuration reads continue. Sample entries are not SKUs or draft orders and never move into a +real cart. Checkout is unavailable, including direct `useCommerce().checkout()` calls. + +`useCommerce().connection` now includes `unbound` and `connecting` in addition to `loading`, +`ready`, and `error`. Only `ready` means a usable store connection. The public `cart` and live +mutation methods apply only to the connected store; sample cart data is internal. A catalog +without `sampleProducts`, and direct product-detail visits, show the unconnected state. +Sample grids and drawers have the attribute `data-commerce-source="sample"`. + +The provider reads configuration on mount and focus, and every five seconds while the page is +visible and the state is `unbound` or `connecting`. The first failed refresh hides samples and +clears the sample cart; a later `unbound` response starts an empty sample cart. Polling stops +when the state is `ready`; focus and explicit retry still refresh configuration. + +Use a server that reports connection state before you enable `sampleProducts`. Successful +config responses without `state` still mean ready. A server that does not report `unbound` +never shows samples. See the [server contract](docs/server-api.md) for the host's +responsibilities. diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index 9c07cb96..7ca2b4df 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -101,3 +101,35 @@ The client refreshes the cart before checkout and sends its ID, an absolute cata When using `@godaddy/gd-commerce-server`, configure the router's `checkoutReturnUrls.returnUrls` with the absolute catalog return URL and `checkoutReturnUrls.successUrls` with the absolute success-page URL. The package matches these exact destinations and permits an additional `orderId` parameter on success URLs. Missing policy disables HTTP checkout. Public HTTP requests accept only cart or SKU checkout; non-catalog amounts belong in a trusted server handler. Do not treat `checkoutSuccessPath`, a client-provided URL, the redirect itself, or a draft-order response as proof that payment succeeded. Verify payment using the checkout/payment service or trusted webhook state. Expire or reject paid/closed drafts on subsequent cart reads so returning customers cannot reuse a completed cart. + +## Connection state + +A host may return one of these uncached config responses: + +```json +{ "state": "unbound" } +{ "state": "connecting" } +{ "state": "ready", "cartScope": "opaque-cart-scope", "currencyCode": "USD" } +``` + +Only an explicit `unbound` response allows `Catalog sampleProducts`. `connecting` suspends all +catalog and cart actions. Loading, malformed state, HTTP errors, and missing routes never allow +samples. A successful `{ cartScope, currencyCode }` response without `state` still means ready. + +`@godaddy/gd-commerce-server` accepts an optional synchronous +`CommerceConfiguration.readConnectionState(): 'unbound' | 'connecting' | 'ready' | undefined`. +Return `undefined` to keep the existing config behavior. Return `unbound` only when the host has +confirmed that no store is connected. Throw when the state is unknown or cannot be read. +`unbound` and `connecting` responses need no credentials. `ready` still requires `read()` to +succeed. Data and checkout handlers reject any state other than `ready`, even if credentials +remain. Do not infer `unbound` from missing credentials or configuration. + +The host owns the connection lifecycle. Report `ready` only when the store connection is +complete, and keep failures in a state other than `unbound`. After a store connects, missing +configuration must never bring samples back. Report `unbound` again only after the store is +deliberately disconnected. State changes must be visible to the next request. + +The storefront polls every five seconds while the page is visible and the state is `unbound` or +`connecting`, and refreshes on mount and focus. A failed refresh immediately hides samples, without +automatic request retries. Aborted configuration requests cannot overwrite newer responses. +Release server and client support before using the `sampleProducts` prop. No new endpoint is required. diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx index 3b6fa554..871ce681 100644 --- a/packages/commerce-storefront/src/cart.tsx +++ b/packages/commerce-storefront/src/cart.tsx @@ -6,6 +6,7 @@ import { type CartSummaryTotals, getCartSummaryTotals } from './cart-model'; import type { SKU } from './catalog-model'; import { getAvailableInventoryQuantity } from './catalog-model'; import { useCommerce } from './commerce-provider'; +import { useSampleCart } from './sample-cart'; import { StorefrontSurface } from './storefront-surface'; export const buttonClass: string = @@ -15,13 +16,17 @@ export const inputClass: string = export function CartButton(): ReactElement { const { cart, setOpen, open, connection } = useCommerce(); - const count: number = cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0; + const sample = useSampleCart(); + const count: number = + connection === 'unbound' + ? sample.items.reduce((total, item) => total + item.quantity, 0) + : (cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0); return ( + + + + + ))} + + + ); +} diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index cf2a7d7c..bf7a461d 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -12,12 +12,13 @@ import { } from 'react'; import { ApiError, CartIdStorage, checkedFetch, message, request, type StorefrontConfig } from './api'; import { type AddToCartItemInput, addToCart, type CartOrder } from './cart-model'; +import { SampleCartProvider } from './sample-cart'; interface CartResponse { cart: CartOrder | null; } export interface CommerceContextValue { - connection: 'loading' | 'ready' | 'error'; + connection: 'loading' | 'unbound' | 'connecting' | 'ready' | 'error'; connectionError: string | null; retryConnection: () => void; theme: StorefrontTheme; @@ -68,12 +69,20 @@ export interface CommerceProviderProps { checkoutSuccessPath?: string; } -async function loadConfig( - signal: AbortSignal, -): Promise> { - const config = await request>('/config', { signal }); +type PublicConfiguration = + | { state: 'unbound' | 'connecting' } + | ({ state: 'ready' } & Pick); + +async function loadConfig(signal: AbortSignal): Promise { + const config = await request>('/config', { signal }); + if (config?.state === 'unbound' || config?.state === 'connecting') { + if ('cartScope' in config || 'currencyCode' in config) + throw new Error('The store returned invalid configuration.'); + return { state: config.state }; + } if ( !config || + (config.state !== undefined && config.state !== 'ready') || typeof config.cartScope !== 'string' || !config.cartScope.trim() || typeof config.currencyCode !== 'string' || @@ -81,7 +90,7 @@ async function loadConfig( ) { throw new Error('The store returned invalid configuration.'); } - return config; + return { state: 'ready', cartScope: config.cartScope, currencyCode: config.currencyCode }; } export function CommerceProvider({ @@ -96,19 +105,24 @@ export function CommerceProvider({ queryKey: ['commerce', 'configuration'], queryFn: ({ signal }) => loadConfig(signal), staleTime: 30_000, - refetchOnWindowFocus: true, + refetchOnMount: 'always', + refetchOnWindowFocus: 'always', + // Errors after an unbound response also need recovery; stale data never grants eligibility. + refetchInterval: (query) => + query.state.data?.state === 'unbound' || query.state.data?.state === 'connecting' ? 5_000 : false, + refetchIntervalInBackground: false, }); return ( { void config.refetch(); @@ -138,7 +152,7 @@ function BoundCommerceProvider({ const storageKey: string = `godaddy:commerce-storefront:cart:${config.cartScope}`; const cartIdStorage = useMemo(() => new CartIdStorage(storageKey), [storageKey]); const ready = connection === 'ready'; - const session = useMemo(() => ({ cartIdStorage, ready }), [cartIdStorage, ready]); + const session = useMemo(() => ({ cartIdStorage, connection }), [cartIdStorage, connection]); const currentSession = useRef(session); currentSession.current = session; const readyRef = useRef(ready); @@ -256,6 +270,7 @@ function BoundCommerceProvider({ setOpen(false); setError(null); setStorageWarning(null); + setAnnouncement(''); setPending(false); setHydrating(ready); if (ready) void refresh(); @@ -272,7 +287,7 @@ function BoundCommerceProvider({ window.removeEventListener('storage', onStorage); window.removeEventListener('focus', onFocus); }; - }, [cartIdStorage, ready]); + }, [cartIdStorage, connection]); function addItem(item: AddToCartItemInput): Promise { rememberOpener(); @@ -406,7 +421,17 @@ function BoundCommerceProvider({ checkout, }} > - {children} + { + rememberOpener(); + setAnnouncement(`${name} added to your sample cart.`); + setOpen(true); + }} + > + {children} + {announcement} diff --git a/packages/commerce-storefront/src/sample-cart.tsx b/packages/commerce-storefront/src/sample-cart.tsx new file mode 100644 index 00000000..8e907f9f --- /dev/null +++ b/packages/commerce-storefront/src/sample-cart.tsx @@ -0,0 +1,97 @@ +import { + createContext, + type ReactElement, + type ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +// Deliberately distinct from SKU groups, SKUs and draft orders. Never sent to Commerce. +export const sampleProducts = Array.from({ length: 6 }, (_, index) => ({ + key: `sample-${index + 1}`, + name: `Product ${index + 1}`, + price: (index + 1) * 1_000, +})); + +interface SampleCartValue { + items: { key: string; name: string; price: number; quantity: number }[]; + add: (key: string) => boolean; + changeQuantity: (key: string, quantity: number) => Promise; + remove: (key: string) => Promise; +} + +const SampleCartContext = createContext(null); + +export function useSampleCart(): SampleCartValue { + const value = useContext(SampleCartContext); + if (!value) throw new Error('Mount CommerceProvider around sample products.'); + return value; +} + +export function SampleCartProvider({ + enabled, + onAdd, + announce, + children, +}: { + enabled: boolean; + onAdd: (name: string) => void; + announce: (message: string) => void; + children: ReactNode; +}): ReactElement { + const session = useMemo(() => ({ enabled }), [enabled]); + const active = useRef(session); + active.current = session; + const [state, setState] = useState({ session, quantities: {} as Record }); + useEffect(() => { + active.current = session; + setState({ session, quantities: {} }); + return () => { + active.current = null; + }; + }, [session]); + const quantities = state.session === session && enabled ? state.quantities : {}; + function update(key: string, quantity?: number): boolean { + if (!enabled || active.current !== session || !sampleProducts.some((product) => product.key === key)) + return false; + if (quantity !== undefined && (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > 999)) + return false; + setState((previous) => { + const current = previous.session === session ? previous.quantities : {}; + const next = quantity ?? (current[key] ?? 0) + 1; + if (next > 999) return previous; + return { session, quantities: { ...current, [key]: next } }; + }); + return true; + } + return ( + + quantities[product.key] ? [{ ...product, quantity: quantities[product.key] }] : [], + ), + add: (key) => { + const product = sampleProducts.find((product) => product.key === key); + if (!product || !update(key)) return false; + onAdd(product.name); + return true; + }, + changeQuantity: async (key, quantity) => { + const updated = quantity >= 1 && update(key, quantity); + if (updated) announce('Sample cart quantity updated.'); + return updated; + }, + remove: async (key) => { + const updated = update(key, 0); + if (updated) announce('Sample item removed from cart.'); + return updated; + }, + }} + > + {children} + + ); +} diff --git a/packages/commerce-storefront/src/storefront-surface.tsx b/packages/commerce-storefront/src/storefront-surface.tsx index 7d30ed2c..17ea027b 100644 --- a/packages/commerce-storefront/src/storefront-surface.tsx +++ b/packages/commerce-storefront/src/storefront-surface.tsx @@ -21,7 +21,9 @@ export function CommerceStatus(): ReactElement | null { if (connection === 'ready') return null; return ( - {connection === 'loading' ? ( + {connection === 'unbound' ? ( +

Connect a store to show products.

+ ) : connection === 'loading' || connection === 'connecting' ? (

Connecting to the store…

) : (
diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index ea3bc707..0b25cf6a 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { type ReactNode, useState } from 'react'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; +import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router'; import { describe, expect, it, vi } from 'vitest'; import { ApiError, CartIdStorage, money, request } from './api'; import { AddToCartButton, CartButton } from './cart'; @@ -40,7 +40,7 @@ function mockApi(handler: (path: string, init?: RequestInit) => Response | Promi return fn; } function mount(children?: ReactNode, path = '/shop', checkoutSuccessPath?: string) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const client = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } }); let context: CommerceContextValue; function Observe() { context = useCommerce(); @@ -257,7 +257,11 @@ describe('shared cart', () => { await Promise.resolve(); }); act(() => { - view.client.setQueryData(['commerce', 'configuration'], { ...configuration, cartScope: 'store-two' }); + view.client.setQueryData(['commerce', 'configuration'], { + state: 'ready', + ...configuration, + cartScope: 'store-two', + }); }); await connected(view); await act(async () => { @@ -293,7 +297,7 @@ describe('shared cart', () => { }); await waitFor(() => expect(view.context().connection).toBe('error')); act(() => { - view.client.setQueryData(['commerce', 'configuration'], configuration); + view.client.setQueryData(['commerce', 'configuration'], { state: 'ready', ...configuration }); }); await connected(view); await act(async () => { @@ -530,3 +534,233 @@ describe('storage and transport', () => { expect(money(1234, 'KWD')).toContain('1.234'); }); }); + +describe('sample products before a store is connected', () => { + it('shares six internal samples and a local cart without live requests or storage', async () => { + const api = mockApi(() => response({ state: 'unbound' })); + const read = vi.spyOn(Storage.prototype, 'getItem'); + const write = vi.spyOn(Storage.prototype, 'setItem'); + const remove = vi.spyOn(Storage.prototype, 'removeItem'); + const view = mount( + <> + + + + , + '/shop', + '/thanks', + ); + await screen.findAllByRole('button', { name: 'Add Product 1 to sample cart' }); + expect(screen.getAllByRole('button', { name: /to sample cart/ })).toHaveLength(12); + expect(view.context().connection).toBe('unbound'); + expect(view.context().cart).toBeNull(); + const buttons = screen.getAllByRole('button', { name: 'Add Product 1 to sample cart' }); + await userEvent.click(buttons[0]); + expect(screen.getByRole('dialog')).toHaveAttribute('data-commerce-source', 'sample'); + await userEvent.keyboard('{Escape}'); + expect(buttons[0]).toHaveFocus(); + await userEvent.click(buttons[1]); + const drawer = screen.getByRole('dialog'); + expect(within(drawer).getAllByTestId('cart-item')).toHaveLength(1); + expect(within(drawer).getAllByText('$20.00')).toHaveLength(2); + await userEvent.click(within(drawer).getByRole('button', { name: 'Increase quantity of Product 1' })); + expect(within(drawer).getAllByText('$30.00')).toHaveLength(2); + await userEvent.click(within(drawer).getByRole('button', { name: 'Decrease quantity of Product 1' })); + expect(within(drawer).getAllByText('$20.00')).toHaveLength(2); + expect(within(drawer).queryByRole('button', { name: /Proceed to Checkout/ })).not.toBeInTheDocument(); + expect(await view.context().checkout()).toBe(false); + expect(await view.context().addItem(item)).toBe(false); + expect(await view.context().changeQuantity('sample-1', 2)).toBe(false); + expect(await view.context().applyDiscount('TEST')).toBe(false); + await userEvent.click(within(drawer).getByRole('button', { name: 'Remove Product 1' })); + expect(within(drawer).getByText('Your cart is empty')).toBeVisible(); + expect(api.mock.calls.every(([path]) => String(path).endsWith('/config'))).toBe(true); + expect(read).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + }); + + it.each([ + { state: 'connecting' }, + { state: 'unknown' }, + {}, + { state: 'ready' }, + { state: 'unbound', ...configuration }, + ])('does not infer samples from %j', async (config) => { + const api = mockApi(() => response(config)); + const view = mount( + <> + + + , + ); + await waitFor(() => expect(view.context().connection).not.toBe('loading')); + expect(screen.queryByText('Product 1')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cart/ })).toBeDisabled(); + expect(api).toHaveBeenCalledTimes(1); + }); + + it('requires catalog opt-in and keeps unbound product details inert', async () => { + const api = mockApi(() => response({ state: 'unbound' })); + mount( + <> + + + } /> + + , + '/products/sample-1', + ); + expect(await screen.findAllByText('Connect a store to show products.')).toHaveLength(2); + expect(screen.queryByText('Product 1')).not.toBeInTheDocument(); + expect(api).toHaveBeenCalledTimes(1); + }); + + it('automatically activates an empty live catalog without prop edits and discards sample items', async () => { + let config: unknown = { state: 'unbound' }; + const api = mockApi((path) => + path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), + ); + const view = mount( + <> + + + , + ); + await userEvent.click(await screen.findByRole('button', { name: 'Add Product 1 to sample cart' })); + config = { state: 'ready', ...configuration }; + await act(async () => { + await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + expect(await screen.findByText('No products available.')).toBeVisible(); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + expect(screen.getByRole('button', { name: 'Cart 0' })).toBeEnabled(); + expect(view.context().cart).toBeNull(); + expect(api.mock.calls.some(([path]) => String(path).includes('/cart'))).toBe(false); + expect(api.mock.calls.some(([, options]) => options?.body?.toString().includes('sample-'))).toBe(false); + }); + + it('suspends samples on the first failed config refresh and recovers with an empty sample cart', async () => { + const api = mockApi(() => response({ state: 'unbound' })); + const view = mount( + <> + + + , + ); + await userEvent.click(await screen.findByRole('button', { name: 'Add Product 1 to sample cart' })); + api.mockResolvedValue(response({ error: 'Host state unavailable' }, 503)); + const before = api.mock.calls.length; + await act(async () => { + await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + expect(await screen.findByText('Host state unavailable')).toBeVisible(); + expect(view.context().connection).toBe('error'); + expect(api.mock.calls.length).toBe(before + 1); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + api.mockResolvedValue(response({ state: 'unbound' })); + await userEvent.click(screen.getByRole('button', { name: 'Retry connection' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Cart 0' })).toBeEnabled()); + }); + + it('polls for binding changes without a page reload', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + try { + let config: unknown = { state: 'unbound' }; + const api = mockApi((path) => + path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), + ); + const view = mount(); + await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); + config = { state: 'connecting' }; + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + await waitFor(() => expect(view.context().connection).toBe('connecting')); + expect(screen.queryByText('Product 1')).toBeNull(); + config = { state: 'ready', ...configuration }; + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + await screen.findByText('No products available.'); + const count = api.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + expect(api).toHaveBeenCalledTimes(count); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('connection state transitions', () => { + it('preserves samples across routed pages but resets when the storefront is remounted', async () => { + mockApi(() => response({ state: 'unbound' })); + const shell = ( + <> + + About + Shop + + } /> + About page

} /> +
+ + ); + const view = mount(shell); + await userEvent.click(await screen.findByRole('button', { name: 'Add Product 1 to sample cart' })); + await userEvent.keyboard('{Escape}'); + await userEvent.click(screen.getByRole('link', { name: 'About' })); + expect(screen.getByText('About page')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Cart 1' })).toBeEnabled(); + await userEvent.click(screen.getByRole('link', { name: 'Shop' })); + expect(screen.getByRole('button', { name: 'Cart 1' })).toBeEnabled(); + view.unmount(); + mount(shell); + await waitFor(() => expect(screen.getByRole('button', { name: 'Cart 0' })).toBeEnabled()); + }); + + it('never restores samples for a failed live catalog or lost connected configuration', async () => { + let config: Response = response({ state: 'ready', ...configuration }); + mockApi((path) => + path.endsWith('/config') ? config.clone() : response({ error: 'Catalog unavailable' }, 503), + ); + const view = mount(); + expect(await screen.findByText('Catalog unavailable')).toBeVisible(); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + config = response({ error: 'Configuration lost' }, 503); + await act(async () => { + await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + expect(await screen.findByText('Configuration lost')).toBeVisible(); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + }); + + it('ignores an older unbound config response after a newer ready response', async () => { + const old = deferred(); + let reads = 0; + mockApi((path) => { + if (!path.endsWith('/config')) return response({ skuGroups: { edges: [] } }); + reads++; + if (reads === 1) return response({ state: 'unbound' }); + if (reads === 2) return old.promise; + return response({ state: 'ready', ...configuration }); + }); + const view = mount(); + await screen.findByText('Product 1'); + act(() => { + void view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + await waitFor(() => expect(reads).toBe(2)); + await act(async () => { + await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + expect(await screen.findByText('No products available.')).toBeVisible(); + await act(async () => { + old.resolve(response({ state: 'unbound' })); + }); + expect(view.context().connection).toBe('ready'); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + }); +});