// @vitest-environment jsdom /** * The two consent gates in assets/bootstrap.js, driven end to end. * * The distinction they encode is the whole point of this file. ATTRIBUTION * events feed the merchant's own funnel on the merchant's own store, so the * merchant's opt-in is a legitimate basis for them. PRODUCT ANALYTICS goes to a * third party for OUR benefit, so only the shopper's CMP can open that gate — * a merchant toggle cannot stand in for a shopper's consent to that. * * The gates differ in their DEFAULT, which is where a refactor would quietly * merge them: with no CMP present, attribution follows the merchant setting and * product analytics is denied. Every case below pins one of those. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const BOOTSTRAP_SRC = readFileSync( resolve( __dirname, '../../assets/bootstrap.js' ), 'utf8' ); function loadBootstrap( config: Record< string, unknown > ) { ( window as any ).AISTHETIX_CONFIG = config; // eslint-disable-next-line no-new-func new Function( BOOTSTRAP_SRC )(); } const BASE = { eventsUrl: 'https://woo.aisthetix.fyi/api/events', publishableKey: 'pk_test', visitorKey: 'vid-1', productId: '42', }; /** Every POST the bootstrap made, parsed. */ function sentPayloads( fetchMock: ReturnType< typeof vi.fn > ): Array< Record< string, unknown > > { return fetchMock.mock.calls.map( ( call ) => JSON.parse( ( call[ 1 ] as { body: string } ).body ) ); } let fetchMock: ReturnType< typeof vi.fn >; beforeEach( () => { fetchMock = vi.fn( () => Promise.resolve( { ok: true } ) ); ( window as any ).fetch = fetchMock; delete ( window as any ).Cookiebot; delete ( window as any ).gtag; delete ( window as any ).google_tag_data; delete ( window as any ).dataLayer; document.cookie = 'cmplz_statistics=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; vi.useFakeTimers(); } ); afterEach( () => { vi.useRealTimers(); delete ( window as any ).aisthetixTrackEvent; delete ( window as any ).aisthetixTrackProductEvent; delete ( window as any ).aisthetixProductAnalyticsAllowed; delete ( window as any ).AISTHETIX_CONFIG; vi.restoreAllMocks(); } ); describe( 'the product-analytics gate', () => { it( 'sends nothing without a CMP, even when the merchant enabled analytics', () => { // THE case that separates the two gates. Attribution would send here. loadBootstrap( { ...BASE, analyticsEnabled: true } ); fetchMock.mockClear(); const sent = ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: false, } ); expect( sent ).toBe( false ); expect( fetchMock ).not.toHaveBeenCalled(); } ); it( 'sends when a CMP grants analytics, even with the merchant setting off', () => { ( window as any ).Cookiebot = { consent: { statistics: true } }; loadBootstrap( { ...BASE, analyticsEnabled: false } ); fetchMock.mockClear(); const sent = ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: false, } ); expect( sent ).toBe( true ); expect( fetchMock ).toHaveBeenCalledTimes( 1 ); } ); it( 'sends nothing when a CMP denies, whatever the merchant set', () => { ( window as any ).Cookiebot = { consent: { statistics: false } }; loadBootstrap( { ...BASE, analyticsEnabled: true } ); fetchMock.mockClear(); expect( ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: false, } ) ).toBe( false ); expect( fetchMock ).not.toHaveBeenCalled(); } ); it( 'reports the gate to the widget, so the recorder is never even fetched', () => { loadBootstrap( { ...BASE, analyticsEnabled: true } ); expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false ); ( window as any ).Cookiebot = { consent: { statistics: true } }; expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true ); } ); it( 're-reads the CMP on every call, so a late grant takes effect', () => { loadBootstrap( { ...BASE, analyticsEnabled: false } ); fetchMock.mockClear(); expect( ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: false } ) ).toBe( false ); // The shopper accepts the banner while the page is open. ( window as any ).Cookiebot = { consent: { statistics: true } }; expect( ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: false } ) ).toBe( true ); } ); it( 'stops immediately when consent is withdrawn mid-session', () => { ( window as any ).Cookiebot = { consent: { statistics: true } }; loadBootstrap( { ...BASE, analyticsEnabled: false } ); fetchMock.mockClear(); ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: false } ); expect( fetchMock ).toHaveBeenCalledTimes( 1 ); ( window as any ).Cookiebot = { consent: { statistics: false } }; ( window as any ).aisthetixTrackProductEvent( 'tryon_requested', { productId: '42', requestId: 'r1' } ); expect( fetchMock, 'no event after withdrawal' ).toHaveBeenCalledTimes( 1 ); } ); } ); describe( 'the attribution gate, unchanged', () => { it( 'still follows the merchant setting when no CMP is present', () => { loadBootstrap( { ...BASE, analyticsEnabled: true } ); fetchMock.mockClear(); ( window as any ).aisthetixTrackEvent( 'product_viewed', { productId: '42' } ); expect( fetchMock ).toHaveBeenCalledTimes( 1 ); } ); it( 'still sends nothing when the setting is off and no CMP is present', () => { loadBootstrap( { ...BASE, analyticsEnabled: false } ); fetchMock.mockClear(); ( window as any ).aisthetixTrackEvent( 'product_viewed', { productId: '42' } ); expect( fetchMock ).not.toHaveBeenCalled(); } ); } ); describe( 'the product-analytics payload', () => { beforeEach( () => { ( window as any ).Cookiebot = { consent: { statistics: true } }; loadBootstrap( { ...BASE, analyticsEnabled: false, pluginVersion: '0.6.0', locale: 'en_GB' } ); fetchMock.mockClear(); } ); it( 'travels under the generic envelope, naming its own contract event', () => { ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: true, } ); const [ payload ] = sentPayloads( fetchMock ); expect( payload.eventName ).toBe( 'aisthetix_product_analytics' ); expect( payload.contractEvent ).toBe( 'tryon_modal_opened' ); } ); it( 'states the consent it saw, so the brain can enforce the same rule', () => { ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: true, } ); expect( sentPayloads( fetchMock )[ 0 ].analyticsConsent ).toBe( 'granted' ); } ); it( 'stamps the schema and plugin version on every event', () => { ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: true, } ); const props = sentPayloads( fetchMock )[ 0 ].props as Record< string, unknown >; expect( props.schemaVersion ).toBe( '1.0.0' ); expect( props.appVersion ).toBe( '0.6.0' ); expect( props.locale ).toBe( 'en_GB' ); } ); it( 'drops anything the contract does not declare for the event', () => { ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: true, shopperNotes: 'anything at all', } ); expect( sentPayloads( fetchMock )[ 0 ].props ).not.toHaveProperty( 'shopperNotes' ); } ); it( 'never carries a photo, a measurement or an email, whatever the caller passes', () => { ( window as any ).aisthetixTrackProductEvent( 'tryon_completed', { productId: '42', requestId: 'r1', durationMs: 100, provider: 'gemini', cached: false, resultImage: 'data:image/png;base64,SECRET', heightCm: 178, weightKg: 72, email: 'shopper@example.com', } ); const serialised = JSON.stringify( sentPayloads( fetchMock )[ 0 ] ); expect( serialised ).not.toContain( 'SECRET' ); expect( serialised ).not.toContain( '178' ); expect( serialised ).not.toContain( 'shopper@example.com' ); } ); it( 'drops a non-scalar value, which is what a blob or a photo would be', () => { ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: { toString: () => 'sneaky' }, hasSavedPhoto: true, } ); expect( sentPayloads( fetchMock )[ 0 ].props ).not.toHaveProperty( 'productId' ); } ); it( 'refuses an event the contract does not declare', () => { expect( ( window as any ).aisthetixTrackProductEvent( 'invented_event', { productId: '42' } ) ).toBe( false ); expect( fetchMock ).not.toHaveBeenCalled(); } ); it( 'never sends a raw URL or referrer', () => { ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: true, url: 'https://shop.example/p?discount=SECRET', referrer: 'https://google.com/search?q=private', } ); const serialised = JSON.stringify( sentPayloads( fetchMock )[ 0 ] ); expect( serialised ).not.toContain( 'SECRET' ); expect( serialised ).not.toContain( 'q=private' ); } ); it( 'never breaks the storefront when fetch throws', () => { fetchMock.mockImplementation( () => { throw new Error( 'network stack exploded' ); } ); expect( () => ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', hasSavedPhoto: true } ) ).not.toThrow(); } ); } ); describe( 'consent changes announced to the widget', () => { it( 'announces a decision change so a running replay can be stopped', () => { loadBootstrap( { ...BASE, analyticsEnabled: false } ); const seen: boolean[] = []; document.addEventListener( 'aisthetix:consent-changed', ( e ) => { seen.push( ( e as CustomEvent ).detail.productAnalytics ); } ); // The bootstrap binds on DOMContentLoaded; jsdom is already loaded, so // init() ran synchronously and the poll is armed. ( window as any ).Cookiebot = { consent: { statistics: true } }; vi.advanceTimersByTime( 1000 ); expect( seen ).toEqual( [ true ] ); ( window as any ).Cookiebot = { consent: { statistics: false } }; vi.advanceTimersByTime( 1000 ); expect( seen ).toEqual( [ true, false ] ); } ); it( 'stops polling rather than leaving a timer running all session', () => { loadBootstrap( { ...BASE, analyticsEnabled: false } ); const seen: boolean[] = []; document.addEventListener( 'aisthetix:consent-changed', ( e ) => { seen.push( ( e as CustomEvent ).detail.productAnalytics ); } ); vi.advanceTimersByTime( 31_000 ); ( window as any ).Cookiebot = { consent: { statistics: true } }; vi.advanceTimersByTime( 10_000 ); // A banner decision lands within seconds of the click; a timer that ran // for the whole session on every product page would be the wrong trade. expect( seen ).toEqual( [] ); } ); } ); describe( 'the CMP signal, across every platform the loader claims to read', () => { // Only the Cookiebot branch was exercised before. The other three are the // ones that fail quietly: if Complianz parsing is wrong, product analytics // simply never works on a Complianz store and nobody is told — the gate // reads "no signal", and no signal is already a refusal. it( 'reads a Complianz grant from its cookie', () => { document.cookie = 'cmplz_statistics=allow'; loadBootstrap( { ...BASE, analyticsEnabled: false } ); expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true ); } ); it( 'reads a Complianz refusal from its cookie', () => { document.cookie = 'cmplz_statistics=deny'; loadBootstrap( { ...BASE, analyticsEnabled: true } ); expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false ); } ); it( 'reads a Complianz cookie sitting among others', () => { document.cookie = 'some_other=1'; document.cookie = 'cmplz_statistics=allow'; document.cookie = 'trailing=2'; loadBootstrap( { ...BASE, analyticsEnabled: false } ); expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true ); } ); it( 'reads a Google Consent Mode update', () => { ( window as any ).gtag = () => {}; ( window as any ).google_tag_data = { ics: { entries: { analytics_storage: { default: 'denied', update: 'granted' } } }, }; loadBootstrap( { ...BASE, analyticsEnabled: false } ); // The update supersedes the default — that is the whole point of the // two fields, and reading the default would freeze a shopper on whatever // the site declared before they answered. expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true ); } ); it( 'falls back to the Consent Mode default when there is no update yet', () => { ( window as any ).gtag = () => {}; ( window as any ).google_tag_data = { ics: { entries: { analytics_storage: { default: 'denied' } } }, }; loadBootstrap( { ...BASE, analyticsEnabled: true } ); expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false ); } ); it( 'scans the dataLayer when Consent Mode has not populated its store', () => { ( window as any ).dataLayer = [ [ 'js', new Date() ], [ 'consent', 'default', { analytics_storage: 'denied' } ], [ 'consent', 'update', { analytics_storage: 'granted' } ], ]; loadBootstrap( { ...BASE, analyticsEnabled: false } ); // Scanned backwards, so the most recent decision wins. expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( true ); } ); it( 'takes the most recent dataLayer decision, not the first', () => { ( window as any ).dataLayer = [ [ 'consent', 'default', { analytics_storage: 'granted' } ], [ 'consent', 'update', { analytics_storage: 'denied' } ], ]; loadBootstrap( { ...BASE, analyticsEnabled: true } ); expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false ); } ); it( 'ignores unrelated dataLayer rows', () => { ( window as any ).dataLayer = [ [ 'event', 'page_view' ], [ 'config', 'G-XXXX' ], ]; loadBootstrap( { ...BASE, analyticsEnabled: true } ); // No recognised signal at all — which is a refusal for product analytics // however the merchant's own setting is configured. expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false ); } ); it( 'lets Cookiebot win over a conflicting Complianz cookie', () => { ( window as any ).Cookiebot = { consent: { statistics: false } }; document.cookie = 'cmplz_statistics=allow'; loadBootstrap( { ...BASE, analyticsEnabled: true } ); // A site running two CMPs is misconfigured, but the refusal must win. expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false ); } ); it( 'never throws when a CMP global is a shape it did not expect', () => { ( window as any ).Cookiebot = 'not an object'; ( window as any ).google_tag_data = { ics: null }; ( window as any ).dataLayer = 'not an array'; loadBootstrap( { ...BASE, analyticsEnabled: true } ); expect( () => ( window as any ).aisthetixProductAnalyticsAllowed() ).not.toThrow(); expect( ( window as any ).aisthetixProductAnalyticsAllowed() ).toBe( false ); } ); } ); describe( 'the ephemeral analytics session id', () => { it( 'is sent so a logged-out shopper on an attribution-off store still correlates', () => { // The ordinary WooCommerce shopper: attribution off (the default) means no // visitorKey cookie, logged out means no customerId. Without an id of some // kind the brain has nothing to key the funnel on and discards every // consented event. ( window as any ).Cookiebot = { consent: { statistics: true } }; loadBootstrap( { ...BASE, visitorKey: null, customerId: null } ); ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', runId: 'r1', openIndex: 1, placement: 'button_block', } ); const [ payload ] = sentPayloads( fetchMock ).filter( ( p ) => p.eventName === 'aisthetix_product_analytics' ); expect( payload.analyticsSessionId ).toMatch( /^as1_[a-z0-9]{8,64}$/ ); } ); it( 'is the same for every event on the page, so one shopper is one funnel', () => { ( window as any ).Cookiebot = { consent: { statistics: true } }; loadBootstrap( { ...BASE, visitorKey: null, customerId: null } ); ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', runId: 'r1', openIndex: 1, placement: 'button_block', } ); ( window as any ).aisthetixTrackProductEvent( 'tryon_requested', { productId: '42', runId: 'r1', requestId: 'q1', attempt: 1, } ); const payloads = sentPayloads( fetchMock ).filter( ( p ) => p.eventName === 'aisthetix_product_analytics' ); expect( payloads ).toHaveLength( 2 ); expect( payloads[ 1 ].analyticsSessionId ).toBe( payloads[ 0 ].analyticsSessionId ); } ); it( 'is not written to the device', () => { // Memory only. A shopper who consented to product analytics agreed to one // funnel being joined up, not to being recognisable on their next visit. ( window as any ).Cookiebot = { consent: { statistics: true } }; const cookiesBefore = document.cookie; loadBootstrap( { ...BASE, visitorKey: null, customerId: null } ); ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', runId: 'r1', openIndex: 1, placement: 'button_block', } ); const [ payload ] = sentPayloads( fetchMock ).filter( ( p ) => p.eventName === 'aisthetix_product_analytics' ); const id = String( payload.analyticsSessionId ); expect( document.cookie ).toBe( cookiesBefore ); expect( localStorage.getItem( 'aisthetix_analytics_session' ) ).toBeNull(); expect( JSON.stringify( { ...localStorage } ) ).not.toContain( id ); expect( JSON.stringify( { ...sessionStorage } ) ).not.toContain( id ); } ); it( 'does not exist at all until a consented event is sent', () => { // No grant, no event, no identifier — not even in memory. loadBootstrap( { ...BASE, visitorKey: null, customerId: null } ); const sent = ( window as any ).aisthetixTrackProductEvent( 'tryon_modal_opened', { productId: '42', runId: 'r1', openIndex: 1, placement: 'button_block', } ); expect( sent ).toBe( false ); expect( sentPayloads( fetchMock ).filter( ( p ) => p.eventName === 'aisthetix_product_analytics' ) ).toHaveLength( 0 ); } ); it( 'does not appear on an attribution payload', () => { // Attribution is a different pipeline with a different consent basis. It // has its own durable key and must not gain a second identifier. ( window as any ).Cookiebot = { consent: { statistics: true } }; loadBootstrap( { ...BASE, analyticsEnabled: true } ); ( window as any ).aisthetixTrackEvent( 'product_viewed', { productId: '42' } ); const attribution = sentPayloads( fetchMock ).filter( ( p ) => p.eventName !== 'aisthetix_product_analytics' ); expect( attribution.length ).toBeGreaterThan( 0 ); for ( const payload of attribution ) { expect( payload ).not.toHaveProperty( 'analyticsSessionId' ); } } ); } );