// @vitest-environment jsdom import type { ReactNode } from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act, render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; import { App } from './App'; import { setConsent } from './storage'; import type { WidgetConfig } from './types'; // Shared spies must exist before the hoisted vi.mock factories run. const { tryOnSync, fetchImageAsBase64, trackTryOnCompleted, compressGarmentImage } = vi.hoisted( () => ( { tryOnSync: vi.fn(), fetchImageAsBase64: vi.fn(), trackTryOnCompleted: vi.fn(), compressGarmentImage: vi.fn(), } ) ); // new VirtualTryOnAPI(...) returns the object below, so the App's `api.tryOnSync` // is our spy. storage is intentionally NOT mocked — hasConsent() reads the real // jsdom localStorage, which is what the gate depends on. vi.mock( './api', () => ( { VirtualTryOnAPI: vi.fn( () => ( { tryOnSync } ) ), QuotaExhaustedError: class QuotaExhaustedError extends Error {}, VisitorRateLimitedError: class VisitorRateLimitedError extends Error {}, } ) ); vi.mock( './imageUtils', () => ( { fetchImageAsBase64, compressGarmentImage, // Real class so `instanceof` works across the mock boundary. UndecodableImageError: class UndecodableImageError extends Error {}, } ) ); vi.mock( './events', () => ( { trackTryOnCompleted } ) ); // Render sub-views as markers so the test isolates App's state machine + gate. vi.mock( './components/Modal', () => ( { Modal: ( { isOpen, children }: { isOpen: boolean; children: ReactNode } ) => isOpen ?
{ children }
: null, } ) ); vi.mock( './components/AvatarUpload', () => ( { AvatarUpload: () =>
} ) ); vi.mock( './components/ProcessingView', () => ( { ProcessingView: () =>
} ) ); vi.mock( './components/ResultView', () => ( { ResultView: () =>
} ) ); vi.mock( './components/ErrorView', () => ( { ErrorView: ( { message }: { message?: string } ) =>
{ message }
, } ) ); vi.mock( './components/QuotaBlockedView', () => ( { QuotaBlockedView: () =>
} ) ); const CONFIG: WidgetConfig = { apiBaseUrl: 'https://brain.test/api/storefront/tryon', publishableKey: 'pk_test', productImage: 'https://shop.test/garment.png', productTitle: 'Test Garment', productId: 'prod_1', productCategories: [], visitorKey: 'test-visitor', locale: 'en', }; const AVATAR = { imageData: 'data:image/png;base64,AVATAR', createdAt: '2026-01-01T00:00:00Z' }; // A stored avatar from a prior visit — the returning-visitor path that skips // AvatarUpload and lands straight in startTryOnWithAvatar (the funnel under test). function seedAvatar(): void { localStorage.setItem( 'aisthetix-avatar', JSON.stringify( AVATAR ) ); } function mountWithTrigger(): HTMLButtonElement { const trigger = document.createElement( 'button' ); trigger.id = 'aisthetix-tryon-trigger'; document.body.appendChild( trigger ); render( ); return trigger; } beforeEach( () => { localStorage.clear(); tryOnSync.mockReset().mockResolvedValue( { image: 'RESULT_BASE64', cached: false } ); fetchImageAsBase64.mockReset().mockResolvedValue( 'data:image/png;base64,GARMENT' ); trackTryOnCompleted.mockReset(); compressGarmentImage.mockReset().mockImplementation( async ( x: string ) => x ); } ); afterEach( () => { cleanup(); document.body.innerHTML = ''; } ); describe( 'consent gate before AI try-on generation (startTryOnWithAvatar funnel)', () => { it( 'returning visitor WITHOUT consent: trigger routes to consent screen and never generates', async () => { seedAvatar(); // avatar present, but consent NOT set -> hasConsent() === false const trigger = mountWithTrigger(); await act( async () => { fireEvent.click( trigger ); } ); await waitFor( () => expect( screen.getByTestId( 'avatar-upload' ) ).toBeTruthy() ); expect( tryOnSync ).not.toHaveBeenCalled(); expect( fetchImageAsBase64 ).not.toHaveBeenCalled(); } ); it( 'WITH consent: the same trigger path proceeds to generation and calls tryOnSync', async () => { seedAvatar(); setConsent(); // hasConsent() === true const trigger = mountWithTrigger(); await act( async () => { fireEvent.click( trigger ); } ); await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) ); expect( tryOnSync ).toHaveBeenCalledWith( AVATAR.imageData, 'data:image/png;base64,GARMENT', '' ); } ); it( 'compresses the garment before sending it to try-on', async () => { seedAvatar(); setConsent(); compressGarmentImage.mockResolvedValue( 'data:image/jpeg;base64,COMPRESSED_GARMENT' ); const trigger = mountWithTrigger(); await act( async () => { fireEvent.click( trigger ); } ); await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) ); expect( compressGarmentImage ).toHaveBeenCalledWith( 'data:image/png;base64,GARMENT' ); expect( tryOnSync ).toHaveBeenCalledWith( AVATAR.imageData, 'data:image/jpeg;base64,COMPRESSED_GARMENT', '' ); } ); it( 'shows the clothing-image error copy when the garment fails to decode', async () => { seedAvatar(); setConsent(); const { UndecodableImageError } = await import( './imageUtils' ); compressGarmentImage.mockRejectedValue( new ( UndecodableImageError as any )() ); const trigger = mountWithTrigger(); await act( async () => { fireEvent.click( trigger ); } ); await waitFor( () => expect( screen.getByTestId( 'error' ).textContent ).toBe( 'Could not load the clothing image. Please try again.' ) ); expect( tryOnSync ).not.toHaveBeenCalled(); } ); } );