-
Notifications
You must be signed in to change notification settings - Fork 306
Audience membership update - Core + multiple destinations #3658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 9 commits
c303690
7992933
0a65bae
3e7cc58
c2f52f6
127121f
e5ce255
6e308a1
3141486
42f5987
b0add83
a7d2a8b
ab36b5a
25a89cd
acf98ef
6f1f38c
29f4186
8c533df
f86ee88
0242bc7
a89a42b
616d193
ca04e80
87a57e0
ec0a6be
2c76e47
b82ddbe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| import { createTestIntegration } from '../create-test-integration' | ||
| import { resolveAudienceMembership } from '../audience-membership' | ||
| import { DestinationDefinition } from '../destination-kit' | ||
| import { ExecuteInput } from '../destination-kit/types' | ||
| import { JSONObject } from '../json-object' | ||
|
|
||
| // --- Unit tests for the helper --- | ||
|
|
||
| describe('resolveAudienceMembership', () => { | ||
| it('returns undefined when rawData is undefined', () => { | ||
| expect(resolveAudienceMembership(undefined)).toBeUndefined() | ||
| }) | ||
|
|
||
| it('returns undefined when computation_class is not audience', () => { | ||
| expect( | ||
| resolveAudienceMembership({ | ||
| context: { personas: { computation_class: 'computed_trait', computation_key: 'my_audience' } }, | ||
| properties: { my_audience: true } | ||
| }) | ||
| ).toBeUndefined() | ||
| }) | ||
|
|
||
| it('returns undefined when computation_class is missing', () => { | ||
| expect( | ||
| resolveAudienceMembership({ | ||
| context: { personas: { computation_key: 'my_audience' } }, | ||
| properties: { my_audience: true } | ||
| }) | ||
| ).toBeUndefined() | ||
| }) | ||
|
|
||
| it('returns undefined when computation_key is missing', () => { | ||
| expect( | ||
| resolveAudienceMembership({ | ||
| context: { personas: { computation_class: 'audience' } }, | ||
| properties: { my_audience: true } | ||
| }) | ||
| ).toBeUndefined() | ||
| }) | ||
|
|
||
| it('returns undefined when the membership value is not a boolean', () => { | ||
| expect( | ||
| resolveAudienceMembership({ | ||
| context: { personas: { computation_class: 'audience', computation_key: 'my_audience' } }, | ||
| properties: { my_audience: 'true' } | ||
| }) | ||
| ).toBeUndefined() | ||
| }) | ||
|
|
||
| it('returns undefined when the computation_key is not present in properties', () => { | ||
| expect( | ||
| resolveAudienceMembership({ | ||
| context: { personas: { computation_class: 'audience', computation_key: 'my_audience' } }, | ||
| properties: {} | ||
| }) | ||
| ).toBeUndefined() | ||
| }) | ||
|
|
||
| it('returns true when the user is being added to the audience', () => { | ||
| expect( | ||
| resolveAudienceMembership({ | ||
| context: { personas: { computation_class: 'audience', computation_key: 'my_audience' } }, | ||
| properties: { my_audience: true } | ||
| }) | ||
| ).toBe(true) | ||
| }) | ||
|
|
||
| it('returns false when the user is being removed from the audience', () => { | ||
| expect( | ||
| resolveAudienceMembership({ | ||
| context: { personas: { computation_class: 'audience', computation_key: 'my_audience' } }, | ||
| properties: { my_audience: false } | ||
| }) | ||
| ).toBe(false) | ||
| }) | ||
|
|
||
| it('returns undefined when context is missing', () => { | ||
| expect( | ||
| resolveAudienceMembership({ | ||
| properties: { my_audience: true } | ||
| }) | ||
| ).toBeUndefined() | ||
| }) | ||
|
|
||
| it('returns undefined when properties is missing', () => { | ||
| expect( | ||
| resolveAudienceMembership({ | ||
| context: { personas: { computation_class: 'audience', computation_key: 'my_audience' } } | ||
| }) | ||
| ).toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| // --- Integration tests verifying audienceMembership is set on ExecuteInput --- | ||
|
|
||
| describe('audienceMembership on ExecuteInput in perform()', () => { | ||
| it('is true when user is being added to an audience', async () => { | ||
| let capturedData: ExecuteInput<JSONObject, JSONObject> | undefined | ||
|
|
||
| const destination: DestinationDefinition<JSONObject> = { | ||
| name: 'Test Destination', | ||
| mode: 'cloud', | ||
| authentication: { scheme: 'custom', fields: {} }, | ||
| actions: { | ||
| testAction: { | ||
| title: 'Test Action', | ||
| description: 'Test', | ||
| fields: { | ||
| userId: { label: 'User ID', description: 'The user ID', type: 'string' } | ||
| }, | ||
| perform: (_request, data) => { | ||
| capturedData = data as ExecuteInput<JSONObject, JSONObject> | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const testDestination = createTestIntegration(destination) | ||
| await testDestination.testAction('testAction', { | ||
| mapping: { userId: { '@path': '$.userId' } }, | ||
| event: { | ||
| type: 'identify', | ||
| userId: 'user-1', | ||
| context: { | ||
| personas: { computation_class: 'audience', computation_key: 'my_audience' } | ||
| }, | ||
| properties: { my_audience: true } | ||
| } | ||
|
||
| }) | ||
|
|
||
| expect(capturedData?.audienceMembership).toBe(true) | ||
| }) | ||
|
|
||
| it('is false when user is being removed from an audience', async () => { | ||
| let capturedData: ExecuteInput<JSONObject, JSONObject> | undefined | ||
|
|
||
| const destination: DestinationDefinition<JSONObject> = { | ||
| name: 'Test Destination', | ||
| mode: 'cloud', | ||
| authentication: { scheme: 'custom', fields: {} }, | ||
| actions: { | ||
| testAction: { | ||
| title: 'Test Action', | ||
| description: 'Test', | ||
| fields: { | ||
| userId: { label: 'User ID', description: 'The user ID', type: 'string' } | ||
| }, | ||
| perform: (_request, data) => { | ||
| capturedData = data as ExecuteInput<JSONObject, JSONObject> | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const testDestination = createTestIntegration(destination) | ||
| await testDestination.testAction('testAction', { | ||
| mapping: { userId: { '@path': '$.userId' } }, | ||
| event: { | ||
| type: 'identify', | ||
| userId: 'user-1', | ||
| context: { | ||
| personas: { computation_class: 'audience', computation_key: 'my_audience' } | ||
| }, | ||
| properties: { my_audience: false } | ||
| } | ||
| }) | ||
|
|
||
| expect(capturedData?.audienceMembership).toBe(false) | ||
| }) | ||
|
|
||
| it('is undefined for non-audience events', async () => { | ||
| let capturedData: ExecuteInput<JSONObject, JSONObject> | undefined | ||
|
|
||
| const destination: DestinationDefinition<JSONObject> = { | ||
| name: 'Test Destination', | ||
| mode: 'cloud', | ||
| authentication: { scheme: 'custom', fields: {} }, | ||
| actions: { | ||
| testAction: { | ||
| title: 'Test Action', | ||
| description: 'Test', | ||
| fields: { | ||
| userId: { label: 'User ID', description: 'The user ID', type: 'string' } | ||
| }, | ||
| perform: (_request, data) => { | ||
| capturedData = data as ExecuteInput<JSONObject, JSONObject> | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const testDestination = createTestIntegration(destination) | ||
| await testDestination.testAction('testAction', { | ||
| mapping: { userId: { '@path': '$.userId' } }, | ||
| event: { | ||
| type: 'track', | ||
| userId: 'user-1', | ||
| properties: { foo: 'bar' } | ||
| } | ||
| }) | ||
|
|
||
| expect(capturedData?.audienceMembership).toBeUndefined() | ||
| }) | ||
|
|
||
| it('does not modify the payload object', async () => { | ||
| let capturedData: ExecuteInput<JSONObject, JSONObject> | undefined | ||
|
|
||
| const destination: DestinationDefinition<JSONObject> = { | ||
| name: 'Test Destination', | ||
| mode: 'cloud', | ||
| authentication: { scheme: 'custom', fields: {} }, | ||
| actions: { | ||
| testAction: { | ||
| title: 'Test Action', | ||
| description: 'Test', | ||
| fields: { | ||
| userId: { label: 'User ID', description: 'The user ID', type: 'string' } | ||
| }, | ||
| perform: (_request, data) => { | ||
| capturedData = data as ExecuteInput<JSONObject, JSONObject> | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const testDestination = createTestIntegration(destination) | ||
| await testDestination.testAction('testAction', { | ||
| mapping: { userId: { '@path': '$.userId' } }, | ||
| event: { | ||
| type: 'identify', | ||
| userId: 'user-1', | ||
| context: { | ||
| personas: { computation_class: 'audience', computation_key: 'my_audience' } | ||
| }, | ||
| properties: { my_audience: true } | ||
| } | ||
| }) | ||
|
|
||
| expect(capturedData?.audienceMembership).toBe(true) | ||
| expect(capturedData?.payload).not.toHaveProperty('audienceMembership') | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import { InputData } from './mapping-kit' | ||
| import { SyncMode, AudienceMembership } from './destination-kit/types' | ||
|
|
||
| /** | ||
| * Resolves whether a user is being added to or removed from an audience. | ||
| * Returns `true` if the user is being added, `false` if being removed, | ||
| * or `undefined` if the payload is not an audience computation or membership cannot be determined. | ||
| */ | ||
| export function resolveAudienceMembership(rawData: InputData | undefined, syncMode?: SyncMode): AudienceMembership { | ||
| if (!rawData) return undefined | ||
|
|
||
| const engageMembership = engageAudienceMembership(rawData) | ||
|
|
||
| if (typeof engageMembership === 'boolean') { | ||
| return engageMembership | ||
| } | ||
|
|
||
| const retlMembership = retlAudienceMembership(rawData, syncMode) | ||
|
|
||
| if (typeof retlMembership === 'boolean') { | ||
| return retlMembership | ||
| } | ||
|
|
||
| return undefined | ||
| } | ||
| /** | ||
| * Resolves whether a user is being added to or removed from an audience based on Engage data. | ||
| * Returns `true` if the user is being added, `false` if being removed, | ||
| * or `undefined` if the payload is not an audience computation or membership cannot be determined. | ||
| */ | ||
| export function engageAudienceMembership(rawData: InputData | undefined): AudienceMembership { | ||
| if (!rawData) return undefined | ||
|
|
||
| const { | ||
| context: { | ||
| personas: { | ||
| computation_class = '', | ||
| computation_key = '' | ||
| } = {} | ||
| } = {}, | ||
| properties = {}, | ||
| traits = {}, | ||
| type = '' | ||
| } = rawData as { | ||
| context?: { personas?: { computation_class?: string; computation_key?: string } } | ||
| properties?: Record<string, unknown> | ||
| traits?: Record<string, unknown> | ||
| type?: string | ||
| } | ||
|
|
||
| if (!['audience', 'journey_step'].includes(computation_class)) return undefined | ||
| if (!computation_key) return undefined | ||
|
|
||
| let membershipValue: boolean | undefined | ||
|
|
||
| if (type === 'identify' && typeof traits?.[computation_key] === 'boolean') { | ||
| membershipValue = traits[computation_key] | ||
| } | ||
| else if (type === 'track' && typeof properties?.[computation_key] === 'boolean') { | ||
| membershipValue = properties[computation_key] | ||
| } | ||
|
|
||
| return typeof membershipValue === 'boolean' ? membershipValue : undefined | ||
| } | ||
|
|
||
| /** | ||
| * Resolves whether a user is being added to or removed from an audience based on RETL data. | ||
| * Returns `true` if the user is being added, `false` if being removed, | ||
| * or `undefined` if the payload is not an audience computation or membership cannot be determined. | ||
| */ | ||
| export function retlAudienceMembership(rawData: InputData | undefined, syncMode?: SyncMode): AudienceMembership { | ||
| if (!rawData || !syncMode) return undefined | ||
|
|
||
| const { | ||
| event = '', | ||
| type = '' | ||
| } = rawData as { | ||
| event?: string | ||
| type?: string | ||
| } | ||
|
|
||
| if (type !== 'track') return undefined | ||
|
|
||
| if( | ||
| (syncMode === 'add' && ['new'].includes(event)) || | ||
| (syncMode === 'update' && ['updated'].includes(event)) || | ||
| (syncMode === 'upsert' && ['new', 'updated'].includes(event)) || | ||
| (syncMode === 'mirror' && ['new', 'updated'].includes(event)) | ||
| ){ | ||
| return true | ||
| } | ||
| else if ( | ||
| (syncMode === 'delete' && ['deleted'].includes(event)) || | ||
| (syncMode === 'mirror' && ['deleted'].includes(event)) | ||
| ){ | ||
| return false | ||
| } | ||
|
|
||
| return undefined | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These unit tests call
resolveAudienceMembershipwithout an eventtype, and expect membership to be inferred fromproperties. The current implementation only resolves Engage membership whentypeisidentify(fromtraits) ortrack(fromproperties), so these assertions will fail. Update the test inputs to includetypeand put the boolean ontraitsforidentify(or switch totrack+properties).