// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest'; import { QuotaExhaustedError, VirtualTryOnAPI, VisitorRateLimitedError } from './api'; function response( status: number, body: unknown, ok = status >= 200 && status < 300 ) { return { status, ok, json: vi.fn().mockResolvedValue( body ) } as unknown as Response; } describe( 'VirtualTryOnAPI', () => { afterEach( () => vi.unstubAllGlobals() ); it( 'sends the multipart request with normalized endpoint and optional routing fields', async () => { const fetchMock = vi.fn().mockResolvedValue( response( 200, { image: 'done', cached: false } ) ); vi.stubGlobal( 'fetch', fetchMock ); const signal = new AbortController().signal; const api = new VirtualTryOnAPI( 'https://brain.test/tryon/', 'pk_test', 'visitor-1' ); await expect( api.tryOnSync( 'data:image/png;base64,WA==', 'WA==', 'swimwear', '42', signal ) ) .resolves.toEqual( { image: 'done', cached: false } ); const [ endpoint, request ] = fetchMock.mock.calls[ 0 ]; expect( endpoint ).toBe( 'https://brain.test/tryon' ); expect( request.headers ).toEqual( { 'X-Aisthetix-Key': 'pk_test', 'X-Aisthetix-Visitor': 'visitor-1', } ); expect( request.signal ).toBe( signal ); expect( request.body.get( 'product_category' ) ).toBe( 'swimwear' ); expect( request.body.get( 'product_id' ) ).toBe( '42' ); expect( request.body.get( 'user_image' ) ).toBeInstanceOf( Blob ); } ); it( 'omits empty optional routing fields', async () => { const fetchMock = vi.fn().mockResolvedValue( response( 200, { image: 'done' } ) ); vi.stubGlobal( 'fetch', fetchMock ); await new VirtualTryOnAPI( '/tryon', 'key', 'visitor' ).tryOnSync( 'WA==', 'WA==' ); const form = fetchMock.mock.calls[ 0 ][ 1 ].body as FormData; expect( form.has( 'product_category' ) ).toBe( false ); expect( form.has( 'product_id' ) ).toBe( false ); } ); it( 'maps quota and visitor limits to stable typed errors', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce( response( 402, {} ) ) .mockResolvedValueOnce( response( 429, { cooldownMinutesRemaining: 17 } ) ) .mockResolvedValueOnce( response( 429, { cooldownMinutesRemaining: 'bad' } ) ); vi.stubGlobal( 'fetch', fetchMock ); const api = new VirtualTryOnAPI( '/tryon', 'key', 'visitor' ); await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toBeInstanceOf( QuotaExhaustedError ); await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toMatchObject( { name: 'VisitorRateLimitedError', cooldownMinutesRemaining: 17, } satisfies Partial ); await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toMatchObject( { cooldownMinutesRemaining: undefined, } ); } ); it( 'surfaces an API detail and falls back when an error body is unreadable', async () => { const unreadable = { status: 503, ok: false, json: vi.fn().mockRejectedValue( new Error( 'invalid json' ) ) }; const fetchMock = vi.fn() .mockResolvedValueOnce( response( 400, { detail: 'Bad photo' }, false ) ) .mockResolvedValueOnce( unreadable ); vi.stubGlobal( 'fetch', fetchMock ); const api = new VirtualTryOnAPI( '/tryon', 'key', 'visitor' ); await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toThrow( 'Bad photo' ); await expect( api.tryOnSync( 'WA==', 'WA==' ) ).rejects.toThrow( 'Unknown error' ); } ); } );