import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
import {
render,
screen,
userEvent,
waitFor,
mockProduct,
mockMinisSDK,
resetAllMocks,
} from '../../test-utils'
import {ProductLink} from './product-link'
// 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('ProductLink', () => {
beforeEach(() => {
resetAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('renders product information correctly', () => {
const product = mockProduct({
title: 'Test Product',
price: {amount: '29.99', currencyCode: 'USD'},
})
render()
expect(screen.getByText('Test Product')).toBeInTheDocument()
expect(screen.getByText('$29.99')).toBeInTheDocument()
})
it('renders product image when available', () => {
const product = mockProduct({
featuredImage: {
url: 'https://example.com/image.jpg',
altText: 'Product Image',
width: 100,
height: 100,
sensitive: false,
thumbhash: null,
},
})
render()
const image = screen.getByRole('img')
expect(image).toHaveAttribute('src', 'https://example.com/image.jpg')
expect(image).toHaveAttribute('alt', 'Product Image')
})
it('shows no image placeholder when image is not available', () => {
const product = mockProduct({
featuredImage: undefined,
})
render()
expect(screen.getByText('No Image')).toBeInTheDocument()
})
it('uses product title as alt text when image altText is missing', () => {
const product = mockProduct({
title: 'My Product',
featuredImage: {
url: 'https://example.com/image.jpg',
altText: '',
width: 100,
height: 100,
sensitive: false,
thumbhash: null,
},
})
render()
const image = screen.getByRole('img')
expect(image).toHaveAttribute('alt', 'My Product')
})
it('displays discount pricing when compareAtPrice exists', () => {
const product = mockProduct({
price: {amount: '19.99', currencyCode: 'USD'},
compareAtPrice: {amount: '29.99', currencyCode: 'USD'},
})
render()
// Discounted price should be shown
expect(screen.getByText('$19.99')).toBeInTheDocument()
// Original price with line-through
const originalPrice = screen.getByText('$29.99')
expect(originalPrice).toBeInTheDocument()
expect(originalPrice).toHaveClass('line-through')
})
it('shows regular price when no discount', () => {
const product = mockProduct({
price: {amount: '29.99', currencyCode: 'USD'},
compareAtPrice: undefined,
})
render()
expect(screen.getByText('$29.99')).toBeInTheDocument()
expect(screen.queryByText(/line-through/)).not.toBeInTheDocument()
})
it('displays review rating and count when available', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: 4.5,
reviewCount: 123,
},
})
render()
// Should show review count
expect(screen.getByText('(123)')).toBeInTheDocument()
// Should show star rating
const stars = document.querySelectorAll('svg')
expect(stars.length).toBeGreaterThan(0)
})
it('does not show rating when review data is missing', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: null,
reviewCount: 0,
},
})
render()
expect(
screen.queryByTestId('product-link-rating')
).not.toBeInTheDocument()
})
it('shows favorite button by default', () => {
const product = mockProduct()
render()
// Should have favorite button
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('hides favorite button when hideFavoriteAction is true', () => {
const product = mockProduct()
render()
// Should not have favorite button
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('renders custom action when provided', () => {
const product = mockProduct()
const onCustomActionClick = vi.fn()
render(
Add to Cart
}
onCustomActionClick={onCustomActionClick}
/>
)
// Custom action should be rendered
expect(screen.getByTestId('custom-cta')).toBeInTheDocument()
expect(screen.getByText('Add to Cart')).toBeInTheDocument()
// Favorite button should not be rendered when custom action is present
// Check that there's only one button (the custom one)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(1)
expect(buttons[0]).toHaveAttribute('data-testid', 'custom-cta')
})
it('renders custom action even when hideFavoriteAction is true', () => {
const product = mockProduct()
const onCustomActionClick = vi.fn()
render(
Buy Now
}
onCustomActionClick={onCustomActionClick}
/>
)
// Custom action should still be rendered
expect(screen.getByTestId('custom-action')).toBeInTheDocument()
expect(screen.getByText('Buy Now')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('navigates to product on click', async () => {
const user = userEvent.setup()
const product = mockProduct()
const onClick = vi.fn()
render()
const productElement = screen.getByText(product.title).closest('div')
?.parentElement?.parentElement
await user.click(productElement!)
expect(mockMinisSDK.navigateToProduct).toHaveBeenCalledWith({
productId: product.id,
})
expect(onClick).toHaveBeenCalledWith(product)
})
it('saves product when favorite button is clicked (unfavorited to favorited)', async () => {
const user = userEvent.setup()
const product = mockProduct({isFavorited: false})
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,
})
})
})
it('unsaves product when favorite button is clicked (favorited to unfavorited)', async () => {
const user = userEvent.setup()
const product = mockProduct({isFavorited: true})
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,
})
})
})
it('uses selected variant ID for favorite actions when available', async () => {
const user = userEvent.setup()
const product = mockProduct({
isFavorited: false,
selectedVariant: {
id: 'selected-variant-id',
title: 'Selected Variant',
isFavorited: false,
availableForSale: true,
price: {amount: '29.99', currencyCode: 'USD'},
compareAtPrice: null,
image: null,
},
})
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: 'selected-variant-id',
})
})
})
it('reverts favorite state on error', async () => {
const user = userEvent.setup()
const product = mockProduct({isFavorited: false})
// 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 and revert
await waitFor(() => {
// Should revert to unfilled state
expect(favoriteButton).toHaveClass('bg-button-overlay/30')
})
})
it('prevents event propagation on favorite button click', async () => {
const user = userEvent.setup()
const product = mockProduct()
const onClick = vi.fn()
mockMinisSDK.saveProduct.mockResolvedValue(undefined)
render()
const favoriteButton = screen.getByRole('button')
await user.click(favoriteButton)
// Should save product but not trigger navigation
expect(mockMinisSDK.saveProduct).toHaveBeenCalled()
expect(onClick).not.toHaveBeenCalled()
expect(mockMinisSDK.navigateToProduct).not.toHaveBeenCalled()
})
it('calls onCustomActionClick when custom action is clicked', async () => {
const user = userEvent.setup()
const product = mockProduct()
const onClick = vi.fn()
const onCustomActionClick = vi.fn()
render(
Quick Add
}
onCustomActionClick={onCustomActionClick}
/>
)
const customButton = screen.getByTestId('custom-btn')
await user.click(customButton)
// Should call custom action handler
expect(onCustomActionClick).toHaveBeenCalledTimes(1)
// Should NOT call favorite save/unsave
expect(mockMinisSDK.saveProduct).not.toHaveBeenCalled()
expect(mockMinisSDK.unsaveProduct).not.toHaveBeenCalled()
// Should NOT trigger product navigation
expect(onClick).not.toHaveBeenCalled()
expect(mockMinisSDK.navigateToProduct).not.toHaveBeenCalled()
})
})
describe('Star Rating Display', () => {
it('displays correct number of filled stars based on rating', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: 3.7,
reviewCount: 50,
},
})
render()
// Check that rating is displayed
expect(screen.getByText('(50)')).toBeInTheDocument()
// Check for star icons (there's at least one star svg)
const stars = document.querySelectorAll('svg')
expect(stars.length).toBeGreaterThan(0)
})
it('shows all empty stars for zero rating', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: 0,
reviewCount: 10,
},
})
render()
const stars = document.querySelectorAll('svg')
const filledStars = Array.from(stars).filter(
star => star.getAttribute('fill') === 'currentColor'
)
expect(filledStars).toHaveLength(0)
})
it('shows all filled stars for 5 star rating', () => {
const product = mockProduct({
reviewAnalytics: {
averageRating: 5,
reviewCount: 100,
},
})
render()
// ProductReviewStars uses overlay technique with specific fill colors
// Find the star container first to only count star SVGs
const starContainer = document.querySelector(
'[data-slot="product-review-stars"]'
)
expect(starContainer).toBeInTheDocument()
const stars = starContainer?.querySelectorAll('svg') || []
// For 5 star rating: 5 empty stars + 5 filled stars = 10 total
expect(stars).toHaveLength(10)
// Check that stars are rendered (5 filled stars using var(--grayscale-d100) style)
const filledStars = Array.from(stars).filter(star => {
const style = star.getAttribute('style')
return style?.includes('var(--grayscale-d100)')
})
expect(filledStars).toHaveLength(5)
})
})
describe('Edge Cases', () => {
it('handles product without price', () => {
const product = mockProduct({
price: undefined,
})
render()
// Should still render without crashing
expect(screen.getByText(product.title)).toBeInTheDocument()
})
it('handles very long product titles', () => {
const product = mockProduct({
title:
'This is a very long product title that should be truncated in the UI to prevent layout issues',
})
render()
// Title should be rendered and have truncate classes
const titleElement = screen.getByText(product.title)
expect(titleElement).toBeInTheDocument()
expect(titleElement.className).toContain('text-ellipsis')
})
it('handles rapid favorite toggling', async () => {
const user = userEvent.setup()
const product = mockProduct({isFavorited: false})
mockMinisSDK.saveProduct.mockResolvedValue(undefined)
mockMinisSDK.unsaveProduct.mockResolvedValue(undefined)
render()
const favoriteButton = screen.getByRole('button')
// Rapid clicks
await user.click(favoriteButton)
await user.click(favoriteButton)
await user.click(favoriteButton)
// Should handle all clicks gracefully
expect(mockMinisSDK.saveProduct.mock.calls.length).toBeGreaterThan(0)
})
it('handles product without shop data', () => {
const product = mockProduct({
shop: {id: '', name: ''},
})
render()
// Should still render
expect(screen.getByText(product.title)).toBeInTheDocument()
})
})
describe('Visual States', () => {
it('applies hover/tap animation styles', () => {
const product = mockProduct()
const {container} = render()
// Check that touchable wrapper exists
const touchable = container.querySelector('[data-touchable="true"]')
expect(touchable).toBeTruthy()
})
it('shows correct favorite button state', async () => {
const product = mockProduct({isFavorited: true})
render()
const favoriteButton = screen.getByRole('button')
expect(favoriteButton).toHaveClass('bg-primary')
})
it('applies correct layout styles', () => {
const product = mockProduct()
render()
const image = screen.getByRole('img')
expect(image.parentElement).toHaveClass('h-16', 'w-16')
})
})
describe('Accessibility', () => {
it('has proper heading hierarchy', () => {
const product = mockProduct()
render()
const heading = screen.getByRole('heading', {level: 3})
expect(heading).toHaveTextContent(product.title)
})
it('provides proper alt text for images', () => {
const product = mockProduct({
featuredImage: {
url: 'https://example.com/image.jpg',
altText: 'Descriptive alt text',
width: 100,
height: 100,
sensitive: false,
thumbhash: null,
},
})
render()
const image = screen.getByRole('img')
expect(image).toHaveAttribute('alt', 'Descriptive alt text')
})
})
})