import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
import {
render,
screen,
userEvent,
waitFor,
mockProduct,
mockProductVariant,
mockMinisSDK,
resetAllMocks,
} from '../../test-utils'
import {
ProductCard,
ProductCardContainer,
ProductCardImageContainer,
ProductCardImage,
ProductCardBadge,
ProductCardFavoriteButton,
ProductCardInfo,
ProductCardTitle,
ProductCardReviewStars,
ProductCardPrice,
} from './product-card'
// Mock hooks
vi.mock('../../hooks/navigation/useShopNavigation', () => ({
useShopNavigation: () => ({
navigateToProduct: mockMinisSDK.navigateToProduct,
navigateToShop: mockMinisSDK.navigateToShop,
}),
}))
vi.mock('../../hooks/user/useSavedProductsActions', () => ({
useSavedProductsActions: () => ({
saveProduct: mockMinisSDK.saveProduct,
unsaveProduct: mockMinisSDK.unsaveProduct,
}),
}))
// Mock formatMoney
vi.mock('../../utils/formatMoney', () => ({
formatMoney: vi.fn((amount: string, currencyCode: string) => {
const numAmount = parseFloat(amount)
return currencyCode === 'USD'
? `$${numAmount.toFixed(2)}`
: `${currencyCode} ${numAmount.toFixed(2)}`
}),
}))
describe('ProductCard', () => {
beforeEach(() => {
resetAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('renders with default variant', () => {
const product = mockProduct()
render()
// Check for product title
expect(screen.getByText(product.title)).toBeInTheDocument()
// Check for price (default from createProduct is $99.99)
expect(screen.getByText('$99.99')).toBeInTheDocument()
// Check for image container
expect(screen.getByRole('img')).toBeInTheDocument()
})
it('renders with priceOverlay variant', () => {
const product = mockProduct()
render()
// Price should be in a badge on top-left
const badges = screen.getAllByText('$99.99')
expect(badges.length).toBeGreaterThan(0)
// Should not show product info section in priceOverlay variant
expect(screen.queryByText(product.title)).toBeNull()
})
it('renders with compact variant', () => {
const product = mockProduct()
render()
// Should not show product info section in compact variant
const title = screen.queryByText(product.title)
expect(title).toBeNull()
})
it('renders with selected variant', () => {
const product = mockProduct()
const selectedVariant = mockProductVariant({
id: 'variant-2',
price: {amount: '39.99', currencyCode: 'USD'},
})
render(
)
// Should show selected variant price
expect(screen.getByText('$39.99')).toBeInTheDocument()
})
it('renders with compare at price', () => {
const product = mockProduct({
price: {amount: '29.99', currencyCode: 'USD'},
compareAtPrice: {amount: '39.99', currencyCode: 'USD'},
})
render()
// Should show both prices
expect(screen.getByText('$29.99')).toBeInTheDocument()
expect(screen.getByText('$39.99')).toBeInTheDocument()
// Compare at price should have line-through
const compareAtPrice = screen.getByText('$39.99')
expect(compareAtPrice).toHaveClass('line-through')
})
it('renders badge when provided', () => {
const product = mockProduct()
render(
)
expect(screen.getByText('Sale')).toBeInTheDocument()
})
it('renders without image when product has no featured image', () => {
const product = mockProduct({featuredImage: undefined})
render()
expect(screen.getByText('No Image')).toBeInTheDocument()
})
it('renders favorite button by default', () => {
const product = mockProduct()
render()
// Favorite button should be present
const favoriteButton = screen.getByRole('button')
expect(favoriteButton).toBeInTheDocument()
})
it('hides favorite button when favoriteButtonDisabled is true', () => {
const product = mockProduct()
render()
// Favorite button should not be present
const favoriteButton = screen.queryByRole('button')
expect(favoriteButton).not.toBeInTheDocument()
})
it('shows favorite button when favoriteButtonDisabled is false', () => {
const product = mockProduct()
render()
// Favorite button should be present
const favoriteButton = screen.getByRole('button')
expect(favoriteButton).toBeInTheDocument()
})
it('renders review stars when review data is available', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: 4.5,
reviewCount: 123,
},
})
render()
// Should show the review stars and count
expect(screen.getByText('(123)')).toBeInTheDocument()
// Check for star elements - ProductReviewStars uses overlay technique with 10 stars total
const container = screen.getByText('(123)').parentElement
const stars = container?.querySelectorAll('svg')
// 5 empty stars + filled stars overlay (4 full + 1 half for 4.5 rating)
expect(stars).toHaveLength(10)
// Verify review count is displayed
expect(screen.getByText('(123)')).toBeInTheDocument()
})
it('does not render review stars when review data is missing', () => {
const product = mockProduct({
reviewAnalytics: undefined,
})
render()
// Should not show review count
expect(screen.queryByText(/^\(\d+\)$/)).not.toBeInTheDocument()
})
it('does not render review stars when averageRating is null', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: null,
reviewCount: 100,
},
})
render()
// Should not show review count
expect(screen.queryByText('(100)')).not.toBeInTheDocument()
})
it('does not render review stars when reviewCount is 0', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: 4.5,
reviewCount: 0,
},
})
render()
// Should not show review count
expect(screen.queryByText('(0)')).not.toBeInTheDocument()
})
it('formats large review counts correctly', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: 4.0,
reviewCount: 1500,
},
})
render()
// Should format as 1K
expect(screen.getByText('(1K)')).toBeInTheDocument()
})
it('does not show review stars in non-default variants', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: 4.5,
reviewCount: 123,
},
})
const {rerender} = render(
)
// Should not show review stars in priceOverlay variant
expect(screen.queryByText('(123)')).not.toBeInTheDocument()
rerender()
// Should not show review stars in compact variant
expect(screen.queryByText('(123)')).not.toBeInTheDocument()
rerender()
// Should show review stars in default variant
expect(screen.getByText('(123)')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('handles product click navigation', async () => {
const user = userEvent.setup()
const product = mockProduct()
const onProductClick = vi.fn()
render()
const container = screen.getByText(product.title).closest('div')
?.parentElement?.parentElement
await user.click(container!)
expect(onProductClick).toHaveBeenCalledTimes(1)
expect(mockMinisSDK.navigateToProduct).toHaveBeenCalledWith({
productId: product.id,
})
})
it('does not navigate when touchable is false', async () => {
const user = userEvent.setup()
const product = mockProduct()
const onProductClick = vi.fn()
render(
)
const container = screen.getByText(product.title).closest('div')
?.parentElement?.parentElement
await user.click(container!)
expect(onProductClick).not.toHaveBeenCalled()
expect(mockMinisSDK.navigateToProduct).not.toHaveBeenCalled()
})
it('handles favorite toggle - save product', async () => {
const user = userEvent.setup()
const product = mockProduct({isFavorited: false})
const onFavoriteToggled = vi.fn()
mockMinisSDK.saveProduct.mockResolvedValue(undefined)
render(
)
const favoriteButton = screen.getByRole('button')
await user.click(favoriteButton)
await waitFor(() => {
expect(mockMinisSDK.saveProduct).toHaveBeenCalledWith({
productId: product.id,
shopId: product.shop.id,
productVariantId: product.defaultVariantId,
})
expect(onFavoriteToggled).toHaveBeenCalledWith(true)
})
})
it('handles favorite toggle - unsave product', async () => {
const user = userEvent.setup()
const product = mockProduct({isFavorited: true})
const onFavoriteToggled = vi.fn()
mockMinisSDK.unsaveProduct.mockResolvedValue(undefined)
render(
)
const favoriteButton = screen.getByRole('button')
await user.click(favoriteButton)
await waitFor(() => {
expect(mockMinisSDK.unsaveProduct).toHaveBeenCalledWith({
productId: product.id,
shopId: product.shop.id,
productVariantId: product.defaultVariantId,
})
expect(onFavoriteToggled).toHaveBeenCalledWith(false)
})
})
it('reverts favorite state on error', async () => {
const user = userEvent.setup()
const product = mockProduct({isFavorited: false})
const onFavoriteToggled = vi.fn()
// Mock save to reject
mockMinisSDK.saveProduct.mockRejectedValue(new Error('Save failed'))
render(
)
const favoriteButton = screen.getByRole('button')
// Initially unfilled
expect(favoriteButton).toHaveClass('bg-button-overlay/30')
await user.click(favoriteButton)
// Should call save
expect(mockMinisSDK.saveProduct).toHaveBeenCalled()
// Wait for error handling
await waitFor(() => {
// Should revert to unfilled state
expect(favoriteButton).toHaveClass('bg-button-overlay/30')
})
})
it('does not allow favorite toggle when favoriteButtonDisabled is true', async () => {
const product = mockProduct({isFavorited: false})
const onFavoriteToggled = vi.fn()
render(
)
// Button should not exist at all
const favoriteButton = screen.queryByRole('button')
expect(favoriteButton).not.toBeInTheDocument()
// Callbacks should not be called
expect(onFavoriteToggled).not.toHaveBeenCalled()
expect(mockMinisSDK.saveProduct).not.toHaveBeenCalled()
expect(mockMinisSDK.unsaveProduct).not.toHaveBeenCalled()
})
})
describe('Custom Composition', () => {
it('renders with custom children layout', () => {
const product = mockProduct()
render(
)
expect(screen.getByTestId('custom-layout')).toBeInTheDocument()
expect(screen.getByText('Custom Title')).toBeInTheDocument()
expect(screen.getByText('$99.99')).toBeInTheDocument()
})
it('allows individual component usage', () => {
const product = mockProduct()
render(
Custom Badge
)
expect(screen.getByText('Custom Badge')).toBeInTheDocument()
expect(screen.getByText(product.title)).toBeInTheDocument()
expect(screen.getByText('$99.99')).toBeInTheDocument()
})
it('allows ProductCardReviewStars in custom composition', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: 4.2,
reviewCount: 50,
},
})
render(
)
// Review stars should be rendered
expect(screen.getByText('(50)')).toBeInTheDocument()
// Check for star elements - ProductReviewStars uses overlay technique
const container = screen.getByText('(50)').parentElement
const stars = container?.querySelectorAll('svg')
expect(stars).toHaveLength(9) // 5 empty + 3 full + 1 half for 3.5 rating
})
it('respects favoriteButtonDisabled in custom composition', () => {
const product = mockProduct()
render(
)
// Favorite button should not be rendered even when explicitly included
const favoriteButton = screen.queryByRole('button')
expect(favoriteButton).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('handles product without variants', () => {
const product = mockProduct({
variants: [],
selectedVariant: undefined,
})
render()
expect(screen.getByText(product.title)).toBeInTheDocument()
expect(screen.getByText('$99.99')).toBeInTheDocument()
})
})
describe('Accessibility', () => {
it('has proper alt text for images', () => {
const product = mockProduct()
render()
const img = screen.getByRole('img')
expect(img).toHaveAttribute(
'alt',
product.featuredImage?.altText || product.title
)
})
it('uses product title as alt when image alt text is missing', () => {
const product = mockProduct({
featuredImage: {
url: 'https://example.com/image.jpg',
altText: '',
width: 1000,
height: 1000,
sensitive: false,
thumbhash: 'thumbhash',
},
})
render()
const img = screen.getByRole('img')
expect(img).toHaveAttribute('alt', product.title)
})
it('maintains proper heading hierarchy', () => {
const product = mockProduct()
render()
const heading = screen.getByRole('heading', {level: 3})
expect(heading).toHaveTextContent(product.title)
})
})
})