import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { CONVERSION_CAPTURE_VERSION, CONVERSION_INTERACTION_TYPES, attachConversionSink, classifyHref, createConversionTracker, hashTarget, isConversionType, redactHref, type ConversionEvent, } from './useConversionTracking' /** * These tests protect three properties, in order of how expensive they are to lose: * * 1. Conversion events survive a DEFERRED telemetry SDK (fleet P3 interlock). A click * captured before any sink exists must still arrive once one attaches. * 2. Classification order is stable — booking beats same-origin and social, protocol * beats everything. A silent reclassification turns booking revenue into `internal`. * 3. Delegated capture catches links the site never hand-wired. This is the whole reason * the module exists: KEPT's `BookPTAppointmentClicked` died on 2026-06-03 because one * branch of the header had no handler, and nothing failed. */ const HOST = 'kineticenergypt.com' function setHost(host: string) { // jsdom's location is not writable; stub the pieces the module reads. Object.defineProperty(window, 'location', { configurable: true, value: { hostname: host, pathname: '/' } as Location, }) } function anchor(html: string): HTMLAnchorElement { document.body.innerHTML = html const el = document.body.querySelector('a') if (!el) throw new Error('fixture has no anchor') return el } // jsdom cannot navigate; without this every anchor click logs "Not implemented: // navigation to another Document". Bubble-phase, so the tracker's capture-phase // listener still sees the click exactly as a browser would. const swallowNavigation = (event: Event) => event.preventDefault() beforeEach(() => { setHost(HOST) document.body.innerHTML = '' document.addEventListener('click', swallowNavigation) }) afterEach(() => { document.removeEventListener('click', swallowNavigation) vi.restoreAllMocks() document.body.innerHTML = '' }) describe('classifyHref — order is load-bearing', () => { const opts = { pageHost: HOST, bookingHosts: ['stridethera.com', 'momence.com', 'vagaro.com'], bookingPaths: ['/book'], socialHosts: ['facebook.com', 'instagram.com'], } it('protocol wins first: tel: and sms: are phone, mailto: is email', () => { expect(classifyHref('tel:+19708798026', opts)).toBe('phone') expect(classifyHref('sms:+19708798026', opts)).toBe('phone') expect(classifyHref('mailto:frontdesk@kineticenergypt.com', opts)).toBe('email') }) it('booking vendors are booking, including subdomains', () => { expect( classifyHref('https://app.stridethera.com/patient-scheduling/1feed7e0', opts), ).toBe('booking') expect(classifyHref('https://www.vagaro.com/us04/justposhesthetics/services', opts)).toBe( 'booking', ) expect(classifyHref('https://momence.com/appointments/x', opts)).toBe('booking') }) it('booking beats same-origin: a self-hosted /book path is revenue, not internal', () => { expect(classifyHref('/book/pt', opts)).toBe('booking') expect(classifyHref('/gym', opts)).toBe('internal') }) it('social, external and internal fall out in that order', () => { expect(classifyHref('https://www.instagram.com/kinetic_energy_pt', opts)).toBe('social') expect(classifyHref('https://example.com/whatever', opts)).toBe('external') expect(classifyHref(`https://${HOST}/staff`, opts)).toBe('internal') }) it('non-navigational anchors are buttons, not conversions', () => { expect(classifyHref('#', opts)).toBe('button') expect(classifyHref('javascript:void(0)', opts)).toBe('button') expect(classifyHref('', opts)).toBe('button') }) }) describe('capture — delegated, so un-wired links are still counted', () => { it('captures a booking link that has no click handler of its own', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() // Deliberately mirrors the KEPT header CTA that lost its handler. const el = anchor( `Book Online`, ) el.click() expect(events).toHaveLength(1) expect(events[0].name).toBe('site_interaction') expect(events[0].properties.interaction_type).toBe('booking') expect(events[0].properties.label).toBe('Book Online') expect(events[0].properties.href_host).toBe('app.stridethera.com') expect(events[0].properties.capture_version).toBe(CONVERSION_CAPTURE_VERSION) tracker.stop() }) it('captures a tel: tap — the signal no DCS site measured before', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() anchor(`(970) 879-8026`).click() expect(events).toHaveLength(1) expect(events[0].properties.interaction_type).toBe('phone') tracker.stop() }) it('captures clicks on a child element of the link', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() document.body.innerHTML = `Book an Appointment` ;(document.getElementById('inner') as HTMLElement).click() expect(events).toHaveLength(1) expect(events[0].properties.interaction_type).toBe('booking') tracker.stop() }) it('strips query and fragment from the destination', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() anchor( `Book`, ).click() expect(events[0].properties.href).not.toContain('example.com') expect(events[0].properties.href).not.toContain('?') expect(events[0].properties.href).not.toContain('#') tracker.stop() }) it('ignores clicks that are not on a link or button', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() document.body.innerHTML = `
just prose
` ;(document.getElementById('text') as HTMLElement).click() expect(events).toHaveLength(0) tracker.stop() }) it('collapses a duplicate fire of the same click', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() const el = anchor(`Call`) el.click() el.click() expect(events).toHaveLength(1) tracker.stop() }) it('a throwing sink never breaks the page', () => { const tracker = createConversionTracker({ sink: () => { throw new Error('transport down') }, }) tracker.start() expect(() => anchor(`Call`).click()).not.toThrow() tracker.stop() }) }) describe('INTERLOCK — survives a deferred sink (fleet P3, App Insights deferral)', () => { it('buffers clicks captured before any sink exists, then flushes on attach', () => { const tracker = createConversionTracker() // no sink: SDK not loaded yet tracker.start() anchor(`Book an Appointment`).click() anchor(`(248) 881-5213`).click() // Nothing has been delivered — but nothing has been lost either. expect(tracker.buffered()).toHaveLength(2) const events: ConversionEvent[] = [] tracker.attachSink((e) => events.push(e)) expect(events.map((e) => e.properties.interaction_type)).toEqual(['booking', 'phone']) expect(tracker.buffered()).toHaveLength(0) // And it stays live afterwards. anchor(`Book`).click() expect(events).toHaveLength(3) tracker.stop() }) it('attachConversionSink reaches a tracker the loader has no reference to', () => { const tracker = createConversionTracker() tracker.start() anchor(`Book`).click() const events: ConversionEvent[] = [] const reached = attachConversionSink((e) => events.push(e)) expect(reached).toBeGreaterThanOrEqual(1) expect(events).toHaveLength(1) tracker.stop() // stop() deregisters, so a later attach must not resurrect it. expect(attachConversionSink(() => {})).toBe(0) }) it('the buffer is bounded, so a click-spamming bot cannot grow memory', () => { const tracker = createConversionTracker({ bufferLimit: 3 }) tracker.start() for (let i = 0; i < 10; i += 1) { anchor(`Book ${i}`).click() } expect(tracker.buffered()).toHaveLength(3) // Oldest dropped, newest kept. expect(tracker.buffered()[2].properties.label).toBe('Book 9') tracker.stop() }) }) describe('configuration', () => { it('honours a custom event name (canonical dcs_conversion)', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ eventName: 'dcs_conversion', sink: (e) => events.push(e), }) tracker.start() anchor(`Call`).click() expect(events[0].name).toBe('dcs_conversion') tracker.stop() }) it('accepts per-site booking hosts on top of the fleet defaults', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ bookingHosts: ['bookme.example'], sink: (e) => events.push(e), }) tracker.start() anchor(`Reserve`).click() expect(events[0].properties.interaction_type).toBe('booking') tracker.stop() }) it('stop() unbinds — no events after teardown', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() tracker.stop() anchor(`Call`).click() expect(events).toHaveLength(0) }) }) describe('managed + bespoke form submits (C-326)', () => { function form(html: string): HTMLFormElement { document.body.innerHTML = html const el = document.body.querySelector('form') if (!el) throw new Error('fixture has no form') return el } it('counts a submitted form as a conversion', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() form( ``, ).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) expect(events).toHaveLength(1) expect(events[0].properties.interaction_type).toBe('form_submit') expect(events[0].properties.is_conversion).toBe('true') expect(events[0].properties.label).toBe('Request a Consultation') tracker.stop() }) it('DOES NOT count the click on the submit button — only the submit that follows', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() const f = form(``) const button = f.querySelector('button') as HTMLElement // A real browser fires click, then submit. Counting both would make every lead two. button.click() f.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) expect(events).toHaveLength(1) expect(events[0].properties.interaction_type).toBe('form_submit') tracker.stop() }) it('a click that never becomes a submit (validation failed) is not a conversion', () => { const events: ConversionEvent[] = [] const tracker = createConversionTracker({ sink: (e) => events.push(e) }) tracker.start() // HTML default: a typeless