diff --git a/assets/hmp-commerce.js b/assets/hmp-commerce.js index bb817f7..c87d5cd 100644 --- a/assets/hmp-commerce.js +++ b/assets/hmp-commerce.js @@ -241,6 +241,7 @@ const result = await response.json(); if ( result.schema_version !== 'registration.response.v1' || + result.registration_status !== 'REGISTERED' || !REGISTRATION_ID_PATTERN.test(result.registration_id || '') || !STATUS_TOKEN_PATTERN.test(result.commerce_status_token || '') || !validCheckoutUrl(result.checkout && result.checkout.widget_url, payload, result.checkout) @@ -255,6 +256,18 @@ session_code: payload.session_code })); storage().removeItem(IDEMPOTENCY_KEY); + const completedRegistration = Object.freeze({ + registrationId: result.registration_id, + sessionCode: payload.session_code + }); + window.__hbCompletedRegistration = completedRegistration; + if (typeof window.CustomEvent === 'function' && typeof window.dispatchEvent === 'function') { + try { + window.dispatchEvent(new window.CustomEvent('hb:registration-completed', { + detail: completedRegistration + })); + } catch (_) {} + } return result; } diff --git a/assets/meta-pixel.js b/assets/meta-pixel.js index 577845b..083f7b3 100644 --- a/assets/meta-pixel.js +++ b/assets/meta-pixel.js @@ -5,6 +5,9 @@ const CONSENT_KEY = 'hb-meta-consent'; const GRANTED = 'granted'; const DENIED = 'denied'; + const REGISTRATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const SESSION_CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + const sentRegistrationIds = new Set(); let pixelLoaded = false; const readConsent = () => { @@ -48,7 +51,12 @@ }; function loadPixel() { - if (pixelLoaded || readConsent() !== GRANTED) return; + if (readConsent() !== GRANTED) return; + if (pixelLoaded) { + if (typeof window.fbq === 'function') window.fbq('consent', 'grant'); + if (window.__hbCompletedRegistration) trackCompletedRegistration(window.__hbCompletedRegistration); + return; + } pixelLoaded = true; /* Meta Pixel base code */ @@ -64,12 +72,53 @@ window.fbq('consent', 'grant'); window.fbq('init', PIXEL_ID); window.fbq('track', 'PageView'); + if (window.__hbCompletedRegistration) trackCompletedRegistration(window.__hbCompletedRegistration); + } + + function trackCompletedRegistration(detail) { + if ( + readConsent() !== GRANTED || + typeof window.fbq !== 'function' || + !detail || + !REGISTRATION_ID_PATTERN.test(detail.registrationId || '') || + !SESSION_CODE_PATTERN.test(detail.sessionCode || '') + ) return; + const registrationKey = `hb-meta-registration-${detail.registrationId}`; + if (sentRegistrationIds.has(detail.registrationId)) return; + let sentInCurrentTab = false; + try { + sentInCurrentTab = Boolean(sessionStorage.getItem(registrationKey)); + } catch (_) {} + try { + if (localStorage.getItem(registrationKey)) return; + if (sentInCurrentTab) localStorage.setItem(registrationKey, 'sent'); + } catch (_) {} + if (sentInCurrentTab) { + return; + } + for (const browserStorage of [sessionStorage, localStorage]) { + try { + browserStorage.setItem(registrationKey, 'sent'); + } catch (_) {} + } + sentRegistrationIds.add(detail.registrationId); + + window.fbq('track', 'CompleteRegistration', { + content_name: 'Harmonic Myth Projection', + content_ids: [detail.sessionCode], + content_type: 'product' + }); } // Purchase intentionally fails closed until commerce-status exposes an // authoritative paid conversion, real amount/currency and stable opaque ID. // registrationId + ACCESS_READY alone can also represent a zero-value access. + window.addEventListener('hb:registration-completed', (event) => { + window.__hbCompletedRegistration = event.detail; + if (pixelLoaded) trackCompletedRegistration(event.detail); + }); + function revokePixel() { if (typeof window.fbq === 'function') window.fbq('consent', 'revoke'); ['_fbp', '_fbc'].forEach((name) => { diff --git a/tests/hmp-commerce.test.js b/tests/hmp-commerce.test.js index 73830e7..08228f2 100644 --- a/tests/hmp-commerce.test.js +++ b/tests/hmp-commerce.test.js @@ -22,6 +22,12 @@ function runtime(fetchImpl, overrides = {}, registrationOpen = true) { fetch: fetchImpl, sessionStorage, setTimeout, + CustomEvent: class CustomEvent { + constructor(type, options = {}) { + this.type = type; + this.detail = options.detail; + } + }, ...overrides }; const context = vm.createContext({URL, window}); @@ -33,7 +39,7 @@ function runtime(fetchImpl, overrides = {}, registrationOpen = true) { ? source : source.replace('const REGISTRATION_OPEN = true;', 'const REGISTRATION_OPEN = false;'); vm.runInContext(evaluatedSource, context); - return {api: window.HMPCommerce, values}; + return {api: window.HMPCommerce, values, window}; } function widgetDom() { @@ -258,6 +264,7 @@ test('stores a separate status token only after a valid registration response', ok: true, json: async () => ({ schema_version: 'registration.response.v1', + registration_status: 'REGISTERED', registration_id: '20000000-0000-4000-8000-000000000001', commerce_status_token: statusToken, checkout @@ -274,6 +281,103 @@ test('stores a separate status token only after a valid registration response', assert.match(result.checkout.widget_url, /^https:\/\/tickets\.harmonicbeacon\.com\//); }); +test('announces CompleteRegistration only after a canonical REGISTERED response', async () => { + const events = []; + const {api, window} = runtime(async () => ({ + ok: true, + json: async () => ({ + schema_version: 'registration.response.v1', + registration_status: 'REGISTERED', + registration_id: '20000000-0000-4000-8000-000000000001', + commerce_status_token: statusToken, + checkout + }) + }), { + dispatchEvent: event => { events.push(event); return true; } + }); + + await api.register(payload); + + assert.equal(events.length, 1); + assert.equal(events[0].type, 'hb:registration-completed'); + assert.deepEqual( + JSON.parse(JSON.stringify(events[0].detail)), + { + registrationId: '20000000-0000-4000-8000-000000000001', + sessionCode: 'es-0830-cr' + } + ); + assert.equal(window.__hbCompletedRegistration.registrationId, events[0].detail.registrationId); +}); + +test('an analytics listener failure never rejects a valid registration response', async () => { + const {api} = runtime(async () => ({ + ok: true, + json: async () => ({ + schema_version: 'registration.response.v1', + registration_status: 'REGISTERED', + registration_id: '20000000-0000-4000-8000-000000000001', + commerce_status_token: statusToken, + checkout + }) + }), { + dispatchEvent: () => { throw new Error('analytics unavailable'); } + }); + + const result = await api.register(payload); + + assert.equal(result.registration_status, 'REGISTERED'); + assert.equal(result.checkout.widget_url, checkout.widget_url); +}); + +test('an analytics listener failure never blocks the validated checkout widget', async () => { + const dom = widgetDom(); + const {api} = runtime(async () => ({ + ok: true, + json: async () => ({ + schema_version: 'registration.response.v1', + registration_status: 'REGISTERED', + registration_id: '20000000-0000-4000-8000-000000000001', + commerce_status_token: statusToken, + checkout + }) + }), { + document: dom.document, + dispatchEvent: () => { throw new Error('analytics unavailable'); } + }); + + const result = await api.register(payload); + const script = api.mountCheckoutWidget(dom.container, payload, result.checkout); + const mountedUrl = new URL(script.attributes.get('data-url')); + const mountedHash = new URLSearchParams(mountedUrl.hash.slice(1)); + + assert.equal(dom.container.children.length, 1); + assert.equal(script.src, 'https://cdn.tickettailor.com/js/widgets/min/widget.js'); + assert.equal(mountedHash.get('p[meta_registration_context]'), checkoutContext); + assert.equal(mountedHash.get('p[first_name]'), payload.first_name); + assert.equal(mountedHash.get('p[last_name]'), payload.last_name); + assert.equal(mountedHash.get('p[email]'), payload.email); +}); + +test('rejects a response without canonical REGISTERED status and emits no event', async () => { + const events = []; + const {api} = runtime(async () => ({ + ok: true, + json: async () => ({ + schema_version: 'registration.response.v1', + registration_status: 'VERIFYING', + registration_id: '20000000-0000-4000-8000-000000000001', + commerce_status_token: statusToken, + checkout + }) + }), { + dispatchEvent: event => { events.push(event); return true; } + }); + + await assert.rejects(api.register(payload), /invalid_registration_response/); + assert.equal(events.length, 0); +}); + test('rejects malformed registration ids and status tokens before storing context', async () => { const malformed = async () => ({ ok: true, diff --git a/tests/meta-pixel.test.js b/tests/meta-pixel.test.js new file mode 100644 index 0000000..fb416ef --- /dev/null +++ b/tests/meta-pixel.test.js @@ -0,0 +1,226 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '..', 'assets', 'meta-pixel.js'), 'utf8'); +const registration = { + registrationId: '20000000-0000-4000-8000-000000000001', + sessionCode: 'es-0830-cr' +}; + +function pixelRuntime(initialConsent = null, sessionValues = new Map(), localValues = new Map()) { + if (initialConsent) localValues.set('hb-meta-consent', initialConsent); + const listeners = new Map(); + const controls = new Map(); + const insertedScripts = []; + const localStorage = { + getItem: key => localValues.has(key) ? localValues.get(key) : null, + setItem: (key, value) => localValues.set(key, String(value)) + }; + const sessionStorage = { + getItem: key => sessionValues.has(key) ? sessionValues.get(key) : null, + setItem: (key, value) => sessionValues.set(key, String(value)) + }; + const button = name => ({ + addEventListener: (type, callback) => controls.set(`${name}:${type}`, callback), + focus() {} + }); + const grant = button('grant'); + const deny = button('deny'); + const document = { + body: {append() {}}, + cookie: '', + documentElement: {lang: 'es'}, + head: {appendChild() {}}, + readyState: 'complete', + querySelector: () => null, + createElement: tag => { + if (tag === 'section') { + return { + hidden: false, + setAttribute() {}, + querySelector: selector => selector.includes('grant') ? grant : deny + }; + } + if (tag === 'button') { + return { + hidden: false, + addEventListener: (type, callback) => controls.set(`settings:${type}`, callback) + }; + } + return {}; + }, + getElementsByTagName: () => [{ + parentNode: {insertBefore: script => insertedScripts.push(script)} + }], + addEventListener() {} + }; + const window = { + addEventListener: (type, callback) => listeners.set(type, callback), + localStorage, + sessionStorage + }; + vm.runInNewContext(source, { + document, + localStorage, + navigator: {language: 'es'}, + sessionStorage, + window + }); + const registrationHandler = listeners.get('hb:registration-completed'); + return { + calls: () => window.fbq?.queue || [], + controls, + insertedScripts, + sessionValues, + triggerRegistration: detail => registrationHandler({detail}), + window + }; +} + +test('consent granted tracks CompleteRegistration once without PII', () => { + const runtime = pixelRuntime('granted'); + + runtime.triggerRegistration(registration); + runtime.triggerRegistration(registration); + + const events = runtime.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.equal(events.length, 1); + assert.deepEqual(Array.from(events[0][2].content_ids), ['es-0830-cr']); + assert.equal(events[0].length, 3); + assert.equal(JSON.stringify(events[0]).includes('email'), false); + assert.equal(JSON.stringify(events[0]).includes('Alma'), false); +}); + +test('English canonical session tracks the selected product without locale-derived values', () => { + const runtime = pixelRuntime('granted'); + + runtime.triggerRegistration({ + registrationId: '20000000-0000-4000-8000-000000000002', + sessionCode: 'en-1400-cr' + }); + + const events = runtime.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.equal(events.length, 1); + assert.deepEqual(Array.from(events[0][2].content_ids), ['en-1400-cr']); + assert.equal('value' in events[0][2], false); + assert.equal('currency' in events[0][2], false); +}); + +test('PageView fires once per page load and is not repeated by consent re-grant', () => { + const runtime = pixelRuntime('granted'); + + let pageViews = runtime.calls().filter(call => call[0] === 'track' && call[1] === 'PageView'); + assert.equal(pageViews.length, 1); + + runtime.controls.get('deny:click')(); + runtime.controls.get('grant:click')(); + + pageViews = runtime.calls().filter(call => call[0] === 'track' && call[1] === 'PageView'); + assert.equal(pageViews.length, 1); +}); + +test('refresh does not resend a completed registration in the same browser tab', () => { + const sessionValues = new Map(); + const firstPage = pixelRuntime('granted', sessionValues); + firstPage.triggerRegistration(registration); + + const refreshedPage = pixelRuntime('granted', sessionValues); + refreshedPage.triggerRegistration(registration); + + const firstEvents = firstPage.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + const refreshedEvents = refreshedPage.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.equal(firstEvents.length, 1); + assert.equal(refreshedEvents.length, 0); +}); + +test('an idempotent response in another browser tab does not resend CompleteRegistration', () => { + const localValues = new Map(); + const firstTab = pixelRuntime('granted', new Map(), localValues); + firstTab.triggerRegistration(registration); + + const secondTab = pixelRuntime('granted', new Map(), localValues); + secondTab.triggerRegistration(registration); + + const firstEvents = firstTab.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + const secondEvents = secondTab.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.equal(firstEvents.length, 1); + assert.equal(secondEvents.length, 0); +}); + +test('a legacy same-tab marker is migrated before another tab receives the response', () => { + const key = `hb-meta-registration-${registration.registrationId}`; + const localValues = new Map(); + const legacySessionValues = new Map([[key, 'sent']]); + const migratedTab = pixelRuntime('granted', legacySessionValues, localValues); + + migratedTab.triggerRegistration(registration); + assert.equal(localValues.get(key), 'sent'); + + const secondTab = pixelRuntime('granted', new Map(), localValues); + secondTab.triggerRegistration(registration); + + const migratedEvents = migratedTab.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + const secondEvents = secondTab.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.equal(migratedEvents.length, 0); + assert.equal(secondEvents.length, 0); +}); + +test('declined consent never loads Meta or tracks the registration', () => { + const runtime = pixelRuntime('denied'); + + runtime.triggerRegistration(registration); + + assert.equal(runtime.insertedScripts.length, 0); + assert.equal(runtime.window.fbq, undefined); + assert.equal(runtime.sessionValues.size, 0); +}); + +test('registration completed before consent is sent once after explicit grant', () => { + const runtime = pixelRuntime(); + runtime.triggerRegistration(registration); + + assert.equal(runtime.window.fbq, undefined); + runtime.controls.get('grant:click')(); + + const events = runtime.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.equal(events.length, 1); +}); + +test('consent can be revoked and granted again without reloading the page', () => { + const runtime = pixelRuntime('granted'); + runtime.triggerRegistration(registration); + + runtime.controls.get('deny:click')(); + runtime.controls.get('grant:click')(); + + const consentCalls = runtime.calls().filter(call => call[0] === 'consent'); + const events = runtime.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.deepEqual(Array.from(consentCalls, call => call[1]), ['grant', 'revoke', 'grant']); + assert.equal(events.length, 1); +}); + +test('a registration completed while consent is revoked is sent after a later grant', () => { + const runtime = pixelRuntime('granted'); + runtime.controls.get('deny:click')(); + runtime.triggerRegistration(registration); + + let events = runtime.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.equal(events.length, 0); + + runtime.controls.get('grant:click')(); + events = runtime.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.equal(events.length, 1); +}); + +test('malformed identifiers never produce CompleteRegistration', () => { + const runtime = pixelRuntime('granted'); + + runtime.triggerRegistration({registrationId: 'not-a-uuid', sessionCode: 'es-0830-cr'}); + runtime.triggerRegistration({registrationId: registration.registrationId, sessionCode: '../private'}); + + const events = runtime.calls().filter(call => call[0] === 'track' && call[1] === 'CompleteRegistration'); + assert.equal(events.length, 0); +}); diff --git a/tests/registration-pages.test.js b/tests/registration-pages.test.js index bb1ac7c..64c6f60 100644 --- a/tests/registration-pages.test.js +++ b/tests/registration-pages.test.js @@ -69,6 +69,16 @@ test('Meta Purchase fails closed without canonical paid conversion facts', () => assert.doesNotMatch(pixel, /tt_order_id/); }); +test('Meta CompleteRegistration is gated by the canonical registration event', () => { + const commerce = read('assets/hmp-commerce.js'); + const pixel = read('assets/meta-pixel.js'); + assert.match(commerce, /result\.registration_status !== 'REGISTERED'/); + assert.match(commerce, /hb:registration-completed/); + assert.match(pixel, /trackCompletedRegistration/); + assert.match(pixel, /'CompleteRegistration'/); + assert.doesNotMatch(pixel, /detail\.email|detail\.firstName|detail\.lastName/); +}); + test('registration-v3 remains byte-exact as immutable historical evidence', () => { const bytes = fs.readFileSync(path.join(root, 'legal/terms/registration-v3.html')); assert.equal(