import {act, renderHook, waitFor} from '@testing-library/react' import {beforeEach, describe, expect, it, vi} from 'vitest' import {useShopActions} from '../../internal/useShopActions' import {fileToDataUri} from '../../utils' import {useSimilarProducts} from './useSimilarProducts' vi.mock('../../internal/useShopActions', () => ({ useShopActions: vi.fn(), })) vi.mock('../../utils', () => ({ fileToDataUri: vi.fn(), })) const mockGetSimilarProducts = vi.fn() const setupShopActions = () => { ;(useShopActions as ReturnType).mockReturnValue({ getSimilarProducts: mockGetSimilarProducts, }) } const makeFile = ( type = 'image/jpeg', size = 100, name = 'photo.jpg' ): File => { const blob = new Blob([new Uint8Array(size)], {type}) return new File([blob], name, {type}) } describe('useSimilarProducts', () => { beforeEach(() => { vi.clearAllMocks() setupShopActions() ;(fileToDataUri as ReturnType).mockResolvedValue( 'data:image/jpeg;base64,AAAA' ) }) it('rejects calls with no source provided', async () => { const {result} = renderHook(() => useSimilarProducts()) await expect(result.current.findSimilarProducts({})).rejects.toThrow( /exactly one source/ ) expect(mockGetSimilarProducts).not.toHaveBeenCalled() }) it.each([ {productId: ''}, {productVariantId: ''}, {productId: '', productVariantId: ''}, ])('rejects calls where all sources are empty strings (%o)', async params => { const {result} = renderHook(() => useSimilarProducts()) await expect(result.current.findSimilarProducts(params)).rejects.toThrow( /exactly one source/ ) expect(mockGetSimilarProducts).not.toHaveBeenCalled() }) it('rejects calls with more than one source provided', async () => { const {result} = renderHook(() => useSimilarProducts()) await expect( result.current.findSimilarProducts({ productId: 'gid://shopify/Product/1', productVariantId: 'gid://shopify/ProductVariant/2', }) ).rejects.toThrow(/exactly one source/) expect(mockGetSimilarProducts).not.toHaveBeenCalled() }) it('rejects non-JPEG images with a clear message', async () => { const {result} = renderHook(() => useSimilarProducts()) const pngFile = makeFile('image/png') await act(async () => { await expect( result.current.findSimilarProducts({image: pngFile}) ).rejects.toThrow(/Unsupported image type.*image\/png/) }) expect(mockGetSimilarProducts).not.toHaveBeenCalled() }) it('rejects images whose encoded payload exceeds 4 MiB', async () => { const {result} = renderHook(() => useSimilarProducts()) const file = makeFile('image/jpeg') const oversizedBase64 = 'a'.repeat(4 * 1024 * 1024 + 1) ;(fileToDataUri as ReturnType).mockResolvedValue( `data:image/jpeg;base64,${oversizedBase64}` ) await act(async () => { await expect( result.current.findSimilarProducts({image: file}) ).rejects.toThrow(/too large/) }) expect(mockGetSimilarProducts).not.toHaveBeenCalled() }) it.each([0, -1, 11, 1.5])( 'rejects invalid `first` value %s', async (invalidFirst: number) => { const {result} = renderHook(() => useSimilarProducts()) await expect( result.current.findSimilarProducts({ productId: 'gid://shopify/Product/1', first: invalidFirst, }) ).rejects.toThrow(/must be an integer between 1 and 10/) } ) it('passes productId through to native action', async () => { mockGetSimilarProducts.mockResolvedValue({ ok: true, data: { data: [], pageInfo: {hasNextPage: false, endCursor: null}, }, }) const {result} = renderHook(() => useSimilarProducts()) await act(async () => { await result.current.findSimilarProducts({ productId: 'gid://shopify/Product/123', }) }) expect(mockGetSimilarProducts).toHaveBeenCalledWith({ similarTo: {productId: 'gid://shopify/Product/123'}, first: 10, }) }) it('passes productVariantId through to native action', async () => { mockGetSimilarProducts.mockResolvedValue({ ok: true, data: { data: [], pageInfo: {hasNextPage: false, endCursor: null}, }, }) const {result} = renderHook(() => useSimilarProducts()) await act(async () => { await result.current.findSimilarProducts({ productVariantId: 'gid://shopify/ProductVariant/456', first: 3, }) }) expect(mockGetSimilarProducts).toHaveBeenCalledWith({ similarTo: {productVariantId: 'gid://shopify/ProductVariant/456'}, first: 3, }) }) it('encodes a JPEG file to base64 and passes it as media', async () => { mockGetSimilarProducts.mockResolvedValue({ ok: true, data: { data: [], pageInfo: {hasNextPage: false, endCursor: null}, }, }) const file = makeFile('image/jpeg') ;(fileToDataUri as ReturnType).mockResolvedValue( 'data:image/jpeg;base64,SGVsbG8=' ) const {result} = renderHook(() => useSimilarProducts()) await act(async () => { await result.current.findSimilarProducts({image: file}) }) expect(mockGetSimilarProducts).toHaveBeenCalledWith({ similarTo: { media: {contentType: 'image/jpeg', base64: 'SGVsbG8='}, }, first: 10, }) }) it('exposes products, loading, and error state', async () => { const products = [ {id: 'gid://shopify/Product/1', title: 'A'} as any, {id: 'gid://shopify/Product/2', title: 'B'} as any, ] mockGetSimilarProducts.mockResolvedValue({ ok: true, data: { data: products, pageInfo: {hasNextPage: false, endCursor: null}, }, }) const {result} = renderHook(() => useSimilarProducts()) expect(result.current.products).toBeNull() expect(result.current.loading).toBe(false) expect(result.current.error).toBeNull() let returned: any await act(async () => { returned = await result.current.findSimilarProducts({ productId: 'gid://shopify/Product/1', }) }) expect(returned).toEqual(products) await waitFor(() => { expect(result.current.products).toEqual(products) expect(result.current.loading).toBe(false) }) }) it('records validation errors in the `error` state', async () => { const {result} = renderHook(() => useSimilarProducts()) // First invalid call: error state should populate from the validation error. await act(async () => { await expect(result.current.findSimilarProducts({})).rejects.toThrow( /exactly one source/ ) }) expect(result.current.error).toBeInstanceOf(Error) expect(result.current.error?.message).toMatch(/exactly one source/) expect(result.current.loading).toBe(false) // Second invalid call with a different message: error state should // update to reflect the new validation failure (no stale messages). await act(async () => { await expect( result.current.findSimilarProducts({ productId: 'gid://shopify/Product/1', first: 0, }) ).rejects.toThrow(/must be an integer between 1 and 10/) }) expect(result.current.error?.message).toMatch( /must be an integer between 1 and 10/ ) }) it('records and rethrows native errors', async () => { const failure = new Error('boom') mockGetSimilarProducts.mockResolvedValue({ok: false, error: failure}) const {result} = renderHook(() => useSimilarProducts()) await expect( result.current.findSimilarProducts({ productId: 'gid://shopify/Product/1', }) ).rejects.toBe(failure) await waitFor(() => { expect(result.current.error).toBe(failure) expect(result.current.loading).toBe(false) }) }) })