diff --git a/kits/rokt/src/Rokt-Kit.ts b/kits/rokt/src/Rokt-Kit.ts index f775268a3..078b13a64 100644 --- a/kits/rokt/src/Rokt-Kit.ts +++ b/kits/rokt/src/Rokt-Kit.ts @@ -37,9 +37,20 @@ import { clearUtmParams, } from './pageViewStorage'; import { isLocalStorageAvailable } from './storage'; - -import { isObject, isString, isEmpty, isFunction, sanitizeUrl } from './utils'; -import { buildSetterDiagnosticLogEntry, buildSelectPlacementsDiagnosticLogEntry } from './diagnosticTiming'; +import { + createPreselectState, + maybeFirePreselect as maybeFirePreselectExternal, + flushPendingPreselectDispatches as flushPendingPreselectDispatchesExternal, + type PreselectState, + type PreselectHost, +} from './preselection'; +import { findPreselectionConfigByIdentifier, isPreselectAttributeKey } from './preselectionConfig'; + +import { isObject, isString, isEmpty, isFunction, sanitizeUrl, djb2 } from './utils'; +import { + buildSetterDiagnosticLogEntry, + buildSelectPlacementsDiagnosticLogEntry, +} from './diagnosticTiming'; import { createLauncherAttachState, markLauncherAttached, @@ -102,6 +113,7 @@ interface RoktLauncher { hashAttributes(attributes: Record): Promise>; use(extensionName: string): Promise; terminate(): Promise; + enablePreselection?: boolean; } interface RoktGlobal { @@ -341,7 +353,7 @@ function generateThankYouElementScript(domain: string | undefined) { } function generateBaseUrl(domain: string | undefined) { - const resolvedDomain = typeof domain !== 'undefined' ? domain : DEFAULT_ROKT_DOMAIN; + const resolvedDomain = domain !== undefined ? domain : DEFAULT_ROKT_DOMAIN; if (resolvedDomain.includes('://')) { return resolvedDomain.replace(/\/+$/, ''); @@ -488,15 +500,6 @@ function generateIntegrationName(customIntegrationName?: string): string { return integrationName; } -function djb2(str: string): number { - let hash = 5381; - for (let i = 0; i < str.length; i++) { - hash = (hash << 5) + hash + str.charCodeAt(i); - hash = hash & hash; - } - return hash; -} - function createAutoRemovedIframe(src: string): void { const iframe = document.createElement('iframe'); iframe.style.display = 'none'; @@ -833,6 +836,9 @@ class RoktKit implements KitInterface { private _launcherAttachState: LauncherAttachState = createLauncherAttachState(); + private accountId: string | null = null; + private _preselectState: PreselectState = createPreselectState(); + // ---- Private helpers ---- private getEventAttributeValue(event: SDKEvent, eventAttributeKey: string): unknown { @@ -841,7 +847,7 @@ class RoktKit implements KitInterface { return null; } - if (typeof attributes[eventAttributeKey] === 'undefined') { + if (attributes[eventAttributeKey] === undefined) { return null; } @@ -958,6 +964,41 @@ class RoktKit implements KitInterface { } } + private isPreselectionEnabled(): boolean { + return this.launcher?.enablePreselection === true; + } + + private buildCacheMatchKeys(identifier: string | undefined): string[] | undefined { + if (!this.isPreselectionEnabled()) { + return undefined; + } + + const configEntry = findPreselectionConfigByIdentifier(this.accountId, identifier); + if (!configEntry) { + return undefined; + } + + return configEntry.attributeKeys; + } + + private buildPreselectHost(): PreselectHost { + return { + accountId: this.accountId, + filteredUser: this.filters.filteredUser, + userAttributes: this.userAttributes, + isKitReady: () => this.isKitReady(), + isPreselectionEnabled: () => this.isPreselectionEnabled(), + getEventAttributeValue: (event, key) => this.getEventAttributeValue(event, key), + logPlacementDiagnostic: (entry) => this.loggingService?.logPlacementDiagnostic(entry), + log: (entry) => this.loggingService?.log(entry), + selectPlacements: (options) => this.selectPlacements(options), + }; + } + + private flushPendingPreselectDispatches(): void { + flushPendingPreselectDispatchesExternal(this._preselectState, this.buildPreselectHost()); + } + private isLauncherReadyToAttach(): boolean { return !!window.Rokt && isFunction(window.Rokt.createLauncher); } @@ -1115,6 +1156,8 @@ class RoktKit implements KitInterface { // Attaches the kit to the Rokt manager mp().Rokt.attachKit(this); + + this.flushPendingPreselectDispatches(); } private fetchOptimizely(): Record { @@ -1181,6 +1224,7 @@ class RoktKit implements KitInterface { ): string { const kitSettings = settings as unknown as RoktKitSettings; const accountId = kitSettings.accountId; + this.accountId = accountId || null; this.userAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(filteredUserAttributes); this._onboardingExpProvider = kitSettings.onboardingExpProvider; @@ -1326,6 +1370,7 @@ class RoktKit implements KitInterface { if (event.EventDataType === MESSAGE_TYPE_PAGE_VIEW) { captureUtmParams(this.loggingService); this.capturePageView(event); + maybeFirePreselectExternal(this._preselectState, this.buildPreselectHost(), event); } if (event.EventDataType === MESSAGE_TYPE_SESSION_END) { @@ -1370,6 +1415,9 @@ class RoktKit implements KitInterface { if (!isSelectPlacementsAttributePersistenceDenied(key)) { this.userAttributes[key] = value; } + if (isPreselectAttributeKey(this.accountId, key)) { + this.flushPendingPreselectDispatches(); + } return 'Successfully set user attribute for forwarder: ' + name; } @@ -1389,7 +1437,13 @@ class RoktKit implements KitInterface { const filteredUser = user as FilteredUser; this.filters.filteredUser = filteredUser; this._workspaceSearchInFlightPromise = this.search(filteredUser); - return this.handleIdentityComplete(user, 'onUserIdentified'); + const result = this.handleIdentityComplete(user, 'onUserIdentified'); + // onUserIdentified fires for every identity op (identify/login/logout/modify) + // with the fresh current user, so this is the single place to retry a preselect + // that was requeued for missing identity; maybeFirePreselect re-checks + // hasValidIdentity itself, so an anonymous user here is a no-op re-queue. + this.flushPendingPreselectDispatches(); + return result; } private search(filteredUser: FilteredUser): Promise { @@ -1584,7 +1638,13 @@ class RoktKit implements KitInterface { mpid, }; - const selectPlacementsOptions: Record = { ...options, attributes: selectPlacementsAttributes }; + const cacheMatchKeys = this.buildCacheMatchKeys(typeof options.identifier === 'string' ? options.identifier : undefined); + + const selectPlacementsOptions: Record = { + ...options, + attributes: selectPlacementsAttributes, + ...(cacheMatchKeys !== undefined ? { cacheMatchKeys } : {}), + }; this.loggingService?.logPlacementDiagnostic( buildSelectPlacementsDiagnosticLogEntry(Object.keys(selectPlacementsAttributes)), @@ -1592,11 +1652,22 @@ class RoktKit implements KitInterface { const selection = this.launcher!.selectPlacements(selectPlacementsOptions); + const isPreselect = options.preselect === true; + // After selection resolves, sync the Rokt session ID back to mParticle, then log - const logSelection = () => this.logSelectPlacementsEvent(selectPlacementsAttributes); + const logSelection = () => { + if (!isPreselect) { + this.logSelectPlacementsEvent(selectPlacementsAttributes); + } + }; void Promise.resolve(selection) - .then((sel) => sel?.context?.sessionId?.then((sessionId) => this.setRoktSessionId(sessionId))) + .then((sel) => { + if (isPreselect) { + return; + } + return sel?.context?.sessionId?.then((sessionId) => this.setRoktSessionId(sessionId)); + }) .catch(() => undefined) .finally(logSelection); diff --git a/kits/rokt/src/activePreselectStorage.ts b/kits/rokt/src/activePreselectStorage.ts new file mode 100644 index 000000000..8696f393c --- /dev/null +++ b/kits/rokt/src/activePreselectStorage.ts @@ -0,0 +1,29 @@ +import { readNamespacedField, writeNamespacedField, LS_NAMESPACE_KEY } from './storage'; +import { isObject } from './utils'; + +export const ACTIVE_PRESELECT_TTL_MS = 60_000; + +export interface ActivePreselectRecord { + expiresAt: number; + attributes: Record; +} + +function isActivePreselectRecord(value: unknown): value is ActivePreselectRecord { + return isObject(value) && typeof value.expiresAt === 'number' && isObject(value.attributes); +} + +export function buildActivePreselectFieldKey(accountId: string, pathname: string): string { + return `activePreselect:${accountId}:${pathname}`; +} + +export function getActivePreselect(fieldKey: string): ActivePreselectRecord | null { + const stored = readNamespacedField(LS_NAMESPACE_KEY, fieldKey); + return isActivePreselectRecord(stored) ? stored : null; +} + +export function setActivePreselect(fieldKey: string, attributes: Record): void { + writeNamespacedField(LS_NAMESPACE_KEY, fieldKey, { + expiresAt: Date.now() + ACTIVE_PRESELECT_TTL_MS, + attributes, + }); +} diff --git a/kits/rokt/src/diagnosticTiming.ts b/kits/rokt/src/diagnosticTiming.ts index 702b4849f..3d4fe594c 100644 --- a/kits/rokt/src/diagnosticTiming.ts +++ b/kits/rokt/src/diagnosticTiming.ts @@ -24,3 +24,21 @@ export function buildSelectPlacementsDiagnosticLogEntry(placementAttributeKeys: code: 'SELECT_PLACEMENTS_DISPATCHED', }; } + +export type PreselectDiagnosticOutcome = 'fired' | 'missed' | 'queued' | 'skipped'; + +export function buildPreselectDiagnosticLogEntry( + outcome: PreselectDiagnosticOutcome, + reason: string, +): DiagnosticLogEntry { + const code: Record = { + fired: 'PRESELECT_FIRED', + missed: 'PRESELECT_MISSED', + queued: 'PRESELECT_QUEUED', + skipped: 'PRESELECT_SKIPPED', + }; + return { + message: `Rokt Kit: preselect ${outcome} [reason=${reason}]`, + code: code[outcome], + }; +} diff --git a/kits/rokt/src/pageViewStorage.ts b/kits/rokt/src/pageViewStorage.ts index 30e57bffd..cafa04d24 100644 --- a/kits/rokt/src/pageViewStorage.ts +++ b/kits/rokt/src/pageViewStorage.ts @@ -1,8 +1,13 @@ import type { LoggingService } from './Rokt-Kit'; -import { readNamespacedField, writeNamespacedField, removeNamespacedField, isLocalStorageAvailable } from './storage'; +import { + readNamespacedField, + writeNamespacedField, + removeNamespacedField, + isLocalStorageAvailable, + LS_NAMESPACE_KEY, +} from './storage'; import { sanitizeUrl, isObject } from './utils'; -const LS_NAMESPACE_KEY = 'mp-rokt-kit'; const LS_PAGE_VIEWS_FIELD = 'pageViews'; const LS_UTM_PARAMS_FIELD = 'utmParams'; export const PAGE_VIEWS_MAX_COUNT = 25; diff --git a/kits/rokt/src/preselection.ts b/kits/rokt/src/preselection.ts new file mode 100644 index 000000000..65cc7c2f5 --- /dev/null +++ b/kits/rokt/src/preselection.ts @@ -0,0 +1,168 @@ +import { IMParticleUser, SDKEvent } from '@mparticle/web-sdk/internal'; +import type { IUserIdentities } from '@mparticle/web-sdk'; + +import { findPreselectionConfig } from './preselectionConfig'; +import { buildActivePreselectFieldKey, getActivePreselect, setActivePreselect } from './activePreselectStorage'; +import { buildPreselectDiagnosticLogEntry, type DiagnosticLogEntry } from './diagnosticTiming'; +import { isEmpty, isString } from './utils'; + +export interface PendingPreselectDispatch { + event: SDKEvent; + pathname: string; +} + +export interface PreselectState { + pending: PendingPreselectDispatch[]; +} + +export function createPreselectState(): PreselectState { + return { pending: [] }; +} + +// Only the most recent pageview per pathname is worth retrying — replace rather than +// accumulate, so a page the user never provides the required attribute on doesn't grow +// state.pending without bound across repeat pageviews. +function enqueuePending(state: PreselectState, dispatch: PendingPreselectDispatch): void { + const existingIndex = state.pending.findIndex((entry) => entry.pathname === dispatch.pathname); + if (existingIndex >= 0) { + state.pending[existingIndex] = dispatch; + return; + } + state.pending.push(dispatch); +} + +export interface PreselectHost { + accountId: string | null; + filteredUser: IMParticleUser | null | undefined; + userAttributes: Record; + isKitReady(): boolean; + isPreselectionEnabled(): boolean; + getEventAttributeValue(event: SDKEvent, key: string): unknown; + logPlacementDiagnostic(entry: DiagnosticLogEntry | null | undefined): void; + log(entry: DiagnosticLogEntry | null | undefined): void; + selectPlacements(options: Record): unknown; +} + +function hasValidIdentity(filteredUser: IMParticleUser | null | undefined): boolean { + if (!filteredUser?.getUserIdentities) { + return false; + } + + const userIdentities: IUserIdentities | null = filteredUser.getUserIdentities().userIdentities; + if (!userIdentities) { + return false; + } + + return Object.keys(userIdentities).some((key) => { + const value = userIdentities[key as keyof IUserIdentities]; + return isString(value) && value.length > 0; + }); +} + +export function dispatchPreselect(host: PreselectHost, options: Record): void { + void Promise.resolve(host.selectPlacements(options)).catch((err: unknown) => { + const errMessage = err instanceof Error ? err.message : String(err); + host.log({ + message: `Rokt Kit: Preselect selectPlacements call failed: ${errMessage}`, + code: 'PRESELECT_DISPATCH_FAILED', + }); + }); +} + +export function maybeFirePreselect( + state: PreselectState, + host: PreselectHost, + event: SDKEvent, + pathname: string = window.location.pathname, +): void { + const configEntry = findPreselectionConfig(host.accountId, pathname); + if (!configEntry) { + return; + } + + if (!host.isKitReady()) { + enqueuePending(state, { event, pathname }); + host.logPlacementDiagnostic(buildPreselectDiagnosticLogEntry('queued', 'not_ready')); + return; + } + + if (!host.isPreselectionEnabled()) { + return; + } + + if (!hasValidIdentity(host.filteredUser)) { + host.logPlacementDiagnostic(buildPreselectDiagnosticLogEntry('missed', 'no_valid_identity')); + // Guest checkout can hit this pageview before login; requeue so a later + // identification on the same page can still flush a confirmation preselect. + enqueuePending(state, { event, pathname }); + return; + } + + const livePersistedAttributes = host.filteredUser?.getAllUserAttributes?.() || {}; + + const collectedAttributes: Record = {}; + const missingKeys: string[] = []; + for (const key of configEntry.attributeKeys) { + const eventValue = host.getEventAttributeValue(event, key); + const value = !isEmpty(eventValue) ? eventValue : (host.userAttributes[key] ?? livePersistedAttributes[key]); + if (isEmpty(value)) { + missingKeys.push(key); + continue; + } + collectedAttributes[key] = value; + } + + if (missingKeys.length > 0) { + for (const key of missingKeys) { + host.logPlacementDiagnostic(buildPreselectDiagnosticLogEntry('missed', `missing_attribute:${key}`)); + } + // The site sets these attributes one at a time via setUserAttribute, often after this + // pageview has already fired, so requeue rather than dropping the attempt on the floor; + // Rokt-Kit's setUserAttribute flushes this queue once a matching key arrives. + enqueuePending(state, { event, pathname }); + return; + } + + const activePreselectKey = buildActivePreselectFieldKey(host.accountId || '', pathname); + const activeRecord = getActivePreselect(activePreselectKey); + const attributesUnchanged = + !!activeRecord && JSON.stringify(activeRecord.attributes) === JSON.stringify(collectedAttributes); + + if (activeRecord && activeRecord.expiresAt > Date.now() && attributesUnchanged) { + host.logPlacementDiagnostic(buildPreselectDiagnosticLogEntry('skipped', 'active_preselection')); + return; + } + + const preselectOptions: Record = { + attributes: collectedAttributes, + preselect: true, + identifier: configEntry.targetPageIdentifier, + omitUrl: true, + }; + + setActivePreselect(activePreselectKey, collectedAttributes); + host.logPlacementDiagnostic(buildPreselectDiagnosticLogEntry('fired', 'fired')); + + dispatchPreselect(host, preselectOptions); +} + +export function flushPendingPreselectDispatches( + state: PreselectState, + host: PreselectHost, + currentPathname: string = window.location.pathname, +): void { + if (state.pending.length === 0) { + return; + } + + const pending = state.pending; + state.pending = []; + pending.forEach(({ event, pathname }) => { + // A stale entry from a page the user has since navigated away from should not fire + // against its originally-queued route; drop it rather than replaying it here. + if (pathname !== currentPathname) { + return; + } + maybeFirePreselect(state, host, event, pathname); + }); +} diff --git a/kits/rokt/src/preselectionConfig.ts b/kits/rokt/src/preselectionConfig.ts new file mode 100644 index 000000000..dbb44aeae --- /dev/null +++ b/kits/rokt/src/preselectionConfig.ts @@ -0,0 +1,62 @@ +import { isString } from './utils'; + +// Hardcoded for now; will move to a server-delivered kit setting later. +export interface PreselectionConfigEntry { + accountId: string; + pathname: string; + targetPageIdentifier: string; + attributeKeys: string[]; +} + +export const PRESELECTION_CONFIG: PreselectionConfigEntry[] = [ + { + accountId: '2919171670744024290', + pathname: '/checkout', + targetPageIdentifier: 'prod.rokt.conf', + attributeKeys: [ + 'email', + 'amount', + 'concessions_total', + 'customertype', + 'eventvenue', + 'firstname', + 'lastname', + 'locale', + 'member_status', + 'billingzipcode', + 'country', + 'currency', + 'language', + ], + }, +]; + +export function findPreselectionConfig( + accountId: string | null | undefined, + pathname: string, +): PreselectionConfigEntry | undefined { + if (!accountId) { + return undefined; + } + + return PRESELECTION_CONFIG.find((entry) => entry.accountId === accountId && entry.pathname === pathname); +} + +export function findPreselectionConfigByIdentifier( + accountId: string | null | undefined, + identifier: unknown, +): PreselectionConfigEntry | undefined { + if (!accountId || !isString(identifier)) { + return undefined; + } + + return PRESELECTION_CONFIG.find((entry) => entry.accountId === accountId && entry.targetPageIdentifier === identifier); +} + +export function isPreselectAttributeKey(accountId: string | null | undefined, key: string): boolean { + if (!accountId) { + return false; + } + + return PRESELECTION_CONFIG.some((entry) => entry.accountId === accountId && entry.attributeKeys.includes(key)); +} diff --git a/kits/rokt/src/storage.ts b/kits/rokt/src/storage.ts index 10b60d496..522b5b882 100644 --- a/kits/rokt/src/storage.ts +++ b/kits/rokt/src/storage.ts @@ -1,5 +1,7 @@ import { isObject } from './utils'; +export const LS_NAMESPACE_KEY = 'mp-rokt-kit'; + const LS_PROBE_KEY = '__rokt_ls_probe__'; export function isLocalStorageAvailable(): boolean { diff --git a/kits/rokt/src/utils.ts b/kits/rokt/src/utils.ts index be905f58e..9e81bcb16 100644 --- a/kits/rokt/src/utils.ts +++ b/kits/rokt/src/utils.ts @@ -12,6 +12,9 @@ export function isFunction(value: unknown): value is (...args: Array) = export function isEmpty(value: unknown): boolean { if (value == null) return true; + if (typeof value === 'string') { + return value.length === 0; + } if (typeof value === 'object') { return Object.keys(value as object).length === 0; } @@ -30,3 +33,13 @@ export function sanitizeUrl(href: string): string { return href; } } + +export function djb2(value: string): number { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = (hash << 5) + hash + value.charCodeAt(i); + hash = hash & hash; + } + return hash; +} + diff --git a/kits/rokt/test/src/preselection.spec.ts b/kits/rokt/test/src/preselection.spec.ts new file mode 100644 index 000000000..91d5fa2ae --- /dev/null +++ b/kits/rokt/test/src/preselection.spec.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { SDKEvent } from '@mparticle/web-sdk/internal'; +import type { DiagnosticLogEntry } from '../../src/diagnosticTiming'; +import { + createPreselectState, + maybeFirePreselect, + dispatchPreselect, + flushPendingPreselectDispatches, + type PreselectHost, + type PreselectState, +} from '../../src/preselection'; +import { findPreselectionConfig } from '../../src/preselectionConfig'; +import { buildActivePreselectFieldKey, getActivePreselect, setActivePreselect } from '../../src/activePreselectStorage'; + +// Isolates preselection.ts from its collaborator modules: findPreselectionConfig and the +// active-preselect cache are mocked per-test rather than driven through the real modules +// (which mutate a shared global array / real localStorage and have their own coverage +// elsewhere), so each test controls exactly the config/cache state it needs. +vi.mock('../../src/preselectionConfig', () => ({ + findPreselectionConfig: vi.fn(), +})); + +vi.mock('../../src/activePreselectStorage', () => ({ + buildActivePreselectFieldKey: vi.fn(), + getActivePreselect: vi.fn(), + setActivePreselect: vi.fn(), +})); + +const ACCOUNT_ID = '900001'; +const PATHNAME = '/preselect-test-path'; +const TARGET_PAGE_IDENTIFIER = 'preselect-target-page'; +const ATTRIBUTE_KEY = 'loyaltyTier'; +const FIELD_KEY = 'active-preselect-field-key'; + +const CONFIG_ENTRY = { + accountId: ACCOUNT_ID, + pathname: PATHNAME, + targetPageIdentifier: TARGET_PAGE_IDENTIFIER, + attributeKeys: [ATTRIBUTE_KEY], +}; + +const buildEvent = (eventAttributes: Record = {}): SDKEvent => + ({ EventAttributes: eventAttributes }) as SDKEvent; + +describe('preselection', () => { + let state: PreselectState; + let host: PreselectHost; + let selectPlacementsCalls: Record[]; + let loggedDiagnostics: DiagnosticLogEntry[]; + let loggedEvents: DiagnosticLogEntry[]; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(buildActivePreselectFieldKey).mockReturnValue(FIELD_KEY); + vi.mocked(getActivePreselect).mockReturnValue(null); + + selectPlacementsCalls = []; + loggedDiagnostics = []; + loggedEvents = []; + state = createPreselectState(); + + host = { + accountId: ACCOUNT_ID, + filteredUser: { + getUserIdentities: () => ({ userIdentities: { email: 'test@example.com' } }), + } as unknown as PreselectHost['filteredUser'], + userAttributes: {}, + isKitReady: () => true, + isPreselectionEnabled: () => true, + getEventAttributeValue: (event, key) => { + const attributes = (event as SDKEvent).EventAttributes; + return attributes && attributes[key] !== undefined ? attributes[key] : null; + }, + logPlacementDiagnostic: (entry) => { + if (entry) loggedDiagnostics.push(entry); + }, + log: (entry) => { + if (entry) loggedEvents.push(entry); + }, + selectPlacements: (options) => { + selectPlacementsCalls.push(options); + }, + }; + }); + + describe('createPreselectState', () => { + it('starts with an empty pending queue', () => { + expect(createPreselectState()).toEqual({ pending: [] }); + }); + }); + + describe('maybeFirePreselect', () => { + beforeEach(() => { + vi.mocked(findPreselectionConfig).mockReturnValue(CONFIG_ENTRY); + host.userAttributes = { [ATTRIBUTE_KEY]: 'gold' }; + }); + + describe('preselection enabled', () => { + describe('preconditions', () => { + it('does nothing when no config entry matches the account/pathname', () => { + vi.mocked(findPreselectionConfig).mockReturnValue(undefined); + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(0); + expect(loggedDiagnostics).toHaveLength(0); + }); + + it('queues rather than fires when the kit is not ready', () => { + host.isKitReady = () => false; + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(0); + expect(state.pending).toEqual([{ event: expect.anything(), pathname: PATHNAME }]); + expect(loggedDiagnostics).toContainEqual(expect.objectContaining({ code: 'PRESELECT_QUEUED' })); + }); + + it('does not fire, but requeues, when there is no valid identity', () => { + host.filteredUser = { getUserIdentities: () => ({ userIdentities: {} }) } as unknown as PreselectHost['filteredUser']; + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(0); + expect(state.pending).toEqual([{ event: expect.anything(), pathname: PATHNAME }]); + expect(loggedDiagnostics).toContainEqual(expect.objectContaining({ code: 'PRESELECT_MISSED' })); + }); + + it('fires on a later flush once identity becomes valid for a requeued attempt', () => { + host.filteredUser = { getUserIdentities: () => ({ userIdentities: {} }) } as unknown as PreselectHost['filteredUser']; + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + expect(selectPlacementsCalls).toHaveLength(0); + expect(state.pending).toHaveLength(1); + + host.filteredUser = { + getUserIdentities: () => ({ userIdentities: { email: 'guest@example.com' } }), + } as unknown as PreselectHost['filteredUser']; + + flushPendingPreselectDispatches(state, host, PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(1); + expect(state.pending).toHaveLength(0); + }); + }); + + describe('attribute matching', () => { + it('requeues and logs missed when a required attribute is unresolved', () => { + host.userAttributes = {}; + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(0); + expect(state.pending).toHaveLength(1); + expect(loggedDiagnostics).toContainEqual( + expect.objectContaining({ code: 'PRESELECT_MISSED', message: expect.stringContaining(ATTRIBUTE_KEY) }), + ); + }); + + it('falls through to userAttributes when the event value is an empty string rather than treating it as present', () => { + host.getEventAttributeValue = () => ''; + host.userAttributes = { [ATTRIBUTE_KEY]: 'gold' }; + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toEqual([ + { attributes: { [ATTRIBUTE_KEY]: 'gold' }, preselect: true, identifier: TARGET_PAGE_IDENTIFIER, omitUrl: true }, + ]); + }); + + it('fires and caches the attributes when all are present and nothing is cached yet', () => { + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toEqual([ + { attributes: { [ATTRIBUTE_KEY]: 'gold' }, preselect: true, identifier: TARGET_PAGE_IDENTIFIER, omitUrl: true }, + ]); + expect(setActivePreselect).toHaveBeenCalledWith(FIELD_KEY, { [ATTRIBUTE_KEY]: 'gold' }); + }); + + it('skips when the cached attributes are unchanged and still fresh', () => { + vi.mocked(getActivePreselect).mockReturnValue({ + expiresAt: Date.now() + 30_000, + attributes: { [ATTRIBUTE_KEY]: 'gold' }, + }); + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(0); + expect(setActivePreselect).not.toHaveBeenCalled(); + expect(loggedDiagnostics).toContainEqual(expect.objectContaining({ code: 'PRESELECT_SKIPPED' })); + }); + + it('fires again when the attributes changed, even though the cache is still fresh', () => { + vi.mocked(getActivePreselect).mockReturnValue({ + expiresAt: Date.now() + 30_000, + attributes: { [ATTRIBUTE_KEY]: 'silver' }, + }); + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(1); + }); + + it('fires again when the cache has expired, even though the attributes are unchanged', () => { + vi.mocked(getActivePreselect).mockReturnValue({ + expiresAt: Date.now() - 1, + attributes: { [ATTRIBUTE_KEY]: 'gold' }, + }); + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(1); + }); + }); + }); + + describe('preselection disabled', () => { + it('does not fire despite a matching config, a ready kit, and a valid identity', () => { + host.isPreselectionEnabled = () => false; + + maybeFirePreselect(state, host, buildEvent(), PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(0); + expect(loggedDiagnostics).toHaveLength(0); + }); + }); + }); + + describe('flushPendingPreselectDispatches', () => { + beforeEach(() => { + vi.mocked(findPreselectionConfig).mockReturnValue(CONFIG_ENTRY); + }); + + it('drains the queue before replaying each pending entry', () => { + host.userAttributes = { [ATTRIBUTE_KEY]: 'gold' }; + state.pending = [ + { event: buildEvent(), pathname: PATHNAME }, + { event: buildEvent(), pathname: PATHNAME }, + ]; + + flushPendingPreselectDispatches(state, host, PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(2); + expect(state.pending).toHaveLength(0); + }); + + it('lands a re-enqueue from a replayed entry in the new queue, rather than looping', () => { + host.userAttributes = {}; // still missing the required attribute + state.pending = [{ event: buildEvent(), pathname: PATHNAME }]; + + flushPendingPreselectDispatches(state, host, PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(0); + expect(state.pending).toHaveLength(1); + }); + + it('is a no-op with nothing pending', () => { + expect(() => flushPendingPreselectDispatches(state, host)).not.toThrow(); + expect(selectPlacementsCalls).toHaveLength(0); + }); + + it('drops a stale entry when the user has navigated to a different pathname', () => { + host.userAttributes = { [ATTRIBUTE_KEY]: 'gold' }; + state.pending = [{ event: buildEvent(), pathname: PATHNAME }]; + + flushPendingPreselectDispatches(state, host, '/some-other-path'); + + expect(selectPlacementsCalls).toHaveLength(0); + expect(state.pending).toHaveLength(0); + }); + + it('still fires an entry whose pathname matches the current pathname', () => { + host.userAttributes = { [ATTRIBUTE_KEY]: 'gold' }; + state.pending = [{ event: buildEvent(), pathname: PATHNAME }]; + + flushPendingPreselectDispatches(state, host, PATHNAME); + + expect(selectPlacementsCalls).toHaveLength(1); + }); + }); + + describe('dispatchPreselect', () => { + it('calls selectPlacements with the given options and logs nothing on success', async () => { + dispatchPreselect(host, { preselect: true }); + await Promise.resolve(); + + expect(selectPlacementsCalls).toEqual([{ preselect: true }]); + expect(loggedEvents).toHaveLength(0); + }); + + it('logs PRESELECT_DISPATCH_FAILED with the error message when selectPlacements rejects', async () => { + host.selectPlacements = () => Promise.reject(new Error('network down')); + + dispatchPreselect(host, { preselect: true }); + await vi.waitFor(() => expect(loggedEvents).toHaveLength(1)); + + expect(loggedEvents[0]).toMatchObject({ code: 'PRESELECT_DISPATCH_FAILED' }); + expect(loggedEvents[0].message).toContain('network down'); + }); + }); +}); diff --git a/kits/rokt/test/src/tests.spec.ts b/kits/rokt/test/src/tests.spec.ts index 7f19c34cd..abf3be24b 100644 --- a/kits/rokt/test/src/tests.spec.ts +++ b/kits/rokt/test/src/tests.spec.ts @@ -6,6 +6,7 @@ import { removeSelectPlacementsAttributePersistenceDeniedAttributes, } from '../../src/selectPlacementsAttributePersistence'; import { readNamespacedField, writeNamespacedField } from '../../src/storage'; +import { PRESELECTION_CONFIG } from '../../src/preselectionConfig'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -7225,6 +7226,581 @@ describe('Rokt Forwarder', () => { }); }); + describe('#preselect', () => { + const PRESELECT_ACCOUNT_ID = '900001'; + const PRESELECT_PATHNAME = '/preselect-test-path'; + const PRESELECT_TARGET_PAGE_IDENTIFIER = 'preselect-target-page'; + + let selectPlacementsCalls: any[]; + + const pushPreselectConfig = (attributeKeys: string[]) => { + PRESELECTION_CONFIG.push({ + accountId: PRESELECT_ACCOUNT_ID, + pathname: PRESELECT_PATHNAME, + targetPageIdentifier: PRESELECT_TARGET_PAGE_IDENTIFIER, + attributeKeys, + }); + }; + + const firePreselectPageview = (eventAttributes: Record = {}) => { + (window as any).mParticle.forwarder.process({ + EventName: 'Preselect Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: eventAttributes, + }); + }; + + beforeEach(async () => { + selectPlacementsCalls = []; + PRESELECTION_CONFIG.length = 0; + mParticle.loggedEvents = []; + window.localStorage.clear(); + + (window as any).Rokt = new (MockRoktForwarder as any)(); + (window as any).mParticle.Rokt = (window as any).Rokt; + (window as any).mParticle.Rokt.attachKitCalled = false; + (window as any).mParticle.Rokt.attachKit = async (kit: any) => { + (window as any).mParticle.Rokt.attachKitCalled = true; + (window as any).mParticle.Rokt.kit = kit; + }; + (window as any).mParticle.Rokt.filters = { + userAttributesFilters: [], + filterUserAttributes: function (attributes: any) { + return attributes; + }, + filteredUser: { + getMPID: function () { + return '123'; + }, + getUserIdentities: function () { + return { userIdentities: { email: 'test@example.com' } }; + }, + }, + }; + + window.history.pushState({}, '', PRESELECT_PATHNAME); + + await (window as any).mParticle.forwarder.init( + { accountId: PRESELECT_ACCOUNT_ID }, + reportService.cb, + true, + null, + {}, + ); + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle.forwarder.launcher = { + enablePreselection: true, + selectPlacements: function (options: any) { + selectPlacementsCalls.push(options); + }, + }; + }); + + afterEach(() => { + PRESELECTION_CONFIG.length = 0; + window.history.pushState({}, '', '/'); + window.localStorage.clear(); + }); + + it('does not fire preselect when the launcher omits enablePreselection', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + delete (window as any).mParticle.forwarder.launcher.enablePreselection; + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + }); + + it('does not fire preselect when the launcher sets enablePreselection to false', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + (window as any).mParticle.forwarder.launcher.enablePreselection = false; + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + }); + + it("fires an early selectPlacements call with preselect:true, preferring the pageview's own event attribute over user attributes", async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + firePreselectPageview({ loyaltyTier: 'from-event' }); + + await waitForCondition(() => selectPlacementsCalls.length > 0); + + expect(selectPlacementsCalls[0].preselect).toBe(true); + expect(selectPlacementsCalls[0].attributes.loyaltyTier).toBe('from-event'); + expect(selectPlacementsCalls[0].identifier).toBe(PRESELECT_TARGET_PAGE_IDENTIFIER); + expect(selectPlacementsCalls[0].omitUrl).toBe(true); + expect(selectPlacementsCalls[0].cacheMatchKeys).toEqual(['loyaltyTier']); + expect(selectPlacementsCalls[0].attributes.preselectCacheMatchHash).toBeUndefined(); + }); + + it('sends the same configured cacheMatchKeys on the later live selectPlacements call for the same identifier', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + firePreselectPageview({ loyaltyTier: 'from-user-attrs' }); + await waitForCondition(() => selectPlacementsCalls.length > 0); + + await (window as any).mParticle.forwarder.selectPlacements({ + attributes: {}, + identifier: PRESELECT_TARGET_PAGE_IDENTIFIER, + }); + + expect(selectPlacementsCalls).toHaveLength(2); + expect(selectPlacementsCalls[1].cacheMatchKeys).toEqual(selectPlacementsCalls[0].cacheMatchKeys); + }); + + it('omits cacheMatchKeys on a selectPlacements call for an identifier with no preselection config', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + await (window as any).mParticle.forwarder.selectPlacements({ + attributes: {}, + identifier: 'some-other-page', + }); + + expect(selectPlacementsCalls[0].cacheMatchKeys).toBeUndefined(); + }); + + it('omits cacheMatchKeys when preselection is not enabled on the launcher', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + (window as any).mParticle.forwarder.launcher.enablePreselection = false; + + await (window as any).mParticle.forwarder.selectPlacements({ + attributes: {}, + identifier: PRESELECT_TARGET_PAGE_IDENTIFIER, + }); + + expect(selectPlacementsCalls[0].cacheMatchKeys).toBeUndefined(); + }); + + it("falls back to user attributes when the pageview event doesn't carry the configured attribute", async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + firePreselectPageview(); + + await waitForCondition(() => selectPlacementsCalls.length > 0); + + expect(selectPlacementsCalls[0].attributes.loyaltyTier).toBe('from-user-attrs'); + }); + + it('retries and fires preselect once a missing attribute arrives via setUserAttribute after the pageview', async () => { + pushPreselectConfig(['loyaltyTier']); + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + + (window as any).mParticle.forwarder.setUserAttribute('loyaltyTier', 'from-late-setter'); + + await waitForCondition(() => selectPlacementsCalls.length > 0); + + expect(selectPlacementsCalls[0].preselect).toBe(true); + expect(selectPlacementsCalls[0].attributes.loyaltyTier).toBe('from-late-setter'); + }); + + it('does not retry preselect when a setUserAttribute call is unrelated to the configured attributes', async () => { + pushPreselectConfig(['loyaltyTier']); + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + + (window as any).mParticle.forwarder.setUserAttribute('unrelatedAttribute', 'some-value'); + + expect(selectPlacementsCalls).toHaveLength(0); + }); + + it('does not fire a second preselect for the same attributes within the active window', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + firePreselectPageview(); + await waitForCondition(() => selectPlacementsCalls.length > 0); + + const logPlacementDiagnosticSpy = vi.spyOn( + (window as any).mParticle.forwarder.loggingService, + 'logPlacementDiagnostic', + ); + + firePreselectPageview(); + + expect(logPlacementDiagnosticSpy).toHaveBeenCalledWith( + expect.objectContaining({ code: 'PRESELECT_SKIPPED', message: expect.stringContaining('reason=active_preselection') }), + ); + expect(selectPlacementsCalls).toHaveLength(1); + logPlacementDiagnosticSpy.mockRestore(); + }); + + it('fires again within the active window if the attributes have changed', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + firePreselectPageview(); + await waitForCondition(() => selectPlacementsCalls.length > 0); + + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'changed-value' }; + firePreselectPageview(); + + await waitForCondition(() => selectPlacementsCalls.length > 1); + expect(selectPlacementsCalls[1].attributes.loyaltyTier).toBe('changed-value'); + expect(selectPlacementsCalls[1].cacheMatchKeys).toEqual(selectPlacementsCalls[0].cacheMatchKeys); + }); + + it('fires again for the same attributes once the active window has expired', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + firePreselectPageview(); + await waitForCondition(() => selectPlacementsCalls.length > 0); + + const stored = JSON.parse(window.localStorage.getItem('mp-rokt-kit') || '{}'); + const fieldKey = Object.keys(stored).find((key) => key.startsWith('activePreselect:')); + stored[fieldKey as string].expiresAt = Date.now() - 1; + window.localStorage.setItem('mp-rokt-kit', JSON.stringify(stored)); + + firePreselectPageview(); + + await waitForCondition(() => selectPlacementsCalls.length > 1); + expect(selectPlacementsCalls[1].preselect).toBe(true); + }); + + it('does not fire when there is no config entry for the current account/pathname', () => { + (window as any).mParticle.forwarder.process({ + EventName: 'Some Other Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: {}, + }); + + expect(selectPlacementsCalls).toHaveLength(0); + }); + + it('does not fire when the current user has no valid identity', () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + (window as any).mParticle.forwarder.filters.filteredUser = { + getMPID: function () { + return '123'; + }, + getUserIdentities: function () { + return { userIdentities: {} }; + }, + }; + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + }); + + it('does not fire when a configured attribute is missing from both event and user attributes', () => { + pushPreselectConfig(['loyaltyTier', 'missingAttr']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + }); + + it('does not log the customer-facing selectPlacements event for a preselect dispatch, but does for a normal one', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + firePreselectPageview(); + + await waitForCondition(() => selectPlacementsCalls.length > 0); + await Promise.resolve(); + await Promise.resolve(); + expect(mParticle.loggedEvents).toHaveLength(0); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: { test: 'test' } }); + await waitForCondition(() => mParticle.loggedEvents.length > 0); + expect(mParticle.loggedEvents[0].eventName).toBe('selectPlacements'); + }); + + it('queues the preselect dispatch when the kit is not ready, and fires it once the launcher attaches', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + (window as any).mParticle.forwarder.isInitialized = false; + (window as any).mParticle.forwarder.launcher = null; + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + expect((window as any).mParticle.forwarder._preselectState.pending).toHaveLength(1); + + (window as any).mParticle.forwarder.isInitialized = true; + (window as any).mParticle.forwarder.launcher = { + enablePreselection: true, + selectPlacements: function (options: any) { + selectPlacementsCalls.push(options); + }, + }; + (window as any).mParticle.forwarder.flushPendingPreselectDispatches(); + + await waitForCondition(() => selectPlacementsCalls.length > 0); + expect(selectPlacementsCalls[0].preselect).toBe(true); + expect((window as any).mParticle.forwarder._preselectState.pending).toHaveLength(0); + }); + + it('drops a queued preselect at flush, rather than firing it, if the user has navigated to a different page since', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + (window as any).mParticle.forwarder.isInitialized = false; + (window as any).mParticle.forwarder.launcher = null; + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + expect((window as any).mParticle.forwarder._preselectState.pending).toHaveLength(1); + + window.history.pushState({}, '', '/some-other-page'); + + (window as any).mParticle.forwarder.isInitialized = true; + (window as any).mParticle.forwarder.launcher = { + enablePreselection: true, + selectPlacements: function (options: any) { + selectPlacementsCalls.push(options); + }, + }; + (window as any).mParticle.forwarder.flushPendingPreselectDispatches(); + + expect(selectPlacementsCalls).toHaveLength(0); + expect((window as any).mParticle.forwarder._preselectState.pending).toHaveLength(0); + + window.history.pushState({}, '', PRESELECT_PATHNAME); + }); + + it('queues rather than drops the attempt when identity is not yet known at enqueue time, and re-evaluates it at flush', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + (window as any).mParticle.forwarder.isInitialized = false; + (window as any).mParticle.forwarder.launcher = null; + (window as any).mParticle.forwarder.filters = {}; + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + expect((window as any).mParticle.forwarder._preselectState.pending).toHaveLength(1); + + (window as any).mParticle.forwarder.isInitialized = true; + (window as any).mParticle.forwarder.launcher = { + enablePreselection: true, + selectPlacements: function (options: any) { + selectPlacementsCalls.push(options); + }, + }; + (window as any).mParticle.forwarder.filters = { + filteredUser: { + getMPID: function () { + return '123'; + }, + getUserIdentities: function () { + return { userIdentities: { email: 'test@example.com' } }; + }, + }, + }; + + (window as any).mParticle.forwarder.flushPendingPreselectDispatches(); + + await waitForCondition(() => selectPlacementsCalls.length > 0); + expect(selectPlacementsCalls[0].preselect).toBe(true); + expect((window as any).mParticle.forwarder._preselectState.pending).toHaveLength(0); + }); + + it('logs a hit diagnostic when preselect fires', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + const logPlacementDiagnosticSpy = vi.spyOn( + (window as any).mParticle.forwarder.loggingService, + 'logPlacementDiagnostic', + ); + + firePreselectPageview(); + + expect(logPlacementDiagnosticSpy).toHaveBeenCalledWith( + expect.objectContaining({ code: 'PRESELECT_FIRED', message: expect.stringContaining('reason=fired') }), + ); + + await waitForCondition(() => selectPlacementsCalls.length > 0); + logPlacementDiagnosticSpy.mockRestore(); + }); + + it('logs a queued diagnostic (not fired) when the kit is not ready yet', () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + + (window as any).mParticle.forwarder.isInitialized = false; + (window as any).mParticle.forwarder.launcher = null; + + const logPlacementDiagnosticSpy = vi.spyOn( + (window as any).mParticle.forwarder.loggingService, + 'logPlacementDiagnostic', + ); + + firePreselectPageview(); + + expect(logPlacementDiagnosticSpy).toHaveBeenCalledWith( + expect.objectContaining({ code: 'PRESELECT_QUEUED', message: expect.stringContaining('reason=not_ready') }), + ); + expect(logPlacementDiagnosticSpy).not.toHaveBeenCalledWith(expect.objectContaining({ code: 'PRESELECT_FIRED' })); + logPlacementDiagnosticSpy.mockRestore(); + }); + + it('logs a miss diagnostic when there is no valid identity', () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + (window as any).mParticle.forwarder.filters.filteredUser = { + getMPID: function () { + return '123'; + }, + getUserIdentities: function () { + return { userIdentities: {} }; + }, + }; + + const logPlacementDiagnosticSpy = vi.spyOn( + (window as any).mParticle.forwarder.loggingService, + 'logPlacementDiagnostic', + ); + + firePreselectPageview(); + + expect(logPlacementDiagnosticSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'PRESELECT_MISSED', + message: expect.stringContaining('reason=no_valid_identity'), + }), + ); + logPlacementDiagnosticSpy.mockRestore(); + }); + + it('requeues on a guest pageview with no identity, and fires once onUserIdentified reports a real identity', async () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: 'from-user-attrs' }; + (window as any).mParticle.forwarder.filters.filteredUser = { + getMPID: function () { + return '0'; + }, + getUserIdentities: function () { + return { userIdentities: {} }; + }, + }; + + firePreselectPageview(); + + expect(selectPlacementsCalls).toHaveLength(0); + expect((window as any).mParticle.forwarder._preselectState.pending).toHaveLength(1); + + (window as any).mParticle.forwarder.onUserIdentified({ + getMPID: function () { + return '123'; + }, + getUserIdentities: function () { + return { userIdentities: { email: 'guest@example.com' } }; + }, + getAllUserAttributes: function () { + return { loyaltyTier: 'from-user-attrs' }; + }, + }); + + await waitForCondition(() => selectPlacementsCalls.length > 0); + expect(selectPlacementsCalls[0].preselect).toBe(true); + expect((window as any).mParticle.forwarder._preselectState.pending).toHaveLength(0); + }); + + it('logs a miss diagnostic naming the missing attribute key', () => { + pushPreselectConfig(['loyaltyTier']); + + const logPlacementDiagnosticSpy = vi.spyOn( + (window as any).mParticle.forwarder.loggingService, + 'logPlacementDiagnostic', + ); + + firePreselectPageview(); + + expect(logPlacementDiagnosticSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'PRESELECT_MISSED', + message: expect.stringContaining('reason=missing_attribute:loyaltyTier'), + }), + ); + logPlacementDiagnosticSpy.mockRestore(); + }); + + it('logs a miss diagnostic for every missing attribute key, not just the first', () => { + pushPreselectConfig(['loyaltyTier', 'missingAttr']); + + const logPlacementDiagnosticSpy = vi.spyOn( + (window as any).mParticle.forwarder.loggingService, + 'logPlacementDiagnostic', + ); + + firePreselectPageview(); + + expect(logPlacementDiagnosticSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'PRESELECT_MISSED', + message: expect.stringContaining('reason=missing_attribute:loyaltyTier'), + }), + ); + expect(logPlacementDiagnosticSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'PRESELECT_MISSED', + message: expect.stringContaining('reason=missing_attribute:missingAttr'), + }), + ); + expect(selectPlacementsCalls).toHaveLength(0); + logPlacementDiagnosticSpy.mockRestore(); + }); + + it('caps the pending queue at one entry per pathname when a required attribute never arrives', () => { + pushPreselectConfig(['neverArrives']); + + firePreselectPageview(); + firePreselectPageview(); + firePreselectPageview(); + firePreselectPageview(); + firePreselectPageview(); + + expect((window as any).mParticle.forwarder._preselectState.pending).toHaveLength(1); + expect(selectPlacementsCalls).toHaveLength(0); + }); + + it('treats an empty-string attribute value as missing, not present', () => { + pushPreselectConfig(['loyaltyTier']); + (window as any).mParticle.forwarder.userAttributes = { loyaltyTier: '' }; + + const logPlacementDiagnosticSpy = vi.spyOn( + (window as any).mParticle.forwarder.loggingService, + 'logPlacementDiagnostic', + ); + + firePreselectPageview(); + + expect(logPlacementDiagnosticSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'PRESELECT_MISSED', + message: expect.stringContaining('reason=missing_attribute:loyaltyTier'), + }), + ); + expect(selectPlacementsCalls).toHaveLength(0); + logPlacementDiagnosticSpy.mockRestore(); + }); + }); + describe('#_setRoktSessionId', () => { let setIntegrationAttributeCalls: any[]; @@ -7368,6 +7944,26 @@ describe('Rokt Forwarder', () => { expect(result).toBe(mockSelection); }); + + it('should not set integration attribute for a preselect selection', async () => { + const mockSelection = createMockSelection('rokt-session-abc'); + setupLauncherWithSelection(mockSelection); + + await (window as any).mParticle.forwarder.init({ accountId: '123456' }, reportService.cb, true, null, {}); + + await waitForCondition(() => (window as any).mParticle.forwarder.isInitialized); + + await (window as any).mParticle.forwarder.selectPlacements({ + identifier: 'test-placement', + attributes: {}, + preselect: true, + }); + + // Give time for any async operations to settle + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(setIntegrationAttributeCalls.length).toBe(0); + }); }); describe('#parseSettingsString', () => { diff --git a/kits/rokt/test/src/utils.spec.ts b/kits/rokt/test/src/utils.spec.ts index 09d3d855c..c20882efa 100644 --- a/kits/rokt/test/src/utils.spec.ts +++ b/kits/rokt/test/src/utils.spec.ts @@ -92,5 +92,9 @@ describe('utils: type guards', () => { expect(isEmpty(0)).toBe(false); expect(isEmpty(false)).toBe(false); }); + + it('is true for an empty string', () => { + expect(isEmpty('')).toBe(true); + }); }); });