diff --git a/src/roktManager.ts b/src/roktManager.ts index bdb8089e..4a9d48e6 100644 --- a/src/roktManager.ts +++ b/src/roktManager.ts @@ -8,10 +8,13 @@ import { generateUniqueId, isFunction, AttributeValue, + isEmpty, } from "./utils"; import { SDKIdentityApi } from "./identity.interfaces"; import { SDKLoggerApi } from "./sdkRuntimeModels"; import { IStore, LocalSessionAttributes } from "./store"; +import { UserIdentities } from "@mparticle/web-sdk"; +import { IdentityType } from "./types"; // https://docs.rokt.com/developers/integration-guides/web/library/attributes export interface IRoktPartnerAttributes { @@ -96,6 +99,7 @@ export default class RoktManager { private launcherOptions?: IRoktLauncherOptions; private logger: SDKLoggerApi; private domain?: string; + private mappedEmailShaIdentityType?: string | null; /** * Initializes the RoktManager with configuration settings and user data. * @@ -116,7 +120,8 @@ export default class RoktManager { options?: IRoktOptions ): void { const { userAttributeFilters, settings } = roktConfig || {}; - const { placementAttributesMapping } = settings || {}; + const { placementAttributesMapping, hashedEmailUserIdentityType } = settings || {}; + this.mappedEmailShaIdentityType = hashedEmailUserIdentityType?.toLowerCase() ?? null; this.identityService = identityService; this.store = store; @@ -182,23 +187,43 @@ export default class RoktManager { // Get current user identities this.currentUser = this.identityService.getCurrentUser(); const currentUserIdentities = this.currentUser?.getUserIdentities()?.userIdentities || {}; + const currentEmail = currentUserIdentities.email; const newEmail = mappedAttributes.email as string; - // https://go.mparticle.com/work/SQDSDKS-7338 - // Check if email exists and differs - if (newEmail && (!currentEmail || currentEmail !== newEmail)) { - if (currentEmail && currentEmail !== newEmail) { + let currentHashedEmail: string | undefined; + let newHashedEmail: string | undefined; + + // Hashed email identity is valid if it is set to Other-Other10 + if(this.mappedEmailShaIdentityType && IdentityType.getIdentityType(this.mappedEmailShaIdentityType) !== false) { + currentHashedEmail = currentUserIdentities[this.mappedEmailShaIdentityType]; + newHashedEmail = mappedAttributes['emailsha256'] as string || mappedAttributes[this.mappedEmailShaIdentityType] as string || undefined; + } + + const emailChanged = this.hasIdentityChanged(currentEmail, newEmail); + const hashedEmailChanged = this.hasIdentityChanged(currentHashedEmail, newHashedEmail); + + const newIdentities: UserIdentities = {}; + if (emailChanged) { + newIdentities.email = newEmail; + if (newEmail) { this.logger.warning(`Email mismatch detected. Current email, ${currentEmail} differs from email passed to selectPlacements call, ${newEmail}. Proceeding to call identify with ${newEmail}. Please verify your implementation.`); } + } + + if (hashedEmailChanged) { + newIdentities[this.mappedEmailShaIdentityType] = newHashedEmail; + this.logger.warning(`emailsha256 mismatch detected. Current mParticle ${this.mappedEmailShaIdentityType} identity, ${currentHashedEmail}, differs from 'emailsha256' passed to selectPlacements call, ${newHashedEmail}. Proceeding to call identify with ${this.mappedEmailShaIdentityType} set to ${newHashedEmail}. Please verify your implementation`); + } + if (!isEmpty(newIdentities)) { // Call identify with the new user identities try { await new Promise((resolve, reject) => { this.identityService.identify({ userIdentities: { ...currentUserIdentities, - email: newEmail + ...newIdentities } }, () => { resolve(); @@ -321,6 +346,7 @@ export default class RoktManager { this.messageQueue.forEach((message) => { if(!(message.methodName in this) || !isFunction(this[message.methodName])) { this.logger?.error(`RoktManager: Method ${message.methodName} not found`); + return; } @@ -373,4 +399,27 @@ export default class RoktManager { this.messageQueue.delete(messageId); } + + /** + * Checks if an identity value has changed by comparing current and new values + * + * @param {string | undefined} currentValue - The current identity value + * @param {string | undefined} newValue - The new identity value to compare against + * @returns {boolean} True if the identity has changed (new value exists and differs from current), false otherwise + */ + private hasIdentityChanged(currentValue: string | undefined, newValue: string | undefined): boolean { + if (!newValue) { + return false; + } + + if (!currentValue) { + return true; // New value exists but no current value + } + + if (currentValue !== newValue) { + return true; // Values are different + } + + return false; // Values are the same + } } diff --git a/test/jest/roktManager.spec.ts b/test/jest/roktManager.spec.ts index 3c3bccae..58beeedc 100644 --- a/test/jest/roktManager.spec.ts +++ b/test/jest/roktManager.spec.ts @@ -364,6 +364,17 @@ describe('RoktManager', () => { ); expect(roktManager['domain']).toBe(domain); }); + + it('should set mappedEmailShaIdentityType as a lowercase hashedEmailUserIdentityType when passed as a setting', () => { + roktManager.init( + {settings: {hashedEmailUserIdentityType: 'Other5'}} as unknown as IKitConfigs, + undefined, + mockMPInstance.Identity, + mockMPInstance._Store, + mockMPInstance.Logger, + ); + expect(roktManager['mappedEmailShaIdentityType']).toBe('other5'); + }); }); describe('#attachKit', () => { @@ -554,6 +565,7 @@ describe('RoktManager', () => { describe('#selectPlacements', () => { beforeEach(() => { roktManager['currentUser'] = currentUser; + jest.clearAllMocks(); }); it('should call kit.selectPlacements with empty attributes', () => { @@ -1110,6 +1122,234 @@ describe('RoktManager', () => { email: 'new@example.com' } }, expect.any(Function)); + expect(mockMPInstance.Logger.warning).toHaveBeenCalled(); + }); + + it('should not call identify when user has current email but no email is passed to selectPlacements', async () => { + const kit: Partial = { + launcher: { + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + use: jest.fn(), + }, + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + setExtensionData: jest.fn(), + }; + + roktManager['placementAttributesMapping'] = []; + roktManager.kit = kit as IRoktKit; + + const mockIdentity = { + getCurrentUser: jest.fn().mockReturnValue({ + getUserIdentities: () => ({ + userIdentities: { + email: 'existing@example.com' + } + }), + setUserAttributes: jest.fn() + }), + identify: jest.fn().mockImplementation((data, callback) => { + // Call callback with no error to simulate success + callback(); + }) + } as unknown as SDKIdentityApi; + + roktManager['identityService'] = mockIdentity; + + const options: IRoktSelectPlacementsOptions = { + attributes: { + // No email attribute passed + // customAttribute: 'some-value' + } + }; + + await roktManager.selectPlacements(options); + + expect(mockIdentity.identify).not.toHaveBeenCalled(); + expect(mockMPInstance.Logger.warning).not.toHaveBeenCalled(); + }); + + it('should call identify with emailsha256 mapped to other5 when it differs from current user other5 identity', async () => { + const kit: Partial = { + launcher: { + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + use: jest.fn(), + }, + selectPlacements: jest.fn().mockResolvedValue({}), + hashAttributes: jest.fn(), + setExtensionData: jest.fn(), + }; + + roktManager.kit = kit as IRoktKit; + roktManager['mappedEmailShaIdentityType'] ='other5'; + + // Set up fresh mocks for this test + const mockIdentity = { + getCurrentUser: jest.fn().mockReturnValue({ + getUserIdentities: () => ({ + userIdentities: { + other5: 'old-other-value' + } + }), + setUserAttributes: jest.fn() + }), + identify: jest.fn().mockImplementation((data, callback) => { + // Call callback with no error to simulate success + callback(); + }) + } as unknown as SDKIdentityApi; + + roktManager['identityService'] = mockIdentity; + + const options: IRoktSelectPlacementsOptions = { + attributes: { + emailsha256: 'new-emailsha256-value' + } + }; + + await roktManager.selectPlacements(options); + + expect(mockIdentity.identify).toHaveBeenCalledWith({ + userIdentities: { + other5: 'new-emailsha256-value' + } + }, expect.any(Function)); + expect(mockMPInstance.Logger.warning).toHaveBeenCalledWith( + "emailsha256 mismatch detected. Current mParticle other5 identity, old-other-value, differs from 'emailsha256' passed to selectPlacements call, new-emailsha256-value. Proceeding to call identify with other5 set to new-emailsha256-value. Please verify your implementation" + ); + }); + + it('should not call identify when emailsha256 matches current user other5 identity', () => { + const kit: Partial = { + launcher: { + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + use: jest.fn(), + }, + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + setExtensionData: jest.fn(), + }; + + roktManager.kit = kit as IRoktKit; + roktManager['mappedEmailShaIdentityType'] = 'other5'; + + // Set up fresh mocks for this test + const mockIdentity = { + getCurrentUser: jest.fn().mockReturnValue({ + getUserIdentities: () => ({ + userIdentities: { + other5: 'same-emailsha256-value' + } + }), + setUserAttributes: jest.fn() + }), + identify: jest.fn() + }; + + roktManager['identityService'] = mockIdentity as unknown as SDKIdentityApi; + + const options: IRoktSelectPlacementsOptions = { + attributes: { + emailsha256: 'same-emailsha256-value' + } + }; + + roktManager.selectPlacements(options); + + expect(mockIdentity.identify).not.toHaveBeenCalled(); + expect(mockMPInstance.Logger.warning).not.toHaveBeenCalled(); + }); + + it('should call identify with emailsha256 mapped to other when current user has no other identity', async () => { + const kit: Partial = { + launcher: { + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + use: jest.fn(), + }, + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + setExtensionData: jest.fn(), + }; + + roktManager.kit = kit as IRoktKit; + roktManager['mappedEmailShaIdentityType'] = 'other'; + + const mockIdentity = { + getCurrentUser: jest.fn().mockReturnValue({ + getUserIdentities: () => ({ + userIdentities: {} + }), + setUserAttributes: jest.fn() + }), + identify: jest.fn().mockImplementation((data, callback) => { + // Call callback with no error to simulate success + callback(); + }) + } as unknown as SDKIdentityApi; + + roktManager['identityService'] = mockIdentity; + + const options: IRoktSelectPlacementsOptions = { + attributes: { + emailsha256: 'new-emailsha256-value' + } + }; + + await roktManager.selectPlacements(options); + + expect(mockIdentity.identify).toHaveBeenCalledWith({ + userIdentities: { + other: 'new-emailsha256-value' + } + }, expect.any(Function)); + expect(mockMPInstance.Logger.warning).toHaveBeenCalledWith( + "emailsha256 mismatch detected. Current mParticle other identity, undefined, differs from 'emailsha256' passed to selectPlacements call, new-emailsha256-value. Proceeding to call identify with other set to new-emailsha256-value. Please verify your implementation" + ); + }); + + it('should not call identify when current user has other identity but emailsha256 is null', () => { + const kit: Partial = { + launcher: { + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + use: jest.fn(), + }, + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + setExtensionData: jest.fn(), + }; + + roktManager.kit = kit as IRoktKit; + roktManager['hashedEmailUserIdentityType'] = 'Other'; + + // Set up fresh mocks for this test + const mockIdentity = { + getCurrentUser: jest.fn().mockReturnValue({ + getUserIdentities: () => ({ + userIdentities: { + other: 'existing-other-value' + } + }), + setUserAttributes: jest.fn() + }), + identify: jest.fn() + }; + + roktManager['identityService'] = mockIdentity as unknown as SDKIdentityApi; + + const options: IRoktSelectPlacementsOptions = { + attributes: { + // emailsha256 is not provided (null/undefined) + } + }; + + roktManager.selectPlacements(options); + + expect(mockIdentity.identify).not.toHaveBeenCalled(); expect(mockMPInstance.Logger.warning).not.toHaveBeenCalled(); }); @@ -1463,4 +1703,70 @@ describe('RoktManager', () => { }); }); + describe('#hasIdentityChanged', () => { + it('should return false when newValue is null', () => { + const result = roktManager['hasIdentityChanged']('current@example.com', null); + expect(result).toBe(false); + }); + + it('should return false when newValue is undefined', () => { + const result = roktManager['hasIdentityChanged']('current@example.com', undefined); + expect(result).toBe(false); + }); + + it('should return false when newValue is empty string', () => { + const result = roktManager['hasIdentityChanged']('current@example.com', ''); + expect(result).toBe(false); + }); + + it('should return true when currentValue is null and newValue exists', () => { + const result = roktManager['hasIdentityChanged'](null, 'new@example.com'); + expect(result).toBe(true); + }); + + it('should return true when currentValue is undefined and newValue exists', () => { + const result = roktManager['hasIdentityChanged'](undefined, 'new@example.com'); + expect(result).toBe(true); + }); + + it('should return true when currentValue is empty string and newValue exists', () => { + const result = roktManager['hasIdentityChanged']('', 'new@example.com'); + expect(result).toBe(true); + }); + + it('should return true when currentValue and newValue are different', () => { + const result = roktManager['hasIdentityChanged']('old@example.com', 'new@example.com'); + expect(result).toBe(true); + }); + + it('should return false when currentValue and newValue are the same', () => { + const result = roktManager['hasIdentityChanged']('same@example.com', 'same@example.com'); + expect(result).toBe(false); + }); + + it('should return false when both currentValue and newValue are null', () => { + const result = roktManager['hasIdentityChanged'](null, null); + expect(result).toBe(false); + }); + + it('should return false when both currentValue and newValue are undefined', () => { + const result = roktManager['hasIdentityChanged'](undefined, undefined); + expect(result).toBe(false); + }); + + it('should return false when both currentValue and newValue are empty strings', () => { + const result = roktManager['hasIdentityChanged']('', ''); + expect(result).toBe(false); + }); + + it('should handle whitespace-only strings as valid values', () => { + const result = roktManager['hasIdentityChanged']('old@example.com', ' '); + expect(result).toBe(true); + }); + + it('should be case sensitive', () => { + const result = roktManager['hasIdentityChanged']('test@example.com', 'TEST@EXAMPLE.COM'); + expect(result).toBe(true); + }); + }); }); \ No newline at end of file