diff --git a/packages/core/src/destination-kit/index.ts b/packages/core/src/destination-kit/index.ts index 99473fe05b8..2d82a87e402 100644 --- a/packages/core/src/destination-kit/index.ts +++ b/packages/core/src/destination-kit/index.ts @@ -239,6 +239,7 @@ interface AuthSettings { interface RefreshAuthSettings { settings: Settings auth: OAuth2ClientCredentials + features?: Features } interface Authentication { @@ -564,7 +565,8 @@ export class Destination { async refreshAccessToken( settings: Settings, oauthData: OAuth2ClientCredentials, - synchronizeRefreshAccessToken?: () => Promise + synchronizeRefreshAccessToken?: () => Promise, + features?: Features ): Promise { if (!(this.authentication?.scheme === 'oauth2' || this.authentication?.scheme === 'oauth-managed')) { throw new IntegrationError( @@ -590,7 +592,7 @@ export class Destination { // Invoke synchronizeRefreshAccessToken handler if synchronizeRefreshAccessToken option is passed. // This will ensure that there is only one active refresh happening at a time. await synchronizeRefreshAccessToken?.() - return this.authentication.refreshAccessToken(requestClient, { settings, auth: oauthData }) + return this.authentication.refreshAccessToken(requestClient, { settings, auth: oauthData, features }) } private partnerAction( @@ -1062,7 +1064,8 @@ export class Destination { const newTokens = await this.refreshAccessToken( destinationSettings, oauthSettings, - options?.synchronizeRefreshAccessToken + options?.synchronizeRefreshAccessToken, + options?.features ) if (!newTokens) { diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts new file mode 100644 index 00000000000..5d041631673 --- /dev/null +++ b/packages/core/src/e2e-helpers.ts @@ -0,0 +1,252 @@ +import type { SegmentEvent } from './segment-event' +import type { JSONValue } from './json-object' +import type { + E2EAudienceEventBase, + E2EEngageAudienceEventOptions, + E2EEngageAudienceEvent, + E2EJourneysV1AudienceEventOptions, + E2EJourneysV1AudienceTrackEvent, + E2EJourneysV2AudienceEventOptions, + E2EJourneysV2AudienceTrackEvent, + E2ERetlAudienceEventOptions, + E2ERetlAudienceTrackEvent +} from './e2e-types' + +/* + * Regular Segment Connections event + */ +export function createE2EEvent( + type: SegmentEvent['type'], + name?: string, + overrides?: Partial> +): SegmentEvent { + if (type === 'track') { + return { + type, + event: name, + messageId: '$guid', + timestamp: '$now', + ...overrides + } + } + + if (type === 'page' || type === 'screen') { + return { + type, + name, + messageId: '$guid', + timestamp: '$now', + ...overrides + } + } + + if (name) { + throw new Error( + `createE2EEvent: "name" is not supported for "${type}" events. Only track, page, and screen accept a name.` + ) + } + + return { + type, + messageId: '$guid', + timestamp: '$now', + ...overrides + } +} + +function buildAudienceEventBase(options: E2EAudienceEventBase) { + const { + computationKey, + computationId, + externalAudienceId, + userId, + anonymousId, + email, + audienceFields, + includeContextTraits = true + } = options + return { + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + context: { + personas: { + computation_class: 'audience', + computation_key: computationKey, + computation_id: computationId, + ...(externalAudienceId && { external_audience_id: externalAudienceId }) + }, + ...(audienceFields && { audienceFields }), + ...(includeContextTraits && email && { traits: { email } }) + } + } +} + +/* + * Engage Audience event + * Supports identify and track events + */ +export function createE2EEngageAudienceEvent( + options: E2EEngageAudienceEventOptions +): E2EEngageAudienceEvent { + const { type, action, computationKey, eventName, email, enrichedTraits } = options + + if (type === 'identify' && eventName) { + throw new Error('createE2EEngageAudienceEvent: "eventName" is not supported for identify events.') + } + + const membership = action === 'add' + const base = buildAudienceEventBase({ ...options, includeContextTraits: type === 'track' }) + + const event = { + ...base, + ...(type === 'track' && { + type: 'track', + event: eventName ?? 'Test Engage Audience Membership Event', + properties: { + [computationKey]: membership, + ...(enrichedTraits as { [k: string]: JSONValue }) + } + }), + ...(type === 'identify' && { + type: 'identify', + traits: { + [computationKey]: membership, + ...(enrichedTraits as { [k: string]: JSONValue }), + ...(email && { email }) + } + }) + } + + return event as E2EEngageAudienceEvent +} + +/* + * Journeys V1 events (preset journeys_step_entered_track) do not have properties[] value. + * All Journeys V1 events enter the user to the audience, never remove them. + * Only track events supported + */ +export function createE2EJourneysV1AudienceEvent( + options: E2EJourneysV1AudienceEventOptions +): E2EJourneysV1AudienceTrackEvent { + const { + computationKey, + computationId, + externalAudienceId, + userId, + anonymousId, + email, + audienceFields, + enrichedTraits + } = options + + const event = { + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + type: 'track', + event: 'Audience Entered', + properties: { + ...(enrichedTraits as { [k: string]: JSONValue }) + }, + context: { + personas: { + computation_class: 'journey_step', + computation_key: computationKey, + computation_id: computationId, + ...(externalAudienceId && { external_audience_id: externalAudienceId }) + }, + ...(audienceFields && { audienceFields }), + ...(email && { traits: { email } }) + } + } + + return event as E2EJourneysV1AudienceTrackEvent +} + +/* + * Journeys V2 events use computation_class 'journey_step' (not 'audience') and carry + * journey_context / journey_metadata in properties alongside properties[], + * the membership boolean (true = entering the step / add, false = exiting / remove). + * Only track events supported. + */ +export function createE2EJourneysV2AudienceEvent( + options: E2EJourneysV2AudienceEventOptions +): E2EJourneysV2AudienceTrackEvent { + const { + action = 'add', + computationKey, + computationId, + externalAudienceId, + eventName, + journeyId, + journeyName, + userId, + anonymousId, + email, + audienceFields, + enrichedTraits + } = options + + const membership = action === 'add' + + const event = { + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + type: 'track', + event: eventName ?? 'Test Journeys V2 Audience Membership Event', + properties: { + [computationKey]: membership, + journey_context: { + [computationKey]: {} + }, + journey_metadata: { + epoch_id: '$guid', + journey_id: journeyId ?? 'jver_e2e_journey', + journey_name: journeyName ?? 'e2e journey' + }, + ...(enrichedTraits as { [k: string]: JSONValue }) + }, + context: { + personas: { + computation_class: 'journey_step', + computation_key: computationKey, + computation_id: computationId, + ...(externalAudienceId && { external_audience_id: externalAudienceId }) + }, + ...(audienceFields && { audienceFields }), + ...(email && { traits: { email } }) + } + } + + return event as E2EJourneysV2AudienceTrackEvent +} + +/* + * Reverse ETL Audience event + * Same payload structure as Engage track events but uses RETL-specific event names: 'new', 'updated', 'deleted' + * Only track events supported + */ +export function createE2ERetlAudienceEvent( + options: E2ERetlAudienceEventOptions +): E2ERetlAudienceTrackEvent { + const { eventName, computationKey, enrichedTraits } = options + const membership = eventName !== 'deleted' + const base = buildAudienceEventBase(options) + + const event = { + ...base, + type: 'track', + event: eventName, + properties: { + [computationKey]: membership, + ...(enrichedTraits as { [k: string]: JSONValue }) + } + } + + return event as E2ERetlAudienceTrackEvent +} diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts new file mode 100644 index 00000000000..accbef68fc5 --- /dev/null +++ b/packages/core/src/e2e-types.ts @@ -0,0 +1,362 @@ +import type { SegmentEvent } from './segment-event' +import type { JSONObject, JSONValue } from './json-object' + +export type E2EExpectation = E2ESuccessExpectation | E2EFailureExpectation | E2EErrorExpectation + +/** + * The HTTP request was sent and the destination API returned a 2xx response. + */ +export interface E2ESuccessExpectation { + status: 'success' + httpStatus?: E2EHttpSuccessCode + bodyContains?: string + /** Partial deep match against the JSON response body. Arrays must match length and each item is partial-matched. */ + jsonContains?: unknown +} + +/** + * The HTTP request was sent and the destination API returned a non-2xx response. + * Use this to verify that the destination rejects specific inputs (e.g., bad auth, invalid payload). + */ +export interface E2EFailureExpectation { + status: 'failure' + httpStatus: E2EHttpFailureCode + bodyContains?: string + /** Partial deep match against the JSON response body. Arrays must match length and each item is partial-matched. */ + jsonContains?: unknown +} + +/** + * Our action code threw before making an HTTP request. + * The request never left. Use this to verify client-side validation + * (e.g., PayloadValidationError when required fields are missing). + */ +export interface E2EErrorExpectation { + status: 'error' + errorType: string + errorMessage?: string +} + +/** + * Dynamic value markers that the runner resolves at execution time. + * + * - '$now' → current ISO 8601 timestamp (e.g., '2026-05-28T14:32:01.000Z') + * - '$guid' → fresh UUID v4, unique each occurrence + * - '$guid:' → UUID v4, consistent within a single fixture execution. + * All occurrences of the same name resolve to the same value. + * - '$externalAudienceId' → resolved after createAudience step returns the destination's audience ID + */ +export type E2EDynamicValue = '$now' | '$guid' | `$guid:${string}` | '$externalAudienceId' + +export type E2EExecutionMode = 'single' | 'batch' | 'batchWithMultistatus' + +export type E2EFixture = E2ESingleFixture | E2EBatchFixture | E2EBatchWithMultistatusFixture + +export interface E2EBaseFixture { + /** Human-readable name for the test case, shown in runner output. */ + description: string + /** FQL query that determines whether the event matches this subscription. */ + subscribe: string + /** Mapping kit directives that transform the event into the action's payload shape. */ + mapping: JSONObject + /** The expected outcome of executing this fixture. */ + expect: E2EExpectation + /** Hint shown in verbose mode when this fixture fails. Helps developers diagnose common issues. */ + verboseFailureHint?: string + /** Feature flags passed to the action, to exercise flag-gated code branches end-to-end. */ + features?: Record + /** + * Max times the runner re-runs this fixture if it fails, with exponential backoff between attempts. + * Overrides the run-level retry default. Useful for destinations with eventual consistency + * (e.g. writes to a freshly-created audience that briefly return a transient error). + */ + retries?: number +} + +export interface E2ESingleFixture extends E2EBaseFixture { + /** Executes via onEvent() with a single event. */ + mode: 'single' + /** + * The Segment event (track, identify, page, screen, etc.) sent into the action. + * String values may use dynamic markers ($now, $guid, $guid:) that the + * runner resolves before execution. + */ + event: SegmentEvent +} + +export interface E2EBatchFixture extends E2EBaseFixture { + /** Executes via onBatch() with multiple events. Response is a standard HTTP response. */ + mode: 'batch' + /** + * Array of Segment events sent into the action as a batch. + * String values may use dynamic markers ($now, $guid, $guid:) that the + * runner resolves before execution. + */ + events: SegmentEvent[] +} + +export interface E2EBatchWithMultistatusFixture extends E2EBaseFixture { + /** Executes via onBatch(). Response is a per-item MultiStatusResponse array. */ + mode: 'batchWithMultistatus' + /** + * Array of Segment events sent into the action as a batch. + * String values may use dynamic markers ($now, $guid, $guid:) that the + * runner resolves before execution. + */ + events: SegmentEvent[] +} + +export interface E2EAudienceEventBase { + computationKey: string + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + includeContextTraits?: boolean +} + +export interface E2EEngageAudienceEventOptions { + type: 'track' | 'identify' + action: 'add' | 'remove' + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + eventName?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2EJourneysV1AudienceEventOptions { + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2EJourneysV2AudienceEventOptions { + /** Whether the user is entering ('add') or exiting ('remove') the journey step. Sets properties[computationKey]. Defaults to 'add'. */ + action?: 'add' | 'remove' + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + eventName?: string + journeyId?: string + journeyName?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2ERetlAudienceEventOptions { + eventName: 'new' | 'updated' | 'deleted' + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2ERetlAudienceTrackEvent extends SegmentEvent { + type: 'track' + event: 'new' | 'updated' | 'deleted' + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'audience' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + traits?: { email?: string } + audienceFields?: Record + } + properties: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export interface E2EJourneysV1AudienceTrackEvent extends SegmentEvent { + type: 'track' + event: "Audience Entered" + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'journey_step' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + traits?: { email?: string } + audienceFields?: Record + } + properties: { [k: string]: JSONValue } +} + +export interface E2EJourneysV2AudienceTrackEvent extends SegmentEvent { + type: 'track' + event: string + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'journey_step' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + traits?: { email?: string } + audienceFields?: Record + } + properties: { + journey_context: { [k: string]: JSONValue } + journey_metadata: { journey_id: string; journey_name: string; [k: string]: JSONValue } + } & { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export interface E2EEngageAudienceTrackEvent extends SegmentEvent { + type: 'track' + event: string + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'audience' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + traits?: { email?: string } + audienceFields?: Record + } + properties: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export interface E2EEngageAudienceIdentifyEvent extends SegmentEvent { + type: 'identify' + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'audience' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + audienceFields?: Record + } + traits: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export type E2EEngageAudienceEvent = + | E2EEngageAudienceTrackEvent + | E2EEngageAudienceIdentifyEvent + +export interface E2ESettingsSecretValue { + $env: string +} + +export interface E2ESettingsObject { + [key: string]: string | number | boolean | E2ESettingsSecretValue | E2ESettingsObject +} + +export interface E2EDestinationConfig { + settings: E2ESettingsObject +} + +export interface E2ETeardownContext { + settings: Record +} + +export interface E2ETeardownAudienceContext extends E2ETeardownContext { + externalAudienceId: string + audienceSettings: Record +} + +export interface E2EAudienceConfig { + /** Name of the audience to create/test against. Used as the audienceName param for createAudience. */ + audienceName: string + /** Audience-level settings passed to createAudience and getAudience (e.g., id_type, owner_email). */ + audienceSettings: Record + /** When true, the runner calls createAudience before executing fixtures and captures the externalAudienceId. */ + createAudience: boolean + /** When true, the runner calls getAudience after fixtures to verify the audience still exists. */ + getAudience: boolean + /** When a function, the runner calls it after all tests to clean up the audience. Set to false to skip. */ + teardown: false | ((context: E2ETeardownAudienceContext) => Promise) +} + +export interface E2EAudienceDestinationConfig extends E2EDestinationConfig { + audience: E2EAudienceConfig +} + +export type E2EHttpSuccessCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 + +export type E2EHttpFailureCode = + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 306 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 421 + | 422 + | 423 + | 424 + | 425 + | 426 + | 428 + | 429 + | 431 + | 451 + | 499 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 506 + | 507 + | 508 + | 509 + | 510 + | 511 + | 529 + | 598 + | 599 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26a3884eaba..2694896e2a6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,3 +112,42 @@ export { export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' + +export { + createE2EEvent, + createE2EEngageAudienceEvent, + createE2EJourneysV1AudienceEvent, + createE2EJourneysV2AudienceEvent, + createE2ERetlAudienceEvent +} from './e2e-helpers' +export type { + E2EFixture, + E2EBaseFixture, + E2ESingleFixture, + E2EBatchFixture, + E2EBatchWithMultistatusFixture, + E2EExecutionMode, + E2EExpectation, + E2ESuccessExpectation, + E2EFailureExpectation, + E2EErrorExpectation, + E2EDestinationConfig, + E2EAudienceDestinationConfig, + E2EAudienceConfig, + E2ETeardownContext, + E2ETeardownAudienceContext, + E2ESettingsSecretValue, + E2EDynamicValue, + E2EEngageAudienceEventOptions, + E2EEngageAudienceEvent, + E2EEngageAudienceTrackEvent, + E2EEngageAudienceIdentifyEvent, + E2EJourneysV1AudienceEventOptions, + E2EJourneysV1AudienceTrackEvent, + E2EJourneysV2AudienceEventOptions, + E2EJourneysV2AudienceTrackEvent, + E2ERetlAudienceEventOptions, + E2ERetlAudienceTrackEvent, + E2EHttpSuccessCode, + E2EHttpFailureCode +} from './e2e-types' diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts new file mode 100644 index 00000000000..ddb88bf9ac3 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts @@ -0,0 +1,18 @@ +import type { E2EAudienceDestinationConfig } from '@segment/actions-core' + +export const config: E2EAudienceDestinationConfig = { + settings: { + api_key: { $env: 'E2E_AMPLITUDE_COHORTS_API_KEY' }, + secret_key: { $env: 'E2E_AMPLITUDE_COHORTS_SECRET_KEY' }, + app_id: { $env: 'E2E_AMPLITUDE_COHORTS_APP_ID' }, + default_owner_email: { $env: 'E2E_AMPLITUDE_COHORTS_OWNER_EMAIL' }, + endpoint: 'north_america' + }, + audience: { + audienceName: 'e2e_test_audience_track', + audienceSettings: { id_type: 'BY_USER_ID' }, + createAudience: true, + getAudience: true, + teardown: false + } +} diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/functions.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/functions.test.ts index 7413577f050..777e435349c 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/functions.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/functions.test.ts @@ -1,15 +1,599 @@ -import { getEndpointByRegion } from '../functions' +import nock from 'nock' +import { createRequestClient } from '@segment/actions-core' +import { getEndpointByRegion, fetchSeedUserId, removeSeedUser, createAudience, getAudience } from '../functions' + +const requestClient = createRequestClient() + +const settings = { + api_key: 'test_api_key', + secret_key: 'test_secret_key', + app_id: 'test_app_id', + default_owner_email: 'owner@example.com', + endpoint: 'north_america' +} describe('Amplitude Cohorts functions', () => { + afterEach(() => { + nock.cleanAll() + }) + describe('getEndpointByRegion', () => { it('should return north america endpoint by default', () => { - const result = getEndpointByRegion('cohorts_upload') - expect(result).toBe('https://amplitude.com/api/3/cohorts/upload') + expect(getEndpointByRegion('cohorts_upload')).toBe('https://amplitude.com/api/3/cohorts/upload') + }) + + it('should return north america endpoint for undefined region', () => { + expect(getEndpointByRegion('cohorts_upload', undefined)).toBe('https://amplitude.com/api/3/cohorts/upload') }) it('should return europe endpoint when specified', () => { - const result = getEndpointByRegion('cohorts_membership', 'europe') - expect(result).toBe('https://analytics.eu.amplitude.com/api/3/cohorts/membership') + expect(getEndpointByRegion('cohorts_membership', 'europe')).toBe( + 'https://analytics.eu.amplitude.com/api/3/cohorts/membership' + ) + }) + + it('should return north america endpoint for unknown region', () => { + expect(getEndpointByRegion('cohorts_membership', 'unknown_region')).toBe( + 'https://amplitude.com/api/3/cohorts/membership' + ) + }) + + it('should return usersearch endpoint for north america', () => { + expect(getEndpointByRegion('usersearch', 'north_america')).toBe('https://amplitude.com/api/2/usersearch') + }) + + it('should return usersearch endpoint for europe', () => { + expect(getEndpointByRegion('usersearch', 'europe')).toBe( + 'https://analytics.eu.amplitude.com/api/2/usersearch' + ) + }) + + it('should return cohorts_get_one endpoint for north america', () => { + expect(getEndpointByRegion('cohorts_get_one', 'north_america')).toBe( + 'https://amplitude.com/api/5/cohorts/request' + ) + }) + + it('should return cohorts_get_one endpoint for europe', () => { + expect(getEndpointByRegion('cohorts_get_one', 'europe')).toBe( + 'https://analytics.eu.amplitude.com/api/5/cohorts/request' + ) + }) + }) + + describe('fetchSeedUserId', () => { + it('should return the first user_id found in the first batch', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'found_user_1', amplitude_id: 12345 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { + matches: [] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { + matches: [] + }) + + const result = await fetchSeedUserId(request, 'north_america') + expect(result).toBe('found_user_1') + }) + + it('should skip matches without user_id and find one in later results', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: null, amplitude_id: 111 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { + matches: [{ user_id: null, amplitude_id: 222 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { + matches: [{ user_id: 'real_user', amplitude_id: 333 }] + }) + + const result = await fetchSeedUserId(request, 'north_america') + expect(result).toBe('real_user') + }) + + it('should search second batch if first batch yields no user_id', async () => { + const request = requestClient + + // First batch returns no user_ids + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + // Second batch has a match + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '4' }) + .reply(200, { + matches: [{ user_id: 'batch2_user', amplitude_id: 444 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '5' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '6' }) + .reply(200, { matches: [] }) + + const result = await fetchSeedUserId(request, 'north_america') + expect(result).toBe('batch2_user') + }) + + it('should throw IntegrationError when no users are found in any batch', async () => { + const request = requestClient + + // All 9 searches return empty + for (const prefix of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) { + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: prefix }) + .reply(200, { matches: [] }) + } + + await expect(fetchSeedUserId(request, 'north_america')).rejects.toThrowError( + 'Unable to fetch a seed user from Amplitude. The project must contain at least one user with a User ID.' + ) + }) + + it('should use europe endpoint when configured', async () => { + const request = requestClient + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'eu_user', amplitude_id: 100 }] + }) + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + const result = await fetchSeedUserId(request, 'europe') + expect(result).toBe('eu_user') + }) + }) + + describe('removeSeedUser', () => { + it('should send removal request and not throw on success', async () => { + const request = requestClient + + const expectedBody = { + cohort_id: 'cohort_abc', + skip_invalid_ids: true, + memberships: [{ + ids: ['seed_user_1'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', expectedBody) + .reply(200, { + cohort_id: 'cohort_abc', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + await expect( + removeSeedUser(request, 'cohort_abc', 'north_america', 'seed_user_1') + ).resolves.not.toThrow() + }) + + it('should not throw when removal request fails', async () => { + const request = requestClient + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership') + .reply(500, { error: 'Internal Server Error' }) + + await expect( + removeSeedUser(request, 'cohort_abc', 'north_america', 'seed_user_1') + ).resolves.not.toThrow() + }) + + it('should use europe endpoint when configured', async () => { + const request = requestClient + + const expectedBody = { + cohort_id: 'cohort_eu', + skip_invalid_ids: true, + memberships: [{ + ids: ['eu_seed_user'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://analytics.eu.amplitude.com') + .post('/api/3/cohorts/membership', expectedBody) + .reply(200, { + cohort_id: 'cohort_eu', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + await expect( + removeSeedUser(request, 'cohort_eu', 'europe', 'eu_seed_user') + ).resolves.not.toThrow() + }) + }) + + describe('createAudience', () => { + it('should fetch a seed user, create the cohort, and remove the seed user', async () => { + const request = requestClient + + // User Search - first batch + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user_1', amplitude_id: 12345 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + // Cohort creation + const expectedUploadBody = { + name: 'My Cohort', + app_id: 'test_app_id', + id_type: 'BY_USER_ID', + ids: ['seed_user_1'], + owner: 'custom@example.com', + published: true + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', expectedUploadBody) + .reply(200, { cohortId: 'new_cohort_id' }) + + // Seed user removal + const expectedRemovalBody = { + cohort_id: 'new_cohort_id', + skip_invalid_ids: true, + memberships: [{ + ids: ['seed_user_1'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', expectedRemovalBody) + .reply(200, { + cohort_id: 'new_cohort_id', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await createAudience( + request, + settings, + 'My Cohort', + 'BY_USER_ID', + 'custom@example.com' + ) + + expect(result).toBe('new_cohort_id') + }) + + it('should use provided user_id and skip User Search', async () => { + const request = requestClient + + const expectedUploadBody = { + name: 'Override Cohort', + app_id: 'test_app_id', + id_type: 'BY_USER_ID', + ids: ['provided_user'], + owner: 'owner@example.com', + published: true + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', expectedUploadBody) + .reply(200, { cohortId: 'override_cohort_id' }) + + // Seed user removal + const expectedRemovalBody = { + cohort_id: 'override_cohort_id', + skip_invalid_ids: true, + memberships: [{ + ids: ['provided_user'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', expectedRemovalBody) + .reply(200, { + cohort_id: 'override_cohort_id', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await createAudience( + request, + settings, + 'Override Cohort', + 'BY_USER_ID', + undefined, + 'provided_user' + ) + + expect(result).toBe('override_cohort_id') + }) + + it('should use default_owner_email when owner_email is not provided', async () => { + const request = requestClient + + const expectedUploadBody = { + name: 'Default Owner Cohort', + app_id: 'test_app_id', + id_type: 'BY_USER_ID', + ids: ['provided_user'], + owner: 'owner@example.com', + published: true + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', expectedUploadBody) + .reply(200, { cohortId: 'default_owner_cohort_id' }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership') + .reply(200, { + cohort_id: 'default_owner_cohort_id', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await createAudience( + request, + settings, + 'Default Owner Cohort', + 'BY_USER_ID', + undefined, + 'provided_user' + ) + + expect(result).toBe('default_owner_cohort_id') + }) + + it('should throw IntegrationError when name is missing', async () => { + const request = requestClient + + await expect( + createAudience(request, settings, '', 'BY_USER_ID', 'owner@example.com') + ).rejects.toThrowError('Missing audience name value') + }) + + it('should throw IntegrationError when id_type is missing', async () => { + const request = requestClient + + await expect( + createAudience(request, settings, 'My Cohort', '' as any, 'owner@example.com') + ).rejects.toThrowError('Missing id_type value') + }) + + it('should throw IntegrationError when Amplitude returns no cohortId', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user', amplitude_id: 100 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload') + .reply(200, {}) + + await expect( + createAudience(request, settings, 'My Cohort', 'BY_USER_ID', 'owner@example.com') + ).rejects.toThrowError( + 'Invalid response from Amplitude Cohorts API when attempting to create new Cohort: Missing cohortId' + ) + }) + + it('should throw when cohort creation request fails', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user', amplitude_id: 100 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload') + .reply(400, { error: 'Bad Request' }) + + await expect( + createAudience(request, settings, 'My Cohort', 'BY_USER_ID', 'owner@example.com') + ).rejects.toThrowError('Bad Request') + }) + + it('should use europe endpoints when configured', async () => { + const request = requestClient + const euSettings = { ...settings, endpoint: 'europe' } + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'eu_seed', amplitude_id: 100 }] + }) + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + const expectedUploadBody = { + name: 'EU Cohort', + app_id: 'test_app_id', + id_type: 'BY_USER_ID', + ids: ['eu_seed'], + owner: 'owner@example.com', + published: true + } + + nock('https://analytics.eu.amplitude.com') + .post('/api/3/cohorts/upload', expectedUploadBody) + .reply(200, { cohortId: 'eu_cohort_id' }) + + const expectedRemovalBody = { + cohort_id: 'eu_cohort_id', + skip_invalid_ids: true, + memberships: [{ + ids: ['eu_seed'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://analytics.eu.amplitude.com') + .post('/api/3/cohorts/membership', expectedRemovalBody) + .reply(200, { + cohort_id: 'eu_cohort_id', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await createAudience( + request, + euSettings, + 'EU Cohort', + 'BY_USER_ID', + undefined, + undefined + ) + + expect(result).toBe('eu_cohort_id') + }) + }) + + describe('getAudience', () => { + it('should resolve when cohort exists and matches externalId', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/5/cohorts/request/cohort_789') + .reply(200, { cohort_id: 'cohort_789', request_id: 'req_123' }) + + await expect(getAudience(request, settings, 'cohort_789')).resolves.not.toThrow() + }) + + it('should throw when API returns mismatched cohort_id', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/5/cohorts/request/expected_id') + .reply(200, { cohort_id: 'different_id', request_id: 'req_456' }) + + await expect(getAudience(request, settings, 'expected_id')).rejects.toThrowError( + 'Cohort with id expected_id not found' + ) + }) + + it('should throw when API returns no cohort_id', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/5/cohorts/request/missing_id') + .reply(200, {}) + + await expect(getAudience(request, settings, 'missing_id')).rejects.toThrowError( + 'Invalid response from Amplitude Cohorts API when attempting to get Cohort: Missing cohort_id' + ) + }) + + it('should use europe endpoint when configured', async () => { + const request = requestClient + const euSettings = { ...settings, endpoint: 'europe' } + + nock('https://analytics.eu.amplitude.com') + .get('/api/5/cohorts/request/eu_cohort') + .reply(200, { cohort_id: 'eu_cohort', request_id: 'req_eu' }) + + await expect(getAudience(request, euSettings, 'eu_cohort')).resolves.not.toThrow() }) }) }) diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/index.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/index.test.ts index eccf31ab8ae..a695f56ecd5 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/index.test.ts @@ -13,6 +13,10 @@ const settings = { } describe('Amplitude Cohorts', () => { + afterEach(() => { + nock.cleanAll() + }) + describe('testAuthentication', () => { it('should validate authentication inputs', async () => { nock('https://amplitude.com') @@ -25,20 +29,53 @@ describe('Amplitude Cohorts', () => { }) describe('createAudience', () => { - it('should create an audience with user_id type', async () => { + it('should create an audience with user_id type using fetched seed user', async () => { const audienceId = 'test_cohort_123' + // User Search API - first batch + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user_1', amplitude_id: 12345 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + // Cohort creation - always uses BY_USER_ID and the seed user nock('https://amplitude.com') .post('/api/3/cohorts/upload', { name: 'Test Audience', app_id: settings.app_id, id_type: 'BY_USER_ID', - ids: [], + ids: ['seed_user_1'], owner: 'owner@example.com', published: true }) + .reply(200, { cohortId: audienceId }) + + // Seed user removal + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', { + cohort_id: audienceId, + skip_invalid_ids: true, + memberships: [{ + ids: ['seed_user_1'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + }) .reply(200, { - cohortId: audienceId + cohort_id: audienceId, + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] }) const result = await testDestination.createAudience({ @@ -53,20 +90,53 @@ describe('Amplitude Cohorts', () => { expect(result).toEqual({ externalId: audienceId }) }) - it('should create an audience with amplitude_id type', async () => { + it('should create an audience with amplitude_id type using fetched seed user', async () => { const audienceId = 'test_cohort_456' + // User Search API + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user_amp', amplitude_id: 99999 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + // Cohort creation - always uses BY_USER_ID regardless of audience id_type nock('https://amplitude.com') .post('/api/3/cohorts/upload', { name: 'Test Amplitude ID Audience', app_id: settings.app_id, - id_type: 'BY_AMP_ID', - ids: [], + id_type: 'BY_USER_ID', + ids: ['seed_user_amp'], owner: 'custom@example.com', published: true }) + .reply(200, { cohortId: audienceId }) + + // Seed user removal + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', { + cohort_id: audienceId, + skip_invalid_ids: true, + memberships: [{ + ids: ['seed_user_amp'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + }) .reply(200, { - cohortId: audienceId + cohort_id: audienceId, + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] }) const result = await testDestination.createAudience({ @@ -80,6 +150,99 @@ describe('Amplitude Cohorts', () => { expect(result).toEqual({ externalId: audienceId }) }) + + it('should use user_id override and skip User Search', async () => { + const audienceId = 'override_cohort' + + // No User Search mocks needed - it should not be called + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', { + name: 'Override Audience', + app_id: settings.app_id, + id_type: 'BY_USER_ID', + ids: ['my_known_user'], + owner: 'owner@example.com', + published: true + }) + .reply(200, { cohortId: audienceId }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', { + cohort_id: audienceId, + skip_invalid_ids: true, + memberships: [{ + ids: ['my_known_user'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + }) + .reply(200, { + cohort_id: audienceId, + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await testDestination.createAudience({ + settings, + audienceName: 'Override Audience', + audienceSettings: { + id_type: 'BY_USER_ID', + user_id: 'my_known_user' + } + }) + + expect(result).toEqual({ externalId: audienceId }) + }) + + it('should use audience_name override when provided', async () => { + const audienceId = 'custom_name_cohort' + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user', amplitude_id: 100 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', { + name: 'Custom Cohort Name', + app_id: settings.app_id, + id_type: 'BY_USER_ID', + ids: ['seed_user'], + owner: 'owner@example.com', + published: true + }) + .reply(200, { cohortId: audienceId }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership') + .reply(200, { + cohort_id: audienceId, + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await testDestination.createAudience({ + settings, + audienceName: 'Original Audience Name', + audienceSettings: { + id_type: 'BY_USER_ID', + audience_name: 'Custom Cohort Name' + } + }) + + expect(result).toEqual({ externalId: audienceId }) + }) }) describe('getAudience', () => { @@ -87,7 +250,8 @@ describe('Amplitude Cohorts', () => { const externalId = 'cohort_789' nock('https://amplitude.com').get(`/api/5/cohorts/request/${externalId}`).reply(200, { - cohortId: externalId + cohort_id: externalId, + request_id: 'test_req_id' }) const result = await testDestination.getAudience({ @@ -102,7 +266,8 @@ describe('Amplitude Cohorts', () => { const externalId = 'nonexistent_cohort' nock('https://amplitude.com').get(`/api/5/cohorts/request/${externalId}`).reply(200, { - cohortId: 'different_id' + cohort_id: 'different_id', + request_id: 'test_req_id' }) await expect( diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/constants.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/constants.ts index a33a90e5b41..3b7aad9bc20 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/constants.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/constants.ts @@ -10,11 +10,14 @@ export const ID_TYPES = { BY_AMP_ID: 'BY_AMP_ID' } + export const OPERATIONS = { ADD: 'ADD', REMOVE: 'REMOVE' } +export const REMOVAL_AWAIT_THRESHOLD_MS = 3000 + export const endpoints = { usersearch: { north_america: `https://amplitude.com/api/${AMPLITUDE_API_USER_SEARCH_VERSION}/usersearch`, diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/functions.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/functions.ts index 5769ec95c13..14ee26aef20 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/functions.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/functions.ts @@ -1,8 +1,8 @@ -import { Region, CreateAudienceJSON, CreateAudienceResponse } from './types' +import { Region, CreateAudienceJSON, CreateAudienceResponse, GetAudienceResponse, IDType, UserSearchResponse } from './types' import { RequestClient, IntegrationError } from '@segment/actions-core' +import { StatsContext } from '@segment/actions-core/destination-kit' import { Settings } from './generated-types' -import { endpoints } from './constants' -import { IDType } from './types' +import { endpoints, REMOVAL_AWAIT_THRESHOLD_MS } from './constants' export function getEndpointByRegion(endpoint: keyof typeof endpoints, region?: string): string { return endpoints[endpoint][region as Region] ?? endpoints[endpoint]['north_america'] @@ -13,56 +13,165 @@ export async function createAudience( settings: Settings, name: string, id_type: IDType, - owner_email?: string + owner_email?: string, + user_id?: string, + statsContext?: StatsContext ): Promise { const { endpoint, app_id, default_owner_email } = settings + const { statsClient, tags } = statsContext || {} + const statsName = 'actions_amplitude_cohorts' + const startTime = Date.now() if (!name) { + statsClient?.incr(`${statsName}.create_audience.error.missing_name`, 1, tags) throw new IntegrationError('Missing audience name value', 'MISSING_REQUIRED_FIELD', 400) } if (!id_type) { + statsClient?.incr(`${statsName}.create_audience.error.missing_id_type`, 1, tags) throw new IntegrationError('Missing id_type value', 'MISSING_REQUIRED_FIELD', 400) } + let seedUserId: string + const trimmedUserId = user_id?.trim() + if (trimmedUserId) { + seedUserId = trimmedUserId + statsClient?.incr(`${statsName}.create_audience.seed_user_provided`, 1, tags) + } else { + try { + seedUserId = await fetchSeedUserId(request, endpoint) + statsClient?.incr(`${statsName}.create_audience.seed_user_fetched`, 1, tags) + } catch (e) { + statsClient?.incr(`${statsName}.create_audience.error.seed_user_fetch_failed`, 1, tags) + throw e + } + } + const url = getEndpointByRegion('cohorts_upload', endpoint) + // Amplitude's upload endpoint requires BY_USER_ID with a valid user_id to create a cohort, + // regardless of the id_type used for subsequent membership operations. const json: CreateAudienceJSON = { name, app_id, - id_type, - ids: [], + id_type: 'BY_USER_ID', + ids: [seedUserId], owner: owner_email ?? default_owner_email, published: true } - const response = await request(url, { - method: 'post', - json - }) + try { + const response = await request(url, { + method: 'post', + json + }) - const id = response?.data?.cohortId + const id = response?.data?.cohortId - if (!id) { - throw new IntegrationError( - 'Invalid response from Amplitude Cohorts API when attempting to create new Cohort: Missing cohortId', - 'INVALID_RESPONSE', - 500 + if (!id) { + statsClient?.incr(`${statsName}.create_audience.error.missing_cohort_id`, 1, tags) + throw new IntegrationError( + 'Invalid response from Amplitude Cohorts API when attempting to create new Cohort: Missing cohortId', + 'INVALID_RESPONSE', + 500 + ) + } + + statsClient?.incr(`${statsName}.create_audience.success`, 1, tags) + + const elapsed = Date.now() - startTime + if (elapsed < REMOVAL_AWAIT_THRESHOLD_MS) { + await removeSeedUser(request, id, endpoint, seedUserId, statsContext) + } else { + void removeSeedUser(request, id, endpoint, seedUserId, statsContext) + } + + return id + } catch (e) { + statsClient?.incr(`${statsName}.create_audience.error.cohort_creation_failed`, 1, tags) + throw e + } +} + +/** + * Amplitude's Cohorts API requires at least one valid user ID in the `ids` array when creating a cohort. + * Empty arrays are rejected. Since at cohort-creation time we don't yet have a real audience member to + * reference, we search for any existing user in the Amplitude project to use as a temporary "seed" user. + * This seed user is added during creation and immediately removed afterward. + * + * We use Amplitude's User Search API with single-digit numeric prefixes ('1', '2', '3', etc.) because + * prefix matching is broad enough to find a user in most projects. Searches are batched in groups of 3 + * to balance parallelism against rate limits. We validate that `match.user_id` is non-null because the + * search endpoint can return phantom matches (amplitude_id-only results with null user_id) which are not + * valid for cohort creation. + */ +export async function fetchSeedUserId(request: RequestClient, endpoint: string): Promise { + const url = getEndpointByRegion('usersearch', endpoint) + const batches = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9']] + + for (const batch of batches) { + const results = await Promise.all( + batch.map(async (prefix) => { + const response = await request(`${url}?user=${prefix}`) + const matches = response?.data?.matches || [] + for (const match of matches) { + if (match.user_id) { + return match.user_id + } + } + return undefined + }) ) + + const found = results.find((id) => id !== undefined) + if (found) { + return found + } + } + + throw new IntegrationError( + 'Unable to fetch a seed user from Amplitude. The project must contain at least one user with a User ID.', + 'INVALID_RESPONSE', + 400 + ) +} + +export async function removeSeedUser(request: RequestClient, cohortId: string, endpoint: string, seedUserId: string, statsContext?: StatsContext): Promise { + const { statsClient, tags } = statsContext || {} + const statsName = 'actions_amplitude_cohorts' + const url = getEndpointByRegion('cohorts_membership', endpoint) + + const json = { + cohort_id: cohortId, + skip_invalid_ids: true, + memberships: [{ + ids: [seedUserId], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + try { + await request(url, { + method: 'POST', + json + }) + statsClient?.incr(`${statsName}.create_audience.seed_user_removal.success`, 1, tags) + } catch { + statsClient?.incr(`${statsName}.create_audience.seed_user_removal.error`, 1, tags) } - return id } export async function getAudience(request: RequestClient, settings: Settings, externalId: string): Promise { const { endpoint } = settings const url = `${getEndpointByRegion('cohorts_get_one', endpoint)}/${externalId}` - const response = await request(url) - const id = response?.data?.cohortId + const response = await request(url) + const id = response?.data?.cohort_id if (!id) { throw new IntegrationError( - 'Invalid response from Amplitude Cohorts API when attempting to get Cohort: Missing cohortId', + 'Invalid response from Amplitude Cohorts API when attempting to get Cohort: Missing cohort_id', 'INVALID_RESPONSE', 500 ) diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/generated-types.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/generated-types.ts index 3ac5e92dfe7..e1c572bbac9 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/generated-types.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/generated-types.ts @@ -37,4 +37,8 @@ export interface AudienceSettings { * The name of the cohort in Amplitude. This will override the default cohort name which is the snake_case version of the Segment Audience name. */ audience_name?: string + /** + * A valid User ID that exists in your Amplitude project. Amplitude requires a temporary seed user to create the cohort; this user will be added during creation and immediately removed. If no value is provided, Segment will attempt to discover a valid User ID automatically. Only provide this field manually if that automatic lookup fails or no users are found. + */ + user_id?: string } diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/index.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/index.ts index dde58862024..356169f6bef 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/index.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/index.ts @@ -1,4 +1,4 @@ -import { AudienceDestinationDefinition, defaultValues } from '@segment/actions-core' +import { AudienceDestinationDefinition } from '@segment/actions-core' import type { AudienceSettings, Settings } from './generated-types' import syncAudience from './syncAudience' import { getEndpointByRegion, createAudience, getAudience } from './functions' @@ -106,6 +106,13 @@ const destination: AudienceDestinationDefinition = { 'The name of the cohort in Amplitude. This will override the default cohort name which is the snake_case version of the Segment Audience name.', type: 'string', required: false + }, + user_id: { + label: 'User ID', + description: + 'A valid User ID that exists in your Amplitude project. Amplitude requires a temporary seed user to create the cohort; this user will be added during creation and immediately removed. If no value is provided, Segment will attempt to discover a valid User ID automatically. Only provide this field manually if that automatic lookup fails or no users are found.', + type: 'string', + required: false } }, audienceConfig: { @@ -114,15 +121,20 @@ const destination: AudienceDestinationDefinition = { full_audience_sync: false }, async createAudience(request, createAudienceInput) { - const { - audienceName, - settings, - audienceSettings: { owner_email, audience_name, id_type } = {} - } = createAudienceInput + const { audienceName, settings, audienceSettings, statsContext } = createAudienceInput + const { owner_email, audience_name, id_type, user_id } = (audienceSettings || {}) as AudienceSettings const name = typeof audience_name === 'string' && audience_name.length > 0 ? audience_name : audienceName - const externalId = await createAudience(request, settings, name, id_type as IDType, owner_email) + const externalId = await createAudience( + request, + settings, + name, + id_type as IDType, + owner_email, + user_id, + statsContext + ) return { externalId } }, async getAudience(request, createAudienceInput) { @@ -135,36 +147,6 @@ const destination: AudienceDestinationDefinition = { }, actions: { syncAudience - }, - presets: [ - { - name: 'Entities Audience Membership Changed', - partnerAction: 'syncAudience', - mapping: defaultValues(syncAudience.fields), - type: 'specificEvent', - eventSlug: 'warehouse_audience_membership_changed_identify' - }, - { - name: 'Associated Entity Added', - partnerAction: 'syncAudience', - mapping: defaultValues(syncAudience.fields), - type: 'specificEvent', - eventSlug: 'warehouse_entity_added_track' - }, - { - name: 'Associated Entity Removed', - partnerAction: 'syncAudience', - mapping: defaultValues(syncAudience.fields), - type: 'specificEvent', - eventSlug: 'warehouse_entity_removed_track' - }, - { - name: 'Journeys Step Entered', - partnerAction: 'syncAudience', - mapping: defaultValues(syncAudience.fields), - type: 'specificEvent', - eventSlug: 'journeys_step_entered_track' - } - ] + } } export default destination diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..077b5dc484e --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.e2e.ts @@ -0,0 +1,101 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' +import syncAudience from '../index' + +const COMPUTATION_KEY = 'e2e_test_audience_track' +const COMPUTATION_ID = 'aud_e2e_test_001' + +const FAILURE_HINT = + 'User IDs must exist in the Amplitude project before they can be added to or removed from a cohort. Ensure the test users have been created in Amplitude first.' + +const fixtures: E2EFixture[] = [ + { + description: 'Add a single user via track event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-test-user-1', + email: 'e2e-user-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Remove a single user via track event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-test-user-2', + email: 'e2e-user-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + // Batch mixing valid users with one whose ID does not exist in Amplitude. The valid add and remove + // succeed, while the nonexistent user is returned in `skipped_ids` (skip_invalid_ids: true) and + // surfaced as a per-item 400 with the correct error message. + description: 'Batch add and remove users', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-test-user-3', + email: 'e2e-user-002@segment.com' + }), + // This user ID does not exist in Amplitude, so it will be skipped and reported as a failure. + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-nonexistent-user', + email: 'e2e-user-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-test-user-5', + email: 'e2e-user-001@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200 }, + { + status: 400, + errortype: 'UNKNOWN_ERROR', + errormessage: + 'The user with User ID segment-e2e-nonexistent-user was invalid and was not processed in the cohort update.' + }, + { status: 200 } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/functions.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/functions.test.ts index 2b1af4d00ad..c3f7475d04d 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/functions.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/functions.test.ts @@ -1,4 +1,5 @@ -import { getId, getIdTypeName } from '../functions' +import { MultiStatusResponse } from '@segment/actions-core' +import { getId, getIdTypeName, getMembershipIdType, getJSON, failAllPayloads, handleError } from '../functions' import type { Payload } from '../generated-types' describe('syncAudience functions', () => { @@ -6,33 +7,52 @@ describe('syncAudience functions', () => { it('should return user_id when id_type is BY_USER_ID', () => { const payload: Payload = { user_id: 'user123', - engage_fields: { - segment_computation_class: 'audience', - traits_or_properties: {}, - segment_audience_key: 'test_audience', - segment_external_audience_id: 'cohort123' - }, + segment_external_audience_id: 'cohort123', batch_size: 100 } - const result = getId(payload, 'BY_USER_ID') - expect(result).toBe('user123') + expect(getId(payload, 'BY_USER_ID')).toBe('user123') }) it('should return amplitude_id when id_type is BY_AMP_ID', () => { const payload: Payload = { amplitude_id: 'amp456', - engage_fields: { - segment_computation_class: 'audience', - traits_or_properties: {}, - segment_audience_key: 'test_audience', - segment_external_audience_id: 'cohort123' - }, + segment_external_audience_id: 'cohort123', batch_size: 100 } - const result = getId(payload, 'BY_AMP_ID') - expect(result).toBe('amp456') + expect(getId(payload, 'BY_AMP_ID')).toBe('amp456') + }) + + it('should return undefined when user_id is missing and id_type is BY_USER_ID', () => { + const payload: Payload = { + amplitude_id: 'amp456', + segment_external_audience_id: 'cohort123', + batch_size: 100 + } + + expect(getId(payload, 'BY_USER_ID')).toBeUndefined() + }) + + it('should return undefined when amplitude_id is missing and id_type is BY_AMP_ID', () => { + const payload: Payload = { + user_id: 'user123', + segment_external_audience_id: 'cohort123', + batch_size: 100 + } + + expect(getId(payload, 'BY_AMP_ID')).toBeUndefined() + }) + + it('should return undefined for an unknown id_type', () => { + const payload: Payload = { + user_id: 'user123', + amplitude_id: 'amp456', + segment_external_audience_id: 'cohort123', + batch_size: 100 + } + + expect(getId(payload, 'UNKNOWN' as any)).toBeUndefined() }) }) @@ -45,4 +65,188 @@ describe('syncAudience functions', () => { expect(getIdTypeName('BY_AMP_ID')).toBe('Amplitude ID') }) }) + + describe('getMembershipIdType', () => { + it('should return BY_NAME for BY_USER_ID', () => { + expect(getMembershipIdType('BY_USER_ID')).toBe('BY_NAME') + }) + + it('should return BY_AMP_ID for BY_AMP_ID', () => { + expect(getMembershipIdType('BY_AMP_ID')).toBe('BY_AMP_ID') + }) + }) + + describe('getJSON', () => { + it('should return correct JSON structure for ADD operation with BY_USER_ID', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { user_id: 'user2', segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + const map = new Map(payloads.map((p, i) => [i, p])) + + const result = getJSON(map, 'BY_USER_ID', 'cohort_1', msResponse, 'ADD', true) + + expect(result).toEqual({ + cohort_id: 'cohort_1', + skip_invalid_ids: true, + memberships: [{ + ids: ['user1', 'user2'], + id_type: 'BY_NAME', + operation: 'ADD' + }] + }) + }) + + it('should return correct JSON structure for REMOVE operation with BY_AMP_ID', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { amplitude_id: 'amp1', segment_external_audience_id: 'cohort_2', batch_size: 100 }, + { amplitude_id: 'amp2', segment_external_audience_id: 'cohort_2', batch_size: 100 } + ] + const map = new Map(payloads.map((p, i) => [i, p])) + + const result = getJSON(map, 'BY_AMP_ID', 'cohort_2', msResponse, 'REMOVE', true) + + expect(result).toEqual({ + cohort_id: 'cohort_2', + skip_invalid_ids: true, + memberships: [{ + ids: ['amp1', 'amp2'], + id_type: 'BY_AMP_ID', + operation: 'REMOVE' + }] + }) + }) + + it('should return undefined when all payloads have missing IDs', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + const map = new Map(payloads.map((p, i) => [i, p])) + + const result = getJSON(map, 'BY_USER_ID', 'cohort_1', msResponse, 'ADD', true) + + expect(result).toBeUndefined() + }) + + it('should exclude duplicate IDs and set errors for duplicates', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { user_id: 'user2', segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + const map = new Map(payloads.map((p, i) => [i, p])) + + const result = getJSON(map, 'BY_USER_ID', 'cohort_1', msResponse, 'ADD', true) + + expect(result).toEqual({ + cohort_id: 'cohort_1', + skip_invalid_ids: true, + memberships: [{ + ids: ['user1', 'user2'], + id_type: 'BY_NAME', + operation: 'ADD' + }] + }) + + expect(msResponse.getResponseAtIndex(1)).toMatchObject({ + data: { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Duplicate ID user1 of type User ID found in payload batch. The duplicate payload has been rejected. Each payload must have a unique ID for the specified ID Type.', + body: { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + } + }) + }) + }) + + describe('handleError', () => { + it('should set error on msResponse in batch mode', () => { + const msResponse = new MultiStatusResponse() + const payload: Payload = { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + + handleError(payload, msResponse, 0, true, 'Test error message', 'PAYLOAD_VALIDATION_FAILED') + + expect(msResponse.getResponseAtIndex(0)).toMatchObject({ + data: { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Test error message', + body: { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + } + }) + }) + + it('should include sent data when provided in batch mode', () => { + const msResponse = new MultiStatusResponse() + const payload: Payload = { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + const sent = { cohort_id: 'cohort_1', memberships: [{ ids: ['user1'] }] } + + handleError(payload, msResponse, 0, true, 'Test error', 'UNKNOWN_ERROR', sent as any) + + expect(msResponse.getResponseAtIndex(0)).toMatchObject({ + data: { + status: 400, + errortype: 'UNKNOWN_ERROR', + errormessage: 'Test error', + body: { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + sent: { cohort_id: 'cohort_1', memberships: [{ ids: ['user1'] }] } + } + }) + }) + + it('should throw PayloadValidationError in non-batch mode', () => { + const msResponse = new MultiStatusResponse() + const payload: Payload = { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + + expect(() => { + handleError(payload, msResponse, 0, false, 'Validation failed', 'PAYLOAD_VALIDATION_FAILED') + }).toThrowError('Validation failed') + }) + }) + + describe('failAllPayloads', () => { + it('should set errors on all payloads in batch mode and return msResponse', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { user_id: 'user2', segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + + const result = failAllPayloads(payloads, msResponse, true, 'All failed') + + expect(result).toBe(msResponse) + expect(msResponse.getResponseAtIndex(0)).toMatchObject({ + data: { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'All failed', + body: { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + } + }) + expect(msResponse.getResponseAtIndex(1)).toMatchObject({ + data: { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'All failed', + body: { user_id: 'user2', segment_external_audience_id: 'cohort_1', batch_size: 100 } + } + }) + }) + + it('should throw PayloadValidationError in non-batch mode', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + + expect(() => { + failAllPayloads(payloads, msResponse, false, 'All failed') + }).toThrowError('All failed') + }) + }) }) diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/getIds.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/getIds.test.ts index d1d9569a86c..f76f46170e1 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/getIds.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/getIds.test.ts @@ -40,7 +40,7 @@ describe('getIds function', () => { const payloads: Payload[] = [ { user_id: 'user1', - segment_external_audience_id: 'cohort123', + segment_external_audience_id: 'cohort123', batch_size: 100 }, { diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/index.test.ts index a7af7a13171..17fdfa190e7 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/index.test.ts @@ -40,7 +40,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user123'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -91,7 +91,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user456'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'REMOVE' } ] @@ -291,7 +291,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user1', 'user2'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -314,7 +314,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user3'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'REMOVE' } ] @@ -385,7 +385,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['valid_user', 'invalid_user'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -436,7 +436,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['eu_user'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -551,7 +551,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') @@ -566,7 +566,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'user1', segment_external_audience_id: 'cohort_123', @@ -575,7 +575,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[1]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'user2', segment_external_audience_id: 'cohort_123', @@ -617,7 +617,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] + memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_NAME', operation: 'REMOVE' }] } nock('https://amplitude.com') @@ -632,7 +632,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'REMOVE' }] }, body: { user_id: 'user1', segment_external_audience_id: 'cohort_123', @@ -641,7 +641,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[1]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_NAME', operation: 'REMOVE' }] }, body: { user_id: 'user2', segment_external_audience_id: 'cohort_123', @@ -696,13 +696,13 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedAddJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['add_user1', 'add_user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['add_user1', 'add_user2'], id_type: 'BY_NAME', operation: 'ADD' }] } const expectedRemoveJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['remove_user1'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] + memberships: [{ ids: ['remove_user1'], id_type: 'BY_NAME', operation: 'REMOVE' }] } nock('https://amplitude.com').post('/api/3/cohorts/membership', expectedAddJson).reply(200, { @@ -720,7 +720,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(3) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedAddJson, memberships: [{ ids: ['add_user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedAddJson, memberships: [{ ids: ['add_user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'add_user1', segment_external_audience_id: 'cohort_123', @@ -729,7 +729,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[1]).toMatchObject({ status: 200, - sent: { ...expectedAddJson, memberships: [{ ids: ['add_user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedAddJson, memberships: [{ ids: ['add_user2'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'add_user2', segment_external_audience_id: 'cohort_123', @@ -738,7 +738,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[2]).toMatchObject({ status: 200, - sent: { ...expectedRemoveJson, memberships: [{ ids: ['remove_user1'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] }, + sent: { ...expectedRemoveJson, memberships: [{ ids: ['remove_user1'], id_type: 'BY_NAME', operation: 'REMOVE' }] }, body: { user_id: 'remove_user1', segment_external_audience_id: 'cohort_123', @@ -851,7 +851,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_eu', skip_invalid_ids: true, - memberships: [{ ids: ['eu_user1', 'eu_user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['eu_user1', 'eu_user2'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://analytics.eu.amplitude.com') @@ -867,7 +867,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['eu_user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['eu_user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'eu_user1', segment_external_audience_id: 'cohort_eu', @@ -876,7 +876,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[1]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['eu_user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['eu_user2'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'eu_user2', segment_external_audience_id: 'cohort_eu', @@ -918,7 +918,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['valid_user', 'skipped_user'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['valid_user', 'skipped_user'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') @@ -933,7 +933,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['valid_user'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['valid_user'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'valid_user', segment_external_audience_id: 'cohort_123', @@ -999,7 +999,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') @@ -1014,7 +1014,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(3) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'user1', segment_external_audience_id: 'cohort_123', @@ -1029,7 +1029,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[2]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'user2', segment_external_audience_id: 'cohort_123', @@ -1073,7 +1073,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') @@ -1092,7 +1092,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { batch_size: 100, segment_external_audience_id: 'cohort_123', @@ -1196,7 +1196,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/functions.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/functions.ts index 7f5b1d40b72..f4293689fc3 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/functions.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/functions.ts @@ -1,10 +1,10 @@ import { RequestClient, MultiStatusResponse, JSONLikeObject, PayloadValidationError, ErrorCodes, AudienceMembership } from '@segment/actions-core' import type { Settings, AudienceSettings } from '../generated-types' import type { Payload } from './generated-types' -import { PayloadMap, Operation, UploadToCohortJSON, UploadToCohortResponse, ResponseError, PossibleErrorCodes } from './types' +import { PayloadMap, UploadToCohortResponse, ResponseError, PossibleErrorCodes } from './types' import { ID_TYPES } from '../constants' import { getEndpointByRegion } from '../functions' -import { IDType } from '../types' +import { IDType, MembershipIdType, Operation, UploadToCohortJSON } from '../types' export async function send(request: RequestClient, payloads: Payload[], settings: Settings, isBatch: boolean, audienceSettings?: AudienceSettings, audienceMemberships?: AudienceMembership[]) { const { @@ -51,31 +51,32 @@ export async function send(request: RequestClient, payloads: Payload[], settings } }) - const requests: Promise[] = [] + // Amplitude returns 429 if a cohort is accessed concurrently, so ADD and REMOVE must run sequentially. + let hasRequests = false if (addMap.size > 0) { const json = getJSON(addMap, id_type, audienceId, msResponse, 'ADD', isBatch) if(json){ - requests.push(sendRequest(request, addMap, msResponse, json, id_type, endpoint, isBatch)) + hasRequests = true + await sendRequest(request, addMap, msResponse, json, id_type, endpoint, isBatch) } } if (deleteMap.size > 0) { const json = getJSON(deleteMap, id_type, audienceId, msResponse, 'REMOVE', isBatch) if(json){ - requests.push(sendRequest(request, deleteMap, msResponse, json, id_type, endpoint, isBatch)) + hasRequests = true + await sendRequest(request, deleteMap, msResponse, json, id_type, endpoint, isBatch) } } - if(requests.length === 0) { + if(!hasRequests) { if(isBatch) { return msResponse } throw new PayloadValidationError("The payload is invalid and cannot be sent to Amplitude. Check that it contains the correct type of identifier") } - await Promise.all(requests) - if (isBatch) { return msResponse } @@ -98,6 +99,10 @@ export function failAllPayloads(payloads: Payload[], msResponse: MultiStatusResp throw new PayloadValidationError(message) } +export function getMembershipIdType(id_type: IDType): MembershipIdType { + return id_type === ID_TYPES.BY_USER_ID ? 'BY_NAME' : 'BY_AMP_ID' +} + export function getJSON(map: PayloadMap, id_type: IDType, audienceId: string, msResponse: MultiStatusResponse, operation: Operation, isBatch: boolean): UploadToCohortJSON | undefined { const ids: string[] = getIds(map, id_type, msResponse, isBatch) if(ids.length === 0){ @@ -108,7 +113,7 @@ export function getJSON(map: PayloadMap, id_type: IDType, audienceId: string, ms skip_invalid_ids: true, memberships: [{ ids, - id_type, + id_type: getMembershipIdType(id_type), operation }] } diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/types.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/types.ts index dd7fd526e98..199c6025b5b 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/types.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/types.ts @@ -1,22 +1,8 @@ -import { OPERATIONS } from '../constants' import { Payload } from './generated-types' import { ErrorCodes } from '@segment/actions-core' -import { IDType } from '../types' - -export type UploadToCohortJSON = { - cohort_id: string - skip_invalid_ids: true - memberships: Array<{ - ids: Array - id_type: IDType - operation: Operation - }> -} export type PayloadMap = Map -export type Operation = keyof typeof OPERATIONS - export type PossibleErrorCodes = keyof typeof ErrorCodes | 'PAYLOAD_VALIDATION_FAILED' | 'UNKNOWN_ERROR' export type UploadToCohortResponse = { diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts index 7cabac7a2e7..2ba0981e9f3 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts @@ -1,23 +1,46 @@ -import { ID_TYPES } from './constants' +import { ID_TYPES, OPERATIONS } from './constants' export type Region = 'north_america' | 'europe' export type CreateAudienceJSON = { - name: string // Cohort Name - app_id: string // Amplitude App ID - id_type: 'BY_AMP_ID' | 'BY_USER_ID' // Device ID not supported by Amplitude Cohorts. Only a few customers will have access to an Amplitude ID. - cg?: string // Cohort Grouping - ids: Array // List of User IDs or Amplitude IDs. Leave empty when creating - owner: string // Cohort owner. The login email of the user who will own the cohort in Ampltitude - published: true // Whether the cohort should be published immediately + name: string // Cohort Name + app_id: string // Amplitude App ID + id_type: 'BY_USER_ID' // Device ID not supported by Amplitude Cohorts. Only BY_USER_ID is supported when creating a cohort. + cg?: string // Cohort Grouping + ids: Array // List of User IDs or Amplitude IDs. Must contain at least one User ID to create the cohort. + owner: string // Cohort owner. The login email of the user who will own the cohort in Amplitude + published: true // Whether the cohort should be published immediately } export type CreateAudienceResponse = { - cohortId: string + cohortId: string } export type GetAudienceResponse = { - cohortId: string + cohort_id: string + request_id: string } -export type IDType = keyof typeof ID_TYPES \ No newline at end of file +export type IDType = keyof typeof ID_TYPES + +export type Operation = keyof typeof OPERATIONS + +export type MembershipIdType = 'BY_NAME' | 'BY_AMP_ID' + +export type UploadToCohortJSON = { + cohort_id: string + skip_invalid_ids: true + memberships: Array<{ + ids: Array + id_type: MembershipIdType + operation: Operation + }> +} + +export type UserSearchResponse = { + matches: Array<{ + user_id?: string | null + amplitude_id?: number + [key: string]: unknown + }> +} diff --git a/packages/destination-actions/src/destinations/amplitude/__e2e__/index.ts b/packages/destination-actions/src/destinations/amplitude/__e2e__/index.ts new file mode 100644 index 00000000000..ff5094b7930 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/__e2e__/index.ts @@ -0,0 +1,9 @@ +import type { E2EDestinationConfig } from '@segment/actions-core' + +export const config: E2EDestinationConfig = { + settings: { + apiKey: { $env: 'E2E_AMPLITUDE_API_KEY' }, + secretKey: { $env: 'E2E_AMPLITUDE_SECRET_KEY' }, + endpoint: 'north_america' + } +} diff --git a/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..2cc07b3417d --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts @@ -0,0 +1,25 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import identifyUser from '../index' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully identifies a user with traits', + subscribe: 'type = "identify"', + mapping: defaultValues(identifyUser.fields), + mode: 'single', + event: createE2EEvent('identify', undefined, { + userId: 'e2e-test-user-amplitude-001', + traits: { + email: 'e2e-test@segment.com', + plan: 'enterprise', + company: 'Segment' + } + }), + expect: { + status: 'success' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..dbd50821891 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.e2e.ts @@ -0,0 +1,43 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import logEventV2 from '../index' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully logs a track event', + subscribe: 'type = "track"', + mapping: defaultValues(logEventV2.fields), + mode: 'single', + event: createE2EEvent('track', 'Button Clicked', { + userId: 'e2e-test-user-amplitude-001', + properties: { + buttonId: 'cta-signup', + page: '/pricing' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully logs event with products array', + subscribe: 'type = "track"', + mapping: defaultValues(logEventV2.fields), + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-amplitude-001', + properties: { + revenue: 99.98, + products: [ + { price: 49.99, quantity: 1, productId: 'prod-001', name: 'Widget' }, + { price: 49.99, quantity: 1, productId: 'prod-002', name: 'Gadget' } + ] + } + }), + expect: { + status: 'success' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/__snapshots__/snapshot.test.ts.snap index b289febcd40..da92733b015 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/__snapshots__/snapshot.test.ts.snap @@ -9,21 +9,15 @@ Object { "braze_id": "tZlmdH(v3%S", "email": "rabavori@fo.sx", "external_id": "tZlmdH(v3%S", - "name": "ecommerce.order_placed", + "name": "ecommerce.checkout_started", "phone": "tZlmdH(v3%S", "properties": Object { "cart_id": "tZlmdH(v3%S", + "checkout_id": "tZlmdH(v3%S", "currency": "HTG", - "discounts": Array [ - Object { - "amount": -17122291498352.64, - "code": "tZlmdH(v3%S", - }, - ], "metadata": Object { "testType": "tZlmdH(v3%S", }, - "order_id": "tZlmdH(v3%S", "products": Array [ Object { "image_url": "http://vaci.va/inu", @@ -35,8 +29,10 @@ Object { "variant_id": "tZlmdH(v3%S", }, ], + "shipping": -17122291498352.64, "source": "tZlmdH(v3%S", - "total_discounts": -17122291498352.64, + "subtotal_value": -17122291498352.64, + "tax": -17122291498352.64, "total_value": -17122291498352.64, }, "time": "2025-01-01T00:00:00.000Z", @@ -56,11 +52,11 @@ Object { "app_id": "tZlmdH(v3%S", "braze_id": "tZlmdH(v3%S", "external_id": "tZlmdH(v3%S", - "name": "ecommerce.order_placed", + "name": "ecommerce.checkout_started", "properties": Object { "cart_id": "tZlmdH(v3%S", + "checkout_id": "tZlmdH(v3%S", "currency": "HTG", - "order_id": "tZlmdH(v3%S", "products": Array [ Object { "price": -17122291498352.64, diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/index.test.ts b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/index.test.ts index 32e5abf7a52..49578d3b883 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/index.test.ts @@ -30,6 +30,10 @@ const payload = { cart_id: 'cart_id_1', checkout_id: 'checkout_id_1', total: 100.0, + subtotal: 85.0, + tax: 9.0, + shipping: 6.0, + action: 'replace', discount: 10, discount_items: [ { @@ -52,7 +56,7 @@ const payload = { product_url: 'https://example.com/prod1', quantity: 2, price: 25.0, - color: 'red', + color: 'red', size: 'M' }, { @@ -99,6 +103,10 @@ const mapping = { order_id: { '@path': '$.properties.order_id' }, cart_id: { '@path': '$.properties.cart_id' }, total_value: { '@path': '$.properties.total' }, + action: { '@path': '$.properties.action' }, + subtotal_value: { '@path': '$.properties.subtotal' }, + tax: { '@path': '$.properties.tax' }, + shipping: { '@path': '$.properties.shipping' }, total_discounts: { '@path': '$.properties.discount' }, discounts: {'@path': '$.properties.discount_items' }, currency: { '@path': '$.properties.currency' }, @@ -113,7 +121,7 @@ const mapping = { image_url: {'@path': '$.image_url'}, product_url: {'@path': '$.url'}, quantity: {'@path': '$.quantity'}, - price: {'@path': '$.price'}, + price: {'@path': '$.price'}, color: { '@path': '$.color' }, size: { '@path': '$.size' } } @@ -193,12 +201,15 @@ describe('Braze.ecommerce', () => { ], total_value: 100, order_id: "order_id_1", + cart_id: "cart_id_1", + subtotal_value: 85, + tax: 9, + shipping: 6, total_discounts: 10, discounts: [ { code: "SUMMER21", amount: 5 }, { code: "VIPCUSTOMER", amount: 5 } ], - cart_id: "cart_id_1", metadata: { custom_field_1: "custom_value_1", custom_field_2: 100, @@ -232,9 +243,9 @@ describe('Braze.ecommerce', () => { it('should send Checkout Started event correctly', async () => { - const mapping2 = { - ...mapping, - name: EVENT_NAMES.CHECKOUT_STARTED + const mapping2 = { + ...mapping, + name: EVENT_NAMES.CHECKOUT_STARTED } const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) @@ -283,6 +294,9 @@ describe('Braze.ecommerce', () => { total_value: 100, checkout_id: "checkout_id_1", cart_id: "cart_id_1", + subtotal_value: 85, + tax: 9, + shipping: 6, metadata: { custom_field_1: "custom_value_1", custom_field_2: 100, @@ -400,9 +414,9 @@ describe('Braze.ecommerce', () => { it('should send Order Cancelled event correctly', async () => { - const mapping2 = { - ...mapping, - name: EVENT_NAMES.ORDER_CANCELLED + const mapping2 = { + ...mapping, + name: EVENT_NAMES.ORDER_CANCELLED } const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) @@ -451,6 +465,9 @@ describe('Braze.ecommerce', () => { total_value: 100, order_id: "order_id_1", cancel_reason: "I didn't like it", + subtotal_value: 85, + tax: 9, + shipping: 6, total_discounts: 10, discounts: [ { code: "SUMMER21", amount: 5 }, @@ -485,6 +502,180 @@ describe('Braze.ecommerce', () => { expect(response.length).toBe(1) }) + it('should send Cart Updated event with all fields correctly', async () => { + + const mapping2 = { + ...mapping, + name: EVENT_NAMES.CART_UPDATED + } + + const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) + const e = createTestEvent(deepCopy) + delete e.properties?.product + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { + alias_name: "alias_name_1", + alias_label: "alias_label_1" + }, + app_id: "test_app_id", + name: "ecommerce.cart_updated", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { + color: "red", + size: "M" + } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + cart_id: "cart_id_1", + action: "replace", + subtotal_value: 85, + tax: 9, + shipping: 6, + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + } + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.testAction('ecommerce', { + event: e, + settings, + useDefaultMappings: true, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + }) + + it('should send Cart Updated event with minimal fields correctly', async () => { + + const mapping2 = { + ...mapping, + name: EVENT_NAMES.CART_UPDATED, + action: undefined, + subtotal_value: undefined, + tax: undefined, + shipping: undefined + } + + const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) + delete deepCopy.properties?.action + delete deepCopy.properties?.subtotal + delete deepCopy.properties?.tax + delete deepCopy.properties?.shipping + const e = createTestEvent(deepCopy) + delete e.properties?.product + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { + alias_name: "alias_name_1", + alias_label: "alias_label_1" + }, + app_id: "test_app_id", + name: "ecommerce.cart_updated", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { + color: "red", + size: "M" + } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + cart_id: "cart_id_1", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + } + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.testAction('ecommerce', { + event: e, + settings, + useDefaultMappings: true, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + }) + it('should throw an error if missing identifier', async () => { const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) @@ -529,15 +720,17 @@ describe('Braze.ecommerce', () => { const deepCopy2: Partial = JSON.parse(JSON.stringify(payload)) const deepCopy3: Partial = JSON.parse(JSON.stringify(payload)) const deepCopy4: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy5: Partial = JSON.parse(JSON.stringify(payload)) const e1 = createTestEvent({...deepCopy1, userId: 'userId1', event: 'ecommerce.order_placed' }) const e2 = createTestEvent({...deepCopy2, userId: 'userId2', event: 'ecommerce.order_refunded' }) const e3 = createTestEvent({...deepCopy3, userId: 'userId3', event: 'ecommerce.checkout_started' }) const e4 = createTestEvent({...deepCopy4, userId: 'userId4', event: 'ecommerce.order_cancelled' }) - const events = [e1, e2, e3, e4] + const e5 = createTestEvent({...deepCopy5, userId: 'userId5', event: 'ecommerce.cart_updated' }) + const events = [e1, e2, e3, e4, e5] - const mapping2 = { - ...mapping, + const mapping2 = { + ...mapping, name: { '@path': '$.event' }, } @@ -585,12 +778,15 @@ describe('Braze.ecommerce', () => { ], total_value: 100, order_id: "order_id_1", + cart_id: "cart_id_1", + subtotal_value: 85, + tax: 9, + shipping: 6, total_discounts: 10, discounts: [ { code: "SUMMER21", amount: 5 }, { code: "VIPCUSTOMER", amount: 5 } - ], - cart_id: "cart_id_1" + ] }, _update_existing_only: true }, @@ -686,7 +882,10 @@ describe('Braze.ecommerce', () => { ], total_value: 100, checkout_id: "checkout_id_1", - cart_id: "cart_id_1" + cart_id: "cart_id_1", + subtotal_value: 85, + tax: 9, + shipping: 6 }, _update_existing_only: true }, @@ -733,6 +932,9 @@ describe('Braze.ecommerce', () => { total_value: 100, order_id: "order_id_1", cancel_reason: "I didn't like it", + subtotal_value: 85, + tax: 9, + shipping: 6, total_discounts: 10, discounts: [ { code: "SUMMER21", amount: 5 }, @@ -740,6 +942,55 @@ describe('Braze.ecommerce', () => { ] }, _update_existing_only: true + }, + { + external_id: "userId5", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.cart_updated", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { color: "red", size: "M" } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + cart_id: "cart_id_1", + action: "replace", + subtotal_value: 85, + tax: 9, + shipping: 6 + }, + _update_existing_only: true } ] } @@ -754,7 +1005,7 @@ describe('Braze.ecommerce', () => { settings, mapping: mapping2 }) - + expect(response.length).toBe(1) }) @@ -876,7 +1127,10 @@ describe('Braze.ecommerce', () => { ], total_value: 100, checkout_id: "checkout_id_1", - cart_id: "cart_id_1" + cart_id: "cart_id_1", + subtotal_value: 85, + tax: 9, + shipping: 6 }, _update_existing_only: true }, @@ -923,6 +1177,9 @@ describe('Braze.ecommerce', () => { total_value: 100, order_id: "order_id_1", cancel_reason: "I didn't like it", + subtotal_value: 85, + tax: 9, + shipping: 6, total_discounts: 10, discounts: [ { code: "SUMMER21", amount: 5 }, @@ -934,8 +1191,8 @@ describe('Braze.ecommerce', () => { ] } - const mapping2 = { - ...mapping, + const mapping2 = { + ...mapping, name: { '@path': '$.event' }, } @@ -958,6 +1215,10 @@ describe('Braze.ecommerce', () => { "order_id": "order_id_1", "cart_id": "cart_id_1", "total_value": 100, + "action": "replace", + "subtotal_value": 85, + "tax": 9, + "shipping": 6, "total_discounts": 10, "discounts": [ { @@ -1023,6 +1284,10 @@ describe('Braze.ecommerce', () => { "order_id": "order_id_1", "cart_id": "cart_id_1", "total_value": 100, + "action": "replace", + "subtotal_value": 85, + "tax": 9, + "shipping": 6, "total_discounts": 10, "discounts": [ { @@ -1095,6 +1360,10 @@ describe('Braze.ecommerce', () => { "order_id": "order_id_1", "cart_id": "cart_id_1", "total_value": 100, + "action": "replace", + "subtotal_value": 85, + "tax": 9, + "shipping": 6, "total_discounts": 10, "discounts": [ { @@ -1147,7 +1416,7 @@ describe('Braze.ecommerce', () => { "batch_size": 75, "index": 1 }, - "body": "{\"external_id\":\"userId3\",\"braze_id\":\"braze_id_1\",\"email\":\"email@email.com\",\"phone\":\"+14155551234\",\"user_alias\":{\"alias_name\":\"alias_name_1\",\"alias_label\":\"alias_label_1\"},\"app_id\":\"test_app_id\",\"name\":\"ecommerce.checkout_started\",\"time\":\"2024-06-10T12:00:00.000Z\",\"properties\":{\"currency\":\"USD\",\"source\":\"test_source\",\"metadata\":{\"custom_field_1\":\"custom_value_1\",\"custom_field_2\":100,\"custom_field_3\":true,\"custom_field_4\":[\"a\",\"b\",\"c\"],\"custom_field_5\":{\"nested_key\":\"nested_value\"},\"checkout_url\":\"https://example.com/checkout\",\"order_status_url\":\"https://example.com/order/status\"},\"products\":[{\"quantity\":2,\"product_id\":\"prod_1\",\"product_name\":\"Product 1\",\"variant_id\":\"Size M\",\"price\":25,\"image_url\":\"https://example.com/prod1.jpg\",\"metadata\":{\"color\":\"red\",\"size\":\"M\"}},{\"quantity\":1,\"product_id\":\"prod_2\",\"product_name\":\"Product 2\",\"variant_id\":\"Size L\",\"price\":50,\"image_url\":\"https://example.com/prod2.jpg\"}],\"total_value\":100,\"checkout_id\":\"checkout_id_1\",\"cart_id\":\"cart_id_1\"},\"_update_existing_only\":true}" + "body": "{\"external_id\":\"userId3\",\"braze_id\":\"braze_id_1\",\"email\":\"email@email.com\",\"phone\":\"+14155551234\",\"user_alias\":{\"alias_name\":\"alias_name_1\",\"alias_label\":\"alias_label_1\"},\"app_id\":\"test_app_id\",\"name\":\"ecommerce.checkout_started\",\"time\":\"2024-06-10T12:00:00.000Z\",\"properties\":{\"currency\":\"USD\",\"source\":\"test_source\",\"metadata\":{\"custom_field_1\":\"custom_value_1\",\"custom_field_2\":100,\"custom_field_3\":true,\"custom_field_4\":[\"a\",\"b\",\"c\"],\"custom_field_5\":{\"nested_key\":\"nested_value\"},\"checkout_url\":\"https://example.com/checkout\",\"order_status_url\":\"https://example.com/order/status\"},\"products\":[{\"quantity\":2,\"product_id\":\"prod_1\",\"product_name\":\"Product 1\",\"variant_id\":\"Size M\",\"price\":25,\"image_url\":\"https://example.com/prod1.jpg\",\"metadata\":{\"color\":\"red\",\"size\":\"M\"}},{\"quantity\":1,\"product_id\":\"prod_2\",\"product_name\":\"Product 2\",\"variant_id\":\"Size L\",\"price\":50,\"image_url\":\"https://example.com/prod2.jpg\"}],\"total_value\":100,\"checkout_id\":\"checkout_id_1\",\"cart_id\":\"cart_id_1\",\"subtotal_value\":85,\"tax\":9,\"shipping\":6},\"_update_existing_only\":true}" }, { "status": 200, @@ -1167,6 +1436,10 @@ describe('Braze.ecommerce', () => { "order_id": "order_id_1", "cart_id": "cart_id_1", "total_value": 100, + "action": "replace", + "subtotal_value": 85, + "tax": 9, + "shipping": 6, "total_discounts": 10, "discounts": [ { @@ -1219,10 +1492,10 @@ describe('Braze.ecommerce', () => { "batch_size": 75, "index": 2 }, - "body": "{\"external_id\":\"userId4\",\"braze_id\":\"braze_id_1\",\"email\":\"email@email.com\",\"phone\":\"+14155551234\",\"user_alias\":{\"alias_name\":\"alias_name_1\",\"alias_label\":\"alias_label_1\"},\"app_id\":\"test_app_id\",\"name\":\"ecommerce.order_cancelled\",\"time\":\"2024-06-10T12:00:00.000Z\",\"properties\":{\"currency\":\"USD\",\"source\":\"test_source\",\"metadata\":{\"custom_field_1\":\"custom_value_1\",\"custom_field_2\":100,\"custom_field_3\":true,\"custom_field_4\":[\"a\",\"b\",\"c\"],\"custom_field_5\":{\"nested_key\":\"nested_value\"},\"checkout_url\":\"https://example.com/checkout\",\"order_status_url\":\"https://example.com/order/status\"},\"products\":[{\"quantity\":2,\"product_id\":\"prod_1\",\"product_name\":\"Product 1\",\"variant_id\":\"Size M\",\"price\":25,\"image_url\":\"https://example.com/prod1.jpg\",\"metadata\":{\"color\":\"red\",\"size\":\"M\"}},{\"quantity\":1,\"product_id\":\"prod_2\",\"product_name\":\"Product 2\",\"variant_id\":\"Size L\",\"price\":50,\"image_url\":\"https://example.com/prod2.jpg\"}],\"total_value\":100,\"order_id\":\"order_id_1\",\"cancel_reason\":\"I didn't like it\",\"total_discounts\":10,\"discounts\":[{\"code\":\"SUMMER21\",\"amount\":5},{\"code\":\"VIPCUSTOMER\",\"amount\":5}]},\"_update_existing_only\":true}" + "body": "{\"external_id\":\"userId4\",\"braze_id\":\"braze_id_1\",\"email\":\"email@email.com\",\"phone\":\"+14155551234\",\"user_alias\":{\"alias_name\":\"alias_name_1\",\"alias_label\":\"alias_label_1\"},\"app_id\":\"test_app_id\",\"name\":\"ecommerce.order_cancelled\",\"time\":\"2024-06-10T12:00:00.000Z\",\"properties\":{\"currency\":\"USD\",\"source\":\"test_source\",\"metadata\":{\"custom_field_1\":\"custom_value_1\",\"custom_field_2\":100,\"custom_field_3\":true,\"custom_field_4\":[\"a\",\"b\",\"c\"],\"custom_field_5\":{\"nested_key\":\"nested_value\"},\"checkout_url\":\"https://example.com/checkout\",\"order_status_url\":\"https://example.com/order/status\"},\"products\":[{\"quantity\":2,\"product_id\":\"prod_1\",\"product_name\":\"Product 1\",\"variant_id\":\"Size M\",\"price\":25,\"image_url\":\"https://example.com/prod1.jpg\",\"metadata\":{\"color\":\"red\",\"size\":\"M\"}},{\"quantity\":1,\"product_id\":\"prod_2\",\"product_name\":\"Product 2\",\"variant_id\":\"Size L\",\"price\":50,\"image_url\":\"https://example.com/prod2.jpg\"}],\"total_value\":100,\"order_id\":\"order_id_1\",\"cancel_reason\":\"I didn't like it\",\"subtotal_value\":85,\"tax\":9,\"shipping\":6,\"total_discounts\":10,\"discounts\":[{\"code\":\"SUMMER21\",\"amount\":5},{\"code\":\"VIPCUSTOMER\",\"amount\":5}]},\"_update_existing_only\":true}" } ] - + nock(settings.endpoint) .post('/users/track', json) .reply(200) @@ -1353,7 +1626,10 @@ describe('Braze.ecommerce', () => { ], total_value: 100, checkout_id: "checkout_id_1", - cart_id: "cart_id_1" + cart_id: "cart_id_1", + subtotal_value: 85, + tax: 9, + shipping: 6 }, _update_existing_only: true }, @@ -1400,6 +1676,9 @@ describe('Braze.ecommerce', () => { total_value: 100, order_id: "order_id_1", cancel_reason: "I didn't like it", + subtotal_value: 85, + tax: 9, + shipping: 6, total_discounts: 10, discounts: [ { code: "SUMMER21", amount: 5 }, @@ -1411,7 +1690,7 @@ describe('Braze.ecommerce', () => { ] } - const mapping2 = { + const mapping2 = { ...mapping, __segment_internal_sync_mode: '', name: { '@path': '$.event' }, @@ -1423,6 +1702,7 @@ describe('Braze.ecommerce', () => { "errorreporter": "DESTINATION", "errortype": "BAD_REQUEST", "sent": { + "action": "replace", "batch_size": 75, "braze_id": "braze_id_1", "cancel_reason": "I didn't like it", @@ -1468,7 +1748,10 @@ describe('Braze.ecommerce', () => { "variant_id": "Size L" } ], + "shipping": 6, "source": "test_source", + "subtotal_value": 85, + "tax": 9, "time": "2024-06-10T12:00:00.000Z", "total_discounts": 10, "total_value": 100, @@ -1484,6 +1767,7 @@ describe('Braze.ecommerce', () => { "errorreporter": "DESTINATION", "errortype": "BAD_REQUEST", "sent": { + "action": "replace", "batch_size": 75, "cancel_reason": "I didn't like it", "cart_id": "cart_id_1", @@ -1525,7 +1809,10 @@ describe('Braze.ecommerce', () => { "variant_id": "Size L" } ], + "shipping": 6, "source": "test_source", + "subtotal_value": 85, + "tax": 9, "time": "2024-06-10T12:00:00.000Z", "total_discounts": 10, "total_value": 100 @@ -1537,6 +1824,7 @@ describe('Braze.ecommerce', () => { "errorreporter": "DESTINATION", "errortype": "BAD_REQUEST", "sent": { + "action": "replace", "batch_size": 75, "braze_id": "braze_id_1", "cancel_reason": "I didn't like it", @@ -1582,7 +1870,10 @@ describe('Braze.ecommerce', () => { "variant_id": "Size L" } ], + "shipping": 6, "source": "test_source", + "subtotal_value": 85, + "tax": 9, "time": "2024-06-10T12:00:00.000Z", "total_discounts": 10, "total_value": 100, @@ -1598,6 +1889,7 @@ describe('Braze.ecommerce', () => { "errorreporter": "DESTINATION", "errortype": "BAD_REQUEST", "sent": { + "action": "replace", "batch_size": 75, "braze_id": "braze_id_1", "cancel_reason": "I didn't like it", @@ -1643,7 +1935,10 @@ describe('Braze.ecommerce', () => { "variant_id": "Size L" } ], + "shipping": 6, "source": "test_source", + "subtotal_value": 85, + "tax": 9, "time": "2024-06-10T12:00:00.000Z", "total_discounts": 10, "total_value": 100, diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/constants.ts b/packages/destination-actions/src/destinations/braze/ecommerce/constants.ts index 52599d1b666..ea60e06e9e0 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/constants.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/constants.ts @@ -1,7 +1,7 @@ export const EVENT_NAMES = { PRODUCT_VIEWED: 'ecommerce.product_viewed', CHECKOUT_STARTED: 'ecommerce.checkout_started', - //CART_UPDATED: 'ecommerce.cart_updated', + CART_UPDATED: 'ecommerce.cart_updated', ORDER_PLACED: 'ecommerce.order_placed', ORDER_CANCELLED: 'ecommerce.order_cancelled', ORDER_REFUNDED: 'ecommerce.order_refunded' diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts index 541969ebbf0..e3b1aa4d805 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts @@ -9,7 +9,7 @@ const name: InputField = { required: true, choices: [ { label: 'Product Viewed', value: EVENT_NAMES.PRODUCT_VIEWED }, - //{ label: 'Cart Updated', value: EVENT_NAMES.CART_UPDATED }, + { label: 'Cart Updated', value: EVENT_NAMES.CART_UPDATED }, { label: 'Checkout Started', value: EVENT_NAMES.CHECKOUT_STARTED }, { label: 'Order Placed', value: EVENT_NAMES.ORDER_PLACED }, { label: 'Order Cancelled', value: EVENT_NAMES.ORDER_CANCELLED }, @@ -198,27 +198,27 @@ const order_id: InputField = { const cart_id: InputField = { label: 'Cart ID', - description: 'Unique identifier for the cart. If no value is passed, Braze will determine a default value (shared across cart, checkout, and order events) for the user cart mapping.', + description: 'Unique identifier for the cart. Required for cart_updated. For checkout and order events, if no value is passed, Braze will determine a default value for the user cart mapping.', type: 'string', default: {'@path': '$.properties.cart_id'}, - // required: { - // match: 'any', - // conditions: [ - // { - // fieldKey: 'name', - // operator: 'is', - // value: [EVENT_NAMES.CART_UPDATED] - // } - // ] - // }, + required: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CART_UPDATED + } + ] + }, depends_on: { match: 'any', conditions: [ - // { - // fieldKey: 'name', - // operator: 'is', - // value: EVENT_NAMES.CART_UPDATED - // }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CART_UPDATED + }, { fieldKey: 'name', operator: 'is', @@ -241,16 +241,16 @@ const total_value: InputField = { required: { match: 'any', conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CART_UPDATED + }, { fieldKey: 'name', operator: 'is', value: EVENT_NAMES.CHECKOUT_STARTED }, - // { - // fieldKey: 'name', - // operator: 'is', - // value: EVENT_NAMES.CART_UPDATED - // }, { fieldKey: 'name', operator: 'is', @@ -271,16 +271,16 @@ const total_value: InputField = { depends_on: { match: 'any', conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CART_UPDATED + }, { fieldKey: 'name', operator: 'is', value: EVENT_NAMES.CHECKOUT_STARTED }, - // { - // fieldKey: 'name', - // operator: 'is', - // value: EVENT_NAMES.CART_UPDATED - // }, { fieldKey: 'name', operator: 'is', @@ -300,6 +300,128 @@ const total_value: InputField = { } } +const action: InputField = { + label: 'Action', + description: 'The cart action that was performed (add, remove, or replace).', + type: 'string', + default: {'@path': '$.properties.action'}, + choices: [ + { label: 'Add', value: 'add' }, + { label: 'Remove', value: 'remove' }, + { label: 'Replace', value: 'replace' } + ], + required: false, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CART_UPDATED + } + ] + } +} + +const subtotal_value: InputField = { + label: 'Subtotal Value', + description: 'Subtotal monetary value of the cart before tax and shipping.', + type: 'number', + default: { '@path': '$.properties.subtotal'}, + required: false, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CART_UPDATED + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CHECKOUT_STARTED + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.ORDER_PLACED + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.ORDER_CANCELLED + } + ] + } +} + +const tax: InputField = { + label: 'Tax', + description: 'Tax amount applied to the transaction.', + type: 'number', + default: { '@path': '$.properties.tax'}, + required: false, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CART_UPDATED + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CHECKOUT_STARTED + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.ORDER_PLACED + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.ORDER_CANCELLED + } + ] + } +} + +const shipping: InputField = { + label: 'Shipping', + description: 'Shipping cost for the transaction.', + type: 'number', + default: { '@path': '$.properties.shipping'}, + required: false, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CART_UPDATED + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CHECKOUT_STARTED + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.ORDER_PLACED + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.ORDER_CANCELLED + } + ] + } +} + const total_discounts: InputField = { label: 'Total Discounts', description: 'Total amount of discounts applied to the order.', @@ -527,6 +649,29 @@ const metadata: InputField = { defaultObjectUI: 'keyvalue' } +const catalog_type: InputField = { + label: 'Catalog Trigger Type', + description: 'Required to use Braze catalog trigger features. Accepted values: price_drop, back_in_stock.', + type: 'string', + multiple: true, + choices: [ + { label: 'Price Drop', value: 'price_drop' }, + { label: 'Back In Stock', value: 'back_in_stock' } + ], + default: { '@path': '$.properties.type' }, + required: false, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.PRODUCT_VIEWED + } + ] + } +} + const enable_batching: InputField = { type: 'boolean', label: 'Batch Data to Braze', @@ -558,6 +703,10 @@ export const commonFields = { order_id, cart_id, total_value, + action, + subtotal_value, + tax, + shipping, total_discounts, discounts, currency, @@ -565,4 +714,6 @@ export const commonFields = { metadata, enable_batching, batch_size -} \ No newline at end of file +} + +export { catalog_type } \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts index e2c1a56af06..3a980ba8622 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts @@ -18,7 +18,7 @@ import type { ProductViewedEventName, ProductViewedEvent, MultiProductBaseEvent, - //CartUpdatedEvent, + CartUpdatedEvent, CheckoutStartedEvent, OrderPlacedEvent, OrderRefundedEvent, @@ -159,7 +159,8 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings switch(name) { case EVENT_NAMES.PRODUCT_VIEWED: { const { - product + product, + catalog_type } = payload as SingleProductPayload const event: ProductViewedEvent = { @@ -167,12 +168,13 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings name: EVENT_NAMES.PRODUCT_VIEWED, properties: { ...baseEvent.properties, - ...product + ...product, + ...(catalog_type && catalog_type.length > 0 ? { type: catalog_type } : {}) } } return event } - // case EVENT_NAMES.CART_UPDATED: + case EVENT_NAMES.CART_UPDATED: case EVENT_NAMES.CHECKOUT_STARTED: case EVENT_NAMES.ORDER_PLACED: case EVENT_NAMES.ORDER_CANCELLED: @@ -192,67 +194,83 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings } switch(name) { - // case EVENT_NAMES.CART_UPDATED: { - // const { cart_id } = payload - - // const event: CartUpdatedEvent = { - // ...multiProductEvent, - // name: EVENT_NAMES.CART_UPDATED, - // properties: { - // ...multiProductEvent.properties, - // cart_id: cart_id as string - // } - // } - // return event - // } + case EVENT_NAMES.CART_UPDATED: { + const { cart_id, action, subtotal_value, tax, shipping } = payload as Payload + + const event: CartUpdatedEvent = { + ...multiProductEvent, + name: EVENT_NAMES.CART_UPDATED, + properties: { + ...multiProductEvent.properties, + cart_id: cart_id as string, + ...(action ? { action: action as 'add' | 'remove' | 'replace' } : {}), + ...(typeof subtotal_value === 'number' ? { subtotal_value } : {}), + ...(typeof tax === 'number' ? { tax } : {}), + ...(typeof shipping === 'number' ? { shipping } : {}) + } + } + return event + } case EVENT_NAMES.CHECKOUT_STARTED: { - const { - checkout_id, - cart_id - } = payload - + const { + checkout_id, + cart_id, + subtotal_value, + tax, + shipping + } = payload as Payload + const event: CheckoutStartedEvent = { ...multiProductEvent, name: EVENT_NAMES.CHECKOUT_STARTED, properties: { ...multiProductEvent.properties, checkout_id: checkout_id as string, - ...(cart_id ? { cart_id } : {}) + ...(cart_id ? { cart_id } : {}), + ...(typeof subtotal_value === 'number' ? { subtotal_value } : {}), + ...(typeof tax === 'number' ? { tax } : {}), + ...(typeof shipping === 'number' ? { shipping } : {}) } } return event } case EVENT_NAMES.ORDER_PLACED: { - const { + const { order_id, - cart_id, - total_discounts, + cart_id, + subtotal_value, + tax, + shipping, + total_discounts, discounts - } = payload - + } = payload as Payload + const event: OrderPlacedEvent = { ...multiProductEvent, name: EVENT_NAMES.ORDER_PLACED, properties: { ...multiProductEvent.properties, order_id: order_id as string, + ...(cart_id ? { cart_id } : {}), + ...(typeof subtotal_value === 'number' ? { subtotal_value } : {}), + ...(typeof tax === 'number' ? { tax } : {}), + ...(typeof shipping === 'number' ? { shipping } : {}), ...(typeof total_discounts === 'number' ? { total_discounts } : {}), - ...(discounts ? { discounts } : {}), - ...(cart_id ? { cart_id } : {}) + ...(discounts ? { discounts } : {}) } } return event } case EVENT_NAMES.ORDER_REFUNDED: { - const { + const { order_id, - total_discounts, + total_discounts, discounts - } = payload - + } = payload as Payload + const event: OrderRefundedEvent = { ...multiProductEvent, name: EVENT_NAMES.ORDER_REFUNDED, @@ -267,13 +285,16 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings } case EVENT_NAMES.ORDER_CANCELLED: { - const { + const { order_id, cancel_reason, - total_discounts, + subtotal_value, + tax, + shipping, + total_discounts, discounts - } = payload - + } = payload as Payload + const event: OrderCancelledEvent = { ...multiProductEvent, name: EVENT_NAMES.ORDER_CANCELLED, @@ -281,6 +302,9 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings ...multiProductEvent.properties, order_id: order_id as string, cancel_reason: cancel_reason as string, + ...(typeof subtotal_value === 'number' ? { subtotal_value } : {}), + ...(typeof tax === 'number' ? { tax } : {}), + ...(typeof shipping === 'number' ? { shipping } : {}), ...(typeof total_discounts === 'number' ? { total_discounts } : {}), ...(discounts ? { discounts } : {}) } diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/generated-types.ts b/packages/destination-actions/src/destinations/braze/ecommerce/generated-types.ts index 29e29851bdc..d19a8f0854b 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/generated-types.ts @@ -45,13 +45,29 @@ export interface Payload { */ order_id?: string /** - * Unique identifier for the cart. If no value is passed, Braze will determine a default value (shared across cart, checkout, and order events) for the user cart mapping. + * Unique identifier for the cart. Required for cart_updated. For checkout and order events, if no value is passed, Braze will determine a default value for the user cart mapping. */ cart_id?: string /** * Total monetary value of the cart. */ total_value?: number + /** + * The cart action that was performed (add, remove, or replace). + */ + action?: string + /** + * Subtotal monetary value of the cart before tax and shipping. + */ + subtotal_value?: number + /** + * Tax amount applied to the transaction. + */ + tax?: number + /** + * Shipping cost for the transaction. + */ + shipping?: number /** * Total amount of discounts applied to the order. */ diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/types.ts b/packages/destination-actions/src/destinations/braze/ecommerce/types.ts index b7882dc20d0..2b17ec687f2 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/types.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/types.ts @@ -10,14 +10,14 @@ export type PayloadWithIndex = (Payload | SingleProductPayload) & { export type ProductViewedEventName = typeof EVENT_NAMES.PRODUCT_VIEWED export type CheckoutStartedEventName = typeof EVENT_NAMES.CHECKOUT_STARTED -//export type CartUpdatedEventName = typeof EVENT_NAMES.CART_UPDATED +export type CartUpdatedEventName = typeof EVENT_NAMES.CART_UPDATED export type OrderPlacedEventName = typeof EVENT_NAMES.ORDER_PLACED export type OrderCancelledEventName = typeof EVENT_NAMES.ORDER_CANCELLED export type OrderRefundedEventName = typeof EVENT_NAMES.ORDER_REFUNDED export type MultiPropertyEventName = CheckoutStartedEventName | - //CartUpdatedEventName | + CartUpdatedEventName | OrderPlacedEventName | OrderCancelledEventName | OrderRefundedEventName @@ -28,7 +28,7 @@ export interface EcommerceEvents { export type EcommerceEvent = | ProductViewedEvent - // | CartUpdatedEvent + | CartUpdatedEvent | CheckoutStartedEvent | OrderPlacedEvent | OrderRefundedEvent @@ -58,7 +58,9 @@ export interface BaseEvent { export interface ProductViewedEvent extends BaseEvent { name: ProductViewedEventName - properties: BaseEvent['properties'] & BaseProduct + properties: BaseEvent['properties'] & BaseProduct & { + type?: string[] + } } export interface MultiProductBaseEvent extends BaseEvent { @@ -85,21 +87,28 @@ export interface BaseProduct { product_url?: string } -// export interface CartUpdatedEvent extends MultiProductBaseEvent { -// name: CartUpdatedEventName -// properties: MultiProductBaseEvent['properties'] & { -// cart_id: string -// } -// } +export interface CartUpdatedEvent extends MultiProductBaseEvent { + name: CartUpdatedEventName + properties: MultiProductBaseEvent['properties'] & { + cart_id: string + action?: 'add' | 'remove' | 'replace' + subtotal_value?: number + tax?: number + shipping?: number + } +} export interface CheckoutStartedEvent extends MultiProductBaseEvent { name: CheckoutStartedEventName properties: MultiProductBaseEvent['properties'] & { - checkout_id: string - cart_id?: string - metadata?: BaseEvent['properties']['metadata'] & { + checkout_id: string + cart_id?: string + subtotal_value?: number + tax?: number + shipping?: number + metadata?: BaseEvent['properties']['metadata'] & { checkout_url?: string - } + } } } @@ -108,6 +117,9 @@ export interface OrderPlacedEvent extends MultiProductBaseEvent { properties: MultiProductBaseEvent['properties'] & { order_id: string cart_id?: string + subtotal_value?: number + tax?: number + shipping?: number total_discounts?: number discounts?: Array<{ code: string @@ -138,6 +150,9 @@ export interface OrderCancelledEvent extends MultiProductBaseEvent { name: OrderCancelledEventName properties: MultiProductBaseEvent['properties'] & { order_id: string + subtotal_value?: number + tax?: number + shipping?: number cancel_reason: string total_discounts?: number discounts?: Array<{ diff --git a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/__snapshots__/snapshot.test.ts.snap index 4b214ce5505..186cf114a61 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/__snapshots__/snapshot.test.ts.snap @@ -22,6 +22,9 @@ Object { "product_name": "Q#t9o", "product_url": "http://egu.ci/ritpaz", "source": "Q#t9o", + "type": Array [ + "price_drop", + ], "variant_id": "Q#t9o", }, "time": Any, diff --git a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/index.test.ts b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/index.test.ts index 094fbc1c05e..a5aad83d0b1 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/index.test.ts @@ -154,7 +154,76 @@ describe('Braze.ecommerce', () => { }) expect(response.length).toBe(1) - }) + }) + + it('should send Product Viewed event with catalog_type correctly', async () => { + + const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) + deepCopy.properties = { + ...deepCopy.properties, + type: ['price_drop', 'back_in_stock'] + } + const e = createTestEvent(deepCopy) + + const mapping2 = { + ...mapping, + catalog_type: { '@path': '$.properties.type' } + } + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { + alias_name: "alias_name_1", + alias_label: "alias_label_1" + }, + app_id: "test_app_id", + name: "ecommerce.product_viewed", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { + nested_key: "nested_value" + }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + product_url: "https://example.com/prod1", + price: 25, + type: ["price_drop", "back_in_stock"] + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.testAction('ecommerceSingleProduct', { + event: e, + settings, + useDefaultMappings: true, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + }) }) describe('batch events', () => { diff --git a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/generated-types.ts b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/generated-types.ts index 1bf9f69d061..8501e7c43ad 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/generated-types.ts @@ -45,13 +45,29 @@ export interface Payload { */ order_id?: string /** - * Unique identifier for the cart. If no value is passed, Braze will determine a default value (shared across cart, checkout, and order events) for the user cart mapping. + * Unique identifier for the cart. Required for cart_updated. For checkout and order events, if no value is passed, Braze will determine a default value for the user cart mapping. */ cart_id?: string /** * Total monetary value of the cart. */ total_value?: number + /** + * The cart action that was performed (add, remove, or replace). + */ + action?: string + /** + * Subtotal monetary value of the cart before tax and shipping. + */ + subtotal_value?: number + /** + * Tax amount applied to the transaction. + */ + tax?: number + /** + * Shipping cost for the transaction. + */ + shipping?: number /** * Total amount of discounts applied to the order. */ @@ -85,6 +101,10 @@ export interface Payload { * Maximum number of events to include in each batch. Actual batch sizes may be lower. */ batch_size: number + /** + * Required to use Braze catalog trigger features. Accepted values: price_drop, back_in_stock. + */ + catalog_type?: string[] /** * Product details associated with the ecommerce event. */ diff --git a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/index.ts b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/index.ts index d9c5692a859..41b317534d0 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/index.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/index.ts @@ -1,9 +1,10 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { +import { commonFields, product, + catalog_type, } from '../ecommerce/fields' import { send } from '../ecommerce/functions' @@ -12,6 +13,7 @@ const action: ActionDefinition = { description: '(Beta) Send a single product [Ecommerce Recommended event](https://www.braze.com/docs/user_guide/data/activation/custom_data/recommended_events/ecommerce_events) to Braze.', fields: { ...commonFields, + catalog_type, product }, syncMode: { diff --git a/packages/destination-actions/src/destinations/braze/index.ts b/packages/destination-actions/src/destinations/braze/index.ts index 85b600ee994..f099df59585 100644 --- a/packages/destination-actions/src/destinations/braze/index.ts +++ b/packages/destination-actions/src/destinations/braze/index.ts @@ -18,6 +18,8 @@ import triggerCanvas from './triggerCanvas' import { EVENT_NAMES } from './ecommerce/constants' import upsertCatalogItem from './upsertCatalogItem' +import mergeUsers from './mergeUsers' + const destination: DestinationDefinition = { name: 'Braze Cloud Mode (Actions)', slug: 'actions-braze-cloud', @@ -96,12 +98,14 @@ const destination: DestinationDefinition = { triggerCampaign, triggerCanvas, ecommerce, - ecommerceSingleProduct + ecommerceSingleProduct, + mergeUsers }, presets: [ { name: 'Track Calls', - subscribe: 'type = "track" and event != "Order Completed" and event != "Checkout Started" and event != "Order Refunded" and event != "Order Cancelled" and event != "Product Viewed"', + subscribe: + 'type = "track" and event != "Order Completed" and event != "Checkout Started" and event != "Order Refunded" and event != "Order Cancelled" and event != "Product Viewed" and event != "Product Added" and event != "Product Removed"', partnerAction: 'trackEvent', mapping: defaultValues(trackEvent.fields), type: 'automatic' @@ -110,9 +114,9 @@ const destination: DestinationDefinition = { name: 'Order Placed (beta)', subscribe: 'event = "Order Completed"', partnerAction: 'ecommerce', - mapping: { + mapping: { ...defaultValues(ecommerce.fields), - name: EVENT_NAMES.ORDER_PLACED, + name: EVENT_NAMES.ORDER_PLACED, metadata: { order_status_url: { '@path': '$.properties.order_status_url' } } @@ -123,9 +127,9 @@ const destination: DestinationDefinition = { name: 'Checkout Started (beta)', subscribe: 'event = "Checkout Started"', partnerAction: 'ecommerce', - mapping: { + mapping: { ...defaultValues(ecommerce.fields), - name: EVENT_NAMES.CHECKOUT_STARTED, + name: EVENT_NAMES.CHECKOUT_STARTED, metadata: { checkout_url: { '@path': '$.properties.checkout_url' } } @@ -136,9 +140,9 @@ const destination: DestinationDefinition = { name: 'Order Refunded (beta)', subscribe: 'event = "Order Refunded"', partnerAction: 'ecommerce', - mapping: { + mapping: { ...defaultValues(ecommerce.fields), - name: EVENT_NAMES.ORDER_REFUNDED, + name: EVENT_NAMES.ORDER_REFUNDED, metadata: { order_status_url: { '@path': '$.properties.order_status_url' } } @@ -149,9 +153,9 @@ const destination: DestinationDefinition = { name: 'Order Cancelled (beta)', subscribe: 'event = "Order Cancelled"', partnerAction: 'ecommerce', - mapping: { + mapping: { ...defaultValues(ecommerce.fields), - name: EVENT_NAMES.ORDER_CANCELLED, + name: EVENT_NAMES.ORDER_CANCELLED, metadata: { order_status_url: { '@path': '$.properties.order_status_url' } } @@ -162,12 +166,56 @@ const destination: DestinationDefinition = { name: 'Product Viewed (beta)', subscribe: 'event = "Product Viewed"', partnerAction: 'ecommerceSingleProduct', - mapping: { + mapping: { ...defaultValues(ecommerceSingleProduct.fields), name: EVENT_NAMES.PRODUCT_VIEWED }, type: 'automatic' }, + { + name: 'Product Added (beta)', + subscribe: 'event = "Product Added"', + partnerAction: 'ecommerce', + mapping: { + ...defaultValues(ecommerce.fields), + name: EVENT_NAMES.CART_UPDATED, + action: 'add', + products: [ + { + product_id: { '@path': '$.properties.product_id' }, + product_name: { '@path': '$.properties.name' }, + variant_id: { '@path': '$.properties.variant' }, + image_url: { '@path': '$.properties.image_url' }, + product_url: { '@path': '$.properties.url' }, + quantity: { '@path': '$.properties.quantity' }, + price: { '@path': '$.properties.price' } + } + ] + }, + type: 'automatic' + }, + { + name: 'Product Removed (beta)', + subscribe: 'event = "Product Removed"', + partnerAction: 'ecommerce', + mapping: { + ...defaultValues(ecommerce.fields), + name: EVENT_NAMES.CART_UPDATED, + action: 'remove', + products: [ + { + product_id: { '@path': '$.properties.product_id' }, + product_name: { '@path': '$.properties.name' }, + variant_id: { '@path': '$.properties.variant' }, + image_url: { '@path': '$.properties.image_url' }, + product_url: { '@path': '$.properties.url' }, + quantity: { '@path': '$.properties.quantity' }, + price: { '@path': '$.properties.price' } + } + ] + }, + type: 'automatic' + }, { name: 'Identify Calls', subscribe: 'type = "identify"', diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 00000000000..b3ec3cd4d39 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Braze's mergeUsers destination action: all fields 1`] = ` +Object { + "merge_updates": Array [ + Object { + "identifier_to_keep": Object { + "email": "keep@example.com", + "prioritization": Array [ + "unidentified", + ], + }, + "identifier_to_merge": Object { + "email": "merge@example.com", + "prioritization": Array [ + "identified", + "most_recently_updated", + ], + }, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's mergeUsers destination action: required fields 1`] = ` +Object { + "merge_updates": Array [ + Object { + "identifier_to_keep": Object { + "external_id": "user-to-keep", + }, + "identifier_to_merge": Object { + "external_id": "user-to-merge", + }, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's mergeUsers destination action: required fields 2`] = ` +Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer 8$QYsxcxpjIn)Y", + ], + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts new file mode 100644 index 00000000000..ecf4b9dcfc6 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts @@ -0,0 +1,699 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent, MultiStatusResponse } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const settings = { + app_id: 'my-app-id', + api_key: 'my-api-key', + endpoint: 'https://rest.iad-01.braze.com' +} + +describe('Braze.mergeUsers', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('should merge users with external_id identifiers', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: 'user-to-merge-456', + keepIdType: 'external_id', + keepIdValue: 'user-to-keep-123' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({ message: 'success' }) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { external_id: 'user-to-merge-456' }, + identifier_to_keep: { external_id: 'user-to-keep-123' } + } + ] + }) + }) + + it('should merge users with user_alias identifiers', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'user_alias', + previousAliasIdValue: { alias_name: 'merge-alias', alias_label: 'segment' }, + keepIdType: 'user_alias', + keepAliasIdValue: { alias_name: 'keep-alias', alias_label: 'segment' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { user_alias: { alias_name: 'merge-alias', alias_label: 'segment' } }, + identifier_to_keep: { user_alias: { alias_name: 'keep-alias', alias_label: 'segment' } } + } + ] + }) + }) + + it('should merge users with email identifiers', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'email', + previousIdValue: 'merge@example.com', + previousIdPrioritization: 'identified', + keepIdType: 'email', + keepIdValue: 'keep@example.com', + keepIdPrioritization: 'identified' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { email: 'merge@example.com', prioritization: ['identified'] }, + identifier_to_keep: { email: 'keep@example.com', prioritization: ['identified'] } + } + ] + }) + }) + + it('should merge users with phone identifiers', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'phone', + previousIdValue: '+14155551234', + previousIdPrioritization: 'identified', + keepIdType: 'phone', + keepIdValue: '+14155555678', + keepIdPrioritization: 'identified' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { phone: '+14155551234', prioritization: ['identified'] }, + identifier_to_keep: { phone: '+14155555678', prioritization: ['identified'] } + } + ] + }) + }) + + it('should merge users with mixed identifier types', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'email', + previousIdValue: 'merge@example.com', + previousIdPrioritization: 'identified', + keepIdType: 'external_id', + keepIdValue: 'user-to-keep-123' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { email: 'merge@example.com', prioritization: ['identified'] }, + identifier_to_keep: { external_id: 'user-to-keep-123' } + } + ] + }) + }) + + it('should merge users with user_alias to merge and external_id to keep', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'user_alias', + previousAliasIdValue: { alias_name: 'anon-alias', alias_label: 'segment' }, + keepIdType: 'external_id', + keepIdValue: 'known-user-123' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { user_alias: { alias_name: 'anon-alias', alias_label: 'segment' } }, + identifier_to_keep: { external_id: 'known-user-123' } + } + ] + }) + }) + + it('should throw error when previousIdValue is missing for a non-alias type', async () => { + const event = createTestEvent({ type: 'alias' }) + + await expect( + testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'external_id', + keepIdType: 'external_id', + keepIdValue: 'user-to-keep-123' + } + }) + ).rejects.toThrowError("missing the required field 'previousIdValue'") + }) + + it('should throw error when keepIdValue is missing for a non-alias type', async () => { + const event = createTestEvent({ type: 'alias' }) + + await expect( + testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: 'user-to-merge-456', + keepIdType: 'external_id' + } + }) + ).rejects.toThrowError("missing the required field 'keepIdValue'") + }) + + it('should throw error when previousAliasIdValue is incomplete (missing alias_label)', async () => { + const event = createTestEvent({ type: 'alias' }) + + await expect( + testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'user_alias', + previousAliasIdValue: { alias_name: 'merge-alias' }, + keepIdType: 'external_id', + keepIdValue: 'user-to-keep-123' + } + }) + ).rejects.toThrowError("missing the required field 'alias_label'") + }) + + it('should throw error when keepAliasIdValue is incomplete (missing alias_name)', async () => { + const event = createTestEvent({ type: 'alias' }) + + await expect( + testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: 'user-to-merge-456', + keepIdType: 'user_alias', + keepAliasIdValue: { alias_label: 'segment' } + } + }) + ).rejects.toThrowError("missing the required field 'alias_name'") + }) + + it('should use userId from event as keepIdValue via path mapping', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias', userId: 'user-to-keep-123' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: 'user-to-merge-456', + keepIdType: 'external_id', + keepIdValue: { '@path': '$.userId' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { external_id: 'user-to-merge-456' }, + identifier_to_keep: { external_id: 'user-to-keep-123' } + } + ] + }) + }) + + it('should include multi-value prioritization array', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'email', + previousIdValue: 'merge@example.com', + previousIdPrioritization: 'identified,most_recently_updated', + keepIdType: 'email', + keepIdValue: 'keep@example.com', + keepIdPrioritization: 'unidentified,least_recently_updated' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { email: 'merge@example.com', prioritization: ['identified', 'most_recently_updated'] }, + identifier_to_keep: { email: 'keep@example.com', prioritization: ['unidentified', 'least_recently_updated'] } + } + ] + }) + }) + + it('should not include prioritization when value is null', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'email', + previousIdValue: 'merge@example.com', + previousIdPrioritization: null, + keepIdType: 'email', + keepIdValue: 'keep@example.com', + keepIdPrioritization: null + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const json = responses[0].options.json as Record + const mergeUpdates = (json.merge_updates as Array>>)[0] + expect(mergeUpdates.identifier_to_merge).not.toHaveProperty('prioritization') + expect(mergeUpdates.identifier_to_keep).not.toHaveProperty('prioritization') + }) + + it('should not include prioritization for external_id identifiers', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: 'user-to-merge-456', + keepIdType: 'external_id', + keepIdValue: 'user-to-keep-123' + } + }) + + expect(responses.length).toBe(1) + const json = responses[0].options.json as Record + const mergeUpdates = (json.merge_updates as Array>>)[0] + expect(mergeUpdates.identifier_to_merge).not.toHaveProperty('prioritization') + expect(mergeUpdates.identifier_to_keep).not.toHaveProperty('prioritization') + }) +}) + +describe('Braze.mergeUsers batch', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('should batch multiple merge operations into a single request', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const events: SegmentEvent[] = [ + createTestEvent({ type: 'alias', userId: 'keep-1', previousId: 'merge-1' }), + createTestEvent({ type: 'alias', userId: 'keep-2', previousId: 'merge-2' }), + createTestEvent({ type: 'alias', userId: 'keep-3', previousId: 'merge-3' }) + ] + + const responses = await testDestination.testBatchAction('mergeUsers', { + events, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: { '@path': '$.previousId' }, + keepIdType: 'external_id', + keepIdValue: { '@path': '$.userId' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { external_id: 'merge-1' }, + identifier_to_keep: { external_id: 'keep-1' } + }, + { + identifier_to_merge: { external_id: 'merge-2' }, + identifier_to_keep: { external_id: 'keep-2' } + }, + { + identifier_to_merge: { external_id: 'merge-3' }, + identifier_to_keep: { external_id: 'keep-3' } + } + ] + }) + }) + + it('should batch with mixed identifier types', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const events: SegmentEvent[] = [ + createTestEvent({ type: 'alias', userId: 'keep-ext-1', previousId: 'merge-ext-1' }), + createTestEvent({ type: 'alias', userId: 'keep-ext-2', previousId: 'merge-ext-2' }) + ] + + const responses = await testDestination.testBatchAction('mergeUsers', { + events, + settings, + mapping: { + previousIdType: 'email', + previousIdValue: { '@path': '$.previousId' }, + previousIdPrioritization: 'identified', + keepIdType: 'external_id', + keepIdValue: { '@path': '$.userId' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { email: 'merge-ext-1', prioritization: ['identified'] }, + identifier_to_keep: { external_id: 'keep-ext-1' } + }, + { + identifier_to_merge: { email: 'merge-ext-2', prioritization: ['identified'] }, + identifier_to_keep: { external_id: 'keep-ext-2' } + } + ] + }) + }) + + it('should batch with user_alias identifiers', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const events: SegmentEvent[] = [ + createTestEvent({ type: 'alias', userId: 'keep-1' }), + createTestEvent({ type: 'alias', userId: 'keep-2' }) + ] + + const responses = await testDestination.testBatchAction('mergeUsers', { + events, + settings, + mapping: { + previousIdType: 'user_alias', + previousAliasIdValue: { alias_name: 'anon-alias', alias_label: 'segment' }, + keepIdType: 'external_id', + keepIdValue: { '@path': '$.userId' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { user_alias: { alias_name: 'anon-alias', alias_label: 'segment' } }, + identifier_to_keep: { external_id: 'keep-1' } + }, + { + identifier_to_merge: { user_alias: { alias_name: 'anon-alias', alias_label: 'segment' } }, + identifier_to_keep: { external_id: 'keep-2' } + } + ] + }) + }) + + it('should batch with a single event', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const events: SegmentEvent[] = [createTestEvent({ type: 'alias', userId: 'keep-1', previousId: 'merge-1' })] + + const responses = await testDestination.testBatchAction('mergeUsers', { + events, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: { '@path': '$.previousId' }, + keepIdType: 'external_id', + keepIdValue: { '@path': '$.userId' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { external_id: 'merge-1' }, + identifier_to_keep: { external_id: 'keep-1' } + } + ] + }) + }) + + it('should batch with multi-value prioritization', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const events: SegmentEvent[] = [ + createTestEvent({ type: 'alias', previousId: 'merge1@example.com' }), + createTestEvent({ type: 'alias', previousId: 'merge2@example.com' }) + ] + + const responses = await testDestination.testBatchAction('mergeUsers', { + events, + settings, + mapping: { + previousIdType: 'email', + previousIdValue: { '@path': '$.previousId' }, + previousIdPrioritization: 'identified,most_recently_updated', + keepIdType: 'phone', + keepIdValue: '+14155551234', + keepIdPrioritization: 'unidentified,least_recently_updated' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { email: 'merge1@example.com', prioritization: ['identified', 'most_recently_updated'] }, + identifier_to_keep: { phone: '+14155551234', prioritization: ['unidentified', 'least_recently_updated'] } + }, + { + identifier_to_merge: { email: 'merge2@example.com', prioritization: ['identified', 'most_recently_updated'] }, + identifier_to_keep: { phone: '+14155551234', prioritization: ['unidentified', 'least_recently_updated'] } + } + ] + }) + }) + + it('should batch without prioritization when not provided', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const events: SegmentEvent[] = [ + createTestEvent({ type: 'alias', userId: 'keep-1', previousId: 'merge-1' }), + createTestEvent({ type: 'alias', userId: 'keep-2', previousId: 'merge-2' }) + ] + + const responses = await testDestination.testBatchAction('mergeUsers', { + events, + settings, + mapping: { + previousIdType: 'email', + previousIdValue: { '@path': '$.previousId' }, + previousIdPrioritization: null, + keepIdType: 'email', + keepIdValue: { '@path': '$.userId' }, + keepIdPrioritization: null + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const json = responses[0].options.json as Record + const mergeUpdates = json.merge_updates as Array>> + expect(mergeUpdates).toHaveLength(2) + expect(mergeUpdates[0].identifier_to_merge).not.toHaveProperty('prioritization') + expect(mergeUpdates[0].identifier_to_keep).not.toHaveProperty('prioritization') + expect(mergeUpdates[1].identifier_to_merge).not.toHaveProperty('prioritization') + expect(mergeUpdates[1].identifier_to_keep).not.toHaveProperty('prioritization') + }) +}) + +describe('Braze.mergeUsers multiStatus', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('should handle a batch with mixed validation failures and successes', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const events: SegmentEvent[] = [ + createTestEvent({ type: 'alias', userId: 'keep-1', previousId: 'merge-1' }), + createTestEvent({ type: 'alias', userId: '', previousId: 'merge-2' }), + createTestEvent({ type: 'alias', userId: 'keep-3', previousId: 'merge-3' }), + createTestEvent({ type: 'alias', userId: '', previousId: '' }) + ] + + const response = await testDestination.executeBatch('mergeUsers', { + events, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: { '@path': '$.previousId' }, + keepIdType: 'external_id', + keepIdValue: { '@path': '$.userId' } + } + }) + + expect(response[0]).toMatchObject({ + status: 200, + body: { merge_updates: [{ identifier_to_merge: { external_id: 'merge-1' }, identifier_to_keep: { external_id: 'keep-1' } }] } + }) + + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'ID value to keep must be provided.', + errorreporter: 'DESTINATION' + }) + + expect(response[2]).toMatchObject({ + status: 200, + body: { merge_updates: [{ identifier_to_merge: { external_id: 'merge-3' }, identifier_to_keep: { external_id: 'keep-3' } }] } + }) + + expect(response[3]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }) + }) + + it('should handle a batch with validation failures and an unsuccessful HTTP response', async () => { + nock(settings.endpoint).post('/users/merge').reply(400, { message: 'Invalid merge request' }) + + const events: SegmentEvent[] = [ + createTestEvent({ type: 'alias', userId: 'keep-1', previousId: 'merge-1' }), + createTestEvent({ type: 'alias', userId: '', previousId: 'merge-2' }), + createTestEvent({ type: 'alias', userId: 'keep-3', previousId: 'merge-3' }) + ] + + const response = await testDestination.executeBatch('mergeUsers', { + events, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: { '@path': '$.previousId' }, + keepIdType: 'external_id', + keepIdValue: { '@path': '$.userId' } + } + }) + + expect(response[0]).toMatchObject({ + status: 400, + errorreporter: 'DESTINATION' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'ID value to keep must be provided.', + errorreporter: 'DESTINATION' + }) + + expect(response[2]).toMatchObject({ + status: 400, + errorreporter: 'DESTINATION' + }) + }) + + it('should handle a batch where all payloads fail validation', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ type: 'alias', userId: '', previousId: 'merge-1' }), + createTestEvent({ type: 'alias', userId: '', previousId: 'merge-2' }) + ] + + const response = await testDestination.executeBatch('mergeUsers', { + events, + settings, + mapping: { + previousIdType: 'external_id', + previousIdValue: { '@path': '$.previousId' }, + keepIdType: 'external_id', + keepIdValue: { '@path': '$.userId' } + } + }) + + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'ID value to keep must be provided.', + errorreporter: 'DESTINATION' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'ID value to keep must be provided.', + errorreporter: 'DESTINATION' + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..7c418631c43 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts @@ -0,0 +1,81 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'mergeUsers' +const destinationSlug = 'Braze' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction(actionSlug, { + event, + mapping: { + previousIdType: 'external_id', + previousIdValue: 'user-to-merge', + keepIdType: 'external_id', + keepIdValue: 'user-to-keep' + }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ type: 'alias' }) + + const responses = await testDestination.testAction(actionSlug, { + event, + mapping: { + previousIdType: 'email', + previousIdValue: 'merge@example.com', + previousIdPrioritization: 'identified,most_recently_updated', + keepIdType: 'email', + keepIdValue: 'keep@example.com', + keepIdPrioritization: 'unidentified' + }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts new file mode 100644 index 00000000000..a7f9efe044c --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts @@ -0,0 +1,173 @@ +import { RequestClient, PayloadValidationError, MultiStatusResponse, JSONLikeObject, HTTPError } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { Payload as MergeUsersPayload } from './generated-types' +import { MergeUsersItem, MergeUsersJSON, MergeIdentifierType, Prioritization } from './types' +import { UserAlias } from '../userAlias' + +export async function send(request: RequestClient, settings: Settings, payloads: MergeUsersPayload[], isBatch: boolean) { + const multiStatusResponse = new MultiStatusResponse() + const validItems: MergeUsersItem[] = [] + const validPayloadIndicesBitmap: number[] = [] + + payloads.forEach((payload, index) => { + try { + const item = getJsonItem(payload) + validItems.push(item) + validPayloadIndicesBitmap.push(index) + } catch (error) { + if (isBatch) { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: (error as PayloadValidationError).message, + sent: payload as unknown as JSONLikeObject + }) + } else { + throw error + } + } + }) + + if (validItems.length === 0) { + return multiStatusResponse + } + + const items: MergeUsersJSON = { + merge_updates: validItems + } + + try { + const response = await request(`${settings.endpoint}/users/merge`, { + method: 'post', + ...(validItems.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + json: items + }) + + for (let i = 0; i < validPayloadIndicesBitmap.length; i++) { + const originalIndex = validPayloadIndicesBitmap[i] + if (multiStatusResponse.isErrorResponseAtIndex(originalIndex)) { + continue + } + multiStatusResponse.setSuccessResponseAtIndex(originalIndex, { + status: response.status, + sent: payloads[originalIndex] as unknown as JSONLikeObject, + body: { merge_updates: [validItems[i]] } as unknown as JSONLikeObject + }) + } + } catch (error) { + if (isBatch && error instanceof HTTPError) { + for (let i = 0; i < validPayloadIndicesBitmap.length; i++) { + const originalIndex = validPayloadIndicesBitmap[i] + if (multiStatusResponse.isErrorResponseAtIndex(originalIndex)) { + continue + } + multiStatusResponse.setErrorResponseAtIndex(originalIndex, { + status: error.response.status, + errormessage: error.message, + sent: payloads[originalIndex] as unknown as JSONLikeObject, + body: { merge_updates: [validItems[i]] } as unknown as JSONLikeObject + }) + } + } else { + throw error + } + } + + return multiStatusResponse +} + +function getJsonItem(payload: MergeUsersPayload): MergeUsersItem { + const { + previousIdType, + previousIdValue, + previousAliasIdValue, + keepIdType, + keepIdValue, + keepAliasIdValue, + previousIdPrioritization, + keepIdPrioritization + } = payload + + const previousId = getMergeIdentifier( + previousIdType as MergeIdentifierType, + 'merge', + previousIdValue, + previousAliasIdValue + ) + const keepId = getMergeIdentifier(keepIdType as MergeIdentifierType, 'keep', keepIdValue, keepAliasIdValue) + + const previousIdPri = toPrioritization(previousIdPrioritization) + const keepIdPri = toPrioritization(keepIdPrioritization) + + const item: MergeUsersItem = { + identifier_to_merge: { + [previousIdType]: previousId, + ...(Array.isArray(previousIdPri) && (previousIdType === 'email' || previousIdType === 'phone') + ? { prioritization: previousIdPri } + : {}) + }, + identifier_to_keep: { + [keepIdType]: keepId, + ...(Array.isArray(keepIdPri) && (keepIdType === 'email' || keepIdType === 'phone') + ? { prioritization: keepIdPri } + : {}) + } + } + + return item +} + +function getMergeIdentifier( + type: MergeIdentifierType, + label: string, + value?: string, + aliasValue?: UserAlias +): string | UserAlias { + if (type === 'user_alias') { + const { alias_label, alias_name } = aliasValue || {} + if (!alias_label || !alias_name) { + throw new PayloadValidationError( + `When Type of Identifier to ${label} is user_alias, alias_label and alias_name must be provided.` + ) + } + return { alias_label, alias_name } + } + + if (!value) { + throw new PayloadValidationError(`ID value to ${label} must be provided.`) + } + + return value +} + +function toPrioritization(value: string | undefined | null): Prioritization | undefined { + if (!value) return undefined + const parts = value + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + if (parts.length === 0) return undefined + return parts as Prioritization +} + +export function getPrioritizationChoices() { + return [ + { value: 'identified', label: 'Identified' }, + { value: 'unidentified', label: 'Unidentified' }, + { value: 'most_recently_updated', label: 'Most Recently Updated' }, + { value: 'least_recently_updated', label: 'Least Recently Updated' }, + { value: 'identified,most_recently_updated', label: 'Identified, Most Recently Updated' }, + { value: 'unidentified,most_recently_updated', label: 'Unidentified, Most Recently Updated' }, + { value: 'identified,least_recently_updated', label: 'Identified, Least Recently Updated' }, + { value: 'unidentified,least_recently_updated', label: 'Unidentified, Least Recently Updated' } + ] +} + +export function getSupportedIdentifierChoices() { + return [ + { label: 'External ID', value: 'external_id' }, + { label: 'User Alias', value: 'user_alias' }, + { label: 'Email', value: 'email' }, + { label: 'Phone', value: 'phone' } + ] +} diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts new file mode 100644 index 00000000000..887dfa31790 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts @@ -0,0 +1,58 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The type of identifier for the user to be merged. One of: external_id, user_alias, email, or phone. + */ + previousIdType: string + /** + * The value of the user identifier to be merged. + */ + previousIdValue?: string + /** + * The value of the user alias identifier to be merged. Required if the previous identifier type is user_alias. + */ + previousAliasIdValue?: { + /** + * The label of the user alias for the user to be merged. + */ + alias_label: string + /** + * The name of the user alias for the user to be merged. + */ + alias_name: string + } + /** + * The type of the user identifier to be kept. One of: external_id, user_alias, email, or phone. + */ + keepIdType: string + /** + * The value of the user identifier to be kept. + */ + keepIdValue?: string + /** + * The value of the user alias identifier for the user to be kept. Required if the keep identifier type is user_alias. + */ + keepAliasIdValue?: { + /** + * The label of the user alias to be kept. + */ + alias_label: string + /** + * The name of the user alias to be kept. + */ + alias_name: string + } + /** + * Rule determining which user to merge if multiple users are found. + */ + keepIdPrioritization?: string | null + /** + * Rule determining which user to merge if multiple users are found. + */ + previousIdPrioritization?: string | null + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number +} diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts new file mode 100644 index 00000000000..391d0e2a18e --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -0,0 +1,222 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { send, getPrioritizationChoices, getSupportedIdentifierChoices } from './functions' + +const action: ActionDefinition = { + title: 'Merge Users [Beta]', + description: + 'Merge one identified user into another identified user. The merge will occur asynchronously and can take between 5-10 minutes. This Action is in Public Beta.', + defaultSubscription: 'type = "alias"', + fields: { + previousIdType: { + label: 'Type of Identifier to merge', + description: + 'The type of identifier for the user to be merged. One of: external_id, user_alias, email, or phone.', + type: 'string', + required: true, + choices: getSupportedIdentifierChoices(), + default: 'external_id' + }, + previousIdValue: { + label: 'ID value to merge', + description: 'The value of the user identifier to be merged.', + type: 'string', + required: { + match: 'all', + conditions: [ + { + fieldKey: 'previousIdType', + operator: 'is_not', + value: 'user_alias' + } + ] + }, + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'previousIdType', + operator: 'is_not', + value: 'user_alias' + } + ] + }, + default: { + '@path': '$.previousId' + } + }, + previousAliasIdValue: { + label: 'User Alias value to merge', + description: + 'The value of the user alias identifier to be merged. Required if the previous identifier type is user_alias.', + type: 'object', + required: { + match: 'all', + conditions: [ + { + fieldKey: 'previousIdType', + operator: 'is', + value: 'user_alias' + } + ] + }, + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'previousIdType', + operator: 'is', + value: 'user_alias' + } + ] + }, + properties: { + alias_label: { + label: 'User Alias Label', + description: 'The label of the user alias for the user to be merged.', + type: 'string', + required: true + }, + alias_name: { + label: 'User Alias Name', + description: 'The name of the user alias for the user to be merged.', + type: 'string', + required: true + } + } + }, + keepIdType: { + label: 'Type of Identifier to keep', + description: 'The type of the user identifier to be kept. One of: external_id, user_alias, email, or phone.', + type: 'string', + required: true, + choices: getSupportedIdentifierChoices(), + default: 'external_id' + }, + keepIdValue: { + label: 'ID value to keep', + description: 'The value of the user identifier to be kept.', + type: 'string', + required: { + match: 'all', + conditions: [ + { + fieldKey: 'keepIdType', + operator: 'is_not', + value: 'user_alias' + } + ] + }, + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'keepIdType', + operator: 'is_not', + value: 'user_alias' + } + ] + }, + default: { '@path': '$.userId' } + }, + keepAliasIdValue: { + label: 'User Alias value to keep', + description: + 'The value of the user alias identifier for the user to be kept. Required if the keep identifier type is user_alias.', + type: 'object', + required: { + match: 'all', + conditions: [ + { + fieldKey: 'keepIdType', + operator: 'is', + value: 'user_alias' + } + ] + }, + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'keepIdType', + operator: 'is', + value: 'user_alias' + } + ] + }, + properties: { + alias_label: { + label: 'User Alias Label', + description: 'The label of the user alias to be kept.', + type: 'string', + required: true + }, + alias_name: { + label: 'User Alias Name', + description: 'The name of the user alias to be kept.', + type: 'string', + required: true + } + } + }, + keepIdPrioritization: { + label: 'Rule Prioritization (ID to keep)', + description: 'Rule determining which user to merge if multiple users are found.', + type: 'string', + allowNull: true, + choices: getPrioritizationChoices(), + required: { + match: 'any', + conditions: [ + { + fieldKey: 'keepIdType', + operator: 'is', + value: 'email' + }, + { + fieldKey: 'keepIdType', + operator: 'is', + value: 'phone' + } + ] + } + }, + previousIdPrioritization: { + label: 'Rule Prioritization (ID to merge)', + description: 'Rule determining which user to merge if multiple users are found.', + type: 'string', + allowNull: true, + choices: getPrioritizationChoices(), + required: { + match: 'any', + conditions: [ + { + fieldKey: 'previousIdType', + operator: 'is', + value: 'email' + }, + { + fieldKey: 'previousIdType', + operator: 'is', + value: 'phone' + } + ] + } + }, + batch_size: { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + default: 50 + } + }, + perform: (request, { settings, payload }) => { + return send(request, settings, [payload], false) + }, + performBatch: (request, { settings, payload }) => { + return send(request, settings, payload, true) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts new file mode 100644 index 00000000000..2770391559b --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts @@ -0,0 +1,40 @@ +export type MergeUsersJSON = { + merge_updates: Array +} + +export type MergeUsersItem = { + identifier_to_merge: { + // Only one of the following + external_id?: string + user_alias?: { + alias_label: string + alias_name: string + } + email?: string + phone?: string + prioritization?: Prioritization + } + identifier_to_keep: { + // Only one of the following + external_id?: string + user_alias?: { + alias_label: string + alias_name: string + } + email?: string + phone?: string + prioritization?: Prioritization + } +} + +export type Prioritization = + | ['identified'] + | ['unidentified'] + | ['most_recently_updated'] + | ['least_recently_updated'] + | ['identified', 'most_recently_updated'] + | ['unidentified', 'most_recently_updated'] + | ['identified', 'least_recently_updated'] + | ['unidentified', 'least_recently_updated'] + +export type MergeIdentifierType = 'external_id' | 'user_alias' | 'email' | 'phone' diff --git a/packages/destination-actions/src/destinations/braze/userAlias.ts b/packages/destination-actions/src/destinations/braze/userAlias.ts index 285b6cecd72..081b122615b 100644 --- a/packages/destination-actions/src/destinations/braze/userAlias.ts +++ b/packages/destination-actions/src/destinations/braze/userAlias.ts @@ -1,4 +1,4 @@ -interface UserAlias { +export interface UserAlias { alias_name: string alias_label: string } diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateObject.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateObject.test.ts index be10d2dd187..c3628bd4549 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateObject.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateObject.test.ts @@ -1,10 +1,197 @@ import { createTestEvent } from '@segment/actions-core' +import { MultiStatusResponse } from '@segment/actions-core' import { Settings } from '../generated-types' import dayjs from '../../../lib/dayjs' import { getDefaultMappings, testRunner } from '../test-helper' +import createUpdateObject from '../createUpdateObject' describe('CustomerIO', () => { describe('createUpdateObject', () => { + describe('performBatch', () => { + it('should return a merged MultiStatusResponse with items at original indices', async () => { + const request = jest.fn() + .mockResolvedValueOnce({ status: 200, data: { errors: [] } }) + .mockResolvedValueOnce({ status: 200, data: { errors: [] } }) + + const payloads = [ + { id: 'grp-1', user_id: 'user-1', object_type_id: '1', convert_timestamp: false }, + { id: 'grp-2', anonymous_id: 'anon-1', object_type_id: '1', convert_timestamp: false }, + { id: 'grp-3', user_id: 'user-2', object_type_id: '1', convert_timestamp: false } + ] + + const settings = { siteId: '12345', apiKey: 'abcde', accountRegion: 'US 🇺🇸' } + const response = await createUpdateObject.performBatch!(request as any, { + payload: payloads, + settings, + rawData: [], + rawMapping: {}, + audienceSettings: {}, + features: {}, + statsContext: {} as any, + logger: {} as any, + engageDestinationCache: {} as any, + transactionContext: {} as any, + stateContext: {} as any, + subscriptionMetadata: {} as any, + hookOutputs: {} + } as any) + + expect(response).toBeInstanceOf(MultiStatusResponse) + const multistatus = response as MultiStatusResponse + expect(multistatus.length()).toBe(3) + expect(multistatus.getAllResponses().map((r) => r.value())).toEqual([ + { + status: 200, + body: { + anonymous_id: undefined, + attributes: undefined, + cio_relationships: [{ identifiers: { id: 'user-1' } }], + object_id: 'grp-1', + object_type_id: '1', + person_id: 'user-1' + }, + sent: { + type: 'object', + action: 'identify', + identifiers: { object_id: 'grp-1', object_type_id: '1' }, + attributes: undefined, + cio_relationships: [{ identifiers: { id: 'user-1' } }] + } + }, + { + status: 200, + body: { + anonymous_id: 'anon-1', + attributes: undefined, + cio_relationships: [{ identifiers: { anonymous_id: 'anon-1' } }], + object_id: 'grp-2', + object_type_id: '1', + person_id: undefined + }, + sent: { + type: 'object', + action: 'identify_anonymous', + identifiers: { object_id: 'grp-2', object_type_id: '1' }, + attributes: { anonymous_id: 'anon-1' }, + cio_relationships: [{ identifiers: { anonymous_id: 'anon-1' } }] + } + }, + { + status: 200, + body: { + anonymous_id: undefined, + attributes: undefined, + cio_relationships: [{ identifiers: { id: 'user-2' } }], + object_id: 'grp-3', + object_type_id: '1', + person_id: 'user-2' + }, + sent: { + type: 'object', + action: 'identify', + identifiers: { object_id: 'grp-3', object_type_id: '1' }, + attributes: undefined, + cio_relationships: [{ identifiers: { id: 'user-2' } }] + } + } + ]) + }) + + it('should report per-item errors at the correct original indices', async () => { + const request = jest.fn() + .mockResolvedValueOnce({ + status: 200, + data: { errors: [{ batch_index: 0, reason: 'invalid', message: 'Object ID invalid' }] } + }) + .mockResolvedValueOnce({ status: 200, data: { errors: [] } }) + + const payloads = [ + { id: 'grp-1', user_id: 'user-1', object_type_id: '1', convert_timestamp: false }, + { id: 'grp-2', anonymous_id: 'anon-1', object_type_id: '1', convert_timestamp: false }, + { id: 'grp-3', user_id: 'user-2', object_type_id: '1', convert_timestamp: false } + ] + + const settings = { siteId: '12345', apiKey: 'abcde', accountRegion: 'US 🇺🇸' } + const response = await createUpdateObject.performBatch!(request as any, { + payload: payloads, + settings, + rawData: [], + rawMapping: {}, + audienceSettings: {}, + features: {}, + statsContext: {} as any, + logger: {} as any, + engageDestinationCache: {} as any, + transactionContext: {} as any, + stateContext: {} as any, + subscriptionMetadata: {} as any, + hookOutputs: {} + } as any) + + expect(response).toBeInstanceOf(MultiStatusResponse) + const multistatus = response as MultiStatusResponse + expect(multistatus.length()).toBe(3) + expect(multistatus.getAllResponses().map((r) => r.value())).toEqual([ + { + status: 400, + errormessage: 'Object ID invalid', + errortype: 'BAD_REQUEST', + body: { + anonymous_id: undefined, + attributes: undefined, + cio_relationships: [{ identifiers: { id: 'user-1' } }], + object_id: 'grp-1', + object_type_id: '1', + person_id: 'user-1' + }, + sent: { + type: 'object', + action: 'identify', + identifiers: { object_id: 'grp-1', object_type_id: '1' }, + attributes: undefined, + cio_relationships: [{ identifiers: { id: 'user-1' } }] + } + }, + { + status: 200, + body: { + anonymous_id: 'anon-1', + attributes: undefined, + cio_relationships: [{ identifiers: { anonymous_id: 'anon-1' } }], + object_id: 'grp-2', + object_type_id: '1', + person_id: undefined + }, + sent: { + type: 'object', + action: 'identify_anonymous', + identifiers: { object_id: 'grp-2', object_type_id: '1' }, + attributes: { anonymous_id: 'anon-1' }, + cio_relationships: [{ identifiers: { anonymous_id: 'anon-1' } }] + } + }, + { + status: 200, + body: { + anonymous_id: undefined, + attributes: undefined, + cio_relationships: [{ identifiers: { id: 'user-2' } }], + object_id: 'grp-3', + object_type_id: '1', + person_id: 'user-2' + }, + sent: { + type: 'object', + action: 'identify', + identifiers: { object_id: 'grp-3', object_type_id: '1' }, + attributes: undefined, + cio_relationships: [{ identifiers: { id: 'user-2' } }] + } + } + ]) + }) + }) + testRunner((settings: Settings, action: Function) => { it('should work with default mappings when userId is supplied', async () => { const userId = 'abc123' diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts index 88aab5d3886..9354705239e 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts @@ -1,4 +1,5 @@ -import { convertValidTimestamp, resolveIdentifiers, isIsoDate } from '../utils' +import { HTTPError, MultiStatusResponse } from '@segment/actions-core' +import { convertValidTimestamp, isIsoDate, parseResponse, resolveIdentifiers, sendBatch } from '../utils' describe('isIsoDate', () => { it('should return true for valid ISO date with fractional seconds from 1-9 digits', () => { @@ -80,8 +81,417 @@ describe('resolveIdentifiers', () => { }) }) +describe('sendBatch', () => { + it('should parse 207 multi-status Track API responses', async () => { + const request = jest.fn().mockResolvedValue({ + status: 207, + data: { + errors: [ + { + batch_index: 1, + reason: 'invalid', + message: 'Attribute value too long' + } + ] + } + }) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + }, + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-2', name: 'Second' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response!.length()).toBe(2) + expect(response!.getResponseAtIndex(0).value()).toEqual({ + status: 207, + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }) + expect(response!.getResponseAtIndex(1).value()).toEqual({ + status: 400, + errormessage: 'Attribute value too long', + errortype: 'BAD_REQUEST', + body: { person_id: 'user-2', name: 'Second' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-2' }, name: 'Second' } + }) + }) + + it('should parse 200 Track API responses that still contain batch errors', async () => { + const request = jest.fn().mockResolvedValue({ + status: 200, + data: { + errors: [ + { + batch_index: 0, + reason: 'required', + field: 'name', + message: 'Name is required' + } + ] + } + }) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response!.getResponseAtIndex(0).value()).toEqual({ + status: 400, + errormessage: 'Name is required', + errortype: 'BAD_REQUEST', + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }) + }) + + it('should return all-success responses when the Track API reports an empty errors array', async () => { + const request = jest.fn().mockResolvedValue({ + status: 200, + data: { + errors: [] + } + }) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + }, + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-2', name: 'Second' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response!.getAllResponses().map((result) => result.value())).toEqual([ + { + status: 200, + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }, + { + status: 200, + body: { person_id: 'user-2', name: 'Second' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-2' }, name: 'Second' } + } + ]) + }) + + it('should convert HTTP errors into per-item MultiStatusResponse entries', async () => { + const error = new HTTPError( + { status: 400, statusText: 'Bad Request' } as any, + { url: 'https://track.customer.io/api/v2/batch' } as any, + {} as any + ) + const request = jest.fn().mockRejectedValue(error) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + }, + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-2', name: 'Second' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response!.length()).toBe(2) + expect(response!.getResponseAtIndex(0).value()).toEqual({ + status: 400, + errormessage: 'Bad Request', + errortype: 'BAD_REQUEST', + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }) + expect(response!.getResponseAtIndex(1).value()).toEqual({ + status: 400, + errormessage: 'Bad Request', + errortype: 'BAD_REQUEST', + body: { person_id: 'user-2', name: 'Second' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-2' }, name: 'Second' } + }) + }) + + it('should convert 429 HTTP errors into per-item MultiStatusResponse entries', async () => { + const error = new HTTPError( + { status: 429, statusText: 'Too Many Requests' } as any, + { url: 'https://track.customer.io/api/v2/batch' } as any, + {} as any + ) + const request = jest.fn().mockRejectedValue(error) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response!.getResponseAtIndex(0).value()).toEqual({ + status: 429, + errormessage: 'Too Many Requests', + errortype: 'TOO_MANY_REQUESTS', + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }) + }) + + it('should convert 500 HTTP errors into per-item MultiStatusResponse entries', async () => { + const error = new HTTPError( + { status: 500, statusText: 'Internal Server Error' } as any, + { url: 'https://track.customer.io/api/v2/batch' } as any, + {} as any + ) + const request = jest.fn().mockRejectedValue(error) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response!.getResponseAtIndex(0).value()).toEqual({ + status: 500, + errormessage: 'Internal Server Error', + errortype: 'INTERNAL_SERVER_ERROR', + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }) + }) + + it('should convert 401 auth errors into per-item MultiStatusResponse entries', async () => { + const error = new HTTPError( + { status: 401, statusText: 'Unauthorized' } as any, + { url: 'https://track.customer.io/api/v2/batch' } as any, + {} as any + ) + const request = jest.fn().mockRejectedValue(error) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response!.getResponseAtIndex(0).value()).toEqual({ + status: 401, + errormessage: 'Unauthorized', + errortype: 'UNAUTHORIZED', + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }) + }) + + it('should handle non-HTTP errors with status 500', async () => { + const error = new Error('Network failure') + const request = jest.fn().mockRejectedValue(error) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response!.getResponseAtIndex(0).value()).toEqual({ + status: 500, + errormessage: 'Network failure', + errortype: 'INTERNAL_SERVER_ERROR', + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }) + }) +}) + +describe('parseResponse', () => { + it('should create error entries for items referenced by batch errors', () => { + const payloads = [{ person_id: 'user-0' }, { person_id: 'user-1' }, { person_id: 'user-2' }] + const batch = [ + { type: 'person', action: 'event', identifiers: { id: 'user-0' } }, + { type: 'person', action: 'event', identifiers: { id: 'user-1' } }, + { type: 'person', action: 'event', identifiers: { id: 'user-2' } } + ] + + const response = parseResponse( + { + status: 207, + data: { + errors: [{ batch_index: 1, reason: 'required', field: 'name', message: 'Name is required' }] + } + } as any, + payloads, + batch + ) + + expect(response.getAllResponses().map((result) => result.value())).toEqual([ + { + status: 207, + body: { person_id: 'user-0' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-0' } } + }, + { + status: 400, + errormessage: 'Name is required', + errortype: 'BAD_REQUEST', + body: { person_id: 'user-1' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' } } + }, + { + status: 207, + body: { person_id: 'user-2' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-2' } } + } + ]) + }) + + it('should use reason as fallback when message is missing', () => { + const payloads = [{ person_id: 'user-0' }] + const batch = [{ type: 'person', action: 'event', identifiers: { id: 'user-0' } }] + + const response = parseResponse( + { + status: 207, + data: { + errors: [{ batch_index: 0, reason: 'invalid' }] + } + } as any, + payloads, + batch + ) + + expect(response.getResponseAtIndex(0).value()).toMatchObject({ + status: 400, + errormessage: 'invalid' + }) + }) + + it('should treat missing errors array as all-success', () => { + const payloads = [{ person_id: 'user-0' }] + const batch = [{ type: 'person', action: 'event', identifiers: { id: 'user-0' } }] + + const response = parseResponse( + { + status: 200, + data: undefined + } as any, + payloads, + batch + ) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response.getResponseAtIndex(0).value()).toEqual({ + status: 200, + body: { person_id: 'user-0' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-0' } } + }) + }) + + it('should treat an empty errors array as all-success', () => { + const payloads = [{ person_id: 'user-0' }] + const batch = [{ type: 'person', action: 'event', identifiers: { id: 'user-0' } }] + + const response = parseResponse( + { + status: 200, + data: { errors: [] } + } as any, + payloads, + batch + ) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response.getResponseAtIndex(0).value()).toEqual({ + status: 200, + body: { person_id: 'user-0' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-0' } } + }) + }) + + it('should skip errors without batch_index', () => { + const payloads = [{ person_id: 'user-0' }] + const batch = [{ type: 'person', action: 'event', identifiers: { id: 'user-0' } }] + + const response = parseResponse( + { + status: 207, + data: { + errors: [{ reason: 'invalid', message: 'some error' }] + } + } as any, + payloads, + batch + ) + + expect(response.getResponseAtIndex(0).value()).toEqual({ + status: 207, + body: { person_id: 'user-0' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-0' } } + }) + }) +}) + describe('convertValidTimestamp', () => { it('should leave decimal unix timestamps unchanged', () => { expect(convertValidTimestamp('1712345678.123')).toBe('1712345678.123') }) + + it('should leave integer unix timestamps unchanged', () => { + expect(convertValidTimestamp('1712345678')).toBe('1712345678') + }) + + it('should convert ISO date strings to unix timestamps', () => { + expect(convertValidTimestamp('2023-12-25T14:30:45Z')).toBe(1703514645) + }) + + it('should return non-string values unchanged', () => { + expect(convertValidTimestamp(12345)).toBe(12345) + expect(convertValidTimestamp(null)).toBe(null) + expect(convertValidTimestamp(undefined)).toBe(undefined) + }) + + it('should return invalid date strings unchanged', () => { + expect(convertValidTimestamp('not-a-date')).toBe('not-a-date') + }) }) diff --git a/packages/destination-actions/src/destinations/customerio/createUpdateObject/index.ts b/packages/destination-actions/src/destinations/customerio/createUpdateObject/index.ts index ab2d15fd628..0aef39f666e 100644 --- a/packages/destination-actions/src/destinations/customerio/createUpdateObject/index.ts +++ b/packages/destination-actions/src/destinations/customerio/createUpdateObject/index.ts @@ -1,11 +1,10 @@ import type { ActionDefinition } from '@segment/actions-core' +import { MultiStatusResponse } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { convertAttributeTimestamps, sendSingle, sendBatch, resolveIdentifiers } from '../utils' import { eventProperties } from '../customerio-properties' -type Action = 'identify' | 'identify_anonymous' - const action: ActionDefinition = { title: 'Create or Update Object', description: 'Create an object in Customer.io or update them if they exist.', @@ -91,26 +90,28 @@ const action: ActionDefinition = { ...eventProperties }, - performBatch: (request, { payload: payloads, settings }) => { - const payloadsByAction: Record[]> = { - identify: [], - identify_anonymous: [] - } + performBatch: async (request, { payload: payloads, settings }) => { + const identifyOptions: { index: number; payload: Record }[] = [] + const identifyAnonOptions: { index: number; payload: Record }[] = [] - for (const payload of payloads) { - const { action, body } = mapPayload(payload) + for (let i = 0; i < payloads.length; i++) { + const { action, body } = mapPayload(payloads[i]) - payloadsByAction[action as Action].push(body) + if (action === 'identify_anonymous') { + identifyAnonOptions.push({ index: i, payload: body }) + } else { + identifyOptions.push({ index: i, payload: body }) + } } - return Promise.all([ + const [identifyResult, identifyAnonResult] = await Promise.all([ sendBatch( request, - payloadsByAction.identify.map((payload) => ({ action: 'identify', payload, settings, type: 'object' })) + identifyOptions.map(({ payload }) => ({ action: 'identify', payload, settings, type: 'object' })) ), sendBatch( request, - payloadsByAction.identify_anonymous.map((payload) => ({ + identifyAnonOptions.map(({ payload }) => ({ action: 'identify_anonymous', payload, settings, @@ -118,6 +119,28 @@ const action: ActionDefinition = { })) ) ]) + + const merged = new MultiStatusResponse() + + if (identifyResult) { + for (let i = 0; i < identifyOptions.length; i++) { + const item = identifyResult.getResponseAtIndex(i) + if (item) { + merged.pushResponseObjectAtIndex(identifyOptions[i].index, item) + } + } + } + + if (identifyAnonResult) { + for (let i = 0; i < identifyAnonOptions.length; i++) { + const item = identifyAnonResult.getResponseAtIndex(i) + if (item) { + merged.pushResponseObjectAtIndex(identifyAnonOptions[i].index, item) + } + } + } + + return merged }, perform: (request, { payload, settings }) => { diff --git a/packages/destination-actions/src/destinations/customerio/types.ts b/packages/destination-actions/src/destinations/customerio/types.ts new file mode 100644 index 00000000000..17068bf9cc3 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/types.ts @@ -0,0 +1,11 @@ + +export type CustomerIOBatchResponse = { + errors: TrackApiError[] +} + +export type TrackApiError = { + batch_index?: number + reason?: string + field?: string + message?: string +} diff --git a/packages/destination-actions/src/destinations/customerio/utils.ts b/packages/destination-actions/src/destinations/customerio/utils.ts index 94718c56a00..e29af2e453b 100644 --- a/packages/destination-actions/src/destinations/customerio/utils.ts +++ b/packages/destination-actions/src/destinations/customerio/utils.ts @@ -2,6 +2,8 @@ import dayjs from '../../lib/dayjs' import isPlainObject from 'lodash/isPlainObject' import { fullFormats } from 'ajv-formats/dist/formats' import { CUSTOMERIO_TRACK_API_VERSION } from './versioning-info' +import { CustomerIOBatchResponse } from './types' +import { MultiStatusResponse, JSONLikeObject, ModifiedResponse, HTTPError } from '@segment/actions-core' const isEmail = (value: string): boolean => { return (fullFormats.email as RegExp).test(value) @@ -197,20 +199,41 @@ export const resolveIdentifiers = ({ } } -export const sendBatch = (request: Function, options: RequestPayload[]) => { +export const sendBatch = async (request: Function, options: RequestPayload[]) => { if (!options?.length) { return } + const payloads = options.map((opts) => ({ ...opts.payload })) + const [{ settings }] = options const batch = options.map((opts) => buildPayload(opts)) - return request(`${trackApiEndpoint(settings)}/api/${CUSTOMERIO_TRACK_API_VERSION}/batch`, { - method: 'post', - json: { - batch + try { + const response: ModifiedResponse = await request(`${trackApiEndpoint(settings)}/api/${CUSTOMERIO_TRACK_API_VERSION}/batch`, { + method: 'post', + json: { + batch + } + }) + + return parseResponse(response, payloads, batch) + } catch (error) { + const multiStatusResponse = new MultiStatusResponse() + const status = error instanceof HTTPError ? error.response.status : 500 + const errormessage = error instanceof Error ? error.message : 'Unknown error' + + for (let i = 0; i < payloads.length; i++) { + multiStatusResponse.setErrorResponseAtIndex(i, { + status, + errormessage, + sent: batch[i] as unknown as JSONLikeObject, + body: payloads[i] as unknown as JSONLikeObject + }) } - }) + + return multiStatusResponse + } } export const sendSingle = (request: Function, options: RequestPayload) => { @@ -221,4 +244,37 @@ export const sendSingle = (request: Function, optio }) } +export const parseResponse = ( + response: ModifiedResponse, + payloads: Payload[], + batch: Record[] +): MultiStatusResponse => { + const multiStatusResponse = new MultiStatusResponse() + + const errors = response.data?.errors ?? [] + const errorStatus = response.status >= 200 && response.status < 300 ? 400 : response.status + for (const error of errors) { + if (error.batch_index != null) { + multiStatusResponse.setErrorResponseAtIndex(error.batch_index, { + status: errorStatus, + errormessage: error.message || error.reason || 'Unknown error', + sent: batch[error.batch_index] as unknown as JSONLikeObject, + body: payloads[error.batch_index] as unknown as JSONLikeObject + }) + } + } + + for (let i = 0; i < payloads.length; i++) { + if (!multiStatusResponse.isErrorResponseAtIndex(i)) { + multiStatusResponse.setSuccessResponseAtIndex(i, { + status: response.status, + sent: batch[i] as unknown as JSONLikeObject, + body: payloads[i] as unknown as JSONLikeObject + }) + } + } + + return multiStatusResponse +} + export { isIsoDate } diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/__tests__/shared-tests/functions.test.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/__tests__/shared-tests/functions.test.ts index 7076f1eca98..bab2523e682 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/__tests__/shared-tests/functions.test.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/__tests__/shared-tests/functions.test.ts @@ -4,9 +4,13 @@ import { isHashedInformation, getPurchaseEventData, getSearchEventData, - getViewContentEventData + getViewContentEventData, + getCustomEventData, + convertToAppendValueEventData, + getUserData } from '../../shared/functions' -import { EventType } from '../../shared/constants' +import { EventType, FEATURE_FLAG_APPEND_VALUE } from '../../shared/constants' +import { CustomEventData, PurchaseEventData } from '../../shared/types' const basePayload = { event_time: '1631210000', @@ -366,4 +370,328 @@ describe('FacebookConversionsApi', () => { expect(result.event_id).toBe('evt-abc-123') }) }) + + describe('convertToAppendValueEventData', () => { + const baseCustomData: CustomEventData = { + event_name: 'Purchase', + event_time: '1631210000', + action_source: 'website', + user_data: { em: 'hashed_email' }, + custom_data: { + order_id: 'order-123', + net_revenue: 85.0, + predicted_ltv: 200.0, + currency: 'USD', + value: 99.99 + } + } + + const validAppendDetails = { + original_event_time: '2021-09-09T16:26:40Z', + original_event_order_id: 'order-123', + original_event_id: 'evt-456', + net_revenue_to_append: 10.5, + predicted_ltv_to_append: 150.0 + } + + it('should return AppendValueEventData with all fields populated', () => { + const result = convertToAppendValueEventData(baseCustomData, validAppendDetails) + + expect(result.event_name).toBe('AppendValue') + expect(result.custom_data).toEqual({ + currency: 'USD', + value: 99.99, + net_revenue: 10.5, + predicted_ltv: 150.0 + }) + expect(result.original_event_data).toEqual({ + event_name: 'Purchase', + event_time: '2021-09-09T16:26:40Z', + order_id: 'order-123', + event_id: 'evt-456' + }) + }) + + it('should return AppendValueEventData with only net_revenue_to_append', () => { + const details = { + ...validAppendDetails, + predicted_ltv_to_append: undefined + } + + const result = convertToAppendValueEventData(baseCustomData, details as any) + + expect(result.custom_data.net_revenue).toBe(10.5) + expect(result.custom_data).not.toHaveProperty('predicted_ltv') + }) + + it('should return AppendValueEventData with only predicted_ltv_to_append', () => { + const details = { + ...validAppendDetails, + net_revenue_to_append: undefined + } + + const result = convertToAppendValueEventData(baseCustomData, details as any) + + expect(result.custom_data.predicted_ltv).toBe(150.0) + expect(result.custom_data).not.toHaveProperty('net_revenue') + }) + + it('should match using original_event_id only', () => { + const details = { + ...validAppendDetails, + original_event_order_id: undefined + } + + const result = convertToAppendValueEventData(baseCustomData, details as any) + + expect(result.original_event_data).toEqual({ + event_name: 'Purchase', + event_time: '2021-09-09T16:26:40Z', + event_id: 'evt-456' + }) + expect(result.original_event_data).not.toHaveProperty('order_id') + }) + + it('should match using original_event_order_id only', () => { + const details = { + ...validAppendDetails, + original_event_id: undefined + } + + const result = convertToAppendValueEventData(baseCustomData, details as any) + + expect(result.original_event_data).toEqual({ + event_name: 'Purchase', + event_time: '2021-09-09T16:26:40Z', + order_id: 'order-123' + }) + expect(result.original_event_data).not.toHaveProperty('event_id') + }) + + it('should strip order_id, net_revenue, and predicted_ltv from custom_data', () => { + const result = convertToAppendValueEventData(baseCustomData, validAppendDetails) + + expect(result.custom_data).not.toHaveProperty('order_id') + expect(result.custom_data.net_revenue).toBe(10.5) + expect(result.custom_data.predicted_ltv).toBe(150.0) + }) + + it('should throw when original_event_time is missing', () => { + const details = { ...validAppendDetails, original_event_time: undefined } + + expect(() => convertToAppendValueEventData(baseCustomData, details as any)).toThrow( + 'If sending an AppendValue, Append Event Details field "Original Event Time" is required' + ) + }) + + it('should throw when neither original_event_order_id nor original_event_id is provided', () => { + const details = { + ...validAppendDetails, + original_event_order_id: undefined, + original_event_id: undefined + } + + expect(() => convertToAppendValueEventData(baseCustomData, details as any)).toThrow( + 'one of "Append Event Details > Original Event ID" or "Append Event Details > Original Order ID" must be provided' + ) + }) + + it('should throw when neither net_revenue_to_append nor predicted_ltv_to_append is a number', () => { + const details = { + ...validAppendDetails, + net_revenue_to_append: undefined, + predicted_ltv_to_append: undefined + } + + expect(() => convertToAppendValueEventData(baseCustomData, details as any)).toThrow( + 'at least one of "Append Event Details > Net Revenue" or "Append Event Details > Predicted Lifetime Value" must be provided as a number' + ) + }) + + it('should increment append_value_event.success stat on success', () => { + const statsClient = { incr: jest.fn() } + const tags = ['test:tag'] + const statsContext = { statsClient, tags } as any + + convertToAppendValueEventData(baseCustomData, validAppendDetails, statsContext) + + expect(statsClient.incr).toHaveBeenCalledWith('append_value_event.success', 1, tags) + }) + + it('should increment append_value_event.error stat on validation failure', () => { + const statsClient = { incr: jest.fn() } + const tags = ['test:tag'] + const statsContext = { statsClient, tags } as any + + const details = { ...validAppendDetails, original_event_time: undefined } + + expect(() => convertToAppendValueEventData(baseCustomData, details as any, statsContext)).toThrow() + expect(statsClient.incr).toHaveBeenCalledWith('append_value_event.error', 1, tags) + }) + }) + + describe('getCustomEventData', () => { + const customPayload = { + ...basePayload, + event_name: 'LTV Update', + is_append_event: true, + append_event_details: { + original_event_time: '2021-09-09T16:26:40Z', + original_event_order_id: 'order-123', + net_revenue_to_append: 10.5 + }, + custom_data: { custom_prop: 'value' } + } as any + + it('should return AppendValueEventData when flag is ON and is_append_event is true', () => { + const features = { [FEATURE_FLAG_APPEND_VALUE]: true } + + const result = getCustomEventData(customPayload, features) + + expect(result.event_name).toBe('AppendValue') + expect((result as any).original_event_data.event_name).toBe('LTV Update') + }) + + it('should throw when is_append_event is true but flag is OFF', () => { + expect(() => getCustomEventData(customPayload, {})).toThrow( + 'AppendValue is not enabled for this destination' + ) + }) + + it('should return normal CustomEventData when is_append_event is false', () => { + const payload = { ...customPayload, is_append_event: false } + const features = { [FEATURE_FLAG_APPEND_VALUE]: true } + + const result = getCustomEventData(payload, features) + + expect(result.event_name).toBe('LTV Update') + expect(result).not.toHaveProperty('original_event_data') + }) + }) + + describe('getPurchaseEventData - AppendValue', () => { + const purchasePayload = { + ...basePayload, + currency: 'USD', + value: 99.99, + is_append_event: true, + append_event_details: { + original_event_time: '2021-09-09T16:26:40Z', + original_event_id: 'evt-789', + predicted_ltv_to_append: 200.0 + }, + custom_data: { order_id: 'order-abc' } + } as any + + it('should return AppendValueEventData when flag is ON and is_append_event is true', () => { + const features = { [FEATURE_FLAG_APPEND_VALUE]: true } + + const result = getPurchaseEventData(purchasePayload, features) + + expect(result.event_name).toBe('AppendValue') + expect((result as any).original_event_data.event_name).toBe('Purchase') + expect((result as any).original_event_data.event_id).toBe('evt-789') + }) + + it('should throw when is_append_event is true but flag is OFF', () => { + expect(() => getPurchaseEventData(purchasePayload, {})).toThrow( + 'AppendValue is not enabled for this destination' + ) + }) + + it('should return normal PurchaseEventData when is_append_event is false', () => { + const payload = { ...purchasePayload, is_append_event: false } + const features = { [FEATURE_FLAG_APPEND_VALUE]: true } + + const result = getPurchaseEventData(payload, features) + + expect(result.event_name).toBe('Purchase') + expect(result).not.toHaveProperty('original_event_data') + }) + }) + + describe('getUserData', () => { + it('should trim whitespace from string fields', () => { + const userData = { + email: 'test@test.com', + client_ip_address: ' 192.168.1.1 ', + client_user_agent: ' Mozilla/5.0 ', + fbc: ' fb.1.123 ', + fbp: ' fb.1.456 ', + subscriptionID: ' sub-123 ', + anonId: ' anon-456 ', + madId: ' mad-789 ', + partner_id: ' partner-1 ', + partner_name: ' Meta ', + ctwa_clid: ' click-id-123 ' + } + + const result = getUserData(userData) + + expect(result.client_ip_address).toBe('192.168.1.1') + expect(result.client_user_agent).toBe('Mozilla/5.0') + expect(result.fbc).toBe('fb.1.123') + expect(result.fbp).toBe('fb.1.456') + expect(result.subscription_id).toBe('sub-123') + expect(result.anon_id).toBe('anon-456') + expect(result.madid).toBe('mad-789') + expect(result.partner_id).toBe('partner-1') + expect(result.partner_name).toBe('Meta') + expect(result.ctwa_clid).toBe('click-id-123') + }) + + it('should exclude fields that are empty strings', () => { + const userData = { + email: 'test@test.com', + client_ip_address: '', + fbc: '', + ctwa_clid: '' + } + + const result = getUserData(userData) + + expect(result).not.toHaveProperty('client_ip_address') + expect(result).not.toHaveProperty('fbc') + expect(result).not.toHaveProperty('ctwa_clid') + }) + + it('should exclude fields that are whitespace-only strings', () => { + const userData = { + email: 'test@test.com', + client_ip_address: ' ', + fbc: ' \t ', + ctwa_clid: ' ' + } + + const result = getUserData(userData) + + expect(result).not.toHaveProperty('client_ip_address') + expect(result).not.toHaveProperty('fbc') + expect(result).not.toHaveProperty('ctwa_clid') + }) + + it('should include ctwa_clid when it is a valid non-empty string', () => { + const userData = { + email: 'test@test.com', + ctwa_clid: 'valid-click-id' + } + + const result = getUserData(userData) + + expect(result.ctwa_clid).toBe('valid-click-id') + }) + + it('should preserve numeric fields without trimming', () => { + const userData = { + email: 'test@test.com', + leadID: 12345, + fbLoginID: 67890 + } + + const result = getUserData(userData) + + expect(result.lead_id).toBe(12345) + expect(result.fb_login_id).toBe(67890) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/addToCart/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/addToCart/generated-types.ts index c4bb03a055f..96d8303ed04 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/addToCart/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/addToCart/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/addToCart2/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/addToCart2/generated-types.ts index c4bb03a055f..96d8303ed04 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/addToCart2/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/addToCart2/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/custom/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/custom/generated-types.ts index 87fa137312d..7bc41b89fa8 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/custom/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/custom/generated-types.ts @@ -1,6 +1,35 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Payload { + /** + * This feature is in beta. Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net revenue values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net revenue values. + */ + is_append_event?: boolean + /** + * This feature is in Beta. Details to append to the original event. Order Id, Event Id and Original Timestamp are used to match the original event. Net Revenue and Predicted Lifetime Value are the late-calculated values to append to the original event. + */ + append_event_details?: { + /** + * A timestamp indicating when the actual original event occurred in ISO 8601 format. Required to match to the original event. + */ + original_event_time?: string + /** + * A unique identifier for the original purchase event, typically the order ID or transaction ID from your ecommerce system. Facebook uses this value to match to the original event. + */ + original_event_order_id?: string + /** + * This ID can be any unique string. Event ID is used to deduplicate events sent by both Facebook Pixel and Conversions API. Facebook uses this value to match to the original event. + */ + original_event_id?: string + /** + * The late-calculated numeric net revenue value to append to the original event. + */ + net_revenue_to_append?: number + /** + * The late-calculated numeric predicted lifetime value (pLTV) to append to the original event. + */ + predicted_ltv_to_append?: number + } /** * This field allows you to specify where your conversions occurred. See [Facebook documentation](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event) for supported values. */ @@ -105,6 +134,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/custom2/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/custom2/generated-types.ts index 87fa137312d..7bc41b89fa8 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/custom2/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/custom2/generated-types.ts @@ -1,6 +1,35 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Payload { + /** + * This feature is in beta. Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net revenue values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net revenue values. + */ + is_append_event?: boolean + /** + * This feature is in Beta. Details to append to the original event. Order Id, Event Id and Original Timestamp are used to match the original event. Net Revenue and Predicted Lifetime Value are the late-calculated values to append to the original event. + */ + append_event_details?: { + /** + * A timestamp indicating when the actual original event occurred in ISO 8601 format. Required to match to the original event. + */ + original_event_time?: string + /** + * A unique identifier for the original purchase event, typically the order ID or transaction ID from your ecommerce system. Facebook uses this value to match to the original event. + */ + original_event_order_id?: string + /** + * This ID can be any unique string. Event ID is used to deduplicate events sent by both Facebook Pixel and Conversions API. Facebook uses this value to match to the original event. + */ + original_event_id?: string + /** + * The late-calculated numeric net revenue value to append to the original event. + */ + net_revenue_to_append?: number + /** + * The late-calculated numeric predicted lifetime value (pLTV) to append to the original event. + */ + predicted_ltv_to_append?: number + } /** * This field allows you to specify where your conversions occurred. See [Facebook documentation](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event) for supported values. */ @@ -105,6 +134,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/initiateCheckout/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/initiateCheckout/generated-types.ts index 47e454f0321..9b2edf317fb 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/initiateCheckout/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/initiateCheckout/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/initiateCheckout2/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/initiateCheckout2/generated-types.ts index 47e454f0321..9b2edf317fb 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/initiateCheckout2/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/initiateCheckout2/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/pageView/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/pageView/generated-types.ts index b01f9406084..d55f9f6ee70 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/pageView/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/pageView/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/pageView2/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/pageView2/generated-types.ts index b01f9406084..d55f9f6ee70 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/pageView2/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/pageView2/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/purchase/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/purchase/generated-types.ts index 4b9489ec29e..f0488b395d8 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/purchase/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/purchase/generated-types.ts @@ -1,6 +1,35 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Payload { + /** + * This feature is in beta. Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net revenue values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net revenue values. + */ + is_append_event?: boolean + /** + * This feature is in Beta. Details to append to the original event. Order Id, Event Id and Original Timestamp are used to match the original event. Net Revenue and Predicted Lifetime Value are the late-calculated values to append to the original event. + */ + append_event_details?: { + /** + * A timestamp indicating when the actual original event occurred in ISO 8601 format. Required to match to the original event. + */ + original_event_time?: string + /** + * A unique identifier for the original purchase event, typically the order ID or transaction ID from your ecommerce system. Facebook uses this value to match to the original event. + */ + original_event_order_id?: string + /** + * This ID can be any unique string. Event ID is used to deduplicate events sent by both Facebook Pixel and Conversions API. Facebook uses this value to match to the original event. + */ + original_event_id?: string + /** + * The late-calculated numeric net revenue value to append to the original event. + */ + net_revenue_to_append?: number + /** + * The late-calculated numeric predicted lifetime value (pLTV) to append to the original event. + */ + predicted_ltv_to_append?: number + } /** * This field allows you to specify where your conversions occurred. See [Facebook documentation](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event) for supported values. */ @@ -105,6 +134,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). @@ -200,6 +233,14 @@ export interface Payload { * A numeric value associated with this event. This could be a monetary value or a value in some other metric. */ value: number + /** + * A unique identifier for the purchase event, typically the order ID or transaction ID from your ecommerce system. + */ + order_id?: string + /** + * The numeric predicted lifetime value (pLTV) associated with the event. + */ + predicted_ltv?: number /** * The numeric net revenue value associated with the purchase event. */ diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/purchase2/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/purchase2/generated-types.ts index 4b9489ec29e..f0488b395d8 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/purchase2/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/purchase2/generated-types.ts @@ -1,6 +1,35 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Payload { + /** + * This feature is in beta. Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net revenue values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net revenue values. + */ + is_append_event?: boolean + /** + * This feature is in Beta. Details to append to the original event. Order Id, Event Id and Original Timestamp are used to match the original event. Net Revenue and Predicted Lifetime Value are the late-calculated values to append to the original event. + */ + append_event_details?: { + /** + * A timestamp indicating when the actual original event occurred in ISO 8601 format. Required to match to the original event. + */ + original_event_time?: string + /** + * A unique identifier for the original purchase event, typically the order ID or transaction ID from your ecommerce system. Facebook uses this value to match to the original event. + */ + original_event_order_id?: string + /** + * This ID can be any unique string. Event ID is used to deduplicate events sent by both Facebook Pixel and Conversions API. Facebook uses this value to match to the original event. + */ + original_event_id?: string + /** + * The late-calculated numeric net revenue value to append to the original event. + */ + net_revenue_to_append?: number + /** + * The late-calculated numeric predicted lifetime value (pLTV) to append to the original event. + */ + predicted_ltv_to_append?: number + } /** * This field allows you to specify where your conversions occurred. See [Facebook documentation](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event) for supported values. */ @@ -105,6 +134,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). @@ -200,6 +233,14 @@ export interface Payload { * A numeric value associated with this event. This could be a monetary value or a value in some other metric. */ value: number + /** + * A unique identifier for the purchase event, typically the order ID or transaction ID from your ecommerce system. + */ + order_id?: string + /** + * The numeric predicted lifetime value (pLTV) associated with the event. + */ + predicted_ltv?: number /** * The numeric net revenue value associated with the purchase event. */ diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/search/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/search/generated-types.ts index f3a6cf0475c..565db6e0b8d 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/search/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/search/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/search2/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/search2/generated-types.ts index f3a6cf0475c..565db6e0b8d 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/search2/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/search2/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/shared/constants.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/constants.ts index 5bc19e5d484..6673b895c93 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/shared/constants.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/constants.ts @@ -14,6 +14,8 @@ export const FEATURE_FLAG_PURCHASE = 'FB_CAPI_REFACTOR_PURCHASE_EVENT' export const FEATURE_FLAG_SEARCH = 'FB_CAPI_REFACTOR_SEARCH_EVENT' export const FEATURE_FLAG_VIEW_CONTENT = 'FB_CAPI_REFACTOR_VIEW_CONTENT_EVENT' +export const FEATURE_FLAG_APPEND_VALUE = 'FB_CAPI_APPEND_VALUE_EVENT' + export const EventType = { AddToCart: 'AddToCart', Custom: 'Custom', diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/shared/fields.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/fields.ts index c70dec7f89a..80b798beabf 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/shared/fields.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/fields.ts @@ -1,5 +1,88 @@ import { InputField } from '@segment/actions-core' +export const is_append_event: InputField = { + label: '[Beta] Append Data to Existing Conversion', + description: + 'This feature is in beta. Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net revenue values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net revenue values.', + type: 'boolean', + default: false, + required: false +} + +export const append_event_details: InputField = { + label: '[Beta] Append Event Details', + description: + 'This feature is in Beta. Details to append to the original event. Order Id, Event Id and Original Timestamp are used to match the original event. Net Revenue and Predicted Lifetime Value are the late-calculated values to append to the original event.', + type: 'object', + defaultObjectUI: 'keyvalue', + properties: { + original_event_time: { + label: 'Original Event Time', + description: 'A timestamp indicating when the actual original event occurred in ISO 8601 format. Required to match to the original event.', + type: 'string', + format: 'date-time' + }, + original_event_order_id: { + label: 'Original Order ID', + description: + 'A unique identifier for the original purchase event, typically the order ID or transaction ID from your ecommerce system. Facebook uses this value to match to the original event.', + type: 'string' + }, + original_event_id: { + label: 'Original Event ID', + description: + 'This ID can be any unique string. Event ID is used to deduplicate events sent by both Facebook Pixel and Conversions API. Facebook uses this value to match to the original event.', + type: 'string' + }, + net_revenue_to_append: { + label: 'Net Revenue', + description: 'The late-calculated numeric net revenue value to append to the original event.', + type: 'number' + }, + predicted_ltv_to_append: { + label: 'Predicted Lifetime Value', + description: 'The late-calculated numeric predicted lifetime value (pLTV) to append to the original event.', + type: 'number' + } + }, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'is_append_event', + operator: 'is', + value: true + } + ] + }, + required: { + match: 'any', + conditions: [ + { + fieldKey: 'is_append_event', + operator: 'is', + value: true + } + ] + } +} + +export const predicted_ltv: InputField = { + label: 'Predicted Lifetime Value', + description: 'The numeric predicted lifetime value (pLTV) associated with the event.', + type: 'number', + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'is_append_event', + operator: 'is_not', + value: true + } + ] + } +} + export const user_data_field: InputField = { label: 'User Data', description: @@ -133,6 +216,11 @@ export const user_data_field: InputField = { label: 'Partner Name', description: 'The name of the Facebook identity partner.', type: 'string' + }, + ctwa_clid: { + label: 'Click to WhatsApp Click ID (ctwa_clid)', + description: 'Click ID generated by Meta for ads that click to WhatsApp.', + type: 'string' } }, default: { @@ -367,7 +455,17 @@ export const value: InputField = { export const net_revenue: InputField = { label: 'Net Revenue', description: 'The numeric net revenue value associated with the purchase event.', - type: 'number' + type: 'number', + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'is_append_event', + operator: 'is_not', + value: true + } + ] + } } export const content_category: InputField = { @@ -525,6 +623,16 @@ export const order_id: InputField = { type: 'string', default: { '@path': '$.properties.order_id' + }, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'is_append_event', + operator: 'is_not', + value: true + } + ] } } @@ -537,6 +645,8 @@ export const test_event_code: InputField = { } export const purchaseFields: Record = { + is_append_event, + append_event_details, action_source: { ...action_source, required: true }, currency: { ...currency, required: true }, event_time: { ...event_time, required: true }, @@ -547,6 +657,8 @@ export const purchaseFields: Record = { required: true, default: { '@path': '$.properties.revenue' } }, + order_id, + predicted_ltv, net_revenue, content_ids, content_name, @@ -723,6 +835,8 @@ export const searchFields: Record = { } export const customFields: Record = { + is_append_event, + append_event_details, action_source: { ...action_source, required: true }, event_name: { label: 'Event Name', diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/shared/functions.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/functions.ts index 4a135be2df3..3076d76051f 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/shared/functions.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/functions.ts @@ -19,9 +19,11 @@ import { InitiateCheckoutEventData, PageEventData, PurchaseEventData, + AppendEventDetails, + AppendValueEventData, GeneratedAppData, UserData, - Content + Content, } from './types' import { API_VERSION, @@ -30,7 +32,8 @@ import { US_STATE_CODES, COUNTRY_CODES, CURRENCY_ISO_CODES, - EventType + EventType, + FEATURE_FLAG_APPEND_VALUE } from './constants' import { processHashing } from '../../../lib/hashing-utils' import type { Settings } from '../generated-types' @@ -53,7 +56,7 @@ export function send

( request: RequestClient, payload: P, settings: Settings, - getDataFunction: (payload: P) => T, + getDataFunction: (payload: P, features?: Features, statsContext?: StatsContext) => T, eventType: EventTypeKey, features?: Features, statsContext?: StatsContext @@ -64,7 +67,7 @@ export function send

( validate(payload, eventType) - const data = getDataFunction(payload) + const data = getDataFunction(payload, features, statsContext) const json: RequestJSON = { data: [data], @@ -186,15 +189,23 @@ export function getAddToCartEventData(payload: AddToCartPayload | AddToCart2Payl return data } -export function getCustomEventData(payload: CustomPayload | Custom2Payload): CustomEventData { +export function getCustomEventData(payload: CustomPayload | Custom2Payload, features?: Features, statsContext?: StatsContext): CustomEventData | AppendValueEventData { const baseEventData = getBaseEventData(payload) - const { custom_data, event_name } = payload + const { is_append_event, append_event_details, custom_data, event_name } = payload const data: CustomEventData = { event_name, ...baseEventData, custom_data: { ...custom_data } } + + if (is_append_event) { + if (!features?.[FEATURE_FLAG_APPEND_VALUE]) { + throw new PayloadValidationError('AppendValue is not enabled for this destination. Please contact Segment support so the feature can be enabled for your Segment workspace.') + } + return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) + } + return data } @@ -229,10 +240,10 @@ export function getPageViewEventData(payload: PageViewPayload | PageView2Payload return data } -export function getPurchaseEventData(payload: PurchasePayload | Purchase2Payload): PurchaseEventData { +export function getPurchaseEventData(payload: PurchasePayload | Purchase2Payload, features?: Features, statsContext?: StatsContext): PurchaseEventData | AppendValueEventData { const baseEventData = getBaseEventData(payload) - const { custom_data, currency, value, content_ids, net_revenue, content_name, content_type, num_items, contents } = + const { is_append_event, append_event_details, order_id, predicted_ltv, custom_data, currency, value, content_ids, net_revenue, content_name, content_type, num_items, contents } = payload const data: PurchaseEventData = { @@ -242,6 +253,8 @@ export function getPurchaseEventData(payload: PurchasePayload | Purchase2Payload ...custom_data, currency, value, + ...(order_id && { order_id }), + ...(typeof predicted_ltv === 'number' && { predicted_ltv }), ...(typeof net_revenue === 'number' && { net_revenue }), ...(Array.isArray(content_ids) && content_ids.length > 0 && { content_ids }), ...(content_name && { content_name }), @@ -250,6 +263,17 @@ export function getPurchaseEventData(payload: PurchasePayload | Purchase2Payload ...(typeof num_items === 'number' && { num_items }) } } + + if (is_append_event) { + if (!features?.[FEATURE_FLAG_APPEND_VALUE]) { + throw new PayloadValidationError('AppendValue is not enabled for this destination. Please contact Segment support so the feature can be enabled for your Segment workspace.') + } + if (!append_event_details) { + throw new PayloadValidationError('If sending an AppendValue, Append Event Details must be provided.') + } + return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) + } + return data } @@ -294,6 +318,67 @@ export function getViewContentEventData(payload: ViewContentPayload | ViewConten return data } +export const convertToAppendValueEventData = ( + data: CustomEventData | PurchaseEventData, + append_event_details?: AppendEventDetails, + statsContext?: StatsContext +): AppendValueEventData => { + const statsClient = statsContext?.statsClient + const tags = statsContext?.tags + + const { + event_name, + custom_data: { order_id: _order_id, net_revenue: _net_revenue, predicted_ltv: _predicted_ltv, ...restCustomData } + } = data + + const { + original_event_time, + original_event_order_id, + original_event_id, + net_revenue_to_append, + predicted_ltv_to_append + } = append_event_details || {} + + if (!append_event_details) { + statsClient?.incr('append_value_event.error', 1, tags) + throw new PayloadValidationError('If sending an AppendValue, Append Event Details must be provided.') + } + + if (!original_event_time) { + statsClient?.incr('append_value_event.error', 1, tags) + throw new PayloadValidationError('If sending an AppendValue, Append Event Details field "Original Event Time" is required') + } + + if (!original_event_order_id && !original_event_id) { + statsClient?.incr('append_value_event.error', 1, tags) + throw new PayloadValidationError('If sending an AppendValue, one of "Append Event Details > Original Event ID" or "Append Event Details > Original Order ID" must be provided') + } + + if (typeof net_revenue_to_append !== 'number' && typeof predicted_ltv_to_append !== 'number') { + statsClient?.incr('append_value_event.error', 1, tags) + throw new PayloadValidationError('If sending an AppendValue, at least one of "Append Event Details > Net Revenue" or "Append Event Details > Predicted Lifetime Value" must be provided as a number') + } + + const appendValueEventData: AppendValueEventData = { + ...data, + event_name: 'AppendValue', + custom_data: { + ...restCustomData, + ...(typeof net_revenue_to_append === 'number' ? { net_revenue: net_revenue_to_append } : {}), + ...(typeof predicted_ltv_to_append === 'number' ? { predicted_ltv: predicted_ltv_to_append } : {}) + }, + original_event_data: { + event_name, + event_time: original_event_time, + ...(original_event_order_id ? { order_id: original_event_order_id } : {}), + ...(original_event_id ? { event_id: original_event_id } : {}) + } + } + + statsClient?.incr('append_value_event.success', 1, tags) + return appendValueEventData +} + export const generateAppData = (app_data: AnyPayload['app_data_field']): GeneratedAppData | undefined => { const { use_app_data, @@ -396,6 +481,12 @@ export const clean = (value: string | undefined): string | undefined => { * @param payload * @see https://developers.facebook.com/docs/marketing-api/audiences/guides/custom-audiences#hash */ +const trimIfString = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed || undefined +} + export const getUserData = (payloadUserData: AnyPayload['user_data']): UserData => { const { email, @@ -419,9 +510,21 @@ export const getUserData = (payloadUserData: AnyPayload['user_data']): UserData madId, fbLoginID, partner_id, - partner_name + partner_name, + ctwa_clid } = payloadUserData ?? {} + const trimmedClientIpAddress = trimIfString(client_ip_address) + const trimmedClientUserAgent = trimIfString(client_user_agent) + const trimmedFbc = trimIfString(fbc) + const trimmedFbp = trimIfString(fbp) + const trimmedSubscriptionID = trimIfString(subscriptionID) + const trimmedAnonId = trimIfString(anonId) + const trimmedMadId = trimIfString(madId) + const trimmedPartnerId = trimIfString(partner_id) + const trimmedPartnerName = trimIfString(partner_name) + const trimmedCtwaClid = trimIfString(ctwa_clid) + const em = cleanAndHash(email) const ph = (() => { @@ -478,7 +581,6 @@ export const getUserData = (payloadUserData: AnyPayload['user_data']): UserData const external_id = hashArray(externalId) const userData: UserData = { - // Hashing this is recommended but not required ...(em ? { em } : {}), ...(ph ? { ph } : {}), ...(ge ? { ge } : {}), @@ -490,17 +592,18 @@ export const getUserData = (payloadUserData: AnyPayload['user_data']): UserData ...(zp ? { zp } : {}), ...(countryValue ? { country: countryValue } : {}), ...(external_id ? { external_id } : {}), - ...(client_ip_address ? { client_ip_address } : {}), - ...(client_user_agent ? { client_user_agent } : {}), - ...(fbc ? { fbc } : {}), - ...(fbp ? { fbp } : {}), - ...(subscriptionID ? { subscription_id: subscriptionID } : {}), + ...(trimmedClientIpAddress ? { client_ip_address: trimmedClientIpAddress } : {}), + ...(trimmedClientUserAgent ? { client_user_agent: trimmedClientUserAgent } : {}), + ...(trimmedFbc ? { fbc: trimmedFbc } : {}), + ...(trimmedFbp ? { fbp: trimmedFbp } : {}), + ...(trimmedSubscriptionID ? { subscription_id: trimmedSubscriptionID } : {}), ...(typeof leadID === 'number' ? { lead_id: leadID } : {}), - ...(anonId ? { anon_id: anonId } : {}), - ...(madId ? { madid: madId } : {}), + ...(trimmedAnonId ? { anon_id: trimmedAnonId } : {}), + ...(trimmedMadId ? { madid: trimmedMadId } : {}), ...(typeof fbLoginID === 'number' ? { fb_login_id: fbLoginID } : {}), - ...(partner_id ? { partner_id } : {}), - ...(partner_name ? { partner_name } : {}) + ...(trimmedPartnerId ? { partner_id: trimmedPartnerId } : {}), + ...(trimmedPartnerName ? { partner_name: trimmedPartnerName } : {}), + ...(trimmedCtwaClid ? { ctwa_clid: trimmedCtwaClid } : {}) } return userData } diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/shared/types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/types.ts index 420c15f2d26..7c583977264 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/shared/types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/types.ts @@ -38,6 +38,7 @@ export type EventDataType = | PageEventData | CustomEventData | SearchEventData + | AppendValueEventData export type EventTypeKey = keyof typeof EventType @@ -164,6 +165,29 @@ export interface ViewContentEventData extends BaseEventData { } } +export interface AppendValueEventData extends BaseEventData { + event_name: 'AppendValue' + original_event_data: { + event_name: string + event_time: string + order_id?: string + event_id?: string + } + custom_data: { + net_revenue?: number + predicted_ltv?: number + [k: string]: unknown + } +} + +export interface AppendEventDetails { + original_event_time: string + original_event_order_id?: string + original_event_id?: string + net_revenue_to_append?: number + predicted_ltv_to_append?: number +} + export type GeneratedAppData = { advertiser_tracking_enabled: 1 | 0 application_tracking_enabled: 1 | 0 @@ -194,6 +218,7 @@ export interface UserData { fb_login_id?: number partner_id?: string partner_name?: string + ctwa_clid?: string } export type Content = { diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/viewContent/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/viewContent/generated-types.ts index 184eafea005..751610ad2c9 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/viewContent/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/viewContent/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/viewContent2/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/viewContent2/generated-types.ts index 184eafea005..751610ad2c9 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/viewContent2/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/viewContent2/generated-types.ts @@ -101,6 +101,10 @@ export interface Payload { * The name of the Facebook identity partner. */ partner_name?: string + /** + * Click ID generated by Meta for ads that click to WhatsApp. + */ + ctwa_clid?: string } /** * These fields support sending app events to Facebook through the Conversions API. For more information about app events support in the Conversions API, see the Facebook docs [here](https://developers.facebook.com/docs/marketing-api/conversions-api/app-events). diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/__e2e__/index.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/__e2e__/index.ts new file mode 100644 index 00000000000..0fcf8f86521 --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/__e2e__/index.ts @@ -0,0 +1,25 @@ +/** + * Required environment variables: + * - E2E_FACEBOOK_CUSTOM_AUDIENCES_ACCESS_TOKEN: Long-lived Facebook OAuth access token + * - E2E_FACEBOOK_CUSTOM_AUDIENCES_AD_ACCOUNT_ID: Facebook Advertiser Account ID (e.g., act_123456789) + */ +import type { E2EAudienceDestinationConfig } from '@segment/actions-core' + +const audienceName = `e2e_test_audience_${Date.now()}` + +export const config: E2EAudienceDestinationConfig = { + settings: { + retlAdAccountId: { $env: 'E2E_FACEBOOK_CUSTOM_AUDIENCES_AD_ACCOUNT_ID' }, + oauth: { + access_token: { $env: 'E2E_FACEBOOK_CUSTOM_AUDIENCES_ACCESS_TOKEN' }, + refresh_token: 'unused' + } + }, + audience: { + audienceName, + audienceSettings: {}, + createAudience: true, + getAudience: true, + teardown: false + } +} diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/constants.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/constants.ts index 38a19c8b2cf..a65a39248e6 100644 --- a/packages/destination-actions/src/destinations/facebook-custom-audiences/constants.ts +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/constants.ts @@ -6,6 +6,4 @@ export const CANARY_API_VERSION = FACEBOOK_CUSTOM_AUDIENCES_CANARY_API_VERSION export const FACEBOOK_CUSTOM_AUDIENCE_FLAGON = 'facebook-custom-audience-actions-canary-version' -export const FACEBOOK_CUSTOM_AUDIENCE_JOURNEYS_FLAGON = 'facebook-custom-audience-actions-journeys-support' - export const BASE_URL = 'https://graph.facebook.com' diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/engage.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/engage.e2e.ts new file mode 100644 index 00000000000..4bdf3fc84d4 --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/engage.e2e.ts @@ -0,0 +1,271 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' +import sync from '../index' + +const COMPUTATION_KEY = 'e2e_test_facebook_audience' +const COMPUTATION_ID = 'aud_e2e_facebook_001' + +const FAILURE_HINT = + 'Ensure E2E_FACEBOOK_CUSTOM_AUDIENCES_ACCESS_TOKEN and E2E_FACEBOOK_CUSTOM_AUDIENCES_AD_ACCOUNT_ID are set. The token must have ads_management permission.' + +// Note: the `sent` object also includes `audienceId`, but we intentionally do not assert it here. +// It resolves to the audience created during the e2e run (a different id each run), and the runner +// does not substitute the $externalAudienceId marker inside expectations — so there is no stable +// value to assert. We assert the operation (method) and the hashed identifier row instead. +const fixtures: E2EFixture[] = [ + { + description: 'Single event: add a user to the audience via identify', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(sync.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-001', + email: 'e2e-fb-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Single event: remove a user from the audience via identify', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(sync.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-001', + email: 'e2e-fb-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch: add multiple users to the audience', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-002', + email: 'e2e-fb-test-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-003', + email: 'e2e-fb-test-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-004', + email: 'e2e-fb-test-004@segment.com' + }) + ], + expect: { + status: 'success', + // All adds => POST. data row is [externalId, hashedEmail, ...empty identifier slots]. + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-user-002', '2a0927f79c0d8dbf12ca428ba51bdb546b6e612e9cb65e6df4b75d637f8696f7', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-user-003', '63812a0b58b3d40cb802a69aae0208400c5da53341faacbadf11fbc2ed8bec5e', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-user-004', '36bca65f27804d68271e85dd6539bb601bc70b8d57a385480e95f7f5c8b8b281', '', '', '', '', '', '', '', '', '', '', '', '', ''] } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch: remove multiple users from the audience', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-002', + email: 'e2e-fb-test-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-003', + email: 'e2e-fb-test-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-004', + email: 'e2e-fb-test-004@segment.com' + }) + ], + expect: { + status: 'success', + // All removes => DELETE. + jsonContains: [ + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-user-002', '2a0927f79c0d8dbf12ca428ba51bdb546b6e612e9cb65e6df4b75d637f8696f7', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-user-003', '63812a0b58b3d40cb802a69aae0208400c5da53341faacbadf11fbc2ed8bec5e', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-user-004', '36bca65f27804d68271e85dd6539bb601bc70b8d57a385480e95f7f5c8b8b281', '', '', '', '', '', '', '', '', '', '', '', '', ''] } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch: mixed add and remove users in a single batch', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-005', + email: 'e2e-fb-test-005@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-006', + email: 'e2e-fb-test-006@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-007', + email: 'e2e-fb-test-007@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-002', + email: 'e2e-fb-test-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-003', + email: 'e2e-fb-test-003@segment.com' + }) + ], + expect: { + status: 'success', + // Indexes 0,1,2 are adds (POST); indexes 3,4 are removes (DELETE). Per-index sent operation + // must stay aligned with the original payload order even though adds/removes are sent as + // two separate Facebook requests. + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-user-005', '63590a80cfc4b727847e97b64b7e908479597052effe780a02f2cd15113e17a0', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-user-006', 'c2f84c216f71811d7b0fc06d30d7784f0a9299587422477f68706d7d34f3334d', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-user-007', '242557ab9fdf0f9206600b352231fc4a8450c4a05a124fa1ee03fbc852ef7605', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-user-002', '2a0927f79c0d8dbf12ca428ba51bdb546b6e612e9cb65e6df4b75d637f8696f7', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-user-003', '63812a0b58b3d40cb802a69aae0208400c5da53341faacbadf11fbc2ed8bec5e', '', '', '', '', '', '', '', '', '', '', '', '', ''] } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch: mixed valid and invalid membership — valid events succeed, invalid gets error', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-008', + email: 'e2e-fb-test-008@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-009', + email: 'e2e-fb-test-009@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-010', + email: 'e2e-fb-test-010@segment.com' + }), + { + ...createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-011', + email: 'e2e-fb-test-011@segment.com' + }), + userId: undefined as unknown as string + } + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-user-008', 'b44ddc1f21dce143a163b617eae3f6e921a87398d708df0d0ba49593ae76c2d2', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-user-009', '6240c5bfbe17f3e6b17ed986031200b3a82b7dec9842d108a3106c87fb8a0130', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-user-010', '374bc76dbe337e73a7bbc0bfcb6b10f5df1f3efa62d48f747ee4d208a18b08cb', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'externalId'.", + errorreporter: 'INTEGRATIONS' + } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/errors.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/errors.e2e.ts new file mode 100644 index 00000000000..92635ab0bda --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/errors.e2e.ts @@ -0,0 +1,139 @@ +import type { E2EFixture } from '@segment/actions-core' +import { + defaultValues, + createE2EJourneysV1AudienceEvent, + createE2EEngageAudienceEvent, + createE2ERetlAudienceEvent +} from '@segment/actions-core' +import sync from '../index' + +const COMPUTATION_KEY = 'e2e_test_facebook_errors' +const COMPUTATION_ID = 'aud_e2e_facebook_errors_001' + +const FAILURE_HINT = + 'Ensure E2E_FACEBOOK_CUSTOM_AUDIENCES_ACCESS_TOKEN and E2E_FACEBOOK_CUSTOM_AUDIENCES_AD_ACCOUNT_ID are set. The token must have ads_management permission.' + +// Error / validation paths. These never reach Facebook — the action rejects them locally. +const fixtures: E2EFixture[] = [ + { + // A batch mixing journey_step and non-journey_step events is rejected wholesale with a thrown + // InvalidAudienceMembershipError (the entire batch fails, not per-item). + description: 'Error: batch mixing journey_step and non-journey_step events is rejected', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-err-journey-001', + email: 'e2e-fb-err-journey-001@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-err-engage-001', + email: 'e2e-fb-err-engage-001@segment.com' + }) + ], + expect: { + status: 'error', + errorType: 'InvalidAudienceMembershipError', + errorMessage: + 'Batch contains a mix of journey_step and non-journey_step events. All events in a batch must be the same computation_class.' + }, + verboseFailureHint: FAILURE_HINT + }, + { + // No audience id resolvable (events carry no external_audience_id and no hook output) => each + // payload gets a per-item INVALID_AUDIENCE_MEMBERSHIP error. Never reaches Facebook. + description: 'Error: batch with missing audience ID returns per-item INVALID_AUDIENCE_MEMBERSHIP', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-fb-err-noaud-001', + email: 'e2e-fb-err-noaud-001@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-fb-err-noaud-002', + email: 'e2e-fb-err-noaud-002@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 400, errortype: 'INVALID_AUDIENCE_MEMBERSHIP', errormessage: 'Missing audience ID.', errorreporter: 'DESTINATION' }, + { status: 400, errortype: 'INVALID_AUDIENCE_MEMBERSHIP', errormessage: 'Missing audience ID.', errorreporter: 'DESTINATION' } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // RETL "new" events with no audience id => per-item INVALID_AUDIENCE_MEMBERSHIP. + description: 'Error: RETL batch with missing audience ID returns per-item INVALID_AUDIENCE_MEMBERSHIP', + subscribe: 'type = "track" or type = "identify"', + mapping: { + ...defaultValues(sync.fields), + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-fb-err-retl-001', + email: 'e2e-fb-err-retl-001@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 400, errortype: 'INVALID_AUDIENCE_MEMBERSHIP', errormessage: 'Missing audience ID.', errorreporter: 'DESTINATION' } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // Passes local validation (audience id is a non-empty string) but Facebook rejects it because the + // audience does not exist. Exercises the parseFacebookError path end-to-end. The exact errortype/ + // errormessage come from Facebook's live response, so we assert only the per-item 400 + reporter; + // tighten these once we observe the real response from a live run. + description: 'Error: Facebook rejects an invalid/non-existent audience ID (per-item API error)', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '000000000000000', // well-formed but non-existent audience id + userId: 'e2e-fb-err-fbapi-001', + email: 'e2e-fb-err-fbapi-001@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [{ status: 400, errorreporter: 'DESTINATION' }] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/identifiers.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/identifiers.e2e.ts new file mode 100644 index 00000000000..98959850fbd --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/identifiers.e2e.ts @@ -0,0 +1,293 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' +import sync from '../index' + +const COMPUTATION_KEY = 'e2e_test_facebook_identifiers' +const COMPUTATION_ID = 'aud_e2e_facebook_identifiers_001' + +const FAILURE_HINT = + 'Ensure E2E_FACEBOOK_CUSTOM_AUDIENCES_ACCESS_TOKEN and E2E_FACEBOOK_CUSTOM_AUDIENCES_AD_ACCOUNT_ID are set. The token must have ads_management permission.' + +// Verifies the full identifier set is normalized + hashed into the correct Facebook schema slots. +// The `sent.data` row positions are: +// [externalId(unhashed), email, phone, dobYear, dobMonth, dobDay, lastName, firstName, firstInitial, +// gender, city, state, zip, country, mobileAdId] +// +// `birth` (year/month/day), `name.firstInitial`, `mobileAdId`, `appId`, `pageId` and `igAccountIds` +// have NO default @path, so the fixtures that exercise them add explicit `mapping` entries pointing +// at the enriched traits below. +const fixtures: E2EFixture[] = [ + { + // Exercises EVERY identifier slot in a single row: externalId, email, phone, birth year/month/day, + // last/first name, first initial, gender, city, state, zip, country and mobileAdId. The fields + // without a default @path are wired explicitly in `mapping`. + // + // Note: appId/pageId/igAccountIds are intentionally NOT exercised here. They land in the request + // body's app_ids/page_ids/ig_account_ids arrays (not in the per-item `sent` row, so there is + // nothing to assert), and Meta validates them against real registered Facebook app/page/IG IDs — + // sending placeholder values fails the live request with "(#100) invalid app id". + description: 'Identifiers: every schema slot (incl. birth, first initial, mobileAdId) is normalized and hashed', + subscribe: 'type = "track" or type = "identify"', + mapping: { + ...defaultValues(sync.fields), + birth: { + year: { '@path': '$.traits.dob_year' }, + month: { '@path': '$.traits.dob_month' }, + day: { '@path': '$.traits.dob_day' } + }, + name: { + first: { '@path': '$.traits.first_name' }, + last: { '@path': '$.traits.last_name' }, + firstInitial: { '@path': '$.traits.first_initial' } + }, + mobileAdId: { '@path': '$.traits.madid' } + }, + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'id-max-001', + email: 'max@segment.com', + enrichedTraits: { + phone: '+1 (650) 555-0100', + dob_year: '1990', + dob_month: 'March', + dob_day: '07', + first_name: 'Jane', + last_name: 'Doe', + first_initial: 'J', + gender: 'female', + city: 'San Francisco', + state: 'California', + postal_code: '94105-1234', + country: 'United States', + madid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + sent: { + method: 'POST', + data: [ + 'id-max-001', // externalId (not hashed) + '57103bd6f7816762760d11c6e79580f7d14d542cb5887bb2c49e05bb4b477652', // email + '43b63f22d478442e6d58c9ba5eef8a8b378b9990aafb681d11e231e3ceab6294', // phone (normalized) + 'a7be8e1fe282a37cd666e0632b17d933fa13f21addf4798fc0455bc166e2488c', // dob year (1990) + '0b8efa5a3bf104413a725c6ff0459a6be12b1fd33314cbb138745baf39504ae5', // dob month ('March' -> '03') + '19b100ab7725c612f3d80ff203ca53cea5cadaafae3bf0f88f0fb4089fe08815', // dob day ('07') + '799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f', // last name (normalized) + '81f8f6dde88365f3928796ec7aa53f72820b06db8664f5fe76a7eb13e24546a2', // first name (normalized) + '189f40034be7a199f1fa9891668ee3ab6049f82d38c68be70f596eab2e1857b7', // first initial ('J' -> 'j') + '252f10c83610ebca1a059c0bae8255eba2f95be4d1d7bcfa89d7248a82d9f111', // gender (normalized to 'f') + '1a6bd4d9d79dc0a79b53795c70d3349fa9e38968a3fbefbfe8783efb1d2b6aac', // city (normalized) + '6959097001d10501ac7d54c0bdb8db61420f658f2922cc26e46d536119a31126', // state (normalized to code) + 'e73ac16e69f060ee98b0fda5f66f48c4648ee26950e9bab3a097389853fd859e', // zip (normalized, dropped +4) + 'fd7321c405f8af43810a6723bbaf6fb5c9461aee273bc3693ee7903becc4e6ea', // country (normalized) + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' // mobileAdId (sent plaintext, not hashed — Meta expects lowercase UUID, hyphens kept) + ] + } + } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Identifiers: all PII fields are normalized and hashed into the Facebook schema row', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-allid-001', + email: 'allid@segment.com', + enrichedTraits: { + phone: '+1 (650) 555-0100', + first_name: 'Jane', + last_name: 'Doe', + gender: 'female', + city: 'San Francisco', + state: 'California', + postal_code: '94105-1234', + country: 'United States' + } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + sent: { + method: 'POST', + data: [ + 'e2e-fb-allid-001', // externalId (not hashed) + '086fff0fc73883ed7fad91b9dab6683b06540149e4bddc2f7649346c59bc33f1', // email + '43b63f22d478442e6d58c9ba5eef8a8b378b9990aafb681d11e231e3ceab6294', // phone (normalized) + '', // dob year + '', // dob month + '', // dob day + '799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f', // last name (normalized) + '81f8f6dde88365f3928796ec7aa53f72820b06db8664f5fe76a7eb13e24546a2', // first name (normalized) + '', // first initial + '252f10c83610ebca1a059c0bae8255eba2f95be4d1d7bcfa89d7248a82d9f111', // gender (normalized to 'f') + '1a6bd4d9d79dc0a79b53795c70d3349fa9e38968a3fbefbfe8783efb1d2b6aac', // city (normalized) + '6959097001d10501ac7d54c0bdb8db61420f658f2922cc26e46d536119a31126', // state (normalized to code) + 'e73ac16e69f060ee98b0fda5f66f48c4648ee26950e9bab3a097389853fd859e', // zip (normalized, dropped +4) + 'fd7321c405f8af43810a6723bbaf6fb5c9461aee273bc3693ee7903becc4e6ea', // country (normalized) + '' // mobileAdId + ] + } + } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // Batch of rows with DIFFERENT identifier subsets, verifying per-row normalization/hashing stays + // correct and aligned across a multi-event batch: + // - row 0: full PII (add) + // - row 1: email + phone only (add) + // - row 2: externalId only, no other identifiers (add) + // - row 3: name + geo, no email, with messy input (" JOHN ", "O'Brien") to exercise + // trimming/lowercasing/punctuation-stripping (remove) + description: 'Identifiers: batch with varied per-row identifier subsets are hashed correctly', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'id-001', + email: 'full@segment.com', + enrichedTraits: { + phone: '+1 (650) 555-0100', + first_name: 'Jane', + last_name: 'Doe', + gender: 'female', + city: 'San Francisco', + state: 'California', + postal_code: '94105-1234', + country: 'United States' + } + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'id-002', + email: 'emailphone@segment.com', + enrichedTraits: { phone: '+44 20 7946 0958' } + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'id-003' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'id-004', + enrichedTraits: { + first_name: ' JOHN ', + last_name: "O'Brien", + city: 'New York', + state: 'NY', + postal_code: '10001', + country: 'USA' + } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + sent: { + method: 'POST', + data: [ + 'id-001', + 'e21fd535ca2f2ab8a6197eca83ea64d577e9ab8c2dbef578d7069a20d8fb1581', // email + '43b63f22d478442e6d58c9ba5eef8a8b378b9990aafb681d11e231e3ceab6294', // phone + '', '', '', + '799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f', // last name + '81f8f6dde88365f3928796ec7aa53f72820b06db8664f5fe76a7eb13e24546a2', // first name + '', + '252f10c83610ebca1a059c0bae8255eba2f95be4d1d7bcfa89d7248a82d9f111', // gender + '1a6bd4d9d79dc0a79b53795c70d3349fa9e38968a3fbefbfe8783efb1d2b6aac', // city + '6959097001d10501ac7d54c0bdb8db61420f658f2922cc26e46d536119a31126', // state + 'e73ac16e69f060ee98b0fda5f66f48c4648ee26950e9bab3a097389853fd859e', // zip + 'fd7321c405f8af43810a6723bbaf6fb5c9461aee273bc3693ee7903becc4e6ea', // country + '' + ] + } + }, + { + status: 200, + sent: { + method: 'POST', + data: [ + 'id-002', + '429e552d0a52bacdd8dbe36947ebe32fc5818724beaff8a0c8fda5e0e68ac18a', // email + '35e206e5dec4c89b9e8b71b8c32724a5bb518483ac5a20c6617d738375b3b823', // phone + '', '', '', '', '', '', '', '', '', '', '', '' + ] + } + }, + { + status: 200, + sent: { + method: 'POST', + data: ['id-003', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] + } + }, + { + status: 200, + sent: { + method: 'DELETE', + data: [ + 'id-004', + '', '', '', '', '', + 'b4cb6cb33fe4b865868de825023a1e2790dc12ac01ecc8d7c5afe8254071c8ba', // last name ("O'Brien" -> obrien) + '96d9632f363564cc3032521409cf22a852f2032eec099ed5967c0d000cec607a', // first name (" JOHN " -> john) + '', + '', + '350c754ba4d38897693aa077ef43072a859d23f613443133fecbbd90a3512ca5', // city + '1b06e2003f8420d6fa42badd8f77ec0f706b976b7a48b13c567dc5a559681683', // state + 'e443169117a184f91186b401133b20be670c7c0896f9886075e5d9b81e9d076b', // zip + '5fc90ab335783816990ffd960cbad0afd64510a53f895b4d02b9f8b279c0ed08', // country + '' + ] + } + } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV1.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV1.e2e.ts new file mode 100644 index 00000000000..b6904fd4cf8 --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV1.e2e.ts @@ -0,0 +1,126 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EJourneysV1AudienceEvent } from '@segment/actions-core' +import sync from '../index' + +const COMPUTATION_KEY = 'e2e_test_facebook_journeys' +const COMPUTATION_ID = 'aud_e2e_facebook_journeys_001' + +const FAILURE_HINT = + 'Ensure E2E_FACEBOOK_CUSTOM_AUDIENCES_ACCESS_TOKEN and E2E_FACEBOOK_CUSTOM_AUDIENCES_AD_ACCOUNT_ID are set. The token must have ads_management permission.' + +// Journeys V1: journey_step events that do NOT carry a per-event membership boolean. The action +// fills membership with all-true (getJourneysV1Memberships), so every user is added (V1 has no +// remove path). Events therefore must omit enrichedTraits[computation_key]. +// +// Note: the `sent` object also includes `audienceId`, but we intentionally do not assert it here. +// It resolves to the audience created during the e2e run (a different id each run), and the runner +// does not substitute the $externalAudienceId marker inside expectations — so there is no stable +// value to assert. We assert the operation (method) and the hashed identifier row instead. +const fixtures: E2EFixture[] = [ + { + description: 'JourneysV1: single journey_step event adds the user', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'single', + event: createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-journeys-user-001', + email: 'e2e-fb-journeys-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'JourneysV1: batch adds all users (membership defaulted to true)', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-journeys-user-002', + email: 'e2e-fb-journeys-002@segment.com' + }), + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-journeys-user-003', + email: 'e2e-fb-journeys-003@segment.com' + }), + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-journeys-user-004', + email: 'e2e-fb-journeys-004@segment.com' + }) + ], + expect: { + status: 'success', + // All journey_step events with no membership boolean => all added => POST. + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-journeys-user-002', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-journeys-user-003', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-journeys-user-004', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // Batch with a schema-invalid event (missing externalId) interleaved between valid ones. The + // invalid payload is rejected with a per-item validation error; the surviving valid journey + // events are still added at their correct indexes (and not failed wholesale). + description: 'JourneysV1: batch with a mix of valid adds and an invalid payload', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-journeys-user-005', + email: 'e2e-fb-journeys-005@segment.com' + }), + { + // Invalid: externalId removed => fails schema validation before reaching Facebook. + ...createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-journeys-user-006', + email: 'e2e-fb-journeys-006@segment.com' + }), + userId: undefined as unknown as string + }, + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-journeys-user-007', + email: 'e2e-fb-journeys-007@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-journeys-user-005', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'externalId'.", + errorreporter: 'INTEGRATIONS' + }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-journeys-user-007', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV2.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV2.e2e.ts new file mode 100644 index 00000000000..c551bd03b3d --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV2.e2e.ts @@ -0,0 +1,156 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EJourneysV2AudienceEvent } from '@segment/actions-core' +import sync from '../index' + +const COMPUTATION_KEY = 'e2e_test_facebook_journeys_v2' +const COMPUTATION_ID = 'aud_e2e_facebook_journeys_v2_001' + +const FAILURE_HINT = + 'Ensure E2E_FACEBOOK_CUSTOM_AUDIENCES_ACCESS_TOKEN and E2E_FACEBOOK_CUSTOM_AUDIENCES_AD_ACCOUNT_ID are set. The token must have ads_management permission.' + +// Journeys V2: journey_step events that DO carry a per-event membership boolean +// (properties[computation_key]). Because membership is already populated, the V1 all-true override +// is skipped and the boolean drives the operation: true => add (POST), false => remove (DELETE). +// +// Note: the `sent` object also includes `audienceId`, but we intentionally do not assert it here. +// It resolves to the audience created during the e2e run (a different id each run), and the runner +// does not substitute the $externalAudienceId marker inside expectations — so there is no stable +// value to assert. We assert the operation (method) and the hashed identifier row instead. +const fixtures: E2EFixture[] = [ + { + description: 'JourneysV2: single journey_step event with membership=true adds the user', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'single', + event: createE2EJourneysV2AudienceEvent({ + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-fb-journeysv2-user-001', + email: 'e2e-fb-journeysv2-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'JourneysV2: single journey_step event with membership=false removes the user', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'single', + event: createE2EJourneysV2AudienceEvent({ + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-fb-journeysv2-user-002', + email: 'e2e-fb-journeysv2-002@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'JourneysV2: batch with mixed add and remove driven by membership boolean', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EJourneysV2AudienceEvent({ + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-fb-journeysv2-user-003', + email: 'e2e-fb-journeysv2-003@segment.com' + }), + createE2EJourneysV2AudienceEvent({ + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-fb-journeysv2-user-004', + email: 'e2e-fb-journeysv2-004@segment.com' + }), + createE2EJourneysV2AudienceEvent({ + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-fb-journeysv2-user-005', + email: 'e2e-fb-journeysv2-005@segment.com' + }) + ], + expect: { + status: 'success', + // Membership boolean drives the op: index 0,2 add (POST), index 1 remove (DELETE). + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-journeysv2-user-003', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-journeysv2-user-004', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-journeysv2-user-005', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // Batch mixing a valid add, a valid remove, and a schema-invalid event (missing externalId). The + // invalid payload gets a per-item validation error; the valid add/remove succeed at their indexes. + description: 'JourneysV2: batch with a mix of valid add, valid remove and an invalid payload', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EJourneysV2AudienceEvent({ + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-fb-journeysv2-user-006', + email: 'e2e-fb-journeysv2-006@segment.com' + }), + createE2EJourneysV2AudienceEvent({ + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-fb-journeysv2-user-007', + email: 'e2e-fb-journeysv2-007@segment.com' + }), + { + // Invalid: externalId removed => fails schema validation before reaching Facebook. + ...createE2EJourneysV2AudienceEvent({ + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-fb-journeysv2-user-008', + email: 'e2e-fb-journeysv2-008@segment.com' + }), + userId: undefined as unknown as string + } + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-journeysv2-user-006', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-journeysv2-user-007', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'externalId'.", + errorreporter: 'INTEGRATIONS' + } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.e2e.ts new file mode 100644 index 00000000000..a38daf1def5 --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.e2e.ts @@ -0,0 +1,240 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2ERetlAudienceEvent } from '@segment/actions-core' +import sync from '../index' + +const COMPUTATION_KEY = 'e2e_test_facebook_retl' +const COMPUTATION_ID = 'aud_e2e_facebook_retl_001' + +const FAILURE_HINT = + 'Ensure E2E_FACEBOOK_CUSTOM_AUDIENCES_ACCESS_TOKEN and E2E_FACEBOOK_CUSTOM_AUDIENCES_AD_ACCOUNT_ID are set. The token must have ads_management permission.' + +// Note: the `sent` object also includes `audienceId`, but we intentionally do not assert it here. +// It resolves to the audience created during the e2e run (a different id each run), and the runner +// does not substitute the $externalAudienceId marker inside expectations — so there is no stable +// value to assert. We assert the operation (method) and the hashed identifier row instead. +const fixtures: E2EFixture[] = [ + { + description: 'RETL: single entity added (track "new" event)', + subscribe: 'type = "track" or type = "identify"', + mapping: { + ...defaultValues(sync.fields), + __segment_internal_sync_mode: 'add' + }, + mode: 'single', + event: createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-001', + email: 'e2e-fb-retl-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'RETL: single entity removed (track "deleted" event)', + subscribe: 'type = "track" or type = "identify"', + mapping: { + ...defaultValues(sync.fields), + __segment_internal_sync_mode: 'delete' + }, + mode: 'single', + event: createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-002', + email: 'e2e-fb-retl-002@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'RETL: batch entity added (syncMode=mirror, "new" events)', + subscribe: 'type = "track" or type = "identify"', + mapping: { + ...defaultValues(sync.fields), + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-003', + email: 'e2e-fb-retl-003@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-004', + email: 'e2e-fb-retl-004@segment.com' + }) + ], + expect: { + status: 'success', + // Both "new" events are adds => POST. data is the Facebook schema row (externalId is index 0, + // unhashed; remaining identifier slots empty as only externalId is set). + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-retl-user-003', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-retl-user-004', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'RETL: batch entity removed (syncMode=mirror, "deleted" events)', + subscribe: 'type = "track" or type = "identify"', + mapping: { + ...defaultValues(sync.fields), + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-005', + email: 'e2e-fb-retl-005@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-006', + email: 'e2e-fb-retl-006@segment.com' + }) + ], + expect: { + status: 'success', + // Both "deleted" events are removes => DELETE. + jsonContains: [ + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-retl-user-005', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-retl-user-006', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'RETL: batch mixed add and remove (syncMode=mirror, "new" + "deleted" events)', + subscribe: 'type = "track" or type = "identify"', + mapping: { + ...defaultValues(sync.fields), + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-007', + email: 'e2e-fb-retl-007@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-008', + email: 'e2e-fb-retl-008@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-003', + email: 'e2e-fb-retl-003@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-004', + email: 'e2e-fb-retl-004@segment.com' + }) + ], + expect: { + status: 'success', + // Indexes 0,1 are "new" (adds => POST); indexes 2,3 are "deleted" (removes => DELETE). + // Per-index sent operation must stay aligned with the original payload order. + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-retl-user-007', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'POST', data: ['e2e-fb-retl-user-008', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-retl-user-003', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-retl-user-004', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // Batch mixing a valid add ("new"), a valid remove ("deleted") and a schema-invalid event + // (missing externalId). The invalid payload gets a per-item validation error; the valid add and + // remove succeed at their indexes. + description: 'RETL: batch with a mix of valid add, valid remove and an invalid payload', + subscribe: 'type = "track" or type = "identify"', + mapping: { + ...defaultValues(sync.fields), + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-009', + email: 'e2e-fb-retl-009@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-010', + email: 'e2e-fb-retl-010@segment.com' + }), + { + // Invalid: externalId removed => fails schema validation before reaching Facebook. + ...createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-retl-user-011', + email: 'e2e-fb-retl-011@segment.com' + }), + userId: undefined as unknown as string + } + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { method: 'POST', data: ['e2e-fb-retl-user-009', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { status: 200, sent: { method: 'DELETE', data: ['e2e-fb-retl-user-010', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] } }, + { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'externalId'.", + errorreporter: 'INTEGRATIONS' + } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__tests__/functions.test.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__tests__/functions.test.ts index bb466712b44..6b3658dfd19 100644 --- a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__tests__/functions.test.ts +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__tests__/functions.test.ts @@ -1,6 +1,7 @@ import * as crypto from 'crypto' import { getAudienceId, + isJourneyPayloads, validate, getData, normalizeMonth, @@ -12,6 +13,7 @@ import { normalizeCountry } from '../functions' import { Payload } from '../generated-types' +import { RawData } from '../types' const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex') @@ -21,6 +23,46 @@ const basePayload: Payload = { batch_size: 1000 } +// --------------------------------------------------------------------------- +// isJourneyPayloads +// --------------------------------------------------------------------------- +describe('isJourneyPayloads', () => { + const journeyRaw: RawData = { context: { personas: { computation_class: 'journey_step' } } } + const audienceRaw: RawData = { context: { personas: { computation_class: 'audience' } } } + + it('returns false when rawDatas is undefined', () => { + expect(isJourneyPayloads(undefined)).toBe(false) + }) + + it('returns false when rawDatas is an empty array', () => { + expect(isJourneyPayloads([])).toBe(false) + }) + + it('returns true when all events are journey_step', () => { + expect(isJourneyPayloads([journeyRaw, journeyRaw])).toBe(true) + }) + + it('returns false when no events are journey_step', () => { + expect(isJourneyPayloads([audienceRaw, audienceRaw])).toBe(false) + }) + + it('returns false when events have no computation_class', () => { + expect(isJourneyPayloads([{}, { context: {} }, { context: { personas: {} } }])).toBe(false) + }) + + it('throws when the batch mixes journey_step and non-journey_step events', () => { + expect(() => isJourneyPayloads([journeyRaw, audienceRaw])).toThrow( + 'Batch contains a mix of journey_step and non-journey_step events. All events in a batch must be the same computation_class.' + ) + }) + + it('throws when journey_step events are mixed with events missing computation_class', () => { + expect(() => isJourneyPayloads([journeyRaw, {}])).toThrow( + 'Batch contains a mix of journey_step and non-journey_step events. All events in a batch must be the same computation_class.' + ) + }) +}) + // --------------------------------------------------------------------------- // getAudienceId // --------------------------------------------------------------------------- diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__tests__/journeys.test.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__tests__/journeys.test.ts index 3ce8fee572e..2e09c394a3d 100644 --- a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__tests__/journeys.test.ts +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__tests__/journeys.test.ts @@ -1,9 +1,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -import { getJourneysMemberships } from '../functions' -import { RawData } from '../types' -import { BASE_URL, FACEBOOK_CUSTOM_AUDIENCE_JOURNEYS_FLAGON } from '../../constants' +import { BASE_URL } from '../../constants' let testDestination = createTestIntegration(Destination) @@ -51,79 +49,20 @@ function makeJourneyEvent(email: string, userId: string) { } // --------------------------------------------------------------------------- -// getJourneysMemberships (unit tests) +// journey_step batch behavior (integration tests) +// +// Unit tests for isJourneyPayloads (which replaced getJourneysV1Memberships) live in +// functions.test.ts. The cases below exercise the end-to-end JourneysV1/V2 batch behavior. // --------------------------------------------------------------------------- -describe('getJourneysMemberships', () => { - it('returns undefined when rawDatas is undefined', () => { - expect(getJourneysMemberships(undefined)).toBeUndefined() - }) - - it('returns undefined when rawDatas is an empty array', () => { - expect(getJourneysMemberships([])).toBeUndefined() - }) - - it('returns undefined when no events are journey_step', () => { - const rawDatas: RawData[] = [ - { context: { personas: { computation_class: 'audience' } } }, - { context: { personas: { computation_class: 'audience' } } } - ] - expect(getJourneysMemberships(rawDatas)).toBeUndefined() - }) - - it('returns an array of true values when all events are journey_step', () => { - const rawDatas: RawData[] = [ - { context: { personas: { computation_class: 'journey_step', computation_key: 'my_audience' } }, properties: {} }, - { context: { personas: { computation_class: 'journey_step', computation_key: 'my_audience' } }, properties: {} }, - { context: { personas: { computation_class: 'journey_step', computation_key: 'my_audience' } }, properties: {} } - ] - const result = getJourneysMemberships(rawDatas) - expect(result).toEqual([true, true, true]) - expect(result).toHaveLength(3) - }) - - it('throws PayloadValidationError when batch contains a mix of journey_step and non-journey_step', () => { - const rawDatas: RawData[] = [ - { context: { personas: { computation_class: 'journey_step', computation_key: 'my_audience' } }, properties: {} }, - { context: { personas: { computation_class: 'audience' } } } - ] - expect(() => getJourneysMemberships(rawDatas)).toThrow( - 'Batch contains a mix of journey_step and non-journey_step events. All events in a batch must be the same computation_class.' - ) - }) - - it('returns array matching rawDatas length for single event', () => { - const rawDatas: RawData[] = [ - { context: { personas: { computation_class: 'journey_step', computation_key: 'my_audience' } }, properties: {} } - ] - const result = getJourneysMemberships(rawDatas) - expect(result).toEqual([true]) - }) - - it('returns all true even when properties[computation_key] is a boolean', () => { - const rawDatas: RawData[] = [ - { context: { personas: { computation_class: 'journey_step', computation_key: 'my_audience' } }, properties: { my_audience: true } }, - { context: { personas: { computation_class: 'journey_step', computation_key: 'my_audience' } }, properties: { my_audience: false } }, - { context: { personas: { computation_class: 'journey_step', computation_key: 'my_audience' } }, properties: { my_audience: true } } - ] - const result = getJourneysMemberships(rawDatas) - expect(result).toEqual([true, true, true]) - }) -}) - -// --------------------------------------------------------------------------- -// Feature flag gating (integration tests) -// --------------------------------------------------------------------------- -describe('FacebookCustomAudiences.sync - journey_step with feature flag', () => { +describe('FacebookCustomAudiences.sync - journey_step', () => { beforeEach(() => { testDestination = createTestIntegration(Destination) nock.cleanAll() }) - it('should add all journey_step users when feature flag is enabled', async () => { - const events = [ - makeJourneyEvent('user1@test.com', 'user-1'), - makeJourneyEvent('user2@test.com', 'user-2') - ] + // JourneysV1: journey_step events without per-event membership booleans => all users added. + it('JourneysV1: adds all journey_step users (no membership booleans)', async () => { + const events = [makeJourneyEvent('user1@test.com', 'user-1'), makeJourneyEvent('user2@test.com', 'user-2')] nock(BASE_URL) .post(new RegExp(`/${AUDIENCE_ID}/users`)) @@ -139,8 +78,7 @@ describe('FacebookCustomAudiences.sync - journey_step with feature flag', () => events, settings, mapping: journeyMapping, - auth, - features: { [FACEBOOK_CUSTOM_AUDIENCE_JOURNEYS_FLAGON]: true } + auth }) expect(responses).toBeDefined() @@ -148,7 +86,9 @@ describe('FacebookCustomAudiences.sync - journey_step with feature flag', () => expect(successResponses).toHaveLength(2) }) - it('should not override audienceMemberships when properties[computation_key] is already a boolean', async () => { + // JourneysV2: journey_step events that DO carry membership booleans => add and remove honored + // (the journey membership override is skipped when audienceMemberships are already booleans). + it('JourneysV2: honors per-event membership booleans (add + remove)', async () => { const events = [ createTestEvent({ type: 'track', @@ -208,8 +148,7 @@ describe('FacebookCustomAudiences.sync - journey_step with feature flag', () => events, settings, mapping: journeyMapping, - auth, - features: { [FACEBOOK_CUSTOM_AUDIENCE_JOURNEYS_FLAGON]: true } + auth }) expect(responses).toBeDefined() @@ -218,25 +157,4 @@ describe('FacebookCustomAudiences.sync - journey_step with feature flag', () => expect(addNock.isDone()).toBe(true) expect(deleteNock.isDone()).toBe(true) }) - - it('should fail with missing membership details when feature flag is disabled', async () => { - const events = [ - makeJourneyEvent('user1@test.com', 'user-1'), - makeJourneyEvent('user2@test.com', 'user-2') - ] - - const responses = await testDestination.executeBatch('sync', { - events, - settings, - mapping: journeyMapping, - auth, - features: {} - }) - - expect(responses).toBeDefined() - const errorResponses = responses.filter((r: any) => r.status >= 400) - expect(errorResponses).toHaveLength(2) - expect(errorResponses[0].errormessage).toBe('Audience membership details missing') - expect(errorResponses[1].errormessage).toBe('Audience membership details missing') - }) }) diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/functions.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/functions.ts index 5c399bdcf4a..10c593cff83 100644 --- a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/functions.ts +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/functions.ts @@ -12,19 +12,17 @@ import type { JSONLikeObject, AudienceMembership } from '@segment/actions-core' import { StatsContext } from '@segment/actions-core/destination-kit' import { processHashing } from '../../../lib/hashing-utils' import { PayloadMap, AudienceJSON, FacebookDataRow, RawData } from './types' -import { BASE_URL, FACEBOOK_CUSTOM_AUDIENCE_JOURNEYS_FLAGON } from '../constants' +import { BASE_URL } from '../constants' import { parseFacebookError, getApiVersion } from '../functions' import { FacebookResponseError } from '../types' /* - * Temporary function to handle audience membership when preset journeys_step_entered_track is in use. - * All users will be added to the audience (no removals). - * This function will be removed once the Journeys team have migrated customers off of the - * journeys_step_entered_track preset. + * Payloads with computation_class === 'journey_step' are Journeys payloads. + * Check that all or none are Journeys payloads. If some are and some aren't, throw an error (which should never happen) */ -export function getJourneysMemberships(rawDatas: RawData[] | undefined): boolean[] | undefined { +export function isJourneyPayloads(rawDatas: RawData[] | undefined): boolean { if (!rawDatas || (Array.isArray(rawDatas) && rawDatas.length === 0)) { - return undefined + return false } const isJourneyStep = rawDatas.map((raw) => raw?.context?.personas?.computation_class === 'journey_step') @@ -38,10 +36,10 @@ export function getJourneysMemberships(rawDatas: RawData[] | undefined): boolean } if (noneJourney) { - return undefined + return false } - return new Array(rawDatas.length).fill(true) + return true } export async function send( @@ -56,21 +54,26 @@ export async function send( ): Promise { const msResponse = new MultiStatusResponse() - if (features && features[FACEBOOK_CUSTOM_AUDIENCE_JOURNEYS_FLAGON]) { - const journeyMemberships = getJourneysMemberships(rawData) - if (Array.isArray(journeyMemberships) && journeyMemberships.length > 0) { - if (!audienceMemberships?.every((m) => typeof m === 'boolean')) { - // The above check is to ensure that the future JourneysVs preset will be able to add + remove users from the audience. - audienceMemberships = journeyMemberships - } - } + // isJourneyPayloads also throws if the batch mixes journey_step and non-journey_step events. + const isJourney = isJourneyPayloads(rawData) + if (isJourney && !audienceMemberships?.every((m) => typeof m === 'boolean')) { + // If audienceMemberships is already populated with booleans we assume JourneysV2 (which can add + // AND remove users), so we leave it untouched. Otherwise we assume JourneysV1 (which only adds) + // and set all memberships to true. + // + // NOTE: the membership array is sized to `payloads`, NOT to `rawData`. `rawData` is the full, + // unfiltered batch, but invalid payloads are dropped before they reach here — so `payloads` may + // be shorter. Building the array from `payloads.length` keeps it aligned and avoids a spurious + // "Audience membership details count does not match batch payload count." error that would fail + // the whole batch when one event was filtered out. + audienceMemberships = new Array(payloads.length).fill(true) } const audienceId = getAudienceId(payloads[0], hookOutputs) const errorMessage = validate(payloads, audienceId, audienceMemberships) if (errorMessage) { - return returnErrorResponse(msResponse, payloads, isBatch, errorMessage, ErrorCodes.PAYLOAD_VALIDATION_FAILED) + return returnErrorResponse(msResponse, payloads, isBatch, errorMessage, ErrorCodes.INVALID_AUDIENCE_MEMBERSHIP) } const addMap: PayloadMap = new Map() diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts new file mode 100644 index 00000000000..b7e049dbe7f --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts @@ -0,0 +1,76 @@ +/** + * Required environment variables: + * - E2E_GOOGLE_ADS_CUSTOMER_ID: Google Ads customer ID + * - E2E_GOOGLE_ADS_REFRESH_TOKEN: OAuth refresh token for Google Ads API + * - E2E_GOOGLE_ADS_CLIENT_ID: OAuth client ID + * - E2E_GOOGLE_ADS_CLIENT_SECRET: OAuth client secret + * - ADWORDS_DEVELOPER_TOKEN: Google Ads API developer token (used in request headers) + * - GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID: Client ID used for token refresh at runtime + * - GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET: Client secret used for token refresh at runtime + */ +import type { E2EAudienceDestinationConfig, E2ETeardownAudienceContext } from '@segment/actions-core' + +const audienceName = `e2e_test_user_list_${Date.now()}` + +export const config: E2EAudienceDestinationConfig = { + settings: { + customerId: { $env: 'E2E_GOOGLE_ADS_CUSTOMER_ID' }, + oauth: { + access_token: 'will_be_refreshed', + refresh_token: { $env: 'E2E_GOOGLE_ADS_REFRESH_TOKEN' }, + clientId: { $env: 'E2E_GOOGLE_ADS_CLIENT_ID' }, + clientSecret: { $env: 'E2E_GOOGLE_ADS_CLIENT_SECRET' } + } + }, + audience: { + audienceName, + audienceSettings: { + supports_conversions: false, + external_id_type: 'CONTACT_INFO' + }, + createAudience: true, + getAudience: true, + teardown: async (context: E2ETeardownAudienceContext) => { + const { settings, externalAudienceId } = context + const oauth = settings.oauth as { refresh_token: string; clientId: string; clientSecret: string } + const customerId = settings.customerId as string + + const tokenResponse = await fetch('https://www.googleapis.com/oauth2/v4/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + refresh_token: oauth.refresh_token, + client_id: process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID ?? oauth.clientId, + client_secret: process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET ?? oauth.clientSecret, + grant_type: 'refresh_token' + }) + }) + + if (!tokenResponse.ok) { + throw new Error(`Failed to refresh token: ${tokenResponse.statusText}`) + } + + const { access_token } = (await tokenResponse.json()) as { access_token: string } + + const response = await fetch( + `https://googleads.googleapis.com/v21/customers/${customerId}/userLists:mutate`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'developer-token': process.env.ADWORDS_DEVELOPER_TOKEN ?? '', + authorization: `Bearer ${access_token}` + }, + body: JSON.stringify({ + operations: [{ remove: `customers/${customerId}/userLists/${externalAudienceId}` }] + }) + } + ) + + if (!response.ok) { + const body = await response.text() + throw new Error(`Failed to delete user list ${externalAudienceId}: ${response.status} ${body}`) + } + } + } +} diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/__helpers__/userList-audiences-helpers.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/__helpers__/userList-audiences-helpers.ts new file mode 100644 index 00000000000..5eb61f9ab50 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/__helpers__/userList-audiences-helpers.ts @@ -0,0 +1,204 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, FLAGS } from '@segment/actions-core' +import type { SegmentEvent } from '@segment/actions-core' +import GoogleEnhancedConversions from '../../index' +import { API_VERSION } from '../../functions' + +export const testDestination = createTestIntegration(GoogleEnhancedConversions) +export const customerId = '1234' +export const timestamp = new Date('Thu Jun 10 2021 11:08:04 GMT-0700 (Pacific Daylight Time)').toISOString() + +// CRM_ID mapping keeps the outbound identifier (thirdPartyUserId) equal to the event's userId, so the +// per-payload `sent` operation is easy to assert exactly. +export const mapping = { + crm_id: { '@path': '$.userId' }, + event_name: { '@path': '$.event' }, + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + external_audience_id: '1234', + retlOnMappingSave: { + outputs: { + id: '1234', + name: 'Test List', + external_id_type: 'CRM_ID' + } + } +} + +export const FLAG_ON = { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } + +// Each flag case: `features` is spread into the action input (undefined => flag off). +export const flagCases = [ + { name: 'flag OFF', features: undefined as Record | undefined }, + { name: 'flag ON', features: FLAG_ON as Record | undefined } +] + +const CREATE_JOB_URL = `https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create` +const ADD_OPS_URL = `https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations` +const RUN_JOB_URL = `https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run` + +/** Mocks the three offline-user-data-job calls for the single-event (perform) path. */ +export function setupNocksForPerform(): void { + nock(CREATE_JOB_URL).post(/.*/).reply(200, { data: 'offlineDataJob' }) + nock(ADD_OPS_URL).post(/.*/).reply(200, { data: 'offlineDataJob' }) + nock(RUN_JOB_URL).post(/.*/).reply(200, { data: 'offlineDataJob' }) +} + +/** + * Mocks the job calls for the batch (performBatch) path. `addOperationsCalls` is how many + * addOperations requests to expect (1 for all-add or all-remove batches, 2 when a batch contains + * both adds and removes — they are sent as separate calls). + */ +export function setupNocksForBatch(addOperationsCalls = 1): void { + nock(CREATE_JOB_URL).post(/.*/).reply(200, { resourceName: 'customers/1234/userLists/1234' }) + nock(ADD_OPS_URL).post(/.*/).times(addOperationsCalls).reply(200, {}) + nock(RUN_JOB_URL).post(/.*/).reply(200, { done: true }) +} + +/** + * Mocks the batch path where Google reports a partial failure for the operation at `failedOpIndex` + * within the CREATE operations array. When the batch also contains removes (`hasRemoves`, the + * default), the separate REMOVE operations call succeeds. For all-add batches (e.g. Journeys V1) + * there is only one addOperations call, so pass `hasRemoves = false`. + */ +export function setupNocksForBatchPartialFailure(failedOpIndex = 0, hasRemoves = true): void { + nock(CREATE_JOB_URL).post(/.*/).reply(200, { resourceName: 'customers/1234/userLists/1234' }) + nock(ADD_OPS_URL) + .post(/.*/, (body: { operations: Array> }) => body.operations.some((op) => 'create' in op)) + .reply(200, { + partialFailureError: { + code: 3, + message: 'partial failure', + details: [ + { + '@type': 'type.googleapis.com/google.ads.googleads.v21.errors.GoogleAdsFailure', + errors: [ + { + errorCode: { offlineUserDataJobError: 'INVALID_SHA256_FORMAT' }, + message: 'The SHA256 encoded value is malformed.', + location: { fieldPathElements: [{ fieldName: 'operations', index: failedOpIndex }] } + } + ] + } + ] + } + }) + if (hasRemoves) { + nock(ADD_OPS_URL) + .post(/.*/, (body: { operations: Array> }) => body.operations.some((op) => 'remove' in op)) + .reply(200, {}) + } + nock(RUN_JOB_URL).post(/.*/).reply(200, { done: true }) +} + +/** + * Journeys V1 event: always uses the "Audience Entered" event name and never carries + * properties[computation_key]. Such events always ADD (V1 has no remove path). + */ +export function createJourneyV1Event(userId: string): SegmentEvent { + return createTestEvent({ + timestamp, + type: 'track', + userId, + event: 'Audience Entered', + properties: { + journey_context: { my_audience: {} }, + journey_metadata: { journey_id: 'jver_1', journey_name: 'journey1' } + }, + context: { + personas: { + computation_id: 'journey_123', + computation_key: 'my_audience', + computation_class: 'journey_step', + namespace: 'spa_abc' + } + } + }) +} + +/** + * Journeys V2 event: a journey_step event carrying the membership boolean + * properties[computation_key] (true => add, false => remove). Only sent with the flag on. + */ +export function createJourneyV2Event(userId: string | undefined, membership: boolean | undefined): SegmentEvent { + const properties: Record = { + journey_context: { my_audience: {} }, + journey_metadata: { journey_id: 'jver_1', journey_name: 'journey1' } + } + if (typeof membership === 'boolean') properties.my_audience = membership + return createTestEvent({ + timestamp, + type: 'track', + userId, + event: 'Journeys V2 Step Transition', + properties, + context: { + personas: { + computation_id: 'journey_123', + computation_key: 'my_audience', + computation_class: 'journey_step', + namespace: 'spa_abc' + } + } + }) +} + +/** + * Engage (audience) event. Add/remove is driven by the event name "Audience Entered"/"Audience + * Exited" (and, when the flag is on, also by the membership boolean) — independent of the flag. + */ +export function createEngageEvent( + userId: string | undefined, + action: 'add' | 'remove', + membership?: boolean +): SegmentEvent { + const properties: Record = {} + if (typeof membership === 'boolean') properties.my_audience = membership + return createTestEvent({ + timestamp, + type: 'track', + userId, + event: action === 'add' ? 'Audience Entered' : 'Audience Exited', + properties, + context: { + personas: { + computation_id: 'aud_123', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_abc' + } + } + }) +} + +export const createOperation = (thirdPartyUserId: string) => ({ create: { userIdentifiers: { thirdPartyUserId } } }) +export const removeOperation = (thirdPartyUserId: string) => ({ remove: { userIdentifiers: { thirdPartyUserId } } }) + +/** Full multi-status node for a successful batch operation. */ +export const successNode = (operation: Record) => ({ + status: 200, + sent: operation, + body: { done: true } +}) + +/** Full multi-status node for a payload that failed client-side validation (missing identifier). */ +export const validationErrorNode = () => ({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Missing or Invalid data for CRM_ID.', + errorreporter: 'INTEGRATIONS' +}) + +/** Full multi-status node for an operation Google rejected via partial failure. */ +export const partialFailureNode = (operation: Record) => ({ + status: 400, + errortype: 'BAD_REQUEST', + errormessage: 'The SHA256 encoded value is malformed.', + sent: operation, + body: { + errorCode: { offlineUserDataJobError: 'INVALID_SHA256_FORMAT' }, + message: 'The SHA256 encoded value is malformed.', + location: { fieldPathElements: [{ fieldName: 'operations', index: 0 }] } + }, + errorreporter: 'DESTINATION' +}) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-engage.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-engage.test.ts new file mode 100644 index 00000000000..6e3f9532faf --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-engage.test.ts @@ -0,0 +1,134 @@ +import nock from 'nock' +import { + testDestination, + customerId, + mapping, + flagCases, + setupNocksForPerform, + setupNocksForBatch, + setupNocksForBatchPartialFailure, + createEngageEvent, + createOperation, + removeOperation, + successNode, + validationErrorNode, + partialFailureNode +} from './__helpers__/userList-audiences-helpers' + +// Engage (audience) events: add/remove is driven by the "Audience Entered"/"Audience Exited" event +// name, and is independent of the audience-membership feature flag — so every scenario is run under +// both flag states and asserts the same result. +describe('GoogleEnhancedConversions userList - Engage', () => { + beforeEach(() => { + nock.cleanAll() + testDestination.responses = [] + }) + afterEach(() => nock.cleanAll()) + + describe.each(flagCases)('$name', ({ features }) => { + const exec = (events: Parameters[1]['events']) => + testDestination.executeBatch('userList', { + events, + mapping: { ...mapping, __segment_internal_sync_mode: 'mirror' }, + settings: { customerId }, + ...(features && { features }) + }) + + describe('single event (perform)', () => { + it('Audience Entered => adds the user', async () => { + setupNocksForPerform() + const responses = await testDestination.testAction('userList', { + event: createEngageEvent('eng_single_add', 'add', true), + mapping: { ...mapping, __segment_internal_sync_mode: 'mirror' }, + useDefaultMappings: true, + settings: { customerId }, + ...(features && { features }) + }) + expect(responses.length).toEqual(3) + expect(responses[1].options.json).toEqual({ + operations: [createOperation('eng_single_add')], + enable_warnings: true + }) + }) + + it('Audience Exited => removes the user', async () => { + setupNocksForPerform() + const responses = await testDestination.testAction('userList', { + event: createEngageEvent('eng_single_remove', 'remove', false), + mapping: { ...mapping, __segment_internal_sync_mode: 'mirror' }, + useDefaultMappings: true, + settings: { customerId }, + ...(features && { features }) + }) + expect(responses.length).toEqual(3) + expect(responses[1].options.json).toEqual({ + operations: [removeOperation('eng_single_remove')], + enable_warnings: true + }) + }) + }) + + describe('batch (performBatch)', () => { + it('batch add', async () => { + setupNocksForBatch(1) + const responses = await exec([ + createEngageEvent('ea1', 'add', true), + createEngageEvent('ea2', 'add', true) + ]) + expect(responses).toMatchObject([successNode(createOperation('ea1')), successNode(createOperation('ea2'))]) + }) + + it('batch remove', async () => { + setupNocksForBatch(1) + const responses = await exec([ + createEngageEvent('er1', 'remove', false), + createEngageEvent('er2', 'remove', false) + ]) + expect(responses).toMatchObject([successNode(removeOperation('er1')), successNode(removeOperation('er2'))]) + }) + + it('batch mixed adds and removes', async () => { + setupNocksForBatch(2) + const responses = await exec([ + createEngageEvent('em0', 'add', true), + createEngageEvent('em1', 'remove', false), + createEngageEvent('em2', 'add', true) + ]) + expect(responses).toMatchObject([ + successNode(createOperation('em0')), + successNode(removeOperation('em1')), + successNode(createOperation('em2')) + ]) + }) + + it('batch mixed with an invalid payload (missing identifier)', async () => { + setupNocksForBatch(2) + const responses = await exec([ + createEngageEvent('ex0', 'add', true), + createEngageEvent('ex1', 'remove', false), + createEngageEvent(undefined, 'add', true) + ]) + expect(responses).toMatchObject([ + successNode(createOperation('ex0')), + successNode(removeOperation('ex1')), + validationErrorNode() + ]) + }) + + it('batch mixed with a Google partial-failure response', async () => { + setupNocksForBatchPartialFailure(0) + // Order: add (Google rejects this op), remove (ok), invalid (missing identifier). + const responses = await exec([ + createEngageEvent('ef0', 'add', true), + createEngageEvent('ef1', 'remove', false), + createEngageEvent(undefined, 'add', true) + ]) + expect(responses).toMatchObject([ + partialFailureNode(createOperation('ef0')), + successNode(removeOperation('ef1')), + validationErrorNode() + ]) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeysV1.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeysV1.test.ts new file mode 100644 index 00000000000..db3fc5b9b92 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeysV1.test.ts @@ -0,0 +1,91 @@ +import nock from 'nock' +import { + testDestination, + customerId, + mapping, + flagCases, + setupNocksForPerform, + setupNocksForBatch, + setupNocksForBatchPartialFailure, + createJourneyV1Event, + createOperation, + successNode, + validationErrorNode, + partialFailureNode +} from './__helpers__/userList-audiences-helpers' + +// Journeys V1: events always use the "Audience Entered" event name and never carry +// properties[computation_key]. They ALWAYS add (V1 has no remove path), and this is true whether the +// audience-membership feature flag is on or off — so every scenario is run under both flag states. +describe('GoogleEnhancedConversions userList - Journeys V1', () => { + beforeEach(() => { + nock.cleanAll() + testDestination.responses = [] + }) + afterEach(() => nock.cleanAll()) + + describe.each(flagCases)('$name', ({ features }) => { + const exec = (events: Parameters[1]['events']) => + testDestination.executeBatch('userList', { + events, + mapping: { ...mapping, __segment_internal_sync_mode: 'mirror' }, + settings: { customerId }, + ...(features && { features }) + }) + + describe('single event (perform)', () => { + it('adds the user', async () => { + setupNocksForPerform() + const responses = await testDestination.testAction('userList', { + event: createJourneyV1Event('v1_single_add'), + mapping: { ...mapping, __segment_internal_sync_mode: 'mirror' }, + useDefaultMappings: true, + settings: { customerId }, + ...(features && { features }) + }) + expect(responses.length).toEqual(3) + expect(responses[1].options.json).toEqual({ + operations: [createOperation('v1_single_add')], + enable_warnings: true + }) + }) + }) + + describe('batch (performBatch)', () => { + it('batch add', async () => { + setupNocksForBatch(1) + const responses = await exec([createJourneyV1Event('v1a1'), createJourneyV1Event('v1a2')]) + expect(responses).toMatchObject([successNode(createOperation('v1a1')), successNode(createOperation('v1a2'))]) + }) + + it('batch mixed with an invalid payload (missing identifier)', async () => { + setupNocksForBatch(1) + const responses = await exec([ + createJourneyV1Event('v1x0'), + createJourneyV1Event(undefined as unknown as string), + createJourneyV1Event('v1x2') + ]) + expect(responses).toMatchObject([ + successNode(createOperation('v1x0')), + validationErrorNode(), + successNode(createOperation('v1x2')) + ]) + }) + + it('batch mixed with a Google partial-failure response', async () => { + setupNocksForBatchPartialFailure(0, false) // V1 is all-adds => single addOperations call + // All adds; Google rejects the operation at index 0, the rest succeed. + const responses = await exec([ + createJourneyV1Event('v1f0'), + createJourneyV1Event('v1f1'), + createJourneyV1Event(undefined as unknown as string) + ]) + expect(responses).toMatchObject([ + partialFailureNode(createOperation('v1f0')), + successNode(createOperation('v1f1')), + validationErrorNode() + ]) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeysV2.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeysV2.test.ts new file mode 100644 index 00000000000..e9f94ba2b02 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeysV2.test.ts @@ -0,0 +1,127 @@ +import nock from 'nock' +import { + testDestination, + customerId, + mapping, + FLAG_ON, + setupNocksForPerform, + setupNocksForBatch, + setupNocksForBatchPartialFailure, + createJourneyV2Event, + createOperation, + removeOperation, + successNode, + validationErrorNode, + partialFailureNode +} from './__helpers__/userList-audiences-helpers' + +// Journeys V2: journey_step events that carry the membership boolean properties[computation_key]. +// V2 is ONLY ever sent with the audience-membership feature flag ON, so every test here enables it. +// The operation is driven by the boolean: true => add, false => remove. +describe('GoogleEnhancedConversions userList - Journeys V2 (flag ON)', () => { + beforeEach(() => { + nock.cleanAll() + testDestination.responses = [] + }) + afterEach(() => nock.cleanAll()) + + const exec = (events: Parameters[1]['events']) => + testDestination.executeBatch('userList', { + events, + mapping: { ...mapping, __segment_internal_sync_mode: 'mirror' }, + settings: { customerId }, + features: FLAG_ON + }) + + describe('single event (perform)', () => { + it('computation_key=true => adds the user', async () => { + setupNocksForPerform() + const responses = await testDestination.testAction('userList', { + event: createJourneyV2Event('v2_single_add', true), + mapping: { ...mapping, __segment_internal_sync_mode: 'mirror' }, + useDefaultMappings: true, + settings: { customerId }, + features: FLAG_ON + }) + // perform path returns raw HTTP responses (create job, addOperations, run job). + expect(responses.length).toEqual(3) + expect(responses[1].options.json).toEqual({ + operations: [createOperation('v2_single_add')], + enable_warnings: true + }) + }) + + it('computation_key=false => removes the user', async () => { + setupNocksForPerform() + const responses = await testDestination.testAction('userList', { + event: createJourneyV2Event('v2_single_remove', false), + mapping: { ...mapping, __segment_internal_sync_mode: 'mirror' }, + useDefaultMappings: true, + settings: { customerId }, + features: FLAG_ON + }) + expect(responses.length).toEqual(3) + expect(responses[1].options.json).toEqual({ + operations: [removeOperation('v2_single_remove')], + enable_warnings: true + }) + }) + }) + + describe('batch (performBatch)', () => { + it('batch add (all computation_key=true)', async () => { + setupNocksForBatch(1) + const responses = await exec([createJourneyV2Event('v2a1', true), createJourneyV2Event('v2a2', true)]) + expect(responses).toMatchObject([successNode(createOperation('v2a1')), successNode(createOperation('v2a2'))]) + }) + + it('batch remove (all computation_key=false)', async () => { + setupNocksForBatch(1) + const responses = await exec([createJourneyV2Event('v2r1', false), createJourneyV2Event('v2r2', false)]) + expect(responses).toMatchObject([successNode(removeOperation('v2r1')), successNode(removeOperation('v2r2'))]) + }) + + it('batch mixed adds and removes', async () => { + setupNocksForBatch(2) + const responses = await exec([ + createJourneyV2Event('v2m0', true), + createJourneyV2Event('v2m1', false), + createJourneyV2Event('v2m2', true) + ]) + expect(responses).toMatchObject([ + successNode(createOperation('v2m0')), + successNode(removeOperation('v2m1')), + successNode(createOperation('v2m2')) + ]) + }) + + it('batch mixed with an invalid payload (missing identifier)', async () => { + setupNocksForBatch(2) + const responses = await exec([ + createJourneyV2Event('v2x0', true), + createJourneyV2Event('v2x1', false), + createJourneyV2Event(undefined, true) + ]) + expect(responses).toMatchObject([ + successNode(createOperation('v2x0')), + successNode(removeOperation('v2x1')), + validationErrorNode() + ]) + }) + + it('batch mixed with a Google partial-failure response', async () => { + setupNocksForBatchPartialFailure(0) + // Order: add (Google rejects this op), remove (ok), invalid (missing identifier). + const responses = await exec([ + createJourneyV2Event('v2f0', true), + createJourneyV2Event('v2f1', false), + createJourneyV2Event(undefined, true) + ]) + expect(responses).toMatchObject([ + partialFailureNode(createOperation('v2f0')), + successNode(removeOperation('v2f1')), + validationErrorNode() + ]) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts index 361b214c1a1..9d4121450a2 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts @@ -1,5 +1,5 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, FLAGS } from '@segment/actions-core' import GoogleEnhancedConversions from '../index' import { API_VERSION } from '../functions' import { SegmentEvent } from '@segment/actions-core' @@ -27,8 +27,19 @@ const mapping = { } } } + +const testCases = [ + { name: 'flag off', features: undefined }, + { name: 'flag on', features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } } +] + describe('GoogleEnhancedConversions', () => { - describe('userList', () => { + describe.each(testCases)('userList ($name)', ({ features }) => { + beforeEach(() => { + nock.cleanAll() + testDestination.responses = [] + }) + afterEach(() => nock.cleanAll()) it('sends an event with default mappings - event = Audience Entered', async () => { const event = createTestEvent({ timestamp, @@ -80,7 +91,8 @@ describe('GoogleEnhancedConversions', () => { useDefaultMappings: true, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses.length).toEqual(3) @@ -143,7 +155,8 @@ describe('GoogleEnhancedConversions', () => { useDefaultMappings: true, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses.length).toEqual(3) @@ -207,7 +220,8 @@ describe('GoogleEnhancedConversions', () => { useDefaultMappings: true, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses.length).toEqual(3) @@ -271,7 +285,8 @@ describe('GoogleEnhancedConversions', () => { useDefaultMappings: true, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses.length).toEqual(3) @@ -335,7 +350,8 @@ describe('GoogleEnhancedConversions', () => { useDefaultMappings: true, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses.length).toEqual(3) @@ -399,7 +415,8 @@ describe('GoogleEnhancedConversions', () => { useDefaultMappings: true, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses.length).toEqual(3) @@ -460,7 +477,8 @@ describe('GoogleEnhancedConversions', () => { useDefaultMappings: true, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses.length).toEqual(3) @@ -523,7 +541,8 @@ describe('GoogleEnhancedConversions', () => { useDefaultMappings: true, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses.length).toEqual(3) @@ -588,7 +607,8 @@ describe('GoogleEnhancedConversions', () => { customerId }, features: { - 'google-enhanced-phone-validation-check': true + 'google-enhanced-phone-validation-check': true, + ...features } }) @@ -658,7 +678,8 @@ describe('GoogleEnhancedConversions', () => { customerId }, features: { - 'google-enhanced-phone-validation-check': true + 'google-enhanced-phone-validation-check': true, + ...features } }) @@ -726,7 +747,8 @@ describe('GoogleEnhancedConversions', () => { useDefaultMappings: true, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses.length).toEqual(3) @@ -783,7 +805,8 @@ describe('GoogleEnhancedConversions', () => { customerId }, features: { - 'google-enhanced-phone-validation-check': true + 'google-enhanced-phone-validation-check': true, + ...features } }) @@ -864,7 +887,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -969,7 +993,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ status: 429, @@ -1091,7 +1116,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ status: 429, @@ -1375,7 +1401,8 @@ describe('GoogleEnhancedConversions', () => { customerId }, features: { - 'google-enhanced-phone-validation-check': true + 'google-enhanced-phone-validation-check': true, + ...features } }) @@ -1427,10 +1454,17 @@ describe('GoogleEnhancedConversions', () => { errormessage: "Email provided doesn't seem to be in a valid format.", errorreporter: 'INTEGRATIONS' }) - //success + //success - `sent` now carries the actual add operation sent to Google for this payload expect(responses[2]).toMatchObject({ status: 200, - sent: '/customers/1234/userLists/1234:run', + sent: { + create: { + userIdentifiers: [ + { hashedEmail: '369274ed2ac3d833f5e09ec23e7dc8cd3f962521c37c38bfe8e0b690aeda9217' }, + { hashedPhoneNumber: '0506a1f3f4c515fd310fce54d253b731f71e33e7e7d2b10848528ca4411120b0' } + ] + } + }, body: { done: true } }) @@ -1566,7 +1600,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -1578,7 +1613,14 @@ describe('GoogleEnhancedConversions', () => { expect(responses[1]).toMatchObject({ status: 200, - sent: '/customers/1234/userLists/1234:run', + sent: { + create: { + userIdentifiers: [ + { hashedEmail: '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674' }, + { hashedPhoneNumber: '0506a1f3f4c515fd310fce54d253b731f71e33e7e7d2b10848528ca4411120b0' } + ] + } + }, body: { done: true } @@ -1629,7 +1671,8 @@ describe('GoogleEnhancedConversions', () => { }, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -1687,7 +1730,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -1787,7 +1831,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -1889,7 +1934,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 725a8aa9605..66ead1c045b 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -26,7 +26,9 @@ import { Features, MultiStatusResponse, JSONLikeObject, - ErrorCodes + ErrorCodes, + AudienceMembership, + FLAGS } from '@segment/actions-core' import { StatsContext } from '@segment/actions-core/destination-kit' import { fullFormats } from 'ajv-formats/dist/formats' @@ -525,7 +527,8 @@ const extractUserIdentifiers = ( idType: string, syncMode?: string, features?: Features | undefined, - statsContext?: StatsContext | undefined + statsContext?: StatsContext | undefined, + audienceMembership?: AudienceMembership ) => { const removeUserIdentifiers = [] const addUserIdentifiers = [] @@ -566,20 +569,52 @@ const extractUserIdentifiers = ( return identifiers } } - // Map user data to Google Ads API format + for (const payload of payloads) { - if ( - payload.event_name === 'Audience Entered' || - syncMode === 'add' || - (syncMode === 'mirror' && (payload.event_name === 'new' || payload.event_name === 'updated')) - ) { - addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) - } else if ( - payload.event_name === 'Audience Exited' || - syncMode === 'delete' || - (syncMode === 'mirror' && payload.event_name === 'deleted') - ) { - removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) + if (features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]) { + if ( + payload.event_name === 'Audience Entered' || + syncMode === 'add' || + (syncMode === 'mirror' && (payload.event_name === 'new' || payload.event_name === 'updated')) || + audienceMembership === true + ) { + addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) + } else if ( + payload.event_name === 'Audience Exited' || + syncMode === 'delete' || + (syncMode === 'mirror' && payload.event_name === 'deleted') || + audienceMembership === false + ) { + removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) + } else { + // Neither add nor remove resolved (e.g. 'mirror' default with an unrecognized event_name and + // no audienceMembership signal): the payload is silently dropped. Track so flag-driven + // regressions in the membership logic are observable. + statsContext?.statsClient?.incr('google_ec.user_list.no_operation', 1, [ + ...(statsContext?.tags ?? []), + 'feature_flag:on' + ]) + } + } else { + // Map user data to Google Ads API format + if ( + payload.event_name === 'Audience Entered' || + syncMode === 'add' || + (syncMode === 'mirror' && (payload.event_name === 'new' || payload.event_name === 'updated')) + ) { + addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) + } else if ( + payload.event_name === 'Audience Exited' || + syncMode === 'delete' || + (syncMode === 'mirror' && payload.event_name === 'deleted') + ) { + removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) + } else { + statsContext?.statsClient?.incr('google_ec.user_list.no_operation', 1, [ + ...(statsContext?.tags ?? []), + 'feature_flag:off' + ]) + } } } return [addUserIdentifiers, removeUserIdentifiers] @@ -660,7 +695,8 @@ const processOperations = async ( failedPayloadIndices: Set, multiStatusResponse: MultiStatusResponse, features?: Features | undefined, - statsContext?: StatsContext | undefined + statsContext?: StatsContext | undefined, + isMixedBatch?: boolean ) => { const operationPayload = { operations: userIdentifiers, enablePartialFailure: true } const { success, data, error } = await addOperations(request, operationPayload, resourceName, features, statsContext) @@ -676,6 +712,15 @@ const processOperations = async ( const partialFailureError = (data as any)?.partialFailureError if (partialFailureError) { + if (isMixedBatch) { + // Partial failure on a batch containing BOTH adds and removes: the error-to-payload index + // mapping is known to be unreliable here (STRATCONN-6862). Track to quantify real-world + // blast radius and gauge when the fix should be prioritized. + statsContext?.statsClient?.incr('google_ec.user_list.mixed_batch_partial_failure', 1, [ + ...(statsContext?.tags ?? []), + `feature_flag:${features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP] ? 'on' : 'off'}` + ]) + } handlePartialFailureResponse( partialFailureError, validPayloadIndicesBitmap, @@ -695,7 +740,8 @@ export const handleUpdate = async ( hookListType: string, syncMode?: string, features?: Features | undefined, - statsContext?: StatsContext + statsContext?: StatsContext, + audienceMembership?: AudienceMembership ) => { const externalAudienceId: string | undefined = hookListId || payloads[0]?.external_audience_id if (!externalAudienceId) { @@ -708,7 +754,8 @@ export const handleUpdate = async ( id_type, syncMode, features, - statsContext + statsContext, + audienceMembership ) const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId) // Create an offline user data job @@ -805,13 +852,17 @@ const updateMultiStatusResponseWithSuccess = ( validPayloadIndicesBitmap: number[], multiStatusResponse: MultiStatusResponse, sentBody: JSONLikeObject | string, - failedPayloadIndices: Set + failedPayloadIndices: Set, + operationByIndex: Map ) => { validPayloadIndicesBitmap.forEach((index) => { if (!failedPayloadIndices.has(index)) { + // Surface the actual operation sent to Google for this payload ({ create: ... } or + // { remove: ... }) so add/remove is observable per item. Falls back to sentBody so a + // successful payload is never suppressed if the operation lookup ever misses. multiStatusResponse.setSuccessResponseAtIndex(index, { status: 200, - sent: sentBody, + sent: operationByIndex.get(index) ?? sentBody, body: executedJob.data as JSONLikeObject }) } @@ -832,6 +883,13 @@ export const handlePartialFailureResponse = ( )?.index if (failedIndex >= 0) { + // KNOWN BUG (pre-existing; out of scope for this PR — fix tracked in STRATCONN-6862): + // Introduced in https://github.com/segmentio/action-destinations/pull/2853 (Multistatus Support). + // For batches that contain BOTH adds and removes, a Google-side partial-failure error can be + // attributed to the wrong original payload index. `failedIndex` is the position within a + // single operations array (adds-only or removes-only, as sent to Google), but + // `validPayloadIndicesBitmap` is indexed across all valid payloads (adds and removes mixed), + // so the two index spaces don't line up. Mapping is correct for all-add or all-remove batches. const originalIndex = validPayloadIndicesBitmap[failedIndex] multiStatusResponse.setErrorResponseAtIndex(originalIndex, { status: STATUS_CODE_MAPPING?.[partialFailureError.code as keyof typeof STATUS_CODE_MAPPING]?.status ?? 500, // error code @@ -912,11 +970,17 @@ const extractBatchUserIdentifiers = ( idType: string, multiStatusResponse: MultiStatusResponse, syncMode?: string, - features?: Features + features?: Features, + audienceMemberships?: AudienceMembership[], + statsContext?: StatsContext ) => { const removeUserIdentifiers: any[] = [] const addUserIdentifiers: any[] = [] const validPayloadIndicesBitmap: number[] = [] + // Maps each original payload index to the exact operation object sent to Google for that + // payload ({ create: ... } or { remove: ... }), so the per-payload multi-status `sent` field + // reflects the actual add/remove decision rather than a generic value. + const operationByIndex = new Map() //Identify the user identifiers based on the idType const extractors = createIdentifierExtractors(features) @@ -943,8 +1007,15 @@ const extractBatchUserIdentifiers = ( }) return } - const operationType = determineOperationType(payload, syncMode) - if (!operationType) { + const operationType = determineOperationType(payload, syncMode, features, audienceMemberships?.[index]) + if (operationType === undefined) { + // Neither add nor remove resolved for this payload (e.g. 'mirror' default with an unrecognized + // event_name and no audienceMembership signal). Track so flag-driven regressions in the + // membership logic are observable rather than silently surfacing as payload validation errors. + statsContext?.statsClient?.incr('google_ec.user_list.undetermined_operation_type', 1, [ + ...(statsContext?.tags ?? []), + `feature_flag:${features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP] ? 'on' : 'off'}` + ]) multiStatusResponse.setErrorResponseAtIndex(index, { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', @@ -954,33 +1025,59 @@ const extractBatchUserIdentifiers = ( } validPayloadIndicesBitmap.push(index) - if (operationType === 'add') { - addUserIdentifiers.push({ create: { userIdentifiers } }) + if (operationType === true) { + const operation = { create: { userIdentifiers } } + addUserIdentifiers.push(operation) + operationByIndex.set(index, operation) } else { - removeUserIdentifiers.push({ remove: { userIdentifiers } }) + const operation = { remove: { userIdentifiers } } + removeUserIdentifiers.push(operation) + operationByIndex.set(index, operation) } }) - return { addUserIdentifiers, removeUserIdentifiers, validPayloadIndicesBitmap } + return { addUserIdentifiers, removeUserIdentifiers, validPayloadIndicesBitmap, operationByIndex } } // Helper function to determine operation type -const determineOperationType = (payload: UserListPayload, syncMode?: string) => { - if ( - payload.event_name === 'Audience Entered' || - syncMode === 'add' || - (syncMode === 'mirror' && (payload.event_name === 'new' || payload.event_name === 'updated')) - ) { - return 'add' - } else if ( - payload.event_name === 'Audience Exited' || - syncMode === 'delete' || - (syncMode === 'mirror' && payload.event_name === 'deleted') - ) { - return 'remove' +const determineOperationType = ( + payload: UserListPayload, + syncMode?: string, + features?: Features, + audienceMembership?: AudienceMembership +): boolean | undefined => { + if (features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]) { + if ( + payload.event_name === 'Audience Entered' || + syncMode === 'add' || + (syncMode === 'mirror' && (payload.event_name === 'new' || payload.event_name === 'updated')) || + audienceMembership === true + ) { + return true + } else if ( + payload.event_name === 'Audience Exited' || + syncMode === 'delete' || + (syncMode === 'mirror' && payload.event_name === 'deleted') || + audienceMembership === false + ) { + return false + } + } else { + if ( + payload.event_name === 'Audience Entered' || + syncMode === 'add' || + (syncMode === 'mirror' && (payload.event_name === 'new' || payload.event_name === 'updated')) + ) { + return true + } else if ( + payload.event_name === 'Audience Exited' || + syncMode === 'delete' || + (syncMode === 'mirror' && payload.event_name === 'deleted') + ) { + return false + } } - - return null + return undefined } const createOfflineUserJobPayload = (audienceId: string, payload: UserListPayload, customerId?: string) => ({ @@ -1005,7 +1102,8 @@ export const processBatchPayload = async ( hookListType: string, syncMode?: string, features?: Features | undefined, - statsContext?: StatsContext + statsContext?: StatsContext, + audienceMemberships?: AudienceMembership[] ) => { const externalAudienceId = hookListId || payloads[0]?.external_audience_id if (!externalAudienceId) { @@ -1014,13 +1112,16 @@ export const processBatchPayload = async ( const multiStatusResponse = new MultiStatusResponse() const id_type = hookListType ?? audienceSettings.external_id_type // Extract user identifiers and validPayloadIndicesBitmap from payloads - const { addUserIdentifiers, removeUserIdentifiers, validPayloadIndicesBitmap } = extractBatchUserIdentifiers( - payloads, - id_type, - multiStatusResponse, - syncMode, - features - ) + const { addUserIdentifiers, removeUserIdentifiers, validPayloadIndicesBitmap, operationByIndex } = + extractBatchUserIdentifiers( + payloads, + id_type, + multiStatusResponse, + syncMode, + features, + audienceMemberships, + statsContext + ) // Create offline user data job payload const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId) // Step1 :- Create an Offline user data job @@ -1039,6 +1140,9 @@ export const processBatchPayload = async ( return multiStatusResponse } const failedPayloadIndices: Set = new Set() + // Batches containing both adds and removes are subject to the partial-failure index + // misattribution bug (STRATCONN-6862); flag so processOperations can track occurrences. + const isMixedBatch = addUserIdentifiers.length > 0 && removeUserIdentifiers.length > 0 // Step 2:- Add operations to the Offline user data job if (addUserIdentifiers.length > 0) { await processOperations( @@ -1049,7 +1153,8 @@ export const processBatchPayload = async ( failedPayloadIndices, multiStatusResponse, features, - statsContext + statsContext, + isMixedBatch ) } @@ -1062,7 +1167,8 @@ export const processBatchPayload = async ( failedPayloadIndices, multiStatusResponse, features, - statsContext + statsContext, + isMixedBatch ) } if (failedPayloadIndices.size === validPayloadIndicesBitmap.length) { @@ -1082,7 +1188,8 @@ export const processBatchPayload = async ( validPayloadIndicesBitmap, multiStatusResponse, sentBody, - failedPayloadIndices + failedPayloadIndices, + operationByIndex ) } return multiStatusResponse diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/engage.e2e.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/engage.e2e.ts new file mode 100644 index 00000000000..10b221a3443 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/engage.e2e.ts @@ -0,0 +1,227 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent, FLAGS } from '@segment/actions-core' +import userList from '../index' + +const COMPUTATION_KEY = 'e2e_test_user_list' +const COMPUTATION_ID = 'aud_e2e_google_001' + +const FAILURE_HINT = 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' + +// Engage add/remove is driven by the event name (Audience Entered/Exited) and is independent of the +// audience-membership feature flag. Each scenario below is defined once and run twice — once +// flag-OFF and once flag-ON — asserting the SAME expected output, which proves the flag does not +// change Engage behaviour. +const baseFixtures: E2EFixture[] = [ + { + description: 'Engage Audience: Add a user to the customer match list via track event', + subscribe: 'event = "Audience Entered" or event = "Audience Exited"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-001', + email: 'e2e-google-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Engage Audience: Remove a user from the customer match list via track event', + subscribe: 'event = "Audience Entered"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + eventName: 'Audience Exited', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-001', + email: 'e2e-google-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Engage Audience: Batch add and remove users from the customer match list', + subscribe: 'event = "Audience Entered" or event = "Audience Exited"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-002', + email: 'e2e-google-test-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-003', + email: 'e2e-google-test-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + eventName: 'Audience Exited', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-001', + email: 'e2e-google-test-001@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: 'ed0929a753a3a21b343a7dde6ea518e71ff7f7016ff9f794fc15a6b281dd3596' }] } } + }, + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: '68bb6163c36a3c629fdec854ba60a445191ae745e81d2ea30bd2dc6de8639fb0' }] } } + }, + { + status: 200, + sent: { remove: { userIdentifiers: [{ hashedEmail: 'afccaad85313c73389344c48ad104e32e73abb309c660c529bc7869aba7e1298' }] } } + } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // 9-event batch with adds and removes interleaved with invalid payloads, to verify the + // per-index multi-status mapping (sent operation + error) stays correct under reordering. + // Order: add✓, remove✓, add✗, remove✓, remove✗, add✓, add✓, remove✗, remove✓ + description: 'Engage Audience: 9-event mixed batch — interleaved valid/invalid adds and removes keep correct indexes', + subscribe: 'event = "Audience Entered" or event = "Audience Exited"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ type: 'track', action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-0', email: 'eng-0@segment.com' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'remove', eventName: 'Audience Exited', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-1', email: 'eng-1@segment.com' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-2' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'remove', eventName: 'Audience Exited', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-3', email: 'eng-3@segment.com' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'remove', eventName: 'Audience Exited', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-4' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-5', email: 'eng-5@segment.com' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-6', email: 'eng-6@segment.com' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'remove', eventName: 'Audience Exited', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-7' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'remove', eventName: 'Audience Exited', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-8', email: 'eng-8@segment.com' }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '933a7b9c77740f82a4dc45199faf1d4480284882be71dcc24d75fc7bd79f316d' }] } } }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: '909eb51e2387a8a1a5529d12de458155784fb822dabd471db33d7d32c209ac36' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: '5850f3d49135b144c6f20afee2c75902077e11361ec47d071e9f83381576ed5a' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: 'b7e64df010cdea3d026ccca6ded57425b47bf7387045ebbcc4ff93b015d3519e' }] } } }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: 'ef8aaa0dc5fbee41ac7525970a089167c17496fd144c6718a7e46539783f7bcb' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: '0184be992dafe5c8a136852078ed10f641bc6dfe9499417f53c9e1a67458e6f8' }] } } } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +// Run every scenario twice: flag-OFF and flag-ON. Both assert the SAME expected output, proving +// Engage behaviour is independent of the audience-membership feature flag. +const flagIndependentFixtures: E2EFixture[] = baseFixtures.flatMap((fixture) => [ + { ...fixture, description: `${fixture.description} (flag OFF)` }, + { ...fixture, description: `${fixture.description} (flag ON)`, features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } } +]) + +// Flag-ON-only fixtures using arbitrary (non-"Audience Entered/Exited") event names. With the flag +// on, the add/remove operation is resolved by actions-core from the audience membership signal +// (properties[computation_key]), NOT from the event name — so any event name works. (With the flag +// off these same payloads would return "Could not determine Operation Type", which is why they are +// flag-ON only.) +const flagOnCustomEventNameFixtures: E2EFixture[] = [ + { + description: 'Engage Audience (flag ON): custom event name "Signed Up" adds via audience membership (true)', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'single', + features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true }, + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + eventName: 'Signed Up', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'eng-custom-add', + email: 'eng-custom-add@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Engage Audience (flag ON): custom event names resolve add/remove from audience membership, not event name', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true }, + events: [ + createE2EEngageAudienceEvent({ type: 'track', action: 'add', eventName: 'Signed Up', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-custom-b1', email: 'eng-custom-b1@segment.com' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'remove', eventName: 'Account Closed', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-custom-b2', email: 'eng-custom-b2@segment.com' }), + createE2EEngageAudienceEvent({ type: 'track', action: 'add', eventName: 'Completed Purchase', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'eng-custom-b3', email: 'eng-custom-b3@segment.com' }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: 'ee7981caccbe1912787c5fcdf9e335b236f3adee3dd200ae2ec043f5a64b140e' }] } } }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: '5e528e6789bc100b9cd37a92ebfe2769c34d116e8dd6a6829769fb04ebec88d5' }] } } }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: 'cab4f03dcf6f27b17ea90819b9be769ad7fbe89320134becbda71397c3520210' }] } } } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +const fixtures: E2EFixture[] = [...flagIndependentFixtures, ...flagOnCustomEventNameFixtures] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV1.e2e.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV1.e2e.ts new file mode 100644 index 00000000000..a8356eb705b --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV1.e2e.ts @@ -0,0 +1,137 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EJourneysV1AudienceEvent, FLAGS } from '@segment/actions-core' +import userList from '../index' + +const COMPUTATION_KEY = 'e2e_test_user_list' +const COMPUTATION_ID = 'aud_e2e_google_journeys_001' + +const FAILURE_HINT = + 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' + +// Journeys V1 events carry no membership signal (properties[computation_key] is omitted), so the +// user is always added regardless of the audience-membership feature flag. Each scenario below is +// defined once and run twice — once flag-OFF and once flag-ON — asserting the SAME expected output, +// which proves the always-add behaviour is independent of the flag. +const baseFixtures: E2EFixture[] = [ + { + description: 'JourneysV1 Audience: Add a user to the customer match list via track event', + subscribe: 'event = "Audience Entered" or event = "Audience Exited"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'single', + event: createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-journeys-user-001', + email: 'e2e-google-journeys-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'JourneysV1 Audience: Batch add users to the customer match list', + subscribe: 'event = "Audience Entered" or event = "Audience Exited"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'batchWithMultistatus', + events: [ + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-journeys-user-002', + email: 'e2e-google-journeys-test-002@segment.com' + }), + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-journeys-user-003', + email: 'e2e-google-journeys-test-003@segment.com' + }), + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-journeys-user-004', + email: 'e2e-google-journeys-test-004@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: 'd2307079cffc9d57323b96b8c42ec0a0319761d49246d574d7aaa69977381fba' }] } } + }, + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: '496b2e198a53783256ff19936a68661751b1f681a3aba56832a7e86ef4c07aa6' }] } } + }, + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: 'd489341851a7f0e6b6f0f40bbd00435b663e71a45ac2e8daf7ca4f92e81896ef' }] } } + } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // 9-event batch mixing plain adds, events carrying properties[computation_key]=false, and + // invalid payloads. Journeys V1 events are ALWAYS named "Audience Entered", and that event name + // resolves to an add before the membership signal is ever considered — so even an event with + // properties[computation_key]=false still results in an add (it is NOT a remove). This asserts + // that holds per-index even with invalid payloads interleaved. + // Order: add✓, false✓(->add), invalid✗, false✓(->add), invalid✗, add✓, false✓(->add), invalid✗, add✓ + description: 'JourneysV1 Audience: 9-event mixed batch — valid adds, removes and invalid payloads keep correct indexes', + subscribe: 'event = "Audience Entered" or event = "Audience Exited"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'batchWithMultistatus', + events: [ + createE2EJourneysV1AudienceEvent({ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'v1mix-0', email: 'v1mix-0@segment.com' }), + createE2EJourneysV1AudienceEvent({ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'v1mix-1', email: 'v1mix-1@segment.com', enrichedTraits: { [COMPUTATION_KEY]: false } }), + createE2EJourneysV1AudienceEvent({ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'v1mix-2' }), + createE2EJourneysV1AudienceEvent({ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'v1mix-3', email: 'v1mix-3@segment.com', enrichedTraits: { [COMPUTATION_KEY]: false } }), + createE2EJourneysV1AudienceEvent({ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'v1mix-4' }), + createE2EJourneysV1AudienceEvent({ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'v1mix-5', email: 'v1mix-5@segment.com' }), + createE2EJourneysV1AudienceEvent({ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'v1mix-6', email: 'v1mix-6@segment.com', enrichedTraits: { [COMPUTATION_KEY]: false } }), + createE2EJourneysV1AudienceEvent({ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'v1mix-7' }), + createE2EJourneysV1AudienceEvent({ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'v1mix-8', email: 'v1mix-8@segment.com' }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '7935d2aa67adb77400ca8f5efae91e616caecc50fb55c07763bb9c260ab4e20e' }] } } }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '798821d7148dd697bc0894041c00ca159b4e6993ec4a65e8c5d9b7a7c3470d1e' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '3e6029913d48115d001921aac9cbf002f23daff3c53b7f7aa276132842420c4f' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '98130000e24927df4230259b474821f269031a019900c76e28558a33cada735b' }] } } }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '8d12d0223f5db0f1df2ac0cf8ee2eabd537326e5adb768811b1df8c7621d6a8d' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '6a2893f98f8e53405aaae5952ab71b287cb7e21c39ff98011f5680de0e6b357d' }] } } } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +// Run every scenario twice: flag-OFF and flag-ON. Both assert the SAME expected output, proving the +// always-add behaviour is independent of the audience-membership feature flag. +const fixtures: E2EFixture[] = baseFixtures.flatMap((fixture) => [ + { ...fixture, description: `${fixture.description} (flag OFF)` }, + { ...fixture, description: `${fixture.description} (flag ON)`, features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } } +]) + +export default fixtures diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV2.e2e.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV2.e2e.ts new file mode 100644 index 00000000000..9adbc1f3319 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV2.e2e.ts @@ -0,0 +1,183 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EJourneysV2AudienceEvent, FLAGS } from '@segment/actions-core' +import userList from '../index' + +const COMPUTATION_KEY = 'e2e_test_user_list' +const COMPUTATION_ID = 'journey_e2e_google_v2_001' + +// Journeys V2 is only ever sent with the audience-membership feature flag ON. Every fixture in this +// file therefore sets the flag; membership (add vs remove) is driven by properties[computation_key]. +const FEATURES = { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } + +const FAILURE_HINT = + 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' + +const fixtures: E2EFixture[] = [ + { + description: 'JourneysV2 Audience (flag ON): Add a user via journey_step track event (computation_key=true)', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'single', + features: FEATURES, + event: createE2EJourneysV2AudienceEvent({ + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-google-journeysv2-user-001', + email: 'e2e-google-journeysv2-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'JourneysV2 Audience (flag ON): Remove a user via journey_step track event (computation_key=false)', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'single', + features: FEATURES, + event: createE2EJourneysV2AudienceEvent({ + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-google-journeysv2-user-002', + email: 'e2e-google-journeysv2-test-002@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'JourneysV2 Audience (flag ON): Batch add users via journey_step track events', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'batchWithMultistatus', + features: FEATURES, + events: [ + createE2EJourneysV2AudienceEvent({ + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-google-journeysv2-user-003', + email: 'e2e-google-journeysv2-test-003@segment.com' + }), + createE2EJourneysV2AudienceEvent({ + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'e2e-google-journeysv2-user-004', + email: 'e2e-google-journeysv2-test-004@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '35bb98bd7dc71b074d55d51ffe418d4d4f07728cd82eaa7c890da9fb23eea157' }] } } }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: 'c159bbc9edbad504b7ca0ffc8232be6a5eca1a2887638a70e436092e040e396e' }] } } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'JourneysV2 Audience (flag ON): Batch remove users via journey_step track events (computation_key=false)', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + features: FEATURES, + events: [ + createE2EJourneysV2AudienceEvent({ + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'v2m-101', + email: 'v2m-101@segment.com' + }), + createE2EJourneysV2AudienceEvent({ + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + journeyName: 'e2e journey v2', + userId: 'v2m-102', + email: 'v2m-102@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: 'f086b2c06faa04d027c3bcdc8366893569cfd99bb4a59018edd2dd2a04d801f6' }] } } }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: 'e4cabef80ff04f76da89d1d00b4d18d58579a71f3c3fdf139cd6d6583c526de6' }] } } } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // 9-event batch (flag ON) interleaving adds, removes and invalid payloads to verify the + // per-index multi-status mapping stays correct under reordering. Membership comes from + // properties[computation_key]: add✓, remove✓, add✗, remove✓, remove✗, add✓, add✓, remove✗, remove✓ + description: 'JourneysV2 Audience (flag ON): 9-event mixed batch — interleaved valid/invalid adds and removes keep correct indexes', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + features: FEATURES, + events: [ + createE2EJourneysV2AudienceEvent({ action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', journeyName: 'e2e journey v2', userId: 'v2m-0', email: 'v2m-0@segment.com' }), + createE2EJourneysV2AudienceEvent({ action: 'remove', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', journeyName: 'e2e journey v2', userId: 'v2m-1', email: 'v2m-1@segment.com' }), + createE2EJourneysV2AudienceEvent({ action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', journeyName: 'e2e journey v2', userId: 'v2m-2' }), + createE2EJourneysV2AudienceEvent({ action: 'remove', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', journeyName: 'e2e journey v2', userId: 'v2m-3', email: 'v2m-3@segment.com' }), + createE2EJourneysV2AudienceEvent({ action: 'remove', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', journeyName: 'e2e journey v2', userId: 'v2m-4' }), + createE2EJourneysV2AudienceEvent({ action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', journeyName: 'e2e journey v2', userId: 'v2m-5', email: 'v2m-5@segment.com' }), + createE2EJourneysV2AudienceEvent({ action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', journeyName: 'e2e journey v2', userId: 'v2m-6', email: 'v2m-6@segment.com' }), + createE2EJourneysV2AudienceEvent({ action: 'remove', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', journeyName: 'e2e journey v2', userId: 'v2m-7' }), + createE2EJourneysV2AudienceEvent({ action: 'remove', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', journeyName: 'e2e journey v2', userId: 'v2m-8', email: 'v2m-8@segment.com' }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: 'd4980da72370143d8d2844ce171c99db1ec59d65f07e7259467df180f5f83f08' }] } } }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: 'fdab3a056ca869762be8fcc2bdc885b2f5f274dc125faef0b33c20bb22769a2d' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: '88dcddb7554e2188369df497d3f6ae5d44a8cc9758c8be12b1584e5364c5216a' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '9fc62099c40f7faa483cf4bb4af48634846ebf5f7a7da497d114b80c7a8407a7' }] } } }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '0e502a442893647887c72ddb2b4d3b2e8f8a2d863ce53dcadbf1e39296e47b83' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: 'e0508f5db35da171d0df69e4df906c6bf524ab5f044f41ba58e8cb60ffbffb29' }] } } } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/retl.e2e.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/retl.e2e.ts new file mode 100644 index 00000000000..19dd4b083f3 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/retl.e2e.ts @@ -0,0 +1,281 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2ERetlAudienceEvent, FLAGS } from '@segment/actions-core' +import userList from '../index' + +const COMPUTATION_KEY = 'e2e_test_user_list' +const COMPUTATION_ID = 'aud_e2e_google_retl_001' + +const FAILURE_HINT = 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' + +// RETL add/remove is driven by the RETL event name (new/updated -> add, deleted -> remove) and is +// independent of the audience-membership feature flag. Each scenario below is defined once and run +// twice — once flag-OFF and once flag-ON — asserting the SAME expected output, which proves the +// flag does not change RETL behaviour. +const baseFixtures: E2EFixture[] = [ + { + description: 'RETL Audience: syncMode=add adds users from a batch of "new" events', + subscribe: 'event = "new"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'add' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-user-001', + email: 'e2e-google-retl-001@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-user-002', + email: 'e2e-google-retl-002@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: 'da38a4fe5619680740b842adecb9ea5120150e4f6ce0c0d0aa7a0ca91c364630' }] } } + }, + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: '5cd717f1a25782ca98b12590144f4c8583a4b810401b328a52fbf4a231c4e6f8' }] } } + } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'RETL Audience: syncMode=delete removes users from a batch of "deleted" events', + subscribe: 'event = "deleted"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'delete' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-user-003', + email: 'e2e-google-retl-003@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-user-004', + email: 'e2e-google-retl-004@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + sent: { remove: { userIdentifiers: [{ hashedEmail: '4e98dfa8c84b29862aa03b0d110b00b7d905c7b9f7c294f68c0e88ee3f29f518' }] } } + }, + { + status: 200, + sent: { remove: { userIdentifiers: [{ hashedEmail: 'b33339bcc552b3d387990463eb7c58a3133112a98ad2e013efd1a796dc7d9644' }] } } + } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'RETL Audience: syncMode=mirror adds users from a batch of "new" events', + subscribe: 'event = "new"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-user-005', + email: 'e2e-google-retl-005@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-user-006', + email: 'e2e-google-retl-006@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: '7ce632dee60b40e07cd7ec6208b86b87315b055e0392e56bba58804ef2754a61' }] } } + }, + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: '3bef4fd9f1da3b027692be905e7a2da5ca5ec118d467d6ff36a16fd8f30a9833' }] } } + } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'RETL Audience: syncMode=mirror removes users from a batch of "deleted" events', + subscribe: 'event = "deleted"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-user-007', + email: 'e2e-google-retl-007@segment.com' + }), + createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-user-008', + email: 'e2e-google-retl-008@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + sent: { remove: { userIdentifiers: [{ hashedEmail: '7ae9a9b8012d258e594599ab711287b4b692bec6277697a5a3db3e3fcd31e12c' }] } } + }, + { + status: 200, + sent: { remove: { userIdentifiers: [{ hashedEmail: '30bd445519516df10e927cd05196d1667aa0bfdb8ea8d6e5741ed75d884176f5' }] } } + } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + // Single-event (perform) path: a one-row "new" sync adds the user. Single mode returns a raw + // HTTP response (no multistatus), so we assert overall success rather than a per-item operation. + description: 'RETL Audience: single "new" event adds the user', + subscribe: 'event = "new"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'single', + event: createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-single-new', + email: 'e2e-google-retl-single-new@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + // Single-event (perform) path: a one-row "deleted" sync removes the user. + description: 'RETL Audience: single "deleted" event removes the user', + subscribe: 'event = "deleted"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'single', + event: createE2ERetlAudienceEvent({ + eventName: 'deleted', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-retl-single-del', + email: 'e2e-google-retl-single-del@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + // 9-event batch with new/updated (add) and deleted (remove) interleaved with invalid payloads, + // to verify the per-index multi-status mapping stays correct under reordering. + // Order: new✓, deleted✓, new✗, deleted✓, deleted✗, new✓, updated✓, deleted✗, new✓ + description: 'RETL Audience: 9-event mixed batch — interleaved valid/invalid adds and removes keep correct indexes', + subscribe: 'event = "new" or event = "updated" or event = "deleted"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + __segment_internal_sync_mode: 'mirror' + }, + mode: 'batchWithMultistatus', + events: [ + createE2ERetlAudienceEvent({ eventName: 'new', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'retl-0', email: 'retl-0@segment.com' }), + createE2ERetlAudienceEvent({ eventName: 'deleted', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'retl-1', email: 'retl-1@segment.com' }), + createE2ERetlAudienceEvent({ eventName: 'new', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'retl-2' }), + createE2ERetlAudienceEvent({ eventName: 'deleted', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'retl-3', email: 'retl-3@segment.com' }), + createE2ERetlAudienceEvent({ eventName: 'deleted', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'retl-4' }), + createE2ERetlAudienceEvent({ eventName: 'new', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'retl-5', email: 'retl-5@segment.com' }), + createE2ERetlAudienceEvent({ eventName: 'updated', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'retl-6', email: 'retl-6@segment.com' }), + createE2ERetlAudienceEvent({ eventName: 'deleted', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'retl-7' }), + createE2ERetlAudienceEvent({ eventName: 'new', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'retl-8', email: 'retl-8@segment.com' }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '4e3e610050ba6c757dc7e5c6f33fcfe8a5f8fd7cb659f02b093eae9f063f8ec7' }] } } }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: 'f29a347244339c963fef80a1d46649aa4c895f8949e8124850156c37656e3c81' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { remove: { userIdentifiers: [{ hashedEmail: 'a68c164db3eb71b91b3df945bcdb17617a9acfa6b1c2775abdde85551d1f88ff' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '4821ab0741dfb49b33153bfe57c22a4829de7a3374d24556501975a3932b7a9a' }] } } }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: '7170e91c6bc5b00770bbb8c2c4461cc64279b15e86f938b8576cb81c01e8fce2' }] } } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Missing or Invalid data for CONTACT_INFO.', errorreporter: 'INTEGRATIONS' }, + { status: 200, sent: { create: { userIdentifiers: [{ hashedEmail: 'e605cb0d45cd796cb1fba41d3782ac1fb0e85c1843cda22c6478617fbb6c2204' }] } } } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +// Run every scenario twice: flag-OFF and flag-ON. Both assert the SAME expected output, proving RETL +// behaviour is independent of the audience-membership feature flag. +const fixtures: E2EFixture[] = baseFixtures.flatMap((fixture) => [ + { ...fixture, description: `${fixture.description} (flag OFF)` }, + { ...fixture, description: `${fixture.description} (flag ON)`, features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } } +]) + +export default fixtures diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts index 2875ba6e0d6..6a09e0ee8ea 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -19,7 +19,7 @@ const action: ActionDefinition = { syncMode: { description: 'Define how the records will be synced to Google', label: 'How to sync records', - default: 'add', + default: 'mirror', choices: [ { label: 'Adds users to the connected Google Customer Match User List', value: 'add' }, { label: 'Remove users from the connected Google Customer Match User List', value: 'delete' }, @@ -303,9 +303,8 @@ const action: ActionDefinition = { } } }, - perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features }) => { + perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features, audienceMembership }) => { settings.customerId = verifyCustomerId(settings.customerId) - return await handleUpdate( request, settings, @@ -315,12 +314,13 @@ const action: ActionDefinition = { hookOutputs?.retlOnMappingSave?.outputs.external_id_type, syncMode, features, - statsContext + statsContext, + audienceMembership ) }, performBatch: async ( request, - { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features } + { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features, audienceMembership } ) => { settings.customerId = verifyCustomerId(settings.customerId) return await processBatchPayload( @@ -332,7 +332,8 @@ const action: ActionDefinition = { hookOutputs?.retlOnMappingSave?.outputs.external_id_type, syncMode, features, - statsContext + statsContext, + audienceMembership ) } } diff --git a/packages/destination-actions/src/destinations/hubspot/__tests__/index.test.ts b/packages/destination-actions/src/destinations/hubspot/__tests__/index.test.ts index 15b85a96bf0..b72292d2cff 100644 --- a/packages/destination-actions/src/destinations/hubspot/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/hubspot/__tests__/index.test.ts @@ -5,6 +5,13 @@ import { HUBSPOT_BASE_URL } from '../properties' const testDestination = createTestIntegration(Definition) +const oauthData = { + refreshToken: 'refresh-token', + accessToken: 'access-token', + clientId: 'client-id', + clientSecret: 'client-secret' +} + describe('HubSpot Cloud Mode (Actions)', () => { describe('testAuthentication', () => { it('should validate authentication inputs', async () => { @@ -23,4 +30,37 @@ describe('HubSpot Cloud Mode (Actions)', () => { ) }) }) + + describe('refreshAccessToken', () => { + it('should use v1 endpoint when feature flag is not set', async () => { + nock(HUBSPOT_BASE_URL) + .post('/oauth/v1/token') + .reply(200, { access_token: 'new-access-token' }) + + const result = await testDestination.refreshAccessToken({}, oauthData) + expect(result).toEqual({ accessToken: 'new-access-token' }) + }) + + it('should use v1 endpoint when feature flag is false', async () => { + nock(HUBSPOT_BASE_URL) + .post('/oauth/v1/token') + .reply(200, { access_token: 'new-access-token' }) + + const result = await testDestination.refreshAccessToken({}, oauthData, undefined, { + 'actions-hubspot-oauth-v2': false + }) + expect(result).toEqual({ accessToken: 'new-access-token' }) + }) + + it('should use 2026-03 endpoint when feature flag is enabled', async () => { + nock(HUBSPOT_BASE_URL) + .post('/oauth/2026-03/token') + .reply(200, { access_token: 'new-access-token-v2' }) + + const result = await testDestination.refreshAccessToken({}, oauthData, undefined, { + 'actions-hubspot-oauth-v2': true + }) + expect(result).toEqual({ accessToken: 'new-access-token-v2' }) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/hubspot/index.ts b/packages/destination-actions/src/destinations/hubspot/index.ts index b51276ff2f7..fe0213498e3 100644 --- a/packages/destination-actions/src/destinations/hubspot/index.ts +++ b/packages/destination-actions/src/destinations/hubspot/index.ts @@ -8,7 +8,7 @@ import upsertCustomObjectRecord from './upsertCustomObjectRecord' import upsertObject from './upsertObject' import customEvent from './customEvent' import { HUBSPOT_BASE_URL } from './properties' -import { HUBSPOT_CRM_API_VERSION, HUBSPOT_OAUTH_API_VERSION } from './versioning-info' +import { HUBSPOT_CRM_API_VERSION, HUBSPOT_OAUTH_API_VERSION, HUBSPOT_OAUTH_API_VERSION_NEXT_FLAGON } from './versioning-info' interface RefreshTokenResponse { access_token: string } @@ -31,9 +31,9 @@ const destination: DestinationDefinition = { // HubSpot doesn't have a test authentication endpoint, so we using a lightweight CRM API to validate access token return request(`${HUBSPOT_BASE_URL}/crm/${HUBSPOT_CRM_API_VERSION}/objects/contacts?limit=1`) }, - refreshAccessToken: async (request, { auth }) => { - // Return a request that refreshes the access_token if the API supports it - const res = await request(`${HUBSPOT_BASE_URL}/oauth/${HUBSPOT_OAUTH_API_VERSION}/token`, { + refreshAccessToken: async (request, { auth, features }) => { + const oauthVersion = features?.['actions-hubspot-oauth-v2'] ? HUBSPOT_OAUTH_API_VERSION_NEXT_FLAGON : HUBSPOT_OAUTH_API_VERSION + const res = await request(`${HUBSPOT_BASE_URL}/oauth/${oauthVersion}/token`, { method: 'POST', body: new URLSearchParams({ refresh_token: auth.refreshToken, diff --git a/packages/destination-actions/src/destinations/hubspot/versioning-info.ts b/packages/destination-actions/src/destinations/hubspot/versioning-info.ts index dd78826dd16..f8bd773babe 100644 --- a/packages/destination-actions/src/destinations/hubspot/versioning-info.ts +++ b/packages/destination-actions/src/destinations/hubspot/versioning-info.ts @@ -11,7 +11,13 @@ export const HUBSPOT_CRM_API_VERSION = 'v3' export const HUBSPOT_CRM_ASSOCIATIONS_API_VERSION = 'v4' /** HUBSPOT_OAUTH_API_VERSION - * HubSpot OAuth API version. + * HubSpot OAuth API version (legacy). * API reference: https://developers.hubspot.com/docs/api-reference/auth-oauth-v1/tokens/post-oauth-v1-token */ export const HUBSPOT_OAUTH_API_VERSION = 'v1' + +/** HUBSPOT_OAUTH_API_VERSION_NEXT_FLAGON + * HubSpot OAuth API version (2026-03). + * API reference: https://developers.hubspot.com/docs/api-reference/latest/authentication/manage-oauth-tokens + */ +export const HUBSPOT_OAUTH_API_VERSION_NEXT_FLAGON = '2026-03' diff --git a/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts new file mode 100644 index 00000000000..bf7febbcc61 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts @@ -0,0 +1,30 @@ +import type { E2EAudienceDestinationConfig, E2ETeardownAudienceContext } from '@segment/actions-core' + +const audienceName = `e2e_test_audience_${Date.now()}` + +export const config: E2EAudienceDestinationConfig = { + settings: { + apiKey: { $env: 'E2E_ITERABLE_AUDIENCES_API_KEY' }, + iterableProjectType: 'hybrid' + }, + audience: { + audienceName, + audienceSettings: {}, + createAudience: true, + getAudience: false, // Iterable is only eventually consistent, so we can't reliably get the audience immediately after creating it + teardown: async (context: E2ETeardownAudienceContext) => { + const { settings, externalAudienceId } = context + const apiKey = settings.apiKey as string + + const response = await fetch(`https://api.iterable.com/api/lists/${externalAudienceId}`, { + method: 'DELETE', + headers: { 'Api-Key': apiKey } + }) + + if (!response.ok) { + const body = await response.text() + throw new Error(`Failed to delete list ${externalAudienceId}: ${response.status} ${body}`) + } + } + } +} diff --git a/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..0a161b41430 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.e2e.ts @@ -0,0 +1,91 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' +import syncAudience from '../index' + +const COMPUTATION_KEY = 'e2e_test_audience' +const COMPUTATION_ID = 'aud_e2e_iterable_001' + +const FAILURE_HINT = 'Ensure the Iterable API key has server-side permissions and the project is configured as hybrid.' + +const fixtures: E2EFixture[] = [ + { + description: 'Subscribe a user to the list via identify event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-001', + email: 'e2e-aud-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Unsubscribe a user from the list via identify event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-001', + email: 'e2e-aud-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch subscribe and unsubscribe users', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-002', + email: 'e2e-aud-test-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-003', + email: 'e2e-aud-test-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-001', + email: 'e2e-aud-test-001@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { email: 'e2e-aud-test-002@segment.com', userId: 'e2e-iterable-aud-user-002' } }, + { status: 200, sent: { email: 'e2e-aud-test-003@segment.com', userId: 'e2e-iterable-aud-user-003' } }, + { status: 200, sent: { email: 'e2e-aud-test-001@segment.com', userId: 'e2e-iterable-aud-user-001' } } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts new file mode 100644 index 00000000000..c28ec0b1662 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts @@ -0,0 +1,19 @@ +import type { E2EDestinationConfig } from '@segment/actions-core' + +/* + * Environment variables required to run the Iterable e2e tests: + * + * E2E_ITERABLE_API_KEY - Iterable API key for the e2e test project (destination auth). + * + * updateSubscriptions fixtures additionally require real resource IDs from that project: + * + * E2E_ITERABLE_MESSAGE_CHANNEL_ID - ID of a Marketing message channel (subscription_group_type = 'messageChannel'). + * E2E_ITERABLE_MESSAGE_TYPE_ID - ID of a message type under that channel (subscription_group_type = 'messageType'). + * E2E_ITERABLE_EMAIL_LIST_ID - ID of a Static list (subscription_group_type = 'emailList'). + */ +export const config: E2EDestinationConfig = { + settings: { + apiKey: { $env: 'E2E_ITERABLE_API_KEY' }, + dataCenterLocation: 'united_states' + } +} diff --git a/packages/destination-actions/src/destinations/iterable/index.ts b/packages/destination-actions/src/destinations/iterable/index.ts index 3f86b7619ca..be2cee1425d 100644 --- a/packages/destination-actions/src/destinations/iterable/index.ts +++ b/packages/destination-actions/src/destinations/iterable/index.ts @@ -5,6 +5,7 @@ import updateUser from './updateUser' import trackEvent from './trackEvent' import updateCart from './updateCart' import trackPurchase from './trackPurchase' +import updateSubscriptions from './updateSubscriptions' import { DataCenterLocation } from './shared-fields' import { getRegionalEndpoint } from './utils' @@ -58,7 +59,8 @@ const destination: DestinationDefinition = { updateUser, trackEvent, updateCart, - trackPurchase + trackPurchase, + updateSubscriptions }, presets: [ { diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..08ef33b02b4 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.e2e.ts @@ -0,0 +1,44 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import trackEvent from '../index' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully tracks a purchase event', + subscribe: 'type = "track"', + mapping: defaultValues(trackEvent.fields), + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-001', + properties: { + email: 'e2e-test@segment.com', + orderId: '$guid:orderId', + total: 49.99 + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Rejects event when both email and userId are missing', + subscribe: 'type = "track"', + mode: 'single', + mapping: (() => { + const { email, userId, ...rest } = defaultValues(trackEvent.fields) + return rest + })(), + event: createE2EEvent('track', 'Button Clicked', { + properties: { + buttonId: 'cta-hero' + } + }), + expect: { + status: 'error', + errorType: 'PayloadValidationError', + errorMessage: 'Must include email or userId.' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..25fef2b26ba --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__e2e__/fixtures.e2e.ts @@ -0,0 +1,235 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import updateSubscriptions from '../index' + +// subscription_group_id must reference real resources in the e2e Iterable project, so the IDs are +// read from the environment rather than hardcoded. Fixtures are plain modules, so we can read +// process.env directly (the $env marker only resolves inside settings). See ../../__e2e__/index.ts +// for the full list of env vars required to run this destination's e2e tests. +const MESSAGE_CHANNEL_ID = process.env.E2E_ITERABLE_MESSAGE_CHANNEL_ID ?? '' +const MESSAGE_TYPE_ID = process.env.E2E_ITERABLE_MESSAGE_TYPE_ID ?? '' +const EMAIL_LIST_ID = process.env.E2E_ITERABLE_EMAIL_LIST_ID ?? '' + +const ID_HINT = + 'Ensure E2E_ITERABLE_MESSAGE_CHANNEL_ID, E2E_ITERABLE_MESSAGE_TYPE_ID and E2E_ITERABLE_EMAIL_LIST_ID are set to ' + + 'real resource IDs in the e2e Iterable project. The channel must be a Marketing channel (opt-out) so subscribe/' + + 'unsubscribe are supported, and the list must be a Static list. ' + + 'NOTE: the /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/... endpoints are gated per project and ' + + 'must be enabled by Iterable (contact your CSM). If these fixtures fail with 404 ' + + '{"code":"NotFound","msg":"Endpoint not found for project: "} while other Iterable endpoints (e.g. GET ' + + '/api/lists) return 200, the Subscription Preferences API is not yet enabled for the project - this is an ' + + 'environment gate, not a code or ID problem.' + +// Reuse the shared e2e users (same as trackEvent fixtures). The Iterable e2e project is Hybrid, so +// both email- and userId-based identification resolve to a real profile. +const TEST_EMAIL = 'e2e-test@segment.com' +const TEST_USER_ID = 'e2e-test-user-001' + +const fixtures: E2EFixture[] = [ + { + // Single subscribe by email -> PATCH /api/subscriptions/messageChannel/{id}/user/{email}. + description: 'Successfully subscribes a user to a message channel by email', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...defaultValues(updateSubscriptions.fields), + subscriptions: [ + { subscription_group_type: 'messageChannel', subscription_group_id: MESSAGE_CHANNEL_ID, action: 'subscribe' } + ] + }, + event: createE2EEvent('track', 'Subscriptions Updated', { + properties: { email: TEST_EMAIL } + }), + expect: { + status: 'success' + }, + verboseFailureHint: ID_HINT + }, + { + // Single unsubscribe by email -> DELETE /api/subscriptions/messageChannel/{id}/user/{email}. + description: 'Successfully unsubscribes a user from a message channel by email', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...defaultValues(updateSubscriptions.fields), + subscriptions: [ + { subscription_group_type: 'messageChannel', subscription_group_id: MESSAGE_CHANNEL_ID, action: 'unsubscribe' } + ] + }, + event: createE2EEvent('track', 'Subscriptions Updated', { + properties: { email: TEST_EMAIL } + }), + expect: { + status: 'success' + }, + verboseFailureHint: ID_HINT + }, + { + // Single subscribe by userId only (Hybrid project) -> PATCH .../messageType/{id}/byUserId/{userId}. + // Exercises the userId identifier branch of resolveIdentifier + getSingleUserEndpoint. + description: 'Successfully subscribes a user to a message type by userId only', + subscribe: 'type = "track"', + mode: 'single', + mapping: (() => { + const { identifier, ...rest } = defaultValues(updateSubscriptions.fields) + return { + ...rest, + identifier: { userId: { '@path': '$.userId' } }, + user_identifier_preference: 'userId', + subscriptions: [ + { subscription_group_type: 'messageType', subscription_group_id: MESSAGE_TYPE_ID, action: 'subscribe' } + ] + } + })(), + event: createE2EEvent('track', 'Subscriptions Updated', { + userId: TEST_USER_ID, + properties: {} + }), + expect: { + status: 'success' + }, + verboseFailureHint: + 'Requires a userId-based or Hybrid Iterable project so the userId resolves to a real profile. ' + ID_HINT + }, + { + // Batch subscribe -> one PUT /api/subscriptions/emailList/{id}?action=subscribe with a users[] body. + // Core fans the single HTTP response out into a per-item multistatus array. Both users share the + // same subscriptions config (required by the `subscriptions` batch key), so both land in one call. + description: 'Batch subscribes multiple users to an email list (bulk PUT, per-item multistatus)', + subscribe: 'type = "track"', + mode: 'batchWithMultistatus', + mapping: { + identifier: { email: { '@path': '$.properties.email' }, userId: { '@path': '$.userId' } }, + user_identifier_preference: 'email', + subscriptions: [ + { subscription_group_type: 'emailList', subscription_group_id: EMAIL_LIST_ID, action: 'subscribe' } + ], + enable_batching: true + }, + events: [ + createE2EEvent('track', 'Subscriptions Updated', { + properties: { email: TEST_EMAIL } + }), + createE2EEvent('track', 'Subscriptions Updated', { + properties: { email: 'e2e-test-2@segment.com' } + }) + ], + expect: { + status: 'success', + jsonContains: [{ status: 200 }, { status: 200 }] + }, + verboseFailureHint: ID_HINT + }, + { + // Client-side validation: no email and no userId -> resolveIdentifier throws before any HTTP call. + description: 'Rejects event when both email and userId are missing', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...defaultValues(updateSubscriptions.fields), + identifier: {}, + subscriptions: [ + { subscription_group_type: 'messageChannel', subscription_group_id: MESSAGE_CHANNEL_ID, action: 'subscribe' } + ] + }, + event: createE2EEvent('track', 'Subscriptions Updated', { + properties: {} + }), + expect: { + status: 'error', + errorType: 'PayloadValidationError', + errorMessage: 'Must include email or userId in identifier.' + } + }, + { + // Client-side validation: more than the 6-item maximum -> performUpdateSubscriptions throws + // before any HTTP call. + description: 'Rejects event with more than 6 subscription items', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...defaultValues(updateSubscriptions.fields), + subscriptions: Array.from({ length: 7 }, () => ({ + subscription_group_type: 'messageChannel', + subscription_group_id: MESSAGE_CHANNEL_ID, + action: 'subscribe' + })) + }, + event: createE2EEvent('track', 'Subscriptions Updated', { + properties: { email: TEST_EMAIL } + }), + expect: { + status: 'error', + errorType: 'PayloadValidationError', + errorMessage: 'Maximum of 6 subscription items allowed. Received 7.' + } + }, + { + // Client-side validation: a batch whose payloads carry different subscriptions violates the + // batching invariant (performBatchUpdateSubscriptions issues one request per subscription using + // the first payload's config for all users), so it throws before any HTTP call. subscriptions is + // mapped from event properties so each event can carry its own. + description: 'Rejects a batch whose payloads have differing subscriptions', + subscribe: 'type = "track"', + mode: 'batch', + mapping: { + identifier: { email: { '@path': '$.properties.email' }, userId: { '@path': '$.userId' } }, + user_identifier_preference: 'email', + subscriptions: { '@path': '$.properties.subscriptions' }, + enable_batching: true + }, + events: [ + createE2EEvent('track', 'Subscriptions Updated', { + properties: { + email: TEST_EMAIL, + subscriptions: [ + { + subscription_group_type: 'messageChannel', + subscription_group_id: MESSAGE_CHANNEL_ID, + action: 'subscribe' + } + ] + } + }), + createE2EEvent('track', 'Subscriptions Updated', { + properties: { + email: 'e2e-test-2@segment.com', + subscriptions: [ + { subscription_group_type: 'emailList', subscription_group_id: EMAIL_LIST_ID, action: 'unsubscribe' } + ] + } + }) + ], + expect: { + status: 'error', + errorType: 'PayloadValidationError', + errorMessage: + 'All events in a batch must share the same subscription preferences. Received a batch with differing subscriptions, which is not supported.' + } + }, + { + // HTTP failure from Iterable: subscribing against a non-existent group ID is rejected by the API. + // Iterable returns a 4xx for an invalid subscription group. If the live response differs, adjust + // httpStatus to match what Iterable actually returns. + description: 'Surfaces an HTTP failure when the subscription group does not exist', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...defaultValues(updateSubscriptions.fields), + subscriptions: [{ subscription_group_type: 'messageChannel', subscription_group_id: '0', action: 'subscribe' }] + }, + event: createE2EEvent('track', 'Subscriptions Updated', { + properties: { email: TEST_EMAIL } + }), + expect: { + status: 'failure', + httpStatus: 400 + }, + verboseFailureHint: + 'Expects Iterable to reject a subscribe to a non-existent group ID (0) with a 4xx. If Iterable ' + + 'returns a different status (or a 200 with a failure body), adjust this fixture accordingly. ' + + ID_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts new file mode 100644 index 00000000000..9c273ed0d16 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts @@ -0,0 +1,576 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, PayloadValidationError } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const defaultMapping = { + identifier: { + email: { '@path': '$.properties.email' }, + userId: { '@path': '$.userId' } + }, + user_identifier_preference: 'email', + subscriptions: [ + { + subscription_group_type: 'messageChannel', + subscription_group_id: '123', + action: 'subscribe' + } + ], + enable_batching: false +} + +describe('Iterable.updateSubscriptions', () => { + afterEach(() => { + nock.cleanAll() + }) + + describe('perform', () => { + it('subscribes a user by email using PATCH', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user123', + properties: { + email: 'test@iterable.com' + } + }) + + nock('https://api.iterable.com') + .patch('/api/subscriptions/messageChannel/123/user/test%40iterable.com') + .reply(200, { code: 'Success', msg: '' }) + + const responses = await testDestination.testAction('updateSubscriptions', { + event, + mapping: defaultMapping + }) + + expect(responses[0].status).toBe(200) + }) + + it('subscribes a user by userId using PATCH', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user123', + properties: {} + }) + + nock('https://api.iterable.com') + .patch('/api/subscriptions/messageChannel/123/byUserId/user123') + .reply(200, { code: 'Success', msg: '' }) + + const responses = await testDestination.testAction('updateSubscriptions', { + event, + mapping: { + ...defaultMapping, + identifier: { + email: { '@path': '$.properties.email' }, + userId: { '@path': '$.userId' } + }, + user_identifier_preference: 'userId' + } + }) + + expect(responses[0].status).toBe(200) + }) + + it('unsubscribes a user by email using DELETE', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user123', + properties: { + email: 'test@iterable.com' + } + }) + + nock('https://api.iterable.com') + .delete('/api/subscriptions/messageChannel/456/user/test%40iterable.com') + .reply(200, { code: 'Success', msg: '' }) + + const responses = await testDestination.testAction('updateSubscriptions', { + event, + mapping: { + ...defaultMapping, + subscriptions: [ + { + subscription_group_type: 'messageChannel', + subscription_group_id: '456', + action: 'unsubscribe' + } + ] + } + }) + + expect(responses[0].status).toBe(200) + }) + + it('handles multiple subscription items in one event', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user123', + properties: { + email: 'test@iterable.com' + } + }) + + nock('https://api.iterable.com') + .patch('/api/subscriptions/messageChannel/123/user/test%40iterable.com') + .reply(200, { code: 'Success', msg: '' }) + + nock('https://api.iterable.com') + .delete('/api/subscriptions/messageType/456/user/test%40iterable.com') + .reply(200, { code: 'Success', msg: '' }) + + nock('https://api.iterable.com') + .patch('/api/subscriptions/emailList/789/user/test%40iterable.com') + .reply(200, { code: 'Success', msg: '' }) + + const responses = await testDestination.testAction('updateSubscriptions', { + event, + mapping: { + ...defaultMapping, + subscriptions: [ + { subscription_group_type: 'messageChannel', subscription_group_id: '123', action: 'subscribe' }, + { subscription_group_type: 'messageType', subscription_group_id: '456', action: 'unsubscribe' }, + { subscription_group_type: 'emailList', subscription_group_id: '789', action: 'subscribe' } + ] + } + }) + + expect(responses.length).toBe(3) + }) + + it('prefers email when preference is email and both are provided', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user123', + properties: { + email: 'test@iterable.com' + } + }) + + nock('https://api.iterable.com') + .patch('/api/subscriptions/messageChannel/123/user/test%40iterable.com') + .reply(200, { code: 'Success', msg: '' }) + + const responses = await testDestination.testAction('updateSubscriptions', { + event, + mapping: { + ...defaultMapping, + user_identifier_preference: 'email' + } + }) + + expect(responses[0].status).toBe(200) + }) + + it('prefers userId when preference is userId and both are provided', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user123', + properties: { + email: 'test@iterable.com' + } + }) + + nock('https://api.iterable.com') + .patch('/api/subscriptions/messageChannel/123/byUserId/user123') + .reply(200, { code: 'Success', msg: '' }) + + const responses = await testDestination.testAction('updateSubscriptions', { + event, + mapping: { + ...defaultMapping, + user_identifier_preference: 'userId' + } + }) + + expect(responses[0].status).toBe(200) + }) + + it('falls back to userId when preference is email but email is missing', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user123', + properties: {} + }) + + nock('https://api.iterable.com') + .patch('/api/subscriptions/messageChannel/123/byUserId/user123') + .reply(200, { code: 'Success', msg: '' }) + + const responses = await testDestination.testAction('updateSubscriptions', { + event, + mapping: { + ...defaultMapping, + identifier: { + userId: { '@path': '$.userId' } + }, + user_identifier_preference: 'email' + } + }) + + expect(responses[0].status).toBe(200) + }) + + it('throws PayloadValidationError when both email and userId are missing', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: null, + properties: {} + }) + + await expect( + testDestination.testAction('updateSubscriptions', { + event, + mapping: { + ...defaultMapping, + identifier: {} + } + }) + ).rejects.toThrowError(PayloadValidationError) + }) + + it('throws PayloadValidationError when more than 6 subscription items are provided', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { email: 'test@iterable.com' } + }) + + await expect( + testDestination.testAction('updateSubscriptions', { + event, + mapping: { + ...defaultMapping, + subscriptions: Array.from({ length: 7 }, (_, i) => ({ + subscription_group_type: 'messageChannel', + subscription_group_id: String(i), + action: 'subscribe' + })) + } + }) + ).rejects.toThrowError(PayloadValidationError) + }) + + it('uses Europe endpoint when data center is configured', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { email: 'test@iterable.com' } + }) + + nock('https://api.eu.iterable.com') + .patch('/api/subscriptions/messageChannel/123/user/test%40iterable.com') + .reply(200, { code: 'Success', msg: '' }) + + const responses = await testDestination.testAction('updateSubscriptions', { + event, + mapping: defaultMapping, + settings: { apiKey: 'test-api-key', dataCenterLocation: 'europe' } + }) + + expect(responses[0].status).toBe(200) + }) + }) + + describe('performBatch', () => { + it('subscribes multiple users via bulk PUT endpoint', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user1', + properties: { email: 'user1@iterable.com' } + }), + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user2', + properties: { email: 'user2@iterable.com' } + }) + ] + + nock('https://api.iterable.com') + .put('/api/subscriptions/messageChannel/123?action=subscribe') + .reply(200, { code: 'Success', msg: '' }) + + const response = await testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: defaultMapping + }) + + expect(response[0].status).toBe(200) + }) + + it('unsubscribes multiple users via bulk PUT endpoint', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user1', + properties: { email: 'user1@iterable.com' } + }), + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user2', + properties: { email: 'user2@iterable.com' } + }) + ] + + nock('https://api.iterable.com') + .put('/api/subscriptions/messageChannel/456?action=unsubscribe') + .reply(200, { code: 'Success', msg: '' }) + + const response = await testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: { + ...defaultMapping, + subscriptions: [ + { + subscription_group_type: 'messageChannel', + subscription_group_id: '456', + action: 'unsubscribe' + } + ] + } + }) + + expect(response[0].status).toBe(200) + }) + + it('handles multiple subscription items for batch', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { email: 'user1@iterable.com' } + }), + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { email: 'user2@iterable.com' } + }) + ] + + nock('https://api.iterable.com') + .put('/api/subscriptions/messageChannel/123?action=subscribe') + .reply(200, { code: 'Success', msg: '' }) + + nock('https://api.iterable.com') + .put('/api/subscriptions/messageType/456?action=unsubscribe') + .reply(200, { code: 'Success', msg: '' }) + + const response = await testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: { + ...defaultMapping, + subscriptions: [ + { subscription_group_type: 'messageChannel', subscription_group_id: '123', action: 'subscribe' }, + { subscription_group_type: 'messageType', subscription_group_id: '456', action: 'unsubscribe' } + ] + } + }) + + expect(response[0].status).toBe(200) + }) + + it('separates users by email and userId in batch request body', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user1', + properties: { email: 'user1@iterable.com' } + }), + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: 'user2', + properties: {} + }) + ] + + nock('https://api.iterable.com') + .put('/api/subscriptions/messageChannel/123?action=subscribe', (body) => { + return body.users.includes('user1@iterable.com') && body.usersByUserId.includes('user2') + }) + .reply(200, { code: 'Success', msg: '' }) + + const response = await testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: { + ...defaultMapping, + user_identifier_preference: 'email' + } + }) + + expect(response[0].status).toBe(200) + }) + + it('returns error in MultiStatusResponse for payloads missing identifiers', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + userId: null, + properties: {} + }), + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { email: 'valid@iterable.com' } + }) + ] + + nock('https://api.iterable.com') + .put('/api/subscriptions/messageChannel/123?action=subscribe') + .reply(200, { code: 'Success', msg: '' }) + + await testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: { + ...defaultMapping, + identifier: { + email: { '@path': '$.properties.email' }, + userId: { '@path': '$.userId' } + } + } + }) + + const multistatus = testDestination.results[0].multistatus + + expect(multistatus).toBeDefined() + expect(multistatus![0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Must include email or userId in identifier.' + }) + expect(multistatus![1]).toMatchObject({ + status: 200 + }) + }) + + it('marks all payloads as failed when API call fails', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { email: 'user1@iterable.com' } + }), + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { email: 'user2@iterable.com' } + }) + ] + + nock('https://api.iterable.com') + .put('/api/subscriptions/messageChannel/123?action=subscribe') + .reply(400, { code: 'BadParams', msg: 'Invalid subscription group' }) + + await testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: defaultMapping + }) + + const multistatus = testDestination.results[0].multistatus + + expect(multistatus).toBeDefined() + expect(multistatus![0]).toMatchObject({ + status: 400, + errortype: 'UNKNOWN_ERROR' + }) + expect(multistatus![1]).toMatchObject({ + status: 400, + errortype: 'UNKNOWN_ERROR' + }) + }) + + it('returns PAYLOAD_VALIDATION_FAILED in MultiStatusResponse for invalid action in batch', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { email: 'user1@iterable.com' } + }), + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { email: 'user2@iterable.com' } + }) + ] + + await testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: { + ...defaultMapping, + subscriptions: [ + { + subscription_group_type: 'messageChannel', + subscription_group_id: '123', + action: 'invalid_action' + } + ] + } + }) + + const multistatus = testDestination.results[0].multistatus + + expect(multistatus).toBeDefined() + expect(multistatus![0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED' + }) + expect(multistatus![0].errormessage).toContain('subscribe') + expect(multistatus![1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED' + }) + expect(multistatus![1].errormessage).toContain('subscribe') + }) + + it('throws PayloadValidationError when payloads in a batch have differing subscriptions', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { + email: 'user1@iterable.com', + subscriptions: [ + { subscription_group_type: 'messageChannel', subscription_group_id: '123', action: 'subscribe' } + ] + } + }), + createTestEvent({ + type: 'track', + event: 'Subscriptions Updated', + properties: { + email: 'user2@iterable.com', + subscriptions: [ + { subscription_group_type: 'messageChannel', subscription_group_id: '456', action: 'unsubscribe' } + ] + } + }) + ] + + await expect( + testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: { + ...defaultMapping, + subscriptions: { '@path': '$.properties.subscriptions' } + } + }) + ).rejects.toThrowError(PayloadValidationError) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts new file mode 100644 index 00000000000..95f710684f0 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts @@ -0,0 +1,2 @@ +export const MAX_SUBSCRIPTION_ITEMS = 6 +export const MIN_REQUEST_TIMEOUT = 30_000 diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/dynamic-fields.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/dynamic-fields.ts new file mode 100644 index 00000000000..55f84613062 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/dynamic-fields.ts @@ -0,0 +1,74 @@ +import { RequestClient, DynamicFieldResponse } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DataCenterLocation } from '../shared-fields' +import { getRegionalEndpoint } from '../utils' +import type { DynamicFieldContext, ChannelsResponse, MessageTypesResponse, ListsResponse } from './types' + +export async function getSubscriptionGroupId( + request: RequestClient, + { payload, dynamicFieldContext, settings }: { payload: Payload; dynamicFieldContext?: DynamicFieldContext; settings: Settings } +): Promise { + const { selectedArrayIndex: index = 0 } = dynamicFieldContext ?? {} + const groupType = payload?.subscriptions?.[index]?.subscription_group_type + const { dataCenterLocation } = settings + + if (!groupType) { + return { + choices: [], + error: { + code: 'MISSING_GROUP_TYPE', + message: "Select a 'Subscription Group Type' first." + } + } + } + + const endpoint = getRegionalEndpoint( + groupType === 'messageChannel' ? 'getChannels' : groupType === 'messageType' ? 'getMessageTypes' : 'getLists', + dataCenterLocation as DataCenterLocation + ) + + switch (groupType) { + case 'messageChannel': + return fetchChannels(request, endpoint) + case 'messageType': + return fetchMessageTypes(request, endpoint) + case 'emailList': + return fetchLists(request, endpoint) + default: + return { + choices: [], + error: { + code: 'INVALID_GROUP_TYPE', + message: `Unknown subscription group type: ${groupType}` + } + } + } +} + +async function fetchChannels(request: RequestClient, endpoint: string): Promise { + const response = await request(endpoint, { method: 'get' }) + const choices = response.data.channels.map(({ id, name, messageMedium, channelType }) => ({ + label: `${name} (${messageMedium} - ${channelType})`, + value: String(id) + })) + return { choices } +} + +async function fetchMessageTypes(request: RequestClient, endpoint: string): Promise { + const response = await request(endpoint, { method: 'get' }) + const choices = response.data.messageTypes.map(({ id, name, subscriptionPolicy }) => ({ + label: `${name} (${subscriptionPolicy})`, + value: String(id) + })) + return { choices } +} + +async function fetchLists(request: RequestClient, endpoint: string): Promise { + const response = await request(endpoint, { method: 'get' }) + const choices = response.data.lists.map(({ id, name, listType }) => ({ + label: `${name} (${listType})`, + value: String(id) + })) + return { choices } +} diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts new file mode 100644 index 00000000000..fbc480ccd7d --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts @@ -0,0 +1,207 @@ +import { + PayloadValidationError, + RequestClient, + MultiStatusResponse, + JSONLikeObject, + HTTPError, + DEFAULT_REQUEST_TIMEOUT +} from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DataCenterLocation } from '../shared-fields' +import { getRegionalBaseUrl } from '../utils' +import { MAX_SUBSCRIPTION_ITEMS, MIN_REQUEST_TIMEOUT } from './constants' +import type { ResolvedIdentifier, BulkSubscriptionRequestBody } from './types' + +export async function performUpdateSubscriptions(request: RequestClient, payload: Payload, settings: Settings) { + const { subscriptions } = payload + const subscriptionCount = subscriptions.length + + if (subscriptionCount === 0) { + throw new PayloadValidationError('At least one subscription item is required.') + } + + if (subscriptionCount > MAX_SUBSCRIPTION_ITEMS) { + throw new PayloadValidationError( + `Maximum of ${MAX_SUBSCRIPTION_ITEMS} subscription items allowed. Received ${subscriptionCount}.` + ) + } + + const identifier = resolveIdentifier(payload) + + const results = await Promise.all( + subscriptions.map(async ({ subscription_group_type, subscription_group_id, action }) => { + const endpoint = getSingleUserEndpoint(settings, subscription_group_type, subscription_group_id, identifier) + const method = action === 'subscribe' ? 'patch' : 'delete' + return request(endpoint, { method, timeout: Math.max(MIN_REQUEST_TIMEOUT, DEFAULT_REQUEST_TIMEOUT) }) + }) + ) + + return results[results.length - 1] +} + +export async function performBatchUpdateSubscriptions(request: RequestClient, payloads: Payload[], settings: Settings) { + const multiStatusResponse = new MultiStatusResponse() + const validPayloads: { index: number; identifier: ResolvedIdentifier }[] = [] + + payloads.forEach((payload, index) => { + const sent = payload as unknown as JSONLikeObject + const subscriptionCount = payload.subscriptions.length + + if (subscriptionCount === 0) { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'At least one subscription item is required.', + sent + }) + return + } + + if (subscriptionCount > MAX_SUBSCRIPTION_ITEMS) { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: `Maximum of ${MAX_SUBSCRIPTION_ITEMS} subscription items allowed. Received ${subscriptionCount}.`, + sent + }) + return + } + + try { + const identifier = resolveIdentifier(payload) + validPayloads.push({ index, identifier }) + } catch (error) { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: (error as Error).message, + sent + }) + } + }) + + if (validPayloads.length === 0) { + return multiStatusResponse + } + + // All payloads in a batch are grouped by the `subscriptions` batch key (see `batch_keys` default in + // index.ts), so every payload here is guaranteed to share the same `subscriptions` config. + const subscriptions = payloads[validPayloads[0].index].subscriptions + const referenceKey = JSON.stringify(subscriptions) + const hasMismatchedSubscriptions = validPayloads.some( + ({ index }) => JSON.stringify(payloads[index].subscriptions) !== referenceKey + ) + if (hasMismatchedSubscriptions) { + throw new PayloadValidationError( + 'All events in a batch must share the same subscription preferences. Received a batch with differing subscriptions, which is not supported.' + ) + } + + const users = validPayloads + .filter(({ identifier }) => identifier.email) + .map(({ identifier }) => identifier.email as string) + const usersByUserId = validPayloads + .filter(({ identifier }) => identifier.userId) + .map(({ identifier }) => identifier.userId as string) + + const body: BulkSubscriptionRequestBody = { + ...(users.length > 0 && { users }), + ...(usersByUserId.length > 0 && { usersByUserId }) + } + + try { + await Promise.all( + subscriptions.map(async ({ subscription_group_type, subscription_group_id, action }) => { + const endpoint = getBulkSubscriptionEndpoint(settings, subscription_group_type, subscription_group_id, action) + return request(endpoint, { + method: 'put', + json: body, + timeout: Math.max(MIN_REQUEST_TIMEOUT, DEFAULT_REQUEST_TIMEOUT) + }) + }) + ) + + validPayloads.forEach(({ index, identifier }) => { + const sentBody: BulkSubscriptionRequestBody = identifier.email + ? { users: [identifier.email] } + : { usersByUserId: [identifier.userId as string] } + + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: 200, + sent: payloads[index] as unknown as JSONLikeObject, + body: sentBody as unknown as JSONLikeObject + }) + }) + } catch (error) { + const isHTTPError = error instanceof HTTPError + const status = isHTTPError ? error.response.status : 500 + const errormessage = isHTTPError ? error.response.statusText : (error as Error).message + + validPayloads.forEach(({ index, identifier }) => { + const sentBody: BulkSubscriptionRequestBody = identifier.email + ? { users: [identifier.email] } + : { usersByUserId: [identifier.userId as string] } + + multiStatusResponse.setErrorResponseAtIndex(index, { + status, + errortype: 'UNKNOWN_ERROR', + errormessage, + sent: payloads[index] as unknown as JSONLikeObject, + body: sentBody as unknown as JSONLikeObject + }) + }) + } + + return multiStatusResponse +} + +export function resolveIdentifier(payload: Payload): ResolvedIdentifier { + const { + identifier: { email, userId }, + user_identifier_preference + } = payload + const trimmedEmail = email?.trim() + const trimmedUserId = userId?.trim() + + if (user_identifier_preference === 'userId' && trimmedUserId) { + return { userId: trimmedUserId } + } + if (user_identifier_preference === 'email' && trimmedEmail) { + return { email: trimmedEmail } + } + if (trimmedEmail) { + return { email: trimmedEmail } + } + if (trimmedUserId) { + return { userId: trimmedUserId } + } + throw new PayloadValidationError('Must include email or userId in identifier.') +} + +export function getBulkSubscriptionEndpoint( + settings: Settings, + groupType: string, + groupId: string, + action: string +): string { + const { dataCenterLocation } = settings + const baseUrl = getRegionalBaseUrl(dataCenterLocation as DataCenterLocation) + return `${baseUrl}/api/subscriptions/${groupType}/${groupId}?action=${action}` +} + +export function getSingleUserEndpoint( + settings: Settings, + groupType: string, + groupId: string, + identifier: ResolvedIdentifier +): string { + const { dataCenterLocation } = settings + const baseUrl = getRegionalBaseUrl(dataCenterLocation as DataCenterLocation) + const { email, userId } = identifier + + if (userId) { + return `${baseUrl}/api/subscriptions/${groupType}/${groupId}/byUserId/${encodeURIComponent(userId)}` + } + return `${baseUrl}/api/subscriptions/${groupType}/${groupId}/user/${encodeURIComponent(email as string)}` +} diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/generated-types.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/generated-types.ts new file mode 100644 index 00000000000..88e20fbf811 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/generated-types.ts @@ -0,0 +1,50 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * User identifier - provide email, userId, or both. At least one is required. + */ + identifier: { + /** + * An email address that identifies a user profile in Iterable. + */ + email?: string + /** + * A user ID that identifies a user profile in Iterable. + */ + userId?: string + } + /** + * When both email and userId are provided, this determines which identifier is sent to Iterable. Iterable requires one or the other, not both. + */ + user_identifier_preference: string + /** + * Subscription changes to apply for this user. Maximum 6 items. + */ + subscriptions: { + /** + * The type of subscription group. + */ + subscription_group_type: string + /** + * The ID of the subscription group. Select a group type first to see available options. + */ + subscription_group_id: string + /** + * Whether to subscribe or unsubscribe the user from this group. + */ + action: string + }[] + /** + * When enabled, Segment will send data to Iterable in batches. + */ + enable_batching?: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number + /** + * The keys to use for batching events together. + */ + batch_keys?: string[] +} diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts new file mode 100644 index 00000000000..e0613e1f82f --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts @@ -0,0 +1,133 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { performUpdateSubscriptions, performBatchUpdateSubscriptions } from './functions' +import { getSubscriptionGroupId } from './dynamic-fields' + +const action: ActionDefinition = { + title: 'Update Subscriptions', + description: + 'Manage subscription preferences for a user, including subscribing and unsubscribing from channels, message types, and email lists.', + defaultSubscription: 'type = "track" and event = "Subscriptions Updated"', + fields: { + identifier: { + label: 'User Identifier', + description: 'User identifier - provide email, userId, or both. At least one is required.', + type: 'object', + required: true, + additionalProperties: false, + properties: { + email: { + label: 'Email', + description: 'An email address that identifies a user profile in Iterable.', + type: 'string', + format: 'email', + required: false + }, + userId: { + label: 'User ID', + description: 'A user ID that identifies a user profile in Iterable.', + type: 'string', + required: false + } + }, + default: { + email: { '@path': '$.properties.email' }, + userId: { '@path': '$.userId' } + } + }, + user_identifier_preference: { + label: 'User Identifier Preference', + description: + 'When both email and userId are provided, this determines which identifier is sent to Iterable. Iterable requires one or the other, not both.', + type: 'string', + required: true, + choices: [ + { label: 'Email', value: 'email' }, + { label: 'User ID', value: 'userId' } + ], + default: 'email', + disabledInputMethods: ['variable', 'function', 'freeform', 'enrichment'] + }, + subscriptions: { + label: 'Subscription Preferences', + description: 'Subscription changes to apply for this user. Maximum 6 items.', + type: 'object', + multiple: true, + required: true, + defaultObjectUI: 'arrayeditor', + additionalProperties: false, + properties: { + subscription_group_type: { + label: 'Subscription Group Type', + description: 'The type of subscription group.', + type: 'string', + required: true, + choices: [ + { label: 'Email List', value: 'emailList' }, + { label: 'Message Type', value: 'messageType' }, + { label: 'Message Channel', value: 'messageChannel' } + ], + disabledInputMethods: ['variable', 'function', 'freeform', 'enrichment'] + }, + subscription_group_id: { + label: 'Subscription Group ID', + description: 'The ID of the subscription group. Select a group type first to see available options.', + type: 'string', + required: true, + dynamic: true, + disabledInputMethods: ['variable', 'function', 'freeform', 'enrichment'] + }, + action: { + label: 'Action', + description: 'Whether to subscribe or unsubscribe the user from this group.', + type: 'string', + required: true, + choices: [ + { label: 'Subscribe', value: 'subscribe' }, + { label: 'Unsubscribe', value: 'unsubscribe' } + ] + } + } + }, + enable_batching: { + label: 'Enable Batching', + description: 'When enabled, Segment will send data to Iterable in batches.', + type: 'boolean', + required: false, + default: true + }, + batch_size: { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + unsafe_hidden: true, + required: false, + default: 1000 + }, + batch_keys: { + label: 'Batch Keys', + description: 'The keys to use for batching events together.', + type: 'string', + multiple: true, + unsafe_hidden: true, + required: false, + default: ['subscriptions', 'user_identifier_preference'] + } + }, + dynamicFields: { + subscriptions: { + subscription_group_id: async (request, { payload, dynamicFieldContext, settings }) => { + return getSubscriptionGroupId(request, { payload, dynamicFieldContext, settings }) + } + } + }, + perform: async (request, { payload, settings }) => { + return performUpdateSubscriptions(request, payload, settings) + }, + performBatch: async (request, { settings, payload: payloads }) => { + return performBatchUpdateSubscriptions(request, payloads, settings) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/types.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/types.ts new file mode 100644 index 00000000000..a7cdf945ba7 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/types.ts @@ -0,0 +1,47 @@ +export interface ChannelDetails { + id: number + name: string + messageMedium: string + channelType: string +} + +export interface MessageTypeDetails { + id: number + name: string + channelId: number + subscriptionPolicy: string +} + +export interface ListDetails { + id: number + name: string + listType: string +} + +export interface ResolvedIdentifier { + email?: string + userId?: string +} + +export interface DynamicFieldContext { + selectedArrayIndex?: number + selectedKey?: string + query?: string +} + +export interface ChannelsResponse { + channels: ChannelDetails[] +} + +export interface MessageTypesResponse { + messageTypes: MessageTypeDetails[] +} + +export interface ListsResponse { + lists: ListDetails[] +} + +export interface BulkSubscriptionRequestBody { + users?: string[] + usersByUserId?: string[] +} diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..e004f126b25 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts @@ -0,0 +1,173 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import { randomUUID } from 'crypto' +import updateUser from '../index' + +// Emails are generated once per process so the full create-then-rename path is +// exercised with fresh identifiers on every run, while staying stable across the +// runner's retries within a single run. The $guid runner marker can't be used +// here because it only substitutes a string that is *entirely* '$guid' — it does +// not interpolate inside an email string. +const singleEmailFrom = `e2e-email-change-from-${randomUUID()}@segment.com` +const singleEmailTo = `e2e-email-change-to-${randomUUID()}@segment.com` + +const singleUserId = `e2e-userid-change-${randomUUID()}` +const singleUserIdEmailTo = `e2e-userid-change-to-${randomUUID()}@segment.com` + +const batchUserId1 = `e2e-batch-user-1-${randomUUID()}` +const batchUserId2 = `e2e-batch-user-2-${randomUUID()}` +const batchEmail1From = `e2e-batch-1-from-${randomUUID()}@segment.com` +const batchEmail1To = `e2e-batch-1-to-${randomUUID()}@segment.com` +const batchEmail2From = `e2e-batch-2-from-${randomUUID()}@segment.com` +const batchEmail2To = `e2e-batch-2-to-${randomUUID()}@segment.com` + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully upserts a user with email and data fields', + subscribe: 'type = "identify"', + mapping: defaultValues(updateUser.fields), + mode: 'single', + event: createE2EEvent('identify', undefined, { + userId: 'e2e-test-user-001', + traits: { + email: 'e2e-test@segment.com', + firstName: 'E2E', + lastName: 'TestUser', + plan: 'premium' + } + }), + expect: { + status: 'success' + } + }, + { + // perform() upserts the user via /api/users/update (keyed by the current + // email), then issues a second /api/users/updateEmail call to re-key them to + // newEmail. perform() returns the second response, so the assertion below + // verifies the updateEmail call succeeded. + description: 'Changes a user email address when identified by email (update + updateEmail)', + subscribe: 'type = "identify"', + mapping: { + ...defaultValues(updateUser.fields), + newEmail: { '@path': '$.traits.newEmail' } + }, + mode: 'single', + event: createE2EEvent('identify', undefined, { + traits: { + email: singleEmailFrom, + newEmail: singleEmailTo, + firstName: 'E2E', + lastName: 'EmailChange' + } + }), + expect: { + status: 'success', + jsonContains: { code: 'Success' } + }, + verboseFailureHint: + 'Asserts the second /api/users/updateEmail call. On reruns Iterable may merge profiles if the target email already exists; it still returns code "Success".' + }, + { + // No email on the event — the user is identified by userId, so perform() + // upserts via /api/users/update (keyed by userId) and then issues + // /api/users/updateEmail with currentUserId (the currentUserId branch). + // perform() returns the updateEmail response, so the assertion verifies it. + // + // This requires an Iterable project with userId-based (or Hybrid) + // identification: in such a project /api/users/update creates the userId-keyed + // user, so the follow-up updateEmail call can find it. (In an email-only + // project this path returns 400 UnknownUserIdError.) + description: 'Changes a user email address when identified by userId only (currentUserId branch)', + subscribe: 'type = "identify"', + mapping: (() => { + const { email, ...rest } = defaultValues(updateUser.fields) + return { + ...rest, + newEmail: { '@path': '$.traits.newEmail' } + } + })(), + mode: 'single', + event: createE2EEvent('identify', undefined, { + userId: singleUserId, + traits: { + newEmail: singleUserIdEmailTo, + firstName: 'E2E', + lastName: 'UserIdEmailChange' + } + }), + expect: { + status: 'success', + jsonContains: { code: 'Success' } + }, + verboseFailureHint: + 'Exercises the currentUserId branch of perform(). Requires a userId-based or Hybrid Iterable project; in an email-only project this returns 400 UnknownUserIdError.' + }, + { + // Batch mode: performBatch folds newEmail into each user's dataFields.email + // and issues a single /api/users/bulkUpdate call. Core fans the single + // response out into a per-item multistatus array of { status, body, sent }. + // + // 'sent' is the mapped *action payload* (pre-transform), so it carries the + // top-level newEmail field — it does NOT reflect the dataFields.email folding + // that performBatch does afterwards. 'body' is the shared bulkUpdate response; + // successCount === number of events proves Iterable accepted the writes. + // + // dataFields is mapped from an explicit object (not raw $.traits) so the + // helper newEmail trait is not dumped into Iterable as a junk custom field. + description: 'Batch updates users carrying newEmail (bulkUpdate, per-item multistatus)', + subscribe: 'type = "identify"', + mapping: { + email: { '@path': '$.traits.email' }, + userId: { '@path': '$.userId' }, + newEmail: { '@path': '$.traits.newEmail' }, + dataFields: { firstName: { '@path': '$.traits.firstName' } }, + enable_batching: true + }, + mode: 'batchWithMultistatus', + events: [ + createE2EEvent('identify', undefined, { + userId: batchUserId1, + traits: { + email: batchEmail1From, + newEmail: batchEmail1To, + firstName: 'BatchOne' + } + }), + createE2EEvent('identify', undefined, { + userId: batchUserId2, + traits: { + email: batchEmail2From, + newEmail: batchEmail2To, + firstName: 'BatchTwo' + } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + body: { successCount: 2, failCount: 0 }, + sent: { + email: batchEmail1From, + userId: batchUserId1, + newEmail: batchEmail1To + } + }, + { + status: 200, + body: { successCount: 2, failCount: 0 }, + sent: { + email: batchEmail2From, + userId: batchUserId2, + newEmail: batchEmail2To + } + } + ] + }, + verboseFailureHint: + 'Asserts the per-item multistatus from the single /api/users/bulkUpdate call. Each item\'s "sent" payload carries the top-level newEmail; "body.successCount" should equal the number of events.' + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/index.test.ts index e498695f296..de1a7cecebb 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/index.test.ts @@ -158,6 +158,160 @@ describe('Iterable.updateUser', () => { }) }) }) + + describe('perform updateEmail', () => { + it('calls updateUser first, then updateEmail with currentEmail when only email is provided', async () => { + const event = createTestEvent({ + type: 'identify', + userId: undefined, + traits: { + email: 'old@example.com' + } + }) + + nock('https://api.iterable.com/api').post('/users/update').reply(200, {}) + nock('https://api.iterable.com/api').post('/users/updateEmail').reply(200, {}) + + const responses = await testDestination.testAction('updateUser', { + event, + useDefaultMappings: true, + mapping: { + newEmail: 'new@example.com' + } + }) + + expect(responses.length).toBe(2) + expect(responses[0].url).toContain('/users/update') + expect(responses[1].url).toContain('/users/updateEmail') + expect(responses[1].options.json).toEqual({ + currentEmail: 'old@example.com', + newEmail: 'new@example.com' + }) + }) + + it('calls updateUser first, then updateEmail with currentUserId when only userId is provided', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'seg_user_01', + traits: { + firstName: 'Test' + } + }) + + nock('https://api.iterable.com/api').post('/users/update').reply(200, {}) + nock('https://api.iterable.com/api').post('/users/updateEmail').reply(200, {}) + + const responses = await testDestination.testAction('updateUser', { + event, + mapping: { + userId: { '@path': '$.userId' }, + newEmail: 'updated@example.com' + } + }) + + expect(responses.length).toBe(2) + expect(responses[0].url).toContain('/users/update') + expect(responses[1].url).toContain('/users/updateEmail') + expect(responses[1].options.json).toEqual({ + currentUserId: 'seg_user_01', + newEmail: 'updated@example.com' + }) + }) + + it('sends both currentEmail and currentUserId when both are available', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'seg_user_01', + traits: { + email: 'old@example.com' + } + }) + + nock('https://api.iterable.com/api').post('/users/update').reply(200, {}) + nock('https://api.iterable.com/api').post('/users/updateEmail').reply(200, {}) + + const responses = await testDestination.testAction('updateUser', { + event, + useDefaultMappings: true, + mapping: { + newEmail: 'new@example.com' + } + }) + + expect(responses.length).toBe(2) + expect(responses[1].options.json).toEqual({ + currentEmail: 'old@example.com', + currentUserId: 'seg_user_01', + newEmail: 'new@example.com' + }) + }) + + it('calls updateUser with dataFields and then updateEmail', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'seg_user_01', + traits: { + email: 'old@example.com', + firstName: 'Jim', + phone: '+14158675309' + } + }) + + nock('https://api.iterable.com/api').post('/users/update').reply(200, {}) + nock('https://api.iterable.com/api').post('/users/updateEmail').reply(200, {}) + + const responses = await testDestination.testAction('updateUser', { + event, + useDefaultMappings: true, + mapping: { + newEmail: 'new@example.com' + } + }) + + expect(responses.length).toBe(2) + expect(responses[0].url).toContain('/users/update') + expect(responses[0].options.json).toMatchObject({ + email: 'old@example.com', + userId: 'seg_user_01', + dataFields: { + firstName: 'Jim', + phoneNumber: '+14158675309' + } + }) + expect(responses[1].url).toContain('/users/updateEmail') + expect(responses[1].options.json).toEqual({ + currentEmail: 'old@example.com', + currentUserId: 'seg_user_01', + newEmail: 'new@example.com' + }) + }) + + it('uses EU endpoint for updateEmail when dataCenterLocation is europe', async () => { + const event = createTestEvent({ + type: 'identify', + traits: { + email: 'old@example.com' + } + }) + + nock('https://api.eu.iterable.com/api').post('/users/update').reply(200, {}) + nock('https://api.eu.iterable.com/api').post('/users/updateEmail').reply(200, {}) + + const responses = await testDestination.testAction('updateUser', { + event, + useDefaultMappings: true, + settings: { apiKey: 'testApiKey', dataCenterLocation: 'europe' }, + mapping: { + newEmail: 'new@example.com' + } + }) + + expect(responses.length).toBe(2) + expect(responses[0].url).toContain('/users/update') + expect(responses[1].url).toContain('/users/updateEmail') + }) + }) + describe('performBatch', () => { it('works with default mappings', async () => { const events = [createTestEvent({ type: 'identify' }), createTestEvent({ type: 'identify' })] @@ -298,5 +452,98 @@ describe('Iterable.updateUser', () => { }) expect(responses[0].options.json).not.toHaveProperty('updateOnly') }) + + it('does not set newEmail in dataFields.email when newEmail is not provided', async () => { + const events = [ + createTestEvent({ + type: 'identify', + userId: 'seg_user_01', + traits: { + name: 'User One' + } + }), + createTestEvent({ + type: 'identify', + userId: 'seg_user_02', + traits: { + name: 'User Two' + } + }) + ] + + nock('https://api.iterable.com/api').post('/users/bulkUpdate').reply(200, {}) + + const responses = await testDestination.testBatchAction('updateUser', { + events, + useDefaultMappings: true, + settings: { apiKey: 'testApiKey', dataCenterLocation: 'united_states' } + }) + + expect(responses.length).toBe(1) + expect(responses[0].options.json).toMatchObject({ + users: [ + { + userId: 'seg_user_01' + }, + { + userId: 'seg_user_02' + } + ] + }) + expect(responses[0].options.json.users[0].dataFields).not.toHaveProperty('email') + expect(responses[0].options.json.users[1].dataFields).not.toHaveProperty('email') + }) + + it('includes newEmail in dataFields.email when newEmail is provided in batch', async () => { + const events = [ + createTestEvent({ + type: 'identify', + userId: 'seg_user_01', + traits: { + email: 'megaman7@gmail.com', + newEmail: 'megaman8@gmail.com' + } + }), + createTestEvent({ + type: 'identify', + userId: 'gen_seg_01', + traits: { + email: 'goldenaxe7@gmail.com', + newEmail: 'goldenaxe8@gmail.com' + } + }) + ] + + nock('https://api.iterable.com/api').post('/users/bulkUpdate').reply(200, {}) + + const responses = await testDestination.testBatchAction('updateUser', { + events, + mapping: { + email: { '@path': '$.traits.email' }, + userId: { '@path': '$.userId' }, + newEmail: { '@path': '$.traits.newEmail' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].options.json).toMatchObject({ + users: [ + { + email: 'megaman7@gmail.com', + userId: 'seg_user_01', + dataFields: { + email: 'megaman8@gmail.com' + } + }, + { + email: 'goldenaxe7@gmail.com', + userId: 'gen_seg_01', + dataFields: { + email: 'goldenaxe8@gmail.com' + } + } + ] + }) + }) }) }) diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts b/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts index d9719ce3ec8..d03efb8b71a 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * A user ID that identifies a user profile in Iterable. */ userId?: string + /** + * The new email address to assign to the user. For single event processing, Segment makes a separate API call to set the new email address. Batch updating a profile email address is only supported for Hybrid projects. + */ + newEmail?: string /** * Data to store on the user profile. */ diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/index.ts b/packages/destination-actions/src/destinations/iterable/updateUser/index.ts index 3a3d0bf5ef3..31a704f828e 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/index.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/index.ts @@ -29,7 +29,7 @@ const transformIterableUserPayload: (payload: Payload) => UserUpdateRequestPaylo const phoneNumber = payload.phoneNumber const formattedDataFields = convertDatesInObject(payload.dataFields ?? {}) const userUpdateRequest: UserUpdateRequestPayload = { - ...omit(payload, ['updateOnly', 'enable_batching', 'batch_size', 'phoneNumber']), + ...omit(payload, ['newEmail', 'updateOnly', 'enable_batching', 'batch_size', 'phoneNumber']), dataFields: { ...formattedDataFields, phoneNumber: phoneNumber @@ -50,6 +50,14 @@ const action: ActionDefinition = { userId: { ...USER_ID_FIELD }, + newEmail: { + label: 'New Email Address', + description: + 'The new email address to assign to the user. For single event processing, Segment makes a separate API call to set the new email address. Batch updating a profile email address is only supported for Hybrid projects.', + type: 'string', + format: 'email', + required: false + }, dataFields: { ...USER_DATA_FIELDS }, @@ -92,25 +100,50 @@ const action: ActionDefinition = { default: 1001 } }, - perform: (request, { payload, settings }) => { + perform: async (request, { payload, settings }) => { if (!payload.email && !payload.userId) { throw new PayloadValidationError('Must include email or userId.') } const updateUserRequestPayload: UserUpdateRequestPayload = transformIterableUserPayload(payload) - const endpoint = getRegionalEndpoint('updateUser', settings.dataCenterLocation as DataCenterLocation) - return request(endpoint, { + const updateUserEndpoint = getRegionalEndpoint('updateUser', settings.dataCenterLocation as DataCenterLocation) + const response = await request(updateUserEndpoint, { method: 'post', json: updateUserRequestPayload, timeout: Math.max(30_000, DEFAULT_REQUEST_TIMEOUT) }) + + if (!payload.newEmail) { + return response + } + + const updateEmailEndpoint = getRegionalEndpoint('updateEmail', settings.dataCenterLocation as DataCenterLocation) + return request(updateEmailEndpoint, { + method: 'post', + json: { + ...(payload.email ? { currentEmail: payload.email } : {}), + ...(payload.userId ? { currentUserId: payload.userId } : {}), + newEmail: payload.newEmail + }, + timeout: Math.max(30_000, DEFAULT_REQUEST_TIMEOUT) + }) }, performBatch: (request, { settings, payload }) => { const { updateOnly } = payload[0] + const users = payload.map((p) => { + const user = transformIterableUserPayload(p) + if (p.newEmail) { + user.dataFields = { + ...user.dataFields, + email: p.newEmail + } + } + return user + }) const bulkUpdateUserRequestPayload: BulkUserUpdateRequestPayload = { - ...( updateOnly ? { updateOnly } : {}), - users: payload.map(transformIterableUserPayload) + ...(updateOnly ? { updateOnly } : {}), + users } const endpoint = getRegionalEndpoint('bulkUpdateUser', settings.dataCenterLocation as DataCenterLocation) diff --git a/packages/destination-actions/src/destinations/iterable/utils.ts b/packages/destination-actions/src/destinations/iterable/utils.ts index 837144dda11..9126dfbf44a 100644 --- a/packages/destination-actions/src/destinations/iterable/utils.ts +++ b/packages/destination-actions/src/destinations/iterable/utils.ts @@ -94,12 +94,16 @@ const regionBaseUrls = { export const apiEndpoints = { updateUser: '/api/users/update', + updateEmail: '/api/users/updateEmail', bulkUpdateUser: '/api/users/bulkUpdate', trackEvent: '/api/events/track', bulkTrackEvent: '/api/events/trackBulk', updateCart: '/api/commerce/updateCart', trackPurchase: '/api/commerce/trackPurchase', - getWebhooks: '/api/webhooks' + getWebhooks: '/api/webhooks', + getChannels: '/api/channels', + getMessageTypes: '/api/messageTypes', + getLists: '/api/lists' } /** @@ -110,6 +114,10 @@ export const apiEndpoints = { * @param dataCenterLocation The data center location for data residency. * @returns The regional API endpoint. */ +export function getRegionalBaseUrl(dataCenterLocation: DataCenterLocation = 'united_states'): string { + return regionBaseUrls[dataCenterLocation] || regionBaseUrls['united_states'] +} + export function getRegionalEndpoint( action: keyof typeof apiEndpoints, dataCenterLocation: DataCenterLocation = 'united_states' diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/__e2e__/index.ts b/packages/destination-actions/src/destinations/linkedin-audiences/__e2e__/index.ts new file mode 100644 index 00000000000..47fbbf7f1aa --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/__e2e__/index.ts @@ -0,0 +1,32 @@ +/** + * E2E config for the LinkedIn Audiences destination. + * + * These tests exercise the `updateCompanyAudience` action against LinkedIn's real + * DMP Segment Companies API (`POST /rest/dmpSegments/{id}/companies`, account-based marketing). + * + * The action is normally hook-gated: the mapping-save hook creates/selects a Company Segment and + * supplies its id via hookOutputs. The e2e framework does not run mapping-save hooks, so instead + * we point every fixture at a REAL, pre-created COMPANY-type DMP segment. Its id is injected into + * each event at `context.personas.external_audience_id` and picked up by the action's hidden + * `external_audience_id` fallback field. See ./updateCompanyAudience/__e2e__/fixtures.e2e.ts. + * + * Required environment variables: + * - E2E_LINKEDIN_AUDIENCES_ACCESS_TOKEN: OAuth access token with the `rw_dmp_segments` permission + * on an ad account where the member has a role other than VIEWER. + * - E2E_LINKEDIN_AUDIENCES_AD_ACCOUNT_ID: LinkedIn Ad Account ID (numeric, e.g. 512345678). + * - E2E_LINKEDIN_COMPANY_SEGMENT_ID: ID of a pre-created COMPANY-type DMP segment to sync into + * (read directly from process.env inside the fixtures file, not via $env). + */ +import type { E2EDestinationConfig } from '@segment/actions-core' + +export const config: E2EDestinationConfig = { + settings: { + ad_account_id: { $env: 'E2E_LINKEDIN_AUDIENCES_AD_ACCOUNT_ID' }, + send_email: true, + send_google_advertising_id: true, + oauth: { + access_token: { $env: 'E2E_LINKEDIN_AUDIENCES_ACCESS_TOKEN' }, + refresh_token: 'unused' + } + } +} diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/__snapshots__/snapshot.test.ts.snap deleted file mode 100644 index e3258c40814..00000000000 --- a/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/__snapshots__/snapshot.test.ts.snap +++ /dev/null @@ -1,23 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Testing snapshot for actions-linkedin-audiences destination: required fields - invalid combination 1`] = `[IntegrationError: At least one of \`Send Email\` or \`Send Google Advertising ID\` must be set to \`true\`.]`; - -exports[`Testing snapshot for actions-linkedin-audiences destination: updateAudience action - all fields 1`] = `""`; - -exports[`Testing snapshot for actions-linkedin-audiences destination: updateAudience action - required fields 1`] = `""`; - -exports[`Testing snapshot for actions-linkedin-audiences destination: updateAudience action - required fields 2`] = ` -Headers { - Symbol(map): Object { - "authorization": Array [ - "Bearer undefined", - ], - "linkedin-version": Array [ - "202505", - ], - "user-agent": Array [ - "Segment (Actions)", - ], - }, -} -`; diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/api.ts b/packages/destination-actions/src/destinations/linkedin-audiences/api.ts index c7cb597003c..697027cac7d 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/api.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/api.ts @@ -3,7 +3,14 @@ import type { RequestClient, ModifiedResponse, Features } from '@segment/actions import type { Settings } from './generated-types' import type { Payload } from './updateAudience/generated-types' import { BASE_URL, LINKEDIN_SOURCE_PLATFORM, getApiVersion } from './constants' +import { LINKEDIN_PROTOCOL_VERSION, SEGMENT_TYPES } from './updateCompanyAudience/constants' import type { ProfileAPIResponse, AdAccountUserResponse, LinkedInAudiencePayload } from './types' +import type { + AudienceJSON, + GetDMPSegmentsResponse, + LinkedInCompanyAudienceElement, + LinkedInBatchUpdateResponse +} from './updateCompanyAudience/types' export class LinkedInAudiences { request: RequestClient @@ -72,4 +79,66 @@ export class LinkedInAudiences { throwHttpErrors: false }) } + + async listDmpSegmentsByAccount(settings: Settings): Promise> { + return this.request(`${BASE_URL}/dmpSegments`, { + method: 'GET', + headers: { + 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION, + 'LinkedIn-Version': getApiVersion(this.features) + }, + searchParams: { + q: 'account', + account: `urn:li:sponsoredAccount:${settings.ad_account_id}`, + sourcePlatform: LINKEDIN_SOURCE_PLATFORM + } + }) + } + + async getDmpSegmentById(dmpSegmentId: string): Promise> { + return this.request(`${BASE_URL}/dmpSegments/${dmpSegmentId}`, { + method: 'GET', + headers: { + 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION, + 'LinkedIn-Version': getApiVersion(this.features) + } + }) + } + + async createCompanyDmpSegment(settings: Settings, segmentName: string): Promise { + return this.request(`${BASE_URL}/dmpSegments`, { + method: 'POST', + headers: { + 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION, + 'LinkedIn-Version': getApiVersion(this.features) + }, + json: { + name: segmentName, + sourcePlatform: LINKEDIN_SOURCE_PLATFORM, + account: `urn:li:sponsoredAccount:${settings.ad_account_id}`, + type: SEGMENT_TYPES.COMPANY, + destinations: [ + { + destination: 'LINKEDIN' + } + ] + } + }) + } + + async batchUpdateCompanies( + dmpSegmentId: string, + json: AudienceJSON + ): Promise> { + return this.request(`${BASE_URL}/dmpSegments/${dmpSegmentId}/companies`, { + method: 'POST', + headers: { + 'X-RestLi-Method': 'BATCH_CREATE', + 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION, + 'LinkedIn-Version': getApiVersion(this.features) + }, + json, + throwHttpErrors: false + }) + } } diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/index.ts b/packages/destination-actions/src/destinations/linkedin-audiences/index.ts index 96650dc540d..38e2c58faf9 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/index.ts @@ -5,6 +5,7 @@ import { InvalidAuthenticationError, IntegrationError, ErrorCodes } from '@segme import type { Settings } from './generated-types' import updateAudience from './updateAudience' +import updateCompanyAudience from './updateCompanyAudience' import { getApiVersion } from './constants' import { LinkedInAudiences } from './api' import type { @@ -153,7 +154,8 @@ const destination: DestinationDefinition = { }, actions: { - updateAudience + updateAudience, + updateCompanyAudience } } diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/__snapshots__/snapshot.test.ts.snap index 8c9637b3097..8b85ce91cb6 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/__snapshots__/snapshot.test.ts.snap @@ -13,7 +13,7 @@ Headers { "Bearer undefined", ], "linkedin-version": Array [ - "202505", + "202506", ], "user-agent": Array [ "Segment (Actions)", diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..4dd72516cca --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__e2e__/fixtures.e2e.ts @@ -0,0 +1,604 @@ +import type { E2EFixture, SegmentEvent } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import updateCompanyAudience from '../index' + +// The real, pre-created COMPANY-type DMP segment these fixtures sync into. Fixtures run in the same +// process as the runner, so they can read process.env directly (the $env marker only resolves inside +// settings). See ../../__e2e__/index.ts for the full list of required environment variables. +const COMPANY_SEGMENT_ID = process.env.E2E_LINKEDIN_COMPANY_SEGMENT_ID ?? '' + +// updateCompanyAudience is hook-gated: send() resolves the DMP segment id from +// hookOutputs.retlOnMappingSave.outputs.id. The runner does not execute mapping-save hooks, but +// destination-kit builds hookOutputs by reading the hook key straight off the raw mapping +// (action.ts: `bundle.mapping?.[hookType]`). So we embed the hook output directly in the mapping — +// mirroring how the unit tests supply it — which exercises the real hook-consuming code path with +// no change to the action source. Omit this key to test the "no audience connected" error. +const hookOutputs = { + retlOnMappingSave: { + inputs: {}, + outputs: { id: COMPANY_SEGMENT_ID, name: 'e2e Company Audience' } + } +} + +const FAILURE_HINT = + 'Ensure E2E_LINKEDIN_AUDIENCES_ACCESS_TOKEN, E2E_LINKEDIN_AUDIENCES_AD_ACCOUNT_ID, and ' + + 'E2E_LINKEDIN_COMPANY_SEGMENT_ID (a COMPANY-type DMP segment id) are set. ' + + 'To mint a fresh access token: LinkedIn Developer Portal (https://www.linkedin.com/developers/apps) ' + + '-> open the actions-linkedin-audiences app (its Client ID matches the linkedin-audiences-client-id ' + + 'secret) -> Auth tab -> OAuth 2.0 tools -> Create token -> check the rw_dmp_segments scope -> ' + + 'Request access token, and approve the consent popup as a member with a non-VIEWER role on the ad ' + + 'account. If the scope is missing, the app is not approved for the Audiences program. Access tokens ' + + 'last ~60 days; if a run returns 401 the token has expired, so re-mint it the same way.' + +// A minimal track event. Company identifiers and action come from the mapping (not the event body), +// so the event itself carries no personas/audience context. +function companyEvent(properties: Record = {}): SegmentEvent { + return { + type: 'track', + event: 'Company Audience Sync', + messageId: '$guid', + timestamp: '$now', + properties + } as unknown as SegmentEvent +} + +// defaultValues wires the hidden batching fields. We override identifiers + action per case and +// spread in hookOutputs so send() can resolve the segment id. +const baseMapping = defaultValues(updateCompanyAudience.fields) + +// 350 real company domains for the large-batch e2e test below. +const COMPANY_DOMAINS: string[] = [ + 'microsoft.com', + 'apple.com', + 'google.com', + 'amazon.com', + 'meta.com', + 'netflix.com', + 'adobe.com', + 'salesforce.com', + 'oracle.com', + 'ibm.com', + 'intel.com', + 'nvidia.com', + 'amd.com', + 'qualcomm.com', + 'cisco.com', + 'dell.com', + 'hp.com', + 'hpe.com', + 'sap.com', + 'vmware.com', + 'servicenow.com', + 'workday.com', + 'atlassian.com', + 'slack.com', + 'zoom.us', + 'dropbox.com', + 'box.com', + 'twilio.com', + 'stripe.com', + 'squareup.com', + 'shopify.com', + 'paypal.com', + 'intuit.com', + 'autodesk.com', + 'snowflake.com', + 'datadoghq.com', + 'mongodb.com', + 'elastic.co', + 'hashicorp.com', + 'gitlab.com', + 'github.com', + 'cloudflare.com', + 'fastly.com', + 'akamai.com', + 'digitalocean.com', + 'heroku.com', + 'twitch.tv', + 'reddit.com', + 'pinterest.com', + 'snap.com', + 'linkedin.com', + 'x.com', + 'spotify.com', + 'uber.com', + 'lyft.com', + 'airbnb.com', + 'doordash.com', + 'instacart.com', + 'grubhub.com', + 'yelp.com', + 'ebay.com', + 'etsy.com', + 'wayfair.com', + 'chewy.com', + 'wish.com', + 'zillow.com', + 'redfin.com', + 'opendoor.com', + 'carvana.com', + 'coinbase.com', + 'robinhood.com', + 'sofi.com', + 'affirm.com', + 'klarna.com', + 'plaid.com', + 'chime.com', + 'brex.com', + 'ramp.com', + 'gusto.com', + 'carta.com', + 'hubspot.com', + 'zendesk.com', + 'freshworks.com', + 'asana.com', + 'monday.com', + 'notion.so', + 'airtable.com', + 'figma.com', + 'canva.com', + 'miro.com', + 'docusign.com', + 'okta.com', + 'crowdstrike.com', + 'paloaltonetworks.com', + 'fortinet.com', + 'zscaler.com', + 'sentinelone.com', + 'splunk.com', + 'dynatrace.com', + 'newrelic.com', + 'pagerduty.com', + 'segment.com', + 'mixpanel.com', + 'amplitude.com', + 'braze.com', + 'iterable.com', + 'mparticle.com', + 'sendgrid.com', + 'mailchimp.com', + 'twilio.io', + 'jpmorgan.com', + 'jpmorganchase.com', + 'bankofamerica.com', + 'wellsfargo.com', + 'citi.com', + 'citigroup.com', + 'goldmansachs.com', + 'morganstanley.com', + 'usbank.com', + 'pnc.com', + 'capitalone.com', + 'americanexpress.com', + 'visa.com', + 'mastercard.com', + 'discover.com', + 'schwab.com', + 'fidelity.com', + 'vanguard.com', + 'blackrock.com', + 'statestreet.com', + 'tdbank.com', + 'truist.com', + 'ally.com', + 'hsbc.com', + 'barclays.com', + 'natwest.com', + 'lloydsbank.com', + 'santander.com', + 'deutsche-bank.de', + 'ubs.com', + 'credit-suisse.com', + 'bnpparibas.com', + 'societegenerale.com', + 'ing.com', + 'rabobank.com', + 'nordea.com', + 'db.com', + 'standardchartered.com', + 'macquarie.com', + 'nomura.com', + 'walmart.com', + 'target.com', + 'costco.com', + 'kroger.com', + 'homedepot.com', + 'lowes.com', + 'bestbuy.com', + 'macys.com', + 'nordstrom.com', + 'kohls.com', + 'gap.com', + 'oldnavy.com', + 'nike.com', + 'adidas.com', + 'underarmour.com', + 'lululemon.com', + 'ralphlauren.com', + 'levi.com', + 'hm.com', + 'zara.com', + 'ikea.com', + 'wayfair.co.uk', + 'cvs.com', + 'walgreens.com', + 'riteaid.com', + 'dollargeneral.com', + 'dollartree.com', + 'aldi.us', + 'publix.com', + 'wholefoodsmarket.com', + 'traderjoes.com', + 'sephora.com', + 'ulta.com', + 'petco.com', + 'petsmart.com', + 'autozone.com', + 'oreillyauto.com', + 'advanceautoparts.com', + 'tractorsupply.com', + 'acehardware.com', + 'coca-cola.com', + 'pepsico.com', + 'nestle.com', + 'unilever.com', + 'pg.com', + 'kraftheinz.com', + 'generalmills.com', + 'kelloggs.com', + 'mondelezinternational.com', + 'mars.com', + 'tyson.com', + 'conagrabrands.com', + 'campbells.com', + 'hersheys.com', + 'danone.com', + 'mccormick.com', + 'molsoncoors.com', + 'anheuser-busch.com', + 'diageo.com', + 'heineken.com', + 'starbucks.com', + 'mcdonalds.com', + 'chipotle.com', + 'dominos.com', + 'yum.com', + 'darden.com', + 'dunkindonuts.com', + 'wendys.com', + 'burgerking.com', + 'subway.com', + 'toyota.com', + 'ford.com', + 'gm.com', + 'honda.com', + 'volkswagen.com', + 'bmw.com', + 'mercedes-benz.com', + 'tesla.com', + 'stellantis.com', + 'nissan-global.com', + 'hyundai.com', + 'kia.com', + 'volvocars.com', + 'ferrari.com', + 'porsche.com', + 'caterpillar.com', + 'deere.com', + 'cummins.com', + 'paccar.com', + 'navistar.com', + 'boeing.com', + 'lockheedmartin.com', + 'rtx.com', + 'northropgrumman.com', + 'generaldynamics.com', + 'honeywell.com', + 'ge.com', + '3m.com', + 'emerson.com', + 'siemens.com', + 'abb.com', + 'schneider-electric.com', + 'rockwellautomation.com', + 'parker.com', + 'illinoistoolworks.com', + 'exxonmobil.com', + 'chevron.com', + 'shell.com', + 'bp.com', + 'totalenergies.com', + 'conocophillips.com', + 'marathonpetroleum.com', + 'phillips66.com', + 'valero.com', + 'schlumberger.com', + 'halliburton.com', + 'bakerhughes.com', + 'occidentalpetroleum.com', + 'dukeenergy.com', + 'nexteraenergy.com', + 'att.com', + 'verizon.com', + 't-mobile.com', + 'comcast.com', + 'charter.com', + 'dish.com', + 'centurylink.com', + 'lumen.com', + 'vodafone.com', + 'orange.com', + 'telefonica.com', + 'deutschetelekom.com', + 'bt.com', + 'telus.com', + 'rogers.com', + 'bell.ca', + 'disney.com', + 'warnerbros.com', + 'paramount.com', + 'nbcuniversal.com', + 'sony.com', + 'fox.com', + 'cbs.com', + 'nytimes.com', + 'wsj.com', + 'bloomberg.com', + 'thomsonreuters.com', + 'condenast.com', + 'hearst.com', + 'gannett.com', + 'jnj.com', + 'pfizer.com', + 'merck.com', + 'abbvie.com', + 'bristolmyerssquibb.com', + 'lilly.com', + 'amgen.com', + 'gilead.com', + 'biogen.com', + 'regeneron.com', + 'moderna.com', + 'novartis.com', + 'roche.com', + 'astrazeneca.com', + 'gsk.com', + 'sanofi.com', + 'bayer.com', + 'novonordisk.com', + 'takeda.com', + 'teva.com', + 'unitedhealthgroup.com', + 'cvshealth.com', + 'cigna.com', + 'humana.com', + 'elevancehealth.com', + 'centene.com', + 'hcahealthcare.com', + 'mckesson.com', + 'cardinalhealth.com', + 'cencora.com', + 'medtronic.com', + 'abbott.com', + 'stryker.com', + 'bostonscientific.com', + 'becton-dickinson.com', + 'edwards.com', + 'intuitivesurgical.com', + 'zimmerbiomet.com', + 'baxter.com', + 'danaher.com', + 'accenture.com', + 'deloitte.com', + 'pwc.com', + 'ey.com', + 'kpmg.com', + 'mckinsey.com', + 'bcg.com', + 'bain.com', + 'capgemini.com', + 'cognizant.com' +] + +const fixtures: E2EFixture[] = [ + { + description: 'Single ADD by company domain', + subscribe: 'type = "track"', + mapping: { + ...baseMapping, + identifiers: { companyDomain: 'microsoft.com' }, + action: 'ADD', + ...hookOutputs + }, + mode: 'single', + event: companyEvent(), + // A freshly-created segment can briefly reject writes; retries give it time to settle. + expect: { status: 'success', httpStatus: 200 }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Single ADD by bare LinkedIn company id (wrapped to organizationUrn)', + subscribe: 'type = "track"', + mapping: { + ...baseMapping, + identifiers: { linkedInCompanyId: '1035' }, + action: 'ADD', + ...hookOutputs + }, + mode: 'single', + event: companyEvent(), + expect: { status: 'success', httpStatus: 200 }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Single REMOVE by company domain', + subscribe: 'type = "track"', + mapping: { + ...baseMapping, + identifiers: { companyDomain: 'microsoft.com' }, + action: 'REMOVE', + ...hookOutputs + }, + mode: 'single', + event: companyEvent(), + expect: { status: 'success', httpStatus: 200 }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Single with no identifier throws PayloadValidationError before any HTTP request', + subscribe: 'type = "track"', + mapping: { + ...baseMapping, + // Required object present, but neither sub-identifier set => client-side validation error. + identifiers: {}, + action: 'ADD', + ...hookOutputs + }, + mode: 'single', + event: companyEvent(), + expect: { + status: 'error', + errorType: 'PayloadValidationError', + errorMessage: "At least one of 'Company Domain' or 'LinkedIn Company ID' is required in the 'Identifiers' field." + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Single with no connected audience (hook output omitted) throws PayloadValidationError', + subscribe: 'type = "track"', + mapping: { + ...baseMapping, + identifiers: { companyDomain: 'microsoft.com' }, + action: 'ADD' + // hookOutputs intentionally omitted => send() cannot resolve a segment id. + }, + mode: 'single', + event: companyEvent(), + expect: { + status: 'error', + errorType: 'PayloadValidationError', + errorMessage: + 'No LinkedIn Company Audience is connected to this mapping. Please re-save the mapping to create or select a Company Audience.' + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch ADD by domain + ADD by id + REMOVE by domain, all valid', + subscribe: 'type = "track"', + mapping: { + ...baseMapping, + // identifiers/action come from each event's properties so a single mapping covers the batch. + identifiers: { + companyDomain: { '@path': '$.properties.companyDomain' }, + linkedInCompanyId: { '@path': '$.properties.linkedInCompanyId' } + }, + action: { '@path': '$.properties.action' }, + ...hookOutputs + }, + mode: 'batchWithMultistatus', + events: [ + companyEvent({ companyDomain: 'linkedin.com', action: 'ADD' }), + companyEvent({ linkedInCompanyId: '1337', action: 'ADD' }), + companyEvent({ companyDomain: 'salesforce.com', action: 'REMOVE' }) + ], + // LinkedIn returns 201 per element for successful add/remove (idempotent by design). + expect: { + status: 'success', + jsonContains: [{ status: 201 }, { status: 201 }, { status: 201 }] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch with a valid ADD, a valid REMOVE, and a no-identifier row (per-item 400)', + subscribe: 'type = "track"', + mapping: { + ...baseMapping, + identifiers: { + companyDomain: { '@path': '$.properties.companyDomain' }, + linkedInCompanyId: { '@path': '$.properties.linkedInCompanyId' } + }, + action: { '@path': '$.properties.action' }, + ...hookOutputs + }, + mode: 'batchWithMultistatus', + events: [ + companyEvent({ companyDomain: 'microsoft.com', action: 'ADD' }), + companyEvent({ companyDomain: 'salesforce.com', action: 'REMOVE' }), + // No identifier => per-item PAYLOAD_VALIDATION_FAILED, other rows still succeed. + companyEvent({ action: 'ADD' }) + ], + expect: { + status: 'success', + jsonContains: [{ status: 201 }, { status: 201 }, { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED' }] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch with duplicate companies collapses to one element, every row still gets a status', + subscribe: 'type = "track"', + mapping: { + ...baseMapping, + identifiers: { + companyDomain: { '@path': '$.properties.companyDomain' }, + linkedInCompanyId: { '@path': '$.properties.linkedInCompanyId' } + }, + action: { '@path': '$.properties.action' }, + ...hookOutputs + }, + mode: 'batchWithMultistatus', + events: [ + companyEvent({ companyDomain: 'Adobe.com', linkedInCompanyId: '1480', action: 'ADD' }), + companyEvent({ companyDomain: 'oracle.com', action: 'ADD' }), + companyEvent({ action: 'ADD' }), + companyEvent({ companyDomain: 'adobe.com', linkedInCompanyId: 'urn:li:organization:1480', action: 'ADD' }), + companyEvent({ linkedInCompanyId: '1476', action: 'ADD' }), + companyEvent({ companyDomain: 'ibm.com', action: 'ADD' }), + companyEvent({ companyDomain: 'ADOBE.COM', linkedInCompanyId: '1480', action: 'ADD' }), + companyEvent({ action: 'REMOVE' }), + companyEvent({ companyDomain: ' oracle.com ', action: 'ADD' }), + companyEvent({ linkedInCompanyId: 'urn:li:organization:1476', action: 'ADD' }), + companyEvent({ companyDomain: 'IBM.com', action: 'ADD' }), + companyEvent({ companyDomain: 'adobe.com', linkedInCompanyId: '1480', action: 'ADD' }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 201 }, + { status: 201 }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED' }, + { status: 201 }, + { status: 201 }, + { status: 201 }, + { status: 201 }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED' }, + { status: 201 }, + { status: 201 }, + { status: 201 }, + { status: 201 } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Large batch: add 350 company domains to the audience', + subscribe: 'type = "track"', + mapping: { + ...baseMapping, + identifiers: { + companyDomain: { '@path': '$.properties.companyDomain' } + }, + action: 'ADD', + ...hookOutputs + }, + mode: 'batchWithMultistatus', + events: COMPANY_DOMAINS.map((companyDomain) => companyEvent({ companyDomain })), + // LinkedIn returns 201 per element for a successful add; every one of the 350 rows should succeed. + expect: { + status: 'success', + jsonContains: COMPANY_DOMAINS.map(() => ({ status: 201 })) + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 00000000000..f550f9e1e1c --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for LinkedinAudiences's updateCompanyAudience destination action: all fields 1`] = ` +Object { + "elements": Array [ + Object { + "action": "ADD", + "companyWebsiteDomain": "t$o9ppw&", + "organizationUrn": "urn:li:organization:t$O9PpW&", + }, + ], +} +`; + +exports[`Testing snapshot for LinkedinAudiences's updateCompanyAudience destination action: required fields 1`] = ` +Object { + "elements": Array [ + Object { + "action": "ADD", + "companyWebsiteDomain": "microsoft.com", + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/index.test.ts new file mode 100644 index 00000000000..0ec118e2c78 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/index.test.ts @@ -0,0 +1,541 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import createRequestClient from '../../../../../../core/src/create-request-client' +import Destination from '../../index' +import { BASE_URL } from '../../constants' +import { toOrganizationUrn } from '../functions' +import { performCompanyHook, getCompanyAudiences, companyAudienceHook } from '../hooks' +import type { Settings } from '../../generated-types' + +const testDestination = createTestIntegration(Destination) + +const settings: Settings = { + ad_account_id: '12345', + send_email: true, + send_google_advertising_id: true +} + +const SEGMENT_ID = 'dmp_segment_id' +const auth = { accessToken: 'token', refreshToken: 'refresh' } + +// A plain RequestClient used to exercise the hook helpers directly (there is no testHook helper +// in the framework). nock matches on URL/body, so the auth header from extendRequest is not needed. +const requestClient = createRequestClient() + +const hookOutputs = { + retlOnMappingSave: { + inputs: {}, + outputs: { id: SEGMENT_ID, name: 'My ABM Audience' } + } +} + +// Full explicit mapping for executeBatch, which (unlike testBatchAction) does not apply useDefaultMappings. +const batchMapping = { + action: 'ADD', + identifiers: { + companyDomain: { '@path': '$.traits.company_domain' }, + linkedInCompanyId: { '@path': '$.traits.linkedin_company_id' } + }, + ...hookOutputs +} + +describe('LinkedinAudiences.updateCompanyAudience', () => { + beforeEach(() => { + nock.cleanAll() + }) + + describe('toOrganizationUrn', () => { + it('wraps a bare organization id into a URN', () => { + expect(toOrganizationUrn('1035')).toBe('urn:li:organization:1035') + }) + it('passes through a value that is already a URN', () => { + expect(toOrganizationUrn('urn:li:organization:1035')).toBe('urn:li:organization:1035') + }) + }) + + describe('perform', () => { + it('adds a single company with both identifiers', async () => { + let sentBody: any + nock(BASE_URL) + .post(`/dmpSegments/${SEGMENT_ID}/companies`, (body) => { + sentBody = body + return true + }) + .reply(200, { elements: [{ status: 201 }] }) + + const responses = await testDestination.testAction('updateCompanyAudience', { + event: { + type: 'track', + traits: { company_domain: 'microsoft.com', linkedin_company_id: '1035' } + } as any, + settings, + auth, + useDefaultMappings: true, + mapping: { + action: 'ADD', + ...hookOutputs + } + }) + + expect(responses[0].status).toBe(200) + expect(sentBody).toEqual({ + elements: [ + { + action: 'ADD', + companyWebsiteDomain: 'microsoft.com', + organizationUrn: 'urn:li:organization:1035' + } + ] + }) + }) + + it('sends only the provided identifier (domain only)', async () => { + let sentBody: any + nock(BASE_URL) + .post(`/dmpSegments/${SEGMENT_ID}/companies`, (body) => { + sentBody = body + return true + }) + .reply(200, { elements: [{ status: 201 }] }) + + await testDestination.testAction('updateCompanyAudience', { + event: { type: 'track', traits: { company_domain: 'microsoft.com' } } as any, + settings, + auth, + useDefaultMappings: true, + mapping: { action: 'ADD', ...hookOutputs } + }) + + expect(sentBody.elements[0]).toEqual({ action: 'ADD', companyWebsiteDomain: 'microsoft.com' }) + }) + + it('sends REMOVE when the action field is Remove', async () => { + let sentBody: any + nock(BASE_URL) + .post(`/dmpSegments/${SEGMENT_ID}/companies`, (body) => { + sentBody = body + return true + }) + .reply(200, { elements: [{ status: 201 }] }) + + await testDestination.testAction('updateCompanyAudience', { + event: { type: 'track', traits: { company_domain: 'microsoft.com' } } as any, + settings, + auth, + useDefaultMappings: true, + mapping: { action: 'REMOVE', ...hookOutputs } + }) + + expect(sentBody.elements[0].action).toBe('REMOVE') + }) + + it('throws when no company audience is connected (no hook output)', async () => { + await expect( + testDestination.testAction('updateCompanyAudience', { + event: { type: 'track', traits: { company_domain: 'microsoft.com' } } as any, + settings, + auth, + useDefaultMappings: true, + mapping: { action: 'ADD' } + }) + ).rejects.toThrow('No LinkedIn Company Audience is connected to this mapping') + }) + + it('throws a validation error when no identifier is provided', async () => { + await expect( + testDestination.testAction('updateCompanyAudience', { + event: { type: 'track', traits: {} } as any, + settings, + auth, + useDefaultMappings: true, + mapping: { action: 'ADD', identifiers: {}, ...hookOutputs } + }) + ).rejects.toThrow("At least one of 'Company Domain' or 'LinkedIn Company ID' is required") + }) + + const performWithStatus = (status: number) => { + nock(BASE_URL).post(`/dmpSegments/${SEGMENT_ID}/companies`).reply(status, {}) + return testDestination.testAction('updateCompanyAudience', { + event: { type: 'track', traits: { company_domain: 'microsoft.com' } } as any, + settings, + auth, + useDefaultMappings: true, + mapping: { action: 'ADD', ...hookOutputs } + }) + } + + it('throws a retryable error on 429 (rate limit)', async () => { + const error = await performWithStatus(429).catch((e) => e) + expect(error.code).toBe('RETRYABLE_ERROR') + expect(error.status).toBe(429) + }) + + it('throws a retryable error on 500', async () => { + const error = await performWithStatus(500).catch((e) => e) + expect(error.code).toBe('RETRYABLE_ERROR') + expect(error.status).toBe(500) + }) + + it('throws InvalidAuthenticationError on 401 so the token can be refreshed', async () => { + const error = await performWithStatus(401).catch((e) => e) + expect(error.code).toBe('INVALID_AUTHENTICATION') + }) + + it('throws a non-retryable error on 400 (malformed request)', async () => { + const error = await performWithStatus(400).catch((e) => e) + expect(error.code).not.toBe('RETRYABLE_ERROR') + expect(error.status).toBe(400) + }) + + it('throws a non-retryable error on 404 (segment not found)', async () => { + const error = await performWithStatus(404).catch((e) => e) + expect(error.code).not.toBe('RETRYABLE_ERROR') + expect(error.status).toBe(404) + }) + }) + + describe('performBatch', () => { + it('dedupes same-company payloads and fans each result back to every original index', async () => { + // The 10 valid rows below collapse to 4 unique company+action elements. Case, surrounding + // whitespace, and bare-id-vs-URN forms of the same company all key the same: + // Adobe (idx 0,3,6,11), oracle.com (idx 1,8), org 1476 (idx 4,9), ibm.com (idx 5,10). + // LinkedIn is sent exactly 4 elements; its per-element result is copied to every original + // index in that group, and the two no-identifier rows (idx 2,7) fail before the request. + let sentBody: any + nock(BASE_URL) + .post(`/dmpSegments/${SEGMENT_ID}/companies`, (body) => { + sentBody = body + return true + }) + .reply(200, { + elements: [ + { status: 201 }, // Adobe + { status: 201 }, // oracle.com + { status: 400, error: { message: 'Invalid organization urn' } }, // org 1476 + { status: 201 } // ibm.com + ] + }) + + const perEventMapping = { + action: { '@path': '$.traits.action' }, + identifiers: { + companyDomain: { '@path': '$.traits.company_domain' }, + linkedInCompanyId: { '@path': '$.traits.linkedin_company_id' } + }, + ...hookOutputs + } + + const events = [ + { type: 'track', traits: { company_domain: 'Adobe.com', linkedin_company_id: '1480', action: 'ADD' } }, + { type: 'track', traits: { company_domain: 'oracle.com', action: 'ADD' } }, + { type: 'track', traits: { action: 'ADD' } }, + { + type: 'track', + traits: { company_domain: 'adobe.com', linkedin_company_id: 'urn:li:organization:1480', action: 'ADD' } + }, + { type: 'track', traits: { linkedin_company_id: '1476', action: 'ADD' } }, + { type: 'track', traits: { company_domain: 'ibm.com', action: 'ADD' } }, + { type: 'track', traits: { company_domain: 'ADOBE.COM', linkedin_company_id: '1480', action: 'ADD' } }, + { type: 'track', traits: { action: 'REMOVE' } }, + { type: 'track', traits: { company_domain: ' oracle.com ', action: 'ADD' } }, + { type: 'track', traits: { linkedin_company_id: 'urn:li:organization:1476', action: 'ADD' } }, + { type: 'track', traits: { company_domain: 'IBM.com', action: 'ADD' } }, + { type: 'track', traits: { company_domain: 'adobe.com', linkedin_company_id: '1480', action: 'ADD' } } + ] as any + + const response = await testDestination.executeBatch('updateCompanyAudience', { + events, + settings, + auth, + mapping: perEventMapping + }) + + // Only 4 unique elements are sent to LinkedIn despite 12 input rows. Domains are normalized + // (trimmed + lower-cased) before being sent. + expect(sentBody).toEqual({ + elements: [ + { action: 'ADD', companyWebsiteDomain: 'adobe.com', organizationUrn: 'urn:li:organization:1480' }, + { action: 'ADD', companyWebsiteDomain: 'oracle.com' }, + { action: 'ADD', organizationUrn: 'urn:li:organization:1476' }, + { action: 'ADD', companyWebsiteDomain: 'ibm.com' } + ] + }) + + const adobe = { + status: 201, + sent: { action: 'ADD', companyWebsiteDomain: 'adobe.com', organizationUrn: 'urn:li:organization:1480' }, + body: { action: 'ADD', identifiers: { companyDomain: 'adobe.com', linkedInCompanyId: '1480' }, index: 0 } + } + const oracle = { + status: 201, + sent: { action: 'ADD', companyWebsiteDomain: 'oracle.com' }, + body: { action: 'ADD', identifiers: { companyDomain: 'oracle.com' }, index: 1 } + } + const ibm = { + status: 201, + sent: { action: 'ADD', companyWebsiteDomain: 'ibm.com' }, + body: { action: 'ADD', identifiers: { companyDomain: 'ibm.com' }, index: 5 } + } + const org1476Error = { + status: 400, + errortype: 'BAD_REQUEST', + errormessage: 'Invalid organization urn', + sent: { action: 'ADD', organizationUrn: 'urn:li:organization:1476' }, + body: { action: 'ADD', identifiers: { linkedInCompanyId: '1476' }, index: 4 }, + errorreporter: 'DESTINATION' + } + const noIdentifier = (action: string) => ({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: + "At least one of 'Company Domain' or 'LinkedIn Company ID' is required in the 'Identifiers' field.", + sent: { action, identifiers: {} }, + body: {}, + errorreporter: 'DESTINATION' + }) + + // Every original index gets a status; duplicate rows carry the representative's sent/body, + // and the two members of the failed org-1476 group (idx 4, 9) both get the 400 error. + expect(response).toEqual([ + adobe, // 0 + oracle, // 1 + noIdentifier('ADD'), // 2 + adobe, // 3 (dup of 0) + org1476Error, // 4 + ibm, // 5 + adobe, // 6 (dup of 0) + noIdentifier('REMOVE'), // 7 + oracle, // 8 (dup of 1, whitespace-normalized) + org1476Error, // 9 (dup of 4, URN form) + ibm, // 10 (dup of 5, case-normalized) + adobe // 11 (dup of 0) + ]) + }) + + it('returns a per-item MultiStatusResponse for a mixed batch', async () => { + nock(BASE_URL) + .post(`/dmpSegments/${SEGMENT_ID}/companies`) + .reply(200, { + elements: [{ status: 201 }, { status: 400, error: { message: 'Invalid company' } }] + }) + + const events = [ + { type: 'track', traits: { company_domain: 'microsoft.com' } }, + { type: 'track', traits: { company_domain: 'invalid' } } + ] as any + + const response = await testDestination.executeBatch('updateCompanyAudience', { + events, + settings, + auth, + mapping: batchMapping + }) + + expect(response[0].status).toBe(201) + expect(response[1].status).toBe(400) + expect((response[1] as any).errormessage).toBe('Invalid company') + + // sent = the element sent to LinkedIn; body = the Segment payload into performBatch + expect((response[0] as any).sent).toEqual({ action: 'ADD', companyWebsiteDomain: 'microsoft.com' }) + expect((response[0] as any).body).toMatchObject({ identifiers: { companyDomain: 'microsoft.com' } }) + expect((response[1] as any).sent).toEqual({ action: 'ADD', companyWebsiteDomain: 'invalid' }) + expect((response[1] as any).body).toMatchObject({ identifiers: { companyDomain: 'invalid' } }) + }) + + it('marks payloads with no identifier as per-item errors without failing the batch', async () => { + nock(BASE_URL) + .post(`/dmpSegments/${SEGMENT_ID}/companies`) + .reply(200, { elements: [{ status: 201 }] }) + + const events = [ + { type: 'track', traits: {} }, + { type: 'track', traits: { company_domain: 'microsoft.com' } } + ] as any + + const response = await testDestination.executeBatch('updateCompanyAudience', { + events, + settings, + auth, + mapping: batchMapping + }) + + expect(response[0].status).toBe(400) + expect((response[0] as any).errormessage).toContain("At least one of 'Company Domain'") + expect(response[1].status).toBe(201) + }) + + it('fails a payload non-retryably when LinkedIn returns fewer results than sent', async () => { + // Two companies sent, but LinkedIn returns only one element in the 200 body. + nock(BASE_URL) + .post(`/dmpSegments/${SEGMENT_ID}/companies`) + .reply(200, { elements: [{ status: 201 }] }) + + const events = [ + { type: 'track', traits: { company_domain: 'microsoft.com' } }, + { type: 'track', traits: { company_domain: 'segment.com' } } + ] as any + + const response = await testDestination.executeBatch('updateCompanyAudience', { + events, + settings, + auth, + mapping: batchMapping + }) + + expect(response[0].status).toBe(201) + expect(response[1].status).toBe(400) + expect((response[1] as any).errormessage).toContain('did not return a result') + }) + }) + + describe('hook: performCompanyHook', () => { + it('creates a new COMPANY segment and returns the id from the x-restli-id header', async () => { + nock(BASE_URL) + .post('/dmpSegments', (body) => body.type === 'COMPANY' && body.name === 'My New Audience') + .reply(201, {}, { 'x-restli-id': 'new_segment_id' }) + + const result = await performCompanyHook(requestClient, settings, { segment_creation_name: 'My New Audience' }) + + expect(result).toEqual({ + successMessage: expect.stringContaining('new_segment_id'), + savedData: { id: 'new_segment_id', name: 'My New Audience' } + }) + }) + + it('validates and reuses a selected existing COMPANY segment', async () => { + nock(BASE_URL).get('/dmpSegments/999').reply(200, { id: '999', name: 'Existing ABM', type: 'COMPANY' }) + + const result = await performCompanyHook(requestClient, settings, { existing_audience_id: '999' }) + + expect(result).toEqual({ + successMessage: expect.stringContaining('Existing ABM'), + savedData: { id: '999', name: 'Existing ABM' } + }) + }) + + it('rejects a selected segment that is not a COMPANY segment', async () => { + nock(BASE_URL).get('/dmpSegments/999').reply(200, { id: '999', name: 'A User List', type: 'USER' }) + + const result = await performCompanyHook(requestClient, settings, { existing_audience_id: '999' }) + + expect(result).toEqual({ + error: { + message: expect.stringContaining('cannot be used as a Company Audience'), + code: 'INVALID_SEGMENT_TYPE' + } + }) + }) + + it('returns an error when neither an existing id nor a name is provided', async () => { + const result = await performCompanyHook(requestClient, settings, {}) + expect(result).toEqual({ + error: { message: expect.stringContaining('Provide a name'), code: 'MISSING_SEGMENT_NAME' } + }) + }) + + it('returns CREATE_SEGMENT_FAILURE when LinkedIn creates the segment but omits the x-restli-id header', async () => { + // 201 with no x-restli-id header => the hook cannot learn the new segment's id. + nock(BASE_URL).post('/dmpSegments').reply(201, {}) + + const result = await performCompanyHook(requestClient, settings, { segment_creation_name: 'My New Audience' }) + + expect(result).toEqual({ + error: { + message: expect.stringContaining('did not return an id'), + code: 'CREATE_SEGMENT_FAILURE' + } + }) + }) + + it('returns an error when creating the segment throws', async () => { + nock(BASE_URL).post('/dmpSegments').reply(500, { message: 'Internal error' }) + + const result = await performCompanyHook(requestClient, settings, { segment_creation_name: 'My New Audience' }) + + expect(result).toMatchObject({ + error: { code: expect.any(String), message: expect.any(String) } + }) + expect(result).not.toHaveProperty('savedData') + }) + + it('returns an error when fetching the selected existing segment throws', async () => { + nock(BASE_URL).get('/dmpSegments/999').reply(500, { message: 'Internal error' }) + + const result = await performCompanyHook(requestClient, settings, { existing_audience_id: '999' }) + + expect(result).toMatchObject({ + error: { code: expect.any(String), message: expect.any(String) } + }) + expect(result).not.toHaveProperty('savedData') + }) + }) + + describe('hook dropdown: getCompanyAudiences', () => { + it('lists only COMPANY-type segments as choices', async () => { + nock(BASE_URL) + .get('/dmpSegments') + .query(true) + .reply(200, { + elements: [ + { id: '1', name: 'Company Aud', type: 'COMPANY' }, + { id: '2', name: 'User Aud', type: 'USER' }, + { id: '3', name: 'Another Company', type: 'COMPANY' } + ] + }) + + const result = await getCompanyAudiences(requestClient, settings) + + expect(result).toEqual({ + choices: [ + { value: '1', label: 'Company Aud' }, + { value: '3', label: 'Another Company' } + ] + }) + }) + + it('returns empty choices with an error when listing segments throws', async () => { + nock(BASE_URL).get('/dmpSegments').query(true).reply(500, { message: 'Internal error' }) + + const result = await getCompanyAudiences(requestClient, settings) + + expect(result.choices).toEqual([]) + expect((result as { error?: { message: string; code: string } }).error).toMatchObject({ + message: expect.any(String), + code: expect.any(String) + }) + }) + }) + + describe('hook: companyAudienceHook.performHook wrapper', () => { + const { performHook } = companyAudienceHook + + it('delegates to performCompanyHook and returns its result', async () => { + nock(BASE_URL) + .post('/dmpSegments', (body) => body.type === 'COMPANY' && body.name === 'Via Wrapper') + .reply(201, {}, { 'x-restli-id': 'wrapped_segment_id' }) + + const result = await performHook(requestClient, { + settings, + payload: {}, + hookInputs: { segment_creation_name: 'Via Wrapper' } + }) + + expect(result).toEqual({ + successMessage: expect.stringContaining('wrapped_segment_id'), + savedData: { id: 'wrapped_segment_id', name: 'Via Wrapper' } + }) + }) + + it('defaults hookInputs to an empty object when none are provided', async () => { + const result = await performHook(requestClient, { + settings, + payload: {}, + hookInputs: undefined + }) + + expect(result).toEqual({ + error: { message: expect.stringContaining('Provide a name'), code: 'MISSING_SEGMENT_NAME' } + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..30c73fb0577 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/snapshot.test.ts @@ -0,0 +1,94 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'updateCompanyAudience' +const destinationSlug = 'LinkedinAudiences' +const seedName = `${destinationSlug}#${actionSlug}` + +// The segment id normally comes from the mapping-save hook. Supply it here so perform can run. +const hookOutputs = { + retlOnMappingSave: { + inputs: {}, + outputs: { id: 'dmp_segment_id', name: 'Company Audience' } + } +} + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/) + .persist() + .get(/.*/) + .reply(200, { elements: [{ id: 'dmp_segment_id', name: 'Company Audience', type: 'COMPANY' }] }) + nock(/.*/) + .persist() + .post(/.*/) + .reply(200, { elements: [{ status: 201 }] }) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + // At least one identifier value is required; the auto-generated data leaves them empty. + mapping: { ...event.properties, identifiers: { companyDomain: 'microsoft.com' }, ...hookOutputs }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/) + .persist() + .get(/.*/) + .reply(200, { elements: [{ id: 'dmp_segment_id', name: 'Company Audience', type: 'COMPANY' }] }) + nock(/.*/) + .persist() + .post(/.*/) + .reply(200, { elements: [{ status: 201 }] }) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { ...event.properties, ...hookOutputs }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/constants.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/constants.ts new file mode 100644 index 00000000000..5124db88295 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/constants.ts @@ -0,0 +1,15 @@ +export const ORGANIZATION_URN_PREFIX = 'urn:li:organization:' + +export const LINKEDIN_PROTOCOL_VERSION = '2.0.0' + +export const SEGMENT_TYPES = { + USER: 'USER', + COMPANY: 'COMPANY' +} as const + +export const AUDIENCE_ACTION = { + ADD: 'ADD', + REMOVE: 'REMOVE' +} as const + +export const RETRYABLE_STATUSES: readonly number[] = [408, 423, 429, 500, 502, 503, 504] diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/fields.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/fields.ts new file mode 100644 index 00000000000..7c0b0102489 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/fields.ts @@ -0,0 +1,64 @@ +import { InputField } from '@segment/actions-core' +import { AUDIENCE_ACTION } from './constants' + +export const fields: Record = { + identifiers: { + label: 'Company Identifiers', + description: + "The company identifiers to add to or remove from the LinkedIn DMP Company Segment. At least one of 'Company Domain' or 'LinkedIn Company ID' is required. When both are provided, both are sent to LinkedIn to improve the match rate.", + type: 'object', + required: true, + additionalProperties: false, + defaultObjectUI: 'keyvalue:only', + properties: { + companyDomain: { + label: 'Company Domain', + description: "The company's website domain to send to LinkedIn, e.g. 'microsoft.com'.", + type: 'string' + }, + linkedInCompanyId: { + label: 'LinkedIn Company ID', + description: + "The company's LinkedIn organization ID or organization URN, e.g. '1035' or 'urn:li:organization:1035'. A bare ID is automatically converted to a URN before being sent to LinkedIn.", + type: 'string' + } + }, + default: { + companyDomain: { '@path': '$.traits.company_domain' }, + linkedInCompanyId: { '@path': '$.traits.linkedin_company_id' } + } + }, + action: { + label: 'Company Segment Action', + description: 'Whether the company should be added to or removed from the LinkedIn DMP Company Segment.', + type: 'string', + required: true, + choices: [ + { label: 'Add to Company Audience', value: AUDIENCE_ACTION.ADD }, + { label: 'Remove from Company Audience', value: AUDIENCE_ACTION.REMOVE } + ] + }, + enable_batching: { + label: 'Enable Batching', + description: 'Enable batching of requests to the LinkedIn DMP Company Segment.', + type: 'boolean', + default: true, + unsafe_hidden: true + }, + batch_size: { + label: 'Batch Size', + description: 'Maximum number of companies to include in each batch. LinkedIn accepts up to 5000 per request.', + type: 'number', + default: 5000, + unsafe_hidden: true + }, + batch_keys: { + label: 'Batch Keys', + description: 'The keys to use for batching the events.', + type: 'string', + unsafe_hidden: true, + required: false, + multiple: true, + default: ['action'] + } +} diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts new file mode 100644 index 00000000000..535d3e57c22 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts @@ -0,0 +1,183 @@ +import { + MultiStatusResponse, + PayloadValidationError, + RetryableError, + InvalidAuthenticationError, + APIError, + JSONLikeObject, + RequestClient, + StatsContext +} from '@segment/actions-core' +import type { Payload } from './generated-types' +import { LinkedInAudiences } from '../api' +import type { AudienceJSON, LinkedInCompanyAudienceElement, ValidCompanyPayload, HookOutputs } from './types' +import { AUDIENCE_ACTION, ORGANIZATION_URN_PREFIX, RETRYABLE_STATUSES } from './constants' + +export function toOrganizationUrn(linkedInCompanyId: string): string { + return linkedInCompanyId.startsWith(ORGANIZATION_URN_PREFIX) + ? linkedInCompanyId + : `${ORGANIZATION_URN_PREFIX}${linkedInCompanyId}` +} + +export function validate( + payloads: Payload[], + msResponse: MultiStatusResponse, + isBatch: boolean +): ValidCompanyPayload[] { + const validPayloads: ValidCompanyPayload[] = [] + + payloads.forEach((payload, index) => { + const companyDomain = payload.identifiers?.companyDomain?.trim().toLowerCase() || undefined + const linkedInCompanyId = payload.identifiers?.linkedInCompanyId?.trim() || undefined + if (!companyDomain && !linkedInCompanyId) { + const message = + "At least one of 'Company Domain' or 'LinkedIn Company ID' is required in the 'Identifiers' field." + if (isBatch) { + msResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: message, + sent: payload as unknown as JSONLikeObject, + body: {} + }) + } else { + throw new PayloadValidationError(message) + } + } else { + validPayloads.push({ ...payload, identifiers: { companyDomain, linkedInCompanyId }, index }) + } + }) + + return validPayloads +} + +export function companyKey(payload: ValidCompanyPayload): string { + const { companyDomain, linkedInCompanyId } = payload.identifiers ?? {} + const domain = companyDomain ?? '' + const urn = linkedInCompanyId ? toOrganizationUrn(linkedInCompanyId) : '' + const action = payload.action === AUDIENCE_ACTION.REMOVE ? AUDIENCE_ACTION.REMOVE : AUDIENCE_ACTION.ADD + return `${action}::${domain}::${urn}` +} + +export function buildJSON(payloads: ValidCompanyPayload[]): AudienceJSON { + const elements: LinkedInCompanyAudienceElement[] = payloads.map((payload) => { + const { companyDomain, linkedInCompanyId } = payload.identifiers ?? {} + const element: LinkedInCompanyAudienceElement = { + action: payload.action === AUDIENCE_ACTION.REMOVE ? AUDIENCE_ACTION.REMOVE : AUDIENCE_ACTION.ADD + } + if (companyDomain) { + element.companyWebsiteDomain = companyDomain + } + if (linkedInCompanyId) { + element.organizationUrn = toOrganizationUrn(linkedInCompanyId) + } + return element + }) + + return { elements } +} + +export async function send( + request: RequestClient, + payloads: Payload[], + hookOutputs: HookOutputs | undefined, + isBatch: boolean, + statsContext: StatsContext | undefined +) { + const segmentId = hookOutputs?.retlOnMappingSave?.outputs?.id ?? hookOutputs?.onMappingSave?.outputs?.id + if (!segmentId) { + throw new PayloadValidationError( + 'No LinkedIn Company Audience is connected to this mapping. Please re-save the mapping to create or select a Company Audience.' + ) + } + + const msResponse = new MultiStatusResponse() + const validPayloads = validate(payloads, msResponse, isBatch) + + if (validPayloads.length === 0) { + if (isBatch) { + return msResponse + } + throw new PayloadValidationError('No valid payloads to process after validation.') + } + + const uniquePayloads: ValidCompanyPayload[] = [] + const payloadIndexes: number[][] = [] + const keyToPosition = new Map() + for (const payload of validPayloads) { + const key = companyKey(payload) + const position = keyToPosition.get(key) + if (position === undefined) { + keyToPosition.set(key, uniquePayloads.length) + uniquePayloads.push(payload) + payloadIndexes.push([payload.index]) + } else { + payloadIndexes[position].push(payload.index) + } + } + + const json = buildJSON(uniquePayloads) + const linkedinApiClient = new LinkedInAudiences(request) + + statsContext?.statsClient?.incr('oauth_app_api_call', 1, [ + ...(statsContext?.tags ?? []), + 'endpoint:add-or-remove-companies-from-dmpSegment' + ]) + + const response = await linkedinApiClient.batchUpdateCompanies(segmentId, json) + + if (response.status < 200 || response.status >= 300) { + handleRequestError(response.status, statsContext) + } + + if (!isBatch) { + return response + } + + const resultElements = response.data?.elements ?? [] + uniquePayloads.forEach((payload, i) => { + const result = resultElements[i] + const sent = json.elements[i] as unknown as JSONLikeObject + payloadIndexes[i].forEach((index) => { + if (result && result.status >= 200 && result.status < 300) { + msResponse.setSuccessResponseAtIndex(index, { + status: result.status, + sent, + body: payload as unknown as JSONLikeObject + }) + } else { + msResponse.setErrorResponseAtIndex(index, { + status: result?.status ?? 400, + errortype: 'BAD_REQUEST', + errormessage: result?.error?.message || 'LinkedIn did not return a result for this company.', + sent, + body: payload as unknown as JSONLikeObject + }) + } + }) + }) + + return msResponse +} + +function handleRequestError(status: number, statsContext: StatsContext | undefined): never { + statsContext?.statsClient?.incr('linkedin_dmp_company_segment_update_error', 1, [ + ...(statsContext?.tags ?? []), + `status_code:${status}` + ]) + + if (status === 401) { + throw new InvalidAuthenticationError( + 'Invalid LinkedIn OAuth access token. New authentication token will be requested.' + ) + } + + if (RETRYABLE_STATUSES.includes(status)) { + throw new RetryableError( + 'Transient error while updating the LinkedIn DMP Company Segment. This batch will be retried.', + status as 408 | 423 | 429 | 500 | 502 | 503 | 504 + ) + } + + throw new APIError(`Failed to update LinkedIn DMP Company Segment. LinkedIn returned status ${status}.`, status) +} diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/generated-types.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/generated-types.ts new file mode 100644 index 00000000000..c0ce894afe9 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/generated-types.ts @@ -0,0 +1,81 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The company identifiers to add to or remove from the LinkedIn DMP Company Segment. At least one of 'Company Domain' or 'LinkedIn Company ID' is required. When both are provided, both are sent to LinkedIn to improve the match rate. + */ + identifiers: { + /** + * The company's website domain to send to LinkedIn, e.g. 'microsoft.com'. + */ + companyDomain?: string + /** + * The company's LinkedIn organization ID or organization URN, e.g. '1035' or 'urn:li:organization:1035'. A bare ID is automatically converted to a URN before being sent to LinkedIn. + */ + linkedInCompanyId?: string + } + /** + * Whether the company should be added to or removed from the LinkedIn DMP Company Segment. + */ + action: string + /** + * Enable batching of requests to the LinkedIn DMP Company Segment. + */ + enable_batching?: boolean + /** + * Maximum number of companies to include in each batch. LinkedIn accepts up to 5000 per request. + */ + batch_size?: number + /** + * The keys to use for batching the events. + */ + batch_keys?: string[] +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface RetlOnMappingSaveInputs { + /** + * Select an existing LinkedIn DMP Company Segment to sync to. If provided, a new audience will not be created. + */ + existing_audience_id?: string + /** + * The name of the LinkedIn DMP Company Segment to create. Only used when an existing audience is not selected above. + */ + segment_creation_name?: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface RetlOnMappingSaveOutputs { + /** + * The ID of the LinkedIn DMP Company Segment that companies will be synced to. + */ + id?: string + /** + * The name of the LinkedIn DMP Company Segment that companies will be synced to. + */ + name?: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface OnMappingSaveInputs { + /** + * Select an existing LinkedIn DMP Company Segment to sync to. If provided, a new audience will not be created. + */ + existing_audience_id?: string + /** + * The name of the LinkedIn DMP Company Segment to create. Only used when an existing audience is not selected above. + */ + segment_creation_name?: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface OnMappingSaveOutputs { + /** + * The ID of the LinkedIn DMP Company Segment that companies will be synced to. + */ + id?: string + /** + * The name of the LinkedIn DMP Company Segment that companies will be synced to. + */ + name?: string +} diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/hooks.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/hooks.ts new file mode 100644 index 00000000000..2c8b5892448 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/hooks.ts @@ -0,0 +1,143 @@ +import { ActionHookDefinition } from '@segment/actions-core/destination-kit' +import { IntegrationError, RequestClient } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload, RetlOnMappingSaveInputs, RetlOnMappingSaveOutputs } from './generated-types' +import { LinkedInAudiences } from '../api' +import { SEGMENT_TYPES } from './constants' +import type { DMPSegment, CompanyHookInputs, CompanyHookResult } from './types' + +export async function getCompanyAudiences(request: RequestClient, settings: Settings) { + const linkedinApiClient = new LinkedInAudiences(request) + try { + const response = await linkedinApiClient.listDmpSegmentsByAccount(settings) + const choices = (response.data?.elements ?? []) + .filter((segment: DMPSegment) => segment.type === SEGMENT_TYPES.COMPANY) + .map((segment: DMPSegment) => ({ value: String(segment.id), label: segment.name })) + return { choices } + } catch (err) { + return { + choices: [], + nextPage: '', + error: { + message: (err as IntegrationError).message ?? 'Unknown error', + code: (err as IntegrationError).code ?? '500' + } + } + } +} + +export async function performCompanyHook( + request: RequestClient, + settings: Settings, + hookInputs: CompanyHookInputs +): Promise { + const linkedinApiClient = new LinkedInAudiences(request) + + if (hookInputs.existing_audience_id) { + try { + const response = await linkedinApiClient.getDmpSegmentById(hookInputs.existing_audience_id) + const { id, name, type } = response.data ?? {} + if (!id || type !== SEGMENT_TYPES.COMPANY) { + return { + error: { + message: `The selected DMP Segment (id: ${hookInputs.existing_audience_id}) is of type '${ + type ?? 'unknown' + }' and cannot be used as a Company Audience. Please select a COMPANY segment.`, + code: 'INVALID_SEGMENT_TYPE' + } + } + } + return { + successMessage: `Using existing Company Audience '${name}' (id: ${id}).`, + savedData: { id, name } + } + } catch (e) { + return { + error: { + message: (e as IntegrationError).message || 'Failed to fetch the selected Company Audience.', + code: (e as IntegrationError).code || 'GET_SEGMENT_FAILURE' + } + } + } + } + + if (!hookInputs.segment_creation_name) { + return { + error: { + message: 'Provide a name to create a new Company Audience, or select an existing one.', + code: 'MISSING_SEGMENT_NAME' + } + } + } + + try { + const response = await linkedinApiClient.createCompanyDmpSegment(settings, hookInputs.segment_creation_name) + // LinkedIn returns the new segment id in the x-restli-id response header, not the body. + const id = response.headers?.get('x-restli-id') ?? '' + if (!id) { + return { + error: { + message: 'LinkedIn did not return an id for the newly created Company Audience.', + code: 'CREATE_SEGMENT_FAILURE' + } + } + } + return { + successMessage: `Company Audience '${hookInputs.segment_creation_name}' (id: ${id}) created successfully!`, + savedData: { id, name: hookInputs.segment_creation_name } + } + } catch (e) { + return { + error: { + message: (e as IntegrationError).message || 'Failed to create the Company Audience.', + code: (e as IntegrationError).code || 'CREATE_SEGMENT_FAILURE' + } + } + } +} + +export const companyAudienceHook: ActionHookDefinition< + Settings, + Payload, + undefined, + RetlOnMappingSaveInputs, + RetlOnMappingSaveOutputs +> = { + label: 'Connect to a LinkedIn Company Audience', + description: + 'When saving this mapping, we will create a new LinkedIn DMP Company Segment, or connect to an existing one that you select.', + inputFields: { + existing_audience_id: { + label: 'Existing Company Audience', + description: + 'Select an existing LinkedIn DMP Company Segment to sync to. If provided, a new audience will not be created.', + type: 'string', + dynamic: async (request, { settings }) => { + return await getCompanyAudiences(request, settings) + } + }, + segment_creation_name: { + label: 'New Company Audience Name', + description: + 'The name of the LinkedIn DMP Company Segment to create. Only used when an existing audience is not selected above.', + type: 'string' + } + }, + outputTypes: { + id: { + label: 'ID', + description: 'The ID of the LinkedIn DMP Company Segment that companies will be synced to.', + type: 'string', + required: false + }, + name: { + label: 'Name', + description: 'The name of the LinkedIn DMP Company Segment that companies will be synced to.', + type: 'string', + required: false + } + }, + performHook: async (request, { settings, hookInputs }) => { + return await performCompanyHook(request, settings, hookInputs ?? {}) + } +} diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/index.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/index.ts new file mode 100644 index 00000000000..25c4757117a --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/index.ts @@ -0,0 +1,25 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { fields } from './fields' +import { send } from './functions' +import { companyAudienceHook } from './hooks' + +const action: ActionDefinition = { + title: 'Sync To LinkedIn DMP Company Segment', + description: 'Syncs companies to LinkedIn DMP Company Segments.', + defaultSubscription: 'type = "track"', + fields, + hooks: { + retlOnMappingSave: companyAudienceHook, + onMappingSave: companyAudienceHook + }, + perform: async (request, { payload, hookOutputs, statsContext }) => { + return await send(request, [payload], hookOutputs, false, statsContext) + }, + performBatch: async (request, { payload, hookOutputs, statsContext }) => { + return await send(request, payload, hookOutputs, true, statsContext) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/types.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/types.ts new file mode 100644 index 00000000000..b5e9eb99c47 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/types.ts @@ -0,0 +1,53 @@ +import type { Payload } from './generated-types' +import { SEGMENT_TYPES, AUDIENCE_ACTION } from './constants' + +export type SegmentType = typeof SEGMENT_TYPES[keyof typeof SEGMENT_TYPES] + +export type AudienceAction = typeof AUDIENCE_ACTION[keyof typeof AUDIENCE_ACTION] + +export interface LinkedInCompanyAudienceElement { + action: AudienceAction + companyWebsiteDomain?: string + organizationUrn?: string +} + +export interface AudienceJSON { + elements: E[] +} + +export interface DMPSegment { + id: string + name: string + type: SegmentType +} + +export interface GetDMPSegmentsResponse { + elements: DMPSegment[] +} + +export interface LinkedInBatchUpdateResponse { + elements: Array<{ + status: number + id?: string + error?: { + message?: string + status?: number + } + }> +} + +export type ValidCompanyPayload = Payload & { index: number } + +export interface HookOutputs { + retlOnMappingSave?: { outputs?: { id?: string } } + onMappingSave?: { outputs?: { id?: string } } +} + +export interface CompanyHookInputs { + existing_audience_id?: string + segment_creation_name?: string +} + +export type CompanyHookResult = + | { successMessage: string; savedData: { id: string; name: string } } + | { error: { message: string; code: string } } diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/versioning-info.ts b/packages/destination-actions/src/destinations/linkedin-audiences/versioning-info.ts index 5aea29f82c4..9595c5c2250 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/versioning-info.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/versioning-info.ts @@ -3,7 +3,7 @@ * API reference: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-audience-management/audience-api?view=li-lms-2024-05 * Changelog: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/recent-changes */ -export const LINKEDIN_AUDIENCES_API_VERSION = '202505' +export const LINKEDIN_AUDIENCES_API_VERSION = '202506' /** LINKEDIN_AUDIENCES_CANARY_API_VERSION * LinkedIn Audiences API version (canary/feature-flagged). diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts b/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts index e694cb6f545..0c825ca4d57 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts @@ -30,7 +30,7 @@ interface ConversionRuleUpdateValues { } interface UserID { - idType: 'SHA256_EMAIL' | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID' | 'AXCIOM_ID' | 'ORACLE_MOAT_ID' + idType: 'SHA256_EMAIL' | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID' | 'AXCIOM_ID' | 'ORACLE_MOAT_ID' | 'PLAINTEXT_IP_ADDRESS' | 'GOOGLE_AID' idValue: string } @@ -41,8 +41,8 @@ function validate(payload: Payload, conversionTime: number) { throw new PayloadValidationError('Timestamp should be within the past 90 days.') } - if (!payload.email && !payload.linkedInUUID && !payload.acxiomID && !payload.oracleID) { - throw new PayloadValidationError('One of email or LinkedIn UUID or Axciom ID or Oracle ID is required.') + if (!payload.email && !payload.linkedInUUID && !payload.acxiomID && !payload.oracleID && !payload.ipAddress && !payload.googleAID) { + throw new PayloadValidationError('At least one user identifier is required (email, LinkedIn UUID, Acxiom ID, Oracle ID, IP Address, or Google Advertising ID).') } } @@ -447,6 +447,20 @@ export class LinkedInConversions { }) } + if (payload.ipAddress) { + userIds.push({ + idType: 'PLAINTEXT_IP_ADDRESS', + idValue: payload.ipAddress + }) + } + + if (payload.googleAID) { + userIds.push({ + idType: 'GOOGLE_AID', + idValue: payload.googleAID + }) + } + return userIds } diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/index.test.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/index.test.ts index c7974033d2c..3163817cfae 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/index.test.ts @@ -509,7 +509,231 @@ describe('LinkedinConversions.streamConversion', () => { batch_size: 5000 } }) - ).rejects.toThrowError('One of email or LinkedIn UUID or Axciom ID or Oracle ID is required.') + ).rejects.toThrowError( + 'At least one user identifier is required (email, LinkedIn UUID, Acxiom ID, Oracle ID, IP Address, or Google Advertising ID).' + ) + }) + + it('should successfully send the event with only ipAddress as identifier', async () => { + nock(`${BASE_URL}/conversionEvents`) + .post('', { + conversion: 'urn:lla:llaPartnerConversion:789123', + conversionHappenedAt: currentTimestamp, + user: { + userIds: [ + { + idType: 'PLAINTEXT_IP_ADDRESS', + idValue: '192.168.1.1' + } + ] + } + }) + .reply(201) + + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + ipAddress: '192.168.1.1', + conversionHappenedAt: { + '@path': '$.timestamp' + }, + onMappingSave: { + inputs: {}, + outputs: { + id: payload.conversionId + } + }, + enable_batching: true, + batch_size: 5000 + } + }) + ).resolves.not.toThrowError() + }) + + it('should successfully send the event with only googleAID as identifier', async () => { + nock(`${BASE_URL}/conversionEvents`) + .post('', { + conversion: 'urn:lla:llaPartnerConversion:789123', + conversionHappenedAt: currentTimestamp, + user: { + userIds: [ + { + idType: 'GOOGLE_AID', + idValue: 'AEBE52E7-03EE-455A-B3C4-E57283966239' + } + ] + } + }) + .reply(201) + + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + googleAID: 'AEBE52E7-03EE-455A-B3C4-E57283966239', + conversionHappenedAt: { + '@path': '$.timestamp' + }, + onMappingSave: { + inputs: {}, + outputs: { + id: payload.conversionId + } + }, + enable_batching: true, + batch_size: 5000 + } + }) + ).resolves.not.toThrowError() + }) + + it('should successfully send the event with all identifier types', async () => { + nock(`${BASE_URL}/conversionEvents`) + .post('', { + conversion: 'urn:lla:llaPartnerConversion:789123', + conversionHappenedAt: currentTimestamp, + user: { + userIds: [ + { + idType: 'SHA256_EMAIL', + idValue: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777' + }, + { + idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', + idValue: 'li-uuid-123' + }, + { + idType: 'AXCIOM_ID', + idValue: 'axciom-456' + }, + { + idType: 'ORACLE_MOAT_ID', + idValue: 'oracle-789' + }, + { + idType: 'PLAINTEXT_IP_ADDRESS', + idValue: '103.20.92.12' + }, + { + idType: 'GOOGLE_AID', + idValue: 'AEBE52E7-03EE-455A-B3C4-E57283966239' + } + ] + } + }) + .reply(201) + + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + email: { '@path': '$.context.traits.email' }, + linkedInUUID: 'li-uuid-123', + acxiomID: 'axciom-456', + oracleID: 'oracle-789', + ipAddress: '103.20.92.12', + googleAID: 'AEBE52E7-03EE-455A-B3C4-E57283966239', + conversionHappenedAt: { + '@path': '$.timestamp' + }, + onMappingSave: { + inputs: {}, + outputs: { + id: payload.conversionId + } + }, + enable_batching: true, + batch_size: 5000 + } + }) + ).resolves.not.toThrowError() + }) + + it('should successfully send a batch request with ipAddress and googleAID', async () => { + nock(`${BASE_URL}/conversionEvents`) + .post('', { + elements: [ + { + conversion: 'urn:lla:llaPartnerConversion:789123', + conversionHappenedAt: currentTimestamp, + user: { + userIds: [ + { + idType: 'PLAINTEXT_IP_ADDRESS', + idValue: '10.0.0.1' + }, + { + idType: 'GOOGLE_AID', + idValue: 'AEBE52E7-03EE-455A-B3C4-E57283966239' + } + ] + } + }, + { + conversion: 'urn:lla:llaPartnerConversion:789123', + conversionHappenedAt: currentTimestamp, + user: { + userIds: [ + { + idType: 'PLAINTEXT_IP_ADDRESS', + idValue: '10.0.0.2' + }, + { + idType: 'GOOGLE_AID', + idValue: 'BFBF63F8-14FF-566B-C4D5-F68394077340' + } + ] + } + } + ] + }) + .reply(201) + + const eventWithIp1 = createTestEvent({ + event: 'Example Event', + type: 'track', + timestamp: currentTimestamp.toString(), + context: { + ip: '10.0.0.1', + device: { advertisingId: 'AEBE52E7-03EE-455A-B3C4-E57283966239' } + } + }) + + const eventWithIp2 = createTestEvent({ + event: 'Example Event', + type: 'track', + timestamp: currentTimestamp.toString(), + context: { + ip: '10.0.0.2', + device: { advertisingId: 'BFBF63F8-14FF-566B-C4D5-F68394077340' } + } + }) + + await expect( + testDestination.testBatchAction('streamConversion', { + events: [eventWithIp1, eventWithIp2], + settings, + mapping: { + ipAddress: { '@path': '$.context.ip' }, + googleAID: { '@path': '$.context.device.advertisingId' }, + conversionHappenedAt: { + '@path': '$.timestamp' + }, + onMappingSave: { + inputs: {}, + outputs: { + id: payload.conversionId + } + }, + enable_batching: true, + batch_size: 5000 + } + }) + ).resolves.not.toThrowError() }) it('should normalize the user ID email field such that uppercase letters are converted to lowercase', async () => { diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts index 6b65bbc3d47..53cdb81d99a 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts @@ -23,21 +23,29 @@ export interface Payload { */ eventId?: string /** - * Email address of the contact associated with the conversion event. Segment will hash this value before sending it to LinkedIn. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required. + * Email address of the contact associated with the conversion event. Segment will hash this value before sending it to LinkedIn. At least one user identifier is required. */ email?: string /** - * First party cookie or Click Id. Enhanced conversion tracking must be enabled to use this ID type. See [LinkedIn documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-01&tabs=http#idtype) for more details. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required. + * First party cookie or Click Id. Enhanced conversion tracking must be enabled to use this ID type. See [LinkedIn documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-01&tabs=http#idtype) for more details. At least one user identifier is required. */ linkedInUUID?: string /** - * User identifier for matching with LiveRamp identity graph. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required. + * User identifier for matching with LiveRamp identity graph. At least one user identifier is required. */ acxiomID?: string /** - * User identifier for matching with Oracle MOAT Identity. Also known as ORACLE_MOAT_ID in LinkedIn documentation. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required. + * User identifier for matching with Oracle MOAT Identity. Also known as ORACLE_MOAT_ID in LinkedIn documentation. At least one user identifier is required. */ oracleID?: string + /** + * The user's IP address in plain text IPv4 format. Do not hash this value. LinkedIn will hash it during processing. At least one user identifier is required. + */ + ipAddress?: string + /** + * The Google Advertising ID (GAID) is a unique, user-resettable, anonymous identifier for Android devices. Do not hash this value. iOS advertising IDs (IDFA) are not supported. At least one user identifier is required. + */ + googleAID?: string /** * Object containing additional fields for user matching. If this object is defined, both firstName and lastName are required. */ diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts index dcdeee7c47e..f628cf1959e 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts @@ -237,7 +237,7 @@ const action: ActionDefinition { + afterEach(() => { + nock.cleanAll() + }) + + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock(settings.marketo_api_domain) + .post('/identity/oauth/token') + .reply(200, { access_token: 'token', token_type: 'bearer', expires_in: 3599, scope: 'scope' }) + + await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError() + }) + + it('should fail when credentials are invalid', async () => { + nock(settings.marketo_api_domain).post('/identity/oauth/token').reply(401, { + error: 'invalid_client', + error_description: 'Bad client credentials' + }) + + await expect(testDestination.testAuthentication(settings)).rejects.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/marketo-private/__tests__/snapshot.test.ts similarity index 53% rename from packages/destination-actions/src/destinations/linkedin-audiences/__tests__/snapshot.test.ts rename to packages/destination-actions/src/destinations/marketo-private/__tests__/snapshot.test.ts index 79c504825a8..d5042a4b70a 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/marketo-private/__tests__/snapshot.test.ts @@ -4,7 +4,19 @@ import destination from '../index' import nock from 'nock' const testDestination = createTestIntegration(destination) -const destinationSlug = 'actions-linkedin-audiences' +const destinationSlug = 'actions-marketo-private' + +// Marketo returns HTTP 200 with this body shape; the token endpoint returns an access token. +function mockMarketo() { + nock(/.*/) + .persist() + .post(/identity\/oauth\/token/) + .reply(200, { access_token: 'test-access-token', token_type: 'bearer', expires_in: 3599 }) + nock(/.*/) + .persist() + .post(/submitForm/) + .reply(200, { requestId: 'abc', success: true, result: [{ id: 1, status: 'created' }] }) +} describe(`Testing snapshot for ${destinationSlug} destination:`, () => { for (const actionSlug in destination.actions) { @@ -13,12 +25,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, true) - nock(/.*/) - .persist() - .get(/.*/) - .reply(200, { elements: [{ id: 'dmp_segment_id' }] }) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) + mockMarketo() const event = createTestEvent({ properties: eventData @@ -31,7 +38,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { auth: undefined }) - const request = responses[0].request + const request = responses[responses.length - 1].request const rawBody = await request.text() try { @@ -45,55 +52,12 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { expect(request.headers).toMatchSnapshot() }) - it('required fields - invalid combination', async () => { - const seedName = `${destinationSlug}#${actionSlug}` - const action = destination.actions[actionSlug] - const [eventData, settingsData] = generateTestData(seedName, destination, action, true) - - // should throw an error if both required settings are set to false - settingsData.send_email = false - settingsData.send_google_advertising_id = false - - const event = createTestEvent({ - properties: eventData - }) - - try { - const responses = await testDestination.testAction(actionSlug, { - event: event, - mapping: event.properties, - settings: settingsData, - auth: undefined - }) - - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() - } catch (e) { - expect(e).toMatchSnapshot() - } - }) - it(`${actionSlug} action - all fields`, async () => { const seedName = `${destinationSlug}#${actionSlug}` const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, false) - nock(/.*/) - .persist() - .get(/.*/) - .reply(200, { elements: [{ id: 'dmp_segment_id' }] }) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) + mockMarketo() const event = createTestEvent({ properties: eventData @@ -106,7 +70,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { auth: undefined }) - const request = responses[0].request + const request = responses[responses.length - 1].request const rawBody = await request.text() try { diff --git a/packages/destination-actions/src/destinations/marketo-private/constants.ts b/packages/destination-actions/src/destinations/marketo-private/constants.ts new file mode 100644 index 00000000000..26cbe043c8b --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/constants.ts @@ -0,0 +1 @@ +export const OAUTH_TOKEN_ENDPOINT = '/identity/oauth/token' diff --git a/packages/destination-actions/src/destinations/marketo-private/functions.ts b/packages/destination-actions/src/destinations/marketo-private/functions.ts new file mode 100644 index 00000000000..950fabf8826 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/functions.ts @@ -0,0 +1,17 @@ +import type { RequestClient } from '@segment/actions-core' +import type { Settings } from './generated-types' +import type { RefreshTokenResponse } from './types' +import { OAUTH_TOKEN_ENDPOINT } from './constants' + +export async function getAccessToken(request: RequestClient, settings: Settings): Promise { + const res = await request(`${settings.marketo_api_domain}${OAUTH_TOKEN_ENDPOINT}`, { + method: 'POST', + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: settings.client_id, + client_secret: settings.client_secret + }) + }) + + return res.data.access_token +} diff --git a/packages/destination-actions/src/destinations/marketo-private/generated-types.ts b/packages/destination-actions/src/destinations/marketo-private/generated-types.ts new file mode 100644 index 00000000000..800f6b364a5 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Marketo REST API Client ID. + */ + client_id: string + /** + * Your Marketo REST API Client Secret. + */ + client_secret: string + /** + * Your Marketo REST API Domain in this format: https://.mktorest.com. + */ + marketo_api_domain: string +} diff --git a/packages/destination-actions/src/destinations/marketo-private/index.ts b/packages/destination-actions/src/destinations/marketo-private/index.ts new file mode 100644 index 00000000000..aaa65532105 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/index.ts @@ -0,0 +1,55 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import sendForm from './sendForm' +import { getAccessToken } from './functions' + +const destination: DestinationDefinition = { + name: 'Marketo Private', + slug: 'actions-marketo-private', + mode: 'cloud', + + authentication: { + scheme: 'oauth2', + fields: { + client_id: { + label: 'Client ID', + description: 'Your Marketo REST API Client ID.', + type: 'password', + required: true + }, + client_secret: { + label: 'Client Secret', + description: 'Your Marketo REST API Client Secret.', + type: 'password', + required: true + }, + marketo_api_domain: { + label: 'Marketo API Domain', + description: 'Your Marketo REST API Domain in this format: https://.mktorest.com.', + type: 'string', + format: 'uri', + required: true + } + }, + testAuthentication: (request, { settings }) => { + return getAccessToken(request, settings) + }, + refreshAccessToken: async (request, { settings }) => { + return { accessToken: await getAccessToken(request, settings) } + } + }, + extendRequest({ auth }) { + return { + headers: { + authorization: `Bearer ${auth?.accessToken}` + } + } + }, + + actions: { + sendForm + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/marketo-private/sendForm/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/marketo-private/sendForm/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..8b6ec3b169a --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/__e2e__/fixtures.e2e.ts @@ -0,0 +1,140 @@ +import type { E2EFixture } from '@segment/actions-core' +import { randomUUID } from 'node:crypto' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import sendForm from '../index' + +// The success fixtures submit to a real, approved Marketo form. The form ID is per-instance, +// so it is read from the environment rather than hardcoded. Fixtures are plain modules, so we +// can read process.env directly (the $env marker only resolves inside settings). +const FORM_ID = process.env.E2E_MARKETO_PRIVATE_FORM_ID ?? '' + +const FAILURE_HINT = + 'Ensure E2E_MARKETO_PRIVATE_CLIENT_ID, E2E_MARKETO_PRIVATE_CLIENT_SECRET, E2E_MARKETO_PRIVATE_API_DOMAIN and ' + + 'E2E_MARKETO_PRIVATE_FORM_ID are set. The form ID must reference an approved form in your Marketo instance.' + +// Marketo de-duplicates leads by email, so a static email upserts the same record on every run. +// We want ONE dynamically-created lead per run, shared across every fixture in that run. +// +// $guid can't do this: the runner gives each fixture its own guid cache, so the same marker +// resolves to a different value per fixture. Instead we generate the email ONCE here at module +// load. The fixtures file is imported a single time per `yarn e2e` invocation, so every fixture +// references the same E2E_USER_EMAIL, and a fresh process (new run) produces a new lead. +const E2E_USER_EMAIL = `e2e-marketo-private-${randomUUID()}@segment.com` + +const fixtures: E2EFixture[] = [ + { + // Happy path: a valid "Form Submitted" event reaches Marketo's submitForm API and is accepted. + description: 'Successfully submits a lead to a Marketo form (Form Submitted)', + subscribe: 'event = "Form Submitted" or event = "Registration Succeeded"', + mapping: { + ...defaultValues(sendForm.fields), + formId: FORM_ID, + // submitForm matches leadFormFields keys against Marketo REST field names (camelCase), + // not the form's display field IDs. Verified live: these keys create a lead. + leadFormFields: { + email: E2E_USER_EMAIL, + firstName: 'E2E', + lastName: 'Tester' + }, + visitorData: { + pageURL: 'https://example.com/segment-e2e' + } + }, + mode: 'single', + event: createE2EEvent('track', 'Form Submitted', { + properties: { + email: E2E_USER_EMAIL + } + }), + expect: { + status: 'success' + }, + verboseFailureHint: FAILURE_HINT + }, + { + // Happy path for the other subscribed event name: a "Registration Succeeded" event is routed to + // the same submitForm API and accepted. Uses the SAME lead (E2E_USER_EMAIL) as every other fixture. + description: 'Successfully submits a lead to a Marketo form (Registration Succeeded)', + subscribe: 'event = "Form Submitted" or event = "Registration Succeeded"', + mapping: { + ...defaultValues(sendForm.fields), + formId: FORM_ID, + leadFormFields: { + email: E2E_USER_EMAIL, + firstName: 'E2E', + lastName: 'Tester' + }, + visitorData: { + pageURL: 'https://example.com/segment-e2e' + } + }, + mode: 'single', + event: createE2EEvent('track', 'Registration Succeeded', { + properties: { + email: E2E_USER_EMAIL + } + }), + expect: { + status: 'success' + }, + verboseFailureHint: FAILURE_HINT + }, + { + // Local validation: formId is required and has no default, so omitting it makes the action throw + // before any HTTP request. The request never reaches Marketo. + description: 'Error: missing required formId is rejected before calling Marketo', + subscribe: 'event = "Form Submitted" or event = "Registration Succeeded"', + mapping: { + ...defaultValues(sendForm.fields), + // formId intentionally omitted + leadFormFields: { + email: E2E_USER_EMAIL + }, + visitorData: { + email: E2E_USER_EMAIL + } + }, + mode: 'single', + event: createE2EEvent('track', 'Form Submitted', { + properties: { + email: E2E_USER_EMAIL + } + }), + expect: { + status: 'error', + errorType: 'AggregateAjvError' + }, + verboseFailureHint: FAILURE_HINT + }, + { + // API error path: payload passes local validation but the form ID does not exist in Marketo. + // Marketo returns HTTP 200 with a non-retryable error code, which the action maps to a thrown + // IntegrationError (PAYLOAD_VALIDATION_FAILED). Exact code/message come from the live response, + // so we assert the error type only; tighten the message once observed against a live run. + description: 'Error: Marketo rejects a non-existent form ID', + subscribe: 'event = "Form Submitted" or event = "Registration Succeeded"', + mapping: { + ...defaultValues(sendForm.fields), + formId: '99999999', // well-formed but non-existent form + leadFormFields: { + email: E2E_USER_EMAIL + }, + visitorData: { + email: E2E_USER_EMAIL + } + }, + mode: 'single', + event: createE2EEvent('track', 'Form Submitted', { + properties: { + email: E2E_USER_EMAIL + } + }), + expect: { + status: 'error', + errorType: 'IntegrationError' + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 00000000000..e84aea2111b --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MarketoPrivate's sendForm destination action: all fields 1`] = ` +Object { + "formId": "M*egw50Q7^Hr(oa9", + "input": Array [ + Object { + "cookie": "M*egw50Q7^Hr(oa9", + "leadFormFields": Object { + "testType": "M*egw50Q7^Hr(oa9", + }, + "visitorData": Object { + "testType": "M*egw50Q7^Hr(oa9", + }, + }, + ], +} +`; + +exports[`Testing snapshot for MarketoPrivate's sendForm destination action: required fields 1`] = ` +Object { + "formId": "M*egw50Q7^Hr(oa9", + "input": Array [ + Object { + "leadFormFields": Object { + "testType": "M*egw50Q7^Hr(oa9", + }, + "visitorData": Object { + "testType": "M*egw50Q7^Hr(oa9", + }, + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/index.test.ts b/packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/index.test.ts new file mode 100644 index 00000000000..bb2b7b692d4 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/index.test.ts @@ -0,0 +1,148 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const settings = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + marketo_api_domain: 'https://123-ABC-456.mktorest.com' +} + +// The OAuth2 access token is supplied by the platform (cached from refreshAccessToken) and +// injected into the Authorization header by the destination's extendRequest. In tests we +// pass it via `auth` rather than mocking the token endpoint per action call. +const auth = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token' +} + +const SUBMIT_FORM_PATH = '/rest/v1/leads/submitForm.json' + +// Mapping mirrors the action fields. leadFormFields / visitorData are free-form objects. +const mapping = { + event_name: 'Form Submitted', + email: 'jane@example.com', + formId: '64', + leadFormFields: { + email: 'jane@example.com', + firstName: 'Jane', + phone: null, + company: undefined + }, + visitorData: { + email: 'jane@example.com', + pageURL: 'https://www.mongodb.com/products' + }, + cookie: 'id:abc&token:xyz' +} + +const event = createTestEvent({ event: 'Form Submitted', properties: { email: 'jane@example.com' } }) + +describe('MarketoPrivate.sendForm', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('submits the form with the bearer token, stripping null/undefined fields', async () => { + let capturedBody: any + let authHeader: string | undefined + nock(settings.marketo_api_domain) + .post(SUBMIT_FORM_PATH, (body: any) => { + capturedBody = body + return true + }) + .reply(function (this: { req: { headers: Record } }) { + const header = this.req.headers.authorization + authHeader = Array.isArray(header) ? header[0] : header + return [200, { requestId: 'abc', success: true, result: [{ id: 1, status: 'created' }] }] + }) + + const responses = await testDestination.testAction('sendForm', { event, settings, auth, mapping }) + + // Only the submitForm call is made; the token is provided via auth, not minted here. + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + + // Bearer header is set from the platform-supplied access token. + expect(authHeader).toBe('Bearer test-access-token') + + // Payload shape. + expect(capturedBody.formId).toBe('64') + expect(capturedBody.input[0].leadFormFields.email).toBe('jane@example.com') + expect(capturedBody.input[0].leadFormFields.firstName).toBe('Jane') + expect(capturedBody.input[0].cookie).toBe('id:abc&token:xyz') + + // removeEmpty strips null and undefined. + expect(capturedBody.input[0].leadFormFields).not.toHaveProperty('phone') + expect(capturedBody.input[0].leadFormFields).not.toHaveProperty('company') + }) + + it('succeeds on a created/updated result with no errors', async () => { + nock(settings.marketo_api_domain) + .post(SUBMIT_FORM_PATH) + .reply(200, { requestId: 'abc', success: true, result: [{ id: 7, status: 'updated' }] }) + + const responses = await testDestination.testAction('sendForm', { event, settings, auth, mapping }) + expect(responses[0].status).toBe(200) + }) + + it('throws InvalidAuthenticationError on a response-level auth error (602)', async () => { + nock(settings.marketo_api_domain) + .post(SUBMIT_FORM_PATH) + .reply(200, { requestId: 'abc', success: false, errors: [{ code: '602', message: 'Access token expired' }] }) + + await expect(testDestination.testAction('sendForm', { event, settings, auth, mapping })).rejects.toMatchObject({ + code: 'INVALID_AUTHENTICATION' + }) + }) + + it('throws a RetryableError on a transient response-level error (611)', async () => { + nock(settings.marketo_api_domain) + .post(SUBMIT_FORM_PATH) + .reply(200, { requestId: 'abc', success: false, errors: [{ code: '611', message: 'System error' }] }) + + await expect(testDestination.testAction('sendForm', { event, settings, auth, mapping })).rejects.toMatchObject({ + code: 'RETRYABLE_ERROR' + }) + }) + + it('throws a RetryableError on a transient record-level error (1016)', async () => { + nock(settings.marketo_api_domain) + .post(SUBMIT_FORM_PATH) + .reply(200, { + requestId: 'abc', + success: true, + result: [{ status: 'skipped', reasons: [{ code: '1016', message: 'Too many imports queued' }] }] + }) + + await expect(testDestination.testAction('sendForm', { event, settings, auth, mapping })).rejects.toMatchObject({ + code: 'RETRYABLE_ERROR' + }) + }) + + it('throws a permanent IntegrationError on a non-retryable record-level skip (1004)', async () => { + nock(settings.marketo_api_domain) + .post(SUBMIT_FORM_PATH) + .reply(200, { + requestId: 'abc', + success: true, + result: [{ status: 'skipped', reasons: [{ code: '1004', message: 'Lead not found' }] }] + }) + + await expect(testDestination.testAction('sendForm', { event, settings, auth, mapping })).rejects.toMatchObject({ + code: 'PAYLOAD_VALIDATION_FAILED' + }) + }) + + it('throws a permanent IntegrationError on a non-retryable response-level error (609)', async () => { + nock(settings.marketo_api_domain) + .post(SUBMIT_FORM_PATH) + .reply(200, { requestId: 'abc', success: false, errors: [{ code: '609', message: 'Invalid JSON' }] }) + + await expect(testDestination.testAction('sendForm', { event, settings, auth, mapping })).rejects.toMatchObject({ + code: 'PAYLOAD_VALIDATION_FAILED' + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..80b6d4cdeb7 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/snapshot.test.ts @@ -0,0 +1,85 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendForm' +const destinationSlug = 'MarketoPrivate' +const seedName = `${destinationSlug}#${actionSlug}` + +// Marketo returns HTTP 200 with this body shape; the token endpoint returns an access token. +// Both must succeed for the action's response handling to pass. +function mockMarketo() { + nock(/.*/) + .persist() + .post(/identity\/oauth\/token/) + .reply(200, { access_token: 'test-access-token', token_type: 'bearer', expires_in: 3599 }) + nock(/.*/) + .persist() + .post(/submitForm/) + .reply(200, { requestId: 'abc', success: true, result: [{ id: 1, status: 'created' }] }) +} + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + mockMarketo() + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + // The submitForm request is the last one (after the token request). + const request = responses[responses.length - 1].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + mockMarketo() + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[responses.length - 1].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-private/sendForm/constants.ts b/packages/destination-actions/src/destinations/marketo-private/sendForm/constants.ts new file mode 100644 index 00000000000..74d5c29ae6e --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/constants.ts @@ -0,0 +1,51 @@ +export const SUBMIT_FORM_ENDPOINT = '/rest/v1/leads/submitForm.json' + +// Marketo returns HTTP 200 even on failure, with the real error code in the response body. +// Errors appear at two levels: +// - Response-level: top-level `errors[]` with `success: false` (whole request failed) +// - Record-level: `result[].reasons[]` with `success: true` (request ok, this record skipped) +// See: https://experienceleague.adobe.com/en/docs/marketo-developer/marketo/rest/error-codes + +// Response-level auth errors. These trigger an OAuth2 token refresh + retry on the platform. +// 601 - Access token invalid +// 602 - Access token expired +export const AUTH_ERROR_CODES = new Set(['601', '602']) + +// Response-level errors that are safe to retry (documented as transient by Marketo). +// Retrying the same request later will likely succeed without any change. +// 500 - Internal server error +// 502 - Bad gateway +// 604 - Request time-out +// 606 - Max rate limit exceeded +// 607 - Daily quota reached +// 608 - API temporarily unavailable +// 611 - System error +// 614 - Invalid subscription +// 615 - Concurrent access limit reached +// 713 - Transient error +// 719 - Lock wait timeout exception +export const ResponseLevelErrorRetryableCode = new Set([ + '500', + '502', + '604', + '606', + '607', + '608', + '611', + '614', + '615', + '713', + '719' +]) + +// Record-level errors that are safe to retry (documented as transient by Marketo). +// These are queue/throttle conditions on an individual record that clear over time. +// 1016 - Too many imports queued +// 1019 - Import in progress +// 1020 - Too many clones to program +// 1029 - Too many jobs in queue / quota exceeded +// NOTE: 1004 "Lead not found" and 1013 "Object not found" are documented PERMANENT, +// so they are intentionally NOT here. Whether the "entity not ready yet" race the +// customer worked around with a 3s delay actually surfaces as a retryable code is +// still TBD -- pending customer clarification. +export const RecordLevelErrorRetryableCode = new Set(['1016', '1019', '1020', '1029']) diff --git a/packages/destination-actions/src/destinations/marketo-private/sendForm/functions.ts b/packages/destination-actions/src/destinations/marketo-private/sendForm/functions.ts new file mode 100644 index 00000000000..a542f64cd79 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/functions.ts @@ -0,0 +1,110 @@ +import { + RequestClient, + InvalidAuthenticationError, + RetryableError, + IntegrationError, + ErrorCodes +} from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import type { MarketoResponseError, MarketoRecordError, MarketoSubmitFormResponse, MarketoJSON } from './types' +import { + SUBMIT_FORM_ENDPOINT, + AUTH_ERROR_CODES, + ResponseLevelErrorRetryableCode, + RecordLevelErrorRetryableCode +} from './constants' + +function removeEmpty(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== null && v !== undefined)) as Record< + string, + string | number | boolean + > +} + +// Response-level errors: top-level `errors[]`, present only when `success: false`. +function getResponseErrors(body: MarketoSubmitFormResponse): MarketoResponseError[] { + return body.errors ?? [] +} + +// Record-level errors: `result[].reasons[]` on skipped records, even when `success: true`. +function getRecordErrors(body: MarketoSubmitFormResponse): MarketoRecordError[] { + const errors: MarketoRecordError[] = [] + for (const item of body.result ?? []) { + if (item.status === 'skipped' && item.reasons?.length) { + errors.push(...item.reasons) + } + } + return errors +} + +function formatErrors(errors: { code: string; message: string }[]): string { + return errors.map((e) => `${e.code}: ${e.message}`).join('; ') || 'Unknown Marketo error' +} + +// Marketo returns HTTP 200 even on failure, with the real error code in the body at one +// of two levels. We check each level separately because the same numeric ranges and the +// retry rules differ between them. +function handleResponse(body: MarketoSubmitFormResponse) { + const responseErrors = getResponseErrors(body) + const recordErrors = getRecordErrors(body) + + if (body.success && recordErrors.length === 0) { + return + } + + // --- Response-level handling (whole request failed) --- + const responseCodes = responseErrors.map((e) => e.code) + const responseMessage = formatErrors(responseErrors) + + // Token invalid/expired. Throwing an auth error (status 401) signals the platform to + // refresh the OAuth2 token via refreshAccessToken and retry the request. + if (responseCodes.some((code) => AUTH_ERROR_CODES.has(code))) { + throw new InvalidAuthenticationError(responseMessage, ErrorCodes.INVALID_AUTHENTICATION) + } + + if (responseCodes.some((code) => ResponseLevelErrorRetryableCode.has(code))) { + throw new RetryableError(`Transient Marketo response error, retrying: ${responseMessage}`) + } + + // --- Record-level handling (request ok, individual record skipped) --- + const recordCodes = recordErrors.map((e) => e.code) + const recordMessage = formatErrors(recordErrors) + + if (recordCodes.some((code) => RecordLevelErrorRetryableCode.has(code))) { + throw new RetryableError(`Transient Marketo record error, retrying: ${recordMessage}`) + } + + // Everything else is a permanent failure the user must fix. + const message = responseErrors.length ? responseMessage : recordMessage + throw new IntegrationError(message, ErrorCodes.PAYLOAD_VALIDATION_FAILED, 400) +} + +export async function send(request: RequestClient, settings: Settings, payload: Payload) { + const { formId, leadFormFields, visitorData, cookie } = payload + + const json: MarketoJSON = { + input: [ + { + leadFormFields: removeEmpty(leadFormFields), + visitorData: removeEmpty(visitorData) as MarketoJSON['input'][0]['visitorData'], + cookie + } + ], + formId + } + + const url = `${settings.marketo_api_domain}${SUBMIT_FORM_ENDPOINT}` + + const response = await request(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + json + }) + + handleResponse(response.data) + + return response +} diff --git a/packages/destination-actions/src/destinations/marketo-private/sendForm/generated-types.ts b/packages/destination-actions/src/destinations/marketo-private/sendForm/generated-types.ts new file mode 100644 index 00000000000..7c37f80d37e --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/generated-types.ts @@ -0,0 +1,32 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the Segment event. Used to route the submission to the correct Marketo form. Only "Form Submitted" and "Registration Succeeded" events are processed. + */ + event_name: string + /** + * The email address of the lead to submit to Marketo. + */ + email: string + /** + * The ID of the Marketo form to submit to. This can be set on the event properties or determined by the destination based on other event properties. + */ + formId: string + /** + * The full set of lead form fields. Used to determine the destination Marketo form, campaign, and any route-specific fields. + */ + leadFormFields: { + [k: string]: unknown + } + /** + * The visitor data to send to Marketo. This is used to associate the lead with the correct visitor in Marketo. + */ + visitorData: { + [k: string]: unknown + } + /** + * The Marketo cookie to send to Marketo. This is used to associate the lead with the correct visitor in Marketo. + */ + cookie?: string +} diff --git a/packages/destination-actions/src/destinations/marketo-private/sendForm/index.ts b/packages/destination-actions/src/destinations/marketo-private/sendForm/index.ts new file mode 100644 index 00000000000..10478bfd1fe --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/index.ts @@ -0,0 +1,66 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { send } from './functions' + +const action: ActionDefinition = { + title: 'Send Form', + description: + 'Submit a lead to a Marketo form using the Forms 2.0 submitForm API. The destination routes the event to the appropriate Marketo form based on the form/campaign properties on the event.', + defaultSubscription: 'event = "Form Submitted" or event = "Registration Succeeded"', + fields: { + event_name: { + label: 'Event Name', + description: + 'The name of the Segment event. Used to route the submission to the correct Marketo form. Only "Form Submitted" and "Registration Succeeded" events are processed.', + type: 'string', + required: true, + choices: [ + { label: 'Form Submitted', value: 'Form Submitted' }, + { label: 'Registration Succeeded', value: 'Registration Succeeded' } + ], + default: { '@path': '$.event' } + }, + email: { + label: 'Email', + description: 'The email address of the lead to submit to Marketo.', + type: 'string', + format: 'email', + required: true, + default: { '@path': '$.properties.email' } + }, + formId: { + label: 'Form ID', + description: + 'The ID of the Marketo form to submit to. This can be set on the event properties or determined by the destination based on other event properties.', + type: 'string', + required: true + }, + leadFormFields: { + label: 'Lead Form FieldS', + description: + 'The full set of lead form fields. Used to determine the destination Marketo form, campaign, and any route-specific fields.', + type: 'object', + required: true + }, + visitorData: { + label: 'Visitor Data', + description: + 'The visitor data to send to Marketo. This is used to associate the lead with the correct visitor in Marketo.', + type: 'object', + required: true + }, + cookie: { + label: 'Cookie', + description: + 'The Marketo cookie to send to Marketo. This is used to associate the lead with the correct visitor in Marketo.', + type: 'string', + required: false + } + }, + perform: (request, { settings, payload }) => { + return send(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/marketo-private/sendForm/types.ts b/packages/destination-actions/src/destinations/marketo-private/sendForm/types.ts new file mode 100644 index 00000000000..08ff2a4a1e9 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/types.ts @@ -0,0 +1,45 @@ +// Type which desribes JSON to send to Marketo +export type MarketoJSON = { + input: [ + { + leadFormFields: Record + visitorData: { + email: string + } & Record + cookie?: string + } + ] + formId: string +} + +// Response-level error: appears in the top-level `errors[]` array with `success: false`. +// Means the WHOLE request failed (nothing was processed). `code` is a string e.g. "602". +export interface MarketoResponseError { + code: string + message: string +} + +// Record-level error: appears in `result[].reasons[]` with `success: true`. +// Means the request succeeded but an INDIVIDUAL record was skipped. `code` e.g. "1004". +export interface MarketoRecordError { + code: string + message: string +} + +// A single record-level result inside the `result` array of a submitForm response. +// On failure `status` is "skipped" and `reasons` explains why. +export interface MarketoResultItem { + id?: number + status?: 'created' | 'updated' | 'skipped' | string + reasons?: MarketoRecordError[] +} + +// Top-level shape of a Marketo REST submitForm response. +// NOTE: Marketo returns HTTP 200 even on failure, surfacing the real error at one of +// two levels: response-level (`errors`) or record-level (`result[].reasons`). +export interface MarketoSubmitFormResponse { + requestId: string + success: boolean + errors?: MarketoResponseError[] + result?: MarketoResultItem[] +} diff --git a/packages/destination-actions/src/destinations/marketo-private/types.ts b/packages/destination-actions/src/destinations/marketo-private/types.ts new file mode 100644 index 00000000000..89bc8ea9ae2 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/types.ts @@ -0,0 +1,3 @@ +export interface RefreshTokenResponse { + access_token: string +} diff --git a/packages/destination-actions/src/destinations/mixpanel/__e2e__/index.ts b/packages/destination-actions/src/destinations/mixpanel/__e2e__/index.ts new file mode 100644 index 00000000000..9cbd648d59d --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/__e2e__/index.ts @@ -0,0 +1,17 @@ +/** + * Required environment variables: + * - E2E_MIXPANEL_PROJECT_TOKEN: Mixpanel project token (used by all actions, and by the + * Import Events API when the `actions-mixpanel-project-token-auth` flag is ON). + * - E2E_MIXPANEL_API_SECRET: Mixpanel project API secret (used by the Import Events API + * when the flag is OFF — i.e. trackEvent / trackPurchase batch + single). + */ +import type { E2EDestinationConfig } from '@segment/actions-core' + +export const config: E2EDestinationConfig = { + settings: { + projectToken: { $env: 'E2E_MIXPANEL_PROJECT_TOKEN' }, + apiSecret: { $env: 'E2E_MIXPANEL_API_SECRET' }, + apiRegion: 'US 🇺🇸', + strictMode: '1' + } +} diff --git a/packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts b/packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts index 7c4401d40d8..8b43cd1138e 100644 --- a/packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts @@ -1,19 +1,25 @@ import { SegmentEvent, createTestEvent, createTestIntegration } from '@segment/actions-core' import nock from 'nock' import Mixpanel from '../index' -import { MixpanelTrackApiResponseType } from '../common/utils' +import { MixpanelTrackApiResponseType, FLAGS } from '../common/utils' import { Features } from '@segment/actions-core/mapping-kit' beforeEach(() => { nock.cleanAll() }) +const PROJECT_TOKEN = 'test-api-key' +const API_SECRET = 'test-api-secret' + const settings = { - projectToken: 'test-api-key', - apiSecret: 'test-proj-token', + projectToken: PROJECT_TOKEN, apiRegion: 'US' } +// Expected Basic auth header values for the /import endpoint: `Basic base64(":")`. +const projectTokenAuth = `Basic ${Buffer.from(`${PROJECT_TOKEN}:`).toString('base64')}` +const apiSecretAuth = `Basic ${Buffer.from(`${API_SECRET}:`).toString('base64')}` + const END_POINT = 'https://api.mixpanel.com' const timestamp = '2024-10-25T15:21:15.449Z' @@ -452,4 +458,58 @@ describe('MultiStatus', () => { ]) }) }) + + describe('/import auth credential', () => { + // nock only matches the request when the authorization header equals the expected credential, so a + // successful (200) batch response proves the correct Basic-auth credential was sent. + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + createTestEvent({ timestamp, event: 'Test event' }), + createTestEvent({ timestamp, event: 'Test event' }) + ] + + const runExpectingAuth = async (expectedAuth: string, batchSettings: Record, flagOn: boolean) => { + nock(END_POINT) + .post('/import?strict=1') + .matchHeader('authorization', expectedAuth) + .reply(200, { code: 200, status: 'Ok', num_records_imported: 2 }) + + const features: Features = { 'mixpanel-multistatus': true } + if (flagOn) { + features[FLAGS.PROJECT_TOKEN_AUTH] = true + } + const response = await testDestination.executeBatch('trackEvent', { + events, + mapping, + settings: batchSettings as never, + features + }) + expect(response[0]).toMatchObject({ status: 200, body: 'Ok' }) + expect(response[1]).toMatchObject({ status: 200, body: 'Ok' }) + } + + it('uses the API secret when the project-token-auth flag is OFF', async () => { + await runExpectingAuth( + apiSecretAuth, + { projectToken: PROJECT_TOKEN, apiSecret: API_SECRET, apiRegion: 'US' }, + false + ) + }) + + it('uses the project token when the project-token-auth flag is ON', async () => { + await runExpectingAuth( + projectTokenAuth, + { projectToken: PROJECT_TOKEN, apiSecret: API_SECRET, apiRegion: 'US' }, + true + ) + }) + + it('falls back to the project token when the flag is OFF and no API secret is set', async () => { + await runExpectingAuth(projectTokenAuth, { projectToken: PROJECT_TOKEN, apiRegion: 'US' }, false) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/mixpanel/alias/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/alias/__tests__/index.test.ts index 70461022d13..f11f0b85559 100644 --- a/packages/destination-actions/src/destinations/mixpanel/alias/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/alias/__tests__/index.test.ts @@ -4,7 +4,6 @@ import Destination from '../../index' import { ApiRegions } from '../../common/utils' const testDestination = createTestIntegration(Destination) -const MIXPANEL_API_SECRET = 'test-api-key' const MIXPANEL_PROJECT_TOKEN = 'test-proj-token' describe('Mixpanel.alias', () => { @@ -18,7 +17,6 @@ describe('Mixpanel.alias', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -49,7 +47,6 @@ describe('Mixpanel.alias', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.EU } }) @@ -78,7 +75,6 @@ describe('Mixpanel.alias', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.IN } }) @@ -109,7 +105,6 @@ describe('Mixpanel.alias', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) expect(responses.length).toBe(1) @@ -139,7 +134,6 @@ describe('Mixpanel.alias', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, sourceName: 'example segment source name' } }) diff --git a/packages/destination-actions/src/destinations/mixpanel/common/utils.ts b/packages/destination-actions/src/destinations/mixpanel/common/utils.ts index a64691e9ac3..6b9fa3294a6 100644 --- a/packages/destination-actions/src/destinations/mixpanel/common/utils.ts +++ b/packages/destination-actions/src/destinations/mixpanel/common/utils.ts @@ -1,4 +1,10 @@ import { JSONLikeObject, ModifiedResponse, MultiStatusResponse } from '@segment/actions-core' +import { Features } from '@segment/actions-core/mapping-kit' +import { Settings } from '../generated-types' + +export const FLAGS = { + PROJECT_TOKEN_AUTH: 'actions-mixpanel-project-token-auth' +} export enum ApiRegions { US = 'US 🇺🇸', @@ -6,6 +12,17 @@ export enum ApiRegions { IN = 'IN 🇮🇳' } +export function getImportApiCredential(settings: Settings, features?: Features): string { + // When the project-token-auth flag is ON, authenticate the Import API with the Project Token. + // When OFF (default), use the API Secret if present. apiSecret is optional, so fall back to the + // Project Token when it is not set — otherwise new connections without a secret would send an + // "undefined:" Basic auth header and get 401s. + if (features && features[FLAGS.PROJECT_TOKEN_AUTH]) { + return settings.projectToken + } + return settings.apiSecret ?? settings.projectToken +} + export enum StrictMode { ON = '1', OFF = '0' diff --git a/packages/destination-actions/src/destinations/mixpanel/generated-types.ts b/packages/destination-actions/src/destinations/mixpanel/generated-types.ts index b79a6b6502d..a20d9c974a8 100644 --- a/packages/destination-actions/src/destinations/mixpanel/generated-types.ts +++ b/packages/destination-actions/src/destinations/mixpanel/generated-types.ts @@ -8,7 +8,7 @@ export interface Settings { /** * Mixpanel project secret. */ - apiSecret: string + apiSecret?: string /** * Learn about [EU data residency](https://docs.mixpanel.com/docs/privacy/eu-residency) and [India data residency](https://docs.mixpanel.com/docs/privacy/in-residency) */ diff --git a/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..0fc38122702 --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__e2e__/fixtures.e2e.ts @@ -0,0 +1,49 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import groupIdentifyUser from '../index' + +// groupIdentifyUser only implements perform() (no performBatch), so every fixture is single-mode. +// It authenticates via the project token in the request body and is unaffected by the +// actions-mixpanel-project-token-auth feature flag. +const fixtures: E2EFixture[] = [ + { + description: 'Successfully updates a group profile with traits', + subscribe: 'type = "group"', + mapping: defaultValues(groupIdentifyUser.fields), + mode: 'single', + event: createE2EEvent('group', undefined, { + userId: 'e2e-test-user-mixpanel-group-001', + groupId: 'e2e-test-group-001', + traits: { + name: 'Segment E2E Co', + industry: 'Software', + employees: 42 + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Rejects a group event when traits are missing', + subscribe: 'type = "group"', + // Drop the traits mapping so the action throws its "traits is a required field" IntegrationError + // before any HTTP request is made. + mapping: (() => { + const { traits, ...rest } = defaultValues(groupIdentifyUser.fields) + return rest + })(), + mode: 'single', + event: createE2EEvent('group', undefined, { + userId: 'e2e-test-user-mixpanel-group-002', + groupId: 'e2e-test-group-002' + }), + expect: { + status: 'error', + errorType: 'IntegrationError', + errorMessage: '"traits" is a required field' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__tests__/index.test.ts index e93d2120ddc..54dfd15db85 100644 --- a/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__tests__/index.test.ts @@ -4,7 +4,6 @@ import Destination from '../../index' import { ApiRegions } from '../../common/utils' const testDestination = createTestIntegration(Destination) -const MIXPANEL_API_SECRET = 'test-api-key' const MIXPANEL_PROJECT_TOKEN = 'test-proj-token' const timestamp = '2021-08-17T15:21:15.449Z' @@ -24,7 +23,6 @@ describe('Mixpanel.groupIdentifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -62,7 +60,6 @@ describe('Mixpanel.groupIdentifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -98,7 +95,6 @@ describe('Mixpanel.groupIdentifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.EU } }) @@ -134,7 +130,6 @@ describe('Mixpanel.groupIdentifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.EU } }) @@ -168,7 +163,6 @@ describe('Mixpanel.groupIdentifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.IN } }) @@ -204,7 +198,6 @@ describe('Mixpanel.groupIdentifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) expect(responses.length).toBe(1) @@ -239,7 +232,6 @@ describe('Mixpanel.groupIdentifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, sourceName: 'example segment source name' } }) diff --git a/packages/destination-actions/src/destinations/mixpanel/identifyUser/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/mixpanel/identifyUser/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..045e27f5926 --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/identifyUser/__e2e__/fixtures.e2e.ts @@ -0,0 +1,46 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import identifyUser from '../index' + +// identifyUser only implements perform() (no performBatch), so every fixture is single-mode. +// It authenticates via the project token in the request body, so it is unaffected by the +// actions-mixpanel-project-token-auth feature flag. +const fixtures: E2EFixture[] = [ + { + description: 'Successfully identifies a user with traits', + subscribe: 'type = "identify"', + mapping: defaultValues(identifyUser.fields), + mode: 'single', + event: createE2EEvent('identify', undefined, { + userId: 'e2e-test-user-mixpanel-identify-001', + traits: { + email: 'e2e-identify@segment.com', + firstName: 'E2E', + lastName: 'Tester', + plan: 'enterprise', + company: 'Segment' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully identifies a user and adds an anonymousId', + subscribe: 'type = "identify"', + mapping: defaultValues(identifyUser.fields), + mode: 'single', + event: createE2EEvent('identify', undefined, { + userId: 'e2e-test-user-mixpanel-identify-002', + anonymousId: '$guid:anon', + traits: { + email: 'e2e-identify-alias@segment.com' + } + }), + expect: { + status: 'success' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/mixpanel/identifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/identifyUser/__tests__/index.test.ts index 7b6ded7bed0..fd14f50eecd 100644 --- a/packages/destination-actions/src/destinations/mixpanel/identifyUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/identifyUser/__tests__/index.test.ts @@ -4,7 +4,6 @@ import Destination from '../../index' import { ApiRegions } from '../../common/utils' const testDestination = createTestIntegration(Destination) -const MIXPANEL_API_SECRET = 'test-api-key' const MIXPANEL_PROJECT_TOKEN = 'test-proj-token' const timestamp = '2021-08-17T15:21:15.449Z' @@ -32,7 +31,6 @@ describe('Mixpanel.identifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -105,7 +103,6 @@ describe('Mixpanel.identifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -130,7 +127,6 @@ describe('Mixpanel.identifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -153,7 +149,6 @@ describe('Mixpanel.identifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -182,7 +177,6 @@ describe('Mixpanel.identifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.EU } }) @@ -226,7 +220,6 @@ describe('Mixpanel.identifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.IN } }) @@ -272,7 +265,6 @@ describe('Mixpanel.identifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) expect(responses.length).toBe(2) @@ -317,7 +309,6 @@ describe('Mixpanel.identifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, sourceName: 'example segment source name' } }) @@ -364,7 +355,6 @@ describe('Mixpanel.identifyUser', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, sourceName: 'example segment source name' } }) diff --git a/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..af5cc5a6839 --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__e2e__/fixtures.e2e.ts @@ -0,0 +1,48 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import incrementProperties from '../index' + +// incrementProperties only implements perform() (no performBatch), so every fixture is single-mode. +// It authenticates via the project token in the request body and is unaffected by the +// actions-mixpanel-project-token-auth feature flag. +const fixtures: E2EFixture[] = [ + { + description: 'Successfully increments numeric user properties', + subscribe: 'type = "track"', + mapping: defaultValues(incrementProperties.fields), + mode: 'single', + event: createE2EEvent('track', 'Increment Properties', { + userId: 'e2e-test-user-mixpanel-increment-001', + properties: { + increment: { + purchases: 1, + items: 6 + } + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Rejects increment with a non-numeric value before sending', + subscribe: 'type = "track"', + mapping: defaultValues(incrementProperties.fields), + mode: 'single', + event: createE2EEvent('track', 'Increment Properties', { + userId: 'e2e-test-user-mixpanel-increment-002', + properties: { + increment: { + purchases: 'not-a-number' + } + } + }), + expect: { + status: 'error', + errorType: 'IntegrationError', + errorMessage: 'The key "purchases" was not numeric' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/index.test.ts index 4b89cd74324..ad7b3ca6660 100644 --- a/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/index.test.ts @@ -4,7 +4,6 @@ import Destination from '../../index' import { ApiRegions } from '../../common/utils' const testDestination = createTestIntegration(Destination) -const MIXPANEL_API_SECRET = 'test-api-key' const MIXPANEL_PROJECT_TOKEN = 'test-proj-token' const timestamp = '2021-08-17T15:21:15.449Z' @@ -21,7 +20,6 @@ describe('Mixpanel.incrementProperties', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.EU } }) @@ -53,7 +51,6 @@ describe('Mixpanel.incrementProperties', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.IN } }) @@ -85,7 +82,6 @@ describe('Mixpanel.incrementProperties', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) @@ -116,7 +112,6 @@ describe('Mixpanel.incrementProperties', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) @@ -157,7 +152,6 @@ describe('Mixpanel.incrementProperties', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) diff --git a/packages/destination-actions/src/destinations/mixpanel/index.ts b/packages/destination-actions/src/destinations/mixpanel/index.ts index 1bebe80d5ec..0727ca34c9c 100644 --- a/packages/destination-actions/src/destinations/mixpanel/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/index.ts @@ -81,12 +81,11 @@ const destination: DestinationDefinition = { type: 'string', required: true }, - // TODO: maybe we should just require service account instead? apiSecret: { label: 'Secret Key', description: 'Mixpanel project secret.', type: 'password', - required: true + required: false }, apiRegion: { label: 'Data Residency', @@ -115,7 +114,6 @@ const destination: DestinationDefinition = { return request(`https://mixpanel.com/api/app/validate-project-credentials/`, { method: 'post', body: JSON.stringify({ - api_secret: settings.apiSecret, project_token: settings.projectToken }) }) diff --git a/packages/destination-actions/src/destinations/mixpanel/trackEvent/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..04a096f22ea --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/__e2e__/fixtures.e2e.ts @@ -0,0 +1,303 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import trackEvent from '../index' +import { FLAGS } from '../../common/utils' + +// The Mixpanel Import Events API (/import) is exercised by trackEvent. It authenticates with the +// API Secret when the project-token-auth flag is OFF and with the Project Token when it is ON. +// Every fixture below is therefore defined once and run twice — flag OFF and flag ON — asserting +// the SAME expected output. That proves switching credentials via the flag does not change +// behaviour (both credentials must be valid for the project). +// +// `mixpanel-multistatus` must be enabled for onBatch() to return a per-item MultiStatus array, so +// the batch fixtures set it on both variants. For batch fixtures we assert the full per-item +// MultiStatus response: each item's `status`, `body`, and the transformed `sent` payload (the exact +// Mixpanel event the action built). On success Mixpanel's import API returns body "OK". +// +// NOTE ON FAILURE TYPES (per request): +// - "Type 1" = a payload that fails Segment schema validation BEFORE performBatch is called +// (here: a track event with no `event` name). It never reaches our code; it is reported at its +// original batch index with errortype PAYLOAD_VALIDATION_FAILED / errorreporter INTEGRATIONS. +// - "Type 2" = a payload that fails validation INSIDE performBatch. trackEvent's performBatch does +// NOT run any per-item code validation, so a true Type-2 failure cannot be manufactured for this +// action. The realistic post-dispatch per-item failure is a SERVER-SIDE rejection by Mixpanel +// strict mode (failed_records), surfaced with errorreporter DESTINATION. A FUTURE timestamp +// triggers it ("'properties.time' is invalid: must not be in the future"); note that a far-PAST +// timestamp is NOT rejected by Mixpanel, so the future timestamp is the reliable trigger. +// +// FIELD COVERAGE: the "all fields" fixture below populates every input field of the action so each +// one is exercised in at least one successful single request. It asserts success only; per-field +// `sent` assertions live in the batch/multistatus fixtures, which expose the transformed payload. + +const MULTISTATUS = 'mixpanel-multistatus' + +// Emit a flag-OFF and a flag-ON variant of a fixture, merging the project-token-auth flag into any +// features the fixture already declares. +function withAuthFlagVariants(fixture: E2EFixture): E2EFixture[] { + const off: E2EFixture = { + ...fixture, + description: `${fixture.description} (project-token-auth OFF)` + } + const on: E2EFixture = { + ...fixture, + description: `${fixture.description} (project-token-auth ON)`, + features: { ...(fixture.features ?? {}), [FLAGS.PROJECT_TOKEN_AUTH]: true } + } + return [off, on] +} + +// A fully-populated event exercising every input field of the trackEvent action. +const ALL_FIELDS_EVENT = { + type: 'track' as const, + event: 'E2E All Fields Event', + // messageId/timestamp are dynamic ($guid/$now) like a real event. + messageId: '$guid', + timestamp: '$now', + userId: 'e2e-user-allfields-001', + anonymousId: 'e2e-anon-allfields-001', + properties: { + customStringProp: 'customValue', + customNumberProp: 42 + }, + context: { + groupId: 'e2e-group-001', + ip: '203.0.113.5', + locale: 'en-US', + timezone: 'America/New_York', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + app: { name: 'E2E App', namespace: 'com.e2e.app', build: '42', version: '1.2.3', platform: 'iOS' }, + os: { name: 'iOS', version: '17.2' }, + device: { + id: 'e2e-dev-001', + type: 'ios', + name: 'iPhone', + manufacturer: 'Apple', + model: 'iPhone15,2', + advertisingId: 'e2e-adid-001', + adTrackingEnabled: true, + idfa: 'e2e-idfa-001' + }, + network: { bluetooth: false, carrier: 'Verizon', cellular: true, wifi: true }, + location: { country: 'US', region: 'NY' }, + library: { name: 'analytics.js', version: '2.0.0' }, + page: { url: 'https://example.com/pricing', referrer: 'https://google.com' }, + screen: { width: 1920, height: 1080, density: 2 }, + campaign: { source: 'newsletter', medium: 'email', name: 'spring-sale', term: 'discount', content: 'top-banner' }, + userAgentData: { + mobile: true, + platform: 'macOS', + architecture: 'arm', + bitness: '64', + model: 'MacBookPro', + platformVersion: '14.2', + uaFullVersion: '120.0.6099.109', + wow64: false + } + } +} + +const baseFixtures: E2EFixture[] = [ + { + description: 'Successfully tracks a single event', + subscribe: 'type = "track"', + mapping: defaultValues(trackEvent.fields), + mode: 'single', + event: createE2EEvent('track', 'E2E Button Clicked', { + userId: 'e2e-test-user-mixpanel-track-001', + properties: { + buttonId: 'cta-signup', + page: '/pricing' + } + }), + expect: { + status: 'success' + } + }, + { + // Field coverage: every input field is populated so each is exercised in a successful request. + description: 'Successfully tracks an event exercising all fields', + subscribe: 'type = "track"', + mapping: defaultValues(trackEvent.fields), + mode: 'single', + event: ALL_FIELDS_EVENT, + expect: { + status: 'success' + } + }, + { + description: 'Successfully tracks a batch of events', + subscribe: 'type = "track"', + mapping: defaultValues(trackEvent.fields), + mode: 'batchWithMultistatus', + features: { [MULTISTATUS]: true }, + events: [ + createE2EEvent('track', 'E2E Batch Event A', { + userId: 'e2e-test-user-mixpanel-track-batch-001', + properties: { plan: 'pro' } + }), + createE2EEvent('track', 'E2E Batch Event B', { + userId: 'e2e-test-user-mixpanel-track-batch-002', + properties: { plan: 'free' } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + body: 'OK', + sent: { + event: 'E2E Batch Event A', + properties: { + distinct_id: 'e2e-test-user-mixpanel-track-batch-001', + $user_id: 'e2e-test-user-mixpanel-track-batch-001', + $source: 'segment', + plan: 'pro' + } + } + }, + { + status: 200, + body: 'OK', + sent: { + event: 'E2E Batch Event B', + properties: { + distinct_id: 'e2e-test-user-mixpanel-track-batch-002', + $user_id: 'e2e-test-user-mixpanel-track-batch-002', + $source: 'segment', + plan: 'free' + } + } + } + ] + } + }, + { + // Type 1 only: a pre-performBatch validation failure (missing `event`) alongside successes. + description: 'Batch with a pre-performBatch validation failure (missing event) and successes', + subscribe: 'type = "track"', + mapping: defaultValues(trackEvent.fields), + mode: 'batchWithMultistatus', + features: { [MULTISTATUS]: true }, + events: [ + createE2EEvent('track', 'E2E Valid Event 1', { + userId: 'e2e-test-user-mixpanel-track-mix-001', + properties: { ok: true } + }), + // No event name -> required field `event` is missing -> fails schema validation before + // performBatch is reached. + createE2EEvent('track', undefined, { + userId: 'e2e-test-user-mixpanel-track-mix-002', + properties: { ok: false } + }), + createE2EEvent('track', 'E2E Valid Event 3', { + userId: 'e2e-test-user-mixpanel-track-mix-003', + properties: { ok: true } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + body: 'OK', + sent: { + event: 'E2E Valid Event 1', + properties: { + distinct_id: 'e2e-test-user-mixpanel-track-mix-001', + ok: true + } + } + }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }, + { + status: 200, + body: 'OK', + sent: { + event: 'E2E Valid Event 3', + properties: { + distinct_id: 'e2e-test-user-mixpanel-track-mix-003', + ok: true + } + } + } + ] + } + }, + { + // Comprehensive mixed batch satisfying requirement 2a: at least one pre-performBatch failure, + // at least one event that fails AFTER dispatch (Mixpanel strict-mode server-side rejection), + // and at least one event successfully delivered to Mixpanel. + description: 'Mixed batch: pre-validation failure + server-side reject + success', + subscribe: 'type = "track"', + mapping: defaultValues(trackEvent.fields), + mode: 'batchWithMultistatus', + features: { [MULTISTATUS]: true }, + events: [ + // [0] valid -> delivered to Mixpanel + createE2EEvent('track', 'E2E Mixed Valid', { + userId: 'e2e-test-user-mixpanel-track-srv-001', + properties: { ok: true } + }), + // [1] missing event name -> Type 1, fails before performBatch (INTEGRATIONS) + createE2EEvent('track', undefined, { + userId: 'e2e-test-user-mixpanel-track-srv-002' + }), + // [2] valid schema but a fixed FUTURE timestamp (year 2100) -> Mixpanel strict mode rejects + // it server-side into failed_records, surfaced with errorreporter DESTINATION. This + // timestamp is the deliberately-invalid value under test, so it is intentionally fixed + // (not $now). messageId stays dynamic. + { + ...createE2EEvent('track', 'E2E Mixed Future', { + userId: 'e2e-test-user-mixpanel-track-srv-003', + properties: { ok: true } + }), + timestamp: '2100-01-01T00:00:00.000Z' + } + ], + verboseFailureHint: + 'Index 2 expects a Mixpanel strict-mode server-side rejection (errorreporter DESTINATION) for the future (year-2100) timestamp ("must not be in the future").', + expect: { + status: 'success', + jsonContains: [ + { + // When ANY record in the batch fails strict mode, Mixpanel returns a top-level + // code 400 / status "Bad Request", and the action stamps that status onto the + // surviving success items' body (so it is "Bad Request" here, not "OK"). + status: 200, + body: 'Bad Request', + sent: { + event: 'E2E Mixed Valid', + properties: { + distinct_id: 'e2e-test-user-mixpanel-track-srv-001', + ok: true + } + } + }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }, + { + status: 400, + errortype: 'BAD_REQUEST', + errorreporter: 'DESTINATION', + errormessage: "'properties.time' is invalid: must not be in the future", + sent: { + event: 'E2E Mixed Future', + properties: { + time: 4102444800000, + distinct_id: 'e2e-test-user-mixpanel-track-srv-003' + } + }, + body: { + field: 'properties.time', + message: "'properties.time' is invalid: must not be in the future" + } + } + ] + } + } +] + +const fixtures: E2EFixture[] = baseFixtures.flatMap(withAuthFlagVariants) + +export default fixtures diff --git a/packages/destination-actions/src/destinations/mixpanel/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/__tests__/index.test.ts index 901b6195e8b..1904689a34d 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/__tests__/index.test.ts @@ -1,13 +1,18 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { Features } from '@segment/actions-core/mapping-kit' import Destination from '../../index' -import { ApiRegions, StrictMode } from '../../common/utils' +import { ApiRegions, StrictMode, FLAGS } from '../../common/utils' const testDestination = createTestIntegration(Destination) -const MIXPANEL_API_SECRET = 'test-api-key' const MIXPANEL_PROJECT_TOKEN = 'test-proj-token' +const MIXPANEL_API_SECRET = 'test-api-secret' const timestamp = '2021-08-17T15:21:15.449Z' +// Expected Basic auth header values for the /import endpoint: `Basic base64(":")`. +const projectTokenAuth = `Basic ${Buffer.from(`${MIXPANEL_PROJECT_TOKEN}:`).toString('base64')}` +const apiSecretAuth = `Basic ${Buffer.from(`${MIXPANEL_API_SECRET}:`).toString('base64')}` + const expectedProperties = { ip: '8.8.8.8', distinct_id: 'user1234', @@ -31,7 +36,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -56,7 +60,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.EU } }) @@ -81,7 +84,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.IN } }) @@ -105,8 +107,7 @@ describe('Mixpanel.trackEvent', () => { event, useDefaultMappings: true, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET + projectToken: MIXPANEL_PROJECT_TOKEN } }) expect(responses.length).toBe(1) @@ -130,7 +131,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, sourceName: 'example segment source name' } }) @@ -156,8 +156,7 @@ describe('Mixpanel.trackEvent', () => { event, useDefaultMappings: true, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET + projectToken: MIXPANEL_PROJECT_TOKEN } }) expect(responses.length).toBe(1) @@ -183,7 +182,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -208,7 +206,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US, strictMode: StrictMode.ON } @@ -234,7 +231,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US, strictMode: StrictMode.OFF } @@ -271,8 +267,7 @@ describe('Mixpanel.trackEvent', () => { const responses = await testDestination.testAction('trackEvent', { event, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET + projectToken: MIXPANEL_PROJECT_TOKEN }, useDefaultMappings: true, mapping: { @@ -306,7 +301,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -325,3 +319,47 @@ describe('Mixpanel.trackEvent', () => { ]) }) }) + +describe('Mixpanel.trackEvent /import auth credential', () => { + // nock only matches the request when the authorization header equals the expected credential, so a + // 200 response (responses.length === 1) proves the correct Basic-auth credential was sent. + const runExpectingAuth = async (expectedAuth: string, settings: Record, features?: Features) => { + const event = createTestEvent({ timestamp, event: 'Test Event' }) + nock('https://api.mixpanel.com').post('/import?strict=1').matchHeader('authorization', expectedAuth).reply(200, {}) + const responses = await testDestination.testAction('trackEvent', { + event, + useDefaultMappings: true, + settings: settings as never, + features + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + } + + it('uses the API secret when the project-token-auth flag is OFF', async () => { + await runExpectingAuth(apiSecretAuth, { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiSecret: MIXPANEL_API_SECRET, + apiRegion: ApiRegions.US + }) + }) + + it('uses the project token when the project-token-auth flag is ON', async () => { + await runExpectingAuth( + projectTokenAuth, + { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiSecret: MIXPANEL_API_SECRET, + apiRegion: ApiRegions.US + }, + { [FLAGS.PROJECT_TOKEN_AUTH]: true } + ) + }) + + it('falls back to the project token when the flag is OFF and no API secret is set', async () => { + await runExpectingAuth(projectTokenAuth, { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiRegion: ApiRegions.US + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts index 2752f197fb0..5d58aaa44e1 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts @@ -2,10 +2,9 @@ import { ActionDefinition, RequestClient } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { MixpanelEvent } from '../mixpanel-types' -import { getApiServerUrl } from '../common/utils' +import { getApiServerUrl, getImportApiCredential, MixpanelTrackApiResponseType, handleMixPanelApiResponse } from '../common/utils' import { getEventProperties } from './functions' import { eventProperties } from '../mixpanel-properties' -import { MixpanelTrackApiResponseType, handleMixPanelApiResponse } from '../common/utils' import { Features } from '@segment/actions-core/mapping-kit' const getEventFromPayload = (payload: Payload, settings: Settings): MixpanelEvent => { @@ -24,7 +23,7 @@ const processData = async (request: RequestClient, settings: Settings, payload: }) const throwHttpErrors = features && features['mixpanel-multistatus'] ? false : true - const response = await callMixpanelApi(request, settings, events, throwHttpErrors) + const response = await callMixpanelApi(request, settings, events, throwHttpErrors, features) if (features && features['mixpanel-multistatus']) { return handleMixPanelApiResponse(payload.length, response, events) } @@ -35,15 +34,17 @@ const callMixpanelApi = async ( request: RequestClient, settings: Settings, events: MixpanelEvent[], - throwHttpErrors: boolean + throwHttpErrors: boolean, + features?: Features ) => { + const credential = getImportApiCredential(settings, features) return await request( `${getApiServerUrl(settings.apiRegion)}/import?strict=${settings.strictMode ?? `1`}`, { method: 'post', json: events, headers: { - authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` + authorization: `Basic ${Buffer.from(`${credential}:`).toString('base64')}` }, throwHttpErrors: throwHttpErrors } diff --git a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..285f4df1a35 --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__e2e__/fixtures.e2e.ts @@ -0,0 +1,331 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import trackPurchase from '../index' +import { FLAGS } from '../../common/utils' + +// trackPurchase posts to the Mixpanel Import Events API (/import), the same flag-gated endpoint as +// trackEvent. Each fixture runs twice (project-token-auth OFF and ON) asserting the same output. +// +// generatePurchaseEventPerProduct is forced OFF in most fixtures so that each input payload maps to +// exactly one Mixpanel event. This keeps the per-item MultiStatus indexes aligned 1:1 with the batch +// payload indexes (with it ON, one payload fans out into N "Product Purchased" events and the +// server-side failed_records indexes would no longer line up with payload positions). One dedicated +// fixture exercises the ON (fan-out) path and asserts the resulting "Product Purchased" events. +// +// For batch fixtures we assert the full per-item MultiStatus response: each item's `status`, `body`, +// and the transformed `sent` payload. Note trackPurchase's `sent` is an ARRAY of Mixpanel events +// (the Order Completed event, plus any per-product events), unlike trackEvent where it is a single +// object. On success Mixpanel's import API returns body "OK". +// +// FAILURE TYPES (per request): same as trackEvent — trackPurchase's performBatch runs no per-item +// code validation, so a true "fails inside performBatch" (Type 2) cannot be manufactured. We cover +// Type 1 (missing required `event`, fails before performBatch) and a server-side strict-mode +// rejection (a FUTURE timestamp -> failed_records "must not be in the future", errorreporter +// DESTINATION; note a far-PAST timestamp is NOT rejected by Mixpanel). +// +// FIELD COVERAGE: the "all fields" fixture populates every input field of the action (including all +// top-level purchase fields and every product sub-field) so each is exercised in a successful single +// request. It asserts success only; per-field `sent` assertions live in the batch/multistatus fixtures. + +const MULTISTATUS = 'mixpanel-multistatus' + +const purchaseMapping = { + ...defaultValues(trackPurchase.fields), + generatePurchaseEventPerProduct: false +} + +function withAuthFlagVariants(fixture: E2EFixture): E2EFixture[] { + const off: E2EFixture = { + ...fixture, + description: `${fixture.description} (project-token-auth OFF)` + } + const on: E2EFixture = { + ...fixture, + description: `${fixture.description} (project-token-auth ON)`, + features: { ...(fixture.features ?? {}), [FLAGS.PROJECT_TOKEN_AUTH]: true } + } + return [off, on] +} + +// Fully-populated Order Completed event exercising every trackPurchase input field, including all +// top-level purchase fields and every product sub-field. messageId/timestamp are dynamic ($guid/$now) +// like a real event. +const ALL_FIELDS_EVENT = { + type: 'track' as const, + event: 'Order Completed', + messageId: '$guid', + timestamp: '$now', + userId: 'e2e-user-purchase-allfields-001', + anonymousId: 'e2e-anon-purchase-allfields-001', + properties: { + order_id: 'E2E-ORDER-ALLFIELDS-001', + affiliation: 'E2E Store', + subtotal: 40.0, + total: 49.98, + revenue: 49.98, + shipping: 5.0, + tax: 4.0, + discount: 2.0, + coupon: 'SAVE10', + currency: 'USD', + order_number: 'ON-001', + products: [ + { + product_id: 'SKU-1', + sku: 'SKU-1', + category: 'Tools', + name: 'Widget', + brand: 'Acme', + variant: 'Blue', + price: 19.99, + quantity: 2, + coupon: 'PROD10', + position: 1, + url: 'https://example.com/products/widget', + image_url: 'https://example.com/products/widget.jpg' + } + ] + }, + context: { + ip: '203.0.113.5', + library: { name: 'analytics.js', version: '2.0.0' } + } +} + +const baseFixtures: E2EFixture[] = [ + { + description: 'Successfully tracks a single Order Completed purchase', + subscribe: 'type = "track"', + mapping: purchaseMapping, + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-mixpanel-purchase-001', + properties: { + order_id: '$guid:orderId', + total: 49.99, + currency: 'USD', + products: [{ product_id: 'sku-001', price: 49.99, quantity: 1, name: 'Widget' }] + } + }), + expect: { + status: 'success' + } + }, + { + // Field coverage: every input field is populated so each is exercised in a successful request. + description: 'Successfully tracks an Order Completed exercising all fields', + subscribe: 'type = "track"', + mapping: purchaseMapping, + mode: 'single', + event: ALL_FIELDS_EVENT, + expect: { + status: 'success' + } + }, + { + // Fan-out path: generatePurchaseEventPerProduct ON emits one "Order Completed" plus one + // "Product Purchased" event per product, all under a single payload's `sent` array. + description: 'Successfully tracks a purchase with per-product events (generatePurchaseEventPerProduct)', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(trackPurchase.fields), + generatePurchaseEventPerProduct: true + }, + mode: 'batchWithMultistatus', + features: { [MULTISTATUS]: true }, + events: [ + createE2EEvent('track', 'Order Completed', { + userId: 'e2e-user-purchase-fanout-001', + properties: { + order_id: 'E2E-ORDER-FANOUT-001', + total: 49.98, + currency: 'USD', + products: [ + { product_id: 'SKU-1', name: 'Widget', price: 19.99, quantity: 1 }, + { product_id: 'SKU-2', name: 'Gadget', price: 29.99, quantity: 1 } + ] + } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + body: 'OK', + sent: [ + { event: 'Order Completed', properties: { order_id: 'E2E-ORDER-FANOUT-001', total: 49.98 } }, + { event: 'Product Purchased', properties: { product_id: 'SKU-1', name: 'Widget', price: 19.99 } }, + { event: 'Product Purchased', properties: { product_id: 'SKU-2', name: 'Gadget', price: 29.99 } } + ] + } + ] + } + }, + { + description: 'Successfully tracks a batch of purchases', + subscribe: 'type = "track"', + mapping: purchaseMapping, + mode: 'batchWithMultistatus', + features: { [MULTISTATUS]: true }, + events: [ + createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-mixpanel-purchase-batch-001', + properties: { order_id: 'E2E-ORDER-BATCH-001', total: 19.99, currency: 'USD' } + }), + createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-mixpanel-purchase-batch-002', + properties: { order_id: 'E2E-ORDER-BATCH-002', total: 29.99, currency: 'USD' } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + body: 'OK', + sent: [ + { + event: 'Order Completed', + properties: { + distinct_id: 'e2e-test-user-mixpanel-purchase-batch-001', + order_id: 'E2E-ORDER-BATCH-001', + total: 19.99, + currency: 'USD' + } + } + ] + }, + { + status: 200, + body: 'OK', + sent: [ + { + event: 'Order Completed', + properties: { + distinct_id: 'e2e-test-user-mixpanel-purchase-batch-002', + order_id: 'E2E-ORDER-BATCH-002', + total: 29.99, + currency: 'USD' + } + } + ] + } + ] + } + }, + { + // Type 1 only: a pre-performBatch validation failure (missing `event`) alongside successes. + description: 'Batch with a pre-performBatch validation failure (missing event) and successes', + subscribe: 'type = "track"', + mapping: purchaseMapping, + mode: 'batchWithMultistatus', + features: { [MULTISTATUS]: true }, + events: [ + createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-mixpanel-purchase-mix-001', + properties: { order_id: 'E2E-ORDER-MIX-001', total: 9.99, currency: 'USD' } + }), + // No event name -> required `event` missing -> fails before performBatch. + createE2EEvent('track', undefined, { + userId: 'e2e-test-user-mixpanel-purchase-mix-002', + properties: { order_id: '$guid:orderMix2', total: 0, currency: 'USD' } + }), + createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-mixpanel-purchase-mix-003', + properties: { order_id: 'E2E-ORDER-MIX-003', total: 14.99, currency: 'USD' } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { + status: 200, + body: 'OK', + sent: [ + { + event: 'Order Completed', + properties: { order_id: 'E2E-ORDER-MIX-001', total: 9.99 } + } + ] + }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }, + { + status: 200, + body: 'OK', + sent: [ + { + event: 'Order Completed', + properties: { order_id: 'E2E-ORDER-MIX-003', total: 14.99 } + } + ] + } + ] + } + }, + { + // Comprehensive mixed batch (requirement 3, mirroring 2a): pre-validation failure + + // server-side strict-mode rejection + success. + description: 'Mixed batch: pre-validation failure + server-side reject + success', + subscribe: 'type = "track"', + mapping: purchaseMapping, + mode: 'batchWithMultistatus', + features: { [MULTISTATUS]: true }, + events: [ + // [0] valid -> delivered + createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-mixpanel-purchase-srv-001', + properties: { order_id: 'E2E-ORDER-SRV-001', total: 24.99, currency: 'USD' } + }), + // [1] missing event name -> Type 1 (INTEGRATIONS) + createE2EEvent('track', undefined, { + userId: 'e2e-test-user-mixpanel-purchase-srv-002', + properties: { order_id: '$guid:orderSrv2', total: 0, currency: 'USD' } + }), + // [2] valid schema, fixed FUTURE timestamp (the invalid value under test; messageId stays + // dynamic) -> Mixpanel strict-mode server-side reject (DESTINATION) + { + ...createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-mixpanel-purchase-srv-003', + properties: { order_id: '$guid:orderSrv3', total: 39.99, currency: 'USD' } + }), + timestamp: '2100-01-01T00:00:00.000Z' + } + ], + verboseFailureHint: + 'Index 2 expects a Mixpanel strict-mode server-side rejection (errorreporter DESTINATION) for the future (year-2100) timestamp ("must not be in the future").', + expect: { + status: 'success', + jsonContains: [ + { + // When ANY record in the batch fails strict mode, Mixpanel returns a top-level + // code 400 / status "Bad Request", and the action stamps that status onto the + // surviving success items' body (so it is "Bad Request" here, not "OK"). + status: 200, + body: 'Bad Request', + sent: [ + { + event: 'Order Completed', + properties: { order_id: 'E2E-ORDER-SRV-001', total: 24.99 } + } + ] + }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }, + { + status: 400, + errortype: 'BAD_REQUEST', + errorreporter: 'DESTINATION', + errormessage: "'properties.time' is invalid: must not be in the future", + body: { + field: 'properties.time', + message: "'properties.time' is invalid: must not be in the future" + } + } + ] + } + } +] + +const fixtures: E2EFixture[] = baseFixtures.flatMap(withAuthFlagVariants) + +export default fixtures diff --git a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__tests__/index.test.ts index d3a83cdbaed..e2212f55774 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__tests__/index.test.ts @@ -1,14 +1,19 @@ import nock from 'nock' import { createTestEvent, createTestIntegration, omit } from '@segment/actions-core' +import { Features } from '@segment/actions-core/mapping-kit' import Destination from '../../index' -import { ApiRegions, StrictMode } from '../../common/utils' +import { ApiRegions, StrictMode, FLAGS } from '../../common/utils' import { SegmentEvent } from '@segment/actions-core' const testDestination = createTestIntegration(Destination) -const MIXPANEL_API_SECRET = 'test-api-key' const MIXPANEL_PROJECT_TOKEN = 'test-proj-token' +const MIXPANEL_API_SECRET = 'test-api-secret' const timestamp = '2021-08-17T15:21:15.449Z' +// Expected Basic auth header values for the /import endpoint: `Basic base64(":")`. +const projectTokenAuth = `Basic ${Buffer.from(`${MIXPANEL_PROJECT_TOKEN}:`).toString('base64')}` +const apiSecretAuth = `Basic ${Buffer.from(`${MIXPANEL_API_SECRET}:`).toString('base64')}` + const orderCompletedEvent: Partial = { event: 'Order Completed', messageId: '112c2a3c-7242-4327-9090-48a89de6a4110', @@ -64,7 +69,6 @@ const mapping = { const settingsObj = { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } @@ -98,7 +102,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.EU } }) @@ -116,7 +119,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.IN } }) @@ -133,8 +135,7 @@ describe('Mixpanel.trackPurchase', () => { event, useDefaultMappings: true, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET + projectToken: MIXPANEL_PROJECT_TOKEN } }) expect(responses.length).toBe(1) @@ -152,7 +153,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, sourceName } }) @@ -179,8 +179,7 @@ describe('Mixpanel.trackPurchase', () => { mapping, useDefaultMappings: true, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET + projectToken: MIXPANEL_PROJECT_TOKEN } }) expect(responses.length).toBe(1) @@ -199,7 +198,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -217,7 +215,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US, strictMode: StrictMode.ON } @@ -236,7 +233,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US, strictMode: StrictMode.OFF } @@ -268,7 +264,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -446,3 +441,48 @@ describe('Mixpanel.trackPurchase', () => { ) }) }) + +describe('Mixpanel.trackPurchase /import auth credential', () => { + // nock only matches the request when the authorization header equals the expected credential, so a + // 200 response (responses.length === 1) proves the correct Basic-auth credential was sent. + const runExpectingAuth = async (expectedAuth: string, settings: Record, features?: Features) => { + const event = createTestEvent(orderCompletedEvent) + nock('https://api.mixpanel.com').post('/import?strict=1').matchHeader('authorization', expectedAuth).reply(200, {}) + const responses = await testDestination.testAction('trackPurchase', { + event, + mapping, + useDefaultMappings: true, + settings: settings as never, + features + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + } + + it('uses the API secret when the project-token-auth flag is OFF', async () => { + await runExpectingAuth(apiSecretAuth, { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiSecret: MIXPANEL_API_SECRET, + apiRegion: ApiRegions.US + }) + }) + + it('uses the project token when the project-token-auth flag is ON', async () => { + await runExpectingAuth( + projectTokenAuth, + { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiSecret: MIXPANEL_API_SECRET, + apiRegion: ApiRegions.US + }, + { [FLAGS.PROJECT_TOKEN_AUTH]: true } + ) + }) + + it('falls back to the project token when the flag is OFF and no API secret is set', async () => { + await runExpectingAuth(projectTokenAuth, { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiRegion: ApiRegions.US + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts index 8b14e32daba..c0e1816ee82 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts @@ -2,7 +2,13 @@ import { ActionDefinition, RequestClient, omit, JSONLikeObject } from '@segment/ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { MixpanelEvent } from '../mixpanel-types' -import { getApiServerUrl, cheapGuid, MixpanelTrackApiResponseType, handleMixPanelApiResponse } from '../common/utils' +import { + getApiServerUrl, + cheapGuid, + getImportApiCredential, + MixpanelTrackApiResponseType, + handleMixPanelApiResponse +} from '../common/utils' import { getEventProperties } from '../trackEvent/functions' import { eventProperties, productsProperties } from '../mixpanel-properties' import dayjs from '../../../lib/dayjs' @@ -61,7 +67,7 @@ const processData = async (request: RequestClient, settings: Settings, payload: }) const throwHttpErrors = features && features['mixpanel-multistatus'] ? false : true - const response = await callMixpanelApi(request, settings, events, throwHttpErrors) + const response = await callMixpanelApi(request, settings, events, throwHttpErrors, features) if (features && features['mixpanel-multistatus']) { return handleMixPanelApiResponse(payload.length, response, sentEvents) } @@ -72,15 +78,17 @@ const callMixpanelApi = async ( request: RequestClient, settings: Settings, events: MixpanelEvent[], - throwHttpErrors: boolean + throwHttpErrors: boolean, + features?: Features ) => { + const credential = getImportApiCredential(settings, features) return await request( `${getApiServerUrl(settings.apiRegion)}/import?strict=${settings.strictMode ?? `1`}`, { method: 'post', json: events, headers: { - authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` + authorization: `Basic ${Buffer.from(`${credential}:`).toString('base64')}` }, throwHttpErrors: throwHttpErrors } diff --git a/packages/destination-actions/src/destinations/pendo-audiences/__e2e__/index.ts b/packages/destination-actions/src/destinations/pendo-audiences/__e2e__/index.ts new file mode 100644 index 00000000000..26991326acb --- /dev/null +++ b/packages/destination-actions/src/destinations/pendo-audiences/__e2e__/index.ts @@ -0,0 +1,41 @@ +import type { E2EAudienceDestinationConfig, E2ETeardownAudienceContext } from '@segment/actions-core' +import { REGIONS, SEGMENT_ENDPOINT } from '../constants' + +const audienceName = `e2e_test_audience_${Date.now()}` + +function domainForRegion(regionName: unknown): string { + const region = Object.values(REGIONS).find((r) => r.name === regionName) + return region?.domain ?? REGIONS.DEFAULT.domain +} + +export const config: E2EAudienceDestinationConfig = { + settings: { + integrationKey: { $env: 'E2E_PENDO_AUDIENCES_INTEGRATION_KEY' }, + // DEFAULT region (US) — REGIONS.DEFAULT.name resolves to https://app.pendo.io + region: REGIONS.DEFAULT.name + }, + audience: { + audienceName, + audienceSettings: {}, + createAudience: true, + getAudience: true, + teardown: async (context: E2ETeardownAudienceContext) => { + const { settings, externalAudienceId } = context + const integrationKey = settings.integrationKey as string + const domain = domainForRegion(settings.region) + + const response = await fetch(`${domain}/${SEGMENT_ENDPOINT}/${externalAudienceId}`, { + method: 'DELETE', + headers: { + 'x-pendo-integration-key': integrationKey, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + const body = await response.text() + throw new Error(`Failed to delete Pendo segment ${externalAudienceId}: ${response.status} ${body}`) + } + } + } +} diff --git a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..ea8abbc7724 --- /dev/null +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__e2e__/fixtures.e2e.ts @@ -0,0 +1,176 @@ +import type { E2EFixture, SegmentEvent } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' +import syncAudience from '../index' + +const COMPUTATION_KEY = 'e2e_test_audience' +const COMPUTATION_ID = 'aud_e2e_pendo_001' + +const FAILURE_HINT = + 'Ensure the Pendo Integration Key has segment read/write permissions and E2E_PENDO_AUDIENCES_REGION matches the account region.' + +// An identify event whose traits omit the computation key, so core cannot resolve +// audience membership (true/false). Used to exercise the InvalidAudienceMembershipError path. +function createNoMembershipEvent(userId: string): SegmentEvent { + return { + type: 'identify', + messageId: '$guid', + timestamp: '$now', + userId, + traits: {}, + context: { + personas: { + computation_class: 'audience', + computation_key: COMPUTATION_KEY, + computation_id: COMPUTATION_ID, + external_audience_id: '$externalAudienceId' + } + } + } as unknown as SegmentEvent +} + +const fixtures: E2EFixture[] = [ + { + description: 'Add a visitor to the Pendo segment via identify event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: '$guid:singleAddUser' + }), + // A freshly-created segment can briefly return 409 ("operation in progress"); retries give it + // time to settle so the write succeeds. Mirrors how Segment retries the 429 we map 409 to. + retries: 9, + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Remove a visitor from the Pendo segment via identify event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: '$guid:singleRemoveUser' + }), + retries: 9, + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: + 'Batch with successful add + remove, a missing-visitorId failure, and an unresolvable-membership failure', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'batchWithMultistatus', + events: [ + // idx 0 — valid add → success + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: '$guid:batchAddUser' + }), + // idx 1 — valid remove → success + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: '$guid:batchRemoveUser' + }), + // idx 2 — no userId, so visitorId cannot be resolved → validation failure + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + anonymousId: '$guid:batchAnonUser' + }), + // idx 3 — has a visitorId but no membership boolean → InvalidAudienceMembershipError + createNoMembershipEvent('$guid:batchNoMembershipUser') + ], + // Visitor IDs are random per run ($guid), so we assert the per-row status plus the + // add/remove operation rather than exact IDs. A freshly-created segment can briefly return + // 409 ("operation in progress"); retries give it time to settle so the writes succeed. + // Pendo returns 200 for a successful add and 202 for a successful remove. + retries: 9, + expect: { + status: 'success', + jsonContains: [ + { status: 200, body: { patch: [{ op: 'add', path: '/visitors' }] } }, + { status: 202, body: { patch: [{ op: 'remove', path: '/visitors' }] } }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED' }, + { status: 400, errormessage: 'Unable to determine audience membership for this event' } + ] + }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch with four valid adds to the same audience', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: '$guid:batchValidUser1' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: '$guid:batchValidUser2' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: '$guid:batchValidUser3' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: '$guid:batchValidUser4' + }) + ], + // All four are adds to the same audience, so they collapse into a single add operation and + // each row reports Pendo's add success code (200). + retries: 9, + expect: { + status: 'success', + jsonContains: [ + { status: 200, body: { patch: [{ op: 'add', path: '/visitors' }] } }, + { status: 200, body: { patch: [{ op: 'add', path: '/visitors' }] } }, + { status: 200, body: { patch: [{ op: 'add', path: '/visitors' }] } }, + { status: 200, body: { patch: [{ op: 'add', path: '/visitors' }] } } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/perform-batch.test.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-batch.test.ts similarity index 50% rename from packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/perform-batch.test.ts rename to packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-batch.test.ts index 297a3680a0c..53bb74a7f73 100644 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/perform-batch.test.ts +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-batch.test.ts @@ -1,5 +1,5 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, RetryableError } from '@segment/actions-core' import type { SegmentEvent } from '@segment/actions-core' import Destination from '../../index' import { REGIONS, SEGMENT_ENDPOINT } from '../../constants' @@ -32,8 +32,6 @@ function makeEvent(userId: string, audienceValue: boolean, type: 'identify' | 't const baseMapping = { visitorId: { '@path': '$.userId' }, - traitsOrProperties: { '@path': '$.traits' }, - segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID, enable_batching: false } @@ -64,18 +62,18 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses.length).toBe(3) expect(responses[0]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] }, - body: { visitorId: 'user1', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } }) expect(responses[1]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user2'] }] }, - body: { visitorId: 'user2', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user2', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user2'] }] } }) expect(responses[2]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user3'] }] }, - body: { visitorId: 'user3', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user3', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user3'] }] } }) }) }) @@ -99,13 +97,13 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'remove', path: '/visitors', value: ['user1'] }] }, - body: { visitorId: 'user1', traitsOrProperties: { test_audience: false }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user1'] }] } }) expect(responses[1]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] }, - body: { visitorId: 'user2', traitsOrProperties: { test_audience: false }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user2', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] } }) }) }) @@ -137,27 +135,107 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses.length).toBe(4) expect(responses[0]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] }, - body: { visitorId: 'user1', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } }) expect(responses[1]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] }, - body: { visitorId: 'user2', traitsOrProperties: { test_audience: false }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user2', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] } }) expect(responses[2]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user3'] }] }, - body: { visitorId: 'user3', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user3', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user3'] }] } }) expect(responses[3]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'remove', path: '/visitors', value: ['user4'] }] }, - body: { visitorId: 'user4', traitsOrProperties: { test_audience: false }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user4', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user4'] }] } }) }) }) + describe('executeBatch - track events', () => { + it('should resolve membership from track event properties and PATCH add/remove accordingly', async () => { + const expectedPatchJSON = { + patch: [ + { op: 'add', path: '/visitors', value: ['user1'] }, + { op: 'remove', path: '/visitors', value: ['user2'] } + ] + } + + nock(REGIONS.DEFAULT.domain) + .patch(`${segmentBase}/visitor`, expectedPatchJSON) + .reply(200, { + multistatus: [ + { status: 200, message: 'success', operation: 'add' }, + { status: 200, message: 'success', operation: 'remove' } + ] + }) + + const responses = await testDestination.executeBatch('syncAudience', { + events: [makeEvent('user1', true, 'track'), makeEvent('user2', false, 'track')], + settings, + mapping: batchMapping + }) + + expect(responses.length).toBe(2) + expect(responses[0]).toMatchObject({ + status: 200, + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } + }) + expect(responses[1]).toMatchObject({ + status: 200, + sent: { visitorId: 'user2', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] } + }) + }) + + it('should return a 400 error for a track event with no membership in properties', async () => { + const expectedPatchJSON = { + patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] + } + + nock(REGIONS.DEFAULT.domain) + .patch(`${segmentBase}/visitor`, expectedPatchJSON) + .reply(200, { multistatus: [{ status: 200, message: 'success', operation: 'add' }] }) + + const events: SegmentEvent[] = [ + makeEvent('user1', true, 'track'), + createTestEvent({ + type: 'track', + event: 'Audience Entered', + userId: 'user2', + properties: {}, // computation_key missing from properties → membership undeterminable + context: { + personas: { computation_class: 'audience', computation_key: 'test_audience', external_audience_id: SEGMENT_ID } + } + }) + ] + + const responses = await testDestination.executeBatch('syncAudience', { + events, + settings, + mapping: batchMapping + }) + + expect(responses.length).toBe(2) + expect(responses[0]).toMatchObject({ + status: 200, + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } + }) + expect(responses[1]).toMatchObject({ + status: 400, + errormessage: 'Unable to determine audience membership for this event', + sent: { visitorId: 'user2', segmentAudienceId: SEGMENT_ID, enable_batching: true } + }) + expect(responses[1]).not.toHaveProperty('body') + }) + }) + describe('executeBatch - validation errors', () => { it('should return a schema error for a payload with undefined visitorId and succeed for the valid one', async () => { const expectedPatchJSON = { @@ -170,14 +248,20 @@ describe('Pendo Audiences - syncAudience', () => { const events: SegmentEvent[] = [ createTestEvent({ + type: 'identify', userId: 'user1', traits: { test_audience: true, customId: 'user1' }, - context: { personas: { computation_key: 'test_audience', external_audience_id: SEGMENT_ID } } + context: { + personas: { computation_class: 'audience', computation_key: 'test_audience', external_audience_id: SEGMENT_ID } + } }), createTestEvent({ + type: 'identify', userId: 'user2', traits: { test_audience: true }, // no customId → visitorId resolves to undefined - context: { personas: { computation_key: 'test_audience', external_audience_id: SEGMENT_ID } } + context: { + personas: { computation_class: 'audience', computation_key: 'test_audience', external_audience_id: SEGMENT_ID } + } }) ] @@ -190,8 +274,8 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] }, - body: { visitorId: 'user1', traitsOrProperties: { test_audience: true, customId: 'user1' }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } }) expect(responses[1]).toMatchObject({ status: 400, @@ -202,7 +286,7 @@ describe('Pendo Audiences - syncAudience', () => { it('should return a 400 error for all payloads when visitorId is empty string', async () => { // Empty strings survive schema validation (required only rejects undefined/null) and - // reach our custom if(!visitorId) guard in sendBatch + // reach our custom if(!visitorId) guard in send const responses = await testDestination.executeBatch('syncAudience', { events: [makeEvent('user1', true)], settings, @@ -213,8 +297,10 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 400, errormessage: 'Visitor ID is required', - body: { visitorId: '', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: '', segmentAudienceId: SEGMENT_ID, enable_batching: true } }) + // Validation error never reached Pendo, so no request body was sent to the destination + expect(responses[0]).not.toHaveProperty('body') }) it('should return a 400 error for all payloads when segmentAudienceId is empty string', async () => { @@ -228,26 +314,77 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 400, errormessage: 'Missing Pendo Segment ID', - body: { visitorId: 'user1', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: '' } + sent: { visitorId: 'user1', segmentAudienceId: '', enable_batching: true } }) + expect(responses[0]).not.toHaveProperty('body') expect(responses[1]).toMatchObject({ status: 400, errormessage: 'Missing Pendo Segment ID', - body: { visitorId: 'user2', traitsOrProperties: { test_audience: false }, segmentAudienceKey: 'test_audience', segmentAudienceId: '' } + sent: { visitorId: 'user2', segmentAudienceId: '', enable_batching: true } }) + expect(responses[1]).not.toHaveProperty('body') }) it('should return a schema error for all payloads when segmentAudienceId is missing', async () => { // No nock mock needed — performBatch is never called when all payloads fail schema validation + // Omit segmentAudienceId entirely so the field is absent and fails schema validation + const { segmentAudienceId, ...mappingWithoutSegmentId } = batchMapping const responses = await testDestination.executeBatch('syncAudience', { events: [makeEvent('user1', true), makeEvent('user2', false)], settings, - mapping: { ...batchMapping, segmentAudienceId: undefined } + mapping: mappingWithoutSegmentId }) expect(responses.length).toBe(2) - expect(responses[0]).toMatchObject({ status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: "The root value is missing the required field 'segmentAudienceId'." }) - expect(responses[1]).toMatchObject({ status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: "The root value is missing the required field 'segmentAudienceId'." }) + expect(responses[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'segmentAudienceId'." + }) + expect(responses[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'segmentAudienceId'." + }) + }) + + it('should return a 400 error when audience membership cannot be determined', async () => { + const expectedPatchJSON = { + patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] + } + + nock(REGIONS.DEFAULT.domain) + .patch(`${segmentBase}/visitor`, expectedPatchJSON) + .reply(200, { multistatus: [{ status: 200, message: 'success', operation: 'add' }] }) + + const events: SegmentEvent[] = [ + makeEvent('user1', true), + createTestEvent({ + type: 'identify', + userId: 'user2', + // computation_class is not "audience", so core cannot resolve membership for this event + context: { personas: { computation_key: 'test_audience', external_audience_id: SEGMENT_ID } } + }) + ] + + const responses = await testDestination.executeBatch('syncAudience', { + events, + settings, + mapping: batchMapping + }) + + expect(responses.length).toBe(2) + expect(responses[0]).toMatchObject({ + status: 200, + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } + }) + expect(responses[1]).toMatchObject({ + status: 400, + errormessage: 'Unable to determine audience membership for this event', + sent: { visitorId: 'user2', segmentAudienceId: SEGMENT_ID, enable_batching: true } + }) + expect(responses[1]).not.toHaveProperty('body') }) }) @@ -265,14 +402,14 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 500, errormessage: 'Internal Server Error', - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] }, - body: { visitorId: 'user1', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } }) expect(responses[1]).toMatchObject({ status: 500, errormessage: 'Internal Server Error', - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user2'] }] }, - body: { visitorId: 'user2', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user2', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user2'] }] } }) }) @@ -289,14 +426,14 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 403, errormessage: 'Forbidden', - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] }, - body: { visitorId: 'user1', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } }) expect(responses[1]).toMatchObject({ status: 403, errormessage: 'Forbidden', - sent: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] }, - body: { visitorId: 'user2', traitsOrProperties: { test_audience: false }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user2', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] } }) }) @@ -320,14 +457,30 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 400, errormessage: 'Error adding visitor to segment', - sent: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] }, - body: { visitorId: 'user1', traitsOrProperties: { test_audience: true }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user1', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } }) expect(responses[1]).toMatchObject({ status: 200, - sent: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] }, - body: { visitorId: 'user2', traitsOrProperties: { test_audience: false }, segmentAudienceKey: 'test_audience', segmentAudienceId: SEGMENT_ID } + sent: { visitorId: 'user2', segmentAudienceId: SEGMENT_ID, enable_batching: true }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] } }) }) + + it('should throw a RetryableError (429) for the whole batch when the API returns 409', async () => { + // 409 is transient ("operation in progress"); the entire PATCH failed, so we retry the whole + // batch by throwing rather than marking individual items as failed. + nock(REGIONS.DEFAULT.domain).patch(`${segmentBase}/visitor`).reply(409, { message: 'Conflict' }) + + const promise = testDestination.executeBatch('syncAudience', { + events: [makeEvent('user1', true), makeEvent('user2', false)], + settings, + mapping: batchMapping + }) + + await expect(promise).rejects.toThrow(RetryableError) + await expect(promise).rejects.toThrow('Pendo returned a 409. Segment is returning a 429 to trigger a retry.') + await expect(promise).rejects.toMatchObject({ status: 429, code: 'RETRYABLE_ERROR' }) + }) }) }) diff --git a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-single.test.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-single.test.ts new file mode 100644 index 00000000000..97e75e3dcb4 --- /dev/null +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-single.test.ts @@ -0,0 +1,151 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, RetryableError } from '@segment/actions-core' +import Destination from '../../index' +import { REGIONS, SEGMENT_ENDPOINT } from '../../constants' + +const testDestination = createTestIntegration(Destination) + +const settings = { + integrationKey: 'test-integration-key', + region: REGIONS.DEFAULT.name +} + +const SEGMENT_ID = 'seg-abc123' +const segmentBase = `/${SEGMENT_ENDPOINT}/${SEGMENT_ID}` + +const baseMapping = { + visitorId: { '@path': '$.userId' }, + segmentAudienceId: SEGMENT_ID, + enable_batching: false +} + +function makeEvent(userId: string, audienceValue: boolean, type: 'identify' | 'track' = 'identify') { + return createTestEvent({ + type, + userId, + traits: type === 'identify' ? { test_audience: audienceValue } : undefined, + properties: type === 'track' ? { test_audience: audienceValue } : undefined, + context: { + personas: { + computation_class: 'audience', + computation_key: 'test_audience', + external_audience_id: SEGMENT_ID + } + } + }) +} + +describe('Pendo Audiences - syncAudience perform', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('should add a visitor to the audience successfully', async () => { + nock(REGIONS.DEFAULT.domain) + .patch(`${segmentBase}/visitor`, { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] }) + .reply(200, { multistatus: [{ status: 200, message: 'success', operation: 'add' }] }) + + const responses = await testDestination.testAction('syncAudience', { + event: makeEvent('user1', true), + settings, + mapping: baseMapping + }) + + expect(responses[0].status).toBe(200) + }) + + it('should remove a visitor from the audience successfully', async () => { + nock(REGIONS.DEFAULT.domain) + .patch(`${segmentBase}/visitor`, { patch: [{ op: 'remove', path: '/visitors', value: ['user1'] }] }) + .reply(200, { multistatus: [{ status: 200, message: 'success', operation: 'remove' }] }) + + const responses = await testDestination.testAction('syncAudience', { + event: makeEvent('user1', false), + settings, + mapping: baseMapping + }) + + expect(responses[0].status).toBe(200) + }) + + it('should add a visitor from a track event successfully', async () => { + nock(REGIONS.DEFAULT.domain) + .patch(`${segmentBase}/visitor`, { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] }) + .reply(200, { multistatus: [{ status: 200, message: 'success', operation: 'add' }] }) + + const responses = await testDestination.testAction('syncAudience', { + event: makeEvent('user1', true, 'track'), + settings, + mapping: baseMapping + }) + + expect(responses[0].status).toBe(200) + }) + + it('should remove a visitor from a track event successfully', async () => { + nock(REGIONS.DEFAULT.domain) + .patch(`${segmentBase}/visitor`, { patch: [{ op: 'remove', path: '/visitors', value: ['user1'] }] }) + .reply(200, { multistatus: [{ status: 200, message: 'success', operation: 'remove' }] }) + + const responses = await testDestination.testAction('syncAudience', { + event: makeEvent('user1', false, 'track'), + settings, + mapping: baseMapping + }) + + expect(responses[0].status).toBe(200) + }) + + it('should throw a validation error when visitorId is not provided', async () => { + await expect( + testDestination.testAction('syncAudience', { + event: makeEvent('user1', true), + settings, + mapping: { ...baseMapping, visitorId: '' } + }) + ).rejects.toThrow("The root value is missing the required field 'visitorId'.") + }) + + it('should throw a validation error when audience membership cannot be determined', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'user1', + // computation_class is not "audience", so core cannot resolve membership + context: { personas: { computation_key: 'test_audience', external_audience_id: SEGMENT_ID } } + }) + + await expect( + testDestination.testAction('syncAudience', { + event, + settings, + mapping: baseMapping + }) + ).rejects.toThrow('Unable to determine audience membership for this event') + }) + + it('should throw an IntegrationError when the API returns 500', async () => { + nock(REGIONS.DEFAULT.domain).patch(`${segmentBase}/visitor`).reply(500, { message: 'Internal Server Error' }) + + await expect( + testDestination.testAction('syncAudience', { + event: makeEvent('user1', true), + settings, + mapping: baseMapping + }) + ).rejects.toThrow('Internal Server Error') + }) + + it('should throw a RetryableError (429) when the API returns 409', async () => { + nock(REGIONS.DEFAULT.domain).patch(`${segmentBase}/visitor`).reply(409, { message: 'Conflict' }) + + const promise = testDestination.testAction('syncAudience', { + event: makeEvent('user1', true), + settings, + mapping: baseMapping + }) + + await expect(promise).rejects.toThrow(RetryableError) + await expect(promise).rejects.toThrow('Pendo returned a 409. Segment is returning a 429 to trigger a retry.') + await expect(promise).rejects.toMatchObject({ status: 429, code: 'RETRYABLE_ERROR' }) + }) +}) diff --git a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/perform.test.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/perform.test.ts deleted file mode 100644 index 7b1a1a26a4c..00000000000 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/perform.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import Destination from '../../index' -import { REGIONS, SEGMENT_ENDPOINT } from '../../constants' - -const testDestination = createTestIntegration(Destination) - -const settings = { - integrationKey: 'test-integration-key', - region: REGIONS.DEFAULT.name -} - -const SEGMENT_ID = 'seg-abc123' -const segmentBase = `/${SEGMENT_ENDPOINT}/${SEGMENT_ID}` - -const baseMapping = { - visitorId: { '@path': '$.userId' }, - traitsOrProperties: { '@path': '$.traits' }, - segmentAudienceKey: 'test_audience', - segmentAudienceId: SEGMENT_ID, - enable_batching: false -} - -describe('Pendo Audiences - syncAudience perform', () => { - afterEach(() => { - nock.cleanAll() - }) - - it('should add a visitor to the audience successfully', async () => { - nock(REGIONS.DEFAULT.domain) - .patch(`${segmentBase}/visitor`, { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] }) - .reply(200, { multistatus: [{ status: 200, message: 'success', operation: 'add' }] }) - - const event = createTestEvent({ - userId: 'user1', - traits: { test_audience: true }, - context: { personas: { computation_key: 'test_audience', external_audience_id: SEGMENT_ID } } - }) - - const responses = await testDestination.testAction('syncAudience', { - event, - settings, - mapping: baseMapping - }) - - expect(responses[0].status).toBe(200) - }) - - it('should remove a visitor from the audience successfully', async () => { - nock(REGIONS.DEFAULT.domain) - .patch(`${segmentBase}/visitor`, { patch: [{ op: 'remove', path: '/visitors', value: ['user1'] }] }) - .reply(200, { multistatus: [{ status: 200, message: 'success', operation: 'remove' }] }) - - const event = createTestEvent({ - userId: 'user1', - traits: { test_audience: false }, - context: { personas: { computation_key: 'test_audience', external_audience_id: SEGMENT_ID } } - }) - - const responses = await testDestination.testAction('syncAudience', { - event, - settings, - mapping: baseMapping - }) - - expect(responses[0].status).toBe(200) - }) - - it('should throw a validation error when visitorId is not provided', async () => { - const event = createTestEvent({ - userId: 'user1', - traits: { test_audience: true }, - context: { personas: { computation_key: 'test_audience', external_audience_id: SEGMENT_ID } } - }) - - await expect( - testDestination.testAction('syncAudience', { - event, - settings, - mapping: { ...baseMapping, visitorId: '' } - }) - ).rejects.toThrow("The root value is missing the required field 'visitorId'.") - }) - - it('should throw an IntegrationError when the API returns 500', async () => { - nock(REGIONS.DEFAULT.domain) - .patch(`${segmentBase}/visitor`) - .reply(500, { message: 'Internal Server Error' }) - - const event = createTestEvent({ - userId: 'user1', - traits: { test_audience: true }, - context: { personas: { computation_key: 'test_audience', external_audience_id: SEGMENT_ID } } - }) - - await expect( - testDestination.testAction('syncAudience', { - event, - settings, - mapping: baseMapping - }) - ).rejects.toThrow("Internal Server Error") - }) -}) diff --git a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/fields.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/fields.ts index 7a87a28a227..9338a4b7cb0 100644 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/fields.ts +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/fields.ts @@ -10,30 +10,6 @@ export const fields: Record = { '@path': '$.userId' } }, - traitsOrProperties: { - label: 'Traits or Properties', - description: 'Traits or Properties object from the identify() or track() call emitted by Engage.', - type: 'object', - required: true, - unsafe_hidden: true, - default: { - '@if': { - exists: { '@path': '$.traits' }, - then: { '@path': '$.traits' }, - else: { '@path': '$.properties' } - } - } - }, - segmentAudienceKey: { - label: 'Segment Audience Key', - description: 'Segment Audience Key. Used to determine whether the user is being added to or removed from the Pendo Segment.', - type: 'string', - required: true, - unsafe_hidden: true, - default: { - '@path': '$.context.personas.computation_key' - } - }, segmentAudienceId: { label: 'Segment External Audience ID', description: 'The External Audience ID from Segment, which maps to the Pendo Segment ID.', diff --git a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/functions.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/functions.ts index 629155fc049..46fab531c08 100644 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/functions.ts +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/functions.ts @@ -1,16 +1,41 @@ -import { IntegrationError, ErrorCodes, getErrorCodeFromHttpStatus, RequestClient, MultiStatusResponse, JSONLikeObject, PayloadValidationError } from '@segment/actions-core' +import { + IntegrationError, + ErrorCodes, + getErrorCodeFromHttpStatus, + RequestClient, + MultiStatusResponse, + JSONLikeObject, + PayloadValidationError, + InvalidAudienceMembershipError, + RetryableError, + AudienceMembership +} from '@segment/actions-core' import type { Payload } from './generated-types' import type { AddMap, RemoveMap, PatchBodyJSON, BatchPatchResponse, BatchMultistatusItem } from './types' import { SEGMENT_ENDPOINT } from '../constants' import { getDomain } from '../functions' -export async function send(request: RequestClient, region: string, payload: Payload[], isBatch: boolean): Promise { +export async function send( + request: RequestClient, + region: string, + payload: Payload[], + isBatch: boolean, + audienceMemberships: AudienceMembership[] +): Promise { const msResponse = new MultiStatusResponse() const segmentId = payload[0]?.segmentAudienceId if (!segmentId) { payload.forEach((p, index) => { - handleError('PayloadValidationError', 'Missing Pendo Segment ID', isBatch, msResponse, index, p, 400) + handleError( + 'PayloadValidationError', + 'Missing Pendo Segment ID', + isBatch, + msResponse, + index, + 400, + p as unknown as JSONLikeObject + ) }) return msResponse } @@ -19,13 +44,33 @@ export async function send(request: RequestClient, region: string, payload: Payl const removes: RemoveMap = new Map() payload.forEach((p, index) => { - const { visitorId, traitsOrProperties, segmentAudienceKey } = p + const { visitorId } = p if (!visitorId) { - handleError('PayloadValidationError','Visitor ID is required', isBatch, msResponse, index, p, 400) + handleError( + 'PayloadValidationError', + 'Visitor ID is required', + isBatch, + msResponse, + index, + 400, + p as unknown as JSONLikeObject + ) return } - const isAdding = Boolean(traitsOrProperties[segmentAudienceKey]) - if (isAdding) { + const membership = Array.isArray(audienceMemberships) ? audienceMemberships[index] : undefined + if (typeof membership !== 'boolean') { + handleError( + 'InvalidAudienceMembershipError', + 'Unable to determine audience membership for this event', + isBatch, + msResponse, + index, + 400, + p as unknown as JSONLikeObject + ) + return + } + if (membership) { adds.set(index, visitorId) } else { removes.set(index, visitorId) @@ -55,7 +100,6 @@ export async function send(request: RequestClient, region: string, payload: Payl } try { - const response = await request( `${getDomain(region)}/${SEGMENT_ENDPOINT}/${segmentId}/visitor`, { @@ -75,50 +119,85 @@ export async function send(request: RequestClient, region: string, payload: Payl if (isSuccess) { msResponse.setSuccessResponseAtIndex(index, { status: item.status, - body: p as unknown as JSONLikeObject, - sent: buildSent(item.operation, visitorId) + sent: p as unknown as JSONLikeObject, + body: buildPendoRequest(item.operation, [visitorId]) }) } else { - handleError('IntegrationError', item.message, isBatch, msResponse, index, p, item.status, buildSent(item.operation, visitorId)) + handleError( + 'IntegrationError', + item.message, + isBatch, + msResponse, + index, + item.status, + p as unknown as JSONLikeObject, + buildPendoRequest(item.operation, [visitorId]) + ) } }) }) - } - catch (error) { + } catch (error) { const status = (error?.response?.status as number) || 500 const message = (error?.message as string) || 'An error occurred while syncing visitors to Pendo Segment.' + // Pendo returns 409 ("Operation in progress") when a write to the segment is already underway. + // This is transient, so throw a RetryableError to let Segment retry the whole request later. + // Throwing (rather than setting per-item responses) retries the entire batch, which is correct + // since the single PATCH for all visitors is what Pendo rejected. + if (status === 409) { + throw new RetryableError('Pendo returned a 409. Segment is returning a 429 to trigger a retry.', 429) + } + const allIndices = [...adds.keys(), ...removes.keys()] allIndices.forEach((index) => { const visitorId = adds.get(index) ?? removes.get(index) const op = adds.has(index) ? 'add' : 'remove' - handleError('IntegrationError', message, isBatch, msResponse, index, payload[index], status, buildSent(op, visitorId as string)) + handleError( + 'IntegrationError', + message, + isBatch, + msResponse, + index, + status, + payload[index] as unknown as JSONLikeObject, + buildPendoRequest(op, [visitorId as string]) + ) }) } - if(isBatch) { + if (isBatch) { return msResponse } return } -function buildSent(op: 'add' | 'remove', visitorId: string): JSONLikeObject { - return { patch: [{ op, path: '/visitors', value: [visitorId] }] } as unknown as JSONLikeObject +function buildPendoRequest(op: 'add' | 'remove', visitorIds: string[]): JSONLikeObject { + return { patch: [{ op, path: '/visitors', value: visitorIds }] } as unknown as JSONLikeObject } -function handleError(errType: 'PayloadValidationError' | 'IntegrationError', message: string, isBatch: boolean, msResponse: MultiStatusResponse, index: number, payload: Payload, status = 400, sent?: JSONLikeObject): void { +function handleError( + errType: 'PayloadValidationError' | 'IntegrationError' | 'InvalidAudienceMembershipError', + message: string, + isBatch: boolean, + msResponse: MultiStatusResponse, + index: number, + status = 400, + sent?: JSONLikeObject, + body?: JSONLikeObject +): void { if (!isBatch) { - if(errType === 'PayloadValidationError') { + if (errType === 'PayloadValidationError') { throw new PayloadValidationError(message) - } - else { - throw new IntegrationError( message, getErrorCodeFromHttpStatus(status) || ErrorCodes.UNKNOWN_ERROR, status) + } else if (errType === 'InvalidAudienceMembershipError' ) { + throw new InvalidAudienceMembershipError(message) + } else { + throw new IntegrationError(message, getErrorCodeFromHttpStatus(status) || ErrorCodes.UNKNOWN_ERROR, status) } } msResponse.setErrorResponseAtIndex(index, { status, - body: payload as unknown as JSONLikeObject, errormessage: message, - ...(sent && { sent }) + ...(sent && { sent }), + ...(body && { body }) }) } diff --git a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/generated-types.ts index a2d1f08b983..06937f7c521 100644 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/generated-types.ts +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/generated-types.ts @@ -5,16 +5,6 @@ export interface Payload { * The Pendo Visitor ID for the user. Maps to the userId in Segment by default. */ visitorId: string - /** - * Traits or Properties object from the identify() or track() call emitted by Engage. - */ - traitsOrProperties: { - [k: string]: unknown - } - /** - * Segment Audience Key. Used to determine whether the user is being added to or removed from the Pendo Segment. - */ - segmentAudienceKey: string /** * The External Audience ID from Segment, which maps to the Pendo Segment ID. */ diff --git a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/index.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/index.ts index 32f7e4a80f5..e0273c2cb18 100644 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/index.ts +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/index.ts @@ -9,11 +9,11 @@ const action: ActionDefinition = { description: 'Sync Segment Engage Audience membership to a Pendo Segment by adding or removing visitors.', defaultSubscription: 'type = "identify" or type = "track"', fields, - perform: (request, { payload, settings }) => { - return send(request, settings.region, [payload], false) + perform: (request, { payload, settings, audienceMembership }) => { + return send(request, settings.region, [payload], false, [audienceMembership]) }, - performBatch: (request, { payload, settings }) => { - return send(request, settings.region, payload, true) + performBatch: (request, { payload, settings, audienceMembership}) => { + return send(request, settings.region, payload, true, audienceMembership ?? []) } } diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/__e2e__/index.ts b/packages/destination-actions/src/destinations/pinterest-conversions/__e2e__/index.ts new file mode 100644 index 00000000000..b9551fdc7f7 --- /dev/null +++ b/packages/destination-actions/src/destinations/pinterest-conversions/__e2e__/index.ts @@ -0,0 +1,8 @@ +import type { E2EDestinationConfig } from '@segment/actions-core' + +export const config: E2EDestinationConfig = { + settings: { + ad_account_id: { $env: 'E2E_PINTEREST_AD_ACCOUNT_ID' }, + conversion_token: { $env: 'E2E_PINTEREST_CONVERSION_TOKEN' } + } +} diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/constants.ts b/packages/destination-actions/src/destinations/pinterest-conversions/constants.ts index 618730fa59d..cdf414fa89c 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/constants.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/constants.ts @@ -2,14 +2,27 @@ import { PINTEREST_CONVERSIONS_API_VERSION } from './versioning-info' export const API_VERSION = PINTEREST_CONVERSIONS_API_VERSION export const EVENT_NAME = { + ADD_PAYMENT_INFO: 'add_payment_info', ADD_TO_CART: 'add_to_cart', + ADD_TO_WISHLIST: 'add_to_wishlist', + APP_INSTALL: 'app_install', + APP_OPEN: 'app_open', CHECKOUT: 'checkout', + CONTACT: 'contact', CUSTOM: 'custom', + CUSTOMIZE_PRODUCT: 'customize_product', + FIND_LOCATION: 'find_location', + INITIATE_CHECKOUT: 'initiate_checkout', LEAD: 'lead', PAGE_VISIT: 'page_visit', + SCHEDULE: 'schedule', SEARCH: 'search', - SIGNUP: 'search', + SIGNUP: 'signup', + START_TRIAL: 'start_trial', + SUBMIT_APPLICATION: 'submit_application', + SUBSCRIBE: 'subscribe', VIEW_CATEGORY: 'view_category', + VIEW_CONTENT: 'view_content', WATCH_VIDEO: 'watch_video' } export const ACTION_SOURCE = ['app_android', 'app_ios', 'web', 'offline'] diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/index.ts b/packages/destination-actions/src/destinations/pinterest-conversions/index.ts index f8e574de627..fa29bb3c555 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/index.ts @@ -1,5 +1,5 @@ import { defaultValues, DestinationDefinition } from '@segment/actions-core' -import { API_VERSION } from './constants' +import { API_VERSION, EVENT_NAME } from './constants' import type { Settings } from './generated-types' import reportConversionEvent from './reportConversionEvent' import type { PinterestConversionsTestAuthenticationError } from './types' @@ -66,13 +66,53 @@ const destination: DestinationDefinition = { } }, presets: [ + { + name: 'Add Payment Info', + subscribe: 'type = "track" AND event = "Payment Info Entered"', + partnerAction: 'reportConversionEvent', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: EVENT_NAME.ADD_PAYMENT_INFO + }, + type: 'automatic' + }, { name: 'Add to Cart', subscribe: 'type = "track" AND event = "Product Added"', partnerAction: 'reportConversionEvent', mapping: { ...defaultValues(reportConversionEvent.fields), - event_name: 'add_to_cart' + event_name: EVENT_NAME.ADD_TO_CART + }, + type: 'automatic' + }, + { + name: 'Add to Wishlist', + subscribe: 'type = "track" AND event = "Product Added to Wishlist"', + partnerAction: 'reportConversionEvent', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: EVENT_NAME.ADD_TO_WISHLIST + }, + type: 'automatic' + }, + { + name: 'App Install', + subscribe: 'type = "track" AND event = "Application Installed"', + partnerAction: 'reportConversionEvent', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: EVENT_NAME.APP_INSTALL + }, + type: 'automatic' + }, + { + name: 'App Open', + subscribe: 'type = "track" AND event = "Application Opened"', + partnerAction: 'reportConversionEvent', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: EVENT_NAME.APP_OPEN }, type: 'automatic' }, @@ -82,7 +122,17 @@ const destination: DestinationDefinition = { partnerAction: 'reportConversionEvent', mapping: { ...defaultValues(reportConversionEvent.fields), - event_name: 'checkout' + event_name: EVENT_NAME.CHECKOUT + }, + type: 'automatic' + }, + { + name: 'Initiate Checkout', + subscribe: 'type = "track" AND event = "Checkout Started"', + partnerAction: 'reportConversionEvent', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: EVENT_NAME.INITIATE_CHECKOUT }, type: 'automatic' }, @@ -92,7 +142,7 @@ const destination: DestinationDefinition = { partnerAction: 'reportConversionEvent', mapping: { ...defaultValues(reportConversionEvent.fields), - event_name: 'lead' + event_name: EVENT_NAME.LEAD }, type: 'automatic' }, @@ -102,7 +152,7 @@ const destination: DestinationDefinition = { partnerAction: 'reportConversionEvent', mapping: { ...defaultValues(reportConversionEvent.fields), - event_name: 'page_visit' + event_name: EVENT_NAME.PAGE_VISIT }, type: 'automatic' }, @@ -112,7 +162,7 @@ const destination: DestinationDefinition = { partnerAction: 'reportConversionEvent', mapping: { ...defaultValues(reportConversionEvent.fields), - event_name: 'search' + event_name: EVENT_NAME.SEARCH }, type: 'automatic' }, @@ -122,7 +172,27 @@ const destination: DestinationDefinition = { partnerAction: 'reportConversionEvent', mapping: { ...defaultValues(reportConversionEvent.fields), - event_name: 'signup' + event_name: EVENT_NAME.SIGNUP + }, + type: 'automatic' + }, + { + name: 'Start Trial', + subscribe: 'type = "track" AND event = "Trial Started"', + partnerAction: 'reportConversionEvent', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: EVENT_NAME.START_TRIAL + }, + type: 'automatic' + }, + { + name: 'Subscribe', + subscribe: 'type = "track" AND event = "Subscription Created"', + partnerAction: 'reportConversionEvent', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: EVENT_NAME.SUBSCRIBE }, type: 'automatic' }, @@ -132,7 +202,17 @@ const destination: DestinationDefinition = { partnerAction: 'reportConversionEvent', mapping: { ...defaultValues(reportConversionEvent.fields), - event_name: 'view_category' + event_name: EVENT_NAME.VIEW_CATEGORY + }, + type: 'automatic' + }, + { + name: 'View Content', + subscribe: 'type = "track" AND event = "Product Viewed"', + partnerAction: 'reportConversionEvent', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: EVENT_NAME.VIEW_CONTENT }, type: 'automatic' }, @@ -142,7 +222,7 @@ const destination: DestinationDefinition = { partnerAction: 'reportConversionEvent', mapping: { ...defaultValues(reportConversionEvent.fields), - event_name: 'watch_video' + event_name: EVENT_NAME.WATCH_VIDEO }, type: 'automatic' } diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/pinterest-capi-custom-data.ts b/packages/destination-actions/src/destinations/pinterest-conversions/pinterest-capi-custom-data.ts index 00560fafb83..1c7ea6a880a 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/pinterest-capi-custom-data.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/pinterest-capi-custom-data.ts @@ -1,9 +1,10 @@ -import { InputField } from '@segment/actions-core/destination-kit/types' +import { InputField, DependsOnConditions } from '@segment/actions-core/destination-kit/types' -export const custom_data_field: InputField = { - label: 'Custom Data', - description: 'Object containing customer information data.', +export const getCustomDataField = (dependsOn: DependsOnConditions): InputField => ({ + label: '[Legacy] Custom Data', + description: 'Object containing custom event data.', type: 'object', + depends_on: dependsOn, properties: { currency: { label: 'Currency', @@ -39,9 +40,29 @@ export const custom_data_field: InputField = { description: 'The price of the Item' }, quantity: { - label: 'quantity', + label: 'Quantity', type: 'integer', - description: 'The number of items purchased' + description: 'The number of items purchased.' + }, + item_brand: { + label: 'Item Brand', + type: 'string', + description: 'The brand of a product.' + }, + item_brand_id: { + label: 'Item Brand ID', + type: 'string', + description: 'The brand ID of a product. Max 64 characters.' + }, + item_category: { + label: 'Item Category', + type: 'string', + description: 'The category of a product.' + }, + item_name: { + label: 'Item Name', + type: 'string', + description: 'The name of a product.' } } }, @@ -63,8 +84,28 @@ export const custom_data_field: InputField = { opt_out_type: { label: 'Opt Out Type', description: - 'opt_out_type is the field where we accept opt outs for your users’ privacy preference. It can handle multiple values with commas separated.', + "Accepts opt outs for your users' privacy preference. Can handle multiple values with commas separated.", + type: 'string' + }, + content_brand: { + label: 'Content Brand', + description: 'The brand of the content associated with the event.', type: 'string' + }, + content_category: { + label: 'Content Category', + description: 'The category of the content associated with the event.', + type: 'string' + }, + content_name: { + label: 'Content Name', + description: 'The name of the page or product associated with the event.', + type: 'string' + }, + predicted_ltv: { + label: 'Predicted LTV', + description: 'Predicted lifetime value of user associated with the event.', + type: 'number' } }, default: { @@ -85,4 +126,141 @@ export const custom_data_field: InputField = { '@path': '$.properties.currency' } } -} +}) + +export const getCustomDataField2 = (dependsOn: DependsOnConditions): InputField => ({ + label: 'Custom Data', + description: 'Object containing custom event data.', + type: 'object', + depends_on: dependsOn, + properties: { + currency: { + label: 'Currency', + type: 'string', + description: 'ISO-4217 currency code. If not provided, it will default to the currency set for the ad account.' + }, + value: { + label: 'Value', + type: 'number', + description: + 'Total value of the event. E.g. if there are multiple items in a checkout event, value should be the total price of all items.' + }, + content_ids: { + label: 'Content IDs', + type: 'string', + multiple: true, + description: 'Product IDs as an array of strings.' + }, + num_items: { + label: 'Number of Items', + type: 'integer', + description: 'Total number of products in the event.' + }, + order_id: { + label: 'Order ID', + type: 'string', + description: 'The order ID.' + }, + search_string: { + label: 'Search String', + type: 'string', + description: 'Search string related to the conversion event.' + }, + opt_out_type: { + label: 'Opt Out Type', + type: 'string', + description: + "The field where Pinterest accepts opt outs for your users' privacy preference. It can handle multiple values with commas separated." + }, + content_brand: { + label: 'Content Brand', + type: 'string', + description: 'The brand of the content associated with the event.' + }, + content_category: { + label: 'Content Category', + type: 'string', + description: 'The category of the content associated with the event.' + }, + content_name: { + label: 'Content Name', + type: 'string', + description: 'The name of the page or product associated with the event.' + }, + predicted_ltv: { + label: 'Predicted LTV', + type: 'number', + description: 'Predicted lifetime value of user associated with the event.' + } + }, + default: { + currency: { '@path': '$.properties.currency' }, + value: { + '@if': { + exists: { '@path': '$.properties.price' }, + then: { '@path': '$.properties.price' }, + else: { '@path': '$.properties.value' } + } + }, + order_id: { '@path': '$.properties.order_id' }, + search_string: { '@path': '$.properties.query' } + } +}) + +export const getContentsField = (dependsOn: DependsOnConditions): InputField => ({ + label: 'Contents', + description: 'A list of objects containing information about products.', + type: 'object', + multiple: true, + depends_on: dependsOn, + properties: { + id: { + label: 'Product ID', + type: 'string', + description: 'The id of the item.' + }, + item_price: { + label: 'Price', + type: 'number', + description: 'The price of the item.' + }, + quantity: { + label: 'Quantity', + type: 'integer', + description: 'The number of items purchased.' + }, + item_brand: { + label: 'Item Brand', + type: 'string', + description: 'The brand of the product.' + }, + item_brand_id: { + label: 'Item Brand ID', + type: 'string', + description: 'The brand ID of the product. Max 64 characters.' + }, + item_category: { + label: 'Item Category', + type: 'string', + description: 'The category of the product.' + }, + item_name: { + label: 'Item Name', + type: 'string', + description: 'The name of the product.' + } + }, + default: { + '@arrayPath': [ + '$.properties.products', + { + id: { '@path': '$.product_id' }, + item_price: { '@path': '$.price' }, + quantity: { '@path': '$.quantity' }, + item_brand: { '@path': '$.brand' }, + item_category: { '@path': '$.category' }, + item_name: { '@path': '$.name' } + } + ] + } +}) diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..d144244919b --- /dev/null +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__e2e__/fixtures.e2e.ts @@ -0,0 +1,259 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import reportConversionEvent from '../index' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully sends a checkout event with order details', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'checkout' + }, + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-pinterest-001', + properties: { + email: 'e2e-test@segment.com', + order_id: '$guid:orderId', + value: 149.98, + currency: 'USD' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.10', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', + page: { + url: 'https://example.com/checkout' + } + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully sends a page visit event', + subscribe: 'type = "page"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'page_visit' + }, + mode: 'single', + event: createE2EEvent('page', 'Home', { + userId: 'e2e-test-user-pinterest-002', + properties: { + url: 'https://example.com/home' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.11', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + page: { + url: 'https://example.com/home' + } + }, + traits: { + email: 'e2e-page@segment.com' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully sends an add_to_cart event with product data', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'add_to_cart' + }, + mode: 'single', + event: createE2EEvent('track', 'Product Added', { + userId: 'e2e-test-user-pinterest-003', + properties: { + email: 'e2e-cart@segment.com', + price: 74.99, + currency: 'USD', + content_ids: ['sku-001'], + num_items: 1 + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.12', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully sends a search event with query string', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'search' + }, + mode: 'single', + event: createE2EEvent('track', 'Products Searched', { + userId: 'e2e-test-user-pinterest-004', + properties: { + email: 'e2e-search@segment.com', + query: 'summer dresses' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.13', + userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully sends a signup event', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'signup' + }, + mode: 'single', + event: createE2EEvent('track', 'Signed Up', { + userId: 'e2e-test-user-pinterest-005', + properties: { + email: 'e2e-signup@segment.com' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.14', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Rejects event when user_data is missing email, hashed_maids, and IP+UA pair', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'checkout', + user_data: {} + }, + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-pinterest-006', + properties: { + order_id: 'order-123' + }, + context: { + app: { + name: 'E2E Test App' + } + } + }), + expect: { + status: 'error', + errorType: 'IntegrationError', + errorMessage: + 'User data must contain values for Email or Phone Number or Mobile Ad Identifier or both IP Address and User Agent fields' + } + }, + { + description: 'Rejects event with invalid event_name not in choices list', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'invalid_event_name' + }, + mode: 'single', + event: createE2EEvent('track', 'Some Event', { + userId: 'e2e-test-user-pinterest-007', + properties: { + email: 'e2e-invalid@segment.com' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.15', + userAgent: 'Mozilla/5.0' + } + }), + expect: { + status: 'error', + errorType: 'AggregateAjvError' + } + }, + { + description: 'Rejects event with invalid action_source not in choices list', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'checkout', + action_source: 'invalid_source' + }, + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-pinterest-008', + properties: { + email: 'e2e-invalid-source@segment.com' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.16', + userAgent: 'Mozilla/5.0' + } + }), + expect: { + status: 'error', + errorType: 'AggregateAjvError' + } + }, + { + description: 'Pinterest rejects event with timestamp too far in the past', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'checkout', + event_time: '2020-01-01T00:00:00.000Z' + }, + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-pinterest-009', + properties: { + email: 'e2e-old-event@segment.com' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.17', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' + } + }), + expect: { + status: 'failure', + httpStatus: 422 + }, + verboseFailureHint: 'Pinterest rejects events with event_time older than 7 days.' + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/__snapshots__/index.test.ts.snap b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/__snapshots__/index.test.ts.snap deleted file mode 100644 index 4830f19ea8d..00000000000 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/__snapshots__/index.test.ts.snap +++ /dev/null @@ -1,157 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PinterestConversionApi ReportConversionEvent Should send an event to pinterest successfully,if user data have either of email,hashed_maids or both client_ip_address and client_user_agent 1`] = ` -Object { - "data": Array [ - Object { - "action_source": "web", - "app_id": undefined, - "app_name": "InitechGlobal", - "app_version": "545", - "custom_data": Object { - "content_ids": undefined, - "contents": undefined, - "currency": undefined, - "num_items": 2, - "opt_out_type": undefined, - "order_id": undefined, - "search_string": undefined, - "value": "2000", - }, - "device_brand": undefined, - "device_carrier": undefined, - "device_model": "iPhone7,2", - "device_type": "ios", - "event_id": "test-message-rocnz07d5e8", - "event_name": "checkout", - "event_source_url": "https://segment.com/academy/", - "event_time": 1678694183, - "language": undefined, - "opt_out": true, - "os_version": "8.1.3", - "partner_name": "ss-segment", - "user_data": Object { - "click_id": "click-id1", - "client_ip_address": "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1", - "client_user_agent": "5.5.5.5", - "country": Array [ - "79adb2a2fce5c6ba215fe5f27f532d4e7edbac4b6a5e09e1ef3a08084a904621", - ], - "ct": Array [ - "688787d8ff144c502c7f5cffaafe2cc588d86079f9de88304c26b0cb99ce91c6", - ], - "db": Array [ - "9e4b15bbd40f2429491316d291927f5153b4f8c28738e6ee6284009ce29d13d6", - ], - "em": Array [ - "87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674", - ], - "external_id": Array [ - "74a6a35e39c525dcf6fd98ba90e79eb3c4358df1ae204e9489d51e6946485b2b", - ], - "fn": Array [ - "44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d", - ], - "ge": Array [ - "62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a", - ], - "hashed_maids": Array [ - "f4c2178860817a2c25d2cb3185aa25779b0ecaf17c30845926218e17a18a9f89", - ], - "ln": Array [ - "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", - ], - "partner_id": "partner-id1", - "ph": Array [ - "15e2b0d3c33891ebb0f1ef609ec419420c20e320ce94c65fbc8c3312448eb225", - ], - "st": Array [ - "6959097001d10501ac7d54c0bdb8db61420f658f2922cc26e46d536119a31126", - ], - "zp": Array [ - "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", - ], - }, - "wifi": undefined, - }, - ], -} -`; - -exports[`PinterestConversionApi ReportConversionEvent Should send an signup event to pinterest successfully,if user data have either of email,hashed_maids or both client_ip_address and client_user_agent 1`] = ` -Object { - "data": Array [ - Object { - "action_source": "web", - "app_id": undefined, - "app_name": "InitechGlobal", - "app_version": "545", - "custom_data": Object { - "content_ids": undefined, - "contents": undefined, - "currency": undefined, - "num_items": undefined, - "opt_out_type": undefined, - "order_id": undefined, - "search_string": undefined, - "value": undefined, - }, - "device_brand": undefined, - "device_carrier": undefined, - "device_model": "iPhone7,2", - "device_type": "ios", - "event_id": "test-message-rocnz07d5e8", - "event_name": "signup", - "event_source_url": "https://segment.com/academy/", - "event_time": 1678694183, - "language": undefined, - "opt_out": true, - "os_version": "8.1.3", - "partner_name": "ss-segment", - "user_data": Object { - "click_id": "click-id1", - "client_ip_address": "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1", - "client_user_agent": "5.5.5.5", - "country": Array [ - "79adb2a2fce5c6ba215fe5f27f532d4e7edbac4b6a5e09e1ef3a08084a904621", - ], - "ct": Array [ - "688787d8ff144c502c7f5cffaafe2cc588d86079f9de88304c26b0cb99ce91c6", - ], - "db": Array [ - "9e4b15bbd40f2429491316d291927f5153b4f8c28738e6ee6284009ce29d13d6", - ], - "em": Array [ - "87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674", - ], - "external_id": Array [ - "74a6a35e39c525dcf6fd98ba90e79eb3c4358df1ae204e9489d51e6946485b2b", - ], - "fn": Array [ - "44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d", - ], - "ge": Array [ - "62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a", - ], - "hashed_maids": Array [ - "f4c2178860817a2c25d2cb3185aa25779b0ecaf17c30845926218e17a18a9f89", - ], - "ln": Array [ - "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", - ], - "partner_id": "partner-id1", - "ph": Array [ - "15e2b0d3c33891ebb0f1ef609ec419420c20e320ce94c65fbc8c3312448eb225", - ], - "st": Array [ - "6959097001d10501ac7d54c0bdb8db61420f658f2922cc26e46d536119a31126", - ], - "zp": Array [ - "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", - ], - }, - "wifi": undefined, - }, - ], -} -`; diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/index.test.ts index 1aeb16f7d6d..aeebd257cf1 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/index.test.ts @@ -84,48 +84,6 @@ describe('PinterestConversionApi', () => { ).rejects.toThrowError() }) - it('Should send an event to pinterest successfully,if user data have either of email,hashed_maids or both client_ip_address and client_user_agent', async () => { - nock(`https://api.pinterest.com`) - .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) - .reply(200, {}) - - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings: authData, - useDefaultMappings: true, - mapping: { - event_name: 'checkout', - user_data: { - first_name: ['Gaurav'], - last_name: ['test'], - external_id: ['test_external_id'], - phone: ['123456789'], - gender: ['male'], - city: ['asd'], - state: ['CA'], - zip: ['123456'], - country: ['US'], - hashed_maids: ['test123123'], - date_of_birth: ['1996-02-01'], - email: ['test@gmail.com'], - client_user_agent: '5.5.5.5', - click_id: 'click-id1', - partner_id: 'partner-id1', - client_ip_address: - 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' - }, - custom_data: { - num_items: '2', - value: 2000 - } - } - }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(JSON.parse(responses[0]?.options?.body as string)?.data?.length).toBe(1) - expect(responses[0].options.json).toMatchSnapshot() - }) - it("Should throw an error when user data doesn't have either of email,hashed_maids or both client_ip_address and client_user_agent", async () => { await expect( testDestination.testAction('reportConversionEvent', { @@ -141,86 +99,506 @@ describe('PinterestConversionApi', () => { ) }) - it('Should send an signup event to pinterest successfully,if user data have either of email,hashed_maids or both client_ip_address and client_user_agent', async () => { - nock(`https://api.pinterest.com`) - .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) - .reply(200, {}) - - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings: authData, - useDefaultMappings: true, - mapping: { - event_name: 'signup', - user_data: { - first_name: ['Gaurav'], - last_name: ['test'], - external_id: ['test_external_id'], - phone: ['123456789'], - gender: ['male'], - city: ['asd'], - state: ['CA'], - zip: ['123456'], - country: ['US'], - hashed_maids: ['test123123'], - date_of_birth: ['1996-02-01'], - email: ['test@gmail.com'], - client_user_agent: '5.5.5.5', - click_id: 'click-id1', - partner_id: 'partner-id1', - client_ip_address: - 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + describe('legacy mode', () => { + it('should send event using legacy custom_data and flat app/device fields', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + useDefaultMappings: true, + mapping: { + data_format: 'legacy', + event_name: 'checkout', + user_data: { + first_name: ['Gaurav'], + last_name: ['test'], + external_id: ['test_external_id'], + phone: ['123456789'], + gender: ['male'], + city: ['asd'], + state: ['CA'], + zip: ['123456'], + country: ['US'], + hashed_maids: ['test123123'], + date_of_birth: ['1996-02-01'], + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + click_id: 'click-id1', + partner_id: 'partner-id1', + client_ip_address: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + }, + custom_data: { + num_items: '2', + value: 2000 + } + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].advertiser_tracking_enabled).toBe(true) + expect(body.data[0].custom_data.value).toBe('2000') + expect(body.data[0].custom_data.num_items).toBe(2) + expect(body.data[0].custom_data.np).toBe('ss-segment') + expect(body.data[0].app_name).toBe('InitechGlobal') + expect(body.data[0].app_version).toBe('545') + expect(body.data[0].device_model).toBe('iPhone7,2') + expect(body.data[0].device_type).toBe('ios') + expect(body.data[0].os_version).toBe('8.1.3') + expect(body.data[0].app_info).toBeUndefined() + expect(body.data[0].device_info).toBeUndefined() + }) + + it('should send signup event in legacy mode', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + useDefaultMappings: true, + mapping: { + data_format: 'legacy', + event_name: 'signup', + user_data: { + first_name: ['Gaurav'], + last_name: ['test'], + external_id: ['test_external_id'], + phone: ['123456789'], + gender: ['male'], + city: ['asd'], + state: ['CA'], + zip: ['123456'], + country: ['US'], + hashed_maids: ['test123123'], + date_of_birth: ['1996-02-01'], + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + click_id: 'click-id1', + partner_id: 'partner-id1', + client_ip_address: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + } } - } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].event_name).toBe('signup') + expect(body.data[0].partner_name).toBe('ss-segment') + expect(body.data[0].app_info).toBeUndefined() + expect(body.data[0].device_info).toBeUndefined() + }) + + it('should detect pre-hashed data in legacy mode', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + useDefaultMappings: true, + mapping: { + data_format: 'legacy', + event_name: 'checkout', + user_data: { + first_name: ['44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d'], + last_name: ['test'], + external_id: ['test_external_id'], + phone: ['63af7d494c194a90e1cf1db5371c13f97db650161aa803e67182c0dbaf668c7b'], + gender: ['male'], + city: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], + state: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], + zip: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], + country: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], + hashed_maids: ['test123123'], + date_of_birth: ['1996-02-01'], + email: ['c551027f06bd3f307ccd6abb61edc500def2680944c010e932ab5b27a3a8f151'], + client_user_agent: '5.5.5.5', + click_id: 'click-id1', + partner_id: 'partner-id1', + client_ip_address: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + }, + custom_data: { + num_items: '2', + value: 2000 + } + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const body = JSON.parse(responses[0].options.body as string) + expect(body.data.length).toBe(1) + expect(body.data[0].user_data.em).toEqual(['c551027f06bd3f307ccd6abb61edc500def2680944c010e932ab5b27a3a8f151']) + expect(body.data[0].user_data.fn).toEqual(['44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d']) + expect(body.data[0].user_data.ph).toEqual(['63af7d494c194a90e1cf1db5371c13f97db650161aa803e67182c0dbaf668c7b']) + expect(body.data[0].custom_data.value).toBe('2000') + expect(body.data[0].custom_data.num_items).toBe(2) + expect(body.data[0].custom_data.np).toBe('ss-segment') + expect(body.data[0].app_name).toBe('InitechGlobal') + expect(body.data[0].device_model).toBe('iPhone7,2') + expect(body.data[0].app_info).toBeUndefined() + expect(body.data[0].device_info).toBeUndefined() }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(JSON.parse(responses[0]?.options?.body as string)?.data?.length).toBe(1) - expect(responses[0].options.json).toMatchSnapshot() }) - it('should be able to detect hashed data when flag is set', async () => { - nock(`https://api.pinterest.com`) - .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) - .reply(200, {}) - - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings: authData, - useDefaultMappings: true, - mapping: { - event_name: 'checkout', - user_data: { - first_name: ['44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d'], - last_name: ['test'], - external_id: ['test_external_id'], - phone: ['63af7d494c194a90e1cf1db5371c13f97db650161aa803e67182c0dbaf668c7b'], - gender: ['male'], - city: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], - state: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], - zip: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], - country: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], - hashed_maids: ['test123123'], - date_of_birth: ['1996-02-01'], - email: ['c551027f06bd3f307ccd6abb61edc500def2680944c010e932ab5b27a3a8f151'], - client_user_agent: '5.5.5.5', - click_id: 'click-id1', - partner_id: 'partner-id1', - client_ip_address: - 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' - }, - custom_data: { - num_items: '2', - value: 2000 + describe('undefined data_format (existing subscriptions)', () => { + it('should use legacy behavior when data_format is not set', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + mapping: { + event_name: 'checkout', + action_source: 'web', + event_time: '2023-03-13T07:56:23.846Z', + event_id: 'test-message-rocnz07d5e8', + app_name: 'InitechGlobal', + user_data: { + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + client_ip_address: '1.2.3.4' + }, + custom_data: { + value: 100, + currency: 'USD' + } } - } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].custom_data.value).toBe('100') + expect(body.data[0].custom_data.currency).toBe('USD') + expect(body.data[0].custom_data.np).toBe('ss-segment') + expect(body.data[0].app_name).toBe('InitechGlobal') + expect(body.data[0].app_info).toBeUndefined() + expect(body.data[0].device_info).toBeUndefined() + }) + }) + + describe('latest mode', () => { + it('should send event using latest fields with app_info and device_info', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + useDefaultMappings: true, + mapping: { + data_format: 'latest', + event_name: 'checkout', + user_data: { + first_name: ['Gaurav'], + last_name: ['test'], + external_id: ['test_external_id'], + phone: ['123456789'], + gender: ['male'], + city: ['asd'], + state: ['CA'], + zip: ['123456'], + country: ['US'], + hashed_maids: ['test123123'], + date_of_birth: ['1996-02-01'], + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + click_id: 'click-id1', + partner_id: 'partner-id1', + client_ip_address: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + }, + custom_data_2: { + value: 2000, + num_items: 2, + currency: 'USD' + }, + contents: [ + { + id: 'sku_123', + item_price: 74.99, + quantity: 2, + item_brand: 'Brand A', + item_category: 'Shoes', + item_name: 'Running Shoe' + } + ] + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].advertiser_tracking_enabled).toBe(true) + expect(body.data[0].custom_data.value).toBe('2000') + expect(body.data[0].custom_data.num_items).toBe(2) + expect(body.data[0].custom_data.currency).toBe('USD') + expect(body.data[0].custom_data.np).toBe('ss-segment') + expect(body.data[0].custom_data.contents).toEqual([ + { + id: 'sku_123', + item_price: '74.99', + quantity: 2, + item_brand: 'Brand A', + item_category: 'Shoes', + item_name: 'Running Shoe' + } + ]) + expect(body.data[0].app_info).toEqual({ + app_name: 'InitechGlobal', + app_package_name: 'com.production.segment', + app_version: '545', + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + }) + expect(body.data[0].device_info).toEqual({ + brand: 'Apple', + model: 'iPhone7,2', + type: 'ios', + os_version: '8.1.3', + timezone: 'Europe/Amsterdam' + }) + expect(body.data[0].app_id).toBeUndefined() + expect(body.data[0].app_name).toBeUndefined() + expect(body.data[0].device_brand).toBeUndefined() + expect(body.data[0].device_model).toBeUndefined() + }) + + it('should detect pre-hashed data in latest mode', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + useDefaultMappings: true, + mapping: { + data_format: 'latest', + event_name: 'checkout', + user_data: { + first_name: ['44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d'], + last_name: ['test'], + external_id: ['test_external_id'], + phone: ['63af7d494c194a90e1cf1db5371c13f97db650161aa803e67182c0dbaf668c7b'], + gender: ['male'], + city: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], + state: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], + zip: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], + country: ['92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d'], + hashed_maids: ['test123123'], + date_of_birth: ['1996-02-01'], + email: ['c551027f06bd3f307ccd6abb61edc500def2680944c010e932ab5b27a3a8f151'], + client_user_agent: '5.5.5.5', + click_id: 'click-id1', + partner_id: 'partner-id1', + client_ip_address: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + }, + custom_data_2: { + value: 2000, + num_items: 2 + } + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].user_data.em).toEqual(['c551027f06bd3f307ccd6abb61edc500def2680944c010e932ab5b27a3a8f151']) + expect(body.data[0].user_data.fn).toEqual(['44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d']) + expect(body.data[0].user_data.ph).toEqual(['63af7d494c194a90e1cf1db5371c13f97db650161aa803e67182c0dbaf668c7b']) + expect(body.data[0].custom_data.value).toBe('2000') + expect(body.data[0].custom_data.num_items).toBe(2) + expect(body.data[0].custom_data.np).toBe('ss-segment') + expect(body.data[0].app_info.app_name).toBe('InitechGlobal') + expect(body.data[0].device_info.model).toBe('iPhone7,2') + }) + }) + + describe('install_time conversion', () => { + it('should convert install_time ISO timestamp to unix seconds', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + useDefaultMappings: true, + mapping: { + data_format: 'latest', + event_name: 'app_install', + user_data: { + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + client_ip_address: '1.2.3.4' + }, + app_info: { + app_id: '429047995', + app_name: 'MyApp', + install_time: '2025-02-10T18:17:49.000Z' + } + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].app_info.install_time).toBe(1739211469) + expect(body.data[0].app_info.app_id).toBe('429047995') + expect(body.data[0].app_info.app_name).toBe('MyApp') + }) + }) + + describe('predicted_ltv conversion', () => { + it('should convert predicted_ltv number to string', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + useDefaultMappings: true, + mapping: { + data_format: 'latest', + event_name: 'checkout', + user_data: { + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + client_ip_address: '1.2.3.4' + }, + custom_data_2: { + value: 149.99, + predicted_ltv: 2794.82 + } + } + }) + expect(responses.length).toBe(1) + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].custom_data.predicted_ltv).toBe('2794.82') + expect(body.data[0].custom_data.value).toBe('149.99') + }) + }) + + describe('empty app_info and device_info omission', () => { + it('should omit app_info when all fields are empty', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + mapping: { + data_format: 'latest', + event_name: 'page_visit', + event_time: '2023-03-13T07:56:23.846Z', + event_id: 'test-123', + action_source: 'web', + user_data: { + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + client_ip_address: '1.2.3.4' + }, + app_info: {}, + device_info: {} + } + }) + expect(responses.length).toBe(1) + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].app_info).toBeUndefined() + expect(body.data[0].device_info).toBeUndefined() + }) + }) + + describe('new event names', () => { + it('should accept start_trial as a valid event name', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + useDefaultMappings: true, + mapping: { + data_format: 'latest', + event_name: 'start_trial', + user_data: { + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + client_ip_address: '1.2.3.4' + } + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].event_name).toBe('start_trial') + }) + }) + + describe('conditional required field', () => { + it('should not require app_name in latest mode', async () => { + nock(`https://api.pinterest.com`) + .post(`/${API_VERSION}/ad_accounts/${authData.ad_account_id}/events`) + .reply(200, {}) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + mapping: { + data_format: 'latest', + event_name: 'checkout', + action_source: 'web', + event_time: '2023-03-13T07:56:23.846Z', + event_id: 'test-123', + user_data: { + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + client_ip_address: '1.2.3.4' + } + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should require app_name in legacy mode', async () => { + await expect( + testDestination.testAction('reportConversionEvent', { + event, + settings: authData, + mapping: { + data_format: 'legacy', + event_name: 'checkout', + action_source: 'web', + event_time: '2023-03-13T07:56:23.846Z', + event_id: 'test-123', + user_data: { + email: ['test@gmail.com'], + client_user_agent: '5.5.5.5', + client_ip_address: '1.2.3.4' + } + } + }) + ).rejects.toThrowError() }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(JSON.parse(responses[0]?.options?.body as string)?.data?.length).toBe(1) - expect(responses[0].options.body).toBe( - '{"data":[{"event_name":"checkout","action_source":"web","event_time":1678694183,"event_id":"test-message-rocnz07d5e8","event_source_url":"https://segment.com/academy/","partner_name":"ss-segment","opt_out":true,"user_data":{"em":["c551027f06bd3f307ccd6abb61edc500def2680944c010e932ab5b27a3a8f151"],"ph":["63af7d494c194a90e1cf1db5371c13f97db650161aa803e67182c0dbaf668c7b"],"ge":["62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a"],"db":["9e4b15bbd40f2429491316d291927f5153b4f8c28738e6ee6284009ce29d13d6"],"ln":["9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"],"fn":["44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d"],"ct":["92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d"],"st":["92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d"],"zp":["92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d"],"country":["92db9c574d420b2437b29d898d55604f61df6c17f5163e53337f2169dd70d38d"],"external_id":["74a6a35e39c525dcf6fd98ba90e79eb3c4358df1ae204e9489d51e6946485b2b"],"client_ip_address":"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1","client_user_agent":"5.5.5.5","hashed_maids":["f4c2178860817a2c25d2cb3185aa25779b0ecaf17c30845926218e17a18a9f89"],"click_id":"click-id1","partner_id":"partner-id1"},"custom_data":{"value":"2000","num_items":2},"app_name":"InitechGlobal","app_version":"545","device_model":"iPhone7,2","device_type":"ios","os_version":"8.1.3"}]}' - ) }) }) }) diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/generated-types.ts b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/generated-types.ts index cd9ad9708d8..a9bcc24d674 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/generated-types.ts @@ -1,6 +1,10 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Payload { + /** + * Switch between the latest field configuration and the legacy fields. New instances default to the latest fields. + */ + data_format?: string /** * The conversion event type. For custom events, you must use the predefined event name "custom". Please refer to the possible event types in [Pinterest API docs](https://developers.pinterest.com/docs/api/v5/#operation/events/create). */ @@ -25,6 +29,10 @@ export interface Payload { * When action_source is web or offline, it defines whether the user has opted out of tracking for web conversion events. While when action_source is app_android or app_ios, it defines whether the user has enabled Limit Ad Tracking on their iOS device, or opted out of Ads Personalization on their Android device. */ opt_out?: boolean + /** + * Defines whether the user has enabled ATT permission on their iOS device. + */ + advertiser_tracking_enabled?: boolean /** * Object containing customer information data. Note, It is required at least one of 1) em, 2) hashed_maids or 3) pair client_ip_address + client_user_agent.. */ @@ -95,7 +103,7 @@ export interface Payload { partner_id?: string | null } /** - * Object containing customer information data. + * Object containing custom event data. */ custom_data?: { /** @@ -123,9 +131,25 @@ export interface Payload { */ item_price?: number /** - * The number of items purchased + * The number of items purchased. */ quantity?: number + /** + * The brand of a product. + */ + item_brand?: string + /** + * The brand ID of a product. Max 64 characters. + */ + item_brand_id?: string + /** + * The category of a product. + */ + item_category?: string + /** + * The name of a product. + */ + item_name?: string }[] /** * Total number of products in the event. @@ -140,9 +164,25 @@ export interface Payload { */ search_string?: string /** - * opt_out_type is the field where we accept opt outs for your users’ privacy preference. It can handle multiple values with commas separated. + * Accepts opt outs for your users' privacy preference. Can handle multiple values with commas separated. */ opt_out_type?: string + /** + * The brand of the content associated with the event. + */ + content_brand?: string + /** + * The category of the content associated with the event. + */ + content_category?: string + /** + * The name of the page or product associated with the event. + */ + content_name?: string + /** + * Predicted lifetime value of user associated with the event. + */ + predicted_ltv?: number } /** * The app store app ID. @@ -151,7 +191,7 @@ export interface Payload { /** * Name of the app. */ - app_name: string + app_name?: string /** * Version of the app. */ @@ -161,7 +201,7 @@ export interface Payload { */ device_brand?: string /** - * User device’s mobile carrier. + * User device's mobile carrier. */ device_carrier?: string /** @@ -176,6 +216,230 @@ export interface Payload { * Version of the device operating system. */ os_version?: string + /** + * Object containing custom event data. + */ + custom_data_2?: { + /** + * ISO-4217 currency code. If not provided, it will default to the currency set for the ad account. + */ + currency?: string + /** + * Total value of the event. E.g. if there are multiple items in a checkout event, value should be the total price of all items. + */ + value?: number + /** + * Product IDs as an array of strings. + */ + content_ids?: string[] + /** + * Total number of products in the event. + */ + num_items?: number + /** + * The order ID. + */ + order_id?: string + /** + * Search string related to the conversion event. + */ + search_string?: string + /** + * The field where Pinterest accepts opt outs for your users' privacy preference. It can handle multiple values with commas separated. + */ + opt_out_type?: string + /** + * The brand of the content associated with the event. + */ + content_brand?: string + /** + * The category of the content associated with the event. + */ + content_category?: string + /** + * The name of the page or product associated with the event. + */ + content_name?: string + /** + * Predicted lifetime value of user associated with the event. + */ + predicted_ltv?: number + } + /** + * A list of objects containing information about products. + */ + contents?: { + /** + * The id of the item. + */ + id?: string + /** + * The price of the item. + */ + item_price?: number + /** + * The number of items purchased. + */ + quantity?: number + /** + * The brand of the product. + */ + item_brand?: string + /** + * The brand ID of the product. Max 64 characters. + */ + item_brand_id?: string + /** + * The category of the product. + */ + item_category?: string + /** + * The name of the product. + */ + item_name?: string + }[] + /** + * Object containing information about the application where event occurred. + */ + app_info?: { + /** + * App ID in Google Play Store, AppStore or other stores. + */ + app_id?: string + /** + * Name of the app. + */ + app_name?: string + /** + * App package name. + */ + app_package_name?: string + /** + * The name of the app distributor or store from which the app was installed. + */ + app_store?: string + /** + * App version. + */ + app_version?: string + /** + * App install time. Will be converted to Unix timestamp in seconds before sending. + */ + install_time?: string | number + /** + * User Agent request header. + */ + user_agent?: string + /** + * Inner height of the window or viewport. + */ + window_height?: number + /** + * Inner width of the window or viewport. + */ + window_width?: number + } + /** + * Object containing information about the device where event occurred. + */ + device_info?: { + /** + * Battery charge level percentage. + */ + battery_level?: number + /** + * Device brand. + */ + brand?: string + /** + * User device's mobile carrier. + */ + carrier?: string + /** + * Number of CPU cores. + */ + cpu_cores?: number + /** + * External storage free space in GB. + */ + external_storage_free_space?: number + /** + * External storage size in GB. + */ + external_storage_size?: number + /** + * Device form factor (desktop, laptop, cellphone, tablet, smartwatch, tv, vr, console, other). + */ + form_factor?: string + /** + * Kernel version of the device's operating system. + */ + kernel_version?: string + /** + * List of user installed languages. ISO 639-1 format. + */ + languages?: string[] + /** + * Device locale in BCP-47 format. + */ + locale?: string + /** + * Device model name. + */ + model?: string + /** + * Network type (wifi, cellular_2g, cellular_3g, cellular_4g, cellular_5g, cellular_6g, ethernet, unknown). + */ + network_type?: string + /** + * OS Family (ios, android, macos, windows, linux, bsd, other). + */ + os_family?: string + /** + * Short name of the OS. + */ + os_name?: string + /** + * Marketing name for the release version. + */ + os_release_name?: string + /** + * Full name of the OS version. + */ + os_version?: string + /** + * Screen density, PPI. + */ + screen_density?: number + /** + * Screen height in pixels. + */ + screen_height?: number + /** + * Screen width in pixels. + */ + screen_width?: number + /** + * Internal storage free space in GB. + */ + storage_free_space?: number + /** + * Internal storage size in GB. + */ + storage_size?: number + /** + * Device timezone. + */ + timezone?: string + /** + * Timezone abbreviation. + */ + timezone_abbr?: string + /** + * Device type. + */ + type?: string + } /** * Whether the event occurred when the user device was connected to wifi. */ diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts index aee1acd0f89..984189ca96e 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -1,18 +1,43 @@ import type { ActionDefinition, RequestClient } from '@segment/actions-core' import { IntegrationError } from '@segment/actions-core' -import { API_VERSION, PARTNER_NAME } from '../constants' +import type { DependsOnConditions } from '@segment/actions-core/destination-kit/types' +import { API_VERSION, PARTNER_NAME, EVENT_NAME } from '../constants' import type { Settings } from '../generated-types' -import { custom_data_field } from '../pinterest-capi-custom-data' +import { getCustomDataField, getCustomDataField2, getContentsField } from '../pinterest-capi-custom-data' import { user_data_field, hash_user_data } from '../pinterset-capi-user-data' import type { Payload } from './generated-types' +import type { PinterestEventPayload, LegacyPinterestEventPayload, CustomData } from '../types' import isEmpty from 'lodash/isEmpty' import dayjs from '../../../lib/dayjs' +const DEPENDS_ON_LEGACY: DependsOnConditions = { + match: 'any', + conditions: [ + { fieldKey: 'data_format', operator: 'is', value: 'legacy' }, + { fieldKey: 'data_format', operator: 'is', value: undefined } + ] +} + +const DEPENDS_ON_LATEST: DependsOnConditions = { + conditions: [{ fieldKey: 'data_format', operator: 'is', value: 'latest' }] +} + const action: ActionDefinition = { title: 'Report Conversion Event', description: 'Report events directly to Pinterest. Data shared can power Pinterest solutions that will help evaluate ads effectiveness and improve content, targeting, and placement of future ads.', fields: { + data_format: { + label: 'Use Latest Fields', + description: + 'Switch between the latest field configuration and the legacy fields. New instances default to the latest fields.', + type: 'string', + choices: [ + { label: 'Latest Fields', value: 'latest' }, + { label: 'Legacy Fields', value: 'legacy' } + ], + default: 'latest' + }, event_name: { label: 'Event Name', description: @@ -20,15 +45,28 @@ const action: ActionDefinition = { type: 'string', required: true, choices: [ - { label: 'Add to Cart', value: 'add_to_cart' }, - { label: 'Checkout', value: 'checkout' }, - { label: 'Lead', value: 'lead' }, - { label: 'Page Visit', value: 'page_visit' }, - { label: 'Search', value: 'search' }, - { label: 'Sign Up', value: 'signup' }, - { label: 'View Category', value: 'view_category' }, - { label: 'Watch Video', value: 'watch_video' }, - { label: 'Custom', value: 'custom' } + { label: 'Add Payment Info', value: EVENT_NAME.ADD_PAYMENT_INFO }, + { label: 'Add to Cart', value: EVENT_NAME.ADD_TO_CART }, + { label: 'Add to Wishlist', value: EVENT_NAME.ADD_TO_WISHLIST }, + { label: 'App Install', value: EVENT_NAME.APP_INSTALL }, + { label: 'App Open', value: EVENT_NAME.APP_OPEN }, + { label: 'Checkout', value: EVENT_NAME.CHECKOUT }, + { label: 'Contact', value: EVENT_NAME.CONTACT }, + { label: 'Custom', value: EVENT_NAME.CUSTOM }, + { label: 'Customize Product', value: EVENT_NAME.CUSTOMIZE_PRODUCT }, + { label: 'Find Location', value: EVENT_NAME.FIND_LOCATION }, + { label: 'Initiate Checkout', value: EVENT_NAME.INITIATE_CHECKOUT }, + { label: 'Lead', value: EVENT_NAME.LEAD }, + { label: 'Page Visit', value: EVENT_NAME.PAGE_VISIT }, + { label: 'Schedule', value: EVENT_NAME.SCHEDULE }, + { label: 'Search', value: EVENT_NAME.SEARCH }, + { label: 'Sign Up', value: EVENT_NAME.SIGNUP }, + { label: 'Start Trial', value: EVENT_NAME.START_TRIAL }, + { label: 'Submit Application', value: EVENT_NAME.SUBMIT_APPLICATION }, + { label: 'Subscribe', value: EVENT_NAME.SUBSCRIBE }, + { label: 'View Category', value: EVENT_NAME.VIEW_CATEGORY }, + { label: 'View Content', value: EVENT_NAME.VIEW_CONTENT }, + { label: 'Watch Video', value: EVENT_NAME.WATCH_VIDEO } ] }, action_source: { @@ -80,73 +118,300 @@ const action: ActionDefinition = { type: 'boolean', default: true }, + advertiser_tracking_enabled: { + label: 'Advertiser Tracking Enabled', + description: 'Defines whether the user has enabled ATT permission on their iOS device.', + type: 'boolean', + default: { + '@path': '$.context.device.adTrackingEnabled' + } + }, user_data: user_data_field, - custom_data: custom_data_field, + + // --- Legacy fields (shown when data_format is 'legacy' or undefined) --- + custom_data: getCustomDataField(DEPENDS_ON_LEGACY), app_id: { - label: 'App ID', + label: '[Legacy] App ID', description: 'The app store app ID.', type: 'string', + depends_on: DEPENDS_ON_LEGACY, default: { '@path': 'context.app.id' } }, app_name: { - label: 'App Name', - description: 'Name of the app. ', + label: '[Legacy] App Name', + description: 'Name of the app.', type: 'string', - required: true, + required: DEPENDS_ON_LEGACY, + depends_on: DEPENDS_ON_LEGACY, default: { '@path': '$.context.app.name' } }, app_version: { - label: 'App Version', + label: '[Legacy] App Version', description: 'Version of the app.', type: 'string', + depends_on: DEPENDS_ON_LEGACY, default: { '@path': '$.context.app.version' } }, device_brand: { - label: 'Device Brand', + label: '[Legacy] Device Brand', description: 'Brand of the user device.', type: 'string', + depends_on: DEPENDS_ON_LEGACY, default: { '@path': '$.context.device.brand' } }, device_carrier: { - label: 'Device Carrier', - description: 'User device’s mobile carrier. ', + label: '[Legacy] Device Carrier', + description: "User device's mobile carrier.", type: 'string', + depends_on: DEPENDS_ON_LEGACY, default: { '@path': 'context.device.carrier' } }, device_model: { - label: 'Device Model', + label: '[Legacy] Device Model', description: 'Model of the user device.', type: 'string', + depends_on: DEPENDS_ON_LEGACY, default: { '@path': '$.context.device.model' } }, device_type: { - label: 'Device Type', + label: '[Legacy] Device Type', description: 'Type of the user device.', type: 'string', + depends_on: DEPENDS_ON_LEGACY, default: { '@path': '$.context.device.type' } }, os_version: { - label: 'OS Version', + label: '[Legacy] OS Version', description: 'Version of the device operating system.', type: 'string', + depends_on: DEPENDS_ON_LEGACY, default: { '@path': '$.context.os.version' } }, + + // --- Latest fields (shown when data_format is 'latest') --- + custom_data_2: getCustomDataField2(DEPENDS_ON_LATEST), + contents: getContentsField(DEPENDS_ON_LATEST), + app_info: { + label: 'App Info', + description: 'Object containing information about the application where event occurred.', + type: 'object', + depends_on: DEPENDS_ON_LATEST, + properties: { + app_id: { + label: 'App ID', + type: 'string', + description: 'App ID in Google Play Store, AppStore or other stores.' + }, + app_name: { + label: 'App Name', + type: 'string', + description: 'Name of the app.' + }, + app_package_name: { + label: 'App Package Name', + type: 'string', + description: 'App package name.' + }, + app_store: { + label: 'App Store', + type: 'string', + description: 'The name of the app distributor or store from which the app was installed.' + }, + app_version: { + label: 'App Version', + type: 'string', + description: 'App version.' + }, + install_time: { + label: 'Install Time', + type: 'datetime', + description: 'App install time. Will be converted to Unix timestamp in seconds before sending.' + }, + user_agent: { + label: 'User Agent', + type: 'string', + description: 'User Agent request header.' + }, + window_height: { + label: 'Window Height', + type: 'integer', + description: 'Inner height of the window or viewport.' + }, + window_width: { + label: 'Window Width', + type: 'integer', + description: 'Inner width of the window or viewport.' + } + }, + default: { + app_name: { '@path': '$.context.app.name' }, + app_package_name: { '@path': '$.context.app.namespace' }, + app_version: { '@path': '$.context.app.version' }, + user_agent: { '@path': '$.context.userAgent' }, + window_height: { '@path': '$.context.screen.height' }, + window_width: { '@path': '$.context.screen.width' } + } + }, + device_info: { + label: 'Device Info', + description: 'Object containing information about the device where event occurred.', + type: 'object', + depends_on: DEPENDS_ON_LATEST, + properties: { + battery_level: { + label: 'Battery Level', + type: 'integer', + description: 'Battery charge level percentage.' + }, + brand: { + label: 'Brand', + type: 'string', + description: 'Device brand.' + }, + carrier: { + label: 'Carrier', + type: 'string', + description: "User device's mobile carrier." + }, + cpu_cores: { + label: 'CPU Cores', + type: 'integer', + description: 'Number of CPU cores.' + }, + external_storage_free_space: { + label: 'External Storage Free Space', + type: 'integer', + description: 'External storage free space in GB.' + }, + external_storage_size: { + label: 'External Storage Size', + type: 'integer', + description: 'External storage size in GB.' + }, + form_factor: { + label: 'Form Factor', + type: 'string', + description: 'Device form factor (desktop, laptop, cellphone, tablet, smartwatch, tv, vr, console, other).' + }, + kernel_version: { + label: 'Kernel Version', + type: 'string', + description: "Kernel version of the device's operating system." + }, + languages: { + label: 'Languages', + type: 'string', + multiple: true, + description: 'List of user installed languages. ISO 639-1 format.' + }, + locale: { + label: 'Locale', + type: 'string', + description: 'Device locale in BCP-47 format.' + }, + model: { + label: 'Model', + type: 'string', + description: 'Device model name.' + }, + network_type: { + label: 'Network Type', + type: 'string', + description: + 'Network type (wifi, cellular_2g, cellular_3g, cellular_4g, cellular_5g, cellular_6g, ethernet, unknown).' + }, + os_family: { + label: 'OS Family', + type: 'string', + description: 'OS Family (ios, android, macos, windows, linux, bsd, other).' + }, + os_name: { + label: 'OS Name', + type: 'string', + description: 'Short name of the OS.' + }, + os_release_name: { + label: 'OS Release Name', + type: 'string', + description: 'Marketing name for the release version.' + }, + os_version: { + label: 'OS Version', + type: 'string', + description: 'Full name of the OS version.' + }, + screen_density: { + label: 'Screen Density', + type: 'integer', + description: 'Screen density, PPI.' + }, + screen_height: { + label: 'Screen Height', + type: 'integer', + description: 'Screen height in pixels.' + }, + screen_width: { + label: 'Screen Width', + type: 'integer', + description: 'Screen width in pixels.' + }, + storage_free_space: { + label: 'Storage Free Space', + type: 'integer', + description: 'Internal storage free space in GB.' + }, + storage_size: { + label: 'Storage Size', + type: 'integer', + description: 'Internal storage size in GB.' + }, + timezone: { + label: 'Timezone', + type: 'string', + description: 'Device timezone.' + }, + timezone_abbr: { + label: 'Timezone Abbreviation', + type: 'string', + description: 'Timezone abbreviation.' + }, + type: { + label: 'Type', + type: 'string', + description: 'Device type.' + } + }, + default: { + brand: { '@path': '$.context.device.manufacturer' }, + carrier: { '@path': '$.context.network.carrier' }, + model: { '@path': '$.context.device.model' }, + type: { '@path': '$.context.device.type' }, + os_version: { '@path': '$.context.os.version' }, + locale: { '@path': '$.context.locale' }, + screen_density: { '@path': '$.context.screen.density' }, + screen_height: { '@path': '$.context.screen.height' }, + screen_width: { '@path': '$.context.screen.width' }, + timezone: { '@path': '$.context.timezone' } + } + }, + + // --- Shared fields (always shown) --- wifi: { label: 'Wifi', description: 'Whether the event occurred when the user device was connected to wifi.', @@ -165,6 +430,7 @@ const action: ActionDefinition = { return processPayload(request, settings, payload) } } + async function processPayload(request: RequestClient, settings: Settings, payload: Payload) { if ( isEmpty(payload.user_data?.email) && @@ -187,7 +453,80 @@ async function processPayload(request: RequestClient, settings: Settings, payloa }) } -function createPinterestPayload(payload: Payload) { +function convertInstallTime(value: string | number | undefined | null): number | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value === 'number') return value + const parsed = dayjs.utc(value) + if (!parsed.isValid()) return undefined + return parsed.unix() +} + +function buildAppInfo(payload: Payload) { + const appInfo = { + ...payload.app_info, + install_time: convertInstallTime(payload.app_info?.install_time) + } + const hasContent = Object.values(appInfo).some((v) => v !== undefined && v !== null) + return hasContent ? appInfo : undefined +} + +function buildDeviceInfo(payload: Payload) { + if (!payload.device_info) return undefined + const hasContent = Object.values(payload.device_info).some((v) => v !== undefined && v !== null) + return hasContent ? payload.device_info : undefined +} + +function buildCustomData(payload: Payload): CustomData { + const isStructured = payload.data_format === 'latest' + + if (isStructured) { + return { + currency: payload.custom_data_2?.currency, + value: typeof payload.custom_data_2?.value === 'number' ? String(payload.custom_data_2.value) : undefined, + content_ids: payload.custom_data_2?.content_ids, + contents: payload.contents?.map((item) => ({ + ...item, + item_price: typeof item.item_price === 'number' ? String(item.item_price) : undefined + })), + num_items: payload.custom_data_2?.num_items, + order_id: payload.custom_data_2?.order_id, + search_string: payload.custom_data_2?.search_string, + opt_out_type: payload.custom_data_2?.opt_out_type, + content_brand: payload.custom_data_2?.content_brand, + content_category: payload.custom_data_2?.content_category, + content_name: payload.custom_data_2?.content_name, + predicted_ltv: + typeof payload.custom_data_2?.predicted_ltv === 'number' + ? String(payload.custom_data_2.predicted_ltv) + : undefined, + np: PARTNER_NAME + } + } + + return { + currency: payload.custom_data?.currency, + value: typeof payload.custom_data?.value === 'number' ? String(payload.custom_data.value) : undefined, + content_ids: payload.custom_data?.content_ids, + contents: payload.custom_data?.contents?.map((item) => ({ + ...item, + item_price: typeof item.item_price === 'number' ? String(item.item_price) : undefined + })), + num_items: payload.custom_data?.num_items, + order_id: payload.custom_data?.order_id, + search_string: payload.custom_data?.search_string, + opt_out_type: payload.custom_data?.opt_out_type, + content_brand: payload.custom_data?.content_brand, + content_category: payload.custom_data?.content_category, + content_name: payload.custom_data?.content_name, + predicted_ltv: + typeof payload.custom_data?.predicted_ltv === 'number' ? String(payload.custom_data.predicted_ltv) : undefined, + np: PARTNER_NAME + } +} + +function createPinterestPayload(payload: Payload): (PinterestEventPayload | LegacyPinterestEventPayload)[] { + const isStructured = payload.data_format === 'latest' + return [ { event_name: payload.event_name, @@ -197,28 +536,19 @@ function createPinterestPayload(payload: Payload) { event_source_url: payload.event_source_url, partner_name: PARTNER_NAME, opt_out: payload.opt_out, + advertiser_tracking_enabled: payload.advertiser_tracking_enabled, user_data: hash_user_data({ user_data: payload.user_data }), - custom_data: { - currency: payload?.custom_data?.currency, - value: typeof payload?.custom_data?.value === 'number' ? String(payload.custom_data.value) : undefined, - content_ids: payload.custom_data?.content_ids, - contents: payload.custom_data?.contents?.map((item) => ({ - ...item, - item_price: typeof item.item_price === 'number' ? String(item.item_price) : undefined - })), - num_items: payload.custom_data?.num_items, - order_id: payload.custom_data?.order_id, - search_string: payload.custom_data?.search_string, - opt_out_type: payload.custom_data?.opt_out_type - }, - app_id: payload.app_id, - app_name: payload.app_name, - app_version: payload.app_version, - device_brand: payload.device_brand, - device_carrier: payload.device_carrier, - device_model: payload.device_model, - device_type: payload.device_type, - os_version: payload.os_version, + custom_data: buildCustomData(payload), + app_id: isStructured ? undefined : payload.app_id, + app_name: isStructured ? undefined : payload.app_name, + app_version: isStructured ? undefined : payload.app_version, + app_info: isStructured ? buildAppInfo(payload) : undefined, + device_brand: isStructured ? undefined : payload.device_brand, + device_carrier: isStructured ? undefined : payload.device_carrier, + device_model: isStructured ? undefined : payload.device_model, + device_type: isStructured ? undefined : payload.device_type, + os_version: isStructured ? undefined : payload.os_version, + device_info: isStructured ? buildDeviceInfo(payload) : undefined, wifi: payload.wifi, language: payload.language } diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/types.ts b/packages/destination-actions/src/destinations/pinterest-conversions/types.ts index f2087b125b7..5f5e35440e1 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/types.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/types.ts @@ -7,3 +7,132 @@ export class PinterestConversionsTestAuthenticationError extends HTTPError { } } } + +// --- Pinterest Conversions API Event Types --- + +export interface UserData { + em?: string[] + ph?: string[] + ge?: string[] + db?: string[] + ln?: string[] + fn?: string[] + ct?: string[] + st?: string[] + zp?: string[] + country?: string[] + external_id?: string[] + client_ip_address?: string + client_user_agent?: string + hashed_maids?: string[] + click_id?: string | null + partner_id?: string | null +} + +export interface ContentsItem { + id?: string + // Converted from number to string before sending + item_price?: string + quantity?: number + item_brand?: string + item_brand_id?: string + item_category?: string + item_name?: string +} + +export interface CustomData { + currency?: string + // Converted from number to string before sending + value?: string + content_ids?: string[] + contents?: ContentsItem[] + num_items?: number + order_id?: string + search_string?: string + opt_out_type?: string + content_brand?: string + content_category?: string + content_name?: string + // Converted from number to string before sending + predicted_ltv?: string + np?: string +} + +export interface AppInfo { + app_id?: string + app_name?: string + app_package_name?: string + app_store?: string + app_version?: string + install_time?: number + user_agent?: string + window_height?: number + window_width?: number +} + +export interface DeviceInfo { + battery_level?: number + brand?: string + carrier?: string + cpu_cores?: number + external_storage_free_space?: number + external_storage_size?: number + form_factor?: string + kernel_version?: string + languages?: string[] + locale?: string + model?: string + network_type?: string + os_family?: string + os_name?: string + os_release_name?: string + os_version?: string + screen_density?: number + screen_height?: number + screen_width?: number + storage_free_space?: number + storage_size?: number + timezone?: string + timezone_abbr?: string + type?: string +} + +export interface PinterestEventPayload { + event_name: string + action_source: string + event_time: number + event_id: string + event_source_url?: string + partner_name: string + opt_out?: boolean + advertiser_tracking_enabled?: boolean + user_data: UserData + custom_data: CustomData + app_info?: AppInfo + device_info?: DeviceInfo + wifi?: boolean + language?: string +} + +export interface LegacyPinterestEventPayload { + event_name: string + action_source: string + event_time: number + event_id: string + event_source_url?: string + partner_name: string + opt_out?: boolean + advertiser_tracking_enabled?: boolean + user_data: UserData + custom_data: CustomData + app_id?: string + app_name?: string + app_version?: string + device_brand?: string + device_carrier?: string + device_model?: string + device_type?: string + os_version?: string + wifi?: boolean + language?: string +} diff --git a/packages/destination-actions/src/destinations/rokt-capi/send/__tests__/index.test.ts b/packages/destination-actions/src/destinations/rokt-capi/send/__tests__/index.test.ts index a3767252cbc..45d2b5b0fec 100644 --- a/packages/destination-actions/src/destinations/rokt-capi/send/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/rokt-capi/send/__tests__/index.test.ts @@ -611,6 +611,73 @@ describe('RoktCapi.send', () => { expect(responses[0].status).toBe(200) }) + it('should not send empty or whitespace-only strings as hashed values', async () => { + const event = createTestEvent({ + event: 'Order Completed', + messageId: 'msg-whitespace', + timestamp: '2024-01-18T12:00:00.000Z', + type: 'track', + properties: { + order_id: 'order-ws', + revenue: 50.00, + currency: 'USD' + }, + userId: 'user-ws' + }) + + nock('https://inbound.mparticle.com') + .post('/s2s/v2/events', (body) => { + expect(body.user_identities.email).toBeUndefined() + expect(body.user_identities.other).toBeUndefined() + expect(body.user_identities.customerid).toBe('user-ws') + expect(body.user_attributes.firstname).toBeUndefined() + expect(body.user_attributes.firstnamesha256).toBeUndefined() + expect(body.user_attributes.lastname).toBeUndefined() + expect(body.user_attributes.lastnamesha256).toBeUndefined() + expect(body.user_attributes.mobile).toBeUndefined() + expect(body.user_attributes.mobilesha256).toBeUndefined() + expect(body.user_attributes.billingzipcode).toBeUndefined() + expect(body.user_attributes.billingzipsha256).toBeUndefined() + expect(body.device_info.http_header_user_agent).toBeUndefined() + expect(body.device_info.ios_advertising_id).toBeUndefined() + expect(body.ip).toBeUndefined() + return true + }) + .reply(200, { success: true }) + + const responses = await testDestination.testAction('send', { + event, + useDefaultMappings: true, + mapping: { + hashingConfiguration: { + hashEmail: true, + hashFirstName: true, + hashLastName: true, + hashMobile: true, + hashBillingZipcode: true + }, + user_identities: { + email: ' ', + customerid: 'user-ws' + }, + user_attributes: { + firstname: '', + lastname: ' ', + mobile: ' ', + billingzipcode: '' + }, + device_info: { + http_header_user_agent: ' ', + ios_advertising_id: '' + }, + ip: ' ' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + it('should reject event with no identifiers', async () => { const event = createTestEvent({ event: 'Order Completed', diff --git a/packages/destination-actions/src/destinations/rokt-capi/send/functions.ts b/packages/destination-actions/src/destinations/rokt-capi/send/functions.ts index e912fcb53c2..8b5c87b8b7a 100644 --- a/packages/destination-actions/src/destinations/rokt-capi/send/functions.ts +++ b/packages/destination-actions/src/destinations/rokt-capi/send/functions.ts @@ -72,7 +72,7 @@ function validate(payload: Payload): string | undefined { device_info: { ios_advertising_id, android_advertising_id, - ios_idfv, + ios_idfv, android_uuid } = {}, user_identities: { @@ -82,7 +82,7 @@ function validate(payload: Payload): string | undefined { rtid } = payload - if(!(email || customerid || rtid || ios_advertising_id || android_advertising_id || ios_idfv || android_uuid)) { + if(!(trimmed(email) || trimmed(customerid) || trimmed(rtid) || trimmed(ios_advertising_id) || trimmed(android_advertising_id) || trimmed(ios_idfv) || trimmed(android_uuid))) { return 'At least one of the following is required: iOS Advertising ID, Android Advertising ID, iOS ID for Vendor, Android UUID, Email, Customer ID, RTID.' } } @@ -100,7 +100,7 @@ function buildJSONItem(payload: Payload): RoktJSON { http_header_user_agent, ios_advertising_id, android_advertising_id, - ios_idfv, + ios_idfv, android_uuid } = {}, user_identities: { @@ -116,22 +116,31 @@ function buildJSONItem(payload: Payload): RoktJSON { gender, ...restUserAttributes } = {}, - ip, + ip, rtid } = payload + const trimmedUserAgent = trimmed(http_header_user_agent) + const trimmedIosAdId = trimmed(ios_advertising_id) + const trimmedAndroidAdId = trimmed(android_advertising_id) + const trimmedIosIdfv = trimmed(ios_idfv) + const trimmedAndroidUuid = trimmed(android_uuid) + const trimmedEmail = trimmed(email) + const trimmedCustomerId = trimmed(customerid) + const trimmedRtid = trimmed(rtid) + const device_info: RoktJSON['device_info'] = { - ...(http_header_user_agent ? { http_header_user_agent } : {}), - ...(ios_advertising_id ? { ios_advertising_id } : {}), - ...(android_advertising_id ? { android_advertising_id } : {}), - ...(ios_idfv ? { ios_idfv } : {}), - ...(android_uuid ? { android_uuid } : {}) + ...(trimmedUserAgent ? { http_header_user_agent: trimmedUserAgent } : {}), + ...(trimmedIosAdId ? { ios_advertising_id: trimmedIosAdId } : {}), + ...(trimmedAndroidAdId ? { android_advertising_id: trimmedAndroidAdId } : {}), + ...(trimmedIosIdfv ? { ios_idfv: trimmedIosIdfv } : {}), + ...(trimmedAndroidUuid ? { android_uuid: trimmedAndroidUuid } : {}) } const user_identities: RoktJSON['user_identities'] = { - ...(email ? maybeHash(email, hashEmail, 'email', 'other', (value) => value.toLocaleLowerCase().trim()) : {}), - ...(customerid ? { customerid } : {}), - ...(rtid ? { other2: rtid } : {}) + ...(trimmedEmail ? maybeHash(trimmedEmail, hashEmail, 'email', 'other', (value) => value.toLocaleLowerCase().trim()) : {}), + ...(trimmedCustomerId ? { customerid: trimmedCustomerId } : {}), + ...(trimmedRtid ? { other2: trimmedRtid } : {}) } const audienceJSON = getAudienceJSON(payload) @@ -144,12 +153,19 @@ function buildJSONItem(payload: Payload): RoktJSON { const { audience_name, status } = audienceJSON?.data?.custom_attributes || {} + const trimmedFirstname = trimmed(firstname) + const trimmedLastname = trimmed(lastname) + const trimmedMobile = trimmed(mobile) + const trimmedBillingZipcode = trimmed(billingzipcode) + const trimmedDob = trimmed(dob) + const trimmedIp = trimmed(ip) + const user_attributes: RoktJSON['user_attributes'] = { - ...(firstname ? maybeHash(firstname, hashFirstName, 'firstname', 'firstnamesha256', (value) => value.trim()) : {}), - ...(lastname ? maybeHash(lastname, hashLastName, 'lastname', 'lastnamesha256', (value) => value.trim()) : {}), - ...(mobile ? maybeHash(mobile, hashMobile, 'mobile', 'mobilesha256', (value) => value.trim()) : {}), - ...(billingzipcode ? maybeHash(billingzipcode, hashBillingZipcode, 'billingzipcode', 'billingzipsha256', (value) => value.trim()) : {}), - ...(dob ? { dob: new Date(dob).toISOString().slice(0, 10).replace(/-/g, '')} : {}), + ...(trimmedFirstname ? maybeHash(trimmedFirstname, hashFirstName, 'firstname', 'firstnamesha256', (value) => value.trim()) : {}), + ...(trimmedLastname ? maybeHash(trimmedLastname, hashLastName, 'lastname', 'lastnamesha256', (value) => value.trim()) : {}), + ...(trimmedMobile ? maybeHash(trimmedMobile, hashMobile, 'mobile', 'mobilesha256', (value) => value.trim()) : {}), + ...(trimmedBillingZipcode ? maybeHash(trimmedBillingZipcode, hashBillingZipcode, 'billingzipcode', 'billingzipsha256', (value) => value.trim()) : {}), + ...(trimmedDob ? { dob: new Date(trimmedDob).toISOString().slice(0, 10).replace(/-/g, '')} : {}), ...(gender === 'm' || gender === 'f' ? { gender } : {}), ...(restUserAttributes && Object.keys(restUserAttributes).length > 0 ? sanitize(restUserAttributes, ['boolean', 'string', 'number'], true) : {}), ...(audience_name && status ? { [`segment_${audience_name}`]: status === 'add' } : {}) @@ -158,11 +174,11 @@ function buildJSONItem(payload: Payload): RoktJSON { const item: RoktJSON = { environment: 'production', device_info, - user_attributes, - user_identities, - ...(rtid ? { integration_attributes: { "1277": { passbackconversiontrackingid: rtid } } } : {}), + user_attributes, + user_identities, + ...(trimmedRtid ? { integration_attributes: { "1277": { passbackconversiontrackingid: trimmedRtid } } } : {}), ...(events && events.length > 0 ? { events } : {}), - ...(ip ? {ip} : {}) + ...(trimmedIp ? { ip: trimmedIp } : {}) } return item @@ -275,6 +291,14 @@ function sanitize(obj: Record | undefined, allowedTypes: ('stri return result } +function trimmed(value: string | undefined | null): string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed || undefined + } + return undefined +} + function maybeHash(value: string | undefined, shouldHash: boolean | undefined, key: string, hashedKey: string, cleaningFunction?: (input: string) => string): Record { if (!value) { return {} diff --git a/packages/destination-actions/src/destinations/s3/__e2e__/index.ts b/packages/destination-actions/src/destinations/s3/__e2e__/index.ts new file mode 100644 index 00000000000..1c489a01408 --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/__e2e__/index.ts @@ -0,0 +1,27 @@ +/** + * E2E config for the AWS S3 (Actions) destination. + * + * These tests write REAL objects to a REAL S3 bucket via the same STS assume-role path the + * Integrations service uses. There is no HTTP response body to assert on, so fixtures verify only + * that the upload did not throw (status: 'success') — except the flag-off hashing case, which is + * expected to throw a PayloadValidationError before any upload (status: 'error'). Inspect the + * bucket manually to confirm file contents (e.g. that hashed columns are actually hashed). + * + * Required environment variables: + * - E2E_S3_IAM_ROLE_ARN: IAM role ARN with write access to the bucket (destination setting) + * - E2E_S3_BUCKET_NAME: Target S3 bucket name (destination setting) + * - E2E_S3_REGION: Bucket region code, e.g. us-east-1 (destination setting) + * - E2E_S3_EXTERNAL_ID: External ID for the IAM role (destination setting) + * - AMAZON_S3_ACTIONS_ROLE_ADDRESS: Intermediary role ARN the S3 client assumes first (read by client.ts) + * - AMAZON_S3_ACTIONS_EXTERNAL_ID: Intermediary external ID (read by client.ts) + */ +import type { E2EDestinationConfig } from '@segment/actions-core' + +export const config: E2EDestinationConfig = { + settings: { + iam_role_arn: { $env: 'E2E_S3_IAM_ROLE_ARN' }, + s3_aws_bucket_name: { $env: 'E2E_S3_BUCKET_NAME' }, + s3_aws_region: { $env: 'E2E_S3_REGION' }, + iam_external_id: { $env: 'E2E_S3_EXTERNAL_ID' } + } +} diff --git a/packages/destination-actions/src/destinations/s3/constants.ts b/packages/destination-actions/src/destinations/s3/constants.ts new file mode 100644 index 00000000000..262ff619b3f --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/constants.ts @@ -0,0 +1 @@ +export const S3_HASHING_FEATURE_FLAG = 'actions-s3-hashing' diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/audiences.e2e.ts b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/audiences.e2e.ts new file mode 100644 index 00000000000..65998bf14da --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/audiences.e2e.ts @@ -0,0 +1,94 @@ +/** + * E2E fixtures for audience events. + * + * The audience columns are populated from context.personas: + * - audience_name <- computation_key + * - audience_id <- computation_id + * - audience_action<- traits_or_props[computation_key] (true = enter, false = exit) + * + * (audience_space_id is left empty — the e2e audience helpers don't set personas.space_id.) + * + * All audience source types (Engage/RETL/Journeys) resolve to the same S3 payload via the mapping + * directives, so from the destination's perspective they are equivalent. These fixtures use Engage + * events only and focus on what actually varies for S3: audience_action true vs false, single vs + * batch. Success = the upload did not throw; inspect the bucket to confirm the audience columns. + */ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' +import syncToS3 from '../index' + +const COMPUTATION_KEY = 'e2e_s3_audience' +const COMPUTATION_ID = 'aud_e2e_s3_001' + +const FAILURE_HINT = + 'Ensure E2E_S3_* and AMAZON_S3_ACTIONS_* env vars are set and the IAM role can write to the bucket.' + +const baseMapping = { + ...defaultValues(syncToS3.fields), + delimiter: ',', + file_extension: 'csv', + s3_aws_folder_name: 'e2e/audiences/' +} + +const fixtures: E2EFixture[] = [ + { + description: 'Audience (track): add a user (audience_action = enter)', + subscribe: 'type = "track"', + mode: 'single', + mapping: { ...baseMapping, filename_prefix: 'aud-add' }, + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-s3-aud-user-001', + email: 'e2e-s3-aud-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Audience (identify): remove a user (audience_action = exit)', + subscribe: 'type = "identify"', + mode: 'single', + mapping: { ...baseMapping, filename_prefix: 'aud-remove' }, + event: createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-s3-aud-user-002', + email: 'e2e-s3-aud-002@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Audience (track): batch of enter + exit events', + subscribe: 'type = "track"', + mode: 'batch', + mapping: { ...baseMapping, filename_prefix: 'aud-batch' }, + events: [ + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-s3-aud-user-003', + email: 'e2e-s3-aud-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-s3-aud-user-004', + email: 'e2e-s3-aud-004@segment.com' + }) + ], + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/batching.e2e.ts b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/batching.e2e.ts new file mode 100644 index 00000000000..5bef9164bd3 --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/batching.e2e.ts @@ -0,0 +1,87 @@ +/** + * E2E fixtures exercising batch vs non-batch execution paths. + * + * - mode: 'single' -> perform() -> send([payload], ...) + * - mode: 'batch' -> performBatch() -> send(payloads, ...) (batch_size column reflects count) + * + * Success = the upload did not throw. Inspect the bucket to confirm the batch file contains one row + * per event and the batch_size column matches the event count. + */ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import syncToS3 from '../index' + +const FAILURE_HINT = + 'Ensure E2E_S3_* and AMAZON_S3_ACTIONS_* env vars are set and the IAM role can write to the bucket.' + +const baseMapping = { + ...defaultValues(syncToS3.fields), + delimiter: ',', + file_extension: 'csv', + s3_aws_folder_name: 'e2e/batching/' +} + +const fixtures: E2EFixture[] = [ + { + description: 'Batching: single event via onEvent (non-batch path)', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...baseMapping, + filename_prefix: 'nonbatch-single', + enable_batching: false + }, + event: createE2EEvent('track', 'E2E Single Non-Batch', { + userId: 'e2e-s3-batch-user-001', + properties: { email: 'e2e-s3-batch-001@segment.com' } + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batching: multiple events via onBatch (batch path)', + subscribe: 'type = "track"', + mode: 'batch', + mapping: { + ...baseMapping, + filename_prefix: 'batch-multi', + enable_batching: true + }, + events: [ + createE2EEvent('track', 'E2E Batch 1', { + userId: 'e2e-s3-batch-user-002', + properties: { email: 'e2e-s3-batch-002@segment.com' } + }), + createE2EEvent('track', 'E2E Batch 2', { + userId: 'e2e-s3-batch-user-003', + properties: { email: 'e2e-s3-batch-003@segment.com' } + }), + createE2EEvent('track', 'E2E Batch 3', { + userId: 'e2e-s3-batch-user-004', + properties: { email: 'e2e-s3-batch-004@segment.com' } + }) + ], + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batching: single-event batch (batch of one via onBatch)', + subscribe: 'type = "track"', + mode: 'batch', + mapping: { + ...baseMapping, + filename_prefix: 'batch-one', + enable_batching: true + }, + events: [ + createE2EEvent('track', 'E2E Batch Of One', { + userId: 'e2e-s3-batch-user-005', + properties: { email: 'e2e-s3-batch-005@segment.com' } + }) + ], + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/fields.e2e.ts b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/fields.e2e.ts new file mode 100644 index 00000000000..bf33f157147 --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/fields.e2e.ts @@ -0,0 +1,102 @@ +/** + * E2E fixtures exercising combinations of columns and value shapes. + * + * `columns` is an object mapping output-column name -> value (mapping-kit directives). These + * fixtures override `columns` explicitly (rather than relying on defaultValues) to control exactly + * which columns land in the file. Delimiter and file-extension coverage lives in formats.e2e.ts; + * this file focuses on which columns are written and how object values serialize. Success = the + * upload did not throw; inspect the bucket to confirm headers and object serialization. + */ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import syncToS3 from '../index' + +const FAILURE_HINT = + 'Ensure E2E_S3_* and AMAZON_S3_ACTIONS_* env vars are set and the IAM role can write to the bucket.' + +const baseMapping = { + ...defaultValues(syncToS3.fields), + file_extension: 'csv', + s3_aws_folder_name: 'e2e/fields/' +} + +const richEvent = () => + createE2EEvent('track', 'E2E Fields Rich', { + userId: 'e2e-s3-fields-user-001', + anonymousId: 'e2e-s3-fields-anon-001', + properties: { + email: 'e2e-s3-fields-001@segment.com', + order_id: '$guid:order', + total: 42.5, + items: ['sku-1', 'sku-2'], + nested: { a: 1, b: { c: 2 } } + }, + traits: { first_name: 'Ada', last_name: 'Lovelace', email: 'e2e-s3-fields-001@segment.com' }, + integrations: { All: false, 'AWS S3 (Actions)': true } + }) + +const fixtures: E2EFixture[] = [ + { + description: 'Fields: minimal columns (user_id + email only)', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...baseMapping, + delimiter: ',', + filename_prefix: 'fields-minimal', + columns: { + user_id: { '@path': '$.userId' }, + email: { '@path': '$.properties.email' } + } + }, + event: createE2EEvent('track', 'E2E Fields Minimal', { + userId: 'e2e-s3-fields-user-002', + properties: { email: 'e2e-s3-fields-002@segment.com' } + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Fields: all standard columns plus object columns (properties/traits/context)', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...baseMapping, + delimiter: ',', + filename_prefix: 'fields-all' + }, + event: richEvent(), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Fields: custom (additional) columns beyond the standard set', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...baseMapping, + delimiter: ',', + filename_prefix: 'fields-custom', + columns: { + user_id: { '@path': '$.userId' }, + email: { '@path': '$.properties.email' }, + order_id: { '@path': '$.properties.order_id' }, + total: { '@path': '$.properties.total' }, + loyalty_tier: { '@path': '$.properties.loyalty_tier' } + } + }, + event: createE2EEvent('track', 'E2E Fields Custom', { + userId: 'e2e-s3-fields-user-003', + properties: { + email: 'e2e-s3-fields-003@segment.com', + order_id: '$guid:order3', + total: 19.99, + loyalty_tier: 'gold' + } + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/formats.e2e.ts b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/formats.e2e.ts new file mode 100644 index 00000000000..db3ce6f0ad5 --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/formats.e2e.ts @@ -0,0 +1,81 @@ +/** + * E2E fixtures exercising every supported delimiter and file extension. + * + * Delimiters: comma, pipe, tab, semicolon, colon. File extensions: csv, txt. Each fixture also + * carries a value that CONTAINS the delimiter character, so the output verifies delimiter handling + * end-to-end (values are wrapped in quotes by encodeString; column-name delimiters are stripped by + * clean()). Success = the upload did not throw; inspect the bucket to confirm the separator and that + * the embedded-delimiter value did not split into extra columns. + */ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import syncToS3 from '../index' + +const FAILURE_HINT = + 'Ensure E2E_S3_* and AMAZON_S3_ACTIONS_* env vars are set and the IAM role can write to the bucket.' + +const baseMapping = { + ...defaultValues(syncToS3.fields), + s3_aws_folder_name: 'e2e/formats/' +} + +// A note value that embeds each delimiter, to prove quoting keeps it in one column. +const NOTE_WITH_DELIMITERS = 'a,b|c\td;e:f' + +const formatEvent = (id: string) => + createE2EEvent('track', 'E2E Formats', { + userId: id, + properties: { email: `${id}@segment.com`, note: NOTE_WITH_DELIMITERS } + }) + +const columns = { + user_id: { '@path': '$.userId' }, + email: { '@path': '$.properties.email' }, + note: { '@path': '$.properties.note' } +} + +const delimiterCases: Array<{ label: string; value: string }> = [ + { label: 'comma', value: ',' }, + { label: 'pipe', value: '|' }, + { label: 'tab', value: 'tab' }, + { label: 'semicolon', value: ';' }, + { label: 'colon', value: ':' } +] + +const delimiterFixtures: E2EFixture[] = delimiterCases.map((d, i) => ({ + description: `Formats: ${d.label} delimiter (value contains all delimiters)`, + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...baseMapping, + delimiter: d.value, + file_extension: 'csv', + filename_prefix: `delim-${d.label}`, + columns + }, + event: formatEvent(`e2e-s3-fmt-delim-${i + 1}`), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT +})) + +const extensionCases = ['csv', 'txt'] + +const extensionFixtures: E2EFixture[] = extensionCases.map((ext, i) => ({ + description: `Formats: ${ext} file extension`, + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...baseMapping, + delimiter: ',', + file_extension: ext, + filename_prefix: `ext-${ext}`, + columns + }, + event: formatEvent(`e2e-s3-fmt-ext-${i + 1}`), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT +})) + +const fixtures: E2EFixture[] = [...delimiterFixtures, ...extensionFixtures] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/hashing.e2e.ts b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/hashing.e2e.ts new file mode 100644 index 00000000000..983933cc45c --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/hashing.e2e.ts @@ -0,0 +1,155 @@ +/** + * E2E fixtures for the SHA256 column hashing / normalization capability. + * + * Hashing and normalization are gated behind the `actions-s3-hashing` feature flag + * (S3_HASHING_FEATURE_FLAG): + * - flag ON + columns_to_transform configured -> file is written with those columns transformed (success) + * - flag ON + no columns_to_transform -> file written as-is (success) + * - flag OFF + columns_to_transform configured -> PayloadValidationError thrown, nothing uploaded (error) + * + * Each entry in columns_to_transform selects a hash_algorithm ('none' or 'sha256') and a normalize + * mode ('none' | 'lowercase' | 'trim' | 'lowercase_trim'). hash_algorithm 'none' normalizes without + * hashing. + * + * NOTE: These fixtures require an e2e runner that forwards `fixture.features` into onEvent/onBatch + * (the runner branch with per-fixture feature-flag support). The success cases only assert the + * upload did not throw — inspect the bucket to confirm the columns are actually transformed. + */ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import syncToS3 from '../index' +import { S3_HASHING_FEATURE_FLAG } from '../../constants' + +const FLAG_ON = { [S3_HASHING_FEATURE_FLAG]: true } + +const FAILURE_HINT = + 'Ensure E2E_S3_* and AMAZON_S3_ACTIONS_* env vars are set and the IAM role can write to the bucket. Flag-gated cases also require an e2e runner that forwards fixture.features.' + +const baseMapping = { + ...defaultValues(syncToS3.fields), + delimiter: ',', + file_extension: 'csv', + s3_aws_folder_name: 'e2e/hashing/' +} + +const fixtures: E2EFixture[] = [ + { + description: 'Hashing (flag ON): single event with email + user_id hashed via SHA256', + subscribe: 'type = "track"', + mode: 'single', + features: FLAG_ON, + mapping: { + ...baseMapping, + filename_prefix: 'hash-single', + columns_to_transform: [ + { column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }, + { column_name: 'user_id', hash_algorithm: 'sha256', normalize: 'none' } + ] + }, + event: createE2EEvent('track', 'E2E Hashing Single', { + userId: 'e2e-s3-hash-user-001', + properties: { email: 'e2e-s3-hash-001@segment.com' }, + traits: { email: 'e2e-s3-hash-001@segment.com' } + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Hashing (flag ON): email normalized (lowercase_trim) before SHA256', + subscribe: 'type = "track"', + mode: 'single', + features: FLAG_ON, + mapping: { + ...baseMapping, + filename_prefix: 'hash-normalize', + columns_to_transform: [{ column_name: 'email', hash_algorithm: 'sha256', normalize: 'lowercase_trim' }] + }, + event: createE2EEvent('track', 'E2E Hashing Normalized', { + userId: 'e2e-s3-hash-user-002', + properties: { email: ' E2E-S3-Hash-002@Segment.com ' }, + traits: { email: ' E2E-S3-Hash-002@Segment.com ' } + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Normalize only (flag ON): email lowercased and trimmed, not hashed', + subscribe: 'type = "track"', + mode: 'single', + features: FLAG_ON, + mapping: { + ...baseMapping, + filename_prefix: 'normalize-only', + columns_to_transform: [{ column_name: 'email', hash_algorithm: 'none', normalize: 'lowercase_trim' }] + }, + event: createE2EEvent('track', 'E2E Normalize Only', { + userId: 'e2e-s3-hash-user-003', + properties: { email: ' E2E-S3-Hash-003@Segment.com ' }, + traits: { email: ' E2E-S3-Hash-003@Segment.com ' } + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Hashing (flag ON): batch of events with email hashed via SHA256', + subscribe: 'type = "track"', + mode: 'batch', + features: FLAG_ON, + mapping: { + ...baseMapping, + filename_prefix: 'hash-batch', + columns_to_transform: [{ column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }] + }, + events: [ + createE2EEvent('track', 'E2E Hashing Batch A', { + userId: 'e2e-s3-hash-user-004', + properties: { email: 'e2e-s3-hash-004@segment.com' }, + traits: { email: 'e2e-s3-hash-004@segment.com' } + }), + createE2EEvent('track', 'E2E Hashing Batch B', { + userId: 'e2e-s3-hash-user-005', + properties: { email: 'e2e-s3-hash-005@segment.com' }, + traits: { email: 'e2e-s3-hash-005@segment.com' } + }) + ], + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Hashing (flag ON): no columns_to_transform configured — file written unchanged', + subscribe: 'type = "track"', + mode: 'single', + features: FLAG_ON, + mapping: { + ...baseMapping, + filename_prefix: 'hash-none' + }, + event: createE2EEvent('track', 'E2E No Hashing', { + userId: 'e2e-s3-hash-user-006', + properties: { email: 'e2e-s3-hash-006@segment.com' }, + traits: { email: 'e2e-s3-hash-006@segment.com' } + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Hashing (flag OFF): columns_to_transform configured — throws PayloadValidationError, no upload', + subscribe: 'type = "track"', + mode: 'single', + // no `features` — flag is off + mapping: { + ...baseMapping, + columns_to_transform: [{ column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }] + }, + event: createE2EEvent('track', 'E2E Hashing Flag Off', { + userId: 'e2e-s3-hash-user-007', + properties: { email: 'e2e-s3-hash-007@segment.com' }, + traits: { email: 'e2e-s3-hash-007@segment.com' } + }), + expect: { status: 'error', errorType: 'PayloadValidationError' }, + verboseFailureHint: + 'Expected a PayloadValidationError because a transform is configured while the actions-s3-hashing flag is off. If this uploaded instead, the flag guard in send() is not firing.' + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/__tests__/index.test.ts b/packages/destination-actions/src/destinations/s3/syncToS3/__tests__/index.test.ts index bf6cc78e40e..ac21c664a1a 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__tests__/index.test.ts @@ -1,7 +1,18 @@ -import { generateFile } from '../functions' // Adjust the import path +import { + generateFile, + resolveColumnTransforms, + getNormalizer, + clean, + encodeString, + getAudienceAction, + send +} from '../functions' import { Payload } from '../generated-types' -import { clean, encodeString, getAudienceAction } from '../functions' -import { ColumnHeader } from '../types' +import { Settings } from '../../generated-types' +import { ColumnHeader, ColumnTransform, RawMapping } from '../types' +import { PayloadValidationError } from '@segment/actions-core' +import { S3_HASHING_FEATURE_FLAG } from '../../constants' +import { processHashing } from '../../../../lib/hashing-utils' // Mock AWS SDK before any imports to avoid initialization issues jest.mock('@aws-sdk/client-s3', () => ({ @@ -16,6 +27,14 @@ jest.mock('@aws-sdk/client-sts', () => ({ AssumeRoleCommand: jest.fn() })) +// Mock the S3 Client so send() can complete without hitting AWS. The flag guard under test +// throws before the Client is ever constructed, so this only matters for the non-throwing cases. +jest.mock('../client', () => ({ + Client: jest.fn().mockImplementation(() => ({ + uploadS3: jest.fn().mockResolvedValue({ statusCode: 200, message: 'Upload successful' }) + })) +})) + describe('clean', () => { it('should remove delimiter from string', () => { expect(clean(',', 'abcd,Efg')).toEqual('abcdEfg') @@ -159,4 +178,318 @@ describe('generateFile', () => { const result = generateFile(payloads, headers, ',', 'audience_action', 'batch_size') expect(result.toString()).toEqual(output) }) + + it('should hash specified columns with sha256', () => { + const columnsToHash = new Map([['email', { algorithm: 'sha256', normalize: 'none' }]]) + const result = generateFile(payloads, headers, ',', 'audience_action', 'batch_size', columnsToHash) + const rows = result.toString().split('\n') + const headerRow = rows[0].split(',') + const dataRow = rows[1].split(',') + + const emailIndex = headerRow.indexOf('email') + const expectedHash = processHashing('test@test.com', 'sha256', 'hex') + expect(dataRow[emailIndex]).toBe(`"${expectedHash}"`) + }) + + it('should not hash empty or null values', () => { + const payloadWithEmpty: Payload[] = [ + { + columns: { email: '', user_id: 'user_1' }, + delimiter: ',', + enable_batching: true, + file_extension: 'csv' + } + ] + const testHeaders: ColumnHeader[] = [ + { cleanName: 'email', originalName: 'email' }, + { cleanName: 'user_id', originalName: 'user_id' } + ] + const columnsToHash = new Map([ + ['email', { algorithm: 'sha256', normalize: 'none' }], + ['user_id', { algorithm: 'sha256', normalize: 'none' }] + ]) + + const result = generateFile(payloadWithEmpty, testHeaders, ',', undefined, undefined, columnsToHash) + const rows = result.toString().split('\n') + const dataRow = rows[1].split(',') + + expect(dataRow[0]).toBe('""') + const expectedHash = processHashing('user_1', 'sha256', 'hex') + expect(dataRow[1]).toBe(`"${expectedHash}"`) + }) + + it('should hash multiple columns independently', () => { + const columnsToHash = new Map([ + ['email', { algorithm: 'sha256', normalize: 'none' }], + ['user_id', { algorithm: 'sha256', normalize: 'none' }] + ]) + const result = generateFile(payloads, headers, ',', 'audience_action', 'batch_size', columnsToHash) + const rows = result.toString().split('\n') + const headerRow = rows[0].split(',') + const dataRow = rows[1].split(',') + + const emailIndex = headerRow.indexOf('email') + const userIdIndex = headerRow.indexOf('user_id') + + const expectedEmailHash = processHashing('test@test.com', 'sha256', 'hex') + const expectedUserIdHash = processHashing('user_id_1', 'sha256', 'hex') + + expect(dataRow[emailIndex]).toBe(`"${expectedEmailHash}"`) + expect(dataRow[userIdIndex]).toBe(`"${expectedUserIdHash}"`) + }) + + it('should not hash columns not in columnsToHash', () => { + const columnsToHash = new Map([['email', { algorithm: 'sha256', normalize: 'none' }]]) + const result = generateFile(payloads, headers, ',', 'audience_action', 'batch_size', columnsToHash) + const rows = result.toString().split('\n') + const headerRow = rows[0].split(',') + const dataRow = rows[1].split(',') + + const userIdIndex = headerRow.indexOf('user_id') + expect(dataRow[userIdIndex]).toBe('"user_id_1"') + }) + + const normalizePayloads: Payload[] = [ + { + columns: { email: ' Test@Test.com ', user_id: 'User_1' }, + delimiter: ',', + enable_batching: true, + file_extension: 'csv' + } + ] + const normalizeHeaders: ColumnHeader[] = [ + { cleanName: 'email', originalName: 'email' }, + { cleanName: 'user_id', originalName: 'user_id' } + ] + + const readColumn = (buffer: Buffer, column: string): string => { + const rows = buffer.toString().split('\n') + const index = rows[0].split(',').indexOf(column) + return rows[1].split(',')[index] + } + + it('should normalize (lowercase) without hashing when algorithm is omitted', () => { + const transforms = new Map([['email', { normalize: 'lowercase' }]]) + const result = generateFile(normalizePayloads, normalizeHeaders, ',', undefined, undefined, transforms) + expect(readColumn(result, 'email')).toBe('" test@test.com "') + }) + + it('should normalize (trim) without hashing', () => { + const transforms = new Map([['email', { normalize: 'trim' }]]) + const result = generateFile(normalizePayloads, normalizeHeaders, ',', undefined, undefined, transforms) + expect(readColumn(result, 'email')).toBe('"Test@Test.com"') + }) + + it('should normalize (lowercase_trim) without hashing', () => { + const transforms = new Map([['email', { normalize: 'lowercase_trim' }]]) + const result = generateFile(normalizePayloads, normalizeHeaders, ',', undefined, undefined, transforms) + expect(readColumn(result, 'email')).toBe('"test@test.com"') + }) + + it('should leave the value unchanged when normalize is none and no algorithm is set', () => { + const transforms = new Map([['email', { normalize: 'none' }]]) + const result = generateFile(normalizePayloads, normalizeHeaders, ',', undefined, undefined, transforms) + expect(readColumn(result, 'email')).toBe('" Test@Test.com "') + }) + + it('should normalize before hashing (hash of the normalized value)', () => { + const transforms = new Map([ + ['email', { algorithm: 'sha256', normalize: 'lowercase_trim' }] + ]) + const result = generateFile(normalizePayloads, normalizeHeaders, ',', undefined, undefined, transforms) + const expectedHash = processHashing(' Test@Test.com ', 'sha256', 'hex', (v) => v.trim().toLowerCase()) + expect(readColumn(result, 'email')).toBe(`"${expectedHash}"`) + // Sanity: this equals hashing the pre-normalized string directly. + expect(expectedHash).toBe(processHashing('test@test.com', 'sha256', 'hex')) + }) + + it('should hash the raw value (no normalization) when normalize is none', () => { + const transforms = new Map([['email', { algorithm: 'sha256', normalize: 'none' }]]) + const result = generateFile(normalizePayloads, normalizeHeaders, ',', undefined, undefined, transforms) + // Hash of the untouched value, NOT the lower-cased / trimmed one. + const expectedHash = processHashing(' Test@Test.com ', 'sha256', 'hex') + expect(readColumn(result, 'email')).toBe(`"${expectedHash}"`) + // Guard against silent normalization: the raw hash must differ from the normalized hash. + expect(expectedHash).not.toBe(processHashing('test@test.com', 'sha256', 'hex')) + }) + + it('should not normalize an already-hashed value when hashing', () => { + const alreadyHashed = processHashing('test@test.com', 'sha256', 'hex') + const payload: Payload[] = [ + { columns: { email: alreadyHashed }, delimiter: ',', enable_batching: true, file_extension: 'csv' } + ] + const transforms = new Map([ + ['email', { algorithm: 'sha256', normalize: 'lowercase_trim' }] + ]) + const result = generateFile( + payload, + [{ cleanName: 'email', originalName: 'email' }], + ',', + undefined, + undefined, + transforms + ) + // The value is passed through untouched (not re-hashed, not lower-cased). + expect(readColumn(result, 'email')).toBe(`"${alreadyHashed}"`) + }) +}) + +describe('getNormalizer', () => { + it('returns undefined for none / undefined', () => { + expect(getNormalizer('none')).toBeUndefined() + expect(getNormalizer(undefined)).toBeUndefined() + }) + it('lowercases', () => { + const normalize = getNormalizer('lowercase') + expect(normalize?.('AbC ')).toBe('abc ') + }) + it('trims', () => { + const normalize = getNormalizer('trim') + expect(normalize?.(' AbC ')).toBe('AbC') + }) + it('lowercases and trims', () => { + const normalize = getNormalizer('lowercase_trim') + expect(normalize?.(' AbC ')).toBe('abc') + }) +}) + +describe('resolveColumnTransforms', () => { + const validColumnNames = new Set(['email', 'user_id', 'anonymous_id', 'event_name']) + + it('should return a valid map when all entries are correct', () => { + const entries = [ + { column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }, + { column_name: 'user_id', hash_algorithm: 'sha256', normalize: 'lowercase' } + ] + const result = resolveColumnTransforms(entries, validColumnNames) + expect(result.size).toBe(2) + expect(result.get('email')).toEqual({ algorithm: 'sha256', normalize: 'none' }) + expect(result.get('user_id')).toEqual({ algorithm: 'sha256', normalize: 'lowercase' }) + }) + + it('should treat hash_algorithm "none" as normalize-only (no algorithm)', () => { + const entries = [{ column_name: 'email', hash_algorithm: 'none', normalize: 'lowercase_trim' }] + const result = resolveColumnTransforms(entries, validColumnNames) + expect(result.get('email')).toEqual({ algorithm: undefined, normalize: 'lowercase_trim' }) + }) + + it('should treat an empty hash_algorithm as normalize-only (no algorithm)', () => { + const entries = [{ column_name: 'email', hash_algorithm: '', normalize: 'trim' }] + const result = resolveColumnTransforms(entries, validColumnNames) + expect(result.get('email')).toEqual({ algorithm: undefined, normalize: 'trim' }) + }) + + it('should default a missing normalize to "none"', () => { + const entries = [ + { column_name: 'email', hash_algorithm: 'sha256' } as unknown as { + column_name: string + hash_algorithm: string + normalize: string + } + ] + const result = resolveColumnTransforms(entries, validColumnNames) + expect(result.get('email')).toEqual({ algorithm: 'sha256', normalize: 'none' }) + }) + + it('should throw when column_name is empty', () => { + const entries = [{ column_name: '', hash_algorithm: 'sha256', normalize: 'none' }] + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow('column_name is required') + }) + + it('should throw when hash_algorithm is unsupported', () => { + const entries = [{ column_name: 'email', hash_algorithm: 'md5', normalize: 'none' }] + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow('unsupported hash_algorithm "md5"') + }) + + it('should throw when normalize is unsupported', () => { + const entries = [{ column_name: 'email', hash_algorithm: 'none', normalize: 'uppercase' }] + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow('unsupported normalize "uppercase"') + }) + + it('should throw when column_name does not exist in valid columns', () => { + const entries = [{ column_name: 'nonexistent_column', hash_algorithm: 'sha256', normalize: 'none' }] + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow( + 'columns_to_transform contains columns that do not exist: nonexistent_column' + ) + }) + + it('should list all invalid columns in the error message', () => { + const entries = [ + { column_name: 'bad_col_1', hash_algorithm: 'sha256', normalize: 'none' }, + { column_name: 'bad_col_2', hash_algorithm: 'sha256', normalize: 'none' } + ] + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow('bad_col_1, bad_col_2') + }) + + it('should throw when column_name is duplicated', () => { + const entries = [ + { column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }, + { column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' } + ] + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => resolveColumnTransforms(entries, validColumnNames)).toThrow('duplicate column_name "email"') + }) + + it('should return an empty map when entries is empty', () => { + const result = resolveColumnTransforms([], validColumnNames) + expect(result.size).toBe(0) + }) +}) + +describe('send with hashing feature flag', () => { + const settings: Settings = { + iam_role_arn: 'arn:aws:iam::123456789012:role/test', + s3_aws_bucket_name: 'test-bucket', + s3_aws_region: 'us-east-1', + iam_external_id: 'external-id' + } + const rawMapping: RawMapping = { columns: { email: 'email', user_id: 'user_id' } } + + const payloadWithHashing: Payload = { + columns: { email: 'test@test.com', user_id: 'user_1' }, + delimiter: ',', + enable_batching: true, + file_extension: 'csv', + columns_to_transform: [{ column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }] + } + + it('should throw when columns_to_transform is configured but the feature flag is off', async () => { + await expect(send([payloadWithHashing], settings, rawMapping, {})).rejects.toThrow(PayloadValidationError) + await expect(send([payloadWithHashing], settings, rawMapping, undefined)).rejects.toThrow( + 'Column hashing and normalization is currently not enabled for your Segment workspace. Remove the columns to hash / normalize or contact Segment by emailing friends@segment.com to enable the feature.' + ) + }) + + it('should not throw when the feature flag is off and no columns are configured', async () => { + const payloadNoHashing: Payload = { + columns: { email: 'test@test.com', user_id: 'user_1' }, + delimiter: ',', + enable_batching: true, + file_extension: 'csv' + } + await expect(send([payloadNoHashing], settings, rawMapping, {})).resolves.not.toThrow() + }) + + it('should not throw when columns_to_transform is configured and the feature flag is on', async () => { + await expect( + send([payloadWithHashing], settings, rawMapping, { [S3_HASHING_FEATURE_FLAG]: true }) + ).resolves.not.toThrow() + }) + + it('should not throw for a normalize-only column when the feature flag is on', async () => { + const payloadNormalizeOnly: Payload = { + columns: { email: 'Test@Test.com', user_id: 'user_1' }, + delimiter: ',', + enable_batching: true, + file_extension: 'csv', + columns_to_transform: [{ column_name: 'email', hash_algorithm: 'none', normalize: 'lowercase_trim' }] + } + await expect( + send([payloadNormalizeOnly], settings, rawMapping, { [S3_HASHING_FEATURE_FLAG]: true }) + ).resolves.not.toThrow() + }) }) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/constants.ts b/packages/destination-actions/src/destinations/s3/syncToS3/constants.ts new file mode 100644 index 00000000000..cd8f72d83f2 --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/constants.ts @@ -0,0 +1,5 @@ +import { HashAlgorithm, Normalization } from './types' + +export const SUPPORTED_HASH_ALGORITHMS: HashAlgorithm[] = ['sha256'] + +export const SUPPORTED_NORMALIZATIONS: Normalization[] = ['none', 'lowercase', 'trim', 'lowercase_trim'] diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts b/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts index 97fd485b4cf..0196350d261 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts @@ -1,5 +1,6 @@ import { ActionDefinition } from '@segment/actions-core' import { Settings } from '../generated-types' +import { SUPPORTED_HASH_ALGORITHMS, SUPPORTED_NORMALIZATIONS } from './constants' export const commonFields: ActionDefinition['fields'] = { columns: { @@ -233,6 +234,44 @@ export const commonFields: ActionDefinition['fields'] = { ], default: 'csv' }, + columns_to_transform: { + label: 'Columns to Hash or Normalize', + description: 'Columns whose values will be normalized and/or hashed before writing to the file.', + type: 'object', + multiple: true, + required: false, + defaultObjectUI: 'arrayeditor', + additionalProperties: false, + properties: { + column_name: { + label: 'Column Name', + description: 'The name of the column to hash or normalize.', + type: 'string', + required: true, + allowNull: false, + disabledInputMethods: ['variable', 'function', 'enrichment'] + }, + hash_algorithm: { + label: 'Hash Algorithm', + description: "The hashing algorithm to apply. Select 'none' to normalize the value without hashing it.", + type: 'string', + required: true, + choices: ['none', ...SUPPORTED_HASH_ALGORITHMS].map((value) => ({ label: value, value })), + default: 'none', + disabledInputMethods: ['enrichment', 'function', 'variable', 'literal'] + }, + normalize: { + label: 'Normalize', + description: + "How to normalize the value before hashing. Values that are already hashed are never re-hashed. Select 'none' to leave the value unchanged.", + type: 'string', + required: true, + choices: SUPPORTED_NORMALIZATIONS.map((value) => ({ label: value, value })), + default: 'none', + disabledInputMethods: ['enrichment', 'function', 'variable', 'literal'] + } + } + }, batch_keys: { label: 'Batch Keys', description: 'The keys to use for batching the events.', @@ -240,6 +279,6 @@ export const commonFields: ActionDefinition['fields'] = { unsafe_hidden: true, required: false, multiple: true, - default: ['s3_aws_folder_name'] + default: ['s3_aws_folder_name', 'columns_to_transform'] } } diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index 6018b34b4af..50fbdb2dfc8 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -1,9 +1,20 @@ +import { PayloadValidationError } from '@segment/actions-core' +import type { Features } from '@segment/actions-core' +import { processHashing } from '../../../lib/hashing-utils' import { Payload } from './generated-types' import { Settings } from '../generated-types' import { Client } from './client' -import { RawMapping, ColumnHeader } from './types' +import { RawMapping, ColumnHeader, HashAlgorithm, Normalization, ColumnTransform } from './types' +import { S3_HASHING_FEATURE_FLAG } from '../constants' +import { SUPPORTED_HASH_ALGORITHMS, SUPPORTED_NORMALIZATIONS } from './constants' -export async function send(payloads: Payload[], settings: Settings, rawMapping: RawMapping, signal?: AbortSignal) { +export async function send( + payloads: Payload[], + settings: Settings, + rawMapping: RawMapping, + features?: Features, + signal?: AbortSignal +) { const delimiter = payloads[0]?.delimiter const actionColName = payloads[0]?.audience_action_column_name const batchColName = payloads[0]?.batch_size_column_name @@ -22,7 +33,20 @@ export async function send(payloads: Payload[], settings: Settings, rawMapping: headers.push({ cleanName: clean(delimiter, batchColName), originalName: batchColName }) } - const fileContent = generateFile(payloads, headers, delimiter, actionColName, batchColName) + const configuredColumnTransforms = payloads[0]?.columns_to_transform ?? [] + const flagEnabled = Boolean(features && features[S3_HASHING_FEATURE_FLAG]) + + if (!flagEnabled && configuredColumnTransforms.length > 0) { + throw new PayloadValidationError( + 'Column hashing and normalization is currently not enabled for your Segment workspace. Remove the columns to hash / normalize or contact Segment by emailing friends@segment.com to enable the feature.' + ) + } + + const columnTransforms = flagEnabled + ? resolveColumnTransforms(configuredColumnTransforms, new Set(headers.map((h) => h.originalName))) + : new Map() + + const fileContent = generateFile(payloads, headers, delimiter, actionColName, batchColName, columnTransforms) const s3Client = new Client(settings.s3_aws_region, settings.iam_role_arn, settings.iam_external_id) @@ -43,14 +67,38 @@ export function clean(delimiter: string, str?: string) { return delimiter === 'tab' ? str : str.replace(delimiter, '') } -function processField(value: unknown | undefined): string { - return encodeString( +export function getNormalizer(normalize?: Normalization): ((value: string) => string) | undefined { + switch (normalize) { + case 'lowercase': + return (value: string) => value.toLowerCase() + case 'trim': + return (value: string) => value.trim() + case 'lowercase_trim': + return (value: string) => value.trim().toLowerCase() + default: + return undefined + } +} + +function processField(value: unknown | undefined, transform?: ColumnTransform): string { + const str = value === undefined || value === null ? '' : typeof value === 'object' ? String(JSON.stringify(value)) : String(value) - ) + + if (str === '' || !transform) { + return encodeString(str) + } + + const normalizer = getNormalizer(transform.normalize) + + if (transform.algorithm) { + return encodeString(processHashing(str, transform.algorithm, 'hex', normalizer)) + } + + return encodeString(normalizer ? normalizer(str) : str) } export function generateFile( @@ -58,18 +106,19 @@ export function generateFile( headers: ColumnHeader[], delimiter: string, actionColName?: string, - batchColName?: string + batchColName?: string, + columnTransforms: Map = new Map() ): Buffer { const rows = payloads.map((payload, index) => { const isLastRow = index === payloads.length - 1 const row = headers.map((header): string => { if (header.originalName === actionColName) { - return processField(getAudienceAction(payload)) + return processField(getAudienceAction(payload), columnTransforms.get(header.originalName)) } if (header.originalName === batchColName) { - return processField(payloads.length) + return processField(payloads.length, columnTransforms.get(header.originalName)) } - return processField(payload.columns[header.originalName]) + return processField(payload.columns[header.originalName], columnTransforms.get(header.originalName)) }) return Buffer.from(`${row.join(delimiter === 'tab' ? '\t' : delimiter)}${isLastRow ? '' : '\n'}`) @@ -92,3 +141,53 @@ export function getAudienceAction(payload: Payload): boolean | undefined { return (payload?.traits_or_props as Record | undefined)?.[payload.computation_key] ?? undefined } + +export function resolveColumnTransforms( + entries: NonNullable, + validColumnNames: Set +): Map { + const columnTransforms = new Map() + + for (const entry of entries) { + const columnName = String(entry.column_name ?? '').trim() + const hashAlgorithm = String(entry.hash_algorithm ?? '').trim() + const normalize = String(entry.normalize ?? 'none').trim() || 'none' + + if (!columnName) { + throw new PayloadValidationError('columns_to_transform: column_name is required.') + } + + let algorithm: HashAlgorithm | undefined + if (hashAlgorithm && hashAlgorithm !== 'none') { + algorithm = SUPPORTED_HASH_ALGORITHMS.find((a) => a === hashAlgorithm) + if (!algorithm) { + throw new PayloadValidationError( + `columns_to_transform: unsupported hash_algorithm "${hashAlgorithm}". Supported: ${[ + 'none', + ...SUPPORTED_HASH_ALGORITHMS + ].join(', ')}` + ) + } + } + + if (!SUPPORTED_NORMALIZATIONS.includes(normalize as Normalization)) { + throw new PayloadValidationError( + `columns_to_transform: unsupported normalize "${normalize}". Supported: ${SUPPORTED_NORMALIZATIONS.join(', ')}` + ) + } + + if (columnTransforms.has(columnName)) { + throw new PayloadValidationError(`columns_to_transform: duplicate column_name "${columnName}".`) + } + columnTransforms.set(columnName, { algorithm, normalize: normalize as Normalization }) + } + + const invalidColumns = [...columnTransforms.keys()].filter((col) => !validColumnNames.has(col)) + if (invalidColumns.length > 0) { + throw new PayloadValidationError( + `columns_to_transform contains columns that do not exist: ${invalidColumns.join(', ')}` + ) + } + + return columnTransforms +} diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts b/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts index 7e51d8cb2ac..ba2feffe55a 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts @@ -113,6 +113,23 @@ export interface Payload { * File extension for the uploaded file. */ file_extension: string + /** + * Columns whose values will be normalized and/or hashed before writing to the file. + */ + columns_to_transform?: { + /** + * The name of the column to hash or normalize. + */ + column_name: string + /** + * The hashing algorithm to apply. Select None to normalize the value without hashing it. + */ + hash_algorithm: string + /** + * How to normalize the value before hashing. Values that are already hashed are never re-hashed. Select None to leave the value unchanged. + */ + normalize: string + }[] /** * The keys to use for batching the events. */ diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/index.ts b/packages/destination-actions/src/destinations/s3/syncToS3/index.ts index d0d6a8ed15d..0568773041f 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/index.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/index.ts @@ -11,14 +11,14 @@ const action: ActionDefinition = { defaultSubscription: 'type = "identify" or type = "track"', fields: commonFields, perform: async (_, data) => { - const { payload, settings, signal } = data + const { payload, settings, signal, features } = data const rawMapping: RawMapping = (data as unknown as Data).rawMapping - return send([payload], settings, rawMapping, signal) + return send([payload], settings, rawMapping, features, signal) }, performBatch: async (_, data) => { - const { payload, settings, signal } = data + const { payload, settings, signal, features } = data const rawMapping: RawMapping = (data as unknown as Data).rawMapping - return send(payload, settings, rawMapping, signal) + return send(payload, settings, rawMapping, features, signal) } } diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/types.ts b/packages/destination-actions/src/destinations/s3/syncToS3/types.ts index 5e934cf14bd..733d79acb6e 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/types.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/types.ts @@ -1,3 +1,14 @@ +import type { EncryptionMethod } from '../../../lib/hashing-utils' + +export type HashAlgorithm = Extract + +export type Normalization = 'none' | 'lowercase' | 'trim' | 'lowercase_trim' + +export interface ColumnTransform { + algorithm?: HashAlgorithm + normalize: Normalization +} + export interface Credentials { accessKeyId: string secretAccessKey: string @@ -15,6 +26,6 @@ export interface RawMapping { } export interface ColumnHeader { - cleanName: string + cleanName: string originalName: string }