diff --git a/packages/template-chakra-storefront/mocks/standard-product.js b/packages/template-chakra-storefront/mocks/standard-product.js new file mode 100644 index 0000000000..96afde2aaf --- /dev/null +++ b/packages/template-chakra-storefront/mocks/standard-product.js @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +export const mockStandardProductOrderable = { + currency: 'GBP', + id: 'a-standard-dress', + imageGroups: [ + { + images: [ + { + alt: 'White and Black Tone, , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw9178fd89/images/large/PG.W20766.IVORYXX.PZ.jpg', + link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw9178fd89/images/large/PG.W20766.IVORYXX.PZ.jpg', + title: 'White and Black Tone, ' + } + ], + viewType: 'large' + }, + { + images: [ + { + alt: 'White and Black Tone, , medium', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw9af8c50e/images/medium/PG.W20766.IVORYXX.PZ.jpg', + link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw9af8c50e/images/medium/PG.W20766.IVORYXX.PZ.jpg', + title: 'White and Black Tone, ' + } + ], + viewType: 'medium' + }, + { + images: [ + { + alt: 'White and Black Tone, , small', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw58be3274/images/small/PG.W20766.IVORYXX.PZ.jpg', + link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw58be3274/images/small/PG.W20766.IVORYXX.PZ.jpg', + title: 'White and Black Tone, ' + } + ], + viewType: 'small' + } + ], + inventory: { + ats: 999999, + backorderable: false, + id: 'inventory_m', + orderable: true, + preorderable: false, + stockLevel: 999999 + }, + longDescription: 'A Standard Dress', + minOrderQuantity: 1, + name: 'White and Black Tone', + pageMetaTags: [ + { + id: 'description', + value: 'Buy White and Black Tone at RefArchGlobal.' + }, + { + id: 'robots', + value: 'index, follow' + }, + { + id: 'og:url', + value: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.store/s/RefArchGlobal/dw/shop/v99_9/products/a-standard-dress?currency=GBP&locale=en-GB&expand=availability,promotions,options,images,prices,variations,set_products,bundled_products,page_meta_tags&all_images=true' + }, + { + id: 'title', + value: 'Buy White and Black Tone for GBP 4.00 | RefArchGlobal' + } + ], + price: 4, + pricePerUnit: 4, + primaryCategoryId: 'womens-outfits', + shortDescription: 'A Standard Dress', + slugUrl: + 'https://zzrf-001.dx.commercecloud.salesforce.com/s/RefArchGlobal/en_GB/product/a-standard-dress/a-standard-dress.html', + stepQuantity: 1, + type: { + item: true + }, + tieredPrices: [ + { + price: 70, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 4, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ] +} + +export const mockStandardProductNotOrderable = { + ...mockStandardProductOrderable, + id: 'a-standard-dress-not-orderable', + inventory: { + ats: 0, + backorderable: false, + id: 'inventory_m', + orderable: false, + preorderable: false, + stockLevel: 0 + } +} diff --git a/packages/template-chakra-storefront/src/components/product-view/index.jsx b/packages/template-chakra-storefront/src/components/product-view/index.jsx index 94080cf1c3..7571f811b8 100644 --- a/packages/template-chakra-storefront/src/components/product-view/index.jsx +++ b/packages/template-chakra-storefront/src/components/product-view/index.jsx @@ -162,8 +162,21 @@ const ProductView = forwardRef( setChildProductOrderability, isBasketLoading = false, onVariantSelected = () => {}, - validateOrderability = (variant, quantity, stockLevel) => - !isProductLoading && variant?.orderable && quantity > 0 && quantity <= stockLevel, + validateOrderability = (product, variant, quantity, stockLevel) => { + if (isProductLoading) return false + + // If product has variations, a variant must be selected + if (product?.variationAttributes?.length > 0 && !variant) { + return false + } + + // Check if product (either variant or standard) is orderable and if quantity is valid + return ( + (variant?.orderable || product?.inventory?.orderable) && + quantity > 0 && + quantity <= stockLevel + ) + }, showImageGallery = true, setSelectedBundleQuantity = () => {}, selectedBundleParentQuantity = 1 @@ -199,8 +212,8 @@ const ProductView = forwardRef( return getPriceData(product, {quantity}) }, [product, quantity]) const canAddToWishlist = !isProductLoading - const isProductASet = product?.type.set - const isProductABundle = product?.type.bundle + const isProductASet = product?.type?.set + const isProductABundle = product?.type?.bundle const errorContainerRef = useRef(null) const {disableButton, customInventoryMessage} = useMemo(() => { @@ -240,7 +253,7 @@ const ProductView = forwardRef( const validateAndShowError = (opts = {}) => { const {scrollErrorIntoView = true} = opts // Validate that all attributes are selected before proceeding. - const hasValidSelection = validateOrderability(variant, quantity, stockLevel) + const hasValidSelection = validateOrderability(product, variant, quantity, stockLevel) const hasError = !isProductASet && !isProductABundle && !hasValidSelection const scrollToError = hasError && scrollErrorIntoView @@ -379,7 +392,7 @@ const ProductView = forwardRef( if ( !isProductASet && !isProductABundle && - validateOrderability(variant, quantity, stockLevel) + validateOrderability(product, variant, quantity, stockLevel) ) { toggleShowOptionsMessage(false) } diff --git a/packages/template-chakra-storefront/src/components/product-view/index.test.js b/packages/template-chakra-storefront/src/components/product-view/index.test.js index 773cba5bd1..f254a4c739 100644 --- a/packages/template-chakra-storefront/src/components/product-view/index.test.js +++ b/packages/template-chakra-storefront/src/components/product-view/index.test.js @@ -7,14 +7,19 @@ import React from 'react' import PropTypes from 'prop-types' -import {act, fireEvent, screen, waitFor} from '@testing-library/react' +import {act, screen, waitFor} from '@testing-library/react' import mockProductDetail from '../../../mocks/variant-750518699578M' import mockProductSet from '../../../mocks/product-set-winter-lookM' import {mockProductBundle} from '../../../mocks/product-bundle' +import { + mockStandardProductOrderable, + mockStandardProductNotOrderable +} from '../../../mocks/standard-product' import ProductView from '../../components/product-view' import {renderWithProviders} from '../../utils/test-utils' -import userEvent from '@testing-library/user-event' + import {useCurrentCustomer} from '../../hooks' +import frMessages from '../../static/translations/compiled/fr-FR.json' const MockComponent = (props) => { const {data: customer} = useCurrentCustomer() @@ -46,261 +51,458 @@ afterEach(() => { sessionStorage.clear() }) -test('ProductView Component renders properly', async () => { - const addToCart = jest.fn() - renderWithProviders() - expect(screen.getAllByText(/Black Single Pleat Athletic Fit Wool Suit/i)).toHaveLength(2) - expect(screen.getAllByText(/299\.99/)).toHaveLength(4) - expect(screen.getAllByText(/Add to cart/i)).toHaveLength(2) - expect(screen.getAllByRole('radiogroup')).toHaveLength(3) - expect(screen.getAllByText(/add to cart/i)).toHaveLength(2) -}) - -test('ProductView Component renders with addToCart event handler', async () => { - const addToCart = jest.fn() - await renderWithProviders() - - const addToCartButton = screen.getAllByText(/add to cart/i)[0] - await act(async () => { - fireEvent.click(addToCartButton) - }) - await waitFor(() => { - expect(addToCart).toHaveBeenCalledTimes(1) - }) -}) - -test('ProductView Component renders with addToWishList event handler', async () => { - const addToWishlist = jest.fn() - - await renderWithProviders( - - ) - - await waitFor(() => { - expect(screen.getByText(/customer: registered/)).toBeInTheDocument() - }) - - const addToWishListButton = screen.getAllByText(/Add to wishlist/i)[0] - - await act(async () => { - fireEvent.click(addToWishListButton) +describe('ProductView Component', () => { + describe('Basic Rendering', () => { + test('renders properly with all expected elements', async () => { + const addToCart = jest.fn() + renderWithProviders() + expect(screen.getAllByText(/Black Single Pleat Athletic Fit Wool Suit/i)).toHaveLength( + 2 + ) + expect(screen.getAllByText(/299\.99/)).toHaveLength(4) + expect(screen.getAllByText(/Add to cart/i)).toHaveLength(2) + expect(screen.getAllByRole('radiogroup')).toHaveLength(3) + expect(screen.getAllByText(/add to cart/i)).toHaveLength(2) + }) }) - expect(addToWishlist).toHaveBeenCalledTimes(1) -}) - -test('ProductView Component renders with updateWishlist event handler', async () => { - const updateWishlist = jest.fn() - await renderWithProviders( - - ) - - await waitFor(() => { - expect(screen.getByText(/customer: registered/)).toBeInTheDocument() + describe('Event Handlers', () => { + test('calls addToCart when add to cart button is clicked', async () => { + const addToCart = jest.fn() + const {user} = renderWithProviders( + + ) + + const addToCartButton = screen.getAllByText(/add to cart/i)[0] + await act(async () => { + await user.click(addToCartButton) + }) + + await waitFor(() => { + expect(addToCart).toHaveBeenCalledTimes(1) + }) + }) + + test('calls addToWishlist when add to wishlist button is clicked', async () => { + const addToWishlist = jest.fn() + + const {user} = await renderWithProviders( + + ) + + await waitFor(() => { + expect(screen.getByText(/customer: registered/)).toBeInTheDocument() + }) + + const addToWishListButton = screen.getAllByText(/Add to wishlist/i)[0] + await act(async () => { + await user.click(addToWishListButton) + }) + await waitFor(() => { + expect(addToWishlist).toHaveBeenCalledTimes(1) + }) + }) + + test('calls updateWishlist when update button is clicked', async () => { + const updateWishlist = jest.fn() + + const {user} = renderWithProviders( + + ) + + await waitFor(() => { + expect(screen.getByText(/customer: registered/)).toBeInTheDocument() + }) + + const updateWishlistButton = screen.getAllByText(/Update/i)[0] + await act(async () => { + await user.click(updateWishlistButton) + }) + + await waitFor(() => { + expect(updateWishlist).toHaveBeenCalledTimes(1) + }) + }) + + test('calls onVariantSelected after variant selection', async () => { + const onVariantSelected = jest.fn() + const child = mockProductSet.setProducts[0] + + const {user} = renderWithProviders( + {}} + addToWishlist={() => {}} + /> + ) + + const size = screen.getByRole('radio', {name: /xl/i}) + await act(async () => { + await user.click(size) + }) + + await waitFor(() => { + expect(onVariantSelected).toHaveBeenCalledTimes(1) + }) + }) + + test('calls validateOrderability when adding a set to cart', async () => { + const parent = mockProductSet + const validateOrderability = jest.fn() + + const {user} = renderWithProviders( + {}} + addToWishlist={() => {}} + /> + ) + + const button = screen.getByRole('button', {name: /add set to cart/i}) + await act(async () => { + await user.click(button) + }) + + await waitFor(() => { + expect(validateOrderability).toHaveBeenCalledTimes(1) + }) + }) }) - const updateWishlistButton = screen.getAllByText(/Update/i)[0] - await act(async () => { - fireEvent.click(updateWishlistButton) + describe('Quantity Management', () => { + test('can update quantity through input field', async () => { + const addToCart = jest.fn() + const {user} = await renderWithProviders( + + ) + + let quantityBox + await waitFor(() => { + quantityBox = screen.getByRole('spinbutton') + }) + + await waitFor(() => { + expect(quantityBox).toHaveValue('1') + }) + + // update item quantity + await act(async () => { + await user.type(quantityBox, '{backspace}3') + }) + + await waitFor(() => { + expect(quantityBox).toHaveValue('3') + }) + }) + + test('handles invalid quantity inputs by resetting to minimum', async () => { + // Any invalid input should be reset to minOrderQuantity + const {user} = renderWithProviders() + + const quantityInput = screen.getByRole('spinbutton', {name: /quantity/i}) + const minQuantity = mockProductDetail.minOrderQuantity.toString() + + // Quantity is empty + await act(async () => { + await user.clear(quantityInput) + }) + await act(async () => { + await user.tab() + }) + await waitFor(() => { + expect(quantityInput).toHaveValue(minQuantity) + }) + + // Quantity is zero + await act(async () => { + await user.clear(quantityInput) + }) + await act(async () => { + await user.type(quantityInput, '0') + }) + await act(async () => { + await user.tab() + }) + await waitFor(() => { + expect(quantityInput).toHaveValue(minQuantity) + }) + }) + + test('increases and decreases quantity with increment/decrement buttons', async () => { + const {user} = await renderWithProviders() + + const quantityInput = await screen.findByRole('spinbutton') + const incrementButton = screen.getByTestId('quantity-increment') + const decrementButton = screen.getByTestId('quantity-decrement') + + // Click increment + await act(async () => { + await user.click(incrementButton) + }) + await waitFor(() => { + expect(quantityInput).toHaveValue('2') + }) + + // Click decrement + await act(async () => { + await user.click(decrementButton) + }) + await waitFor(() => { + expect(quantityInput).toHaveValue('1') + }) + }) }) - expect(updateWishlist).toHaveBeenCalledTimes(1) -}) -test('Product View can update quantity', async () => { - const user = userEvent.setup() - const addToCart = jest.fn() - await renderWithProviders() - - let quantityBox - await waitFor(() => { - quantityBox = screen.getByRole('spinbutton') + describe('Loading States', () => { + test('disables add to cart button when basket is loading', async () => { + renderWithProviders( + {}} + isBasketLoading={true} + /> + ) + + // In Chakra UI v3, when a Button has loading={true}, the button text is hidden with a loading spinner. + // A data-loading attribute is automatically added to the DOM element + const addToCartButton = document.querySelector('[data-loading]') + expect(addToCartButton).toBeDisabled() + }) + + test('enables add to cart button when basket is not loading', async () => { + renderWithProviders( + {}} + isBasketLoading={false} + /> + ) + expect(screen.getByRole('button', {name: /add to cart/i})).toBeEnabled() + }) }) - await waitFor(() => { - expect(quantityBox).toHaveValue('1') + describe('Product Sets', () => { + test('renders parent item correctly', () => { + const parent = mockProductSet + renderWithProviders( + {}} addToWishlist={() => {}} /> + ) + + // NOTE: there can be duplicates of the same element, due to mobile and desktop views + // (they're hidden with display:none style) + + const fromAtLabel = screen.getAllByText(/from/i)[0] + const addSetToCartButton = screen.getAllByRole('button', {name: /add set to cart/i})[0] + const addSetToWishlistButton = screen.getAllByRole('button', { + name: /add set to wishlist/i + })[0] + const variationAttributes = screen.queryAllByRole('radiogroup') // e.g. sizes, colors + const quantityPicker = screen.queryByRole('spinbutton', {name: /quantity/i}) + + // What should exist: + expect(fromAtLabel).toBeInTheDocument() + expect(addSetToCartButton).toBeInTheDocument() + expect(addSetToWishlistButton).toBeInTheDocument() + + // What should _not_ exist: + expect(variationAttributes).toHaveLength(0) + expect(quantityPicker).toBeNull() + }) + + test('renders child item correctly', () => { + const child = mockProductSet.setProducts[0] + renderWithProviders( + {}} addToWishlist={() => {}} /> + ) + + // NOTE: there can be duplicates of the same element, due to mobile and desktop views + // (they're hidden with display:none style) + + const addToCartButton = screen.getAllByRole('button', {name: /add to cart/i})[0] + const addToWishlistButton = screen.getAllByRole('button', {name: /add to wishlist/i})[0] + const variationAttributes = screen.getAllByRole('radiogroup') // e.g. sizes, colors + const quantityPicker = screen.getByRole('spinbutton', {name: /quantity/i}) + const fromLabels = screen.queryAllByText(/from/i) + + // What should exist: + expect(addToCartButton).toBeInTheDocument() + expect(addToWishlistButton).toBeInTheDocument() + expect(variationAttributes).toHaveLength(2) + expect(quantityPicker).toBeInTheDocument() + + // since setProducts are master products, as pricing now display From X (cross) Y where X Y are sale and lis price respectively + // of the variant that has lowest price (including promotional price) + expect(fromLabels).toHaveLength(4) + }) }) - await act(async () => { - // update item quantity - await user.type(quantityBox, '{backspace}3') - }) - await waitFor(() => { - expect(quantityBox).toHaveValue('3') + describe('Product Bundles', () => { + test('renders parent item correctly', () => { + const parent = mockProductBundle + renderWithProviders( + {}} addToWishlist={() => {}} /> + ) + + // NOTE: there can be duplicates of the same element, due to mobile and desktop views + // (they're hidden with display:none style) + const addBundleToCartButton = screen.getAllByRole('button', { + name: /add bundle to cart/i + })[0] + const addBundleToWishlistButton = screen.getAllByRole('button', { + name: /add bundle to wishlist/i + })[0] + const quantityPicker = screen.getByRole('spinbutton', {name: /quantity/i}) + const variationAttributes = screen.queryAllByRole('radiogroup') // e.g. sizes, colors + + // What should exist: + expect(addBundleToCartButton).toBeInTheDocument() + expect(addBundleToWishlistButton).toBeInTheDocument() + expect(quantityPicker).toBeInTheDocument() + + // What should _not_ exist: + expect(variationAttributes).toHaveLength(0) + }) + + test('renders child item correctly', () => { + const child = mockProductBundle.bundledProducts[0].product + renderWithProviders( + {}} + addToWishlist={() => {}} + isProductPartOfBundle={true} + setChildProductOrderability={() => {}} + /> + ) + + const addToCartButton = screen.queryByRole('button', {name: /add to cart/i}) + const addToWishlistButton = screen.queryByRole('button', {name: /add to wishlist/i}) + const variationAttributes = screen.getAllByRole('radiogroup') // e.g. sizes, colors + const quantityPicker = screen.queryByRole('spinbutton', {name: /quantity:/i}) + + // What should exist: + expect(variationAttributes).toHaveLength(2) + + // What should _not_ exist: + expect(addToCartButton).toBeNull() + expect(addToWishlistButton).toBeNull() + expect(quantityPicker).toBeNull() + }) }) }) -test('renders a product set properly - parent item', () => { - const parent = mockProductSet +test('renders "Add to Cart" and "Add to Wishlist" buttons in French', async () => { + const addToCart = jest.fn() + const addToWishlist = jest.fn() renderWithProviders( - {}} addToWishlist={() => {}} /> + , + { + wrapperProps: {locale: {id: 'fr-FR'}, messages: frMessages} + } ) - // NOTE: there can be duplicates of the same element, due to mobile and desktop views - // (they're hidden with display:none style) - - const fromAtLabel = screen.getAllByText(/from/i)[0] - const addSetToCartButton = screen.getAllByRole('button', {name: /add set to cart/i})[0] - const addSetToWishlistButton = screen.getAllByRole('button', {name: /add set to wishlist/i})[0] - const variationAttributes = screen.queryAllByRole('radiogroup') // e.g. sizes, colors - const quantityPicker = screen.queryByRole('spinbutton', {name: /quantity/i}) - - // What should exist: - expect(fromAtLabel).toBeInTheDocument() - expect(addSetToCartButton).toBeInTheDocument() - expect(addSetToWishlistButton).toBeInTheDocument() - - // What should _not_ exist: - expect(variationAttributes).toHaveLength(0) - expect(quantityPicker).toBeNull() + const titles = await screen.findAllByText(/Black Single Pleat Athletic Fit Wool Suit/i) + expect(titles.length).toBeGreaterThan(0) + expect(screen.getByRole('button', {name: /ajouter au panier/i})).toBeInTheDocument() + expect( + screen.getByRole('button', {name: /ajouter à la liste de souhaits/i}) + ).toBeInTheDocument() }) -test('renders a product set properly - child item', () => { - const child = mockProductSet.setProducts[0] - renderWithProviders( - {}} addToWishlist={() => {}} /> - ) - - // NOTE: there can be duplicates of the same element, due to mobile and desktop views - // (they're hidden with display:none style) +describe('validateOrderability', () => { + test('returns true when variant is undefined but product is orderable', () => { + const validateOrderability = (variant, product, quantity, stockLevel) => + (variant?.orderable || product?.inventory?.orderable) && + quantity > 0 && + quantity <= stockLevel - const addToCartButton = screen.getAllByRole('button', {name: /add to cart/i})[0] - const addToWishlistButton = screen.getAllByRole('button', {name: /add to wishlist/i})[0] - const variationAttributes = screen.getAllByRole('radiogroup') // e.g. sizes, colors - const quantityPicker = screen.getByRole('spinbutton', {name: /quantity/i}) - const fromLabels = screen.queryAllByText(/from/i) + const variant = undefined + const product = mockStandardProductOrderable + const quantity = 1 + const stockLevel = 999999 - // What should exist: - expect(addToCartButton).toBeInTheDocument() - expect(addToWishlistButton).toBeInTheDocument() - expect(variationAttributes).toHaveLength(2) - expect(quantityPicker).toBeInTheDocument() - - // since setProducts are master products, as pricing now display From X (cross) Y where X Y are sale and lis price respectively - // of the variant that has lowest price (including promotional price) - expect(fromLabels).toHaveLength(4) -}) + const result = validateOrderability(variant, product, quantity, stockLevel) + expect(result).toBe(true) + }) -test('validateOrderability callback is called when adding a set to cart', async () => { - const user = userEvent.setup() + test('returns false when variant is undefined and product is not orderable', () => { + const validateOrderability = (variant, product, quantity, stockLevel) => + (variant?.orderable || product?.inventory?.orderable) && + quantity > 0 && + quantity <= stockLevel - const parent = mockProductSet - const validateOrderability = jest.fn() + const variant = undefined + const product = mockStandardProductNotOrderable + const quantity = 1 + const stockLevel = 0 - renderWithProviders( - {}} - addToWishlist={() => {}} - /> - ) - - const button = screen.getByRole('button', {name: /add set to cart/i}) - await act(async () => { - await user.click(button) - }) - await waitFor(() => { - expect(validateOrderability).toHaveBeenCalledTimes(1) + const result = validateOrderability(variant, product, quantity, stockLevel) + expect(result).toBe(false) }) -}) - -test('onVariantSelected callback is called after successfully selected a variant', async () => { - const user = userEvent.setup() - const onVariantSelected = jest.fn() - const child = mockProductSet.setProducts[0] + test('returns true when variant is orderable regardless of product orderability', () => { + const validateOrderability = (variant, product, quantity, stockLevel) => + (variant?.orderable || product?.inventory?.orderable) && + quantity > 0 && + quantity <= stockLevel - renderWithProviders( - {}} - addToWishlist={() => {}} - /> - ) + const variant = {orderable: true} + const product = mockStandardProductNotOrderable // product is not orderable + const quantity = 1 + const stockLevel = 999999 - const size = screen.getByRole('radio', {name: /xl/i}) - await act(async () => { - await user.click(size) + const result = validateOrderability(variant, product, quantity, stockLevel) + expect(result).toBe(true) }) - await waitFor(() => { - expect(onVariantSelected).toHaveBeenCalledTimes(1) - }) -}) -describe('add to cart button loading tests', () => { - test('add to cart button is disabled if isBasketLoading is true', async () => { - renderWithProviders( - {}} - isBasketLoading={true} - /> - ) - // In Chakra UI v3, when a Button has loading={true}, the button text is hidden with a loading spinner. - // A data-loading attribute is automatically added to the DOM element - const addToCartButton = document.querySelector('[data-loading]') - expect(addToCartButton).toBeDisabled() - }) + test('returns false when both variant and product are not orderable', () => { + const validateOrderability = (variant, product, quantity, stockLevel) => + (variant?.orderable || product?.inventory?.orderable) && + quantity > 0 && + quantity <= stockLevel + + const variant = {orderable: false} + const product = mockStandardProductNotOrderable + const quantity = 1 + const stockLevel = 0 - test('add to cart button is enabled if isBasketLoading is false', async () => { - renderWithProviders( - {}} - isBasketLoading={false} - /> - ) - expect(screen.getByRole('button', {name: /add to cart/i})).toBeEnabled() + const result = validateOrderability(variant, product, quantity, stockLevel) + expect(result).toBe(false) }) -}) -test('renders a product bundle properly - parent item', () => { - const parent = mockProductBundle - renderWithProviders( - {}} addToWishlist={() => {}} /> - ) + test('returns false when quantity is invalid even if product/variant are orderable', () => { + const validateOrderability = (variant, product, quantity, stockLevel) => + (variant?.orderable || product?.inventory?.orderable) && + quantity > 0 && + quantity <= stockLevel - // NOTE: there can be duplicates of the same element, due to mobile and desktop views - // (they're hidden with display:none style) - const addBundleToCartButton = screen.getAllByRole('button', {name: /add bundle to cart/i})[0] - const addBundleToWishlistButton = screen.getAllByRole('button', { - name: /add bundle to wishlist/i - })[0] - const quantityPicker = screen.getByRole('spinbutton', {name: /quantity/i}) - const variationAttributes = screen.queryAllByRole('radiogroup') // e.g. sizes, colors - - // What should exist: - expect(addBundleToCartButton).toBeInTheDocument() - expect(addBundleToWishlistButton).toBeInTheDocument() - expect(quantityPicker).toBeInTheDocument() - - // What should _not_ exist: - expect(variationAttributes).toHaveLength(0) -}) + const variant = undefined + const product = mockStandardProductOrderable + const quantity = 0 // invalid quantity + const stockLevel = 999999 -test('renders a product bundle properly - child item', () => { - const child = mockProductBundle.bundledProducts[0].product - renderWithProviders( - {}} - addToWishlist={() => {}} - isProductPartOfBundle={true} - setChildProductOrderability={() => {}} - /> - ) + const result = validateOrderability(variant, product, quantity, stockLevel) + expect(result).toBe(false) + }) - const addToCartButton = screen.queryByRole('button', {name: /add to cart/i}) - const addToWishlistButton = screen.queryByRole('button', {name: /add to wishlist/i}) - const variationAttributes = screen.getAllByRole('radiogroup') // e.g. sizes, colors - const quantityPicker = screen.queryByRole('spinbutton', {name: /quantity:/i}) + test('returns false when quantity exceeds stock level', () => { + const validateOrderability = (variant, product, quantity, stockLevel) => + (variant?.orderable || product?.inventory?.orderable) && + quantity > 0 && + quantity <= stockLevel - // What should exist: - expect(variationAttributes).toHaveLength(2) + const variant = undefined + const product = mockStandardProductOrderable + const quantity = 1000000 // exceeds stock level + const stockLevel = 999999 - // What should _not_ exist: - expect(addToCartButton).toBeNull() - expect(addToWishlistButton).toBeNull() - expect(quantityPicker).toBeNull() + const result = validateOrderability(variant, product, quantity, stockLevel) + expect(result).toBe(false) + }) }) diff --git a/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.js b/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.js index fbaf6b9100..044706efb4 100644 --- a/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.js +++ b/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.js @@ -168,8 +168,8 @@ export const AddToCartModal = () => { {bundleImage.alt} @@ -256,20 +256,22 @@ export const AddToCartModal = () => { )} {!isProductABundle && itemsAdded.map(({product, variant, quantity}, index) => { + const productId = variant?.productId || product?.id const image = findImageGroupBy(product.imageGroups, { viewType: 'small', - selectedVariationAttributes: variant.variationValues + selectedVariationAttributes: + variant?.variationValues })?.images?.[0] const priceData = getPriceData(product, {quantity}) const variationAttributeValues = getDisplayVariationValues( product.variationAttributes, - variant.variationValues + variant?.variationValues ) return ( { {image.alt} diff --git a/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.test.js b/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.test.js index 105da546c7..8a9d4f9999 100644 --- a/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.test.js +++ b/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.test.js @@ -569,6 +569,57 @@ const MOCK_PRODUCT = { c_size: '9LG', c_width: 'Z' } + +// Mock data for testing individual products with missing image data +const MOCK_PRODUCT_NO_IMAGE_LINK = { + ...MOCK_PRODUCT, + price: 29.99, + currency: 'USD', + imageGroups: [ + { + viewType: 'small', + images: [ + { + alt: 'Test product image', + disBaseLink: 'https://example.com/image.jpg' + } + ] + } + ] +} + +const MOCK_PRODUCT_NO_IMAGE_GROUPS = { + ...MOCK_PRODUCT, + price: 29.99, + currency: 'USD', + imageGroups: [] +} + +// Mock data for testing bundle products with missing image data +const MOCK_BUNDLE_NO_IMAGE_LINK = { + ...mockProductBundle, + price: 59.99, + currency: 'USD', + imageGroups: [ + { + viewType: 'small', + images: [ + { + alt: 'Bundle product image', + disBaseLink: 'https://example.com/bundle-image.jpg' + } + ] + } + ] +} + +const MOCK_BUNDLE_NO_IMAGE_GROUPS = { + ...mockProductBundle, + price: 59.99, + currency: 'USD', + imageGroups: [] +} + beforeEach(() => { jest.resetModules() @@ -677,3 +728,103 @@ test('renders product bundle', () => { }) }) }) + +test('renders individual product image correctly when there is no image link', async () => { + const MOCK_DATA_NO_IMAGE = { + product: MOCK_PRODUCT_NO_IMAGE_LINK, + itemsAdded: [ + { + product: MOCK_PRODUCT_NO_IMAGE_LINK, + variant: MOCK_PRODUCT.variants[0], // Provide a variant to avoid undefined errors + quantity: 1 + } + ], + selectedQuantity: 1 + } + + renderWithProviders( + + + + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText(MOCK_PRODUCT.name)).toBeInTheDocument() +}) + +test('renders individual product image when image object is not provided', async () => { + const MOCK_DATA_NO_IMAGE_OBJECT = { + product: MOCK_PRODUCT_NO_IMAGE_GROUPS, + itemsAdded: [ + { + product: MOCK_PRODUCT_NO_IMAGE_GROUPS, + variant: MOCK_PRODUCT.variants[0], + quantity: 1 + } + ], + selectedQuantity: 1 + } + + renderWithProviders( + + + + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText(MOCK_PRODUCT.name)).toBeInTheDocument() +}) + +test('renders bundle product image correctly when there is no image link', async () => { + const MOCK_BUNDLE_DATA_NO_IMAGE = { + product: MOCK_BUNDLE_NO_IMAGE_LINK, + itemsAdded: mockBundleItemsAdded, + selectedQuantity: 1 + } + + renderWithProviders( + + + + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText(mockProductBundle.name)).toBeInTheDocument() +}) + +test('renders bundle product image when image object is not provided', async () => { + const MOCK_BUNDLE_DATA_NO_IMAGE_OBJECT = { + product: MOCK_BUNDLE_NO_IMAGE_GROUPS, + itemsAdded: mockBundleItemsAdded, + selectedQuantity: 1 + } + + renderWithProviders( + + + + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText(mockProductBundle.name)).toBeInTheDocument() +}) diff --git a/packages/template-chakra-storefront/src/pages/product-detail/hooks/use-product-detail-data.js b/packages/template-chakra-storefront/src/pages/product-detail/hooks/use-product-detail-data.js index bb23f36656..922a657587 100644 --- a/packages/template-chakra-storefront/src/pages/product-detail/hooks/use-product-detail-data.js +++ b/packages/template-chakra-storefront/src/pages/product-detail/hooks/use-product-detail-data.js @@ -183,9 +183,9 @@ export const useProductDetailData = () => { const handleAddToCart = async (productSelectionValues) => { try { - const productItems = productSelectionValues.map(({variant, quantity}) => ({ - productId: variant.productId, - price: variant.price, + const productItems = productSelectionValues.map(({product, variant, quantity}) => ({ + productId: variant?.productId || product?.id, + price: variant?.price || product?.price, quantity })) diff --git a/packages/template-chakra-storefront/src/pages/product-detail/index.test.js b/packages/template-chakra-storefront/src/pages/product-detail/index.test.js index 8751ccb425..80b2c01fe4 100644 --- a/packages/template-chakra-storefront/src/pages/product-detail/index.test.js +++ b/packages/template-chakra-storefront/src/pages/product-detail/index.test.js @@ -25,6 +25,7 @@ import { basketWithProductBundle, bundleProductItemsForPDP } from '../../../mocks/product-bundle' +import {mockStandardProductOrderable} from '../../../mocks/standard-product' import Toaster, {toaster} from '../../components/toaster' jest.mock('../../hooks/use-datacloud', () => ({ @@ -596,3 +597,100 @@ describe('product bundles', () => { }) }) }) + +describe('standard product', () => { + let mockAddToCart = jest.fn() + + beforeEach(() => { + mockAddToCart = jest.fn() + prependHandlersToServer([ + { + // Use standard product without variants + path: '*/products/:productId', + method: 'get', + res: () => mockStandardProductOrderable + }, + { + // Mock the add to cart API call to capture the request + path: '*/baskets/:basketId/items', + method: 'post', + res: (req) => { + const requestBody = req.body + mockAddToCart(requestBody) + return { + basketId: 'test-basket-id', + productItems: [ + { + productId: requestBody[0].productId, + price: requestBody[0].price, + quantity: requestBody[0].quantity + } + ] + } + } + } + ]) + }) + + test('should be successfully added to cart', async () => { + window.history.pushState({}, 'ProductDetail', '/uk/en-GB/product/a-standard-dress') + + const initialBasket = {basketId: 'test-basket-id'} + const {user} = renderWithProviders(, {wrapperProps: {initialBasket}}) + + await waitFor(() => { + expect(screen.getAllByText('White and Black Tone')[0]).toBeInTheDocument() + expect(screen.getByRole('button', {name: /add to cart/i})).toBeInTheDocument() + }) + + const addToCartButton = screen.getByRole('button', {name: /add to cart/i}) + await act(async () => { + await user.click(addToCartButton) + }) + + await waitFor(() => { + expect(mockAddToCart).toHaveBeenCalledWith([ + { + productId: mockStandardProductOrderable.id, + price: mockStandardProductOrderable.price, + quantity: 1 + } + ]) + }) + }) + + test('should handle quantity change before adding to cart', async () => { + window.history.pushState({}, 'ProductDetail', '/uk/en-GB/product/a-standard-dress') + + const initialBasket = {basketId: 'test-basket-id'} + const {user} = renderWithProviders(, {wrapperProps: {initialBasket}}) + + await waitFor(() => { + expect(screen.getAllByText('White and Black Tone')[0]).toBeInTheDocument() + expect(screen.getByRole('spinbutton', {name: /quantity/i})).toBeInTheDocument() + }) + + // Change quantity to 3 + const quantityInput = screen.getByRole('spinbutton', {name: /quantity/i}) + await act(async () => { + await user.clear(quantityInput) + await user.type(quantityInput, '3') + }) + + const addToCartButton = screen.getByRole('button', {name: /add to cart/i}) + await act(async () => { + await user.click(addToCartButton) + }) + + await waitFor(() => { + // Verify that the correct quantity is passed when variant is undefined + expect(mockAddToCart).toHaveBeenCalledWith([ + { + productId: mockStandardProductOrderable.id, + price: mockStandardProductOrderable.price, + quantity: 3 + } + ]) + }) + }) +})