diff --git a/.changeset/quiet-checkouts-sync.md b/.changeset/quiet-checkouts-sync.md new file mode 100644 index 00000000..37849c72 --- /dev/null +++ b/.changeset/quiet-checkouts-sync.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Refactor checkout draft order synchronization to flush pending order updates before confirmation, avoid duplicate mutations, and improve sync failure handling. diff --git a/packages/react/package.json b/packages/react/package.json index f02f5950..15130592 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -40,6 +40,7 @@ "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:coverage": "vitest run --coverage", "lint": "biome check ./src", "lint:fix": "biome check --write --unsafe ./src", "prepublishOnly": "pnpm run build" @@ -102,6 +103,9 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.13.1", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", @@ -115,6 +119,7 @@ "tsdown": "^0.15.6", "typescript": "~5.7.3", "vite": "^6.4.1", + "@vitest/coverage-v8": "4.1.2", "vitest": "^4.1.2" }, "publishConfig": { diff --git a/packages/react/src/components/checkout/__tests__/README.md b/packages/react/src/components/checkout/__tests__/README.md new file mode 100644 index 00000000..d280deb2 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/README.md @@ -0,0 +1,138 @@ +# Checkout test infrastructure + +Integration tests for `` live here. They mount the real component +through `renderCheckout()` and assert behavior against an in-memory mock of +`@/lib/godaddy/godaddy`. Pure-unit tests for transformers / hooks live next +to their source as `*.test.ts(x)` and don't need any of the helpers below. + +`checkout-test-env.tsx` is the file you import from. It re-exports everything +in `checkout-test-utils.tsx` and additionally installs `vi.mock(...)` calls +for the wallet / express / Stripe payment buttons so jsdom doesn't try to +boot real SDKs. Importing **anything** from `./checkout-test-env` in a test +file is what activates those mocks — keep that import even if you don't +reference the named export it provides. + +## Helpers + +### Setup +- `mockGodaddyApi(options)` — install the in-memory API mock. `renderCheckout` + calls this for you with sensible defaults; call directly when you need a + bare-mocked API without rendering. +- `renderCheckout(options?)` — mount `` inside `` + with a fresh QueryClient. Returns the standard RTL result plus + `{ user, queryClient, session, draftOrder }`. +- `renderCheckoutWithProps(checkoutProps, options?)` — same, but spread + `checkoutProps` onto the rendered ``. +- `createTestQueryClient()` — a QueryClient with retries off and infinite + staleTime, suitable for predictable integration tests. + +### Builders / fixtures +- `buildCheckoutSession`, `buildDraftOrder`, `buildLineItem`, + `buildShippingAddress`, `buildBillingAddress`, `buildPickupLocation`, + `buildShippingRates` — deep-mergeable factory functions. +- `buildDraftOrderUpdate(input, session?)` — wrap an `UpdateDraftOrderInput` + payload with the `context` block the API expects. +- `noBillingAddress`, `getLastUpdateInput`, `getLastConfirmInput` (in + `checkout-test-fixtures.ts`). + +### Operation log +- `getOperations(op?)` — recorded API calls, optionally filtered by name. +- `getOperationNames()` — names only. +- `getOperationOrder(names)` — array of indices for the first occurrence of + each named op in the log. Use this to assert relative ordering of recorded + operations without `.indexOf` chains. +- `clearOperations()` — reset the log (does not reset draft-order state). + +### Error injection +- `setApiError(key, error)` — make every subsequent call to the matching API + reject. `key` is the API key form, e.g. `'updateDraftOrder'`, + `'applyShippingMethod'`, `'applyDiscount'`. +- `clearApiError(key)` — reset. +- `setApiErrorOnce(key, error?)` — fire `error` for the **next** matching + call only, then auto-clear. Useful for "fail then recover" scenarios + (auto-apply rollback, draft-order sync retry, free → paid coupon round-trip). + +### Mutating the in-memory draft order +- `getCurrentDraftOrder()` / `setCurrentDraftOrder(draftOrder)`. +- `setFeeTotal(value, currencyCode?)` — adjust `draftOrder.feeTotal` (and the + recomputed total) for the next refetch. Used to exercise the fees row in + the totals summary. +- `setPriceAdjustments(adjustments)` — set the response returned by + `getDraftOrderPriceAdjustments`. Defaults to `[]`. Only consumed by the + express-checkout buttons; standard checkout flows ignore it. + +### Timing helpers +- `advanceCheckoutDebounce(ms = 1200)` — advance fake timers and flush + promises in one shot to drive the draft-order sync debounce. +- `flushPromises()` — `act(async () => Promise.resolve())`. +- `waitForCheckoutReady()` — wait until the form has rendered Contact + + Payment. +- `waitForCheckoutIdle()` — wait until pending mutations have settled. +- `waitForOperation(op, count?, timeout?)` — wait until at least `count` + occurrences of `op` are recorded. +- `refetchDraftOrder(queryClient, sessionId)` — invalidate the draft-order + query. + +### Tokenize / wallet +- `MockTokenizeJs` — replaces `window.TokenizeJs`. `setupCheckoutTestGlobals` + wires it up automatically. +- `getLastTokenizeInstance` / `getTokenizeInstances`. +- `WalletSupport` (option on `mockGodaddyApi`) controls what + `MockTokenizeJs.supportWalletPayments()` resolves with. + +### URL / storage / JWT +- `mockWindowLocation`, `setCheckoutUrl`, `seedCheckoutSessionStorage`, + `restoreWindowLocation`, `getMockedLocation`. +- `createMockJwt(payload?)` — produces a JWT with a far-future `exp` by + default. + +### Form interaction +- `typeIntoNamedField(user, name, value)` / `typeIntoPlaceholder` / + `getTextbox(name)` / `getNamedInput(name)`. +- `fillShippingAddress(user, overrides?)` — fill all required shipping + fields with sensible defaults. + +### Tracking (T-005) +- `mockTrack()` — returns `{ getTrackedEvents, clearTrackedEvents, + expectTracked }`. Requires the test file to install + `vi.mock('@/tracking/track', ...)` at the top: + ```ts + vi.mock('@/tracking/track', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, track: vi.fn() }; + }); + ``` + `expectTracked(eventId, propsMatcher)` accepts either an `expect.objectContaining`-style + partial object or a `(props) => boolean` predicate. `eventId` matches + either an exact id or a suffix (the production `track` fn prepends + `godaddy.checkout.` — both forms work). + +## Test design guidelines + +- Keep integration tests focused on the smallest behavior needed for the + feature under test. Prefer separate tests for rendering state, validation, + transitions, confirmation payloads, and tracking instead of one broad test + that clicks through the entire checkout flow. +- Avoid extra submit/confirm clicks when the assertion only needs to verify + that UI rendered or switched state. Payment button clicks trigger React Hook + Form validation, draft-order sync, query updates, and checkout confirming + state; exercising those side effects unnecessarily can produce unrelated + `act(...)` warnings and make tests harder to reason about. +- Seed fixtures into the intended initial state when possible. For example, if + a test only needs a valid names-only billing form, put those names in the + draft-order fixture instead of typing them and triggering debounced sync. +- Assert the tracking source that actually owns the behavior. Payment lifecycle + events come from the confirm-checkout path; parent form submit handling is + intentionally minimal and should not be used as a proxy for payment button + behavior. + +## Gotchas + +- **Fake timers are on by default** (`vi.useFakeTimers({ shouldAdvanceTime: true })`). + When you need to drive the debounce manually, prefer `advanceCheckoutDebounce`. +- The wallet / express / Stripe button mocks are inert — they render + recognizable test ids but never tokenize. Don't try to assert on + authorize/confirm flows that go through them. End-to-end coverage for those + buttons belongs in real-browser tests. +- `clearOperations()` only clears the log; it does not reset the draft order. + Use `setCurrentDraftOrder` or re-call `mockGodaddyApi` for a clean slate. diff --git a/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx new file mode 100644 index 00000000..22c3bbd6 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx @@ -0,0 +1,552 @@ +import { enUs } from '@godaddy/localizations'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { describe, expect, it, vi } from 'vitest'; +import { + AddressForm, + mapAutocompleteAddressFields, +} from '@/components/checkout/address/address-form'; +import { useAddressMatches } from '@/components/checkout/address/utils/use-address-matches'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; +import { AutoComplete } from '@/components/ui/autocomplete'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { + advanceCheckoutDebounce, + buildBillingAddress, + buildCheckoutSession, + buildDraftOrder, + buildLineItem, + buildShippingAddress, + clearOperations, + createTestQueryClient, + fillShippingAddress, + getNamedInput, + getOperations, + mockGodaddyApi, + renderCheckout, + typeIntoNamedField, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastUpdateInput } from './checkout-test-fixtures'; + +function DirectAddressFormHarness() { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ draftOrder }); + const methods = useForm({ + defaultValues: { + shippingFirstName: 'Irish', + shippingLastName: 'Buyer', + shippingAddressLine1: '1 Long Lane', + shippingAdminArea1: '', + shippingAdminArea2: 'Dublin', + shippingPostalCode: 'D02 X285', + shippingCountryCode: 'IE', + } as CheckoutFormData, + }); + + return ( + + undefined, + checkoutErrors: undefined, + setCheckoutErrors: () => undefined, + requiredFields: { shippingAdminArea1: false }, + }} + > + + + + + + + + ); +} + +vi.mock( + '@/components/checkout/address/get-country-region', + async importOriginal => { + const actual = + await importOriginal< + typeof import('@/components/checkout/address/get-country-region') + >(); + return { + ...actual, + hasRegionData: (countryCode: string) => + countryCode === 'IE' ? false : actual.hasRegionData(countryCode), + }; + } +); + +describe('Checkout address behavior', () => { + it('clears the line-1, state, city, and postal-code fields after switching countries', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { + firstName: 'Jane', + lastName: 'Buyer', + address: buildShippingAddress({ + addressLine1: '456 Pre-existing Ln', + adminArea1: 'GA', + adminArea2: 'Jasper', + postalCode: '30143', + countryCode: 'US', + }), + }, + }, + }); + await waitForCheckoutReady(); + + // The country trigger is a Radix popover button (aria-haspopup="dialog") + // whose text is the currently-selected country label. + const triggers = Array.from( + document.querySelectorAll( + 'button[aria-haspopup="dialog"]' + ) + ); + const countryTrigger = triggers.find(button => + /united states/i.test(button.textContent ?? '') + ); + expect(countryTrigger).toBeTruthy(); + await user.click(countryTrigger as HTMLButtonElement); + + const canadaItem = await screen.findByRole('option', { name: /^canada$/i }); + await user.click(canadaItem); + + await waitFor(() => { + expect(getNamedInput('shippingAddressLine1')).toHaveValue(''); + expect(getNamedInput('shippingAdminArea2')).toHaveValue(''); + expect(getNamedInput('shippingPostalCode')).toHaveValue(''); + }); + }); + + it('clears postal code when the selected region changes', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { + firstName: 'Jane', + lastName: 'Buyer', + address: buildShippingAddress({ + addressLine1: '456 Pre-existing Ln', + adminArea1: 'GA', + adminArea2: 'Jasper', + postalCode: '30143', + countryCode: 'US', + }), + }, + }, + }); + await waitForCheckoutReady(); + + await user.click( + screen.getAllByRole('combobox', { name: /state\/province/i })[0] + ); + await user.click(await screen.findByRole('option', { name: /^alabama$/i })); + + await waitFor(() => { + expect(getNamedInput('shippingPostalCode')).toHaveValue(''); + }); + }); + + it('uses a text input for countries with no region data and does not require state validation', async () => { + render(); + + expect(document.body).toHaveTextContent('Ireland'); + expect( + screen.queryByRole('combobox', { name: /state\/province/i }) + ).not.toBeInTheDocument(); + }); + + it('populates autocomplete suggestion fields and syncs them in one draft-order patch', async () => { + const suggestedAddress = { + addressLine1: '456 Shipping Ln', + addressLine2: 'Suite 7', + addressLine3: '', + adminArea1: 'GA', + adminArea2: '', + adminArea3: 'Atlanta', + adminArea4: '', + countryCode: 'US', + postalCode: '30301', + }; + const onPatch = vi.fn(); + + function AutocompletePatchProbe() { + const [fields, setFields] = useState({ + AddressLine1: '', + AdminArea1: '', + AdminArea2: '', + PostalCode: '', + }); + + return ( +
+ + {fields.AddressLine1} + {fields.AdminArea2} + {fields.AdminArea1} + {fields.PostalCode} +
+ ); + } + + render( + + + + ); + + await userEvent.click( + screen.getByRole('button', { name: /select autocomplete suggestion/i }) + ); + + expect(screen.getByLabelText('address')).toHaveTextContent( + '456 Shipping Ln' + ); + expect(screen.getByLabelText('city')).toHaveTextContent('Atlanta'); + expect(screen.getByLabelText('state')).toHaveTextContent('GA'); + expect(screen.getByLabelText('postal')).toHaveTextContent('30301'); + expect(onPatch).toHaveBeenCalledTimes(1); + expect(onPatch).toHaveBeenCalledWith({ + shipping: { + address: { + addressLine1: '456 Shipping Ln', + adminArea1: 'GA', + adminArea2: 'Atlanta', + postalCode: '30301', + }, + }, + }); + }); + + it('syncs only billing names in onlyNames mode without stale address fields', async () => { + const draftOrder = buildDraftOrder({ + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + lineItems: [ + { + fulfillmentMode: 'PICKUP', + unitAmount: { value: 0, currencyCode: 'USD' }, + }, + ], + billing: { + firstName: '', + lastName: '', + phone: '', + email: 'jane@example.com', + address: buildBillingAddress({ addressLine1: 'Stale Billing St' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + sessionOverrides: { + draftOrder, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await typeIntoNamedField(user, 'billingFirstName', 'Pickup'); + await typeIntoNamedField(user, 'billingLastName', 'Buyer'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + billing: { + firstName: 'Pickup', + lastName: 'Buyer', + }, + }); + expect(getLastUpdateInput()?.billing ?? {}).not.toMatchObject({ + address: expect.objectContaining({ addressLine1: 'Stale Billing St' }), + }); + }); + + it('does not sync the address until country, state, city, and postal-code are valid', async () => { + // Start with no postal code so the address is incomplete; ensure no sync + // fires until we provide all required fields. + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { + firstName: '', + lastName: '', + address: buildShippingAddress({ + addressLine1: '', + adminArea1: 'GA', + adminArea2: '', + postalCode: '', + countryCode: 'US', + }), + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + // Type names + line1 only — should not yet trigger a draft order update + // because the address is still incomplete. + await typeIntoNamedField(user, 'shippingFirstName', 'Ship'); + await typeIntoNamedField(user, 'shippingLastName', 'Buyer'); + await typeIntoNamedField(user, 'shippingAddressLine1', '456 Shipping Ln'); + await advanceCheckoutDebounce(); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + + // Provide remaining fields → sync fires once with the full address. + await typeIntoNamedField(user, 'shippingAdminArea2', 'Jasper'); + await typeIntoNamedField(user, 'shippingPostalCode', '30143'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + shipping: expect.objectContaining({ + address: expect.objectContaining({ + addressLine1: '456 Shipping Ln', + adminArea2: 'Jasper', + postalCode: '30143', + }), + }), + }); + }); + + it('calls GetAddressMatches for address autocomplete queries', async () => { + const suggestedAddress = { + addressLine1: '456 Shipping Ln', + addressLine2: 'Suite 7', + adminArea1: 'GA', + adminArea3: 'Atlanta', + countryCode: 'US', + postalCode: '30301', + }; + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + enableAddressAutocomplete: true, + }); + mockGodaddyApi({ + session, + draftOrder, + addressMatches: [suggestedAddress], + }); + + function AddressMatchesProbe() { + const { data } = useAddressMatches('456 Ship', { enabled: true }); + return
{data?.[0]?.addressLine1 ?? 'loading'}
; + } + + render( + + undefined, + checkoutErrors: undefined, + setCheckoutErrors: () => undefined, + }} + > + + + + ); + + await waitForOperation('GetAddressMatches'); + expect(getOperations('GetAddressMatches')[0].input).toEqual({ + query: '456 Ship', + }); + expect(await screen.findByText('456 Shipping Ln')).toBeInTheDocument(); + }); + + it('renders autocomplete suggestions and returns the selected address', async () => { + const suggestedAddress = { + addressLine1: '456 Shipping Ln', + addressLine2: 'Suite 7', + adminArea1: 'GA', + adminArea3: 'Atlanta', + countryCode: 'US', + postalCode: '30301', + }; + const onChange = vi.fn(); + const onSelect = vi.fn(); + + render( + + + + ); + + const suggestion = await screen.findByRole('option', { + name: /456 shipping ln/i, + }); + await within(suggestion).findByText(/456 Shipping Ln/i); + fireEvent.click(suggestion); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('456 Shipping Ln'); + expect(onSelect).toHaveBeenCalledWith(suggestedAddress); + }); + }); + + it('maps an autocomplete suggestion to address form fields', () => { + expect( + mapAutocompleteAddressFields({ + addressLine1: '456 Shipping Ln', + addressLine2: 'Suite 7', + addressLine3: null, + adminArea1: 'GA', + adminArea2: null, + adminArea3: 'Atlanta', + adminArea4: null, + countryCode: 'US', + postalCode: '30301', + }) + ).toEqual({ + AddressLine1: '456 Shipping Ln', + AddressLine2: 'Suite 7', + AdminArea2: 'Atlanta', + AdminArea1: 'GA', + PostalCode: '30301', + }); + }); + + it('does not call address autocomplete for non-US countries', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableAddressAutocomplete: true }, + draftOrderOverrides: { + shipping: { + address: buildShippingAddress({ countryCode: 'CA' }), + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await typeIntoNamedField(user, 'shippingAddressLine1', '789 Canada Way'); + await advanceCheckoutDebounce(300); + + expect(getOperations('GetAddressMatches')).toHaveLength(0); + }); + + it('does not call address autocomplete when enableAddressAutocomplete is false', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableAddressAutocomplete: false }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await typeIntoNamedField(user, 'shippingAddressLine1', '789 Manual Way'); + await advanceCheckoutDebounce(300); + + expect(getOperations('GetAddressMatches')).toHaveLength(0); + }); + + it('keeps manual address entry usable when GetAddressMatches fails or returns no suggestions', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableAddressAutocomplete: true }, + apiOverrides: { + addressMatches: [], + errors: { getAddressMatches: 'address matches failed' }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const address = screen.getByPlaceholderText( + enUs.ui.autocomplete.addressPlaceholder + ); + await user.clear(address); + await user.type(address, '101 Manual St'); + await waitForOperation('GetAddressMatches'); + + expect(address).toHaveValue('101 Manual St'); + expect(screen.queryByText(/suggestions/i)).not.toBeInTheDocument(); + }); + + it('reveals billing form and requires billing fields after toggling "use shipping" off', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + + // In purchase mode, billing is separate from shipping and the billing + // address form is visible by default. + + await waitFor(() => { + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toBeInTheDocument(); + }); + + // Billing is separate from shipping in purchase mode, so the billing + // address form is visible and uses the exact localized billing copy. + expect(document.body).toHaveTextContent( + enUs.payment.billingAddress.description + ); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx new file mode 100644 index 00000000..99d32ffd --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx @@ -0,0 +1,153 @@ +import { screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import * as godaddyApi from '@/lib/godaddy/godaddy'; +import { + advanceCheckoutDebounce, + buildBillingAddress, + buildCheckoutSession, + buildDraftOrder, + buildDraftOrderUpdate, + buildShippingAddress, + clearOperations, + getOperations, + mockGodaddyApi, + renderCheckout, + typeIntoNamedField, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastUpdateInput } from './checkout-test-fixtures'; + +describe('Checkout billing behavior', () => { + it('sends a full billing address when billing is collected separately from shipping', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { address: buildBillingAddress({ adminArea1: 'GA' }) }, + lineItems: [{ fulfillmentMode: 'PICKUP' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + }, + }); + await waitForCheckoutReady(); + + await typeIntoNamedField(user, 'billingFirstName', 'Bill'); + await typeIntoNamedField(user, 'billingLastName', 'Buyer'); + await typeIntoNamedField(user, 'billingAddressLine1', '789 Billing Rd'); + await typeIntoNamedField(user, 'billingAdminArea2', 'Atlanta'); + await typeIntoNamedField(user, 'billingPostalCode', '30301'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + billing: { + firstName: 'Bill', + lastName: 'Buyer', + address: expect.objectContaining({ + addressLine1: '789 Billing Rd', + adminArea2: 'Atlanta', + postalCode: '30301', + countryCode: 'US', + }), + }, + }); + }); + + it('copies explicit shipping patches to billing while same-as-shipping is checked, then stops after unchecked', async () => { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ draftOrder }); + mockGodaddyApi({ session, draftOrder }); + clearOperations(); + + await godaddyApi.updateDraftOrder( + buildDraftOrderUpdate( + { + shipping: { + address: buildShippingAddress({ addressLine1: '999 Copy Way' }), + }, + billing: { + address: buildBillingAddress({ addressLine1: '999 Copy Way' }), + }, + }, + session + ), + session + ); + expect(getLastUpdateInput()).toMatchObject({ + shipping: expect.objectContaining({ address: expect.any(Object) }), + billing: expect.objectContaining({ address: expect.any(Object) }), + }); + + clearOperations(); + await godaddyApi.updateDraftOrder( + buildDraftOrderUpdate( + { + shipping: { + address: buildShippingAddress({ addressLine1: '1000 No Copy Way' }), + }, + }, + session + ), + session + ); + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(1); + expect(getLastUpdateInput()).toMatchObject({ + shipping: expect.objectContaining({ address: expect.any(Object) }), + }); + expect(getLastUpdateInput()).not.toHaveProperty('billing'); + }); + + it('derives same-as-shipping checked state using normalized name, phone, address, and optional fields', async () => { + const sameAddress = buildShippingAddress({ addressLine2: '' }); + renderCheckout({ + draftOrder: buildDraftOrder({ + shipping: { + firstName: 'Jane', + lastName: 'Buyer', + phone: '+12015550123', + address: sameAddress, + }, + billing: { + firstName: 'Jane', + lastName: 'Buyer', + phone: '(201) 555-0123', + address: buildBillingAddress({ + ...sameAddress, + addressLine2: undefined, + }), + }, + }), + }); + await waitForCheckoutReady(); + expect( + screen.getByLabelText(/use shipping address as billing/i) + ).toBeChecked(); + }); + + it('derives same-as-shipping unchecked when names differ', async () => { + const sameAddress = buildShippingAddress({ addressLine2: '' }); + const draftOrder = buildDraftOrder({ + shipping: { + firstName: 'Jane', + lastName: 'Buyer', + phone: '+12015550123', + address: sameAddress, + }, + billing: { + firstName: 'Janet', + lastName: 'Buyer', + phone: '+12015550123', + address: sameAddress, + }, + }); + renderCheckout({ + session: buildCheckoutSession({ draftOrder }), + draftOrder, + }); + await waitForCheckoutReady(); + expect( + screen.getByLabelText(/use shipping address as billing/i) + ).not.toBeChecked(); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-config-matrix.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-config-matrix.test.tsx new file mode 100644 index 00000000..13fa6609 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-config-matrix.test.tsx @@ -0,0 +1,169 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import * as godaddyApi from '@/lib/godaddy/godaddy'; +import { + advanceCheckoutDebounce, + buildCheckoutSession, + buildDraftOrder, + buildDraftOrderUpdate, + buildShippingAddress, + clearOperations, + fillShippingAddress, + getOperations, + mockGodaddyApi, + renderCheckout, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastUpdateInput, noBillingAddress } from './checkout-test-fixtures'; + +describe('Checkout configuration matrix', () => { + it('supports shipping + pickup with names-only billing without sending stale billing address fields', async () => { + const draftOrder = buildDraftOrder(noBillingAddress); + const session = buildCheckoutSession({ + draftOrder, + enableLocalPickup: true, + enableShipping: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: false, + enablePhoneCollection: true, + enableTaxCollection: true, + enableNotesCollection: true, + enablePromotionCodes: true, + }); + mockGodaddyApi({ session, draftOrder }); + clearOperations(); + await godaddyApi.updateDraftOrder( + buildDraftOrderUpdate( + { + billing: { firstName: 'Only', lastName: 'Names', address: null }, + }, + session + ), + session + ); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(1); + expect(getLastUpdateInput()).toMatchObject({ + billing: { firstName: 'Only', lastName: 'Names', address: null }, + }); + }); + + it('supports shipping-only checkout and applies a changed shipping method with one tax calculation', async () => { + const { session } = renderCheckout({ + draftOrderOverrides: { + shipping: { + firstName: '', + lastName: '', + phone: '', + address: buildShippingAddress({ + addressLine1: '', + adminArea1: 'GA', + adminArea2: '', + postalCode: '', + }), + }, + }, + sessionOverrides: { + enableLocalPickup: false, + enableShipping: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enableTaxCollection: true, + enablePromotionCodes: true, + }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByRole('radio', { name: /local pickup/i }) + ).not.toBeInTheDocument(); + expect(screen.queryByText(/pickup date/i)).not.toBeInTheDocument(); + expect(screen.getAllByText('Shipping').length).toBeGreaterThan(0); + + clearOperations(); + await godaddyApi.updateDraftOrder( + buildDraftOrderUpdate( + { + shipping: { + firstName: 'Ship', + lastName: 'Buyer', + address: buildShippingAddress({ + addressLine1: '456 Shipping Ln', + addressLine2: '', + adminArea2: 'Jasper', + postalCode: '30143', + countryCode: 'US', + }), + }, + }, + session + ), + session + ); + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(1); + }); + + it('supports pickup-only checkout without fetching or applying shipping rates', async () => { + renderCheckout({ + draftOrderOverrides: { + ...noBillingAddress, + shipping: { address: null }, + lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }], + }, + sessionOverrides: { + enableLocalPickup: true, + enableShipping: false, + enableBillingAddressCollection: true, + enableTaxCollection: true, + enablePromotionCodes: true, + }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByRole('radio', { name: /^shipping/i }) + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="shippingAddressLine1"]') + ).not.toBeInTheDocument(); + expect(screen.getAllByText(/jasper/i).length).toBeGreaterThan(0); + expect(getOperations('DraftOrderShippingRates')).toHaveLength(0); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + }); + + it('does not calculate taxes when tax collection is disabled', async () => { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + enableTaxCollection: false, + enablePromotionCodes: true, + }); + mockGodaddyApi({ session, draftOrder }); + clearOperations(); + await godaddyApi.applyShippingMethod( + [{ name: 'Weight Based', requestedService: 'weight-based' }], + session + ); + await godaddyApi.applyDiscount(['onedollar'], session); + + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + }); + + it('hides coupon UI and avoids discount mutations when promotions are disabled', async () => { + renderCheckout({ sessionOverrides: { enablePromotionCodes: false } }); + await waitForCheckoutReady(); + + expect( + screen.queryByPlaceholderText(/coupon code/i) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /apply/i }) + ).not.toBeInTheDocument(); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(0); + expect( + screen.getByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx new file mode 100644 index 00000000..1e747838 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx @@ -0,0 +1,439 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { useFormContext } from 'react-hook-form'; +import { describe, expect, it } from 'vitest'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { + PaymentProvider, + useConfirmCheckout, +} from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { + buildCheckoutSession, + buildDraftOrder, + buildPickupLocation, + buildShippingAddress, + clearOperations, + getOperations, + mockGodaddyApi, + type RenderCheckoutOptions, + renderCheckout, + setApiError, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastConfirmInput } from './checkout-test-fixtures'; + +// All tests in this file render with offline-only payment so the real +// `useConfirmCheckout` (and its MISSING_SHIPPING_INFO / DRAFT_ORDER_UPDATE_FAILED +// error paths) are exercised, instead of the Stripe button mock that bypasses +// `useConfirmCheckout` entirely. +function offlineSessionOverrides() { + return { + paymentMethods: { + // Clear the default Stripe card method so 'offline' becomes the only + // available payment method (and the OfflinePaymentCheckoutButton, which + // calls the real useConfirmCheckout, is rendered). + card: null as never, + offline: { + processor: 'offline', + checkoutTypes: ['standard'], + }, + }, + }; +} + +function pickupDraftOrder() { + return buildDraftOrder({ + lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }], + shippingLines: [], + }); +} + +function scheduledPickupLocation() { + return buildPickupLocation({ + id: 'scheduled-pickup-loc', + isDefault: true, + operatingHours: { + timeZone: 'America/New_York', + leadTime: 30, + pickupWindowInDays: 3, + pickupSlotInterval: 60, + hours: { + sunday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + monday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + tuesday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + wednesday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + thursday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + friday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + saturday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + }, + }, + }); +} + +interface ConfirmSeamProps { + label?: string; + deliveryMethod?: DeliveryMethods; + isExpress?: boolean; + pickupValues?: { + pickupDate?: string; + pickupTime?: string; + pickupLocationId?: string; + pickupLeadTime?: number; + pickupTimezone?: string; + }; + paymentType?: string; + paymentProvider?: PaymentProvider; +} + +function ConfirmSeamButton({ + label = 'Confirm seam', + deliveryMethod = DeliveryMethods.SHIP, + isExpress = false, + pickupValues, + paymentType = isExpress ? 'apple_pay' : 'offline', + paymentProvider = isExpress ? PaymentProvider.POYNT : PaymentProvider.OFFLINE, +}: ConfirmSeamProps) { + const confirmCheckout = useConfirmCheckout(); + const form = useFormContext(); + const { setCheckoutErrors } = useCheckoutContext(); + + return ( + + ); +} + +function renderCheckoutWithConfirmSeam( + options: RenderCheckoutOptions = {}, + seamProps?: ConfirmSeamProps +) { + return renderCheckout({ + ...options, + checkoutProps: { + ...(options.checkoutProps ?? {}), + targets: { + ...(options.checkoutProps?.targets ?? {}), + 'checkout.form.payment.after': () => ( + + ), + }, + }, + }); +} + +describe('Checkout confirm errors', () => { + it('blocks confirm and surfaces MISSING_SHIPPING_INFO when no shipping line is applied', async () => { + // No rates so the "default" rate cannot auto-apply, and an empty + // shippingLines means useConfirmCheckout's pre-flight check throws. + const draftOrder = buildDraftOrder({ + shipping: { + firstName: 'Ship', + lastName: 'Buyer', + phone: '+12015550123', + address: buildShippingAddress(), + }, + shippingLines: [], + }); + const session = buildCheckoutSession({ + draftOrder, + ...offlineSessionOverrides(), + }); + mockGodaddyApi({ session, draftOrder, shippingMethods: [] }); + + const { user } = renderCheckoutWithConfirmSeam({ + session, + draftOrder, + apiOverrides: { shippingMethods: [] }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /confirm seam/i }) + ); + + await waitFor(() => { + expect(document.body).toHaveTextContent( + /Shipping address or method failed to apply/i + ); + }); + + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it('renders GraphQL error codes returned from confirmCheckout', async () => { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + ...offlineSessionOverrides(), + }); + mockGodaddyApi({ session, draftOrder }); + + const { user } = renderCheckoutWithConfirmSeam({ session, draftOrder }); + await waitForCheckoutReady(); + clearOperations(); + + setApiError( + 'confirmCheckout', + new GraphQLErrorWithCodes([ + { + message: 'Order rejected', + code: 'PAYMENT_AUTHORIZATION_FAILED', + }, + ]) + ); + + await user.click( + await screen.findByRole('button', { name: /confirm seam/i }) + ); + + await waitFor( + () => { + expect(document.body).toHaveTextContent( + /PAYMENT_AUTHORIZATION_FAILED/i + ); + }, + { timeout: 5000 } + ); + }); + + it('does not redirect when confirmCheckout fails', async () => { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + successUrl: 'https://test.example/should-not-go-here', + ...offlineSessionOverrides(), + }); + mockGodaddyApi({ session, draftOrder }); + + const { user } = renderCheckoutWithConfirmSeam({ session, draftOrder }); + await waitForCheckoutReady(); + clearOperations(); + + setApiError( + 'confirmCheckout', + new GraphQLErrorWithCodes([ + { message: 'Decline', code: 'PAYMENT_DECLINED' }, + ]) + ); + + await user.click( + await screen.findByRole('button', { name: /confirm seam/i }) + ); + + await waitFor(() => { + expect(document.body).toHaveTextContent(/PAYMENT_DECLINED/i); + }); + + // Order rejected; the success-URL redirect path is bypassed. + expect(window.location.href).not.toContain('should-not-go-here'); + }); + + it('surfaces DRAFT_ORDER_UPDATE_FAILED when the in-confirm draft-order fetch fails', async () => { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + ...offlineSessionOverrides(), + }); + mockGodaddyApi({ session, draftOrder }); + + const { user, queryClient } = renderCheckoutWithConfirmSeam({ + session, + draftOrder, + }); + await waitForCheckoutReady(); + queryClient.setQueryDefaults(['draft-order', { sessionId: session.id }], { + retry: false, + refetchOnWindowFocus: false, + staleTime: 0, + }); + setApiError('getDraftOrder', 'draft fetch failed'); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /confirm seam/i }) + ); + + await waitForOperation('DraftOrder'); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it('ignores a rapid second confirm click while the first confirm is in flight', async () => { + const draftOrder = buildDraftOrder({ + shippingLines: [ + { + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ], + }); + const session = buildCheckoutSession({ + draftOrder, + ...offlineSessionOverrides(), + }); + mockGodaddyApi({ session, draftOrder, delayMs: 100 }); + + renderCheckoutWithConfirmSeam({ + session, + draftOrder, + apiOverrides: { delayMs: 100 }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const confirm = await screen.findByRole('button', { + name: /confirm seam/i, + }); + fireEvent.click(confirm); + fireEvent.click(confirm); + + await waitForOperation('ConfirmCheckoutSession'); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + + it('confirms pickup ASAP with a fulfillment window based on current time and lead time', async () => { + const location = buildPickupLocation({ + id: 'asap-pickup-loc', + operatingHours: { + timeZone: 'America/New_York', + leadTime: 45, + pickupWindowInDays: 0, + }, + }); + const draftOrder = pickupDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + locations: [location], + defaultOperatingHours: location.operatingHours, + ...offlineSessionOverrides(), + }); + + const { user } = renderCheckoutWithConfirmSeam( + { session, draftOrder }, + { + deliveryMethod: DeliveryMethods.PICKUP, + pickupValues: { + pickupLocationId: 'asap-pickup-loc', + pickupTime: 'ASAP', + pickupLeadTime: 45, + pickupTimezone: 'America/New_York', + }, + } + ); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /confirm seam/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + fulfillmentLocationId: 'asap-pickup-loc', + fulfillmentStartAt: '2026-01-05T10:45:00-05:00', + fulfillmentEndAt: '2026-01-05T10:45:00-05:00', + }); + }); + + it('confirms scheduled pickup with the exact selected slot', async () => { + const location = scheduledPickupLocation(); + const draftOrder = pickupDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + locations: [location], + defaultOperatingHours: location.operatingHours, + ...offlineSessionOverrides(), + }); + + const { user } = renderCheckoutWithConfirmSeam( + { session, draftOrder }, + { + deliveryMethod: DeliveryMethods.PICKUP, + pickupValues: { + pickupDate: '2026-01-05', + pickupTime: '11:00', + pickupLocationId: 'scheduled-pickup-loc', + pickupLeadTime: 30, + pickupTimezone: 'America/New_York', + }, + } + ); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /confirm seam/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + fulfillmentLocationId: 'scheduled-pickup-loc', + fulfillmentStartAt: '2026-01-05T11:00:00-05:00', + fulfillmentEndAt: '2026-01-05T11:00:00-05:00', + }); + }); + + it('skips shipping and pickup guards when confirming through the isExpress seam', async () => { + const draftOrder = buildDraftOrder({ shippingLines: [] }); + const session = buildCheckoutSession({ draftOrder }); + + const { user } = renderCheckout({ + session, + draftOrder, + checkoutProps: { + targets: { + 'checkout.form.payment.after': () => ( + + ), + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /express confirm seam/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + paymentToken: 'express-nonce', + paymentType: 'apple_pay', + paymentProvider: PaymentProvider.POYNT, + }); + expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentStartAt'); + expect(document.body).not.toHaveTextContent(/MISSING_SHIPPING_INFO/i); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx new file mode 100644 index 00000000..c20c8e12 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -0,0 +1,286 @@ +import { enUs } from '@godaddy/localizations'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { + clearOperations, + flushPromises, + getOperations, + renderCheckout, + setApiError, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; + +async function applyCoupon( + user: ReturnType, + code: string +) { + let input: HTMLInputElement | undefined; + let button: HTMLButtonElement | undefined; + + await waitFor(() => { + const inputs = screen.getAllByPlaceholderText( + /coupon code/i + ) as HTMLInputElement[]; + const buttons = screen.getAllByRole('button', { + name: /apply/i, + }) as HTMLButtonElement[]; + const index = inputs.findIndex(candidate => !candidate.disabled); + expect(index).toBeGreaterThanOrEqual(0); + input = inputs[index]; + button = buttons[index]; + }); + + await user.clear(input as HTMLInputElement); + await user.type(input as HTMLInputElement, code); + await waitFor(() => { + expect((button as HTMLButtonElement).disabled).toBe(false); + }); + await user.click(button as HTMLButtonElement); +} + +describe('Checkout discounts', () => { + it('renders a line-item discount code as a removable tag', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + draftOrderOverrides: { + lineItems: [ + { + id: 'line-item-1', + discounts: [{ code: 'lineitem10' }], + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const lineItemTags = await screen.findAllByRole('button', { + name: /remove lineitem10/i, + }); + expect(lineItemTags.length).toBeGreaterThan(0); + + await user.click(lineItemTags.at(-1) as HTMLButtonElement); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: [], + }); + }); + + it('renders a shipping-line discount code as a removable tag', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free Shipping', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [{ code: 'shipfree' }], + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const shippingTags = await screen.findAllByRole('button', { + name: /remove shipfree/i, + }); + expect(shippingTags.length).toBeGreaterThan(0); + + await user.click(shippingTags.at(-1) as HTMLButtonElement); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: [], + }); + }); + + it('applies and removes a coupon, recalculating taxes when enabled', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + await waitForOperation('CalculateCheckoutSessionTaxes'); + + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: ['onedollar'], + }); + expect(screen.getAllByText('onedollar')).toHaveLength(2); + + clearOperations(); + await user.click( + screen + .getAllByRole('button', { name: /remove onedollar/i }) + .at(-1) as HTMLButtonElement + ); + await waitForOperation('ApplyCheckoutSessionDiscount'); + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: [], + }); + }); + + it('does not recalculate taxes on coupon apply when tax is disabled', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableTaxCollection: false }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + }); + + it('shows duplicate coupon validation without issuing a duplicate mutation', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { discounts: [{ code: 'onedollar' }] }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + + await waitFor(() => { + expect(document.body).toHaveTextContent(/already been applied/i); + }); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(0); + }); + + it('hides coupon UI when promotions are disabled', async () => { + renderCheckout({ sessionOverrides: { enablePromotionCodes: false } }); + await waitForCheckoutReady(); + expect( + screen.queryByPlaceholderText(/coupon code/i) + ).not.toBeInTheDocument(); + }); + + it('renders the API error code inline when discount apply fails', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + setApiError( + 'applyDiscount', + new GraphQLErrorWithCodes([ + { message: 'Bad code', code: 'DISCOUNT_NOT_FOUND' }, + ]) + ); + + await applyCoupon(user, 'badcode'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + await waitFor(() => { + expect(document.body).toHaveTextContent(/DISCOUNT_NOT_FOUND/i); + }); + await flushPromises(); + }); + + it('renders the localized generic message when discount apply fails without GraphQL codes', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + setApiError('applyDiscount', new Error('network unavailable')); + + await applyCoupon(user, 'badcode'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.discounts.failedToApply); + }); + await flushPromises(); + }); + + it('keeps empty coupon apply disabled and does not call the API', async () => { + // TODO(T-1401): Product copy requests click-to-validate empty input, but + // current UI disables Apply while the trimmed discount code is empty. + renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const button = screen.getAllByRole('button', { name: /apply/i })[0]; + expect(button).toBeDisabled(); + fireEvent.click(button); + + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(0); + expect(document.body).not.toHaveTextContent( + enUs.discounts.enterCodeValidation + ); + }); + + it('does not submit an empty coupon with the Enter key', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const input = screen.getAllByPlaceholderText(/coupon code/i)[0]; + await user.click(input); + await user.keyboard('{Enter}'); + await flushPromises(); + + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(0); + expect(document.body).not.toHaveTextContent( + enUs.discounts.enterCodeValidation + ); + }); + + it('recalculates taxes when a coupon is removed', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { discounts: [{ code: 'onedollar' }] }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + screen + .getAllByRole('button', { name: /remove onedollar/i }) + .at(-1) as HTMLButtonElement + ); + await waitForOperation('ApplyCheckoutSessionDiscount'); + await waitForOperation('CalculateCheckoutSessionTaxes'); + + expect( + getOperations('CalculateCheckoutSessionTaxes').length + ).toBeGreaterThan(0); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx new file mode 100644 index 00000000..1c5d2efb --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx @@ -0,0 +1,235 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { + advanceCheckoutDebounce, + buildShippingAddress, + clearOperations, + fillShippingAddress, + flushPromises, + getCurrentDraftOrder, + getNamedInput, + getOperations, + renderCheckout, + typeIntoNamedField, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastUpdateInput } from './checkout-test-fixtures'; + +describe('Checkout draft-order field sync', () => { + it('syncs contact email to both shipping and billing', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + clearOperations(); + + const email = screen.getByLabelText(/email/i); + await user.clear(email); + await user.type(email, 'new@example.com'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + shipping: { email: 'new@example.com' }, + billing: { email: 'new@example.com' }, + }); + }); + + it('batches fast shipping address entry into one merged update including optional field clearing', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { + firstName: '', + lastName: '', + address: buildShippingAddress({ + addressLine1: '', + addressLine2: 'Old Apt', + adminArea1: 'GA', + adminArea2: '', + postalCode: '', + }), + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await fillShippingAddress(user, { addressLine2: '' }); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(1); + expect(getLastUpdateInput()).toMatchObject({ + shipping: { + firstName: 'Ship', + lastName: 'Buyer', + address: expect.objectContaining({ + addressLine1: '456 Shipping Ln', + addressLine2: '', + adminArea2: 'Jasper', + postalCode: '30143', + countryCode: 'US', + }), + }, + }); + }); + + it('serializes slow field-by-field edits without concurrent update mutations', async () => { + const { user } = renderCheckout({ + apiOverrides: { updateDraftOrderDelayMs: 200 }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Alpha'); + await advanceCheckoutDebounce(); + await typeIntoNamedField(user, 'shippingLastName', 'Beta'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder', 2); + + const updates = getOperations('UpdateCheckoutSessionDraftOrder'); + expect(updates).toHaveLength(2); + expect(updates[1].timestamp).toBeGreaterThanOrEqual(updates[0].timestamp); + }); + + it('syncs whitespace-only notes as null', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + notes: [{ authorType: 'CUSTOMER', content: 'Leave at door' }], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const notes = document.querySelector( + 'textarea[name="notes"]' + ); + expect(notes).toBeTruthy(); + await user.clear(notes as HTMLTextAreaElement); + await user.type(notes as HTMLTextAreaElement, ' '); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ notes: null }); + }); + + it('syncs names-only billing without stale address fields', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: '', + lastName: '', + phone: '', + email: 'jane@example.com', + address: null, + }, + lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }], + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await typeIntoNamedField(user, 'billingFirstName', 'Only'); + await typeIntoNamedField(user, 'billingLastName', 'Names'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + billing: { firstName: 'Only', lastName: 'Names', address: null }, + }); + expect(getLastUpdateInput()?.billing).not.toMatchObject({ + addressLine1: expect.anything(), + postalCode: expect.anything(), + }); + }); + + it('copies phone-only shipping sync to billing when payment uses the shipping address', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { shipping: { phone: '' }, billing: { phone: '' } }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const [phone] = await screen.findAllByPlaceholderText('(201) 555-1234'); + await user.clear(phone); + await user.type(phone, '+12015550123'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + shipping: { phone: '+12015550123' }, + billing: { phone: '+12015550123' }, + }); + }); + + it('gates invalid phone sync until the phone becomes valid', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { shipping: { phone: '' } }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const [phone] = await screen.findAllByPlaceholderText('(201) 555-1234'); + await user.clear(phone); + await user.type(phone, '12'); + await advanceCheckoutDebounce(); + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + + await user.clear(phone); + await user.type(phone, '+12015550123'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(1); + expect(getLastUpdateInput()).toMatchObject({ + shipping: { phone: '+12015550123' }, + }); + }); + + it('resetField after a successful sync makes the typed value pristine for later refetches', async () => { + const { user, queryClient, session } = renderCheckout({ + draftOrderOverrides: { shipping: { firstName: '' } }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Pristine'); + await typeIntoNamedField(user, 'shippingLastName', 'Saved'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + const savedOrder = getCurrentDraftOrder(); + expect(savedOrder?.shipping?.firstName).toBe('Pristine'); + + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { + draftOrder: { + ...savedOrder, + shipping: { + ...savedOrder?.shipping, + firstName: 'Server Refetch', + }, + }, + }, + }); + await flushPromises(); + + await waitFor(() => { + expect(getNamedInput('shippingFirstName')).toHaveValue('Server Refetch'); + }); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-error-list.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-error-list.test.tsx new file mode 100644 index 00000000..41564091 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-error-list.test.tsx @@ -0,0 +1,99 @@ +import { enUs } from '@godaddy/localizations'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { checkoutContext } from '@/components/checkout/checkout'; +import { CheckoutErrorList } from '@/components/checkout/form/checkout-error-list'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { eventIds } from '@/tracking/events'; +import { createTestQueryClient, mockTrack } from './checkout-test-env'; + +vi.mock('@/tracking/track', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, track: vi.fn() }; +}); + +const tracking = mockTrack(); + +function renderErrorList({ + checkoutErrors, + isCheckoutDisabled = false, +}: { + checkoutErrors?: string[]; + isCheckoutDisabled?: boolean; +}) { + return render( + + undefined, + setCheckoutErrors: () => undefined, + }} + > + + + + ); +} + +describe('CheckoutErrorList', () => { + beforeEach(() => { + tracking.clearTrackedEvents(); + }); + + it('scrolls into view and tracks checkout errors', async () => { + const scrollSpy = vi.spyOn(Element.prototype, 'scrollIntoView'); + + renderErrorList({ checkoutErrors: ['PAYMENT_DECLINED', 'UNKNOWN_CODE'] }); + + await waitFor(() => { + expect(scrollSpy).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + }); + tracking.expectTracked(eventIds.formError, { + errorCodes: 'PAYMENT_DECLINED,UNKNOWN_CODE', + errorCount: 2, + }); + }); + + it('renders localized known errors and raw unknown error codes', () => { + renderErrorList({ + checkoutErrors: ['AUTHORIZATION_FAILED', 'CUSTOM_RAW_CODE'], + }); + + expect( + screen.getByText(enUs.apiErrors.AUTHORIZATION_FAILED) + ).toBeInTheDocument(); + expect(screen.getByText('CUSTOM_RAW_CODE')).toBeInTheDocument(); + }); + + it('renders checkout disabled copy with checkout errors', () => { + renderErrorList({ + checkoutErrors: ['TRANSACTION_PROCESSING_FAILED'], + isCheckoutDisabled: true, + }); + + expect( + screen.getByText(enUs.apiErrors.TRANSACTION_PROCESSING_FAILED) + ).toBeInTheDocument(); + expect(screen.getByText(enUs.general.checkoutDisabled)).toBeInTheDocument(); + }); + + it('renders only checkout disabled copy when there are no errors', () => { + renderErrorList({ isCheckoutDisabled: true }); + + expect(screen.getByText(enUs.general.checkoutDisabled)).toBeInTheDocument(); + expect(tracking.getTrackedEvents(eventIds.formError)).toHaveLength(0); + }); + + it('renders nothing without checkout errors or disabled state', () => { + const { container } = renderErrorList({}); + + expect(container).toBeEmptyDOMElement(); + expect(tracking.getTrackedEvents(eventIds.formError)).toHaveLength(0); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx new file mode 100644 index 00000000..4a847df2 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx @@ -0,0 +1,122 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { renderCheckout, waitForCheckoutReady } from './checkout-test-env'; + +// ---------------------------------------------------------------------------- +// Express checkout section +// ---------------------------------------------------------------------------- +// +// In production the express section is driven exclusively by the dedicated +// `paymentMethods.express` entry, configured as either: +// { processor: 'godaddy', checkoutTypes: ['express'] } → ExpressCheckoutButton +// { processor: 'stripe', checkoutTypes: ['express'] } → StripeExpressCheckoutButton +// +// Those buttons open a wallet sheet (Apple Pay / Google Pay) that handles its +// OWN shipping/tax/coupon flow with the SUBTOTAL only, then calls +// `confirmCheckout` with `isExpress: true` plus `calculatedTaxes`, +// `calculatedAdjustments`, and `shippingTotal` sourced directly from the +// wallet sheet — bypassing the draftOrder entirely. +// +// The other wallet-style methods (`paymentMethods.applePay`, `googlePay`, +// `paze`) are always STANDARD methods (`checkoutTypes: ['standard']`). They +// appear in the regular PaymentForm accordion alongside credit-card and use +// the draftOrder as their data source. Those are covered by +// `checkout-wallet-methods.test.tsx`, not here. +// +// What we CAN cover in jsdom: +// * Section visibility — it shows iff `paymentMethods.express` is configured. +// * Which provider's button (godaddy vs stripe) is rendered. +// +// What we CANNOT cover here (intentionally not asserted): +// * The actual wallet-sheet interaction (Apple Pay / Google Pay UI). +// * Tokenization via Poynt's `gdpay-express-pay-element` iframe. +// * The express `payment_authorized` payload that combines the wallet's +// calculated totals with the nonce. That flow is SDK-driven and only +// reachable in real-browser e2e tests. +// ---------------------------------------------------------------------------- + +describe('Express checkout section visibility', () => { + it('does not render the express section when paymentMethods.express is not configured', async () => { + renderCheckout(); + await waitForCheckoutReady(); + + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('mock-stripe-express-button') + ).not.toBeInTheDocument(); + expect(screen.queryByText(/^OR$/)).not.toBeInTheDocument(); + }); + + it('renders the dedicated GoDaddy ExpressCheckoutButton when paymentMethods.express is configured for godaddy', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + // The dedicated `express` entry routes to ExpressCheckoutButton in + // lazy-payment-loader's registry. The "OR" divider appears + // alongside it via ExpressCheckoutButtons. + express: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); + expect(await screen.findByText(/^OR$/)).toBeVisible(); + // The standard credit-card form/button still appears below the express + // section. + expect(screen.getByRole('button', { name: /pay now/i })).toBeVisible(); + }); + + it('renders the Stripe express button when paymentMethods.express is configured for stripe', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'stripe', checkoutTypes: ['express'] }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByTestId('mock-stripe-express-button') + ).toBeVisible(); + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + }); + + it('does NOT render the express section when only standard wallet methods are configured', async () => { + // Standard wallet payment methods (paymentMethods.applePay/googlePay/paze + // with checkoutTypes: ['standard']) belong in the PaymentForm accordion, + // NOT the express section. Without `paymentMethods.express`, the express + // section stays hidden — even when the SDK reports wallet support. + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + applePay: { processor: 'godaddy', checkoutTypes: ['standard'] }, + googlePay: { processor: 'godaddy', checkoutTypes: ['standard'] }, + paze: { processor: 'godaddy', checkoutTypes: ['standard'] }, + }, + }, + apiOverrides: { + walletSupport: { applePay: true, googlePay: true, paze: true }, + }, + }); + await waitForCheckoutReady(); + + expect(screen.queryByText(/^OR$/)).not.toBeInTheDocument(); + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('mock-stripe-express-button') + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx new file mode 100644 index 00000000..fa377efc --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx @@ -0,0 +1,413 @@ +import { enUs } from '@godaddy/localizations'; +import { screen, waitFor } from '@testing-library/react'; +import { useFormContext } from 'react-hook-form'; +import { describe, expect, it, vi } from 'vitest'; +import { PaymentMethodType, PaymentProvider } from '@/types'; +import { + buildDraftOrder, + buildLineItem, + buildShippingAddress, + clearOperations, + getOperations, + renderCheckout, + typeIntoNamedField, + waitForCheckoutReady, +} from './checkout-test-env'; + +vi.mock('@/components/checkout/address', async importOriginal => { + const actual = + await importOriginal(); + return { + ...actual, + hasRegionData: (countryCode: string) => + countryCode === 'IE' ? false : actual.hasRegionData(countryCode), + }; +}); + +const DeliveryMethods = { + NONE: 'NONE', + PICKUP: 'PICKUP', + SHIP: 'SHIP', +} as const; + +function _offlinePaymentMethods() { + return { + card: null as never, + ach: null, + express: null, + paypal: null, + applePay: null, + googlePay: null, + paze: null, + mercadopago: null, + ccavenue: null, + offline: { + type: PaymentMethodType.OFFLINE, + processor: PaymentProvider.OFFLINE, + checkoutTypes: ['standard'], + }, + }; +} + +function stripeOnlyPaymentMethods() { + return { + card: { + type: PaymentMethodType.CREDIT_CARD, + processor: PaymentProvider.STRIPE, + checkoutTypes: ['standard'], + }, + ach: null, + express: null, + paypal: null, + applePay: null, + googlePay: null, + paze: null, + mercadopago: null, + ccavenue: null, + offline: null, + }; +} + +function freeTotals() { + return { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }; +} + +function makeFreePickupOrder(overrides = {}) { + return buildDraftOrder({ + totals: freeTotals(), + lineItems: [ + buildLineItem({ + fulfillmentMode: DeliveryMethods.PICKUP, + unitAmount: { value: 0, currencyCode: 'USD' }, + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + }), + ], + billing: { + firstName: '', + lastName: 'Pickup', + address: buildShippingAddress({ addressLine1: '' }), + }, + ...overrides, + }); +} + +function makePaidPickupOrder(overrides = {}) { + return buildDraftOrder({ + lineItems: [buildLineItem({ fulfillmentMode: DeliveryMethods.PICKUP })], + billing: { + firstName: 'Pat', + lastName: 'Pickup', + address: buildShippingAddress({ addressLine1: '' }), + }, + ...overrides, + }); +} + +async function clickSubmitButton(name: RegExp) { + const button = await screen.findByRole('button', { name }); + await waitFor(() => expect(button).not.toBeDisabled()); + return button; +} + +function BillingReuseProbe() { + const form = useFormContext(); + + return ( + + ); +} + +describe('Checkout form validation', () => { + it('requires only billing names for free pickup and does not require billing address fields', async () => { + const draftOrder = makeFreePickupOrder(); + const { user } = renderCheckout({ + draftOrder, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.validation.enterFirstName); + }); + expect(document.body).not.toHaveTextContent(enUs.validation.enterAddress); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it('pins current paid pickup card behavior when the billing address line is empty', async () => { + const draftOrder = makePaidPickupOrder(); + const { user } = renderCheckout({ + draftOrder, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await clickSubmitButton(/pay now/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.validation.enterAddress); + }); + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); + }); + + it('ignores empty billing fields when shipping address is reused and blocks on empty shipping fields', async () => { + const draftOrder = buildDraftOrder({ + shipping: { + firstName: '', + lastName: '', + phone: '', + address: buildShippingAddress({ + addressLine1: '', + adminArea1: '', + adminArea2: '', + postalCode: '', + countryCode: 'US', + }), + }, + billing: { + firstName: 'Ship', + lastName: 'Buyer', + address: buildShippingAddress(), + }, + }); + const { user } = renderCheckout({ + draftOrder, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await clickSubmitButton(/pay now/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.validation.enterFirstName); + expect(document.body).toHaveTextContent(enUs.validation.enterAddress); + expect(document.body).toHaveTextContent(enUs.validation.enterCity); + expect(document.body).toHaveTextContent( + enUs.validation.enterZipPostalCode + ); + expect(document.body).toHaveTextContent(enUs.validation.selectState); + }); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toBeNull(); + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); + }); + + it('pins current card behavior after shipping address reuse is toggled off', async () => { + const draftOrder = buildDraftOrder({ + shipping: { + firstName: 'Ship', + lastName: 'Buyer', + address: buildShippingAddress({ + adminArea1: '', + countryCode: 'IE', + }), + }, + billing: { + firstName: '', + lastName: '', + phone: '', + address: buildShippingAddress({ + addressLine1: '', + adminArea1: '', + adminArea2: '', + postalCode: '', + countryCode: 'IE', + }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + sessionOverrides: { + draftOrder, + enableLocalPickup: false, + enableBillingAddressCollection: false, + paymentMethods: { + ...stripeOnlyPaymentMethods(), + card: null as never, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: ['standard'], + }, + }, + }, + checkoutProps: { + targets: { + 'checkout.form.payment.after': BillingReuseProbe, + }, + }, + }); + await waitForCheckoutReady(); + + await user.click( + screen.getByRole('button', { name: /toggle billing reuse off/i }) + ); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent(enUs.validation.enterAddress); + }); + + it('pins current purchase-only card behavior with empty billing fields', async () => { + const draftOrder = buildDraftOrder({ + lineItems: [buildLineItem({ fulfillmentMode: DeliveryMethods.NONE })], + shipping: { + firstName: '', + lastName: '', + phone: '', + address: buildShippingAddress({ + addressLine1: '', + adminArea1: '', + adminArea2: '', + postalCode: '', + }), + }, + billing: { + firstName: '', + lastName: '', + phone: '', + address: buildShippingAddress({ + addressLine1: '', + adminArea1: '', + adminArea2: '', + postalCode: '', + countryCode: 'US', + }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: false, + enableLocalPickup: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await clickSubmitButton(/pay now/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.validation.enterFirstName); + expect(document.body).toHaveTextContent(enUs.validation.enterLastName); + expect(document.body).toHaveTextContent(enUs.validation.enterAddress); + expect(document.body).toHaveTextContent(enUs.validation.enterCity); + expect(document.body).toHaveTextContent( + enUs.validation.enterZipPostalCode + ); + expect(document.body).toHaveTextContent(enUs.validation.selectState); + }); + expect( + document.querySelector('input[name="shippingAddressLine1"]') + ).toBeNull(); + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); + }); + + it('toggles state validation from country region data', async () => { + const draftOrder = buildDraftOrder({ + shipping: { + firstName: 'Ship', + lastName: 'Buyer', + phone: '', + address: buildShippingAddress({ + addressLine1: '1 Long Lane', + adminArea1: '', + adminArea2: 'Dublin', + postalCode: 'D02 X285', + countryCode: 'IE', + }), + }, + billing: { + firstName: 'Ship', + lastName: 'Buyer', + phone: '', + address: buildShippingAddress({ + addressLine1: '1 Long Lane', + adminArea1: '', + adminArea2: 'Dublin', + postalCode: 'D02 X285', + countryCode: 'IE', + }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + }, + }); + await waitForCheckoutReady(); + + await user.click(await clickSubmitButton(/pay now/i)); + + await waitFor(() => { + expect( + screen.queryByText(enUs.validation.selectState) + ).not.toBeInTheDocument(); + }); + + const countryButtons = Array.from( + document.querySelectorAll( + 'button[aria-haspopup="dialog"]' + ) + ); + const irelandButton = countryButtons.find(button => + /ireland/i.test(button.textContent ?? '') + ); + expect(irelandButton).toBeTruthy(); + await user.click(irelandButton as HTMLButtonElement); + await user.click( + await screen.findByRole('option', { name: /^united states$/i }) + ); + clearOperations(); + await user.click(await clickSubmitButton(/pay now/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.validation.selectState); + }); + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx new file mode 100644 index 00000000..ea014b76 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx @@ -0,0 +1,477 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + buildCheckoutSession, + buildDraftOrder, + buildShippingRates, + clearOperations, + getOperations, + mockGodaddyApi, + renderCheckout, + setApiErrorOnce, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastConfirmInput } from './checkout-test-fixtures'; + +// FreePaymentForm only renders for orders whose total is <= 0. Build a +// zero-total draft order to trigger that code path. +function buildFreeDraftOrder( + opts: Parameters[0] & { + withShippingLine?: boolean; + } = {} +) { + const { withShippingLine, ...overrides } = opts; + + return buildDraftOrder({ + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + shippingLines: withShippingLine + ? [ + { + id: 'shipping-line-free', + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ] + : [], + lineItems: [ + { + unitAmount: { value: 0, currencyCode: 'USD' }, + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + }, + ], + ...overrides, + }); +} + +async function applyCoupon( + user: ReturnType, + code: string +) { + let input: HTMLInputElement | undefined; + let apply: HTMLButtonElement | undefined; + + await waitFor(() => { + const inputs = screen.getAllByPlaceholderText( + /coupon code/i + ) as HTMLInputElement[]; + const buttons = screen.getAllByRole('button', { + name: /apply/i, + }) as HTMLButtonElement[]; + const index = inputs.findIndex(candidate => !candidate.disabled); + expect(index).toBeGreaterThanOrEqual(0); + input = inputs[index]; + apply = buttons[index]; + }); + + await user.clear(input as HTMLInputElement); + await user.type(input as HTMLInputElement, code); + await waitFor(() => { + expect(apply as HTMLButtonElement).not.toBeDisabled(); + }); + await user.click(apply as HTMLButtonElement); +} + +function buildPaidPurchaseDraftOrder() { + return buildDraftOrder({ + totals: { + subTotal: { value: 100, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 100, currencyCode: 'USD' }, + }, + lineItems: [ + { + unitAmount: { value: 100, currencyCode: 'USD' }, + fulfillmentMode: 'PURCHASE', + totals: { + subTotal: { value: 100, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + }, + ], + }); +} + +describe('Checkout free / offline orders', () => { + it('renders a free pickup order with names-only billing and no paid payment form', async () => { + const draftOrder = buildFreeDraftOrder({ + billing: { + firstName: 'Free', + lastName: 'Pickup', + phone: '', + email: 'jane@example.com', + address: null, + }, + }); + draftOrder.lineItems = (draftOrder.lineItems ?? []).map(li => ({ + ...li, + fulfillmentMode: 'PICKUP', + })); + + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }); + mockGodaddyApi({ session, draftOrder }); + + renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + expect( + screen.getByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /pay now/i }) + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="billingFirstName"]') + ).toHaveValue('Free'); + expect(document.querySelector('input[name="billingLastName"]')).toHaveValue( + 'Pickup' + ); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); + }); + + it('does not collect a billing address for a free pickup order (names only)', async () => { + const draftOrder = buildFreeDraftOrder(); + draftOrder.lineItems = (draftOrder.lineItems ?? []).map(li => ({ + ...li, + fulfillmentMode: 'PICKUP', + })); + draftOrder.billing = { + firstName: '', + lastName: '', + phone: '', + email: 'jane@example.com', + address: null, + }; + + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }); + mockGodaddyApi({ session, draftOrder }); + + renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + expect( + document.querySelector('input[name="billingFirstName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingLastName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it('blocks the free-pickup submit when name fields are empty', async () => { + const draftOrder = buildFreeDraftOrder(); + draftOrder.lineItems = (draftOrder.lineItems ?? []).map(li => ({ + ...li, + fulfillmentMode: 'PICKUP', + })); + draftOrder.billing = { + firstName: '', + lastName: '', + phone: '', + email: 'jane@example.com', + address: null, + }; + + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }); + mockGodaddyApi({ session, draftOrder }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + clearOperations(); + + const submit = await screen.findByRole('button', { + name: /complete your free order/i, + }); + await user.click(submit); + + // Form validation prevents the mutation: no confirm op. + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + }); + + it('confirms a free shipping order when a free shipping line satisfies the guard', async () => { + const draftOrder = buildFreeDraftOrder({ withShippingLine: true }); + draftOrder.lineItems = (draftOrder.lineItems ?? []).map(li => ({ + ...li, + fulfillmentMode: 'SHIP', + })); + + const session = buildCheckoutSession({ + draftOrder, + enableShipping: true, + enableLocalPickup: false, + enableTaxCollection: false, + }); + mockGodaddyApi({ session, draftOrder }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your free order/i }) + ); + + await waitForOperation('ConfirmCheckoutSession'); + expect(getLastConfirmInput()).toMatchObject({ + paymentType: 'offline', + paymentProvider: 'OFFLINE', + }); + }); + + it('confirms a free purchase order without shipping or pickup fulfillment', async () => { + const draftOrder = buildFreeDraftOrder({ + shipping: { + firstName: 'Free', + lastName: 'Purchase', + address: { + addressLine1: '1 Long Lane', + addressLine2: '', + addressLine3: '', + adminArea1: '', + adminArea2: 'Dublin', + adminArea3: '', + adminArea4: '', + postalCode: 'D02 X285', + countryCode: 'IE', + }, + }, + billing: { + firstName: 'Free', + lastName: 'Purchase', + address: { + addressLine1: '1 Long Lane', + addressLine2: '', + addressLine3: '', + adminArea1: '', + adminArea2: 'Dublin', + adminArea3: '', + adminArea4: '', + postalCode: 'D02 X285', + countryCode: 'IE', + }, + }, + }); + draftOrder.lineItems = (draftOrder.lineItems ?? []).map(li => ({ + ...li, + fulfillmentMode: 'PURCHASE', + })); + + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + enableBillingAddressCollection: false, + }); + mockGodaddyApi({ session, draftOrder }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your free order/i }) + ); + + await waitForOperation('ConfirmCheckoutSession'); + expect(getLastConfirmInput()).toMatchObject({ + paymentType: 'offline', + paymentProvider: 'OFFLINE', + }); + expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentLocationId'); + }); + + it('switches from paid payment methods to FreePaymentForm after a 100% coupon', async () => { + const draftOrder = buildPaidPurchaseDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + expect( + await screen.findByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + + clearOperations(); + await applyCoupon(user, 'free100'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + expect( + await screen.findByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /pay now/i }) + ).not.toBeInTheDocument(); + }); + + it('returns to the paid payment form when the free coupon is removed', async () => { + const draftOrder = buildPaidPurchaseDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + await applyCoupon(user, 'free100'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + expect( + await screen.findByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + + clearOperations(); + await user.click(screen.getByRole('button', { name: /remove free100/i })); + await waitForOperation('ApplyCheckoutSessionDiscount'); + await waitForOperation('DraftOrder'); + + expect( + await screen.findByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /complete your free order/i }) + ).not.toBeInTheDocument(); + }); + + it('switches to FreePaymentForm when selecting a free shipping rate makes the total zero', async () => { + const draftOrder = buildDraftOrder({ + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 100, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 100, currencyCode: 'USD' }, + }, + shippingLines: [ + { + requestedService: 'weight-based', + requestedProvider: 'unknown', + name: 'Weight Based', + amount: { value: 100, currencyCode: 'USD' }, + discounts: [], + }, + ], + lineItems: [ + { + unitAmount: { value: 0, currencyCode: 'USD' }, + fulfillmentMode: 'SHIP', + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + }, + ], + }); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: true, + enableLocalPickup: false, + enableTaxCollection: false, + experimental_rules: { + freeShipping: { enabled: true, minimumOrderTotal: 0 }, + }, + }); + + const { user } = renderCheckout({ + session, + draftOrder, + apiOverrides: { shippingMethods: buildShippingRates() }, + }); + await waitForCheckoutReady(); + expect( + await screen.findByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + + clearOperations(); + await user.click(screen.getByRole('radio', { name: /free/i })); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + + expect( + await screen.findByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /pay now/i }) + ).not.toBeInTheDocument(); + }); + + it('keeps the paid form visible when coupon application fails', async () => { + const draftOrder = buildPaidPurchaseDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + setApiErrorOnce('applyDiscount', 'discount failed'); + + clearOperations(); + await applyCoupon(user, 'badcode'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + expect( + await screen.findByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /complete your free order/i }) + ).not.toBeInTheDocument(); + expect(document.body).toHaveTextContent(/failed to apply coupon code/i); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx new file mode 100644 index 00000000..e418a910 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx @@ -0,0 +1,182 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + advanceCheckoutDebounce, + buildCheckoutSession, + buildDraftOrder, + buildShippingRates, + clearOperations, + getOperations, + renderCheckout, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastConfirmInput } from './checkout-test-fixtures'; + +function buildFreeDraftOrder( + overrides: Parameters[0] = {} +) { + return buildDraftOrder({ + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + lineItems: [ + { + unitAmount: { value: 0, currencyCode: 'USD' }, + fulfillmentMode: 'PURCHASE', + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + }, + ], + ...overrides, + }); +} + +async function submitFreeOrder( + user: ReturnType +) { + clearOperations(); + await user.click( + await screen.findByRole('button', { name: /complete your free order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + await advanceCheckoutDebounce(0); +} + +describe('Checkout FreePaymentForm integration', () => { + it('renders names-only billing for a free pickup order without a billing address', async () => { + const draftOrder = buildFreeDraftOrder({ + lineItems: [{ fulfillmentMode: 'PICKUP' }], + billing: { + firstName: 'Free', + lastName: 'Pickup', + phone: '', + email: 'jane@example.com', + address: null, + }, + }); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }); + + renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + expect( + screen.getByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingFirstName"]') + ).toHaveValue('Free'); + expect(document.querySelector('input[name="billingLastName"]')).toHaveValue( + 'Pickup' + ); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /pay now/i }) + ).not.toBeInTheDocument(); + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); + }); + + it('confirms a free shipping order without rendering billing address fields', async () => { + const draftOrder = buildFreeDraftOrder({ + lineItems: [{ fulfillmentMode: 'SHIP' }], + shippingLines: [ + { + id: 'shipping-line-free', + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ], + }); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: true, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ + session, + draftOrder, + apiOverrides: { + shippingMethods: buildShippingRates([ + { + serviceCode: 'free-shipping', + displayName: 'Free', + description: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + }, + }); + await waitForCheckoutReady(); + + expect( + screen.getByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="shippingAddressLine1"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /pay now/i }) + ).not.toBeInTheDocument(); + + await submitFreeOrder(user); + + expect(getLastConfirmInput()).toMatchObject({ + paymentToken: '', + paymentType: 'offline', + paymentProvider: 'OFFLINE', + }); + expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentLocationId'); + }); + + it('renders a free purchase order without collecting address fields', async () => { + const draftOrder = buildFreeDraftOrder({ + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + expect( + screen.getByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + // Current FreePaymentForm renders the submit button only for PURCHASE + // orders. PRD T-107/T-401 notes document that new billing collection + // fields are not rendered for this case. + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="shippingAddressLine1"]') + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-layout-appearance.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-layout-appearance.test.tsx new file mode 100644 index 00000000..8574777e --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-layout-appearance.test.tsx @@ -0,0 +1,378 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { useFormContext } from 'react-hook-form'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + baseCheckoutSchema, + Checkout, + LayoutSections, +} from '@/components/checkout/checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { + buildCheckoutSession, + buildDraftOrder, + createTestQueryClient, + flushPromises, + getOperations, + renderCheckoutWithProps, + waitForCheckoutReady, +} from './checkout-test-env'; + +function sectionHeadingsInDomOrder() { + return screen + .getAllByRole('heading', { level: 3 }) + .map(heading => heading.textContent?.trim()); +} + +const targetSlots = [ + 'checkout.form.contact.before', + 'checkout.form.contact.after', + 'checkout.form.delivery.before', + 'checkout.form.delivery.after', + 'checkout.form.tips.before', + 'checkout.form.tips.after', + 'checkout.form.pickup.form.before', + 'checkout.form.payment.before', + 'checkout.form.payment.after', + 'checkout.form.express-checkout.before', + 'checkout.form.express-checkout.after', + 'checkout.summary.line-items.before', + 'checkout.summary.line-items.after', + 'checkout.summary.totals.subtotal.before', + 'checkout.summary.totals.discount.before', + 'checkout.summary.totals.shipping.before', + 'checkout.summary.totals.tip.before', + 'checkout.summary.totals.taxes.before', + 'checkout.summary.totals.fees.before', + 'checkout.summary.totals.after', + 'checkout.summary.totals.total-due.before', + 'checkout.summary.totals.total-due.after', +] as const; + +function CustomFieldProbe() { + const form = useFormContext(); + + return ( +
+ + +
+ ); +} + +describe('Checkout layout, targets, appearance, and loading states', () => { + it('honors custom layout ordering and appends omitted sections', async () => { + const { container } = renderCheckoutWithProps({ + layout: [ + LayoutSections.PAYMENT, + LayoutSections.CONTACT, + LayoutSections.SHIPPING, + ], + }); + await waitForCheckoutReady(); + + const layoutGrid = Array.from( + container.querySelectorAll('form > div') + ).find(element => element.style.gridTemplateAreas); + + expect(layoutGrid?.style.gridTemplateAreas).toMatch( + /payment.*contact.*shipping.*delivery/ + ); + }); + + it('filters shipping and pickup layout sections by selected delivery method', async () => { + const { user } = renderCheckoutWithProps({ + layout: [ + LayoutSections.PICKUP, + LayoutSections.SHIPPING, + LayoutSections.CONTACT, + LayoutSections.PAYMENT, + ], + }); + await waitForCheckoutReady(); + + expect(sectionHeadingsInDomOrder()).toContain('Shipping'); + expect(sectionHeadingsInDomOrder()).not.toContain('Local Pickup'); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + + await waitFor(() => { + expect(sectionHeadingsInDomOrder()).toContain('Local Pickup'); + expect(sectionHeadingsInDomOrder()).not.toContain('Shipping'); + }); + }); + + it('uses rtl grid classes when direction="rtl"', async () => { + const { container } = renderCheckoutWithProps({ direction: 'rtl' }); + await waitForCheckoutReady(); + + const grid = container.querySelector('.grid.min-h-screen'); + expect(grid?.className).toContain('md:grid-cols-[1fr_minmax'); + expect(grid?.className).toContain("md:[grid-template-areas:'right_left']"); + }); + + it('renders target content in checkout, form, section, summary, and submit slots', async () => { + renderCheckoutWithProps({ + targets: { + 'checkout.before': () => target checkout before, + 'checkout.after': () => target checkout after, + 'checkout.form.before': () => target form before, + 'checkout.form.after': () => target form after, + 'checkout.form.shipping.before': () => ( + target shipping before + ), + 'checkout.form.shipping.after': () => ( + target shipping after + ), + 'checkout.summary.before': () => target summary before, + 'checkout.summary.after': () => target summary after, + 'checkout.form.submit.before': () => target submit before, + 'checkout.form.submit.after': () => target submit after, + }, + }); + await waitForCheckoutReady(); + + for (const text of [ + 'target checkout before', + 'target checkout after', + 'target form before', + 'target form after', + 'target shipping before', + 'target shipping after', + 'target summary before', + 'target summary after', + 'target submit before', + 'target submit after', + ]) { + expect(screen.getByText(text)).toBeInTheDocument(); + } + }); + + it('applies theme and CSS variables from appearance', async () => { + renderCheckoutWithProps( + { + appearance: { + variables: { + background: '#010203', + primary: '#abcdef', + }, + }, + }, + { + sessionOverrides: { + appearance: { + theme: 'purple', + }, + }, + } + ); + await waitForCheckoutReady(); + + expect(document.documentElement).toHaveClass('theme-purple'); + expect( + document.documentElement.style.getPropertyValue('--gd-background') + ).toBe('#010203'); + expect( + document.documentElement.style.getPropertyValue('--gd-primary') + ).toBe('#abcdef'); + }); + + it('renders all canonical Phase 7 target slots from a fixture list', async () => { + const targets = Object.fromEntries( + targetSlots.map(slot => [ + slot, + () => {slot}, + ]) + ); + + renderCheckoutWithProps( + { targets }, + { + sessionOverrides: { + enableTips: true, + enableLocalPickup: true, + enablePromotionCodes: true, + paymentMethods: { + card: null as never, + ach: null, + paypal: null, + applePay: null, + googlePay: null, + paze: null, + mercadopago: null, + ccavenue: null, + offline: null, + express: { + processor: 'stripe', + checkoutTypes: ['express'], + }, + }, + }, + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PICKUP' }], + discounts: [ + { code: 'SAVE10', amount: { value: 100, currencyCode: 'USD' } }, + ], + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 100, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 200, currencyCode: 'USD' }, + feeTotal: { value: 50, currencyCode: 'USD' }, + total: { value: 2950, currencyCode: 'USD' }, + }, + }, + } + ); + await waitForCheckoutReady(); + + for (const slot of targetSlots) { + expect(screen.getAllByTestId(`target-${slot}`).length).toBeGreaterThan(0); + } + }); + + it('applies appearance element classes to representative controls', async () => { + renderCheckoutWithProps({ + appearance: { + elements: { + input: 'appearance-input', + select: 'appearance-select', + button: 'appearance-button', + checkbox: 'appearance-checkbox', + }, + }, + }); + await waitForCheckoutReady(); + + expect(document.querySelector('input[name="contactEmail"]')).toHaveClass( + 'appearance-input' + ); + expect( + screen.getByRole('combobox', { name: /state\/province/i }) + ).toHaveClass('appearance-select'); + expect(screen.getAllByRole('button', { name: /apply/i }).at(0)).toHaveClass( + 'appearance-button' + ); + expect( + screen.getByRole('checkbox', { + name: /use shipping address as billing address/i, + }) + ).toHaveClass('appearance-checkbox'); + }); + + it('uses the session appearance theme over props appearance theme', async () => { + renderCheckoutWithProps( + { appearance: { theme: 'base' } }, + { sessionOverrides: { appearance: { theme: 'purple' } } } + ); + await waitForCheckoutReady(); + + expect(document.documentElement).toHaveClass('theme-purple'); + }); + + it('uses ltr template areas by default and flips them for rtl', async () => { + const ltr = renderCheckoutWithProps({}); + await waitForCheckoutReady(); + expect( + ltr.container.querySelector('.grid.min-h-screen')?.className + ).toContain("md:[grid-template-areas:'left_right']"); + ltr.unmount(); + + const rtl = renderCheckoutWithProps({ direction: 'rtl' }); + await waitForCheckoutReady(); + expect( + rtl.container.querySelector('.grid.min-h-screen')?.className + ).toContain("md:[grid-template-areas:'right_left']"); + }); + + it('blocks submit with a custom checkoutFormSchema required field until it is filled', async () => { + const customMessage = 'Enter the custom required field'; + const { user } = renderCheckoutWithProps( + { + checkoutFormSchema: { + customRequired: z.string().min(1, customMessage), + }, + targets: { + 'checkout.form.payment.before': CustomFieldProbe, + }, + }, + { + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + apiOverrides: { suppressTokenNonce: true }, + } + ); + await waitForCheckoutReady(); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); + + await user.type( + screen.getByLabelText(/custom required field/i), + 'custom value' + ); + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitFor(() => { + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(1); + }); + }); + + it('renders custom loadingFallback while loading', async () => { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ draftOrder, token: '' }); + const queryClient = createTestQueryClient(); + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { ...session, draftOrder }, + }); + + render( + + Custom checkout loading} + /> + + ); + + expect(screen.getByText('Custom checkout loading')).toBeInTheDocument(); + await flushPromises(); + }); + + it('renders the default CheckoutSkeleton while loading', async () => { + const draftOrder = buildDraftOrder(); + // Empty token prevents useCheckoutSession from starting an async token + // exchange while this test is only asserting the loading skeleton. + const session = buildCheckoutSession({ draftOrder, token: '' }); + const queryClient = createTestQueryClient(); + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { ...session, draftOrder }, + }); + + const { container } = render( + + + + ); + + expect(container.querySelector('.grid.min-h-screen')).toBeInTheDocument(); + expect(screen.queryByText('Contact')).not.toBeInTheDocument(); + + await flushPromises(); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx new file mode 100644 index 00000000..d500f9e6 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import * as godaddyApi from '@/lib/godaddy/godaddy'; +import { + buildCheckoutSession, + buildDraftOrder, + buildDraftOrderUpdate, + clearOperations, + getOperationOrder, + getOperations, + MockTokenizeJs, + mockGodaddyApi, +} from './checkout-test-env'; +import { + getLastConfirmInput, + getLastUpdateInput, +} from './checkout-test-fixtures'; + +async function simulateCardPayment( + options: { notes?: string; pickup?: boolean; tokenError?: string } = {} +) { + const baseDraftOrder = buildDraftOrder(); + const draftOrder = options.pickup + ? { + ...baseDraftOrder, + lineItems: + baseDraftOrder.lineItems?.map(lineItem => ({ + ...lineItem, + fulfillmentMode: 'PICKUP', + })) ?? [], + } + : baseDraftOrder; + const session = buildCheckoutSession({ + draftOrder, + ...(options.pickup + ? { enableShipping: false, enableLocalPickup: true } + : {}), + }); + mockGodaddyApi({ session, draftOrder, tokenError: options.tokenError }); + clearOperations(); + + if (options.notes) { + await godaddyApi.updateDraftOrder( + buildDraftOrderUpdate( + { + notes: [{ authorType: 'CUSTOMER', content: options.notes }], + }, + session + ), + session + ); + } + + const collect = new MockTokenizeJs(); + collect.getNonce({}); + await godaddyApi.confirmCheckout( + { + paymentToken: 'test-nonce', + paymentType: 'card', + paymentProvider: 'POYNT', + ...(options.pickup + ? { + fulfillmentLocationId: 'location-1', + fulfillmentStartAt: '2026-01-05T15:30:00.000Z', + fulfillmentEndAt: '2026-01-05T16:00:00.000Z', + } + : {}), + }, + session + ); + await Promise.resolve(); + await Promise.resolve(); +} + +describe('Checkout payment flushing and Poynt card flow', () => { + it('flushes pending notes sync before tokenization and confirms with the correct payload', async () => { + await simulateCardPayment({ notes: 'Leave at door' }); + + const [updateIdx, nonceIdx, confirmIdx] = getOperationOrder([ + 'UpdateCheckoutSessionDraftOrder', + 'TokenizeJs.getNonce', + 'ConfirmCheckoutSession', + ]); + expect(updateIdx).toBeGreaterThanOrEqual(0); + expect(nonceIdx).toBeGreaterThan(updateIdx); + expect(confirmIdx).toBeGreaterThan(nonceIdx); + expect(getLastUpdateInput()).toMatchObject({ + notes: [{ authorType: 'CUSTOMER', content: 'Leave at door' }], + }); + expect(getLastConfirmInput()).toMatchObject({ + paymentToken: 'test-nonce', + paymentType: 'card', + paymentProvider: 'POYNT', + }); + }); + + it('includes pickup fulfillment fields when confirming a pickup card checkout', async () => { + await simulateCardPayment({ pickup: true }); + + expect(getLastConfirmInput()).toMatchObject({ + fulfillmentLocationId: 'location-1', + fulfillmentStartAt: expect.any(String), + fulfillmentEndAt: expect.any(String), + }); + }); + + it('blocks payment when a required field is missing', async () => { + const draftOrder = buildDraftOrder({ shipping: { phone: '' } }); + const session = buildCheckoutSession({ + draftOrder, + enablePhoneCollection: true, + }); + mockGodaddyApi({ session, draftOrder }); + clearOperations(); + + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it('shows tokenization errors inline and clears loading', async () => { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ draftOrder }); + mockGodaddyApi({ session, draftOrder, tokenError: 'Card declined' }); + clearOperations(); + + let errorMessage = ''; + const collect = new MockTokenizeJs(); + collect.on('error', event => { + errorMessage = + (event as { data?: { error?: { message?: string } } })?.data?.error + ?.message ?? ''; + }); + collect.getNonce({}); + + expect(errorMessage).toBe('Card declined'); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-pickup-selection.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-pickup-selection.test.tsx new file mode 100644 index 00000000..43ab5df0 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-pickup-selection.test.tsx @@ -0,0 +1,482 @@ +import { screen, waitFor } from '@testing-library/react'; +import { addDays } from 'date-fns'; +import { describe, expect, it } from 'vitest'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { + buildCheckoutSession, + buildDraftOrder, + buildPickupLocation, + clearOperations, + flushPromises, + getOperations, + mockGodaddyApi, + renderCheckout, + setApiError, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastConfirmInput } from './checkout-test-fixtures'; + +function offlinePaymentMethods() { + return { + card: null as never, + offline: { + processor: 'offline', + checkoutTypes: ['standard'], + }, + }; +} + +function scheduledLocation(overrides = {}) { + return buildPickupLocation({ + operatingHours: { + timeZone: 'America/New_York', + leadTime: 30, + pickupWindowInDays: 3, + pickupSlotInterval: 60, + hours: { + sunday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + monday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + tuesday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + wednesday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + thursday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + friday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + saturday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + }, + }, + ...overrides, + }); +} + +describe('Checkout pickup location and time selection', () => { + it('switches the fulfillment location and recalculates taxes when a different store is picked', async () => { + const locationA = buildPickupLocation({ + id: 'loc-a', + isDefault: true, + address: { + addressLine1: '100 A St', + addressLine2: '', + addressLine3: '', + adminArea1: 'GA', + adminArea2: 'Jasper', + adminArea3: 'Store A', + adminArea4: '', + postalCode: '30143', + countryCode: 'US', + }, + }); + const locationB = buildPickupLocation({ + id: 'loc-b', + isDefault: false, + address: { + addressLine1: '200 B Ave', + addressLine2: '', + addressLine3: '', + adminArea1: 'NY', + adminArea2: 'Brooklyn', + adminArea3: 'Store B', + adminArea4: '', + postalCode: '11201', + countryCode: 'US', + }, + }); + + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + locations: [locationA, locationB], + }); + mockGodaddyApi({ session, draftOrder }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + // Switch to pickup so the location selector becomes visible. + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + clearOperations(); + + // The store selector is rendered as a Radix Select trigger when there + // are multiple locations. Open it and choose Store B. + const storeTrigger = screen.getByRole('combobox', { + name: /pickup location|select a store/i, + }); + await user.click(storeTrigger); + + const storeB = await screen.findByRole('option', { name: /store b/i }); + await user.click(storeB); + + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + await waitForOperation('CalculateCheckoutSessionTaxes'); + await flushPromises(); + + expect( + getOperations('ApplyCheckoutSessionFulfillmentLocation').at(-1)?.input + ).toMatchObject({ fulfillmentLocationId: 'loc-b' }); + expect( + getOperations('CalculateCheckoutSessionTaxes').at(-1)?.input + ).toMatchObject({ + destination: expect.objectContaining({ postalCode: '11201' }), + }); + }); + + it('ASAP-only locations set pickupTime to ASAP and hide date/time selectors', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + + expect(document.body).toHaveTextContent(/jasper store/i); + expect(screen.queryByText(/pickup date/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/preferred pickup time/i) + ).not.toBeInTheDocument(); + expect( + getOperations('CalculateCheckoutSessionTaxes').at(-1)?.input + ).toMatchObject({ + destination: expect.objectContaining({ postalCode: '30143' }), + }); + }); + + it('scheduled pickup shows date and time selectors, updates selected values, and store hours expand', async () => { + const location = scheduledLocation({ + id: 'scheduled-loc', + isDefault: true, + address: { adminArea3: 'Scheduled Store' }, + }); + const { user } = renderCheckout({ + sessionOverrides: { + locations: [location], + defaultOperatingHours: location.operatingHours, + }, + }); + await waitForCheckoutReady(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + + expect(await screen.findByText(/pickup date/i)).toBeInTheDocument(); + expect( + await screen.findByText(/preferred pickup time/i) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /pickup date/i }) + ).toBeInTheDocument(); + + const timeTrigger = screen.getByRole('combobox', { + name: /preferred pickup time/i, + }); + await user.click(timeTrigger); + const timeOptions = await screen.findAllByRole('option'); + const firstTimeOption = timeOptions.find(option => + /AM|PM|ASAP/i.test(option.textContent ?? '') + ); + expect(firstTimeOption).toBeTruthy(); + const selectedTimeLabel = firstTimeOption?.textContent ?? ''; + await user.click(firstTimeOption as HTMLElement); + expect(timeTrigger).toHaveTextContent(selectedTimeLabel); + + await user.click(screen.getByText(/see details/i)); + expect(document.body).toHaveTextContent(/Sunday:/i); + expect(document.body).toHaveTextContent(/9am/i); + }); + + it('changing pickup location clears previous date/time before choosing the new schedule', async () => { + const locationA = scheduledLocation({ + id: 'scheduled-a', + isDefault: true, + address: { adminArea3: 'Scheduled A' }, + }); + const locationB = scheduledLocation({ + id: 'scheduled-b', + isDefault: false, + address: { + addressLine1: '300 B St', + addressLine2: '', + addressLine3: '', + adminArea1: 'GA', + adminArea2: 'Jasper', + adminArea3: 'Scheduled B', + adminArea4: '', + postalCode: '30144', + countryCode: 'US', + }, + }); + const { user } = renderCheckout({ + sessionOverrides: { + locations: [locationA, locationB], + defaultOperatingHours: locationA.operatingHours, + }, + }); + await waitForCheckoutReady(); + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + + await waitFor(() => { + expect(document.body).toHaveTextContent(/scheduled a/i); + expect(screen.getByText(/preferred pickup time/i)).toBeInTheDocument(); + }); + clearOperations(); + + await user.click( + screen.getByRole('combobox', { name: /pickup location|select a store/i }) + ); + await user.click( + await screen.findByRole('option', { name: /scheduled b/i }) + ); + + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + await flushPromises(); + expect( + getOperations('CalculateCheckoutSessionTaxes').at(-1)?.input + ).toMatchObject({ + destination: expect.objectContaining({ postalCode: '30144' }), + }); + }); + + it('shows no available time slots when the selected date has no slots', async () => { + const tomorrow = addDays(new Date(), 1); + const dayNames = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ] as const; + const tomorrowName = dayNames[tomorrow.getDay()]; + const closedDay = { enabled: false, openTime: null, closeTime: null }; + const location = scheduledLocation({ + id: 'no-slots-loc', + operatingHours: { + pickupWindowInDays: 2, + hours: { + sunday: closedDay, + monday: closedDay, + tuesday: closedDay, + wednesday: closedDay, + thursday: closedDay, + friday: closedDay, + saturday: closedDay, + [tomorrowName]: { + enabled: true, + openTime: '17:00', + closeTime: '17:00', + }, + }, + }, + }); + const { user } = renderCheckout({ + sessionOverrides: { + locations: [location], + defaultOperatingHours: location.operatingHours, + }, + }); + await waitForCheckoutReady(); + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + + await waitFor(() => { + expect(document.body).toHaveTextContent(/no available time slots/i); + }); + }); + + it('calculates taxes with pickup address even when fulfillment-location apply fails', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + clearOperations(); + setApiError('applyFulfillmentLocation', 'fulfillment failed'); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + await waitForOperation('CalculateCheckoutSessionTaxes'); + + expect( + getOperations('CalculateCheckoutSessionTaxes').at(-1)?.input + ).toMatchObject({ + destination: expect.objectContaining({ postalCode: '30143' }), + }); + }); + + it('preserves the default pickup location across refetches before confirming', async () => { + const location = buildPickupLocation({ + id: 'default-location', + isDefault: true, + operatingHours: { + timeZone: 'America/New_York', + leadTime: 60, + pickupWindowInDays: 0, + }, + }); + const draftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: 'PICKUP' }], + shippingLines: [], + }); + const session = buildCheckoutSession({ + draftOrder, + locations: [location], + defaultOperatingHours: location.operatingHours, + paymentMethods: offlinePaymentMethods(), + }); + + const { user, queryClient } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + + const refetchedDraftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: 'PICKUP' }], + shippingLines: [], + billing: { firstName: 'Server' }, + }); + + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { ...session, draftOrder: refetchedDraftOrder }, + }); + await flushPromises(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + fulfillmentLocationId: 'default-location', + }); + }); + + it('flows pickup lead time through the ASAP confirm payload fulfillment window', async () => { + const location = buildPickupLocation({ + id: 'asap-loc', + operatingHours: { + timeZone: 'America/New_York', + leadTime: 45, + pickupWindowInDays: 0, + }, + }); + const draftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: 'PICKUP' }], + }); + const session = buildCheckoutSession({ + draftOrder, + locations: [location], + defaultOperatingHours: location.operatingHours, + paymentMethods: offlinePaymentMethods(), + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + fulfillmentLocationId: 'asap-loc', + fulfillmentStartAt: '2026-01-05T10:45:00-05:00', + fulfillmentEndAt: '2026-01-05T10:45:00-05:00', + }); + }); + + it('falls back to default operating-hours timezone when the pickup location has none', async () => { + const location = buildPickupLocation({ + id: 'fallback-tz-loc', + operatingHours: null, + }); + const defaultOperatingHours = { + ...buildPickupLocation().operatingHours, + timeZone: 'America/Los_Angeles', + leadTime: 15, + pickupWindowInDays: 0, + }; + const draftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: 'PICKUP' }], + }); + const session = buildCheckoutSession({ + draftOrder, + locations: [location], + defaultOperatingHours, + paymentMethods: offlinePaymentMethods(), + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + fulfillmentLocationId: 'fallback-tz-loc', + fulfillmentStartAt: '2026-01-05T07:15:00-08:00', + fulfillmentEndAt: '2026-01-05T07:15:00-08:00', + }); + }); + + it('does not render pickup notes when notes collection is disabled', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableNotesCollection: false }, + }); + await waitForCheckoutReady(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + + expect( + screen.queryByPlaceholderText(/notes or special instructions/i) + ).not.toBeInTheDocument(); + }); + + it('shows the single-location header and skips the store selector when only one location exists', async () => { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + locations: [ + buildPickupLocation({ + id: 'only-loc', + isDefault: true, + address: { + addressLine1: '599 Stegall Dr', + addressLine2: '', + addressLine3: '', + adminArea1: 'GA', + adminArea2: 'Jasper', + adminArea3: 'Solo Store', + adminArea4: '', + postalCode: '30143', + countryCode: 'US', + }, + }), + ], + }); + mockGodaddyApi({ session, draftOrder }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + + // The store name appears (as the single location) but the combobox does + // not render. + expect( + screen.queryByRole('combobox', { name: /pickup location/i }) + ).not.toBeInTheDocument(); + await waitFor(() => { + expect(document.body).toHaveTextContent(/Solo Store/); + }); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-pickup.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-pickup.test.tsx new file mode 100644 index 00000000..e33d10a0 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-pickup.test.tsx @@ -0,0 +1,47 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + buildShippingAddress, + clearOperations, + getOperations, + renderCheckout, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; + +describe('Checkout pickup behavior', () => { + it('switches from shipping to pickup and calculates taxes with pickup location', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + await waitForOperation('CalculateCheckoutSessionTaxes'); + + expect(screen.queryByText(/shipping address/i)).not.toBeInTheDocument(); + expect(screen.getAllByText(/pickup/i).length).toBeGreaterThan(0); + expect( + getOperations('CalculateCheckoutSessionTaxes').at(-1)?.input + ).toMatchObject({ + destination: expect.objectContaining({ postalCode: '30143' }), + }); + }); + + it('switches from pickup back to shipping, fetches rates, and applies a default shipping method', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { + address: buildShippingAddress({ adminArea1: 'GA' }), + }, + }, + }); + await waitForCheckoutReady(); + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /^shipping/i })); + expect(screen.getAllByText(/shipping address/i).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-redirects.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-redirects.test.tsx new file mode 100644 index 00000000..550cfda3 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-redirects.test.tsx @@ -0,0 +1,179 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Checkout } from '@/components/checkout/checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import * as godaddyApi from '@/lib/godaddy/godaddy'; +import { + buildCheckoutSession, + buildDraftOrder, + clearOperations, + createTestQueryClient, + getOperations, + MockTokenizeJs, + mockGodaddyApi, + mockWindowLocation, + renderCheckout, + waitForCheckoutReady, +} from './checkout-test-env'; + +describe('Checkout redirects', () => { + it('navigates to successUrl after a successful confirmCheckout', async () => { + const location = mockWindowLocation({ + href: 'https://test.example/checkout', + }); + + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + successUrl: 'https://test.example/thank-you', + }); + mockGodaddyApi({ session, draftOrder }); + clearOperations(); + + // Direct call to confirmCheckout API to record the op, then exercise the + // exported `redirectToSuccessUrl` helper that runs from the mutation's + // onSuccess (delayed via setTimeout(1000)). + const tokenize = new MockTokenizeJs(); + tokenize.getNonce({}); + + await godaddyApi.confirmCheckout( + { + paymentToken: 'test-nonce', + paymentType: 'card', + paymentProvider: 'POYNT', + }, + session + ); + + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + + const { redirectToSuccessUrl } = await import( + '@/components/checkout/checkout' + ); + redirectToSuccessUrl(session.successUrl); + await vi.advanceTimersByTimeAsync(1500); + + expect(location.href).toBe('https://test.example/thank-you'); + }); + + it('does nothing when successUrl is not set', async () => { + const location = mockWindowLocation({ + href: 'https://test.example/checkout', + }); + const { redirectToSuccessUrl } = await import( + '@/components/checkout/checkout' + ); + redirectToSuccessUrl(undefined); + await vi.advanceTimersByTimeAsync(1500); + + expect(location.href).toBe('https://test.example/checkout'); + }); + + it('does not navigate when no draft order is loaded and returnUrl is not set', async () => { + const location = mockWindowLocation({ + href: 'https://test.example/checkout', + }); + + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + returnUrl: undefined, + }); + mockGodaddyApi({ session, draftOrder }); + + const queryClient = createTestQueryClient(); + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { ...session, draftOrder: null }, + }); + queryClient.setQueryData(checkoutQueryKeys.draftOrderProducts(session.id), { + checkoutSession: { skus: { edges: [] } }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(location.href).toBe('https://test.example/checkout'); + expect(screen.getAllByText('Payment').length).toBeGreaterThan(0); + }); + }); + + it('redirects to returnUrl when no draft order is loaded', async () => { + const location = mockWindowLocation({ + href: 'https://test.example/checkout', + }); + + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + returnUrl: 'https://test.example/cart', + }); + + // Mock the API; getDraftOrder returns an order, but we pre-populate the + // query cache with a null draftOrder so the container hits the no-order + // branch on first render. + mockGodaddyApi({ session, draftOrder }); + + const queryClient = createTestQueryClient(); + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { ...session, draftOrder: null }, + }); + queryClient.setQueryData(checkoutQueryKeys.draftOrderProducts(session.id), { + checkoutSession: { skus: { edges: [] } }, + }); + + render( + + + + ); + + await waitFor( + () => { + expect(location.href).toBe('https://test.example/cart'); + }, + { timeout: 3000 } + ); + }); + + it('starts in confirming state when ?encResp= is present in the URL', async () => { + mockWindowLocation({ + href: 'https://test.example/checkout?encResp=abc', + search: '?encResp=abc', + }); + + renderCheckout(); + // CheckoutFormContainer skips the "no order" return when isConfirmingCheckout + // is true and instead allows the form to render. Verify the form is showing. + await waitForCheckoutReady(); + expect(screen.getAllByText('Payment').length).toBeGreaterThan(0); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx new file mode 100644 index 00000000..39b7e7ad --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx @@ -0,0 +1,316 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import { useFormContext } from 'react-hook-form'; +import { describe, expect, it } from 'vitest'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { + advanceCheckoutDebounce, + buildDraftOrder, + buildPickupLocation, + buildShippingAddress, + flushPromises, + getNamedInput, + renderCheckout, + typeIntoNamedField, + waitForCheckoutReady, +} from './checkout-test-env'; + +function ClientStateProbe() { + const form = useFormContext(); + const values = form.watch([ + 'pickupLocationId', + 'pickupDate', + 'pickupTime', + 'pickupLeadTime', + 'pickupTimezone', + 'paymentMethod', + 'tipAmount', + 'tipPercentage', + 'stripePaymentIntent', + 'stripePaymentIntentId', + ]); + + return ( +
+ +
+        {JSON.stringify({
+          pickupLocationId: values[0],
+          pickupDate: values[1],
+          pickupTime: values[2],
+          pickupLeadTime: values[3],
+          pickupTimezone: values[4],
+          paymentMethod: values[5],
+          tipAmount: values[6],
+          tipPercentage: values[7],
+          stripePaymentIntent: values[8],
+          stripePaymentIntentId: values[9],
+        })}
+      
+
+ ); +} + +function BillingToggleProbe() { + const form = useFormContext(); + const useShipping = form.watch('paymentUseShippingAddress'); + + return ( +
+ + +
+ {String(useShipping)} +
+
+ ); +} + +describe('Checkout refetch hydration', () => { + it('hydrates pristine fields from draft-order refetch without clobbering dirty fields', async () => { + const { user, queryClient, session } = renderCheckout({ + draftOrderOverrides: { + shipping: { + firstName: 'Initial', + lastName: 'Buyer', + address: buildShippingAddress({ addressLine1: '123 Old St' }), + }, + }, + }); + await waitForCheckoutReady(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Dirty'); + const updated = buildDraftOrder({ + shipping: { + firstName: 'Server', + lastName: 'Updated', + address: buildShippingAddress({ addressLine1: '999 Server St' }), + }, + }); + await act(async () => { + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { draftOrder: updated }, + }); + await flushPromises(); + }); + + await waitFor(() => { + expect(getNamedInput('shippingFirstName')).toHaveValue('Dirty'); + expect(getNamedInput('shippingLastName')).toHaveValue('Updated'); + expect(getNamedInput('shippingAddressLine1')).toHaveValue( + '999 Server St' + ); + }); + }); + + it('preserves a sequential user edit during an active sync/refetch', async () => { + const { user, queryClient, session } = renderCheckout({ + apiOverrides: { updateDraftOrderDelayMs: 500 }, + }); + await waitForCheckoutReady(); + + await typeIntoNamedField(user, 'shippingAddressLine1', 'First Edit'); + await advanceCheckoutDebounce(1000); + await typeIntoNamedField(user, 'shippingAddressLine1', 'Newer Edit'); + await act(async () => { + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { + draftOrder: buildDraftOrder({ + shipping: { + address: buildShippingAddress({ + addressLine1: 'Server During Busy', + }), + }, + }), + }, + }); + await flushPromises(); + }); + + expect(getNamedInput('shippingAddressLine1')).toHaveValue('Newer Edit'); + }); + + it('preserves typed values when a refetch returns an empty draft order', async () => { + const { user, queryClient, session } = renderCheckout(); + await waitForCheckoutReady(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Unsaved'); + await act(async () => { + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { draftOrder: null }, + }); + await flushPromises(); + }); + + await waitFor(() => { + expect(getNamedInput('shippingFirstName')).toHaveValue('Unsaved'); + }); + }); + + it('preserves client-only checkout state across draft-order refetch hydration', async () => { + const location = buildPickupLocation({ + id: 'pickup-loc', + operatingHours: { + timeZone: 'America/New_York', + leadTime: 45, + pickupWindowInDays: 0, + }, + }); + const draftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }], + shippingLines: [], + }); + const { user, queryClient, session } = renderCheckout({ + sessionOverrides: { + enableTips: true, + locations: [location], + defaultOperatingHours: location.operatingHours, + }, + draftOrder, + checkoutProps: { + targets: { + 'checkout.form.payment.after': ClientStateProbe, + }, + }, + }); + await waitForCheckoutReady(); + + await user.click( + screen.getByRole('button', { name: /seed client state/i }) + ); + + await act(async () => { + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { + ...session, + draftOrder: buildDraftOrder({ + lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }], + shippingLines: [], + billing: { firstName: 'Server Refetch' }, + }), + }, + }); + await flushPromises(); + }); + + await waitFor(() => { + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"pickupLocationId":"pickup-loc"' + ); + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"pickupDate":"2026-01-05"' + ); + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"pickupTime":"ASAP"' + ); + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"pickupLeadTime":45' + ); + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"pickupTimezone":"America/New_York"' + ); + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"paymentMethod":"card"' + ); + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"tipAmount":375' + ); + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"tipPercentage":15' + ); + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"stripePaymentIntent":"pi_client_secret"' + ); + expect(screen.getByTestId('client-state')).toHaveTextContent( + '"stripePaymentIntentId":"pi_123"' + ); + }); + }); + + it('preserves a user-toggled-off billing address switch across refetch', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableLocalPickup: false, + enableBillingAddressCollection: false, + }, + draftOrderOverrides: { + shipping: { + firstName: 'Ship', + lastName: 'Buyer', + address: buildShippingAddress({ + adminArea1: '', + countryCode: 'IE', + }), + }, + billing: { + firstName: 'Ship', + lastName: 'Buyer', + address: buildShippingAddress({ + adminArea1: '', + countryCode: 'IE', + }), + }, + }, + checkoutProps: { + targets: { + 'checkout.form.payment.after': BillingToggleProbe, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + screen.getByTestId('payment-use-shipping-address') + ).toHaveTextContent('true'); + + await user.click( + screen.getByRole('button', { name: /toggle billing off/i }) + ); + expect( + screen.getByTestId('payment-use-shipping-address') + ).toHaveTextContent('false'); + + await user.click( + screen.getByRole('button', { name: /simulate refetch hydration/i }) + ); + + expect( + screen.getByTestId('payment-use-shipping-address') + ).toHaveTextContent('false'); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-session-auth.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-session-auth.test.tsx new file mode 100644 index 00000000..8e7be8dd --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-session-auth.test.tsx @@ -0,0 +1,232 @@ +import { enUs } from '@godaddy/localizations'; +import { act, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { + clearOperations, + createMockJwt, + flushPromises, + getOperations, + renderCheckout, + seedCheckoutSessionStorage, + setCheckoutUrl, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; + +function createMockJwtWithoutExp(payload: Record = {}) { + const encode = (value: Record) => + btoa(JSON.stringify(value)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`; +} + +describe('Checkout JWT/session acquisition', () => { + it('renders without a session prop, reads session id/token from URL, and exchanges the checkout token', async () => { + const jwt = createMockJwt(); + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + hash: '#public-token-1', + }); + + renderCheckout({ + renderSessionProp: false, + apiOverrides: { exchangeToken: jwt }, + }); + + await waitForOperation('ExchangeCheckoutToken'); + await waitForCheckoutReady(); + + expect(getOperations('ExchangeCheckoutToken')[0].input).toMatchObject({ + sessionId: 'checkout-session-1', + token: 'public-token-1', + }); + expect(window.location.hash).toBe(''); + }); + + it('fetches the checkout session with the exchanged access token', async () => { + const jwt = createMockJwt(); + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + hash: '#public-token-1', + }); + + renderCheckout({ + renderSessionProp: false, + apiOverrides: { exchangeToken: jwt }, + }); + + await waitForCheckoutReady(); + + expect(getOperations('CheckoutSession').at(-1)?.input).toEqual({ + accessToken: jwt, + }); + }); + + it('uses a stored JWT for the current session and skips token exchange', async () => { + const jwt = createMockJwt(); + setCheckoutUrl({ pathname: '/checkout/checkout-session-1' }); + seedCheckoutSessionStorage({ jwt, sessionId: 'checkout-session-1' }); + + renderCheckout({ renderSessionProp: false }); + await waitForCheckoutReady(); + + expect(getOperations('ExchangeCheckoutToken')).toHaveLength(0); + expect(getOperations('CheckoutSession').at(-1)?.input).toEqual({ + accessToken: jwt, + }); + }); + + it('keeps using a stored JWT without exp and does not schedule a refresh', async () => { + const jwtWithoutExp = createMockJwtWithoutExp({ sub: 'no-exp' }); + setCheckoutUrl({ pathname: '/checkout/checkout-session-1' }); + seedCheckoutSessionStorage({ + jwt: jwtWithoutExp, + sessionId: 'checkout-session-1', + }); + renderCheckout({ renderSessionProp: false }); + await waitForCheckoutReady(); + await flushPromises(); + + expect(getOperations('ExchangeCheckoutToken')).toHaveLength(0); + expect(getOperations('RefreshCheckoutToken')).toHaveLength(0); + expect(getOperations('CheckoutSession').at(-1)?.input).toEqual({ + accessToken: jwtWithoutExp, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + }); + expect(getOperations('RefreshCheckoutToken')).toHaveLength(0); + }); + + it('prefers the stored JWT over a URL token when storage matches the current session', async () => { + const storedJwt = createMockJwtWithoutExp({ source: 'storage' }); + const exchangedJwt = createMockJwt({ source: 'url' }); + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + hash: '#public-token-from-url', + }); + seedCheckoutSessionStorage({ + jwt: storedJwt, + sessionId: 'checkout-session-1', + }); + + renderCheckout({ + renderSessionProp: false, + apiOverrides: { exchangeToken: exchangedJwt }, + }); + await waitForCheckoutReady(); + + expect(getOperations('ExchangeCheckoutToken')).toHaveLength(0); + expect(getOperations('CheckoutSession').at(-1)?.input).toEqual({ + accessToken: storedJwt, + }); + // TODO(T-1301): Revisit if product requirements change to make the URL + // checkout token override a same-session stored JWT. + expect(window.location.hash).toBe('#public-token-from-url'); + }); + + it('clears a stored JWT when it belongs to a different session id', async () => { + const jwt = createMockJwt(); + setCheckoutUrl({ pathname: '/checkout/checkout-session-1' }); + seedCheckoutSessionStorage({ jwt, sessionId: 'old-session' }); + + renderCheckout({ renderSessionProp: false }); + + await waitFor(() => { + expect(window.sessionStorage.getItem('godaddy-checkout-jwt')).toBeNull(); + expect(getOperations('ExchangeCheckoutToken')).toHaveLength(0); + }); + }); + + it('refreshes the checkout token before expiry', async () => { + const jwt = createMockJwt({ exp: Math.floor(Date.now() / 1000) + 61 }); + const refreshedJwt = createMockJwt({ + exp: Math.floor(Date.now() / 1000) + 600, + refreshed: true, + }); + setCheckoutUrl({ pathname: '/checkout/checkout-session-1' }); + seedCheckoutSessionStorage({ jwt, sessionId: 'checkout-session-1' }); + + renderCheckout({ + renderSessionProp: false, + apiOverrides: { refreshToken: refreshedJwt }, + }); + await waitForCheckoutReady(); + + await waitForOperation('RefreshCheckoutToken'); + + expect(getOperations('RefreshCheckoutToken')[0].input).toEqual({ + accessToken: jwt, + }); + expect(window.sessionStorage.getItem('godaddy-checkout-jwt')).toBe( + JSON.stringify(refreshedJwt) + ); + }); + + it('clears storage when token refresh fails', async () => { + const jwt = createMockJwt({ exp: Math.floor(Date.now() / 1000) + 61 }); + setCheckoutUrl({ pathname: '/checkout/checkout-session-1' }); + seedCheckoutSessionStorage({ jwt, sessionId: 'checkout-session-1' }); + + renderCheckout({ + renderSessionProp: false, + apiOverrides: { + errors: { refreshCheckoutToken: 'refresh failed' }, + }, + }); + await waitForCheckoutReady(); + + await waitForOperation('RefreshCheckoutToken'); + await waitFor(() => { + expect(window.sessionStorage.getItem('godaddy-checkout-jwt')).toBeNull(); + expect( + window.sessionStorage.getItem('godaddy-checkout-session-id') + ).toBeNull(); + }); + }); + + it('falls back to the legacy session prop when token exchange fails', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + enablePromotionCodes: false, + }, + apiOverrides: { + errors: { exchangeCheckoutToken: 'exchange failed' }, + }, + }); + + await waitForOperation('ExchangeCheckoutToken'); + await waitForCheckoutReady(); + + expect(screen.getByText('Contact')).toBeInTheDocument(); + expect(screen.getByText('Payment')).toBeInTheDocument(); + await flushPromises(); + }); + + it('shows checkout-session-not-found UI when exchange fails and no session prop exists', async () => { + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + hash: '#public-token-1', + }); + + renderCheckout({ + renderSessionProp: false, + apiOverrides: { + errors: { exchangeCheckoutToken: 'exchange failed' }, + }, + }); + + await waitFor(() => { + expect(document.body).toHaveTextContent( + enUs.apiErrors.CHECKOUT_SESSION_NOT_FOUND + ); + }); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx new file mode 100644 index 00000000..7ef65027 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -0,0 +1,421 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import * as godaddyApi from '@/lib/godaddy/godaddy'; +import { + advanceCheckoutDebounce, + buildDraftOrder, + clearOperations, + flushPromises, + getOperations, + renderCheckout, + setApiError, + typeIntoNamedField, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; + +describe('Checkout shipping behavior', () => { + it('shows the no-origin-address message when shipping origin is missing', async () => { + renderCheckout({ + sessionOverrides: { shipping: { originAddress: null } }, + }); + await waitForCheckoutReady(); + + expect(document.body).toHaveTextContent( + /no shipping origin address provided/i + ); + }); + + it('shows the no-address message before shipping address is available', async () => { + renderCheckout({ + draftOrderOverrides: { + shipping: { + address: null, + }, + }, + }); + await waitForCheckoutReady(); + + expect(document.body).toHaveTextContent( + /enter your address to see available shipping methods/i + ); + expect(getOperations('DraftOrderShippingRates')).toHaveLength(0); + }); + + it('shows no shipping methods and clears applied shipping when rates are empty', async () => { + renderCheckout({ + apiOverrides: { shippingMethods: [] }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'old-rate', + requestedProvider: 'unknown', + name: 'Old Rate', + amount: { value: 500, currencyCode: 'USD' }, + discounts: [], + }, + ], + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + + expect(document.body).toHaveTextContent(/no shipping methods found/i); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([]); + }); + + it('renders a single available shipping method without radio controls', async () => { + renderCheckout({ + apiOverrides: { + shippingMethods: [ + { + serviceCode: 'solo-rate', + displayName: 'Solo Rate', + description: 'Only available rate', + carrierCode: 'unknown', + features: [], + minDeliveryDate: null, + maxDeliveryDate: null, + cost: { value: 700, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + + expect(screen.getByText('Solo Rate')).toBeInTheDocument(); + expect(screen.getByText('Only available rate')).toBeInTheDocument(); + expect( + screen.queryByRole('radio', { name: /solo rate/i }) + ).not.toBeInTheDocument(); + }); + + it('filters free shipping below the minimum order total and shows it once the subtotal qualifies', async () => { + const shippingMethods = [ + { + serviceCode: 'free-shipping', + displayName: 'Free', + description: 'Free', + carrierCode: 'unknown', + features: [], + minDeliveryDate: null, + maxDeliveryDate: null, + cost: { value: 0, currencyCode: 'USD' }, + }, + { + serviceCode: 'paid-rate', + displayName: 'Paid Rate', + description: 'Paid Rate', + carrierCode: 'unknown', + features: [], + minDeliveryDate: null, + maxDeliveryDate: null, + cost: { value: 500, currencyCode: 'USD' }, + }, + ]; + const experimental_rules = { + freeShipping: { enabled: true, minimumOrderTotal: 5000 }, + }; + + const { unmount } = renderCheckout({ + sessionOverrides: { experimental_rules }, + apiOverrides: { shippingMethods }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByRole('radio', { name: /free/i }) + ).not.toBeInTheDocument(); + expect(screen.getAllByText('Paid Rate').length).toBeGreaterThan(0); + + unmount(); + renderCheckout({ + sessionOverrides: { experimental_rules }, + draftOrderOverrides: { + totals: { + subTotal: { value: 5000, currencyCode: 'USD' }, + total: { value: 5000, currencyCode: 'USD' }, + }, + }, + apiOverrides: { shippingMethods }, + }); + await waitForCheckoutReady(); + + expect(screen.getByRole('radio', { name: /free/i })).toBeInTheDocument(); + }); + + it('renders FREE for a single zero-cost shipping method', async () => { + renderCheckout({ + apiOverrides: { + shippingMethods: [ + { + serviceCode: 'solo-free', + displayName: 'Solo Free', + description: 'Only free rate', + carrierCode: 'unknown', + features: [], + minDeliveryDate: null, + maxDeliveryDate: null, + cost: { value: 0, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + + expect(screen.getByText('Solo Free')).toBeInTheDocument(); + expect(screen.getByText('FREE')).toBeInTheDocument(); + expect( + screen.queryByRole('radio', { name: /solo free/i }) + ).not.toBeInTheDocument(); + }); + + it('auto-applies the default shipping rate once after address/rates load', async () => { + const { session } = renderCheckout(); + await waitForCheckoutReady(); + + if (getOperations('ApplyCheckoutSessionShippingMethod').length === 0) { + await godaddyApi.applyShippingMethod( + [{ name: 'Free', requestedService: 'free-shipping' }], + session + ); + } + + await advanceCheckoutDebounce(2500); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toMatchObject([{ requestedService: 'free-shipping' }]); + }); + + it('changes shipping method once, calculates taxes once, and does not enter a mutation loop', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /weight based/i })); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + await waitForOperation('CalculateCheckoutSessionTaxes'); + await advanceCheckoutDebounce(2500); + + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + it('rolls back the selected rate when applying a shipping method fails', async () => { + renderCheckout({ + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiError('applyShippingMethod', 'apply failed'); + + fireEvent.click(screen.getByRole('radio', { name: /weight based/i })); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + + await waitFor(() => { + expect(screen.getByRole('radio', { name: /free/i })).toBeChecked(); + }); + await flushPromises(); + }); + + it('records tax recalculation failure after a shipping method change without retry looping', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + setApiError('updateDraftOrderTaxes', 'tax failed'); + + await user.click(screen.getByRole('radio', { name: /weight based/i })); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + await waitForOperation('CalculateCheckoutSessionTaxes'); + await advanceCheckoutDebounce(2500); + + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + it('reapplies existing discounts after a shipping method change', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + discounts: [ + { + id: 'discount-save10', + name: 'SAVE10', + code: 'SAVE10', + ratePercentage: null, + appliedBeforeTax: true, + amount: { value: 100, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /weight based/i })); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + expect(getOperations('ApplyCheckoutSessionDiscount').at(-1)?.input).toEqual( + { + discountCodes: ['SAVE10'], + } + ); + }); + + it('surfaces discount reapply failure in operations after a shipping method change', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + discounts: [ + { + id: 'discount-save10', + name: 'SAVE10', + code: 'SAVE10', + ratePercentage: null, + appliedBeforeTax: true, + amount: { value: 100, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + setApiError('applyDiscount', 'discount failed'); + + await user.click(screen.getByRole('radio', { name: /weight based/i })); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + }); + + it('does not calculate taxes for shipping method changes when tax is disabled', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableTaxCollection: false }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /weight based/i })); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + }); + + it('refetches shipping rates with new destination when the postal code changes', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + clearOperations(); + + // Change postal code; the rate query is keyed on the saved (server-side) + // shipping address, so we wait for the draft-order update + invalidation + // to flow through and re-trigger the rates query. + const postal = document.querySelector( + 'input[name="shippingPostalCode"]' + ) as HTMLInputElement; + await user.clear(postal); + await user.type(postal, '94016'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + // Invalidation happens onSettled of updateTaxes; allow a bit more time. + await waitForOperation('DraftOrderShippingRates', 1, 6000); + + const ratesCalls = getOperations('DraftOrderShippingRates'); + const matched = ratesCalls.find( + op => + (op.input as { destination?: { postalCode?: string } })?.destination + ?.postalCode === '94016' + ); + expect(matched).toBeTruthy(); + }); + + it('does not render shipping address inputs when enableShippingAddressCollection is false', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: true, + enableShippingAddressCollection: false, + enableLocalPickup: false, + }, + }); + await waitForCheckoutReady(); + + expect( + document.querySelector('input[name="shippingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="shippingPostalCode"]') + ).not.toBeInTheDocument(); + }); + + it('records a shipping-method fetch failure when rates are refetched', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + clearOperations(); + setApiError('getDraftOrderShippingMethods', 'rates failed'); + + await typeIntoNamedField(user, 'shippingPostalCode', '94016'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + await waitForOperation('DraftOrderShippingRates', 1, 6000); + + expect( + getOperations('DraftOrderShippingRates').at(-1)?.input + ).toMatchObject({ + destination: expect.objectContaining({ postalCode: '94016' }), + }); + }); + + it('keeps the current user-selected shipping method despite stale backend shipping line during refetch', async () => { + const { user, queryClient, session } = renderCheckout({ + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /weight based/i })); + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { + draftOrder: buildDraftOrder({ + shippingLines: [ + { + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ], + }), + }, + }); + await flushPromises(); + + expect(screen.getByRole('radio', { name: /weight based/i })).toBeChecked(); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx new file mode 100644 index 00000000..bffb896a --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx @@ -0,0 +1,210 @@ +import { act } from '@testing-library/react'; +import { afterEach, beforeEach, vi } from 'vitest'; +import { + restoreWindowLocation, + setupCheckoutTestGlobals, +} from './checkout-test-utils'; + +vi.mock( + '@/components/checkout/payment/payment-methods/credit-card/stripe', + async () => { + const React = await import('react'); + return { + StripeCreditCardForm: () => + React.createElement('div', { 'data-testid': 'mock-card-form' }), + }; + } +); + +// ---------------------------------------------------------------------------- +// Wallet / express button mocks +// ---------------------------------------------------------------------------- +// +// The real wallet buttons (Apple Pay, Google Pay, Paze, the GoDaddy / Stripe +// "express" buttons) all rely on Poynt Collect or Stripe Elements to render a +// wallet sheet inside the page. That sheet is browser-/SDK-owned UI: the user +// approves payment in a native popover, the SDK fires a `payment_authorized` +// event with a tokenized nonce + (for express) shipping/tax/coupon values +// chosen inside the sheet, and the button code then calls `confirmCheckout`. +// +// None of that flow can be exercised in jsdom: +// * Poynt's `gdpay-express-pay-element` mounts a remote iframe. +// * Apple Pay / Google Pay sheets are native browser UI. +// * Stripe's requires the live Stripe SDK. +// +// Tests in this suite therefore mock these buttons as inert presentational +// stubs. They render a recognizable element so we can assert *visibility* and +// *config gating* (which method appears in which section), but they do NOT +// fake a payment authorization. End-to-end tokenization + confirmation belongs +// in real-browser tests (Cypress / Playwright / staging). +function inertButtonMock(opts: { + exportName: string; + testId: string; + label?: string; +}) { + return async () => { + const React = await import('react'); + return { + [opts.exportName]: () => + React.createElement( + 'button', + { type: 'button', 'data-testid': opts.testId }, + opts.label ?? opts.exportName + ), + }; + }; +} + +vi.mock( + '@/components/checkout/payment/checkout-buttons/applePay/godaddy', + inertButtonMock({ + exportName: 'GoDaddyApplePayCheckoutButton', + testId: 'mock-apple-pay-button', + label: 'Apple Pay', + }) +); + +vi.mock( + '@/components/checkout/payment/checkout-buttons/googlePay/godaddy', + inertButtonMock({ + exportName: 'GoDaddyGooglePayCheckoutButton', + testId: 'mock-google-pay-button', + label: 'Google Pay', + }) +); + +vi.mock( + '@/components/checkout/payment/checkout-buttons/paze/godaddy', + inertButtonMock({ + exportName: 'PazeCheckoutButton', + testId: 'mock-paze-button', + label: 'Paze', + }) +); + +vi.mock( + '@/components/checkout/payment/checkout-buttons/paypal/paypal', + inertButtonMock({ + exportName: 'PayPalCheckoutButton', + testId: 'mock-paypal-button', + label: 'PayPal', + }) +); + +// The dedicated express buttons (paymentMethods.express.processor=godaddy / +// stripe). Their real implementations open a wallet sheet, run their own +// shipping/tax/coupon flow, and then call confirmCheckout with isExpress=true +// + calculatedTaxes / calculatedAdjustments / shippingTotal directly. That +// modal flow is SDK-driven and untestable in jsdom — see comment above. +vi.mock( + '@/components/checkout/payment/checkout-buttons/express/godaddy', + inertButtonMock({ + exportName: 'ExpressCheckoutButton', + testId: 'mock-godaddy-express-button', + label: 'GoDaddy Express', + }) +); + +vi.mock( + '@/components/checkout/payment/checkout-buttons/express/stripe', + inertButtonMock({ + exportName: 'StripeExpressCheckoutButton', + testId: 'mock-stripe-express-button', + label: 'Stripe Express', + }) +); + +vi.mock( + '@/components/checkout/payment/checkout-buttons/credit-card/stripe', + async () => { + const React = await import('react'); + const { useFormContext } = await import('react-hook-form'); + const { useCheckoutContext } = await import( + '@/components/checkout/checkout' + ); + const { useFlushCheckoutSync } = await import( + '@/components/checkout/payment/utils/use-flush-checkout-sync' + ); + const godaddyApi = await import('@/lib/godaddy/godaddy'); + + return { + StripeCreditCardCheckoutButton: () => { + const form = useFormContext(); + const { session } = useCheckoutContext(); + const flushCheckoutSync = useFlushCheckoutSync(); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(''); + + const onClick = async () => { + const deliveryMethod = form.getValues('deliveryMethod'); + if (!deliveryMethod) { + act(() => { + form.setValue('deliveryMethod', 'SHIP'); + }); + } + const valid = await form.trigger(); + if (!valid) return; + await flushCheckoutSync({ includeFetches: false }); + act(() => { + setIsLoading(true); + }); + const collect = new window.TokenizeJs({ + businessId: session?.businessId ?? 'business-1', + applicationId: 'test-app-id', + }); + collect.on('nonce', async event => { + await godaddyApi.confirmCheckout( + { + paymentToken: event?.data?.nonce ?? 'test-nonce', + paymentType: 'card', + paymentProvider: 'POYNT', + fulfillmentLocationId: + form.getValues('pickupLocationId') ?? undefined, + fulfillmentStartAt: form.getValues('pickupDate') || undefined, + fulfillmentEndAt: form.getValues('pickupTime') || undefined, + }, + session + ); + act(() => { + setIsLoading(false); + }); + }); + collect.on('error', event => { + act(() => { + setError(event?.data?.error?.message || 'Payment failed'); + setIsLoading(false); + }); + }); + collect.getNonce({}); + }; + + return React.createElement( + React.Fragment, + null, + error ? React.createElement('p', null, error) : null, + React.createElement( + 'button', + { type: 'button', onClick, disabled: isLoading }, + isLoading ? 'Processing payment' : 'Pay now' + ) + ); + }, + }; + } +); + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + setupCheckoutTestGlobals(); +}); + +afterEach(() => { + act(() => { + vi.runOnlyPendingTimers(); + }); + vi.useRealTimers(); + vi.restoreAllMocks(); + restoreWindowLocation(); +}); + +export * from './checkout-test-utils'; diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-fixtures.ts b/packages/react/src/components/checkout/__tests__/checkout-test-fixtures.ts new file mode 100644 index 00000000..d3a7ce72 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-test-fixtures.ts @@ -0,0 +1,24 @@ +import type { DraftOrder } from '@/types'; +import { getOperations } from './checkout-test-env'; + +export const noBillingAddress = { + billing: { + firstName: '', + lastName: '', + phone: '', + email: 'jane@example.com', + address: null, + }, +} satisfies Partial; + +export function getLastUpdateInput() { + return getOperations('UpdateCheckoutSessionDraftOrder').at(-1)?.input as + | Record + | undefined; +} + +export function getLastConfirmInput() { + return getOperations('ConfirmCheckoutSession').at(-1)?.input as + | Record + | undefined; +} diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx new file mode 100644 index 00000000..9f12aadf --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -0,0 +1,1721 @@ +import '@testing-library/jest-dom/vitest'; +import { expect, type Mock, vi } from 'vitest'; + +vi.mock('@paypal/react-paypal-js', () => ({ + PayPalScriptProvider: ({ children }: { children: React.ReactNode }) => + children, + PayPalButtons: () => , +})); + +import { QueryClient } from '@tanstack/react-query'; +import type { RenderResult } from '@testing-library/react'; +import { act, render, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Checkout, type CheckoutProps } from '@/components/checkout/checkout'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import * as godaddyApi from '@/lib/godaddy/godaddy'; +import * as trackingModule from '@/tracking/track'; +import type { + CheckoutSession, + DraftOrder, + ShippingMethod, + UpdateDraftOrderInput, +} from '@/types'; +import { CheckoutType, PaymentMethodType, PaymentProvider } from '@/types'; + +export type OperationName = + | 'CheckoutSession' + | 'DraftOrder' + | 'DraftOrderSkus' + | 'DraftOrderShippingRates' + | 'UpdateCheckoutSessionDraftOrder' + | 'CalculateCheckoutSessionTaxes' + | 'ApplyCheckoutSessionShippingMethod' + | 'RemoveAppliedCheckoutSessionShippingMethod' + | 'ApplyCheckoutSessionDiscount' + | 'ApplyCheckoutSessionFulfillmentLocation' + | 'ConfirmCheckoutSession' + | 'TokenizeJs.getNonce' + | 'ExchangeCheckoutToken' + | 'RefreshCheckoutToken' + | 'GetAddressMatches'; + +export interface OperationRecord { + op: OperationName; + input?: unknown; + timestamp: number; +} + +type DeepPartial = T extends Array + ? Array>> + : T extends object + ? { + [K in keyof T]?: DeepPartial> | Extract; + } + : T; + +export type MockGodaddyApiErrorKey = + | 'getCheckoutSession' + | 'exchangeCheckoutToken' + | 'refreshCheckoutToken' + | 'getAddressMatches' + | 'getDraftOrder' + | 'updateDraftOrder' + | 'updateDraftOrderTaxes' + | 'applyShippingMethod' + | 'removeShippingMethod' + | 'applyDiscount' + | 'applyFulfillmentLocation' + | 'confirmCheckout' + | 'getDraftOrderShippingMethods' + | 'getProductsFromOrderSkus'; + +export type MockGodaddyApiErrors = Partial< + Record +>; + +export interface WalletSupport { + applePay?: boolean; + googlePay?: boolean; + paze?: boolean; +} + +interface ApiState { + session: CheckoutSession; + draftOrder: DraftOrder; + shippingMethods: ShippingMethod[]; + operations: OperationRecord[]; + delayMs: number; + updateDraftOrderDelayMs: number; + tokenNonce: string; + tokenError?: string; + exchangeToken: string; + refreshToken: string; + addressMatches: Array>; + suppressTokenNonce?: boolean; + errors: MockGodaddyApiErrors; + oneShotErrors: Partial>; + walletSupport: WalletSupport; + priceAdjustments: unknown[]; +} + +interface MockGodaddyApiOptions { + session: CheckoutSession; + draftOrder: DraftOrder; + shippingMethods?: ShippingMethod[]; + delayMs?: number; + updateDraftOrderDelayMs?: number; + tokenNonce?: string; + tokenError?: string; + exchangeToken?: string; + refreshToken?: string; + addressMatches?: Array>; + suppressTokenNonce?: boolean; + errors?: MockGodaddyApiErrors; + walletSupport?: WalletSupport; +} + +export interface RenderCheckoutOptions { + sessionOverrides?: DeepPartial; + draftOrderOverrides?: DeepPartial; + apiOverrides?: Partial< + Pick< + MockGodaddyApiOptions, + | 'shippingMethods' + | 'delayMs' + | 'updateDraftOrderDelayMs' + | 'tokenNonce' + | 'tokenError' + | 'exchangeToken' + | 'refreshToken' + | 'addressMatches' + | 'suppressTokenNonce' + | 'errors' + | 'walletSupport' + > + >; + session?: CheckoutSession; + draftOrder?: DraftOrder; + queryClient?: QueryClient; + renderSessionProp?: boolean; + checkoutProps?: Partial; +} + +export interface RenderCheckoutResult extends RenderResult { + user: ReturnType; + queryClient: QueryClient; + session: CheckoutSession; + draftOrder: DraftOrder; +} + +const baseTimestamp = Date.UTC(2026, 0, 5, 15, 0, 0); +let state: ApiState | undefined; +let tokenizeInstances: MockTokenizeJs[] = []; + +function clone(value: T): T { + return typeof structuredClone === 'function' + ? structuredClone(value) + : JSON.parse(JSON.stringify(value)); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function deepMerge(base: T, patch?: DeepPartial): T { + if (!patch) return clone(base); + + if (!isPlainObject(base) || !isPlainObject(patch)) { + return clone(patch as T); + } + + const result: Record = clone(base) as Record< + string, + unknown + >; + + for (const [key, patchValue] of Object.entries(patch)) { + if (patchValue === undefined) continue; + + if (Array.isArray(patchValue)) { + result[key] = clone(patchValue); + continue; + } + + if (isPlainObject(result[key]) && isPlainObject(patchValue)) { + result[key] = deepMerge(result[key], patchValue as never); + continue; + } + + result[key] = clone(patchValue); + } + + return result as T; +} + +async function maybeDelay(ms = state?.delayMs ?? 0) { + if (ms <= 0) return; + await new Promise(resolve => setTimeout(resolve, ms)); +} + +function record(op: OperationName, input?: unknown) { + state?.operations.push({ + op, + input: clone(input), + timestamp: Date.now(), + }); +} + +function money(value: number, currencyCode = 'USD') { + return { value, currencyCode }; +} + +function base64Url(value: string) { + return btoa(value) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +export function createMockJwt( + payload: { exp?: number; [key: string]: unknown } = {} +) { + const exp = payload.exp ?? Math.floor(Date.now() / 1000) + 300; + return `${base64Url(JSON.stringify({ alg: 'none', typ: 'JWT' }))}.${base64Url( + JSON.stringify({ ...payload, exp }) + )}.signature`; +} + +type DraftOrderAddress = NonNullable< + NonNullable['address'] +>; +type DraftOrderContact = NonNullable; +type DraftOrderLineItem = NonNullable[number]; +type DraftOrderShippingLine = NonNullable[number]; +type DraftOrderDiscount = NonNullable[number]; +type CheckoutLocation = NonNullable[number]; + +type MockedGodaddyApi = { + [K in keyof typeof godaddyApi]: Mock; +}; + +const mockedGodaddyApi = godaddyApi as unknown as MockedGodaddyApi; + +function discount(code: string): DraftOrderDiscount { + return { + id: `discount-${code}`, + name: code, + code, + ratePercentage: null, + appliedBeforeTax: true, + amount: money(100), + }; +} + +function defaultTotals() { + return { + subTotal: money(2500), + discountTotal: money(0), + shippingTotal: money(0), + taxTotal: money(0), + feeTotal: money(0), + total: money(2500), + }; +} + +export function buildShippingAddress( + overrides: DeepPartial = {} +): DraftOrderAddress { + const base: DraftOrderAddress = { + addressLine1: '123 Test St', + addressLine2: '', + addressLine3: '', + adminArea1: 'GA', + adminArea2: 'Jasper', + adminArea3: '', + adminArea4: '', + postalCode: '30143', + countryCode: 'US', + }; + + return deepMerge(base, overrides); +} + +export function buildBillingAddress( + overrides: DeepPartial = {} +): DraftOrderAddress { + return buildShippingAddress(overrides); +} + +function buildContact( + overrides: DeepPartial = {} +): DraftOrderContact { + const base: DraftOrderContact = { + firstName: 'Jane', + lastName: 'Buyer', + email: 'jane@example.com', + phone: '+12015550123', + address: buildShippingAddress(), + }; + + return deepMerge(base, overrides); +} + +export function buildLineItem( + overrides: DeepPartial = {} +): DraftOrderLineItem { + const base: DraftOrderLineItem = { + externalId: null, + id: `line-item-${Math.random().toString(36).slice(2, 8)}`, + name: 'Test Product', + productId: 'product-1', + quantity: 1, + status: 'ACTIVE', + tags: [], + type: 'SKU', + fulfillmentMode: DeliveryMethods.SHIP, + details: { + sku: 'sku-1', + productAssetUrl: null, + selectedAddons: [], + selectedOptions: [], + unitOfMeasure: null, + }, + totals: { + subTotal: money(2500), + discountTotal: money(0), + feeTotal: money(0), + taxTotal: money(0), + }, + unitAmount: money(2500), + discounts: [], + fees: [], + notes: [], + taxes: [], + metafields: [], + } as unknown as DraftOrderLineItem; + + return deepMerge(base, overrides); +} + +export function buildShippingRates( + overrides: DeepPartial[] = [] +): ShippingMethod[] { + const defaults = [ + { + serviceCode: 'free-shipping', + displayName: 'Free', + description: 'Free', + carrierCode: 'unknown', + features: [], + minDeliveryDate: null, + maxDeliveryDate: null, + cost: money(0), + }, + { + serviceCode: 'weight-based', + displayName: 'Weight Based', + description: 'Weight Based', + carrierCode: 'unknown', + features: [], + minDeliveryDate: null, + maxDeliveryDate: null, + cost: money(100), + }, + ] as ShippingMethod[]; + + if (!overrides.length) return defaults; + + return overrides.map((override, index) => + deepMerge(defaults[index] ?? defaults[0], override) + ); +} + +export function buildDraftOrder( + overrides: DeepPartial = {} +): DraftOrder { + const draftOrder: DraftOrder = { + id: 'draft-order-1', + customerId: 'customer-1', + shipping: buildContact(), + billing: buildContact(), + notes: [], + discounts: [], + shippingLines: [], + taxes: [], + fees: [], + statuses: { + fulfillmentStatus: null, + paymentStatus: null, + status: 'CREATED', + }, + totals: defaultTotals(), + lineItems: [ + { + externalId: null, + id: 'line-item-1', + name: 'Test Product', + productId: 'product-1', + quantity: 1, + status: 'ACTIVE', + tags: [], + type: 'SKU', + fulfillmentMode: DeliveryMethods.SHIP, + details: { + sku: 'sku-1', + productAssetUrl: null, + selectedAddons: [], + selectedOptions: [], + unitOfMeasure: null, + }, + totals: { + subTotal: money(2500), + discountTotal: money(0), + feeTotal: money(0), + taxTotal: money(0), + }, + unitAmount: money(2500), + discounts: [], + fees: [], + notes: [], + taxes: [], + metafields: [], + }, + ], + } as unknown as DraftOrder; + + return deepMerge(draftOrder, overrides); +} + +export function buildPickupLocation( + overrides: DeepPartial = {} +): CheckoutLocation { + const base: CheckoutLocation = { + id: 'location-1', + isDefault: true, + address: { + addressLine1: '599 Stegall Dr', + addressLine2: '', + addressLine3: '', + adminArea1: 'GA', + adminArea2: 'Jasper', + adminArea3: 'Jasper Store', + adminArea4: '', + postalCode: '30143', + countryCode: 'US', + }, + operatingHours: { + timeZone: 'America/New_York', + leadTime: 30, + pickupWindowInDays: 0, + pickupSlotInterval: 30, + hours: { + sunday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + monday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + tuesday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + wednesday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + thursday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + friday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + saturday: { enabled: true, openTime: '09:00', closeTime: '17:00' }, + }, + }, + }; + + return deepMerge(base, overrides); +} + +export function buildCheckoutSession( + overrides: DeepPartial & { draftOrder?: DraftOrder } = {} +): CheckoutSession { + const draftOrder = overrides.draftOrder + ? (overrides.draftOrder as DraftOrder) + : buildDraftOrder(); + + const session = { + id: 'checkout-session-1', + token: 'session-token-1', + channelId: 'channel-1', + storeId: 'store-1', + storeName: 'Test Store', + businessId: 'business-1', + customerId: 'customer-1', + successUrl: undefined, + returnUrl: undefined, + enableShipping: true, + enableLocalPickup: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enablePhoneCollection: true, + enableTaxCollection: true, + enableNotesCollection: true, + enablePromotionCodes: true, + enableTips: false, + enableAddressAutocomplete: false, + shipping: { + originAddress: { + addressLine1: '1 Origin Way', + addressLine2: '', + addressLine3: '', + adminArea1: 'GA', + adminArea2: 'Jasper', + adminArea3: '', + adminArea4: '', + postalCode: '30143', + countryCode: 'US', + }, + }, + paymentMethods: { + card: { + type: PaymentMethodType.CREDIT_CARD, + processor: PaymentProvider.STRIPE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, + defaultOperatingHours: buildPickupLocation().operatingHours, + locations: [buildPickupLocation()], + draftOrder, + experimental_rules: { + gopay_override: { + enabled: true, + goPayAppId: 'test-app-id', + }, + }, + } as unknown as CheckoutSession; + + return deepMerge(session, overrides); +} + +function recalculateTotal(draftOrder: DraftOrder): DraftOrder { + const totals = draftOrder.totals ?? defaultTotals(); + const subtotal = totals.subTotal?.value ?? 0; + const shipping = totals.shippingTotal?.value ?? 0; + const discountTotal = totals.discountTotal?.value ?? 0; + const tax = totals.taxTotal?.value ?? 0; + const fee = totals.feeTotal?.value ?? 0; + + return { + ...draftOrder, + totals: { + subTotal: totals.subTotal ?? money(0), + discountTotal: totals.discountTotal ?? money(0), + shippingTotal: totals.shippingTotal ?? money(0), + taxTotal: totals.taxTotal ?? money(0), + feeTotal: totals.feeTotal ?? money(0), + total: money( + Math.max(0, subtotal + shipping + tax + fee - discountTotal) + ), + }, + }; +} + +function mergeDraftOrderPatch(input: Record) { + if (!state) return; + + const { context: _context, customerId: _customerId, ...patch } = input; + state.draftOrder = recalculateTotal( + deepMerge(state.draftOrder, patch as DeepPartial) + ); + state.session = { ...state.session, draftOrder: state.draftOrder }; +} + +function makeDraftOrderResponse() { + if (!state) throw new Error('mockGodaddyApi must be called first'); + return { + checkoutSession: { + ...state.session, + draftOrder: state.draftOrder, + }, + }; +} + +function applyShippingLines(shippingMethods: unknown) { + if (!state || !Array.isArray(shippingMethods)) return; + + const shippingLines: DraftOrderShippingLine[] = shippingMethods.map( + method => { + const typedMethod = method as { + requestedService?: string | null; + requestedProvider?: string | null; + name?: string | null; + subTotal?: { + value?: number | null; + currencyCode?: string | null; + } | null; + }; + + return { + id: `shipping-line-${typedMethod.requestedService ?? 'unknown'}`, + requestedService: typedMethod.requestedService ?? '', + requestedProvider: typedMethod.requestedProvider ?? '', + name: typedMethod.name ?? '', + amount: money( + typedMethod.subTotal?.value ?? 0, + typedMethod.subTotal?.currencyCode ?? 'USD' + ), + discounts: [], + }; + } + ); + + const shippingTotal = money( + shippingLines.reduce((sum, line) => sum + (line.amount?.value ?? 0), 0) + ); + + state.draftOrder = recalculateTotal({ + ...state.draftOrder, + shippingLines, + totals: { + ...(state.draftOrder.totals ?? defaultTotals()), + shippingTotal, + }, + lineItems: + state.draftOrder.lineItems?.map(lineItem => ({ + ...lineItem, + fulfillmentMode: DeliveryMethods.SHIP, + })) ?? [], + }); + state.session = { ...state.session, draftOrder: state.draftOrder }; +} + +function applyDiscountCodes(discountCodes: string[]) { + if (!state) return; + const discounts = discountCodes.map(code => discount(code)); + const discountTotal = money(discountCodes.length * 100); + state.draftOrder = recalculateTotal({ + ...state.draftOrder, + discounts, + totals: { + ...(state.draftOrder.totals ?? defaultTotals()), + discountTotal, + }, + }); + state.session = { ...state.session, draftOrder: state.draftOrder }; +} + +function calculateTaxes() { + if (!state) return money(0); + const taxTotal = money(200); + state.draftOrder = recalculateTotal({ + ...state.draftOrder, + totals: { + ...(state.draftOrder.totals ?? defaultTotals()), + taxTotal, + }, + }); + state.session = { ...state.session, draftOrder: state.draftOrder }; + return taxTotal; +} + +function applyFulfillmentLocation(fulfillmentLocationId?: string | null) { + if (!state || !fulfillmentLocationId) return; + state.draftOrder = { + ...state.draftOrder, + lineItems: + state.draftOrder.lineItems?.map(lineItem => ({ + ...lineItem, + fulfillmentMode: DeliveryMethods.PICKUP, + })) ?? [], + shippingLines: [], + }; + state.session = { ...state.session, draftOrder: state.draftOrder }; +} + +function maybeThrow(key: MockGodaddyApiErrorKey) { + // One-shot errors fire once and clear themselves. + if (state?.oneShotErrors && key in state.oneShotErrors) { + const err = state.oneShotErrors[key]; + delete state.oneShotErrors[key]; + if (err) { + if (err instanceof Error) throw err; + throw new Error(typeof err === 'string' ? err : `Mock error: ${key}`); + } + } + const err = state?.errors?.[key]; + if (!err) return; + if (err instanceof Error) throw err; + throw new Error(typeof err === 'string' ? err : `Mock error: ${key}`); +} + +export function mockGodaddyApi(options: MockGodaddyApiOptions) { + state = { + session: options.session, + draftOrder: options.draftOrder, + shippingMethods: options.shippingMethods ?? buildShippingRates(), + operations: [], + delayMs: options.delayMs ?? 0, + updateDraftOrderDelayMs: options.updateDraftOrderDelayMs ?? 0, + tokenNonce: options.tokenNonce ?? 'test-nonce', + tokenError: options.tokenError, + exchangeToken: options.exchangeToken ?? '', + refreshToken: options.refreshToken ?? createMockJwt(), + addressMatches: options.addressMatches ?? [], + suppressTokenNonce: options.suppressTokenNonce, + errors: { ...(options.errors ?? {}) }, + oneShotErrors: {}, + walletSupport: { ...(options.walletSupport ?? {}) }, + priceAdjustments: [], + }; + + mockedGodaddyApi.exchangeCheckoutToken.mockImplementation(async input => { + record('ExchangeCheckoutToken', input); + await maybeDelay(); + maybeThrow('exchangeCheckoutToken'); + return { jwt: state?.exchangeToken ?? '', expiresAt: '', expiresIn: 300 }; + }); + + mockedGodaddyApi.refreshCheckoutToken.mockImplementation( + async accessToken => { + record('RefreshCheckoutToken', { accessToken }); + await maybeDelay(); + maybeThrow('refreshCheckoutToken'); + return { jwt: state?.refreshToken ?? '', expiresAt: '', expiresIn: 300 }; + } + ); + + mockedGodaddyApi.getCheckoutSession.mockImplementation(async auth => { + record('CheckoutSession', auth); + await maybeDelay(); + maybeThrow('getCheckoutSession'); + return state?.session; + }); + + mockedGodaddyApi.getAddressMatches.mockImplementation(async input => { + record('GetAddressMatches', input); + await maybeDelay(); + maybeThrow('getAddressMatches'); + return { + checkoutSession: { + addresses: state?.addressMatches ?? [], + }, + }; + }); + + mockedGodaddyApi.getDraftOrder.mockImplementation(async () => { + record('DraftOrder'); + await maybeDelay(); + maybeThrow('getDraftOrder'); + return makeDraftOrderResponse(); + }); + + mockedGodaddyApi.getProductsFromOrderSkus.mockImplementation(async () => { + record('DraftOrderSkus'); + await maybeDelay(); + maybeThrow('getProductsFromOrderSkus'); + return { + checkoutSession: { + id: state?.session.id ?? 'checkout-session-1', + skus: { + edges: [ + { + node: { + id: 'sku-node-1', + code: 'sku-1', + label: 'Test Product', + name: 'Test Product', + description: null, + status: 'ACTIVE', + weight: null, + unitOfWeight: null, + disableShipping: null, + htmlDescription: null, + prices: [], + attributes: [], + attributeValues: [], + }, + }, + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }, + }, + }; + }); + + mockedGodaddyApi.getDraftOrderShippingMethods.mockImplementation( + async (_sessionOrAuth, destination) => { + record('DraftOrderShippingRates', { destination }); + await maybeDelay(); + maybeThrow('getDraftOrderShippingMethods'); + return { + checkoutSession: { + id: state?.session.id ?? 'checkout-session-1', + storeId: state?.session.storeId ?? 'store-1', + draftOrder: { + id: state?.draftOrder.id ?? null, + calculatedShippingRates: { + rates: state?.shippingMethods ?? [], + }, + }, + }, + }; + } + ); + + mockedGodaddyApi.updateDraftOrder.mockImplementation(async input => { + record('UpdateCheckoutSessionDraftOrder', input); + await maybeDelay(state?.updateDraftOrderDelayMs ?? state?.delayMs ?? 0); + maybeThrow('updateDraftOrder'); + mergeDraftOrderPatch(input as Record); + return { + updateCheckoutSessionDraftOrder: { + id: state?.draftOrder.id ?? null, + totals: state?.draftOrder.totals ?? null, + }, + }; + }); + + mockedGodaddyApi.updateDraftOrderTaxes.mockImplementation( + async (_sessionOrAuth, destination) => { + record('CalculateCheckoutSessionTaxes', { destination }); + await maybeDelay(); + maybeThrow('updateDraftOrderTaxes'); + const totalTaxAmount = calculateTaxes(); + return { + calculateCheckoutSessionTaxes: { + totalTaxAmount, + draftOrder: state?.draftOrder, + }, + }; + } + ); + + mockedGodaddyApi.applyShippingMethod.mockImplementation( + async shippingMethods => { + record('ApplyCheckoutSessionShippingMethod', shippingMethods); + await maybeDelay(); + maybeThrow('applyShippingMethod'); + applyShippingLines(shippingMethods); + return { + applyCheckoutSessionShippingMethod: { + draftOrder: state?.draftOrder, + }, + }; + } + ); + + mockedGodaddyApi.removeShippingMethod.mockImplementation(async input => { + record('RemoveAppliedCheckoutSessionShippingMethod', input); + await maybeDelay(); + maybeThrow('removeShippingMethod'); + if (state) { + state.draftOrder = recalculateTotal({ + ...state.draftOrder, + shippingLines: [], + totals: { + ...(state.draftOrder.totals ?? defaultTotals()), + shippingTotal: money(0), + }, + }); + state.session = { ...state.session, draftOrder: state.draftOrder }; + } + return { + removeAppliedCheckoutSessionShippingMethod: { + draftOrder: state?.draftOrder, + }, + }; + }); + + mockedGodaddyApi.applyDiscount.mockImplementation(async discountCodes => { + record('ApplyCheckoutSessionDiscount', { discountCodes }); + await maybeDelay(); + maybeThrow('applyDiscount'); + applyDiscountCodes([...(discountCodes ?? [])]); + return { + applyCheckoutSessionDiscount: { + ...state?.draftOrder, + totals: state?.draftOrder.totals, + discounts: state?.draftOrder.discounts, + lineItems: state?.draftOrder.lineItems, + shippingLines: state?.draftOrder.shippingLines, + }, + }; + }); + + mockedGodaddyApi.applyFulfillmentLocation.mockImplementation(async input => { + record('ApplyCheckoutSessionFulfillmentLocation', input); + await maybeDelay(); + maybeThrow('applyFulfillmentLocation'); + applyFulfillmentLocation(input?.fulfillmentLocationId); + return { + applyCheckoutSessionFulfillmentLocation: { + draftOrder: state?.draftOrder, + }, + }; + }); + + // getDraftOrderPriceAdjustments is only invoked by the express-checkout + // buttons (which are mocked inert in checkout-test-env). We still wire a + // default to keep the mock surface symmetric and to support targeted unit + // tests that may invoke it directly. + if ( + typeof (mockedGodaddyApi as Record) + .getDraftOrderPriceAdjustments === 'function' + ) { + ( + mockedGodaddyApi as unknown as { + getDraftOrderPriceAdjustments: Mock; + } + ).getDraftOrderPriceAdjustments.mockImplementation(async () => { + return { + checkoutSession: { + draftOrder: { + calculatedAdjustments: { + adjustments: state?.priceAdjustments ?? [], + }, + }, + }, + }; + }); + } + + mockedGodaddyApi.confirmCheckout.mockImplementation(async input => { + record('ConfirmCheckoutSession', input); + await maybeDelay(); + maybeThrow('confirmCheckout'); + return { + confirmCheckoutSession: { + status: 'COMPLETED', + }, + }; + }); + + return state; +} + +export function setApiError(key: MockGodaddyApiErrorKey, error: unknown) { + if (!state) throw new Error('mockGodaddyApi must be called first'); + state.errors[key] = error; +} + +export function clearApiError(key: MockGodaddyApiErrorKey) { + if (!state) return; + delete state.errors[key]; +} + +/** + * Make the next call to the mocked operation matching `key` throw `error`, + * then clear itself so subsequent calls succeed normally. + * + * `key` is the API key form (matches `setApiError`/`MockGodaddyApiErrorKey`), + * e.g. `'updateDraftOrder'`, `'applyShippingMethod'`, `'applyDiscount'`. + */ +export function setApiErrorOnce( + key: MockGodaddyApiErrorKey, + error: unknown = new Error(`Mock one-shot error: ${key}`) +) { + if (!state) throw new Error('mockGodaddyApi must be called first'); + state.oneShotErrors[key] = error; +} + +/** + * Set the next refetch's `feeTotal` on the current draft order. Recalculates + * the order total to keep the totals consistent. + */ +export function setFeeTotal(value: number, currencyCode = 'USD') { + if (!state) throw new Error('mockGodaddyApi must be called first'); + state.draftOrder = recalculateTotal({ + ...state.draftOrder, + totals: { + ...(state.draftOrder.totals ?? defaultTotals()), + feeTotal: { value, currencyCode }, + }, + }); + state.session = { ...state.session, draftOrder: state.draftOrder }; +} + +/** + * Replace the mock `getDraftOrderPriceAdjustments` response. By default the + * mock returns an empty list of adjustments. + */ +export function setPriceAdjustments(adjustments: unknown[]) { + if (!state) throw new Error('mockGodaddyApi must be called first'); + state.priceAdjustments = adjustments; +} + +export function getOperations(op?: OperationName) { + const operations = state?.operations ?? []; + return op ? operations.filter(operation => operation.op === op) : operations; +} + +export function getOperationNames() { + return getOperations().map(operation => operation.op); +} + +/** + * Return the index of the first occurrence of each named operation in the + * order they appear in the operations log. If a name is not present, its + * entry in the result is `-1`. + * + * Useful for asserting relative ordering of recorded operations without + * brittle `.indexOf` chains in tests. + */ +export function getOperationOrder(names: OperationName[]): number[] { + const log = getOperationNames(); + return names.map(name => log.indexOf(name)); +} + +export function clearOperations() { + if (state) state.operations = []; +} + +export function getCurrentDraftOrder() { + return state?.draftOrder; +} + +export function setCurrentDraftOrder(draftOrder: DraftOrder) { + if (!state) throw new Error('mockGodaddyApi must be called first'); + state.draftOrder = draftOrder; + state.session = { ...state.session, draftOrder }; +} + +export function buildDraftOrderUpdate( + input: Omit, + session = state?.session +): UpdateDraftOrderInput['input'] { + if (!session) throw new Error('mockGodaddyApi must be called first'); + + return { + ...input, + context: { + channelId: session.channelId ?? 'channel-1', + storeId: session.storeId ?? 'store-1', + }, + }; +} + +export function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + staleTime: Number.POSITIVE_INFINITY, + }, + mutations: { + retry: false, + }, + }, + }); +} + +export async function advanceCheckoutDebounce(ms = 1200) { + await act(async () => { + vi.advanceTimersByTime(ms); + }); + await flushPromises(); +} + +export async function flushPromises() { + await act(async () => { + await Promise.resolve(); + }); +} + +export async function waitForCheckoutReady() { + await waitFor( + () => { + expect(document.body).toHaveTextContent('Contact'); + expect(document.body).toHaveTextContent('Payment'); + }, + { timeout: 5000 } + ); + await flushPromises(); +} + +export async function waitForCheckoutIdle() { + await waitFor( + () => { + expect( + getOperations().filter(operation => + [ + 'DraftOrder', + 'DraftOrderSkus', + 'DraftOrderShippingRates', + 'ApplyCheckoutSessionShippingMethod', + 'CalculateCheckoutSessionTaxes', + 'ApplyCheckoutSessionFulfillmentLocation', + ].includes(operation.op) + ).length + ).toBeGreaterThanOrEqual(0); + expect( + document.querySelectorAll('button[disabled], input[disabled]').length + ).toBe(0); + }, + { timeout: 5000 } + ); +} + +export async function waitForOperation( + op: OperationName, + count = 1, + timeout = 3000 +) { + await waitFor( + () => { + expect(getOperations(op).length).toBeGreaterThanOrEqual(count); + }, + { timeout } + ); +} + +interface MockWindowLocationHandle { + href: string; + search: string; + pathname: string; + hash: string; + origin: string; + restore: () => void; + setSearch: (search: string) => void; +} + +let activeLocationMock: MockWindowLocationHandle | undefined; + +export function mockWindowLocation( + initial: { + href?: string; + search?: string; + pathname?: string; + hash?: string; + } = {} +): MockWindowLocationHandle { + if (activeLocationMock) { + activeLocationMock.restore(); + } + + const originalLocation = window.location; + const handle: MockWindowLocationHandle = { + href: initial.href ?? 'https://test.example/checkout', + search: initial.search ?? '', + pathname: initial.pathname ?? '/checkout', + hash: initial.hash ?? '', + origin: 'https://test.example', + restore: () => { + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: originalLocation, + }); + activeLocationMock = undefined; + }, + setSearch: (search: string) => { + handle.search = search; + }, + }; + + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { + get href() { + return handle.href; + }, + set href(value: string) { + handle.href = value; + }, + get search() { + return handle.search; + }, + set search(value: string) { + handle.search = value; + }, + get pathname() { + return handle.pathname; + }, + get hash() { + return handle.hash; + }, + set hash(value: string) { + handle.hash = value; + }, + get origin() { + return handle.origin; + }, + assign: (value: string) => { + handle.href = value; + }, + replace: (value: string) => { + handle.href = value; + }, + reload: () => undefined, + toString: () => handle.href, + }, + }); + + activeLocationMock = handle; + return handle; +} + +export function getMockedLocation() { + return activeLocationMock; +} + +export function setCheckoutUrl({ + pathname = '/checkout/checkout-session-1', + search = '', + hash = '', +}: { + pathname?: string; + search?: string; + hash?: string; +} = {}) { + const normalizedSearch = + search && !search.startsWith('?') ? `?${search}` : search; + const normalizedHash = hash && !hash.startsWith('#') ? `#${hash}` : hash; + window.history.pushState( + {}, + '', + `${pathname}${normalizedSearch}${normalizedHash}` + ); + return window.location; +} + +export function seedCheckoutSessionStorage({ + jwt, + sessionId, +}: { + jwt?: string; + sessionId?: string; +}) { + if (jwt !== undefined) { + window.sessionStorage.setItem('godaddy-checkout-jwt', JSON.stringify(jwt)); + } + if (sessionId !== undefined) { + window.sessionStorage.setItem( + 'godaddy-checkout-session-id', + JSON.stringify(sessionId) + ); + } +} + +export function restoreWindowLocation() { + activeLocationMock?.restore(); +} + +export function setupCheckoutTestGlobals() { + vi.setSystemTime(baseTimestamp); + window.sessionStorage.clear(); + window.history.pushState({}, '', '/'); + document.documentElement.className = ''; + document.documentElement.removeAttribute('style'); + tokenizeInstances = []; + + vi.stubGlobal('TokenizeJs', MockTokenizeJs); + const scripts = Array.from(document.querySelectorAll('script')); + for (const script of scripts) { + act(() => { + script.onload?.(new Event('load')); + }); + } + const originalAppendChild = document.body.appendChild.bind(document.body); + vi.spyOn(document.body, 'appendChild').mockImplementation(node => { + const appended = originalAppendChild(node); + if (node instanceof HTMLScriptElement) { + setTimeout(() => { + act(() => { + node.onload?.(new Event('load')); + }); + }, 0); + } + return appended; + }); + + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn(); + } else { + vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation( + () => undefined + ); + } + + // Polyfill PointerEvent capture API used by Radix UI primitives. jsdom + // does not implement these methods, which causes Radix Select / Popover + // pointer-down handlers to throw and abort interactions during tests. + type PointerCapableElement = Element & { + hasPointerCapture: (pointerId: number) => boolean; + setPointerCapture: (pointerId: number) => void; + releasePointerCapture: (pointerId: number) => void; + }; + const proto = Element.prototype as unknown as PointerCapableElement; + if (typeof proto.hasPointerCapture !== 'function') { + proto.hasPointerCapture = () => false; + } + if (typeof proto.setPointerCapture !== 'function') { + proto.setPointerCapture = () => undefined; + } + if (typeof proto.releasePointerCapture !== 'function') { + proto.releasePointerCapture = () => undefined; + } + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + class ResizeObserverMock { + observe() { + return undefined; + } + unobserve() { + return undefined; + } + disconnect() { + return undefined; + } + } + + class IntersectionObserverMock { + observe() { + return undefined; + } + unobserve() { + return undefined; + } + disconnect() { + return undefined; + } + takeRecords() { + return []; + } + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); +} + +export class MockTokenizeJs { + handlers = new Map void>(); + + constructor() { + tokenizeInstances.push(this); + } + + mount() { + setTimeout(() => { + act(() => { + this.handlers.get('ready')?.({}); + }); + }, 0); + } + + unmount() { + return undefined; + } + + on(eventName: string, callback: (event: unknown) => void) { + this.handlers.set(eventName, callback); + } + + getNonce(request?: object) { + record('TokenizeJs.getNonce', request); + + if (state?.tokenError) { + act(() => { + this.handlers.get('error')?.({ + data: { error: { message: state?.tokenError } }, + }); + }); + return; + } + + if (state?.suppressTokenNonce) return; + + act(() => { + this.handlers.get('nonce')?.({ + data: { nonce: state?.tokenNonce ?? 'test-nonce' }, + }); + }); + } + + supportWalletPayments() { + const support = state?.walletSupport ?? {}; + return Promise.resolve({ + applePay: support.applePay ?? false, + googlePay: support.googlePay ?? false, + paze: support.paze ?? false, + }); + } +} + +export function getLastTokenizeInstance() { + return tokenizeInstances.at(-1); +} + +export function getTokenizeInstances() { + return tokenizeInstances; +} + +export function renderCheckout({ + sessionOverrides, + draftOrderOverrides, + apiOverrides, + session: providedSession, + draftOrder: providedDraftOrder, + queryClient = createTestQueryClient(), + renderSessionProp = true, + checkoutProps, +}: RenderCheckoutOptions = {}): RenderCheckoutResult { + const draftOrder = providedDraftOrder ?? buildDraftOrder(draftOrderOverrides); + const session = + providedSession ?? + buildCheckoutSession({ + ...(sessionOverrides ?? {}), + draftOrder, + }); + + mockGodaddyApi({ + session, + draftOrder, + ...apiOverrides, + }); + + queryClient.setQueryDefaults(checkoutQueryKeys.draftOrder(session.id), { + retry: false, + refetchOnWindowFocus: false, + staleTime: Number.POSITIVE_INFINITY, + }); + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { + ...session, + draftOrder, + }, + }); + if (!apiOverrides?.errors?.getProductsFromOrderSkus) { + queryClient.setQueryData(checkoutQueryKeys.draftOrderProducts(session.id), { + checkoutSession: { + skus: { + edges: [ + { + node: { + code: 'sku-1', + label: 'Test Product', + name: 'Test Product', + attributes: [], + attributeValues: [], + }, + }, + ], + }, + }, + }); + } + const shippingDestination = draftOrder.shipping?.address; + if (shippingDestination?.addressLine1) { + queryClient.setQueryData( + [ + ...checkoutQueryKeys.draftOrderShippingMethods(session.id), + { + addressLine1: shippingDestination.addressLine1, + adminArea1: shippingDestination.adminArea1, + adminArea2: shippingDestination.adminArea2, + postalCode: shippingDestination.postalCode, + countryCode: shippingDestination.countryCode, + }, + ], + { + checkoutSession: { + draftOrder: { + calculatedShippingRates: { + rates: apiOverrides?.shippingMethods ?? buildShippingRates(), + }, + }, + }, + } + ); + } + + const user = userEvent.setup({ + advanceTimers: vi.advanceTimersByTime, + }); + + const result = render( + + + + ); + + return { + user, + queryClient, + session, + draftOrder, + ...result, + }; +} + +export function renderCheckoutWithProps( + checkoutProps: Partial, + options: Omit = {} +): RenderCheckoutResult { + return renderCheckout({ ...options, checkoutProps }); +} + +export function getTextbox(name: RegExp | string) { + return document.body.querySelector( + `input[placeholder="${name}"]` + ); +} + +export async function typeIntoPlaceholder( + user: ReturnType, + placeholder: string | RegExp, + value: string +) { + const field = await within(document.body).findByPlaceholderText(placeholder); + await user.clear(field); + if (value) { + await user.type(field, value); + } + return field; +} + +export async function fillShippingAddress( + user: ReturnType, + values: { + firstName?: string; + lastName?: string; + addressLine1?: string; + addressLine2?: string; + city?: string; + state?: string; + postalCode?: string; + phone?: string; + } = {} +) { + const defaults = { + firstName: 'Ship', + lastName: 'Buyer', + addressLine1: '456 Shipping Ln', + addressLine2: 'Unit 7', + city: 'Jasper', + state: 'GA', + postalCode: '30143', + phone: '(201) 555-1234', + ...values, + }; + + await typeIntoNamedField(user, 'shippingFirstName', defaults.firstName); + await typeIntoNamedField(user, 'shippingLastName', defaults.lastName); + await typeIntoNamedField(user, 'shippingAddressLine1', defaults.addressLine1); + await typeIntoNamedField(user, 'shippingAddressLine2', defaults.addressLine2); + await typeIntoNamedField(user, 'shippingAdminArea2', defaults.city); + await typeIntoNamedField(user, 'shippingPostalCode', defaults.postalCode); +} + +export async function typeIntoNamedField( + user: ReturnType, + name: string, + value: string +) { + const field = await waitFor(() => { + const input = document.querySelector( + `input[name="${name}"]` + ); + expect(input).toBeTruthy(); + return input as HTMLInputElement; + }); + await user.clear(field); + if (value) { + await user.type(field, value); + } + return field; +} + +export function getNamedInput(name: string) { + const field = document.querySelector( + `input[name="${name}"]` + ); + if (!field) throw new Error(`Could not find input named ${name}`); + return field; +} + +// ---------------------------------------------------------------------------- +// Tracking helpers +// ---------------------------------------------------------------------------- +// +// Tests assert that the right `track({ eventId, type, properties })` calls +// fire from checkout components. To keep wiring consistent across files, the +// pattern is: +// +// vi.mock('@/tracking/track', async (importOriginal) => { +// const actual = await importOriginal(); +// return { ...actual, track: vi.fn() }; +// }); +// +// const tracking = mockTrack(); +// beforeEach(() => tracking.clearTrackedEvents()); +// +// `mockTrack()` returns helpers that read from the now-mocked `track` fn. + +interface TrackedEvent { + eventId: string; + type?: string; + properties?: Record; +} + +type TrackPropsMatcher = + | Record + | ((props: Record | undefined) => boolean); + +export interface MockTrackHandle { + /** Filter recorded tracking events, optionally by event id (suffix-matched). */ + getTrackedEvents: (eventId?: string) => TrackedEvent[]; + /** Clear the recorded events log. */ + clearTrackedEvents: () => void; + /** + * Assert at least one recorded event matches `eventId` and the optional + * matcher. `eventId` matches if the recorded id ends with the given value + * (so callers can pass `eventIds.checkoutStart` directly without worrying + * about the `godaddy.checkout.` prefix that `track()` may prepend). + */ + expectTracked: (eventId: string, propsMatcher?: TrackPropsMatcher) => void; +} + +function matchesEventId(recordedId: string, expected: string) { + return recordedId === expected || recordedId.endsWith(`.${expected}`); +} + +function matchesProps( + recorded: Record | undefined, + matcher: TrackPropsMatcher | undefined +): boolean { + if (matcher === undefined) return true; + if (typeof matcher === 'function') return matcher(recorded); + for (const [key, expectedValue] of Object.entries(matcher)) { + const actualValue = recorded?.[key]; + try { + expect(actualValue).toEqual(expectedValue); + } catch { + return false; + } + } + return true; +} + +/** + * Returns helpers backed by the mocked `@/tracking/track`. The caller is + * responsible for hoisting the `vi.mock('@/tracking/track', …)` call at the + * top of their test file (see comment above for the canonical snippet). + */ +export function mockTrack(): MockTrackHandle { + const trackFn = (trackingModule as { track: unknown }).track as Mock; + if (!trackFn || typeof (trackFn as Mock).mock?.calls === 'undefined') { + throw new Error( + 'mockTrack: `@/tracking/track` does not appear to be mocked. ' + + 'Add `vi.mock("@/tracking/track", ...)` at the top of your test file.' + ); + } + + function readEvents(): TrackedEvent[] { + return trackFn.mock.calls.map(([arg]: [TrackedEvent]) => arg); + } + + return { + getTrackedEvents(eventId?: string) { + const events = readEvents(); + if (!eventId) return events; + return events.filter(event => matchesEventId(event.eventId, eventId)); + }, + clearTrackedEvents() { + trackFn.mockClear(); + }, + expectTracked(eventId, propsMatcher) { + const events = readEvents().filter(event => + matchesEventId(event.eventId, eventId) + ); + const match = events.find(event => + matchesProps(event.properties, propsMatcher) + ); + if (!match) { + const summary = events.map(event => ({ + eventId: event.eventId, + type: event.type, + properties: event.properties, + })); + throw new Error( + `expectTracked: no event matching "${eventId}" with the given props matcher.\n` + + `Recorded events for that id:\n${JSON.stringify(summary, null, 2)}` + ); + } + }, + }; +} + +export async function refetchDraftOrder( + queryClient: QueryClient, + sessionId: string +) { + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrder(sessionId), + }); + }); +} diff --git a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx new file mode 100644 index 00000000..d634fea9 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx @@ -0,0 +1,349 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { eventIds } from '@/tracking/events'; +import { + clearOperations, + mockTrack, + renderCheckout, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; + +vi.mock('@/tracking/track', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, track: vi.fn() }; +}); + +const tracking = mockTrack(); + +async function applyCoupon( + user: ReturnType, + code: string +) { + let input: HTMLInputElement | undefined; + let apply: HTMLButtonElement | undefined; + + await waitFor(() => { + const inputs = screen.getAllByPlaceholderText( + /coupon code/i + ) as HTMLInputElement[]; + const buttons = screen.getAllByRole('button', { + name: /apply/i, + }) as HTMLButtonElement[]; + const index = inputs.findIndex(candidate => !candidate.disabled); + expect(index).toBeGreaterThanOrEqual(0); + input = inputs[index]; + apply = buttons[index]; + }); + + await user.clear(input as HTMLInputElement); + await user.type(input as HTMLInputElement, code); + await waitFor(() => { + expect(apply as HTMLButtonElement).not.toBeDisabled(); + }); + await user.click(apply as HTMLButtonElement); +} + +describe('Checkout tips', () => { + it('does not render the tips section when enableTips is false', async () => { + renderCheckout({ sessionOverrides: { enableTips: false } }); + await waitForCheckoutReady(); + + expect( + screen.queryByRole('button', { name: /15%/ }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /no tip/i }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /custom amount/i }) + ).not.toBeInTheDocument(); + }); + + it('renders the percentage buttons, "No tip" and "Custom amount" when enableTips is true', async () => { + renderCheckout({ sessionOverrides: { enableTips: true } }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /15%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /18%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /20%/ })).toBeVisible(); + expect( + await screen.findByRole('button', { name: /no tip/i }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /custom amount/i }) + ).toBeVisible(); + }); + + it('marks the percentage button as aria-checked when clicked', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableTips: true }, + }); + await waitForCheckoutReady(); + + const fifteen = await screen.findByRole('button', { name: /15%/ }); + expect(fifteen).toHaveAttribute('aria-checked', 'false'); + + await user.click(fifteen); + + await waitFor(() => { + expect(fifteen).toHaveAttribute('aria-checked', 'true'); + }); + }); + + it('percentage and no-tip choices update the order summary total due', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + + expect(document.body).toHaveTextContent(/total due/i); + expect(screen.getAllByText('$25.00').length).toBeGreaterThan(0); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + + await waitFor(() => { + expect(screen.getAllByText('$30.00').length).toBeGreaterThan(0); + expect(document.body).toHaveTextContent(/tip/i); + expect(screen.getAllByText('$5.00').length).toBeGreaterThan(0); + }); + + await user.click(await screen.findByRole('button', { name: /no tip/i })); + + await waitFor(() => { + expect(screen.getAllByText('$25.00').length).toBeGreaterThan(0); + expect(screen.queryByText('$30.00')).not.toBeInTheDocument(); + }); + }); + + it('shows the custom tip input only after clicking "Custom amount"', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + + // No custom-tip input visible initially. + expect( + document.querySelector('input[name="tipAmount"]') + ).not.toBeInTheDocument(); + + const customBtn = await screen.findByRole('button', { + name: /custom amount/i, + }); + await user.click(customBtn); + + await waitFor(() => { + expect(customBtn).toHaveAttribute('aria-checked', 'true'); + }); + expect(await screen.findByPlaceholderText('0.00')).toBeVisible(); + }); + + it('sanitizes and formats custom tips in major units', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + + await user.click( + await screen.findByRole('button', { name: /custom amount/i }) + ); + const input = screen.getByPlaceholderText('0.00'); + await user.click(input); + await user.type(input, 'abc10.5x9'); + + expect(input).toHaveValue('10.59'); + + await user.tab(); + + await waitFor(() => { + expect(input).toHaveValue('10.59'); + expect(screen.getAllByText('$10.59').length).toBeGreaterThan(0); + expect(screen.getAllByText('$35.59').length).toBeGreaterThan(0); + }); + }); + + it('keeps a percentage tip snapshot after a coupon changes the order total', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + enablePromotionCodes: true, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + tracking.clearTrackedEvents(); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + await waitFor(() => { + expect(screen.getAllByText('$5.00').length).toBeGreaterThan(0); + expect(screen.getAllByText('$30.00').length).toBeGreaterThan(0); + }); + tracking.expectTracked(eventIds.selectTipAmount, { + tipPercentage: 20, + tipAmount: 500, + totalBeforeTip: 2500, + }); + + await applyCoupon(user, 'onedollar'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + await waitFor(() => { + expect(screen.getAllByText('$5.00').length).toBeGreaterThan(0); + expect(screen.getAllByText('$29.00').length).toBeGreaterThan(0); + }); + }); + + it('documents current custom tip sanitization for negative and NaN input', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + + await user.click( + await screen.findByRole('button', { name: /custom amount/i }) + ); + const input = screen.getByPlaceholderText('0.00'); + + await user.click(input); + await user.type(input, '-5'); + expect(input).toHaveValue('5'); + await user.tab(); + await waitFor(() => { + expect(screen.getAllByText('$5.00').length).toBeGreaterThan(0); + expect(screen.getAllByText('$30.00').length).toBeGreaterThan(0); + }); + + await user.click(input); + await user.clear(input); + await user.type(input, 'NaN'); + expect(input).toHaveValue(''); + await user.tab(); + await waitFor(() => { + expect(screen.queryByText('$30.00')).not.toBeInTheDocument(); + expect(screen.getAllByText('$25.00').length).toBeGreaterThan(0); + }); + }); + + it('converts KWD custom tips with 3-decimal precision to minor units', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 25000, currencyCode: 'KWD' }, + discountTotal: { value: 0, currencyCode: 'KWD' }, + shippingTotal: { value: 0, currencyCode: 'KWD' }, + taxTotal: { value: 0, currencyCode: 'KWD' }, + feeTotal: { value: 0, currencyCode: 'KWD' }, + total: { value: 25000, currencyCode: 'KWD' }, + }, + }, + }); + await waitForCheckoutReady(); + tracking.clearTrackedEvents(); + + await user.click( + await screen.findByRole('button', { name: /custom amount/i }) + ); + const input = await screen.findByPlaceholderText('0.000'); + await user.click(input); + await user.type(input, '1.234'); + await user.tab(); + + await waitFor(() => { + expect(input).toHaveValue('1.234'); + expect(document.body).toHaveTextContent( + /KWD\s*1\.234|1\.234\s*KWD|د\.ك\s*1\.234/ + ); + expect(document.body).toHaveTextContent( + /KWD\s*26\.234|26\.234\s*KWD|د\.ك\s*26\.234/ + ); + }); + tracking.expectTracked(eventIds.enterCustomTip, { + tipAmount: 1234, + totalBeforeTip: 25000, + tipPercentage: 4.94, + currencyCode: 'KWD', + }); + }); + + it('converts zero-decimal custom tips and switches back to percentage cleanly', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'JPY' }, + discountTotal: { value: 0, currencyCode: 'JPY' }, + shippingTotal: { value: 0, currencyCode: 'JPY' }, + taxTotal: { value: 0, currencyCode: 'JPY' }, + feeTotal: { value: 0, currencyCode: 'JPY' }, + total: { value: 2500, currencyCode: 'JPY' }, + }, + }, + }); + await waitForCheckoutReady(); + + await user.click( + await screen.findByRole('button', { name: /custom amount/i }) + ); + const input = await screen.findByPlaceholderText('0'); + await user.click(input); + await user.type(input, '12.34'); + expect(input).toHaveValue('1234'); + await user.tab(); + + await waitFor(() => { + expect(screen.getAllByText('¥1,234').length).toBeGreaterThan(0); + }); + + const fifteen = await screen.findByRole('button', { name: /15%/ }); + await user.click(fifteen); + + await waitFor(() => { + expect(fifteen).toHaveAttribute('aria-checked', 'true'); + expect(screen.queryByPlaceholderText('0')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-totals-summary.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-totals-summary.test.tsx new file mode 100644 index 00000000..4be61e02 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-totals-summary.test.tsx @@ -0,0 +1,297 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { checkoutMutationKeys } from '@/components/checkout/utils/query-keys'; +import { + buildLineItem, + renderCheckout, + setFeeTotal, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; + +function totals(overrides = {}) { + return { + subTotal: { value: 5000, currencyCode: 'USD' }, + discountTotal: { value: 500, currencyCode: 'USD' }, + shippingTotal: { value: 700, currencyCode: 'USD' }, + taxTotal: { value: 300, currencyCode: 'USD' }, + feeTotal: { value: 200, currencyCode: 'USD' }, + total: { value: 5700, currencyCode: 'USD' }, + ...overrides, + }; +} + +describe('Checkout totals and order summary UI', () => { + it('renders subtotal count, discount, shipping, taxes, fees, line item savings, and total due', async () => { + renderCheckout({ + sessionOverrides: { enableTips: true }, + draftOrderOverrides: { + totals: totals(), + discounts: [ + { + id: 'discount-save', + name: 'SAVE', + code: 'SAVE', + ratePercentage: null, + appliedBeforeTax: true, + amount: { value: 500, currencyCode: 'USD' }, + }, + ], + lineItems: [ + buildLineItem({ + quantity: 2, + totals: { + subTotal: { value: 5000, currencyCode: 'USD' }, + discountTotal: { value: 300, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + discounts: [ + { + code: 'LINE', + name: 'Line discount', + ratePercentage: null, + amount: { value: 300, currencyCode: 'USD' }, + }, + ], + }), + ], + }, + }); + await waitForCheckoutReady(); + + expect(document.body).toHaveTextContent(/2 items/i); + expect(document.body).toHaveTextContent(/discount/i); + expect(document.body).toHaveTextContent(/shipping/i); + expect(document.body).toHaveTextContent(/estimated taxes/i); + expect(document.body).toHaveTextContent(/fees/i); + expect(document.body).toHaveTextContent(/total due/i); + }); + + it('renders loading skeleton rows for in-flight discount, shipping, tax, and fee mutations', async () => { + const { queryClient, session } = renderCheckout({ + sessionOverrides: { + enableShipping: true, + enableLocalPickup: false, + enableTaxCollection: true, + }, + draftOrderOverrides: { + totals: totals({ + discountTotal: { value: 500, currencyCode: 'USD' }, + shippingTotal: { value: 700, currencyCode: 'USD' }, + taxTotal: { value: 300, currencyCode: 'USD' }, + feeTotal: { value: 200, currencyCode: 'USD' }, + }), + }, + }); + await waitForCheckoutReady(); + + const mutationKeys = [ + checkoutMutationKeys.applyDiscount(session.id), + checkoutMutationKeys.applyShippingMethod(session.id), + checkoutMutationKeys.updateDraftOrderTaxes(session.id), + checkoutMutationKeys.updateDraftOrderFees(session.id), + ]; + + const resolvers: Array<() => void> = []; + const executions = mutationKeys.map(mutationKey => + queryClient + .getMutationCache() + .build(queryClient, { + mutationKey, + mutationFn: () => + new Promise(resolve => { + resolvers.push(resolve); + }), + }) + .execute(undefined) + ); + + await waitFor(() => { + const skeletons = document.body.querySelectorAll('.animate-pulse'); + expect(skeletons.length).toBeGreaterThanOrEqual(4); + }); + expect(document.body).toHaveTextContent(/discount/i); + expect(document.body).toHaveTextContent(/shipping/i); + expect(document.body).toHaveTextContent(/estimated taxes/i); + expect(document.body).toHaveTextContent(/fees/i); + + await act(async () => { + for (const resolve of resolvers) resolve(); + await Promise.all(executions); + }); + + await waitFor(() => { + expect(document.body.querySelectorAll('.animate-pulse')).toHaveLength(0); + }); + }); + + it('renders a fees row when feeTotal appears after a draft-order refetch', async () => { + const { queryClient, session } = renderCheckout({ + draftOrderOverrides: { + totals: totals({ + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 5500, currencyCode: 'USD' }, + }), + }, + }); + await waitForCheckoutReady(); + + expect(document.body).not.toHaveTextContent(/fees/i); + + setFeeTotal(375); + await queryClient.invalidateQueries({ + queryKey: ['draft-order', { sessionId: session.id }], + }); + await waitForOperation('DraftOrder', 2); + + await waitFor(() => { + expect(document.body).toHaveTextContent(/fees/i); + expect(screen.getAllByText('$3.75').length).toBeGreaterThan(0); + }); + }); + + it('uses the no-items subtotal description when itemCount is zero', async () => { + renderCheckout({ + draftOrderOverrides: { + lineItems: [], + totals: totals({ + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }), + }, + }); + await waitForCheckoutReady(); + + expect(document.body).toHaveTextContent(/no items/i); + expect(document.body).not.toHaveTextContent(/0 items/i); + }); + + it('shows preset shipping and taxes even when collection is disabled', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + draftOrderOverrides: { + totals: totals({ + shippingTotal: { value: 400, currencyCode: 'USD' }, + taxTotal: { value: 250, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 5150, currencyCode: 'USD' }, + }), + }, + }); + await waitForCheckoutReady(); + + expect(document.body).toHaveTextContent(/shipping/i); + expect(screen.getAllByText('$4.00').length).toBeGreaterThan(0); + expect(document.body).toHaveTextContent(/estimated taxes/i); + expect(screen.getAllByText('$2.50').length).toBeGreaterThan(0); + }); + + it('adds selected tip to total due without changing the draft-order total line', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + draftOrderOverrides: { + totals: totals({ + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }), + }, + }); + await waitForCheckoutReady(); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + + await waitFor(() => { + expect(screen.getAllByText('$5.00').length).toBeGreaterThan(0); + expect(screen.getAllByText('$30.00').length).toBeGreaterThan(0); + expect(screen.getAllByText('$25.00').length).toBeGreaterThan(0); + }); + }); + + it('mobile order-summary accordion opens and shows line items and totals', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + + const summaries = screen.getAllByRole('button', { name: /order summary/i }); + await user.click(summaries[0]); + + await waitFor(() => { + expect(screen.getAllByText(/test product/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/subtotal/i).length).toBeGreaterThan(0); + }); + }); + + it('renders product image, quantity, options, add-ons, and SKU fallback data', async () => { + renderCheckout({ + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Fallback Product Name', + quantity: 3, + details: { + sku: 'missing-sku', + productAssetUrl: 'https://cdn.example.test/product.png', + selectedOptions: [{ attribute: 'Size', values: ['Large'] }], + selectedAddons: [ + { + attribute: 'Gift wrap', + sku: 'gift-wrap', + values: [{ name: 'Red paper' }], + }, + ], + unitOfMeasure: null, + }, + }), + ], + }, + }); + await waitForCheckoutReady(); + + expect( + screen.getAllByText(/fallback product name/i).length + ).toBeGreaterThan(0); + expect(screen.getAllByText(/large/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/gift wrap/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/red paper/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/quantity: 3/i).length).toBeGreaterThan(0); + expect(screen.getAllByAltText(/fallback product name/i)[0]).toHaveAttribute( + 'src', + 'https://cdn.example.test/product.png' + ); + }); + + it('falls back to order line item data when SKU fetch fails', async () => { + renderCheckout({ + apiOverrides: { + errors: { getProductsFromOrderSkus: 'sku fetch failed' }, + }, + draftOrderOverrides: { + lineItems: [buildLineItem({ name: 'Order Fallback Item' })], + }, + }); + await waitForCheckoutReady(); + + await waitFor(() => { + expect( + screen.getAllByText(/order fallback item/i).length + ).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-tracking.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-tracking.test.tsx new file mode 100644 index 00000000..a0e241e7 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-tracking.test.tsx @@ -0,0 +1,653 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { describe, expect, it, vi } from 'vitest'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; +import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; +import { useConfirmCheckout } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { eventIds } from '@/tracking/events'; +import { TrackingEventType, track } from '@/tracking/track'; +import { CheckoutType, PaymentMethodType, PaymentProvider } from '@/types'; +import { + buildCheckoutSession, + buildDraftOrder, + buildLineItem, + buildPickupLocation, + buildShippingAddress, + clearOperations, + createTestQueryClient, + flushPromises, + mockGodaddyApi, + mockTrack, + renderCheckout, + setApiError, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; + +vi.mock('@/tracking/track', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, track: vi.fn() }; +}); + +const tracking = mockTrack(); + +function offlinePaymentMethods() { + return { + card: null as never, + offline: { + type: PaymentMethodType.OFFLINE, + processor: PaymentProvider.OFFLINE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }; +} + +async function applyCoupon( + user: ReturnType, + code: string +) { + let input: HTMLInputElement | undefined; + let button: HTMLButtonElement | undefined; + + await waitFor(() => { + const inputs = screen.getAllByPlaceholderText( + /coupon code/i + ) as HTMLInputElement[]; + const buttons = screen.getAllByRole('button', { + name: /apply/i, + }) as HTMLButtonElement[]; + const index = inputs.findIndex(candidate => !candidate.disabled); + expect(index).toBeGreaterThanOrEqual(0); + input = inputs[index]; + button = buttons[index]; + }); + + await user.clear(input as HTMLInputElement); + await user.type(input as HTMLInputElement, code); + await waitFor(() => { + expect(button as HTMLButtonElement).not.toBeDisabled(); + }); + await user.click(button as HTMLButtonElement); +} + +function _buildFreePickupDraftOrder() { + return buildDraftOrder({ + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + shippingLines: [], + lineItems: [ + buildLineItem({ + fulfillmentMode: DeliveryMethods.PICKUP, + unitAmount: { value: 0, currencyCode: 'USD' }, + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + }), + ], + billing: { + firstName: 'Free', + lastName: 'Pickup', + phone: '', + email: 'free@example.com', + address: null, + }, + }); +} + +async function clickCompleteOrder(user: ReturnType) { + const button = await screen.findByRole('button', { + name: /complete your order/i, + }); + await waitFor(() => expect(button).not.toBeDisabled()); + await user.click(button); +} + +interface ConfirmHarnessProps { + paymentType: string; + paymentProvider: string; + confirmError?: unknown; +} + +function ConfirmHarness({ + paymentType, + paymentProvider, + confirmError, +}: ConfirmHarnessProps) { + const queryClient = React.useMemo(() => createTestQueryClient(), []); + const draftOrder = React.useMemo( + () => + buildDraftOrder({ + shippingLines: [ + { + id: 'shipping-line-free', + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ], + }), + [] + ); + const session = React.useMemo( + () => buildCheckoutSession({ draftOrder }), + [draftOrder] + ); + const methods = useForm({ + defaultValues: { deliveryMethod: DeliveryMethods.SHIP }, + }); + const [checkoutErrors, setCheckoutErrors] = React.useState< + string[] | undefined + >(); + const [isConfirmingCheckout, setIsConfirmingCheckout] = React.useState(false); + + React.useMemo(() => { + mockGodaddyApi({ session, draftOrder }); + queryClient.setQueryDefaults(checkoutQueryKeys.draftOrder(session.id), { + retry: false, + refetchOnWindowFocus: false, + staleTime: Number.POSITIVE_INFINITY, + queryFn: async () => ({ + checkoutSession: { ...session, draftOrder }, + }), + }); + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { ...session, draftOrder }, + }); + if (confirmError) { + setApiError('confirmCheckout', confirmError); + } + }, [confirmError, draftOrder, queryClient, session]); + + function ConfirmButton() { + const confirmCheckout = useConfirmCheckout(); + return ( + + ); + } + + return ( + + + + + + + + + + + + ); +} + +function renderConfirmHarness(props: ConfirmHarnessProps) { + render(); + return userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); +} + +function ToggleHarness() { + const methods = useForm({ + defaultValues: { + paymentUseShippingAddress: true, + shippingFirstName: 'Ship', + shippingLastName: 'Buyer', + shippingAddressLine1: '123 Test St', + shippingAdminArea2: 'Jasper', + shippingAdminArea1: 'GA', + shippingPostalCode: '30143', + shippingCountryCode: 'US', + }, + }); + + const queryClient = React.useMemo(() => createTestQueryClient(), []); + + return ( + + + undefined, + setCheckoutErrors: () => undefined, + }} + > + + + + + + + + + ); +} + +describe('Checkout tracking contract', () => { + it('tracks checkout and express impressions only when the express section is visible', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { + processor: PaymentProvider.STRIPE, + checkoutTypes: [CheckoutType.STANDARD], + }, + express: { + processor: PaymentProvider.GODADDY, + checkoutTypes: [CheckoutType.EXPRESS], + }, + }, + }, + }); + await waitForCheckoutReady(); + + tracking.expectTracked(eventIds.checkoutStart, { + subtotal: 2500, + total: 2500, + itemCount: 1, + currencyCode: 'USD', + }); + tracking.expectTracked(eventIds.expressCheckoutImpression, { + availableMethods: 'express', + }); + expect(tracking.getTrackedEvents(eventIds.checkoutStart)).toHaveLength(1); + expect( + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); + + cleanup(); + tracking.clearTrackedEvents(); + renderCheckout(); + await waitForCheckoutReady(); + + tracking.expectTracked(eventIds.checkoutStart, { + subtotal: 2500, + total: 2500, + itemCount: 1, + currencyCode: 'USD', + }); + // TODO(T-601): Current implementation tracks an empty-method impression + // even when the express section is gated off; PRD notes mark this [!]. + tracking.expectTracked(eventIds.expressCheckoutImpression, { + availableMethods: '', + }); + }); + + it('tracks invalid-submit field names', async () => { + const invalidOrder = buildDraftOrder({ + shipping: { + firstName: '', + lastName: '', + address: buildShippingAddress({ + addressLine1: '', + adminArea1: '', + adminArea2: '', + postalCode: '', + countryCode: 'US', + }), + }, + billing: { + firstName: '', + lastName: '', + address: buildShippingAddress({ + addressLine1: '', + adminArea1: '', + adminArea2: '', + postalCode: '', + countryCode: 'US', + }), + }, + }); + tracking.clearTrackedEvents(); + renderCheckout({ + draftOrder: invalidOrder, + sessionOverrides: { draftOrder: invalidOrder }, + }); + await waitForCheckoutReady(); + + fireEvent.submit(document.querySelector('form') as HTMLFormElement); + + await waitFor(() => { + tracking.expectTracked(eventIds.formValidationError, props => { + const errorFields = String(props?.errorFields ?? ''); + return ( + Number(props?.errorCount) >= 5 && + errorFields.includes('shippingFirstName') && + errorFields.includes('shippingAddressLine1') && + errorFields.includes('shippingAdminArea2') && + errorFields.includes('shippingPostalCode') && + errorFields.includes('shippingAdminArea1') + ); + }); + }); + }); + + it('tracks delivery-method, shipping-method, pickup-location, date, and time changes', () => { + tracking.clearTrackedEvents(); + + track({ + eventId: eventIds.selectShippingMethod, + type: TrackingEventType.CLICK, + properties: { + shippingMethod: 'Weight Based', + shippingMethodId: 'weight-based', + shippingCarrier: 'unknown', + cost: 100, + currencyCode: 'USD', + }, + }); + tracking.expectTracked(eventIds.selectShippingMethod, { + shippingMethod: 'Weight Based', + shippingMethodId: 'weight-based', + shippingCarrier: 'unknown', + cost: 100, + currencyCode: 'USD', + }); + + track({ + eventId: eventIds.changeDeliveryMethod, + type: TrackingEventType.CLICK, + properties: { deliveryMethod: DeliveryMethods.PICKUP }, + }); + tracking.expectTracked(eventIds.changeDeliveryMethod, { + deliveryMethod: DeliveryMethods.PICKUP, + }); + + track({ + eventId: eventIds.selectPickupLocation, + type: TrackingEventType.CLICK, + properties: { + locationId: 'pickup-b', + locationName: 'Pickup B', + }, + }); + tracking.expectTracked(eventIds.selectPickupLocation, { + locationId: 'pickup-b', + locationName: 'Pickup B', + }); + + track({ + eventId: eventIds.changePickupDate, + type: TrackingEventType.CLICK, + properties: { + pickupDate: '2026-01-06', + dayOfWeek: 'Tuesday', + locationId: 'pickup-b', + }, + }); + tracking.expectTracked(eventIds.changePickupDate, { + pickupDate: '2026-01-06', + dayOfWeek: 'Tuesday', + locationId: 'pickup-b', + }); + + track({ + eventId: eventIds.changePickupTime, + type: TrackingEventType.CLICK, + properties: { + pickupTime: '10:00', + isAsap: false, + pickupDate: '2026-01-06', + locationId: 'pickup-b', + }, + }); + tracking.expectTracked(eventIds.changePickupTime, props => { + return ( + Object.hasOwn(props ?? {}, 'pickupTime') && + props?.isAsap === false && + typeof props?.pickupDate === 'string' && + typeof props?.locationId === 'string' + ); + }); + }); + + it('tracks discount apply, remove, and failure contracts', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + tracking.clearTrackedEvents(); + + await applyCoupon(user, 'onedollar'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + tracking.expectTracked(eventIds.applyCoupon, { + success: true, + discountCount: 1, + }); + + clearOperations(); + await user.click( + screen + .getAllByRole('button', { name: /remove onedollar/i }) + .at(-1) as HTMLButtonElement + ); + await waitForOperation('ApplyCheckoutSessionDiscount'); + tracking.expectTracked(eventIds.removeDiscount, { + success: true, + discountCount: 0, + }); + + clearOperations(); + setApiError( + 'applyDiscount', + new GraphQLErrorWithCodes([ + { message: 'Bad code', code: 'DISCOUNT_NOT_FOUND' }, + ]) + ); + await applyCoupon(user, 'badcode'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + await flushPromises(); + + tracking.expectTracked(eventIds.discountError, { + success: false, + errorCodes: 'DISCOUNT_NOT_FOUND', + }); + }); + + it('tracks integration form errors and billing-address toggle changes', async () => { + render(); + const toggleUser = userEvent.setup({ + advanceTimers: vi.advanceTimersByTime, + }); + tracking.clearTrackedEvents(); + + const sameAsShipping = await screen.findByRole('checkbox', { + name: /shipping address as billing address/i, + }); + await toggleUser.click(sameAsShipping); + tracking.expectTracked(eventIds.toggleSameAsBillingAddress, { + useShippingAddress: false, + }); + await toggleUser.click(sameAsShipping); + tracking.expectTracked(eventIds.toggleSameAsBillingAddress, { + useShippingAddress: true, + }); + + cleanup(); + + const draftOrder = buildDraftOrder({ + shippingLines: [], + shipping: { + firstName: 'Ship', + lastName: 'Buyer', + phone: '+12015550123', + address: buildShippingAddress(), + }, + }); + const { user } = renderCheckout({ + draftOrder, + sessionOverrides: { + draftOrder, + paymentMethods: offlinePaymentMethods(), + }, + apiOverrides: { shippingMethods: [] }, + }); + await waitForCheckoutReady(); + tracking.clearTrackedEvents(); + + await clickCompleteOrder(user); + await waitFor(() => { + expect(document.body).toHaveTextContent( + /Shipping address or method failed to apply/i + ); + }); + tracking.expectTracked(eventIds.formError, { + errorCodes: 'MISSING_SHIPPING_INFO', + errorCount: 1, + }); + }); + + it('tracks payment lifecycle success and failure events', async () => { + const successUser = renderConfirmHarness({ + paymentType: PaymentMethodType.OFFLINE, + paymentProvider: PaymentProvider.OFFLINE, + }); + tracking.clearTrackedEvents(); + + await successUser.click(screen.getByRole('button', { name: 'Confirm' })); + await waitForOperation('ConfirmCheckoutSession'); + + tracking.expectTracked(eventIds.paymentStart, { + paymentType: PaymentMethodType.OFFLINE, + provider: PaymentProvider.OFFLINE, + draftOrderId: 'draft-order-1', + }); + tracking.expectTracked(eventIds.checkoutComplete, { + draftOrderId: 'draft-order-1', + total: 2500, + currencyCode: 'USD', + paymentType: PaymentMethodType.OFFLINE, + provider: PaymentProvider.OFFLINE, + }); + + const failureUser = renderConfirmHarness({ + paymentType: PaymentMethodType.OFFLINE, + paymentProvider: PaymentProvider.OFFLINE, + confirmError: new GraphQLErrorWithCodes([ + { message: 'Declined', code: 'PAYMENT_DECLINED' }, + ]), + }); + tracking.clearTrackedEvents(); + + await failureUser.click( + screen + .getAllByRole('button', { name: 'Confirm' }) + .at(-1) as HTMLButtonElement + ); + await waitForOperation('ConfirmCheckoutSession'); + + tracking.expectTracked(eventIds.checkoutError, props => { + return ( + props?.errorCodes === 'GraphQLErrorWithCodes' && + props?.errorType === 'Declined' && + props?.paymentType === PaymentMethodType.OFFLINE && + props?.provider === PaymentProvider.OFFLINE && + props?.draftOrderId === 'draft-order-1' + ); + }); + }); + + it('tracks wallet-specific completion events from the confirm-checkout seam', async () => { + const walletCases = [ + { + paymentType: 'apple_pay', + provider: PaymentProvider.GODADDY, + eventId: eventIds.expressApplePayCompleted, + }, + { + paymentType: 'google_pay', + provider: PaymentProvider.GODADDY, + eventId: eventIds.expressGooglePayCompleted, + }, + { + paymentType: 'paze', + provider: PaymentProvider.PAZE, + eventId: eventIds.pazePayCompleted, + }, + ]; + + for (const walletCase of walletCases) { + renderConfirmHarness({ + paymentType: walletCase.paymentType, + paymentProvider: walletCase.provider, + }); + tracking.clearTrackedEvents(); + + await userEvent + .setup({ advanceTimers: vi.advanceTimersByTime }) + .click( + screen + .getAllByRole('button', { name: 'Confirm' }) + .at(-1) as HTMLButtonElement + ); + await waitForOperation('ConfirmCheckoutSession'); + + tracking.expectTracked(walletCase.eventId, { + draftOrderId: 'draft-order-1', + paymentType: walletCase.paymentType, + provider: 'poynt', + }); + tracking.expectTracked(eventIds.checkoutComplete, { + draftOrderId: 'draft-order-1', + total: 2500, + currencyCode: 'USD', + paymentType: walletCase.paymentType, + provider: walletCase.provider, + }); + } + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx new file mode 100644 index 00000000..d39f5c01 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx @@ -0,0 +1,222 @@ +import { enUs } from '@godaddy/localizations'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Checkout } from '@/components/checkout/checkout'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { + advanceCheckoutDebounce, + buildCheckoutSession, + buildDraftOrder, + clearOperations, + createTestQueryClient, + getOperations, + mockGodaddyApi, + renderCheckout, + typeIntoNamedField, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastUpdateInput } from './checkout-test-fixtures'; + +describe('Checkout validation behaviors', () => { + it('hides phone fields and does not require a phone when phone collection is disabled', async () => { + renderCheckout({ + sessionOverrides: { enablePhoneCollection: false }, + draftOrderOverrides: { shipping: { phone: '' } }, + }); + await waitForCheckoutReady(); + + // No PhoneInput visible (its placeholder text is "Phone Number"). + expect( + screen.queryByPlaceholderText(/phone number/i) + ).not.toBeInTheDocument(); + + // Pay button should be present and not disabled by missing phone. + expect( + screen.getByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + }); + + it('blocks submit and shows an error when the shipping phone is invalid', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enablePhoneCollection: true }, + }); + await waitForCheckoutReady(); + clearOperations(); + + // PhoneInput is from react-phone-number-input; the visible input has + // placeholder "(201) 555-1234". Type just enough digits to fail validation. + const phone = ( + await screen.findAllByPlaceholderText(/201.*555/) + )[0] as HTMLInputElement; + await user.clear(phone); + await user.type(phone, '12'); + + const payNow = screen.getByRole('button', { name: /pay now/i }); + await user.click(payNow); + + // The form's superRefine emits the exact localized invalid-phone issue. + await waitFor(() => { + expect(document.body).toHaveTextContent( + enUs.validation.enterValidShippingPhone + ); + }); + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); + }); +}); + +describe('Checkout notes UI', () => { + it('hides notes textarea when enableNotesCollection is false', async () => { + renderCheckout({ sessionOverrides: { enableNotesCollection: false } }); + await waitForCheckoutReady(); + + expect( + document.querySelector('textarea[name="notes"]') + ).not.toBeInTheDocument(); + }); + + it('renders notes in both shipping and pickup flows', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + + expect( + document.querySelector('textarea[name="notes"]') + ).toBeInTheDocument(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + + await waitFor(() => { + expect( + document.querySelector('textarea[name="notes"]') + ).toBeInTheDocument(); + }); + }); + + it('hydrates an existing customer note into the notes textarea', async () => { + renderCheckout({ + draftOrderOverrides: { + notes: [ + { + authorType: 'CUSTOMER', + content: 'Ring the doorbell', + }, + ], + }, + }); + await waitForCheckoutReady(); + + expect(document.querySelector('textarea[name="notes"]')).toHaveValue( + 'Ring the doorbell' + ); + }); + + it('debounces a notes edit into one UpdateCheckoutSessionDraftOrder with the trimmed value', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + clearOperations(); + + const notes = document.querySelector( + 'textarea[name="notes"]' + ) as HTMLTextAreaElement; + expect(notes).toBeTruthy(); + + await user.clear(notes); + await user.type(notes, ' Leave at front door '); + await advanceCheckoutDebounce(1500); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + notes: [{ authorType: 'CUSTOMER', content: 'Leave at front door' }], + }); + }); + + it('clears an existing customer note from the draft order', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + notes: [ + { + authorType: 'CUSTOMER', + content: 'Remove this note', + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const notes = document.querySelector( + 'textarea[name="notes"]' + ) as HTMLTextAreaElement; + await user.clear(notes); + await advanceCheckoutDebounce(1500); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ notes: null }); + }); +}); + +describe('Checkout error UI states', () => { + it('shows the "checkout session not found" panel when there is no session and not loading', async () => { + const queryClient = createTestQueryClient(); + render( + + {/* No session + isLoading=false → render the not-found panel. */} + + + ); + + await waitFor(() => { + // The panel uses t.apiErrors.CHECKOUT_SESSION_NOT_FOUND. + expect(document.body).toHaveTextContent( + enUs.apiErrors.CHECKOUT_SESSION_NOT_FOUND + ); + }); + }); + + it('renders the "checkout disabled" message when isCheckoutDisabled is true', async () => { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ draftOrder }); + mockGodaddyApi({ session, draftOrder }); + + // Render via the normal helper but pass isCheckoutDisabled through props.\n // renderCheckout doesn't accept this directly, so render manually. + const queryClient = createTestQueryClient(); + const { checkoutQueryKeys } = await import( + '@/components/checkout/utils/query-keys' + ); + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { ...session, draftOrder }, + }); + queryClient.setQueryData(checkoutQueryKeys.draftOrderProducts(session.id), { + checkoutSession: { skus: { edges: [] } }, + }); + + render( + + + + ); + + await waitFor(() => { + // The CheckoutErrorList renders t.general.checkoutDisabled when + // isCheckoutDisabled is true (even with no API errors). + expect(document.body).toHaveTextContent( + /checkout is currently disabled|disabled/i + ); + }); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-wallet-methods.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-wallet-methods.test.tsx new file mode 100644 index 00000000..8888115e --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-wallet-methods.test.tsx @@ -0,0 +1,344 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { PaymentProvider } from '@/types'; +import { + getTokenizeInstances, + MockTokenizeJs, + renderCheckout, + waitForCheckoutReady, +} from './checkout-test-env'; + +// ---------------------------------------------------------------------------- +// Standard wallet payment methods (Apple Pay / Google Pay / Paze) +// ---------------------------------------------------------------------------- +// +// These methods are configured with `checkoutTypes: ['standard']` and appear +// inside the regular PaymentForm accordion alongside the credit-card method. +// At runtime, when the user selects (e.g.) Apple Pay, the corresponding +// button is rendered in place of the "Pay now" button. Clicking it opens the +// wallet sheet, which produces a tokenized credit-card nonce; the button +// then sends `paymentType: card` (NOT `apple_pay`/`google_pay`/`paze`) to +// confirmCheckout, using the draftOrder's existing totals. +// +// What we CAN cover in jsdom: +// * PaymentForm filters wallet methods on a `supportWalletPayments` check +// from the Poynt SDK. Visibility is therefore gated on BOTH the session +// config AND the SDK's support response. +// * PaymentForm filters GoDaddy card/ACH on a resolvable application id. +// * The accordion lists configured regular methods with labels/copy. +// +// What we CANNOT cover: +// * The actual wallet-sheet flow (Poynt's TokenizeJs `mount` → wallet sheet +// → `nonce` event). Those interactions are SDK-driven. +// * The follow-up confirmCheckout with the wallet's nonce — gated by SDK +// callbacks we cannot fire from jsdom. +// ---------------------------------------------------------------------------- + +describe('Payment method gating', () => { + it('filters GoDaddy card and ACH when no application id can be resolved', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + ach: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + }, + experimental_rules: { + gopay_override: { + enabled: false, + goPayAppId: '', + }, + }, + }, + checkoutProps: { + godaddyPaymentsConfig: { businessId: 'business-1', appId: '' }, + }, + }); + await waitForCheckoutReady(); + + expect( + screen.getByText('No payment methods available') + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /credit or debit card/i }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /bank account/i }) + ).not.toBeInTheDocument(); + }); + + it('uses gopay_override to re-enable GoDaddy card and ACH without a configured app id', async () => { + const tokenizeArgs: unknown[][] = []; + class RecordingTokenizeJs extends MockTokenizeJs { + constructor(...args: unknown[]) { + super(); + tokenizeArgs.push(args); + } + } + vi.stubGlobal('TokenizeJs', RecordingTokenizeJs); + + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + ach: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + }, + experimental_rules: { + gopay_override: { + enabled: true, + goPayAppId: 'override-app-id', + }, + }, + }, + checkoutProps: { + godaddyPaymentsConfig: { businessId: 'business-1', appId: '' }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /credit or debit card/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /bank account/i }) + ).toBeInTheDocument(); + + await waitFor(() => { + expect(getTokenizeInstances().length).toBeGreaterThan(0); + }); + expect( + tokenizeArgs.some(([config]) => + Boolean( + config && + typeof config === 'object' && + 'applicationId' in config && + (config as { applicationId?: unknown }).applicationId === + 'override-app-id' + ) + ) + ).toBe(true); + }); + + it('renders offline as a regular non-free payment method', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { + processor: PaymentProvider.STRIPE, + checkoutTypes: ['standard'], + }, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: ['standard'], + }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /offline payments/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /credit or debit card/i }) + ).toBeInTheDocument(); + }); + + it('auto-selects a single method with no accordion content and hides the accordion', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: null, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: ['standard'], + }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByRole('button', { name: /offline payments/i }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /complete your order/i }) + ).toBeInTheDocument(); + }); + + it('renders description-only method content without mounting an inline form', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { + processor: PaymentProvider.STRIPE, + checkoutTypes: ['standard'], + }, + mercadopago: { + processor: PaymentProvider.MERCADOPAGO, + checkoutTypes: ['standard'], + }, + }, + }, + checkoutProps: { + mercadoPagoConfig: { publicKey: 'mp-public-key', country: 'MX' }, + }, + }); + await waitForCheckoutReady(); + + await user.click( + await screen.findByRole('button', { name: /mercado pago/i }) + ); + expect( + await screen.findByText( + 'Use the MercadoPago form below to complete your purchase securely.' + ) + ).toBeInTheDocument(); + expect(screen.queryByTestId('mock-card-form')).not.toBeInTheDocument(); + }); +}); + +describe('Standard wallet payment methods', () => { + it('does not show Apple Pay in the accordion when the SDK does not report support', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { + processor: PaymentProvider.STRIPE, + checkoutTypes: ['standard'], + }, + applePay: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + }, + }, + // Default walletSupport is { applePay: false, ... } + }); + await waitForCheckoutReady(); + + expect( + screen.queryByRole('button', { name: /^apple pay/i }) + ).not.toBeInTheDocument(); + }); + + it('shows Apple Pay in the accordion when configured AND the SDK reports support', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { + processor: PaymentProvider.STRIPE, + checkoutTypes: ['standard'], + }, + applePay: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + }, + }, + apiOverrides: { + walletSupport: { applePay: true }, + }, + }); + await waitForCheckoutReady(); + + // The accordion item is implemented as an AccordionTrigger button. The + // accessible name includes the method label. + expect( + await screen.findByRole('button', { name: /apple pay/i }) + ).toBeInTheDocument(); + }); + + it('shows Paze and Google Pay in the accordion when both are supported', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { + processor: PaymentProvider.STRIPE, + checkoutTypes: ['standard'], + }, + paze: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + googlePay: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + }, + }, + apiOverrides: { + walletSupport: { paze: true, googlePay: true }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /paze/i }) + ).toBeInTheDocument(); + expect( + await screen.findByRole('button', { name: /google pay/i }) + ).toBeInTheDocument(); + }); + + it('keeps credit-card as the first/default accordion option even with wallet methods configured', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: { + card: { + processor: PaymentProvider.STRIPE, + checkoutTypes: ['standard'], + }, + applePay: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + googlePay: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + paze: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + }, + }, + apiOverrides: { + walletSupport: { applePay: true, googlePay: true, paze: true }, + }, + }); + await waitForCheckoutReady(); + + // The "Pay now" button (credit-card) should be present immediately — + // i.e., the credit-card method is the initially-selected accordion item. + expect( + screen.getByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + + // The credit-card accordion item should be present and listed before + // the wallet items in DOM order (PaymentForm sorts credit-card first). + const cardTrigger = await screen.findByRole('button', { + name: /credit or debit card/i, + }); + const applePayTrigger = screen.getByRole('button', { + name: /apple pay/i, + }); + const cardComesBeforeApplePay = + // biome-ignore lint/suspicious/noBitwiseOperators: bitmask comparison required by the DOM API. + (cardTrigger.compareDocumentPosition(applePayTrigger) & + Node.DOCUMENT_POSITION_FOLLOWING) !== + 0; + expect(cardComesBeforeApplePay).toBe(true); + }); +}); diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx index 03d98d1a..76e9e293 100644 --- a/packages/react/src/components/checkout/address/address-form.tsx +++ b/packages/react/src/components/checkout/address/address-form.tsx @@ -58,6 +58,18 @@ interface AddressFormProps { onlyNames?: boolean; } +export function mapAutocompleteAddressFields(selectedAddress?: Address) { + if (!selectedAddress) return {}; + + return { + AddressLine1: selectedAddress.addressLine1, + AddressLine2: selectedAddress.addressLine2, + AdminArea2: selectedAddress.adminArea3, + AdminArea1: selectedAddress.adminArea1, + PostalCode: selectedAddress.postalCode, + } satisfies Record; +} + export function AddressForm({ sectionKey, onlyNames = false, @@ -120,14 +132,15 @@ export function AddressForm({ () => ({ firstName, lastName }), [firstName, lastName] ); - - const [debouncedFullName] = useDebouncedValue( - Object.values(contact).join(''), - { - wait: 1000, - } + const serializedContact = React.useMemo( + () => JSON.stringify(contact), + [contact] ); + const [debouncedContact] = useDebouncedValue(serializedContact, { + wait: 1000, + }); + const [debouncedAddressValue] = useDebouncedValue(addressValue, { wait: 200, }); @@ -145,22 +158,28 @@ export function AddressForm({ }, [draftOrder, sectionKey, firstName, lastName]); const shouldVerifyName = + onlyNames && nameHasChanged && // Only sync if values differ from order - debouncedFullName !== '' && - debouncedFullName === Object.values(contact).join(''); + !!firstName?.trim() && + !!lastName?.trim() && + debouncedContact === serializedContact; useDraftOrderFieldSync({ key: 'name', data: contact, - deps: [contact], + deps: [contact, serializedContact, debouncedContact], enabled: shouldVerifyName, fieldNames: [`${sectionKey}FirstName`, `${sectionKey}LastName`], + preserveFormData: false, mapToInput: data => { + const fields = { + firstName: data.firstName.trim(), + lastName: data.lastName.trim(), + address: null, + }; + return mapAddressFieldsToInput( - { - firstName: data.firstName.trim(), - lastName: data.lastName.trim(), - }, + fields, sectionKey as 'shipping' | 'billing', useShippingAddress ); @@ -192,8 +211,20 @@ export function AddressForm({ ] ); - const [debouncedFullAddress] = useDebouncedValue( - Object.values(address).join(''), + const sectionContactAndAddress = React.useMemo( + () => ({ + ...contact, + address, + }), + [contact, address] + ); + const serializedSectionContactAndAddress = React.useMemo( + () => JSON.stringify(sectionContactAndAddress), + [sectionContactAndAddress] + ); + + const [debouncedSectionContactAndAddress] = useDebouncedValue( + serializedSectionContactAndAddress, { wait: 1000 } ); @@ -262,39 +293,44 @@ export function AddressForm({ function handleUpdateAddress(selectedAddress?: Address) { if (!selectedAddress) return; - const fieldMap: Record = { - AddressLine1: selectedAddress.addressLine1, - AddressLine2: selectedAddress.addressLine2, - AdminArea2: selectedAddress.adminArea3, - AdminArea1: selectedAddress.adminArea1, - PostalCode: selectedAddress.postalCode, - }; - - for (const [key, value] of Object.entries(fieldMap)) { - if (value) { - form.setValue(`${sectionKey}${key}`, value, { shouldValidate: true }); + for (const [key, value] of Object.entries( + mapAutocompleteAddressFields(selectedAddress) + )) { + if (value && form.getValues(`${sectionKey}${key}`) !== value) { + form.setValue(`${sectionKey}${key}`, value, { + shouldDirty: true, + shouldValidate: true, + }); } } } const shouldUpdateAddress = Boolean( - addressHasChanged && // Only sync if values differ from order - !!debouncedFullAddress && + (addressHasChanged || nameHasChanged) && // Only sync if values differ from order + !!firstName?.trim() && + !!lastName?.trim() && isAddressComplete(address) && - debouncedFullAddress === Object.values(address).join('') && - debouncedFullAddress.trim() !== '' && + debouncedSectionContactAndAddress === + serializedSectionContactAndAddress && !isAutocompleteOpen ); useDraftOrderFieldSync({ key: 'address', - data: address, - deps: [address, shouldUpdateAddress], - enabled: shouldUpdateAddress, + data: sectionContactAndAddress, + deps: [ + sectionContactAndAddress, + shouldUpdateAddress, + serializedSectionContactAndAddress, + debouncedSectionContactAndAddress, + ], + enabled: !onlyNames && shouldUpdateAddress, fieldNames: [ + `${sectionKey}FirstName`, + `${sectionKey}LastName`, `${sectionKey}AddressLine1`, `${sectionKey}AddressLine2`, - `${sectionKey}City`, + `${sectionKey}AdminArea2`, `${sectionKey}AdminArea1`, `${sectionKey}PostalCode`, `${sectionKey}CountryCode`, @@ -302,7 +338,9 @@ export function AddressForm({ mapToInput: data => { return mapAddressFieldsToInput( { - address: data, + firstName: data.firstName.trim(), + lastName: data.lastName.trim(), + address: data.address, }, sectionKey as 'shipping' | 'billing', useShippingAddress @@ -378,6 +416,7 @@ export function AddressForm({ `${sectionKey}CountryCode`, country.value, { + shouldDirty: true, shouldValidate: true, } ); @@ -444,9 +483,7 @@ export function AddressForm({ name={`${sectionKey}FirstName`} render={({ field, fieldState }) => ( - - {t.shipping.firstName} {!onlyNames && `(${t.general.optional})`} - + {t.shipping.firstName} { + const previousRegion = form.getValues( + `${sectionKey}AdminArea1` + ); + field.onChange(value); form.setValue(`${sectionKey}AdminArea1`, value, { + shouldDirty: true, shouldValidate: true, }); - form.setValue(`${sectionKey}PostalCode`, '', { - shouldDirty: true, - shouldValidate: false, - }); + if (previousRegion && previousRegion !== value) { + form.setValue(`${sectionKey}PostalCode`, '', { + shouldDirty: true, + shouldValidate: false, + }); + } // Track region selection event track({ diff --git a/packages/react/src/components/checkout/address/utils/check-is-valid-address.test.ts b/packages/react/src/components/checkout/address/utils/check-is-valid-address.test.ts new file mode 100644 index 00000000..ce1dbaa9 --- /dev/null +++ b/packages/react/src/components/checkout/address/utils/check-is-valid-address.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import type { Address } from '@/types'; +import { checkIsValidAddress } from './check-is-valid-address'; + +const formAddress = (overrides: Partial
= {}): Address => + ({ + addressLine1: '123 Main St', + addressLine2: 'Suite 100', + addressLine3: null, + adminArea1: 'GA', + adminArea2: 'Atlanta', + adminArea3: null, + adminArea4: null, + postalCode: '30301', + countryCode: 'US', + ...overrides, + }) as Address; + +const verifiedAddress = (overrides: Partial
= {}): Address => + ({ + addressLine1: '123 Main St', + addressLine2: 'Suite 100', + addressLine3: null, + adminArea1: 'GA', + adminArea2: 'Fulton County', + adminArea3: 'Atlanta', + adminArea4: null, + postalCode: '30301', + countryCode: 'US', + ...overrides, + }) as Address; + +describe('checkIsValidAddress', () => { + it('returns true for identical normalized addresses', () => { + const address = formAddress({ adminArea3: 'Atlanta' }); + + expect( + checkIsValidAddress( + address, + verifiedAddress({ adminArea2: 'Atlanta', adminArea3: 'Atlanta' }) + ) + ).toBe(true); + }); + + it('normalizes USA and US country codes', () => { + expect( + checkIsValidAddress( + formAddress({ countryCode: 'US' }), + verifiedAddress({ countryCode: 'USA' }) + ) + ).toBe(true); + }); + + it('compares the form city against verified adminArea3', () => { + expect( + checkIsValidAddress( + formAddress({ adminArea2: 'Atlanta' }), + verifiedAddress({ adminArea2: 'Fulton County', adminArea3: 'Atlanta' }) + ) + ).toBe(true); + }); + + it('trims and compares postal codes case-insensitively', () => { + expect( + checkIsValidAddress( + formAddress({ countryCode: 'GB', postalCode: ' SW1A 1AA ' }), + verifiedAddress({ countryCode: 'GB', postalCode: 'sw1a 1aa' }) + ) + ).toBe(true); + }); + + it('returns false when addressLine2 differs', () => { + expect( + checkIsValidAddress( + formAddress({ addressLine2: 'Suite 100' }), + verifiedAddress({ addressLine2: 'Suite 200' }) + ) + ).toBe(false); + }); +}); diff --git a/packages/react/src/components/checkout/address/utils/check-is-valid-address.ts b/packages/react/src/components/checkout/address/utils/check-is-valid-address.ts index 2eb1291f..6d2f3f10 100644 --- a/packages/react/src/components/checkout/address/utils/check-is-valid-address.ts +++ b/packages/react/src/components/checkout/address/utils/check-is-valid-address.ts @@ -1,10 +1,12 @@ import type { Address } from '@/types'; -const convertCountryCode = ( - country?: string | null -): string | null | undefined => { - if (country === 'USA') return 'US'; - return country; +const normalize = (value?: string | null): string => + value?.trim().toLowerCase() ?? ''; + +const convertCountryCode = (country?: string | null): string => { + const normalizedCountry = country?.trim().toUpperCase(); + if (normalizedCountry === 'USA') return 'US'; + return normalizedCountry ?? ''; }; export function checkIsValidAddress( @@ -12,15 +14,14 @@ export function checkIsValidAddress( verifiedAddress?: Address ) { return ( - address.addressLine1?.toLowerCase() === - verifiedAddress?.addressLine1?.toLowerCase() && - address.adminArea1?.toLowerCase() === - verifiedAddress?.adminArea1?.toLowerCase() && - address.adminArea2?.toLowerCase() === - verifiedAddress?.adminArea3?.toLowerCase() && - address.postalCode?.toLowerCase() === - verifiedAddress?.postalCode?.toLowerCase() && - address.countryCode?.toLowerCase() === - convertCountryCode(verifiedAddress?.countryCode)?.toLowerCase() + normalize(address.addressLine1) === + normalize(verifiedAddress?.addressLine1) && + normalize(address.addressLine2) === + normalize(verifiedAddress?.addressLine2) && + normalize(address.adminArea1) === normalize(verifiedAddress?.adminArea1) && + normalize(address.adminArea2) === normalize(verifiedAddress?.adminArea3) && + normalize(address.postalCode) === normalize(verifiedAddress?.postalCode) && + convertCountryCode(address.countryCode) === + convertCountryCode(verifiedAddress?.countryCode) ); } diff --git a/packages/react/src/components/checkout/address/utils/format-address.test.ts b/packages/react/src/components/checkout/address/utils/format-address.test.ts new file mode 100644 index 00000000..aee2b8e0 --- /dev/null +++ b/packages/react/src/components/checkout/address/utils/format-address.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import type { Address } from '@/types'; +import { formatSingleLineAddress } from './format-address'; + +const address = (overrides: Partial
= {}): Address => + ({ + addressLine1: '123 Main St', + addressLine2: 'Suite 100', + addressLine3: null, + adminArea1: 'GA', + adminArea2: 'Jasper', + adminArea3: null, + adminArea4: null, + postalCode: '30143', + countryCode: 'US', + ...overrides, + }) as Address; + +describe('formatSingleLineAddress', () => { + it('renders a standard US address comma-separated with no duplicates', () => { + expect(formatSingleLineAddress(address())).toBe( + '123 Main St, Suite 100, Jasper, GA, 30143, United States' + ); + }); + + it('omits a missing addressLine2 without leaving a double comma', () => { + expect(formatSingleLineAddress(address({ addressLine2: null }))).toBe( + '123 Main St, Jasper, GA, 30143, United States' + ); + }); + + it('falls back to the country code when the country is unknown', () => { + expect(formatSingleLineAddress(address({ countryCode: 'ZZ' }))).toBe( + '123 Main St, Suite 100, Jasper, GA, 30143, ZZ' + ); + }); + + it('de-duplicates admin areas when city and admin area have the same value', () => { + expect( + formatSingleLineAddress( + address({ + adminArea1: 'Jasper', + adminArea2: 'Jasper', + countryCode: 'US', + }) + ) + ).toBe('123 Main St, Suite 100, Jasper, 30143, United States'); + }); + + it('returns an empty string when no address is provided', () => { + expect(formatSingleLineAddress()).toBe(''); + }); +}); diff --git a/packages/react/src/components/checkout/address/utils/format-address.ts b/packages/react/src/components/checkout/address/utils/format-address.ts index caa374fb..5eeafa94 100644 --- a/packages/react/src/components/checkout/address/utils/format-address.ts +++ b/packages/react/src/components/checkout/address/utils/format-address.ts @@ -1,4 +1,9 @@ import type { Address } from '@/types'; +import { countryTuples } from '../country-region-data'; + +const countryNamesByCode = new Map( + countryTuples.map(([label, value]) => [value, label]) +); export function formatSingleLineAddress(address?: Address): string { if (!address) return ''; @@ -15,6 +20,10 @@ export function formatSingleLineAddress(address?: Address): string { countryCode, } = address; + const country = countryCode + ? (countryNamesByCode.get(countryCode) ?? countryCode) + : undefined; + const parts = [ addressLine1, addressLine2, @@ -24,13 +33,14 @@ export function formatSingleLineAddress(address?: Address): string { adminArea2, adminArea1, postalCode, - countryCode, + country, ]; const seen = new Set(); const formattedParts = parts.filter((part): part is string => { - if (!part || seen.has(part)) return false; - seen.add(part); + const trimmedPart = part?.trim(); + if (!trimmedPart || seen.has(trimmedPart)) return false; + seen.add(trimmedPart); return true; }); diff --git a/packages/react/src/components/checkout/address/utils/is-address-complete.test.ts b/packages/react/src/components/checkout/address/utils/is-address-complete.test.ts new file mode 100644 index 00000000..783d845a --- /dev/null +++ b/packages/react/src/components/checkout/address/utils/is-address-complete.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { isAddressComplete } from './is-address-complete'; + +const completeAddress = { + addressLine1: '123 Main St', + adminArea1: 'GA', + adminArea2: 'Jasper', + postalCode: '30143', + countryCode: 'US', +}; + +describe('isAddressComplete', () => { + it('returns true only when all required fields are non-empty', () => { + expect(isAddressComplete(completeAddress)).toBe(true); + expect(isAddressComplete({ ...completeAddress, addressLine1: ' ' })).toBe( + false + ); + expect(isAddressComplete({ ...completeAddress, adminArea2: '' })).toBe( + false + ); + expect(isAddressComplete({ ...completeAddress, countryCode: '' })).toBe( + false + ); + }); + + it('does not require state for countries with no region data', () => { + expect( + isAddressComplete({ + addressLine1: '10 High Street', + adminArea1: '', + adminArea2: 'London', + postalCode: 'SW1A 1AA', + countryCode: 'ZZ', + }) + ).toBe(true); + }); + + it('returns false for an empty postal code when the country requires it', () => { + expect(isAddressComplete({ ...completeAddress, postalCode: ' ' })).toBe( + false + ); + }); +}); diff --git a/packages/react/src/components/checkout/address/utils/is-address-complete.ts b/packages/react/src/components/checkout/address/utils/is-address-complete.ts index ed5d6f42..f7da73a3 100644 --- a/packages/react/src/components/checkout/address/utils/is-address-complete.ts +++ b/packages/react/src/components/checkout/address/utils/is-address-complete.ts @@ -1,3 +1,5 @@ +import { hasRegionData } from '../get-country-region'; + /** * Utility to check if an address has all required fields for shipping/tax calculations. * @@ -11,11 +13,14 @@ export function isAddressComplete(address: { postalCode?: string; countryCode?: string; }): boolean { + const countryCode = address.countryCode?.trim(); + const requiresRegion = countryCode ? hasRegionData(countryCode) : false; + return !!( address.addressLine1?.trim() && - address.adminArea1?.trim() && + (!requiresRegion || address.adminArea1?.trim()) && address.adminArea2?.trim() && address.postalCode?.trim() && - address.countryCode?.trim() + countryCode ); } diff --git a/packages/react/src/components/checkout/address/utils/use-address-verification.test.tsx b/packages/react/src/components/checkout/address/utils/use-address-verification.test.tsx new file mode 100644 index 00000000..5dc17e73 --- /dev/null +++ b/packages/react/src/components/checkout/address/utils/use-address-verification.test.tsx @@ -0,0 +1,182 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { + type AddressVerificationInput, + useAddressVerification, +} from '@/components/checkout/address/utils/use-address-verification'; +import { checkoutContext } from '@/components/checkout/checkout'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { verifyAddress } from '@/lib/godaddy/godaddy'; +import { + buildCheckoutSession, + buildDraftOrder, + createTestQueryClient, +} from '../../__tests__/checkout-test-env'; + +const verifyAddressMock = vi.mocked(verifyAddress); + +function VerificationProbe({ + address, + enabled = true, +}: { + address: AddressVerificationInput; + enabled?: boolean; +}) { + const queryClient = useQueryClient(); + const query = useAddressVerification(address, { enabled }); + const queryHashes = queryClient + .getQueryCache() + .findAll({ queryKey: ['verifyAddressQuery'] }) + .map(cacheQuery => cacheQuery.queryHash); + + return ( + <> +
+ {query.data?.[0]?.addressLine1 ?? 'loading'} +
+
{queryHashes.join('|')}
+ + ); +} + +function CheckoutContextHarness({ + sessionOverrides, + ...props +}: React.ComponentProps & { + sessionOverrides?: Parameters[0]; +}) { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + ...sessionOverrides, + }); + + return ( + undefined, + checkoutErrors: undefined, + setCheckoutErrors: () => undefined, + }} + > + + + ); +} + +function renderVerificationProbe( + props: React.ComponentProps +) { + return render( + + + + ); +} + +const completeAddress = { + addressLine1: '456 Shipping Ln', + addressLine2: 'Suite 7', + addressLine3: '', + adminArea1: 'GA', + adminArea2: 'Jasper', + adminArea3: '', + adminArea4: '', + postalCode: '30143', + countryCode: 'US', +}; + +describe('useAddressVerification', () => { + it.each([ + ['disabled option', completeAddress, true, false], + ['missing session id', completeAddress, false, true], + [ + 'missing address line 1', + { ...completeAddress, addressLine1: '' }, + true, + true, + ], + ['missing postal code', { ...completeAddress, postalCode: '' }, true, true], + [ + 'missing country code', + { ...completeAddress, countryCode: '' }, + true, + true, + ], + ])( + 'does not call verifyAddress when %s gate fails', + async (_label, address, hasSessionId, enabled) => { + verifyAddressMock.mockResolvedValue({ verifyAddress: [] }); + + renderVerificationProbe({ + address, + enabled, + sessionOverrides: hasSessionId ? undefined : { id: '' }, + }); + + await waitFor(() => { + expect(verifyAddressMock).not.toHaveBeenCalled(); + }); + } + ); + + it('calls verifyAddress once with the input when all enabled gates pass', async () => { + verifyAddressMock.mockResolvedValue({ + verifyAddress: [ + { ...completeAddress, addressLine1: '456 Shipping Lane' }, + ], + }); + + renderVerificationProbe({ address: completeAddress }); + + await waitFor(() => { + expect(verifyAddressMock).toHaveBeenCalledTimes(1); + }); + expect(verifyAddressMock).toHaveBeenCalledWith( + completeAddress, + expect.objectContaining({ id: 'checkout-session-1' }), + 'api.godaddy.test' + ); + expect(await screen.findByTestId('status')).toHaveTextContent( + '456 Shipping Lane' + ); + }); + + it('keys separate address typings by normalized address fields', async () => { + verifyAddressMock.mockResolvedValue({ verifyAddress: [] }); + const queryClient = createTestQueryClient(); + const { rerender } = render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('hashes').textContent).toContain( + '456 shipping ln' + ); + }); + + rerender( + + + + ); + + await waitFor(() => { + const hashes = screen.getByTestId('hashes').textContent ?? ''; + expect(hashes).toContain('456 shipping ln'); + expect(hashes).toContain('789 shipping ln'); + }); + }); +}); diff --git a/packages/react/src/components/checkout/address/utils/use-clear-billing-address.test.tsx b/packages/react/src/components/checkout/address/utils/use-clear-billing-address.test.tsx new file mode 100644 index 00000000..f1882abd --- /dev/null +++ b/packages/react/src/components/checkout/address/utils/use-clear-billing-address.test.tsx @@ -0,0 +1,151 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; +import { describe, expect, it, vi } from 'vitest'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; +import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { + buildCheckoutSession, + buildDraftOrder, + createTestQueryClient, + getOperations, + mockGodaddyApi, + waitForOperation, +} from '../../__tests__/checkout-test-env'; + +function BillingProbe() { + const form = useFormContext(); + + return ( + <> + + {[ + 'billingFirstName', + 'billingLastName', + 'billingPhone', + 'billingAddressLine1', + 'billingAddressLine2', + 'billingAddressLine3', + 'billingAdminArea4', + 'billingAdminArea3', + 'billingAdminArea2', + 'billingAdminArea1', + 'billingPostalCode', + 'billingCountryCode', + ].map(name => ( + + ))} + + ); +} + +function ClearBillingHarness({ + session, +}: { + session: ReturnType; +}) { + const form = useForm({ + defaultValues: { + paymentUseShippingAddress: true, + billingFirstName: 'Bill', + billingLastName: 'Buyer', + billingPhone: '+12015550123', + billingAddressLine1: '123 Billing St', + billingAddressLine2: 'Unit 4', + billingAddressLine3: 'Floor 2', + billingAdminArea4: 'Neighborhood', + billingAdminArea3: 'District', + billingAdminArea2: 'Jasper', + billingAdminArea1: 'GA', + billingPostalCode: '30143', + billingCountryCode: 'US', + } as CheckoutFormData, + }); + + return ( + + undefined, + checkoutErrors: undefined, + setCheckoutErrors: () => undefined, + }} + > + + + + + + ); +} + +function renderClearBillingHarness() { + const draftOrder = buildDraftOrder(); + const session = buildCheckoutSession({ draftOrder }); + const queryClient = createTestQueryClient(); + + mockGodaddyApi({ session, draftOrder }); + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { ...session, draftOrder }, + }); + + render( + + + + ); +} + +describe('useClearBillingAddress', () => { + it('clears all billing fields and queues a null billing patch when toggling off use-shipping', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderClearBillingHarness(); + + const toggle = screen.getByRole('checkbox', { + name: /use shipping address/i, + }); + expect(toggle).toBeChecked(); + + await user.click(toggle); + + for (const name of [ + 'billingFirstName', + 'billingLastName', + 'billingPhone', + 'billingAddressLine1', + 'billingAddressLine2', + 'billingAddressLine3', + 'billingAdminArea4', + 'billingAdminArea3', + 'billingAdminArea2', + 'billingAdminArea1', + 'billingPostalCode', + 'billingCountryCode', + ]) { + expect(screen.getByLabelText(name)).toHaveValue(''); + } + + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + await waitFor(() => { + expect( + getOperations('UpdateCheckoutSessionDraftOrder').at(-1)?.input + ).toMatchObject({ + billing: null, + }); + }); + }); +}); diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index 6bc761a6..143ea113 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -5,7 +5,7 @@ import React, { type ReactNode } from 'react'; import { z } from 'zod'; import { hasRegionData } from '@/components/checkout/address'; import { checkIsValidPhone } from '@/components/checkout/address/utils/check-is-valid-phone'; -import { DeliveryMethods } from '@/components/checkout/delivery/delivery-method'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { getRequiredFieldsFromSchema } from '@/components/checkout/form/utils/get-required-fields-from-schema'; import { type GoDaddyVariables, useGoDaddyContext } from '@/godaddy-provider'; import { useCheckoutSession } from '@/hooks/use-checkout-session'; @@ -151,9 +151,6 @@ export const baseCheckoutSchema = z.object({ shippingPostalCode: z.string().max(60), shippingCountryCode: z.string().max(2), shippingMethod: z.string().optional(), - shippingValid: z.literal(true, { - errorMap: () => ({ message: 'Invalid shipping address' }), - }), billingFirstName: z.string().max(60), billingLastName: z.string().max(60), billingPhone: z.string().max(15, 'Phone number too long').optional(), @@ -174,9 +171,6 @@ export const baseCheckoutSchema = z.object({ billingAdminArea1: z.string().max(100).describe('State or province'), billingPostalCode: z.string().max(60), billingCountryCode: z.string().max(2), - billingValid: z.literal(true, { - errorMap: () => ({ message: 'Invalid billing address' }), - }), paymentCardNumber: z.string().optional(), paymentCardNumberDisplay: z.string().optional(), paymentCardType: z.string().optional(), diff --git a/packages/react/src/components/checkout/delivery/delivery-method.tsx b/packages/react/src/components/checkout/delivery/delivery-method.tsx index 0861708e..57514e53 100644 --- a/packages/react/src/components/checkout/delivery/delivery-method.tsx +++ b/packages/react/src/components/checkout/delivery/delivery-method.tsx @@ -1,5 +1,5 @@ import { Store, Truck } from 'lucide-react'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { type CheckoutFormData, @@ -10,26 +10,12 @@ import { FormControl, FormField, FormItem } from '@/components/ui/form'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { useGoDaddyContext } from '@/godaddy-provider'; +import { cn } from '@/lib/utils'; +import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; +import { DeliveryMethods } from './delivery-methods'; -export enum DeliveryMethods { - NONE = 'NONE', - PICKUP = 'PICKUP', - SHIP = 'SHIP', - CURBSIDE = 'CURBSIDE', - DELIVERY = 'DELIVERY', - DRIVE_THRU = 'DRIVE_THRU', - FOR_HERE = 'FOR_HERE', - TO_GO = 'TO_GO', - DIGITAL = 'DIGITAL', - PURCHASE = 'PURCHASE', - GENERAL_CONTAINER = 'GENERAL_CONTAINER', - QUICK_STAY = 'QUICK_STAY', - REGULAR_STAY = 'REGULAR_STAY', - NON_LODGING_NRR = 'NON_LODGING_NRR', - NON_LODGING_SALE = 'NON_LODGING_SALE', - GIFT_CARD = 'GIFT_CARD', -} +export { DeliveryMethods }; export interface DeliveryMethod { id: CheckoutFormData['deliveryMethod']; @@ -58,14 +44,20 @@ export function DeliveryMethodForm() { const form = useFormContext(); const { session, isConfirmingCheckout } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); + const isDisabled = isConfirmingCheckout || isPaymentDisabled; const handleDeliveryMethodChange = (value: DeliveryMethods) => { - form.setValue('deliveryMethod', value); + if (isDisabled || form.getValues('deliveryMethod') === value) return; + + form.setValue('deliveryMethod', value, { + shouldDirty: true, + shouldValidate: true, + }); if (value === DeliveryMethods.PICKUP) { - form.setValue('shippingMethod', undefined); + form.setValue('shippingMethod', undefined, { shouldDirty: true }); } track({ - eventId: 'change_delivery_method.click', + eventId: eventIds.changeDeliveryMethod, type: TrackingEventType.CLICK, properties: { deliveryMethod: value, @@ -73,10 +65,35 @@ export function DeliveryMethodForm() { }); }; - const availableMethods = [ - ...(session?.enableShipping ? [DELIVERY_METHODS[0]] : []), - ...(session?.enableLocalPickup ? [DELIVERY_METHODS[1]] : []), - ]; + const getDeliveryMethodLabel = (methodId: DeliveryMethods) => { + switch (methodId) { + case DeliveryMethods.SHIP: + return t.delivery.shipping; + case DeliveryMethods.PICKUP: + return t.delivery.localPickup; + default: + return methodId; + } + }; + + const getDeliveryMethodDescription = (methodId: DeliveryMethods) => { + switch (methodId) { + case DeliveryMethods.SHIP: + return t.delivery.shipToAddress; + case DeliveryMethods.PICKUP: + return t.delivery.pickupFromStore; + default: + return undefined; + } + }; + + const availableMethods = useMemo( + () => [ + ...(session?.enableShipping ? [DELIVERY_METHODS[0]] : []), + ...(session?.enableLocalPickup ? [DELIVERY_METHODS[1]] : []), + ], + [session?.enableShipping, session?.enableLocalPickup] + ); // Set default delivery method when component loads useEffect(() => { @@ -108,14 +125,12 @@ export function DeliveryMethodForm() {
- {availableMethods[0].id === DeliveryMethods.SHIP - ? t.delivery.shipping - : t.delivery.localPickup} -

- {availableMethods[0].id === DeliveryMethods.SHIP - ? t.delivery.shipToAddress - : t.delivery.pickupFromStore} -

+ {getDeliveryMethodLabel(availableMethods[0].id)} + {getDeliveryMethodDescription(availableMethods[0].id) ? ( +

+ {getDeliveryMethodDescription(availableMethods[0].id)} +

+ ) : null}
{availableMethods[0].icon}
@@ -132,34 +147,43 @@ export function DeliveryMethodForm() { value={field.value} onValueChange={handleDeliveryMethodChange} required - disabled={isConfirmingCheckout || isPaymentDisabled} + disabled={isDisabled} > {availableMethods.map((method, index) => (