From 81e4b1761090e2c8f3c1a8e75ce5b6a4fa42b05b Mon Sep 17 00:00:00 2001 From: Vitor Oliveira Date: Wed, 15 Apr 2026 14:40:29 +0100 Subject: [PATCH 001/210] Braze cloud mode -New merge users action --- .../src/destinations/braze/index.ts | 26 +- .../__snapshots__/snapshot.test.ts.snap | 45 +++ .../braze/mergeUsers/__tests__/index.test.ts | 355 ++++++++++++++++++ .../mergeUsers/__tests__/snapshot.test.ts | 110 ++++++ .../braze/mergeUsers/generated-types.ts | 60 +++ .../destinations/braze/mergeUsers/index.ts | 128 +++++++ .../src/destinations/braze/utils.ts | 62 +++ 7 files changed, 775 insertions(+), 11 deletions(-) create mode 100644 packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/braze/mergeUsers/index.ts diff --git a/packages/destination-actions/src/destinations/braze/index.ts b/packages/destination-actions/src/destinations/braze/index.ts index 85b600ee994..1b45b8e771f 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"', 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,7 +166,7 @@ const destination: DestinationDefinition = { name: 'Product Viewed (beta)', subscribe: 'event = "Product Viewed"', partnerAction: 'ecommerceSingleProduct', - mapping: { + mapping: { ...defaultValues(ecommerceSingleProduct.fields), name: EVENT_NAMES.PRODUCT_VIEWED }, 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..91c9b17acfd --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,45 @@ +// 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 { + "braze_id": "8$QYsxcxpjIn)Y", + "email": "gonwijpel@vep.il", + "external_id": "8$QYsxcxpjIn)Y", + "phone": "8$QYsxcxpjIn)Y", + "user_alias": Object { + "alias_label": "segment", + "alias_name": "keep-alias", + }, + }, + "identifier_to_merge": Object { + "braze_id": "8$QYsxcxpjIn)Y", + "email": "gonwijpel@vep.il", + "external_id": "8$QYsxcxpjIn)Y", + "phone": "8$QYsxcxpjIn)Y", + "user_alias": Object { + "alias_label": "segment", + "alias_name": "merge-alias", + }, + }, + }, + ], +} +`; + +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", + }, + }, + ], +} +`; 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..fc39bae3003 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts @@ -0,0 +1,355 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } 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: 'track', + userId: 'user-to-keep-123' + }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: { + external_id: 'user-to-merge-456' + }, + identifier_to_keep: { + external_id: '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: 'track' + }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: { + user_alias: { + alias_name: 'merge-alias', + alias_label: 'segment' + } + }, + identifier_to_keep: { + user_alias: { + 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: 'track' + }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: { + email: 'merge@example.com' + }, + identifier_to_keep: { + email: 'keep@example.com' + } + } + }) + + 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' + }, + identifier_to_keep: { + email: 'keep@example.com' + } + } + ] + }) + }) + + it('should merge users with braze_id identifiers', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ + type: 'track' + }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: { + braze_id: 'braze-merge-id-123' + }, + identifier_to_keep: { + braze_id: 'braze-keep-id-456' + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { + braze_id: 'braze-merge-id-123' + }, + identifier_to_keep: { + braze_id: 'braze-keep-id-456' + } + } + ] + }) + }) + + it('should merge users with phone identifiers', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ + type: 'track' + }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: { + phone: '+14155551234' + }, + identifier_to_keep: { + phone: '+14155555678' + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + merge_updates: [ + { + identifier_to_merge: { + phone: '+14155551234' + }, + identifier_to_keep: { + phone: '+14155555678' + } + } + ] + }) + }) + + it('should merge users with mixed identifier types', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ + type: 'track', + userId: 'user-to-keep-123' + }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: { + email: 'merge@example.com' + }, + identifier_to_keep: { + external_id: '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' + }, + identifier_to_keep: { + external_id: 'user-to-keep-123' + } + } + ] + }) + }) + + it('should throw error when identifier_to_merge has no valid identifier', async () => { + const event = createTestEvent({ + type: 'track' + }) + + await expect( + testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: {}, + identifier_to_keep: { + external_id: 'user-to-keep-123' + } + } + }) + ).rejects.toThrowError( + 'Identifier to Merge must specify one of: external_id, user_alias, braze_id, email, or phone.' + ) + }) + + it('should throw error when identifier_to_keep has no valid identifier', async () => { + const event = createTestEvent({ + type: 'track' + }) + + await expect( + testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: { + external_id: 'user-to-merge-456' + }, + identifier_to_keep: {} + } + }) + ).rejects.toThrowError( + 'Identifier to Keep must specify one of: external_id, user_alias, braze_id, email, or phone.' + ) + }) + + it('should handle incomplete user_alias (missing required fields)', async () => { + const event = createTestEvent({ + type: 'track' + }) + + await expect( + testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: { + user_alias: { + alias_name: 'test' + // missing alias_label + } + }, + identifier_to_keep: { + external_id: 'user-to-keep-123' + } + } + }) + ).rejects.toThrowError( + 'Identifier to Merge must specify one of: external_id, user_alias, braze_id, email, or phone.' + ) + }) + + it('should use default mapping from userId for identifier_to_keep', async () => { + nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) + + const event = createTestEvent({ + type: 'track', + userId: 'user-to-keep-123' + }) + + const responses = await testDestination.testAction('mergeUsers', { + event, + settings, + mapping: { + identifier_to_merge: { + external_id: 'user-to-merge-456' + }, + identifier_to_keep: { + external_id: { + '@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' + } + } + ] + }) + }) +}) 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..263bd2efb7a --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts @@ -0,0 +1,110 @@ +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) + + // Provide custom event data with valid identifiers + const customEventData = { + identifier_to_merge: { + external_id: 'user-to-merge' + }, + identifier_to_keep: { + external_id: 'user-to-keep' + } + } + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: customEventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: customEventData, + 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) + + // Customize the nested objects to include all identifier types + const customEventData = { + ...eventData, + identifier_to_merge: { + external_id: eventData.identifier_to_merge?.external_id || 'merge-external-id', + braze_id: eventData.identifier_to_merge?.braze_id || 'merge-braze-id', + email: eventData.identifier_to_merge?.email || 'merge@example.com', + phone: eventData.identifier_to_merge?.phone || '+14155551234', + user_alias: { + alias_name: 'merge-alias', + alias_label: 'segment' + } + }, + identifier_to_keep: { + external_id: eventData.identifier_to_keep?.external_id || 'keep-external-id', + braze_id: eventData.identifier_to_keep?.braze_id || 'keep-braze-id', + email: eventData.identifier_to_keep?.email || 'keep@example.com', + phone: eventData.identifier_to_keep?.phone || '+14155555678', + user_alias: { + alias_name: 'keep-alias', + alias_label: 'segment' + } + } + } + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: customEventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: customEventData, + 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/braze/mergeUsers/generated-types.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts new file mode 100644 index 00000000000..2567bcc30a5 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts @@ -0,0 +1,60 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * User identifier for the user to be merged (the user to be deprecated). Must specify one of: External ID, User Alias, Braze ID, Email, or Phone. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_users_merge/). + */ + identifier_to_merge: { + /** + * The external ID of the user to merge + */ + external_id?: string + /** + * The user alias object identifying the user to merge + */ + user_alias?: { + alias_name?: string + alias_label?: string + } + /** + * The Braze ID of the user to merge + */ + braze_id?: string + /** + * The email address of the user to merge + */ + email?: string + /** + * The phone number of the user to merge in E.164 format (e.g., +14155552671) + */ + phone?: string + } + /** + * User identifier for the user to keep (the target user). Must specify one of: External ID, User Alias, Braze ID, Email, or Phone. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_users_merge/). + */ + identifier_to_keep: { + /** + * The external ID of the user to keep + */ + external_id?: string + /** + * The user alias object identifying the user to keep + */ + user_alias?: { + alias_name?: string + alias_label?: string + } + /** + * The Braze ID of the user to keep + */ + braze_id?: string + /** + * The email address of the user to keep + */ + email?: string + /** + * The phone number of the user to keep in E.164 format (e.g., +14155552671) + */ + phone?: string + } +} 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..3d4aa6669b1 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -0,0 +1,128 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { mergeUsers } from '../utils' + +const action: ActionDefinition = { + title: 'Merge Users', + description: + 'Merge one identified user into another identified user. The merge will occur asynchronously and can take between 5-10 minutes.', + fields: { + identifier_to_merge: { + label: 'Identifier to Merge', + description: + 'User identifier for the user to be merged (the user to be deprecated). Must specify one of: External ID, User Alias, Braze ID, Email, or Phone. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_users_merge/).', + type: 'object', + required: true, + defaultObjectUI: 'keyvalue', + additionalProperties: false, + properties: { + external_id: { + label: 'External ID', + description: 'The external ID of the user to merge', + type: 'string' + }, + user_alias: { + label: 'User Alias', + description: 'The user alias object identifying the user to merge', + type: 'object', + properties: { + alias_name: { + label: 'Alias Name', + type: 'string' + }, + alias_label: { + label: 'Alias Label', + type: 'string' + } + } + }, + braze_id: { + label: 'Braze ID', + description: 'The Braze ID of the user to merge', + type: 'string' + }, + email: { + label: 'Email', + description: 'The email address of the user to merge', + type: 'string', + format: 'email' + }, + phone: { + label: 'Phone', + description: 'The phone number of the user to merge in E.164 format (e.g., +14155552671)', + type: 'string' + } + } + }, + identifier_to_keep: { + label: 'Identifier to Keep', + description: + 'User identifier for the user to keep (the target user). Must specify one of: External ID, User Alias, Braze ID, Email, or Phone. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_users_merge/).', + type: 'object', + required: true, + defaultObjectUI: 'keyvalue', + additionalProperties: false, + properties: { + external_id: { + label: 'External ID', + description: 'The external ID of the user to keep', + type: 'string', + default: { + '@path': '$.userId' + } + }, + user_alias: { + label: 'User Alias', + description: 'The user alias object identifying the user to keep', + type: 'object', + properties: { + alias_name: { + label: 'Alias Name', + type: 'string' + }, + alias_label: { + label: 'Alias Label', + type: 'string' + } + } + }, + braze_id: { + label: 'Braze ID', + description: 'The Braze ID of the user to keep', + type: 'string' + }, + email: { + label: 'Email', + description: 'The email address of the user to keep', + type: 'string', + format: 'email' + }, + phone: { + label: 'Phone', + description: 'The phone number of the user to keep in E.164 format (e.g., +14155552671)', + type: 'string' + } + }, + default: { + external_id: { + '@path': '$.userId' + }, + braze_id: { + '@path': '$.context.traits.brazeId' + }, + email: { + '@path': '$.context.traits.email' + }, + phone: { + '@path': '$.context.traits.phone' + } + } + } + }, + perform: (request, { settings, payload }) => { + return mergeUsers(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/braze/utils.ts b/packages/destination-actions/src/destinations/braze/utils.ts index e9a863cefb1..63a76db07c4 100644 --- a/packages/destination-actions/src/destinations/braze/utils.ts +++ b/packages/destination-actions/src/destinations/braze/utils.ts @@ -6,6 +6,7 @@ import action from './trackPurchase' import { Payload as TrackEventPayload } from './trackEvent/generated-types' import { Payload as TrackPurchasePayload } from './trackPurchase/generated-types' import { Payload as UpdateUserProfilePayload } from './updateUserProfile/generated-types' +import { Payload as MergeUsersPayload } from './mergeUsers/generated-types' import { getUserAlias } from './userAlias' import { HTTPError } from '@segment/actions-core' import { MAX_BATCH_SIZE } from './constants' @@ -659,6 +660,67 @@ async function handleBrazeAPIResponse( } } +export function mergeUsers(request: RequestClient, settings: Settings, payload: MergeUsersPayload) { + // Validate identifier_to_merge + const mergeUserAlias = getUserAlias(payload.identifier_to_merge?.user_alias) + const hasMergeIdentifier = + payload.identifier_to_merge?.external_id || + mergeUserAlias || + payload.identifier_to_merge?.braze_id || + payload.identifier_to_merge?.email || + payload.identifier_to_merge?.phone + + if (!hasMergeIdentifier) { + throw new IntegrationError( + 'Identifier to Merge must specify one of: external_id, user_alias, braze_id, email, or phone.', + 'Missing required identifier', + 400 + ) + } + + // Validate identifier_to_keep + const keepUserAlias = getUserAlias(payload.identifier_to_keep?.user_alias) + const hasKeepIdentifier = + payload.identifier_to_keep?.external_id || + keepUserAlias || + payload.identifier_to_keep?.braze_id || + payload.identifier_to_keep?.email || + payload.identifier_to_keep?.phone + + if (!hasKeepIdentifier) { + throw new IntegrationError( + 'Identifier to Keep must specify one of: external_id, user_alias, braze_id, email, or phone.', + 'Missing required identifier', + 400 + ) + } + + // Build the merge update object + const mergeUpdate: Record = { + identifier_to_merge: { + ...(payload.identifier_to_merge?.external_id && { external_id: payload.identifier_to_merge.external_id }), + ...(mergeUserAlias && { user_alias: mergeUserAlias }), + ...(payload.identifier_to_merge?.braze_id && { braze_id: payload.identifier_to_merge.braze_id }), + ...(payload.identifier_to_merge?.email && { email: payload.identifier_to_merge.email }), + ...(payload.identifier_to_merge?.phone && { phone: payload.identifier_to_merge.phone }) + }, + identifier_to_keep: { + ...(payload.identifier_to_keep?.external_id && { external_id: payload.identifier_to_keep.external_id }), + ...(keepUserAlias && { user_alias: keepUserAlias }), + ...(payload.identifier_to_keep?.braze_id && { braze_id: payload.identifier_to_keep.braze_id }), + ...(payload.identifier_to_keep?.email && { email: payload.identifier_to_keep.email }), + ...(payload.identifier_to_keep?.phone && { phone: payload.identifier_to_keep.phone }) + } + } + + return request(`${settings.endpoint}/users/merge`, { + method: 'post', + json: { + merge_updates: [mergeUpdate] + } + }) +} + export function generateMultiStatusError(batchSize: number, errorMessage: string): MultiStatusResponse { const multiStatusResponse = new MultiStatusResponse() From 9ea55a174309faaca132ce41d132aa108c89938a Mon Sep 17 00:00:00 2001 From: Vitor Oliveira Date: Wed, 15 Apr 2026 14:59:29 +0100 Subject: [PATCH 002/210] addressing issues raised by copilot --- .../src/destinations/braze/mergeUsers/index.ts | 18 +++++++++++++++--- .../src/destinations/braze/utils.ts | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts index 3d4aa6669b1..f4125102232 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -109,13 +109,25 @@ const action: ActionDefinition = { '@path': '$.userId' }, braze_id: { - '@path': '$.context.traits.brazeId' + '@if': { + exists: { '@path': '$.context.traits.brazeId' } + }, + then: { '@path': '$.context.traits.brazeId' }, + else: { '@path': '$.properties.brazeId' } }, email: { - '@path': '$.context.traits.email' + '@if': { + exists: { '@path': '$.context.traits.email' } + }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } }, phone: { - '@path': '$.context.traits.phone' + '@if': { + exists: { '@path': '$.context.traits.phone' } + }, + then: { '@path': '$.context.traits.phone' }, + else: { '@path': '$.properties.phone' } } } } diff --git a/packages/destination-actions/src/destinations/braze/utils.ts b/packages/destination-actions/src/destinations/braze/utils.ts index 63a76db07c4..48b0b3da7e9 100644 --- a/packages/destination-actions/src/destinations/braze/utils.ts +++ b/packages/destination-actions/src/destinations/braze/utils.ts @@ -673,7 +673,7 @@ export function mergeUsers(request: RequestClient, settings: Settings, payload: if (!hasMergeIdentifier) { throw new IntegrationError( 'Identifier to Merge must specify one of: external_id, user_alias, braze_id, email, or phone.', - 'Missing required identifier', + 'Missing required fields', 400 ) } From 538097d8ba951b1cbcd6c859288e5978d0f6b6ab Mon Sep 17 00:00:00 2001 From: Vitor Oliveira Date: Wed, 15 Apr 2026 15:13:29 +0100 Subject: [PATCH 003/210] addressing issues raised by copilot --- packages/destination-actions/src/destinations/braze/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/destination-actions/src/destinations/braze/utils.ts b/packages/destination-actions/src/destinations/braze/utils.ts index 48b0b3da7e9..70ec3d6fb65 100644 --- a/packages/destination-actions/src/destinations/braze/utils.ts +++ b/packages/destination-actions/src/destinations/braze/utils.ts @@ -690,7 +690,7 @@ export function mergeUsers(request: RequestClient, settings: Settings, payload: if (!hasKeepIdentifier) { throw new IntegrationError( 'Identifier to Keep must specify one of: external_id, user_alias, braze_id, email, or phone.', - 'Missing required identifier', + 'Missing required fields', 400 ) } From dd570f65f888130175a21231c4571aff3372663e Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 21 Apr 2026 14:36:55 +0100 Subject: [PATCH 004/210] updating the new action as per review session with Rohan and Joe --- .../braze/mergeUsers/generated-types.ts | 70 ++--- .../destinations/braze/mergeUsers/index.ts | 250 ++++++++++-------- .../destinations/braze/mergeUsers/types.ts | 26 ++ .../src/destinations/braze/userAlias.ts | 2 +- .../src/destinations/braze/utils.ts | 87 +++--- 5 files changed, 234 insertions(+), 201 deletions(-) create mode 100644 packages/destination-actions/src/destinations/braze/mergeUsers/types.ts diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts index 2567bcc30a5..02845070f7d 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts @@ -2,59 +2,45 @@ export interface Payload { /** - * User identifier for the user to be merged (the user to be deprecated). Must specify one of: External ID, User Alias, Braze ID, Email, or Phone. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_users_merge/). + * The type of identifier for the user to be merged. One of: external_id, user_alias, braze_id, email, or phone. */ - identifier_to_merge: { - /** - * The external ID of the user to merge - */ - external_id?: string - /** - * The user alias object identifying the user to merge - */ - user_alias?: { - alias_name?: string - alias_label?: string - } - /** - * The Braze ID of the user to merge - */ - braze_id?: string + previousIdType: string + /** + * The value of the identifier for the user to be merged. + */ + previousIdValue?: string + /** + * The value of the user alias identifier for the user to be merged. Required if the previous identifier type is user_alias. + */ + previousAliasIdValue?: { /** - * The email address of the user to merge + * The label of the user alias for the user to be merged. */ - email?: string + alias_label: string /** - * The phone number of the user to merge in E.164 format (e.g., +14155552671) + * The name of the user alias for the user to be merged. */ - phone?: string + alias_name: string } /** - * User identifier for the user to keep (the target user). Must specify one of: External ID, User Alias, Braze ID, Email, or Phone. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_users_merge/). + * The type of identifier for the user to be kept. One of: external_id, user_alias, braze_id, email, or phone. */ - identifier_to_keep: { - /** - * The external ID of the user to keep - */ - external_id?: string - /** - * The user alias object identifying the user to keep - */ - user_alias?: { - alias_name?: string - alias_label?: string - } - /** - * The Braze ID of the user to keep - */ - braze_id?: string + keepIdType: string + /** + * The value of the identifier for the user 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 email address of the user to keep + * The label of the user alias for the user to be kept. */ - email?: string + alias_label: string /** - * The phone number of the user to keep in E.164 format (e.g., +14155552671) + * The name of the user alias for the user to be kept. */ - phone?: string + alias_name: string } } diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts index f4125102232..ee22fe9d3b4 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -7,127 +7,165 @@ const action: ActionDefinition = { title: 'Merge Users', description: 'Merge one identified user into another identified user. The merge will occur asynchronously and can take between 5-10 minutes.', + defaultSubscription: 'type = "alias"', fields: { - identifier_to_merge: { - label: 'Identifier to Merge', + previousIdType: { + label: 'Type of Identifier to merge', description: - 'User identifier for the user to be merged (the user to be deprecated). Must specify one of: External ID, User Alias, Braze ID, Email, or Phone. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_users_merge/).', - type: 'object', + 'The type of identifier for the user to be merged. One of: external_id, user_alias, braze_id, email, or phone.', + type: 'string', required: true, - defaultObjectUI: 'keyvalue', - additionalProperties: false, - properties: { - external_id: { - label: 'External ID', - description: 'The external ID of the user to merge', - type: 'string' - }, - user_alias: { - label: 'User Alias', - description: 'The user alias object identifying the user to merge', - type: 'object', - properties: { - alias_name: { - label: 'Alias Name', - type: 'string' - }, - alias_label: { - label: 'Alias Label', - type: 'string' - } + choices: [ + { label: 'External ID', value: 'external_id' }, + { label: 'User Alias', value: 'user_alias' }, + { label: 'Braze ID', value: 'braze_id' }, + { label: 'Email', value: 'email' }, + { label: 'Phone', value: 'phone' } + ], + default: 'external_id' + }, + previousIdValue: { + label: 'ID value to merge', + description: 'The value of the identifier for the user to be merged.', + type: 'string', + required: { + match: 'all', + conditions: [ + { + fieldKey: 'previousIdType', + operator: 'is_not', + value: 'user_alias' } - }, - braze_id: { - label: 'Braze ID', - description: 'The Braze ID of the user to merge', - type: 'string' - }, - email: { - label: 'Email', - description: 'The email address of the user to merge', + ] + }, + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'previousIdType', + operator: 'is_not', + value: 'user_alias' + } + ] + }, + default: '$.previousId' + }, + previousAliasIdValue: { + label: 'User Alias value to merge', + description: 'The value of the user alias identifier for the user 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', - format: 'email' + required: true }, - phone: { - label: 'Phone', - description: 'The phone number of the user to merge in E.164 format (e.g., +14155552671)', - type: 'string' + alias_name: { + label: 'User Alias Name', + description: 'The name of the user alias for the user to be merged.', + type: 'string', + required: true } } }, - identifier_to_keep: { - label: 'Identifier to Keep', + keepIdType: { + label: 'Type of Identifier to keep', description: - 'User identifier for the user to keep (the target user). Must specify one of: External ID, User Alias, Braze ID, Email, or Phone. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_users_merge/).', - type: 'object', + 'The type of identifier for the user to be kept. One of: external_id, user_alias, braze_id, email, or phone.', + type: 'string', required: true, - defaultObjectUI: 'keyvalue', - additionalProperties: false, - properties: { - external_id: { - label: 'External ID', - description: 'The external ID of the user to keep', - type: 'string', - default: { - '@path': '$.userId' + choices: [ + { label: 'External ID', value: 'external_id' }, + { label: 'User Alias', value: 'user_alias' }, + { label: 'Braze ID', value: 'braze_id' }, + { label: 'Email', value: 'email' }, + { label: 'Phone', value: 'phone' } + ], + default: 'external_id' + }, + keepIdValue: { + label: 'ID value to keep', + description: 'The value of the identifier for the user to be kept.', + type: 'string', + required: { + match: 'all', + conditions: [ + { + fieldKey: 'keepIdType', + operator: 'is_not', + value: 'user_alias' } - }, - user_alias: { - label: 'User Alias', - description: 'The user alias object identifying the user to keep', - type: 'object', - properties: { - alias_name: { - label: 'Alias Name', - type: 'string' - }, - alias_label: { - label: 'Alias Label', - type: 'string' - } + ] + }, + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'keepIdType', + operator: 'is_not', + value: 'user_alias' } - }, - braze_id: { - label: 'Braze ID', - description: 'The Braze ID of the user to keep', - type: 'string' - }, - email: { - label: 'Email', - description: 'The email address of the user to keep', - type: 'string', - format: 'email' - }, - phone: { - label: 'Phone', - description: 'The phone number of the user to keep in E.164 format (e.g., +14155552671)', - type: 'string' - } + ] }, - default: { - external_id: { - '@path': '$.userId' - }, - braze_id: { - '@if': { - exists: { '@path': '$.context.traits.brazeId' } - }, - then: { '@path': '$.context.traits.brazeId' }, - else: { '@path': '$.properties.brazeId' } - }, - email: { - '@if': { - exists: { '@path': '$.context.traits.email' } - }, - then: { '@path': '$.context.traits.email' }, - else: { '@path': '$.properties.email' } + default: '$.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 for the user to be kept.', + type: 'string', + required: true }, - phone: { - '@if': { - exists: { '@path': '$.context.traits.phone' } - }, - then: { '@path': '$.context.traits.phone' }, - else: { '@path': '$.properties.phone' } + alias_name: { + label: 'User Alias Name', + description: 'The name of the user alias for the user to be kept.', + type: 'string', + required: true } } } 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..a9876294386 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts @@ -0,0 +1,26 @@ +export type MergeUsersJSON = { + identifier_to_merge: { + // Only one of the following + external_id?: string + user_alias?: { + alias_label: string + alias_name: string + } + braze_id?: string + email?: string + phone?: string + } + identifier_to_keep: { + // Only one of the following + external_id?: string + user_alias?: { + alias_label: string + alias_name: string + } + braze_id?: string + email?: string + phone?: string + } +} + +export type MergeIdentifierType = 'external_id' | 'user_alias' | 'braze_id' | 'email' | 'phone' \ No newline at end of file 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/braze/utils.ts b/packages/destination-actions/src/destinations/braze/utils.ts index 70ec3d6fb65..b0b7e932554 100644 --- a/packages/destination-actions/src/destinations/braze/utils.ts +++ b/packages/destination-actions/src/destinations/braze/utils.ts @@ -1,4 +1,4 @@ -import { JSONLikeObject, ModifiedResponse, MultiStatusResponse, omit } from '@segment/actions-core' +import { JSONLikeObject, ModifiedResponse, MultiStatusResponse, omit, PayloadValidationError } from '@segment/actions-core' import { IntegrationError, RequestClient, removeUndefined } from '@segment/actions-core' import dayjs from 'dayjs' import { Settings } from './generated-types' @@ -7,7 +7,8 @@ import { Payload as TrackEventPayload } from './trackEvent/generated-types' import { Payload as TrackPurchasePayload } from './trackPurchase/generated-types' import { Payload as UpdateUserProfilePayload } from './updateUserProfile/generated-types' import { Payload as MergeUsersPayload } from './mergeUsers/generated-types' -import { getUserAlias } from './userAlias' +import { MergeUsersJSON, MergeIdentifierType } from './mergeUsers/types' +import { getUserAlias, UserAlias } from './userAlias' import { HTTPError } from '@segment/actions-core' import { MAX_BATCH_SIZE } from './constants' type DateInput = string | Date | number | null | undefined @@ -661,56 +662,21 @@ async function handleBrazeAPIResponse( } export function mergeUsers(request: RequestClient, settings: Settings, payload: MergeUsersPayload) { - // Validate identifier_to_merge - const mergeUserAlias = getUserAlias(payload.identifier_to_merge?.user_alias) - const hasMergeIdentifier = - payload.identifier_to_merge?.external_id || - mergeUserAlias || - payload.identifier_to_merge?.braze_id || - payload.identifier_to_merge?.email || - payload.identifier_to_merge?.phone - - if (!hasMergeIdentifier) { - throw new IntegrationError( - 'Identifier to Merge must specify one of: external_id, user_alias, braze_id, email, or phone.', - 'Missing required fields', - 400 - ) - } - - // Validate identifier_to_keep - const keepUserAlias = getUserAlias(payload.identifier_to_keep?.user_alias) - const hasKeepIdentifier = - payload.identifier_to_keep?.external_id || - keepUserAlias || - payload.identifier_to_keep?.braze_id || - payload.identifier_to_keep?.email || - payload.identifier_to_keep?.phone - - if (!hasKeepIdentifier) { - throw new IntegrationError( - 'Identifier to Keep must specify one of: external_id, user_alias, braze_id, email, or phone.', - 'Missing required fields', - 400 - ) - } - - // Build the merge update object - const mergeUpdate: Record = { - identifier_to_merge: { - ...(payload.identifier_to_merge?.external_id && { external_id: payload.identifier_to_merge.external_id }), - ...(mergeUserAlias && { user_alias: mergeUserAlias }), - ...(payload.identifier_to_merge?.braze_id && { braze_id: payload.identifier_to_merge.braze_id }), - ...(payload.identifier_to_merge?.email && { email: payload.identifier_to_merge.email }), - ...(payload.identifier_to_merge?.phone && { phone: payload.identifier_to_merge.phone }) - }, - identifier_to_keep: { - ...(payload.identifier_to_keep?.external_id && { external_id: payload.identifier_to_keep.external_id }), - ...(keepUserAlias && { user_alias: keepUserAlias }), - ...(payload.identifier_to_keep?.braze_id && { braze_id: payload.identifier_to_keep.braze_id }), - ...(payload.identifier_to_keep?.email && { email: payload.identifier_to_keep.email }), - ...(payload.identifier_to_keep?.phone && { phone: payload.identifier_to_keep.phone }) - } + const { + previousIdType, + previousIdValue, + previousAliasIdValue, + keepIdType, + keepIdValue, + keepAliasIdValue + } = payload + + const previousId = getMergeIdentifier(previousIdType as MergeIdentifierType, 'merge', previousIdValue, previousAliasIdValue) + const keepId = getMergeIdentifier(keepIdType as MergeIdentifierType, 'keep', keepIdValue, keepAliasIdValue) + + const mergeUpdate: MergeUsersJSON = { + identifier_to_merge: { [previousIdType]: previousId }, + identifier_to_keep: { [keepIdType]: keepId } } return request(`${settings.endpoint}/users/merge`, { @@ -721,6 +687,23 @@ export function mergeUsers(request: RequestClient, settings: Settings, payload: }) } +export 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} value must be provided.`) + } + + return value +} + + export function generateMultiStatusError(batchSize: number, errorMessage: string): MultiStatusResponse { const multiStatusResponse = new MultiStatusResponse() From 599739a43ab4ff432cbda72add0a023b934a7133 Mon Sep 17 00:00:00 2001 From: Vitor Oliveira Date: Wed, 22 Apr 2026 15:18:13 +0200 Subject: [PATCH 005/210] changes to parameters --- .../destinations/braze/mergeUsers/index.ts | 84 ++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts index ee22fe9d3b4..48de156c6f7 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -3,6 +3,13 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { mergeUsers } from '../utils' +const prioritizationChoices = [ + { value: 'identified', label: 'Identified' }, + { value: 'unidentified', label: 'Unidentified' }, + { value: 'most_recently_updated', label: 'Most Recently Updated' }, + { value: 'least_recently_updated', label: 'Least Recently Updated' } +] + const action: ActionDefinition = { title: 'Merge Users', description: @@ -12,17 +19,18 @@ const action: ActionDefinition = { previousIdType: { label: 'Type of Identifier to merge', description: - 'The type of identifier for the user to be merged. One of: external_id, user_alias, braze_id, email, or phone.', + 'The type of identifier for the user to be merged. One of: external_id, user_alias, email, or phone.', type: 'string', required: true, choices: [ { label: 'External ID', value: 'external_id' }, { label: 'User Alias', value: 'user_alias' }, - { label: 'Braze ID', value: 'braze_id' }, { label: 'Email', value: 'email' }, { label: 'Phone', value: 'phone' } ], - default: 'external_id' + default: { + '@path': 'external_id' + } }, previousIdValue: { label: 'ID value to merge', @@ -48,11 +56,14 @@ const action: ActionDefinition = { } ] }, - default: '$.previousId' + default: { + '@path': '$.previousId' + } }, previousAliasIdValue: { label: 'User Alias value to merge', - description: 'The value of the user alias identifier for the user to be merged. Required if the previous identifier type is user_alias.', + description: + 'The value of the user alias identifier for the user to be merged. Required if the previous identifier type is user_alias.', type: 'object', required: { match: 'all', @@ -91,14 +102,12 @@ const action: ActionDefinition = { }, keepIdType: { label: 'Type of Identifier to keep', - description: - 'The type of identifier for the user to be kept. One of: external_id, user_alias, braze_id, email, or phone.', + description: 'The type of identifier for the user to be kept. One of: external_id, user_alias, email, or phone.', type: 'string', required: true, choices: [ { label: 'External ID', value: 'external_id' }, { label: 'User Alias', value: 'user_alias' }, - { label: 'Braze ID', value: 'braze_id' }, { label: 'Email', value: 'email' }, { label: 'Phone', value: 'phone' } ], @@ -128,11 +137,12 @@ const action: ActionDefinition = { } ] }, - default: '$.userId' + default: 'external_id' }, 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.', + 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', @@ -168,6 +178,60 @@ const action: ActionDefinition = { required: true } } + }, + keepIdPrioritization: { + label: 'Rule Prioritization', + description: 'Rule determining which user to merge if multiple users are found.', + type: 'string', + choices: prioritizationChoices, + required: { + match: 'all', + conditions: [ + { + fieldKey: 'keepIdType', + operator: 'is', + value: ['email', 'phone'] + } + ] + }, + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'keepIdType', + operator: 'is', + value: ['email', 'phone'] + } + ] + }, + default: 'identified' + }, + previousIdPrioritization: { + label: 'Rule Prioritization', + description: 'Rule determining which user to merge if multiple users are found.', + type: 'string', + choices: prioritizationChoices, + required: { + match: 'all', + conditions: [ + { + fieldKey: 'previousIdType', + operator: 'is', + value: ['email', 'phone'] + } + ] + }, + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'previousIdType', + operator: 'is', + value: ['email', 'phone'] + } + ] + }, + default: 'identified' } }, perform: (request, { settings, payload }) => { From a9e73d5103cf3731003129137028b4b577efc727 Mon Sep 17 00:00:00 2001 From: Vitor Oliveira Date: Wed, 22 Apr 2026 16:10:34 +0200 Subject: [PATCH 006/210] updating types --- .../src/destinations/braze/mergeUsers/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts index a9876294386..55db4a62b1d 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts @@ -6,9 +6,9 @@ export type MergeUsersJSON = { alias_label: string alias_name: string } - braze_id?: string email?: string phone?: string + previousIdPrioritization?: string } identifier_to_keep: { // Only one of the following @@ -17,10 +17,10 @@ export type MergeUsersJSON = { alias_label: string alias_name: string } - braze_id?: string email?: string phone?: string + keepIdPrioritization?: string } } -export type MergeIdentifierType = 'external_id' | 'user_alias' | 'braze_id' | 'email' | 'phone' \ No newline at end of file +export type MergeIdentifierType = 'external_id' | 'user_alias' | 'email' | 'phone' From 983d641e1de9cddc367a0d3b5159e09900f37ccf Mon Sep 17 00:00:00 2001 From: Vitor Oliveira Date: Wed, 6 May 2026 10:24:03 +0100 Subject: [PATCH 007/210] New unit tests --- .../__snapshots__/snapshot.test.ts.snap | 8 - .../braze/mergeUsers/__tests__/index.test.ts | 258 +++++++----------- .../mergeUsers/__tests__/snapshot.test.ts | 65 ++--- 3 files changed, 113 insertions(+), 218 deletions(-) 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 index 91c9b17acfd..b69c727ebaa 100644 --- 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 @@ -5,20 +5,12 @@ Object { "merge_updates": Array [ Object { "identifier_to_keep": Object { - "braze_id": "8$QYsxcxpjIn)Y", - "email": "gonwijpel@vep.il", - "external_id": "8$QYsxcxpjIn)Y", - "phone": "8$QYsxcxpjIn)Y", "user_alias": Object { "alias_label": "segment", "alias_name": "keep-alias", }, }, "identifier_to_merge": Object { - "braze_id": "8$QYsxcxpjIn)Y", - "email": "gonwijpel@vep.il", - "external_id": "8$QYsxcxpjIn)Y", - "phone": "8$QYsxcxpjIn)Y", "user_alias": Object { "alias_label": "segment", "alias_name": "merge-alias", 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 index fc39bae3003..80609ef997d 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts @@ -18,21 +18,16 @@ describe('Braze.mergeUsers', () => { it('should merge users with external_id identifiers', async () => { nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) - const event = createTestEvent({ - type: 'track', - userId: 'user-to-keep-123' - }) + const event = createTestEvent({ type: 'alias' }) const responses = await testDestination.testAction('mergeUsers', { event, settings, mapping: { - identifier_to_merge: { - external_id: 'user-to-merge-456' - }, - identifier_to_keep: { - external_id: 'user-to-keep-123' - } + previousIdType: 'external_id', + previousIdValue: 'user-to-merge-456', + keepIdType: 'external_id', + keepIdValue: 'user-to-keep-123' } }) @@ -42,12 +37,8 @@ describe('Braze.mergeUsers', () => { 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' - } + identifier_to_merge: { external_id: 'user-to-merge-456' }, + identifier_to_keep: { external_id: 'user-to-keep-123' } } ] }) @@ -56,26 +47,16 @@ describe('Braze.mergeUsers', () => { it('should merge users with user_alias identifiers', async () => { nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) - const event = createTestEvent({ - type: 'track' - }) + const event = createTestEvent({ type: 'alias' }) const responses = await testDestination.testAction('mergeUsers', { event, settings, mapping: { - identifier_to_merge: { - user_alias: { - alias_name: 'merge-alias', - alias_label: 'segment' - } - }, - identifier_to_keep: { - user_alias: { - alias_name: 'keep-alias', - alias_label: 'segment' - } - } + previousIdType: 'user_alias', + previousAliasIdValue: { alias_name: 'merge-alias', alias_label: 'segment' }, + keepIdType: 'user_alias', + keepAliasIdValue: { alias_name: 'keep-alias', alias_label: 'segment' } } }) @@ -84,18 +65,8 @@ describe('Braze.mergeUsers', () => { 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' - } - } + identifier_to_merge: { user_alias: { alias_name: 'merge-alias', alias_label: 'segment' } }, + identifier_to_keep: { user_alias: { alias_name: 'keep-alias', alias_label: 'segment' } } } ] }) @@ -104,20 +75,18 @@ describe('Braze.mergeUsers', () => { it('should merge users with email identifiers', async () => { nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) - const event = createTestEvent({ - type: 'track' - }) + const event = createTestEvent({ type: 'alias' }) const responses = await testDestination.testAction('mergeUsers', { event, settings, mapping: { - identifier_to_merge: { - email: 'merge@example.com' - }, - identifier_to_keep: { - email: 'keep@example.com' - } + previousIdType: 'email', + previousIdValue: 'merge@example.com', + previousIdPrioritization: 'identified', + keepIdType: 'email', + keepIdValue: 'keep@example.com', + keepIdPrioritization: 'identified' } }) @@ -126,34 +95,28 @@ describe('Braze.mergeUsers', () => { expect(responses[0].options.json).toMatchObject({ merge_updates: [ { - identifier_to_merge: { - email: 'merge@example.com' - }, - identifier_to_keep: { - email: 'keep@example.com' - } + identifier_to_merge: { email: 'merge@example.com' }, + identifier_to_keep: { email: 'keep@example.com' } } ] }) }) - it('should merge users with braze_id identifiers', async () => { + it('should merge users with phone identifiers', async () => { nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) - const event = createTestEvent({ - type: 'track' - }) + const event = createTestEvent({ type: 'alias' }) const responses = await testDestination.testAction('mergeUsers', { event, settings, mapping: { - identifier_to_merge: { - braze_id: 'braze-merge-id-123' - }, - identifier_to_keep: { - braze_id: 'braze-keep-id-456' - } + previousIdType: 'phone', + previousIdValue: '+14155551234', + previousIdPrioritization: 'identified', + keepIdType: 'phone', + keepIdValue: '+14155555678', + keepIdPrioritization: 'identified' } }) @@ -162,34 +125,27 @@ describe('Braze.mergeUsers', () => { expect(responses[0].options.json).toMatchObject({ merge_updates: [ { - identifier_to_merge: { - braze_id: 'braze-merge-id-123' - }, - identifier_to_keep: { - braze_id: 'braze-keep-id-456' - } + identifier_to_merge: { phone: '+14155551234' }, + identifier_to_keep: { phone: '+14155555678' } } ] }) }) - it('should merge users with phone identifiers', async () => { + it('should merge users with mixed identifier types', async () => { nock(settings.endpoint).post('/users/merge').reply(200, { message: 'success' }) - const event = createTestEvent({ - type: 'track' - }) + const event = createTestEvent({ type: 'alias' }) const responses = await testDestination.testAction('mergeUsers', { event, settings, mapping: { - identifier_to_merge: { - phone: '+14155551234' - }, - identifier_to_keep: { - phone: '+14155555678' - } + previousIdType: 'email', + previousIdValue: 'merge@example.com', + previousIdPrioritization: 'identified', + keepIdType: 'external_id', + keepIdValue: 'user-to-keep-123' } }) @@ -198,35 +154,26 @@ describe('Braze.mergeUsers', () => { expect(responses[0].options.json).toMatchObject({ merge_updates: [ { - identifier_to_merge: { - phone: '+14155551234' - }, - identifier_to_keep: { - phone: '+14155555678' - } + identifier_to_merge: { email: 'merge@example.com' }, + identifier_to_keep: { external_id: 'user-to-keep-123' } } ] }) }) - it('should merge users with mixed identifier types', async () => { + 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: 'track', - userId: 'user-to-keep-123' - }) + const event = createTestEvent({ type: 'alias' }) const responses = await testDestination.testAction('mergeUsers', { event, settings, mapping: { - identifier_to_merge: { - email: 'merge@example.com' - }, - identifier_to_keep: { - external_id: 'user-to-keep-123' - } + previousIdType: 'user_alias', + previousAliasIdValue: { alias_name: 'anon-alias', alias_label: 'segment' }, + keepIdType: 'external_id', + keepIdValue: 'known-user-123' } }) @@ -235,105 +182,92 @@ describe('Braze.mergeUsers', () => { expect(responses[0].options.json).toMatchObject({ merge_updates: [ { - identifier_to_merge: { - email: 'merge@example.com' - }, - identifier_to_keep: { - external_id: 'user-to-keep-123' - } + 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 identifier_to_merge has no valid identifier', async () => { - const event = createTestEvent({ - type: 'track' - }) + 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: { - identifier_to_merge: {}, - identifier_to_keep: { - external_id: 'user-to-keep-123' - } + previousIdType: 'external_id', + keepIdType: 'external_id', + keepIdValue: 'user-to-keep-123' } }) - ).rejects.toThrowError( - 'Identifier to Merge must specify one of: external_id, user_alias, braze_id, email, or phone.' - ) + ).rejects.toThrowError("missing the required field 'previousIdValue'") }) - it('should throw error when identifier_to_keep has no valid identifier', async () => { - const event = createTestEvent({ - type: 'track' - }) + 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: { - identifier_to_merge: { - external_id: 'user-to-merge-456' - }, - identifier_to_keep: {} + previousIdType: 'external_id', + previousIdValue: 'user-to-merge-456', + keepIdType: 'external_id' } }) - ).rejects.toThrowError( - 'Identifier to Keep must specify one of: external_id, user_alias, braze_id, email, or phone.' - ) + ).rejects.toThrowError("missing the required field 'keepIdValue'") }) - it('should handle incomplete user_alias (missing required fields)', async () => { - const event = createTestEvent({ - type: 'track' - }) + 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: { - identifier_to_merge: { - user_alias: { - alias_name: 'test' - // missing alias_label - } - }, - identifier_to_keep: { - external_id: 'user-to-keep-123' - } + previousIdType: 'user_alias', + previousAliasIdValue: { alias_name: 'merge-alias' }, + keepIdType: 'external_id', + keepIdValue: 'user-to-keep-123' } }) - ).rejects.toThrowError( - 'Identifier to Merge must specify one of: external_id, user_alias, braze_id, email, or phone.' - ) + ).rejects.toThrowError("missing the required field 'alias_label'") }) - it('should use default mapping from userId for identifier_to_keep', async () => { + 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: 'track', - userId: 'user-to-keep-123' - }) + const event = createTestEvent({ type: 'alias', userId: 'user-to-keep-123' }) const responses = await testDestination.testAction('mergeUsers', { event, settings, mapping: { - identifier_to_merge: { - external_id: 'user-to-merge-456' - }, - identifier_to_keep: { - external_id: { - '@path': '$.userId' - } - } + previousIdType: 'external_id', + previousIdValue: 'user-to-merge-456', + keepIdType: 'external_id', + keepIdValue: { '@path': '$.userId' } } }) @@ -342,12 +276,8 @@ describe('Braze.mergeUsers', () => { 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' - } + identifier_to_merge: { external_id: 'user-to-merge-456' }, + identifier_to_keep: { external_id: 'user-to-keep-123' } } ] }) 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 index 263bd2efb7a..0affab6d029 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts @@ -13,27 +13,20 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [, settingsData] = generateTestData(seedName, destination, action, true) - // Provide custom event data with valid identifiers - const customEventData = { - identifier_to_merge: { - external_id: 'user-to-merge' - }, - identifier_to_keep: { - external_id: 'user-to-keep' - } - } - nock(/.*/).persist().get(/.*/).reply(200) nock(/.*/).persist().post(/.*/).reply(200) nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ - properties: customEventData - }) + const event = createTestEvent({ type: 'alias' }) const responses = await testDestination.testAction(actionSlug, { - event: event, - mapping: customEventData, + event, + mapping: { + previousIdType: 'external_id', + previousIdValue: 'user-to-merge', + keepIdType: 'external_id', + keepIdValue: 'user-to-keep' + }, settings: settingsData, auth: undefined }) @@ -54,44 +47,24 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac it('all fields', async () => { const action = destination.actions[actionSlug] - const [eventData, settingsData] = generateTestData(seedName, destination, action, false) - - // Customize the nested objects to include all identifier types - const customEventData = { - ...eventData, - identifier_to_merge: { - external_id: eventData.identifier_to_merge?.external_id || 'merge-external-id', - braze_id: eventData.identifier_to_merge?.braze_id || 'merge-braze-id', - email: eventData.identifier_to_merge?.email || 'merge@example.com', - phone: eventData.identifier_to_merge?.phone || '+14155551234', - user_alias: { - alias_name: 'merge-alias', - alias_label: 'segment' - } - }, - identifier_to_keep: { - external_id: eventData.identifier_to_keep?.external_id || 'keep-external-id', - braze_id: eventData.identifier_to_keep?.braze_id || 'keep-braze-id', - email: eventData.identifier_to_keep?.email || 'keep@example.com', - phone: eventData.identifier_to_keep?.phone || '+14155555678', - user_alias: { - alias_name: 'keep-alias', - alias_label: 'segment' - } - } - } + 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({ - properties: customEventData - }) + const event = createTestEvent({ type: 'alias' }) const responses = await testDestination.testAction(actionSlug, { - event: event, - mapping: customEventData, + event, + mapping: { + previousIdType: 'user_alias', + previousAliasIdValue: { alias_name: 'merge-alias', alias_label: 'segment' }, + previousIdPrioritization: 'identified', + keepIdType: 'user_alias', + keepAliasIdValue: { alias_name: 'keep-alias', alias_label: 'segment' }, + keepIdPrioritization: 'identified' + }, settings: settingsData, auth: undefined }) From 9b0f722e1ec4a9661fe895b8a878fc6fe6e8cf8b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 7 May 2026 11:23:01 +0100 Subject: [PATCH 008/210] fixing some issues and tests --- .../__snapshots__/snapshot.test.ts.snap | 17 +-- .../braze/mergeUsers/__tests__/index.test.ts | 89 ++++++++++++++- .../mergeUsers/__tests__/snapshot.test.ts | 13 ++- .../braze/mergeUsers/functions.ts | 101 ++++++++++++++++++ .../braze/mergeUsers/generated-types.ts | 22 ++-- .../destinations/braze/mergeUsers/index.ts | 79 +++++--------- .../destinations/braze/mergeUsers/types.ts | 56 ++++++---- .../src/destinations/braze/utils.ts | 49 +-------- 8 files changed, 277 insertions(+), 149 deletions(-) create mode 100644 packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts 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 index b69c727ebaa..61c0aa2e8b6 100644 --- 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 @@ -5,16 +5,17 @@ Object { "merge_updates": Array [ Object { "identifier_to_keep": Object { - "user_alias": Object { - "alias_label": "segment", - "alias_name": "keep-alias", - }, + "email": "keep@example.com", + "prioritization": Array [ + "unidentified", + ], }, "identifier_to_merge": Object { - "user_alias": Object { - "alias_label": "segment", - "alias_name": "merge-alias", - }, + "email": "merge@example.com", + "prioritization": Array [ + "identified", + "most_recently_updated", + ], }, }, ], 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 index 80609ef997d..727970dd834 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts @@ -95,8 +95,8 @@ describe('Braze.mergeUsers', () => { expect(responses[0].options.json).toMatchObject({ merge_updates: [ { - identifier_to_merge: { email: 'merge@example.com' }, - identifier_to_keep: { email: 'keep@example.com' } + identifier_to_merge: { email: 'merge@example.com', prioritization: ['identified'] }, + identifier_to_keep: { email: 'keep@example.com', prioritization: ['identified'] } } ] }) @@ -125,8 +125,8 @@ describe('Braze.mergeUsers', () => { expect(responses[0].options.json).toMatchObject({ merge_updates: [ { - identifier_to_merge: { phone: '+14155551234' }, - identifier_to_keep: { phone: '+14155555678' } + identifier_to_merge: { phone: '+14155551234', prioritization: ['identified'] }, + identifier_to_keep: { phone: '+14155555678', prioritization: ['identified'] } } ] }) @@ -154,7 +154,7 @@ describe('Braze.mergeUsers', () => { expect(responses[0].options.json).toMatchObject({ merge_updates: [ { - identifier_to_merge: { email: 'merge@example.com' }, + identifier_to_merge: { email: 'merge@example.com', prioritization: ['identified'] }, identifier_to_keep: { external_id: 'user-to-keep-123' } } ] @@ -282,4 +282,83 @@ describe('Braze.mergeUsers', () => { ] }) }) + + 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') + }) }) 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 index 0affab6d029..75bb5e5a096 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts @@ -58,12 +58,12 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const responses = await testDestination.testAction(actionSlug, { event, mapping: { - previousIdType: 'user_alias', - previousAliasIdValue: { alias_name: 'merge-alias', alias_label: 'segment' }, - previousIdPrioritization: 'identified', - keepIdType: 'user_alias', - keepAliasIdValue: { alias_name: 'keep-alias', alias_label: 'segment' }, - keepIdPrioritization: 'identified' + previousIdType: 'email', + previousIdValue: 'merge@example.com', + previousIdPrioritization: 'identified,most_recently_updated', + keepIdType: 'email', + keepIdValue: 'keep@example.com', + keepIdPrioritization: 'unidentified' }, settings: settingsData, auth: undefined @@ -75,7 +75,6 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac try { const json = JSON.parse(rawBody) expect(json).toMatchSnapshot() - return } 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..4ceb8f2eb4b --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts @@ -0,0 +1,101 @@ +import { RequestClient, PayloadValidationError } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { Payload as MergeUsersPayload } from '../mergeUsers/generated-types' +import { MergeUsersJSON, MergeIdentifierType, Prioritization } from '../mergeUsers/types' +import { UserAlias } from '../userAlias' + +export function mergeUsers(request: RequestClient, settings: Settings, payload: MergeUsersPayload) { + 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 json: MergeUsersJSON = { + merge_updates: [ + { + identifier_to_merge: { + [previousIdType]: previousId, + ...(Array.isArray(previousIdPri) ? { prioritization: previousIdPri } : {}) + }, + identifier_to_keep: { + [keepIdType]: keepId, + ...(Array.isArray(keepIdPri) ? { prioritization: keepIdPri } : {}) + } + } + ] + } + + return request(`${settings.endpoint}/users/merge`, { + method: 'post', + json + }) +} + +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(',').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 index 02845070f7d..8de93c3f852 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts @@ -2,15 +2,15 @@ export interface Payload { /** - * The type of identifier for the user to be merged. One of: external_id, user_alias, braze_id, email, or phone. + * 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 identifier for the user to be merged. + * The value of the user identifier to be merged. */ previousIdValue?: string /** - * The value of the user alias identifier for the user to be merged. Required if the previous identifier type is user_alias. + * The value of the user alias identifier to be merged. Required if the previous identifier type is user_alias. */ previousAliasIdValue?: { /** @@ -23,11 +23,11 @@ export interface Payload { alias_name: string } /** - * The type of identifier for the user to be kept. One of: external_id, user_alias, braze_id, email, or phone. + * The type of the user identifier to be kept. One of: external_id, user_alias, email, or phone. */ keepIdType: string /** - * The value of the identifier for the user to be kept. + * The value of the user identifier to be kept. */ keepIdValue?: string /** @@ -35,12 +35,20 @@ export interface Payload { */ keepAliasIdValue?: { /** - * The label of the user alias for the user to be kept. + * The label of the user alias to be kept. */ alias_label: string /** - * The name of the user alias for the user to be kept. + * 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 } diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts index 48de156c6f7..77e81bdde63 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -1,14 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { mergeUsers } from '../utils' - -const prioritizationChoices = [ - { value: 'identified', label: 'Identified' }, - { value: 'unidentified', label: 'Unidentified' }, - { value: 'most_recently_updated', label: 'Most Recently Updated' }, - { value: 'least_recently_updated', label: 'Least Recently Updated' } -] +import { mergeUsers, getPrioritizationChoices, getSupportedIdentifierChoices } from './functions' const action: ActionDefinition = { title: 'Merge Users', @@ -22,19 +15,12 @@ const action: ActionDefinition = { 'The type of identifier for the user to be merged. One of: external_id, user_alias, email, or phone.', type: 'string', required: true, - choices: [ - { label: 'External ID', value: 'external_id' }, - { label: 'User Alias', value: 'user_alias' }, - { label: 'Email', value: 'email' }, - { label: 'Phone', value: 'phone' } - ], - default: { - '@path': 'external_id' - } + choices: getSupportedIdentifierChoices(), + default: 'external_id' }, previousIdValue: { label: 'ID value to merge', - description: 'The value of the identifier for the user to be merged.', + description: 'The value of the user identifier to be merged.', type: 'string', required: { match: 'all', @@ -63,7 +49,7 @@ const action: ActionDefinition = { previousAliasIdValue: { label: 'User Alias value to merge', description: - 'The value of the user alias identifier for the user to be merged. Required if the previous identifier type is user_alias.', + 'The value of the user alias identifier to be merged. Required if the previous identifier type is user_alias.', type: 'object', required: { match: 'all', @@ -102,20 +88,15 @@ const action: ActionDefinition = { }, keepIdType: { label: 'Type of Identifier to keep', - description: 'The type of identifier for the user to be kept. One of: external_id, user_alias, email, or phone.', + description: 'The type of the user identifier to be kept. One of: external_id, user_alias, email, or phone.', type: 'string', required: true, - choices: [ - { label: 'External ID', value: 'external_id' }, - { label: 'User Alias', value: 'user_alias' }, - { label: 'Email', value: 'email' }, - { label: 'Phone', value: 'phone' } - ], + choices: getSupportedIdentifierChoices(), default: 'external_id' }, keepIdValue: { label: 'ID value to keep', - description: 'The value of the identifier for the user to be kept.', + description: 'The value of the user identifier to be kept.', type: 'string', required: { match: 'all', @@ -137,7 +118,7 @@ const action: ActionDefinition = { } ] }, - default: 'external_id' + default: { '@path': '$.userId' } }, keepAliasIdValue: { label: 'User Alias value to keep', @@ -167,13 +148,13 @@ const action: ActionDefinition = { properties: { alias_label: { label: 'User Alias Label', - description: 'The label of the user alias for the user to be kept.', + 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 for the user to be kept.', + description: 'The name of the user alias to be kept.', type: 'string', required: true } @@ -183,55 +164,45 @@ const action: ActionDefinition = { label: 'Rule Prioritization', description: 'Rule determining which user to merge if multiple users are found.', type: 'string', - choices: prioritizationChoices, + allowNull: true, + choices: getPrioritizationChoices(), required: { - match: 'all', + match: 'any', conditions: [ { fieldKey: 'keepIdType', operator: 'is', - value: ['email', 'phone'] - } - ] - }, - depends_on: { - match: 'all', - conditions: [ + value: 'email' + }, { fieldKey: 'keepIdType', operator: 'is', - value: ['email', 'phone'] + value: 'phone' } ] - }, - default: 'identified' + } }, previousIdPrioritization: { label: 'Rule Prioritization', description: 'Rule determining which user to merge if multiple users are found.', type: 'string', - choices: prioritizationChoices, + allowNull: true, + choices: getPrioritizationChoices(), required: { - match: 'all', + match: 'any', conditions: [ { fieldKey: 'previousIdType', operator: 'is', - value: ['email', 'phone'] - } - ] - }, - depends_on: { - match: 'all', - conditions: [ + value: 'email' + }, { fieldKey: 'previousIdType', operator: 'is', - value: ['email', 'phone'] + value: 'phone' } ] - }, - default: 'identified' + } } }, perform: (request, { settings, payload }) => { diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts index 55db4a62b1d..57146c8bac1 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts @@ -1,26 +1,40 @@ export type MergeUsersJSON = { - identifier_to_merge: { - // Only one of the following - external_id?: string - user_alias?: { - alias_label: string - alias_name: string + merge_updates: [ + { + 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 + } } - email?: string - phone?: string - previousIdPrioritization?: string - } - identifier_to_keep: { - // Only one of the following - external_id?: string - user_alias?: { - alias_label: string - alias_name: string - } - email?: string - phone?: string - keepIdPrioritization?: string - } + ] } +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/utils.ts b/packages/destination-actions/src/destinations/braze/utils.ts index b0b7e932554..e9a863cefb1 100644 --- a/packages/destination-actions/src/destinations/braze/utils.ts +++ b/packages/destination-actions/src/destinations/braze/utils.ts @@ -1,4 +1,4 @@ -import { JSONLikeObject, ModifiedResponse, MultiStatusResponse, omit, PayloadValidationError } from '@segment/actions-core' +import { JSONLikeObject, ModifiedResponse, MultiStatusResponse, omit } from '@segment/actions-core' import { IntegrationError, RequestClient, removeUndefined } from '@segment/actions-core' import dayjs from 'dayjs' import { Settings } from './generated-types' @@ -6,9 +6,7 @@ import action from './trackPurchase' import { Payload as TrackEventPayload } from './trackEvent/generated-types' import { Payload as TrackPurchasePayload } from './trackPurchase/generated-types' import { Payload as UpdateUserProfilePayload } from './updateUserProfile/generated-types' -import { Payload as MergeUsersPayload } from './mergeUsers/generated-types' -import { MergeUsersJSON, MergeIdentifierType } from './mergeUsers/types' -import { getUserAlias, UserAlias } from './userAlias' +import { getUserAlias } from './userAlias' import { HTTPError } from '@segment/actions-core' import { MAX_BATCH_SIZE } from './constants' type DateInput = string | Date | number | null | undefined @@ -661,49 +659,6 @@ async function handleBrazeAPIResponse( } } -export function mergeUsers(request: RequestClient, settings: Settings, payload: MergeUsersPayload) { - const { - previousIdType, - previousIdValue, - previousAliasIdValue, - keepIdType, - keepIdValue, - keepAliasIdValue - } = payload - - const previousId = getMergeIdentifier(previousIdType as MergeIdentifierType, 'merge', previousIdValue, previousAliasIdValue) - const keepId = getMergeIdentifier(keepIdType as MergeIdentifierType, 'keep', keepIdValue, keepAliasIdValue) - - const mergeUpdate: MergeUsersJSON = { - identifier_to_merge: { [previousIdType]: previousId }, - identifier_to_keep: { [keepIdType]: keepId } - } - - return request(`${settings.endpoint}/users/merge`, { - method: 'post', - json: { - merge_updates: [mergeUpdate] - } - }) -} - -export 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} value must be provided.`) - } - - return value -} - - export function generateMultiStatusError(batchSize: number, errorMessage: string): MultiStatusResponse { const multiStatusResponse = new MultiStatusResponse() From 361a7f37f391ff6b2a8e3168dcb4f1d53ee7c6b3 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 7 May 2026 11:40:52 +0100 Subject: [PATCH 009/210] adding batching --- .../braze/mergeUsers/functions.ts | 42 +++++++++------- .../destinations/braze/mergeUsers/index.ts | 9 ++-- .../destinations/braze/mergeUsers/types.ts | 50 +++++++++---------- 3 files changed, 54 insertions(+), 47 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts index 4ceb8f2eb4b..89276872217 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts @@ -1,10 +1,21 @@ import { RequestClient, PayloadValidationError } from '@segment/actions-core' import { Settings } from '../generated-types' import { Payload as MergeUsersPayload } from '../mergeUsers/generated-types' -import { MergeUsersJSON, MergeIdentifierType, Prioritization } from '../mergeUsers/types' +import { MergeUsersItem, MergeUsersJSON, MergeIdentifierType, Prioritization } from '../mergeUsers/types' import { UserAlias } from '../userAlias' -export function mergeUsers(request: RequestClient, settings: Settings, payload: MergeUsersPayload) { +export function mergeUsers(request: RequestClient, settings: Settings, payloads: MergeUsersPayload[]) { + const items: MergeUsersJSON = { + merge_updates: payloads.map(getJsonItem) + } + + return request(`${settings.endpoint}/users/merge`, { + method: 'post', + json: items + }) +} + +function getJsonItem(payload: MergeUsersPayload): MergeUsersItem { const { previousIdType, previousIdValue, @@ -27,25 +38,18 @@ export function mergeUsers(request: RequestClient, settings: Settings, payload: const previousIdPri = toPrioritization(previousIdPrioritization) const keepIdPri = toPrioritization(keepIdPrioritization) - const json: MergeUsersJSON = { - merge_updates: [ - { - identifier_to_merge: { - [previousIdType]: previousId, - ...(Array.isArray(previousIdPri) ? { prioritization: previousIdPri } : {}) - }, - identifier_to_keep: { - [keepIdType]: keepId, - ...(Array.isArray(keepIdPri) ? { prioritization: keepIdPri } : {}) - } - } - ] + const item: MergeUsersItem = { + identifier_to_merge: { + [previousIdType]: previousId, + ...(Array.isArray(previousIdPri) ? { prioritization: previousIdPri } : {}) + }, + identifier_to_keep: { + [keepIdType]: keepId, + ...(Array.isArray(keepIdPri) ? { prioritization: keepIdPri } : {}) + } } - return request(`${settings.endpoint}/users/merge`, { - method: 'post', - json - }) + return item } function getMergeIdentifier( diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts index 77e81bdde63..5a5ceadfe23 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -4,9 +4,9 @@ import type { Payload } from './generated-types' import { mergeUsers, getPrioritizationChoices, getSupportedIdentifierChoices } from './functions' const action: ActionDefinition = { - title: 'Merge Users', + 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.', + '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: { @@ -206,8 +206,11 @@ const action: ActionDefinition = { } }, perform: (request, { settings, payload }) => { + return mergeUsers(request, settings, [payload]) + }, + performBatch: (request, { settings, payload }) => { return mergeUsers(request, settings, payload) - } + }, } 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 index 57146c8bac1..741a7679a6a 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts @@ -1,30 +1,30 @@ export type MergeUsersJSON = { - merge_updates: [ - { - 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 - } + 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 = From 3e2e2f22f405afb1d0792e2c16e253c8b684224e Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 7 May 2026 11:43:39 +0100 Subject: [PATCH 010/210] adding batch size field --- .../src/destinations/braze/mergeUsers/generated-types.ts | 4 ++++ .../src/destinations/braze/mergeUsers/index.ts | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts index 8de93c3f852..887dfa31790 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/generated-types.ts @@ -51,4 +51,8 @@ export interface Payload { * 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 index 5a5ceadfe23..8bed8aa5691 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -203,6 +203,14 @@ const action: ActionDefinition = { } ] } + }, + 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, + maximum: 50, + minimum: 1 } }, perform: (request, { settings, payload }) => { From 0ecc645bbbcd8d0af21dd05ed8e03c13df2c7ae6 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 7 May 2026 11:51:42 +0100 Subject: [PATCH 011/210] batch tests added --- .../braze/mergeUsers/__tests__/index.test.ts | 216 +++++++++++++++++- 1 file changed, 215 insertions(+), 1 deletion(-) 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 index 727970dd834..0bf070d42a6 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts @@ -1,5 +1,5 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' import Destination from '../../index' const testDestination = createTestIntegration(Destination) @@ -362,3 +362,217 @@ describe('Braze.mergeUsers', () => { 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') + }) +}) From 686ecdc34c3de6cc5497d0f21f0e181577b38f79 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 7 May 2026 12:01:33 +0100 Subject: [PATCH 012/210] copilot review changes --- .../__snapshots__/snapshot.test.ts.snap | 16 ++++++++++++++++ .../braze/mergeUsers/__tests__/snapshot.test.ts | 1 - .../destinations/braze/mergeUsers/functions.ts | 10 +++++++--- .../src/destinations/braze/mergeUsers/index.ts | 2 +- .../src/destinations/braze/mergeUsers/types.ts | 2 +- 5 files changed, 25 insertions(+), 6 deletions(-) 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 index 61c0aa2e8b6..b3ec3cd4d39 100644 --- 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 @@ -36,3 +36,19 @@ Object { ], } `; + +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__/snapshot.test.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts index 75bb5e5a096..7c418631c43 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/snapshot.test.ts @@ -37,7 +37,6 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac try { const json = JSON.parse(rawBody) expect(json).toMatchSnapshot() - return } 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 index 89276872217..6577cf45187 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts @@ -1,7 +1,7 @@ import { RequestClient, PayloadValidationError } from '@segment/actions-core' import { Settings } from '../generated-types' -import { Payload as MergeUsersPayload } from '../mergeUsers/generated-types' -import { MergeUsersItem, MergeUsersJSON, MergeIdentifierType, Prioritization } from '../mergeUsers/types' +import { Payload as MergeUsersPayload } from './generated-types' +import { MergeUsersItem, MergeUsersJSON, MergeIdentifierType, Prioritization } from './types' import { UserAlias } from '../userAlias' export function mergeUsers(request: RequestClient, settings: Settings, payloads: MergeUsersPayload[]) { @@ -11,6 +11,7 @@ export function mergeUsers(request: RequestClient, settings: Settings, payloads: return request(`${settings.endpoint}/users/merge`, { method: 'post', + ...(payloads.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), json: items }) } @@ -77,7 +78,10 @@ function getMergeIdentifier( function toPrioritization(value: string | undefined | null): Prioritization | undefined { if (!value) return undefined - const parts = value.split(',').filter(Boolean) + const parts = value + .split(',') + .map((p) => p.trim()) + .filter(Boolean) if (parts.length === 0) return undefined return parts as Prioritization } diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts index 8bed8aa5691..22fca561c5a 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -218,7 +218,7 @@ const action: ActionDefinition = { }, performBatch: (request, { settings, payload }) => { return mergeUsers(request, settings, payload) - }, + } } 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 index 741a7679a6a..2770391559b 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/types.ts @@ -1,5 +1,5 @@ export type MergeUsersJSON = { - merge_updates:Array + merge_updates: Array } export type MergeUsersItem = { From 04fc1e63977bd6938d7208757807ae539d28b5a3 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 8 May 2026 13:33:13 +0100 Subject: [PATCH 013/210] fixing batch size --- .../src/destinations/braze/mergeUsers/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts index 22fca561c5a..a2ab68bf819 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -208,9 +208,7 @@ const action: ActionDefinition = { label: 'Batch Size', description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', type: 'number', - default: 50, - maximum: 50, - minimum: 1 + default: 50 } }, perform: (request, { settings, payload }) => { From fd5f34ebc2b020c0d885f6a1569236ecef849441 Mon Sep 17 00:00:00 2001 From: Vitor Oliveira Date: Fri, 8 May 2026 15:13:43 +0100 Subject: [PATCH 014/210] remove prioritization if type is not phone or email --- .../src/destinations/braze/mergeUsers/functions.ts | 8 ++++++-- .../src/destinations/braze/mergeUsers/index.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts index 6577cf45187..6038f45af32 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts @@ -42,11 +42,15 @@ function getJsonItem(payload: MergeUsersPayload): MergeUsersItem { const item: MergeUsersItem = { identifier_to_merge: { [previousIdType]: previousId, - ...(Array.isArray(previousIdPri) ? { prioritization: previousIdPri } : {}) + ...(Array.isArray(previousIdPri) && (previousIdType === 'email' || previousIdType === 'phone') + ? { prioritization: previousIdPri } + : {}) }, identifier_to_keep: { [keepIdType]: keepId, - ...(Array.isArray(keepIdPri) ? { prioritization: keepIdPri } : {}) + ...(Array.isArray(keepIdPri) && (keepIdType === 'email' || keepIdType === 'phone') + ? { prioritization: keepIdPri } + : {}) } } diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts index a2ab68bf819..fcefd7ded84 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -161,7 +161,7 @@ const action: ActionDefinition = { } }, keepIdPrioritization: { - label: 'Rule Prioritization', + label: 'Rule Prioritization (ID to keep)', description: 'Rule determining which user to merge if multiple users are found.', type: 'string', allowNull: true, @@ -183,7 +183,7 @@ const action: ActionDefinition = { } }, previousIdPrioritization: { - label: 'Rule Prioritization', + label: 'Rule Prioritization (ID to merge)', description: 'Rule determining which user to merge if multiple users are found.', type: 'string', allowNull: true, From affea50c538e3d9b4d2a45674d35ff41797b667c Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 12 May 2026 08:38:30 +0100 Subject: [PATCH 015/210] [Google Enhanced Conversions] - Journeysv2 support --- packages/core/src/destination-kit/index.ts | 1 + .../google-enhanced-conversions/functions.ts | 136 ++++++++++++------ .../userList/constants.ts | 1 + .../userList/index.ts | 12 +- 4 files changed, 106 insertions(+), 44 deletions(-) create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/userList/constants.ts diff --git a/packages/core/src/destination-kit/index.ts b/packages/core/src/destination-kit/index.ts index 99473fe05b8..1e6124e155c 100644 --- a/packages/core/src/destination-kit/index.ts +++ b/packages/core/src/destination-kit/index.ts @@ -101,6 +101,7 @@ export type AudienceMode = { type: 'realtime' } | { type: 'synced'; full_audienc export type Personas = { computation_id: string computation_key: string + computation_class: string namespace: string [key: string]: unknown } 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..13a46e019a7 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -26,9 +26,11 @@ import { Features, MultiStatusResponse, JSONLikeObject, - ErrorCodes + ErrorCodes, + AudienceMembership, + FLAGS } from '@segment/actions-core' -import { StatsContext } from '@segment/actions-core/destination-kit' +import { StatsContext, Personas } from '@segment/actions-core/destination-kit' import { fullFormats } from 'ajv-formats/dist/formats' import { HTTPError } from '@segment/actions-core' import type { Payload as UserListPayload } from './userList/generated-types' @@ -525,7 +527,9 @@ const extractUserIdentifiers = ( idType: string, syncMode?: string, features?: Features | undefined, - statsContext?: StatsContext | undefined + statsContext?: StatsContext | undefined, + audienceMembership?: AudienceMembership, + personasContext?: Personas ) => { const removeUserIdentifiers = [] const addUserIdentifiers = [] @@ -566,20 +570,43 @@ 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]){ + const { computation_key, computation_class } = personasContext || {} + for (const payload of payloads) { + if(computation_class === 'journey_step' && !computation_key){ + // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined + audienceMembership === true + } + else if ( + payload.event_name === 'Audience Entered' || + audienceMembership === true + ) { + addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) + } else if ( + payload.event_name === 'Audience Exited' || + audienceMembership === false + ) { + removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) + } + } + } + else { + // 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) } }) + } } } return [addUserIdentifiers, removeUserIdentifiers] @@ -695,7 +722,9 @@ export const handleUpdate = async ( hookListType: string, syncMode?: string, features?: Features | undefined, - statsContext?: StatsContext + statsContext?: StatsContext, + audienceMembership?: AudienceMembership, + personasContext?: Personas ) => { const externalAudienceId: string | undefined = hookListId || payloads[0]?.external_audience_id if (!externalAudienceId) { @@ -708,7 +737,9 @@ export const handleUpdate = async ( id_type, syncMode, features, - statsContext + statsContext, + audienceMembership, + personasContext ) const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId) // Create an offline user data job @@ -912,7 +943,9 @@ const extractBatchUserIdentifiers = ( idType: string, multiStatusResponse: MultiStatusResponse, syncMode?: string, - features?: Features + features?: Features, + audienceMemberships?: AudienceMembership[], + personasContext?: Personas ) => { const removeUserIdentifiers: any[] = [] const addUserIdentifiers: any[] = [] @@ -943,8 +976,8 @@ const extractBatchUserIdentifiers = ( }) return } - const operationType = determineOperationType(payload, syncMode) - if (!operationType) { + const operationType = determineOperationType(payload, syncMode, features, audienceMemberships?.[index], personasContext) + if (operationType === undefined) { multiStatusResponse.setErrorResponseAtIndex(index, { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', @@ -954,7 +987,7 @@ const extractBatchUserIdentifiers = ( } validPayloadIndicesBitmap.push(index) - if (operationType === 'add') { + if (operationType === true) { addUserIdentifiers.push({ create: { userIdentifiers } }) } else { removeUserIdentifiers.push({ remove: { userIdentifiers } }) @@ -965,22 +998,41 @@ const extractBatchUserIdentifiers = ( } // 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, personasContext?: Personas): boolean | undefined => { + if(features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]){ + const { computation_key, computation_class } = personasContext || {} + if(computation_class === 'journey_step' && !computation_key){ + // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined + audienceMembership === true + } + if ( + payload.event_name === 'Audience Entered' || + audienceMembership === true + ) { + return true + } else if ( + payload.event_name === 'Audience Exited' || + audienceMembership === false + ) { + return false + } } - - return null + else { + if ( + payload.event_name === 'Audience Entered' || + syncMode === 'add' || + (syncMode === 'mirror' && payload.event_name === 'new') + ) { + return true + } else if ( + payload.event_name === 'Audience Exited' || + syncMode === 'delete' || + (syncMode === 'mirror' && payload.event_name === 'deleted') + ) { + return false + } + } + return undefined } const createOfflineUserJobPayload = (audienceId: string, payload: UserListPayload, customerId?: string) => ({ @@ -1005,7 +1057,9 @@ export const processBatchPayload = async ( hookListType: string, syncMode?: string, features?: Features | undefined, - statsContext?: StatsContext + statsContext?: StatsContext, + audienceMemberships?: AudienceMembership[], + personasContext?: Personas ) => { const externalAudienceId = hookListId || payloads[0]?.external_audience_id if (!externalAudienceId) { @@ -1019,7 +1073,9 @@ export const processBatchPayload = async ( id_type, multiStatusResponse, syncMode, - features + features, + audienceMemberships, + personasContext ) // Create offline user data job payload const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/constants.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/constants.ts new file mode 100644 index 00000000000..007bcb1d333 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/constants.ts @@ -0,0 +1 @@ +export const GOOGLE_ENHANCED_CONVERSIONS_AUDIENCE_JOURNEYS_FLAGON = 'google-enhanced-conversions-actions-journeys-support' \ No newline at end of file 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..392619b070d 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 @@ -303,7 +303,7 @@ const action: ActionDefinition = { } } }, - perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features }) => { + perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features, audienceMembership, personasContext }) => { settings.customerId = verifyCustomerId(settings.customerId) return await handleUpdate( @@ -315,12 +315,14 @@ const action: ActionDefinition = { hookOutputs?.retlOnMappingSave?.outputs.external_id_type, syncMode, features, - statsContext + statsContext, + audienceMembership, + personasContext ) }, performBatch: async ( request, - { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features } + { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features, audienceMembership, personasContext } ) => { settings.customerId = verifyCustomerId(settings.customerId) return await processBatchPayload( @@ -332,7 +334,9 @@ const action: ActionDefinition = { hookOutputs?.retlOnMappingSave?.outputs.external_id_type, syncMode, features, - statsContext + statsContext, + audienceMembership, + personasContext ) } } From 8fc2226ba0ab16b1060a18d363a1cc5e335a77a0 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 12 May 2026 08:47:14 +0100 Subject: [PATCH 016/210] tweak --- .../google-enhanced-conversions/functions.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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 13a46e019a7..219830be5d9 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -571,9 +571,9 @@ const extractUserIdentifiers = ( } } - if(features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]){ - const { computation_key, computation_class } = personasContext || {} - for (const payload of payloads) { + const { computation_key, computation_class } = personasContext || {} + for (const payload of payloads) { + if(features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]){ if(computation_class === 'journey_step' && !computation_key){ // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined audienceMembership === true @@ -589,11 +589,9 @@ const extractUserIdentifiers = ( ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) } - } - } - else { - // Map user data to Google Ads API format - for (const payload of payloads) { + } + else { + // Map user data to Google Ads API format if ( payload.event_name === 'Audience Entered' || syncMode === 'add' || From 4a1395a76bb1fa4876a7a192ed23556ebf466f04 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 12 May 2026 09:06:07 +0100 Subject: [PATCH 017/210] changes --- .../src/destinations/google-enhanced-conversions/functions.ts | 4 ++-- .../google-enhanced-conversions/userList/constants.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/userList/constants.ts 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 219830be5d9..4340168ee3e 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -576,7 +576,7 @@ const extractUserIdentifiers = ( if(features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]){ if(computation_class === 'journey_step' && !computation_key){ // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined - audienceMembership === true + addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) } else if ( payload.event_name === 'Audience Entered' || @@ -1001,7 +1001,7 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea const { computation_key, computation_class } = personasContext || {} if(computation_class === 'journey_step' && !computation_key){ // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined - audienceMembership === true + return true } if ( payload.event_name === 'Audience Entered' || diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/constants.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/constants.ts deleted file mode 100644 index 007bcb1d333..00000000000 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const GOOGLE_ENHANCED_CONVERSIONS_AUDIENCE_JOURNEYS_FLAGON = 'google-enhanced-conversions-actions-journeys-support' \ No newline at end of file From a17ff73413fa35d440fb8c4dec275759fa4d75c5 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 12 May 2026 09:09:25 +0100 Subject: [PATCH 018/210] changes --- .../src/destinations/google-enhanced-conversions/functions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4340168ee3e..5c4777f0d54 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -1019,7 +1019,7 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea if ( payload.event_name === 'Audience Entered' || syncMode === 'add' || - (syncMode === 'mirror' && payload.event_name === 'new') + (syncMode === 'mirror' && (payload.event_name === 'new' || payload.event_name === 'updated')) ) { return true } else if ( From 96439d2470cc94231d690e9e1e4547b8d57e9761 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 12 May 2026 09:12:59 +0100 Subject: [PATCH 019/210] code OK by Claude --- .../destinations/google-enhanced-conversions/functions.ts | 8 ++++---- .../google-enhanced-conversions/userList/index.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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 5c4777f0d54..f8aa7a80810 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -573,8 +573,8 @@ const extractUserIdentifiers = ( const { computation_key, computation_class } = personasContext || {} for (const payload of payloads) { - if(features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]){ - if(computation_class === 'journey_step' && !computation_key){ + if (features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]) { + if (computation_class === 'journey_step' && !computation_key) { // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) } @@ -997,9 +997,9 @@ const extractBatchUserIdentifiers = ( // Helper function to determine operation type const determineOperationType = (payload: UserListPayload, syncMode?: string, features?: Features, audienceMembership?: AudienceMembership, personasContext?: Personas): boolean | undefined => { - if(features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]){ + if (features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]) { const { computation_key, computation_class } = personasContext || {} - if(computation_class === 'journey_step' && !computation_key){ + if (computation_class === 'journey_step' && !computation_key) { // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined return true } 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 392619b070d..b8687f372e8 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 @@ -316,7 +316,7 @@ const action: ActionDefinition = { syncMode, features, statsContext, - audienceMembership, + audienceMembership, personasContext ) }, @@ -335,7 +335,7 @@ const action: ActionDefinition = { syncMode, features, statsContext, - audienceMembership, + audienceMembership, personasContext ) } From 774344d3f3742a490bd1e4904bceef8fb78393f9 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 12 May 2026 11:28:25 +0100 Subject: [PATCH 020/210] adding tests --- .../__tests__/userList.test.ts | 492 +++++++++++++++++- .../google-enhanced-conversions/functions.ts | 8 + .../userList/index.ts | 2 +- 3 files changed, 478 insertions(+), 24 deletions(-) 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..928ecb4b462 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 } }) @@ -1566,7 +1593,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -1629,7 +1657,8 @@ describe('GoogleEnhancedConversions', () => { }, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -1687,7 +1716,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -1787,7 +1817,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -1889,7 +1920,8 @@ describe('GoogleEnhancedConversions', () => { mapping, settings: { customerId - } + }, + ...(features && { features }) }) expect(responses[0]).toMatchObject({ @@ -1913,4 +1945,418 @@ describe('GoogleEnhancedConversions', () => { }) }) }) + + describe('userList - audienceMembership (flag on only)', () => { + const flagOnFeatures = { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } + + beforeEach(() => { + nock.cleanAll() + testDestination.responses = [] + }) + afterEach(() => nock.cleanAll()) + + it('adds user when audienceMembership is true (track event with computation_key in properties)', async () => { + const event = createTestEvent({ + timestamp, + type: 'track', + event: 'Test Event', + properties: { + email: 'test@gmail.com', + phone: '3234567890', + firstName: 'Jane', + lastName: 'Doe', + my_audience: true + }, + context: { + personas: { + computation_id: 'comp-1', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_1234' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + 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: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + }, + features: flagOnFeatures + }) + + expect(responses.length).toEqual(3) + expect(responses[1].options.body).toContain('"create"') + expect(responses[1].options.body).not.toContain('"remove"') + }) + + it('removes user when audienceMembership is false (track event with computation_key = false in properties)', async () => { + const event = createTestEvent({ + timestamp, + type: 'track', + event: 'Test Event', + properties: { + email: 'test@gmail.com', + phone: '3234567890', + firstName: 'Jane', + lastName: 'Doe', + my_audience: false + }, + context: { + personas: { + computation_id: 'comp-1', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_1234' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + 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: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + }, + features: flagOnFeatures + }) + + expect(responses.length).toEqual(3) + expect(responses[1].options.body).toContain('"remove"') + expect(responses[1].options.body).not.toContain('"create"') + }) + + it('adds user when audienceMembership is true (identify event with computation_key in traits)', async () => { + const event = createTestEvent({ + timestamp, + type: 'identify', + traits: { + email: 'test@gmail.com', + phone: '3234567890', + firstName: 'Jane', + lastName: 'Doe', + my_audience: true + }, + context: { + personas: { + computation_id: 'comp-1', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_1234' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + external_audience_id: '1234', + email: { '@path': '$.traits.email' }, + phone: { '@path': '$.traits.phone' }, + firstName: { '@path': '$.traits.firstName' }, + lastName: { '@path': '$.traits.lastName' }, + retlOnMappingSave: { + outputs: { + id: '1234', + name: 'Test List', + external_id_type: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + }, + features: flagOnFeatures + }) + + expect(responses.length).toEqual(3) + expect(responses[1].options.body).toContain('"create"') + expect(responses[1].options.body).not.toContain('"remove"') + }) + + it('adds user for legacy Journeys preset (journey_step with no computation_key)', async () => { + const event = createTestEvent({ + timestamp, + type: 'track', + event: 'Some Journey Event', + properties: { + email: 'test@gmail.com', + phone: '3234567890', + firstName: 'Jane', + lastName: 'Doe' + }, + context: { + personas: { + computation_id: 'comp-1', + computation_class: 'journey_step', + namespace: 'spa_1234' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + 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: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + }, + features: flagOnFeatures + }) + + expect(responses.length).toEqual(3) + expect(responses[1].options.body).toContain('"create"') + expect(responses[1].options.body).not.toContain('"remove"') + }) + + it('batch: adds users when audienceMembership is true', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + timestamp, + type: 'track', + event: 'Test Event', + properties: { + email: 'test@gmail.com', + phone: '3234567890', + firstName: 'Jane', + lastName: 'Doe', + my_audience: true + }, + context: { + personas: { + computation_id: 'comp-1', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_1234' + } + } + }), + createTestEvent({ + timestamp, + type: 'track', + event: 'Test Event', + properties: { + email: 'test2@gmail.com', + phone: '3234567891', + firstName: 'John', + lastName: 'Doe', + my_audience: true + }, + context: { + personas: { + computation_id: 'comp-1', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_1234' + } + } + }) + ] + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { resourceName: 'customers/1234/userLists/1234' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { done: true }) + + const responses = await testDestination.executeBatch('userList', { + events, + mapping, + settings: { + customerId + }, + features: flagOnFeatures + }) + + expect(responses[0]).toMatchObject({ status: 200 }) + expect(responses[1]).toMatchObject({ status: 200 }) + }) + + it('batch: removes users when audienceMembership is false', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + timestamp, + type: 'track', + event: 'Test Event', + properties: { + email: 'test@gmail.com', + phone: '3234567890', + firstName: 'Jane', + lastName: 'Doe', + my_audience: false + }, + context: { + personas: { + computation_id: 'comp-1', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_1234' + } + } + }) + ] + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { resourceName: 'customers/1234/userLists/1234' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { done: true }) + + const responses = await testDestination.executeBatch('userList', { + events, + mapping, + settings: { + customerId + }, + features: flagOnFeatures + }) + + expect(responses[0]).toMatchObject({ status: 200 }) + }) + + it('batch: adds user for legacy Journeys preset (journey_step with no computation_key)', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + timestamp, + type: 'track', + event: 'Some Journey Event', + properties: { + email: 'test@gmail.com', + phone: '3234567890', + firstName: 'Jane', + lastName: 'Doe' + }, + context: { + personas: { + computation_id: 'comp-1', + computation_class: 'journey_step', + namespace: 'spa_1234' + } + } + }) + ] + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { resourceName: 'customers/1234/userLists/1234' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { done: true }) + + const responses = await testDestination.executeBatch('userList', { + events, + mapping, + settings: { + customerId + }, + features: flagOnFeatures + }) + + expect(responses[0]).toMatchObject({ status: 200 }) + }) + }) }) 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 f8aa7a80810..8621439dbcb 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -580,11 +580,15 @@ const extractUserIdentifiers = ( } else 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) } }) @@ -1005,11 +1009,15 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea } 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 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 b8687f372e8..54b3d69cd5c 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' }, From 34f46edefce7122a072134e2a716fd25a7d9385c Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 12 May 2026 11:39:08 +0100 Subject: [PATCH 021/210] lint change --- .../google-enhanced-conversions/functions.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 8621439dbcb..443b62c91a3 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -575,8 +575,8 @@ const extractUserIdentifiers = ( for (const payload of payloads) { if (features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]) { if (computation_class === 'journey_step' && !computation_key) { - // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined - addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) + // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined + addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) } else if ( payload.event_name === 'Audience Entered' || @@ -589,12 +589,11 @@ const extractUserIdentifiers = ( payload.event_name === 'Audience Exited' || syncMode === 'delete' || (syncMode === 'mirror' && payload.event_name === 'deleted') || - audienceMembership === false + audienceMembership === false ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) } - } - else { + } else { // Map user data to Google Ads API format if ( payload.event_name === 'Audience Entered' || @@ -1004,7 +1003,7 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea if (features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]) { const { computation_key, computation_class } = personasContext || {} if (computation_class === 'journey_step' && !computation_key) { - // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined + // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined return true } if ( From e3cb32b4a9efb93f78f3561b330cc0864ea2e102 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 13 May 2026 14:48:38 +0100 Subject: [PATCH 022/210] tested all flows locally --- packages/cli/src/lib/server.ts | 10 ++++-- .../google-enhanced-conversions/functions.ts | 31 ++++++++++++------- .../userList/index.ts | 1 - 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/lib/server.ts b/packages/cli/src/lib/server.ts index b03bb8c2fce..c27baeafad4 100644 --- a/packages/cli/src/lib/server.ts +++ b/packages/cli/src/lib/server.ts @@ -21,7 +21,8 @@ import { ActionHookType, ActionHookResponse, AudienceDestinationConfigurationWithCreateGet, - RequestFn + RequestFn, + Personas } from '@segment/actions-core/destination-kit' interface ResponseError extends Error { status?: number @@ -298,7 +299,12 @@ function setupRoutes(def: DestinationDefinition | null): void { mapping: mapping || req.body.payload || {}, auth: req.body.auth || {}, features: req.body.features || {}, - subscriptionMetadata: req.body.subscriptionMetadata || {} + subscriptionMetadata: req.body.subscriptionMetadata || {}, + personasContext: (() => { + return Array.isArray(req.body.payload) + ? req.body.payload[0]?.context?.personas + : req.body.payload?.context?.personas + })() as Personas } if (Array.isArray(eventParams.data)) { 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 443b62c91a3..cdb4e00904a 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -571,14 +571,10 @@ const extractUserIdentifiers = ( } } - const { computation_key, computation_class } = personasContext || {} + const { computation_class } = personasContext || {} for (const payload of payloads) { if (features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]) { - if (computation_class === 'journey_step' && !computation_key) { - // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined - addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) - } - else if ( + if ( payload.event_name === 'Audience Entered' || syncMode === 'add' || (syncMode === 'mirror' && (payload.event_name === 'new' || payload.event_name === 'updated')) || @@ -593,6 +589,11 @@ const extractUserIdentifiers = ( ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) } + else if (computation_class === 'journey_step') { + // For legacy Journeys preset journeys_step_entered_track which omits properties[] + // Should always adds the user, never delete + addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) + } } else { // Map user data to Google Ads API format if ( @@ -607,6 +608,10 @@ const extractUserIdentifiers = ( (syncMode === 'mirror' && payload.event_name === 'deleted') ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) + } else if (computation_class === 'journey_step') { + // For legacy Journeys preset journeys_step_entered_track which omits properties[] + // Should always adds the user, never delete + addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) } } } @@ -1000,12 +1005,8 @@ const extractBatchUserIdentifiers = ( // Helper function to determine operation type const determineOperationType = (payload: UserListPayload, syncMode?: string, features?: Features, audienceMembership?: AudienceMembership, personasContext?: Personas): boolean | undefined => { + const { computation_class } = personasContext || {} if (features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]) { - const { computation_key, computation_class } = personasContext || {} - if (computation_class === 'journey_step' && !computation_key) { - // Covers legacy Journeys preset journeys_step_entered_track where computation_key is undefined - return true - } if ( payload.event_name === 'Audience Entered' || syncMode === 'add' || @@ -1020,6 +1021,10 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea audienceMembership === false ) { return false + } else if (computation_class === 'journey_step') { + // For legacy Journeys preset journeys_step_entered_track which omits properties[] + // Should always adds the user, never delete + return true } } else { @@ -1035,6 +1040,10 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea (syncMode === 'mirror' && payload.event_name === 'deleted') ) { return false + } else if (computation_class === 'journey_step') { + // For legacy Journeys preset journeys_step_entered_track which omits properties[] + // Should always adds the user, never delete + return true } } return undefined 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 54b3d69cd5c..ef37f18293e 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 @@ -305,7 +305,6 @@ const action: ActionDefinition = { }, perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features, audienceMembership, personasContext }) => { settings.customerId = verifyCustomerId(settings.customerId) - return await handleUpdate( request, settings, From 71fb32ccac40853d13e27cc84e88a1e9e82d803a Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 13 May 2026 16:08:43 +0100 Subject: [PATCH 023/210] adding unit tests --- .../userList-journeys-audiences.test.ts | 743 ++++++++++++++++++ .../__tests__/userList.test.ts | 414 ---------- 2 files changed, 743 insertions(+), 414 deletions(-) create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeys-audiences.test.ts diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeys-audiences.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeys-audiences.test.ts new file mode 100644 index 00000000000..fe3937f1421 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeys-audiences.test.ts @@ -0,0 +1,743 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, FLAGS } from '@segment/actions-core' +import GoogleEnhancedConversions from '../index' +import { API_VERSION } from '../functions' +import { SegmentEvent } from '@segment/actions-core' + +const testDestination = createTestIntegration(GoogleEnhancedConversions) +const timestamp = new Date('Thu Jun 10 2021 11:08:04 GMT-0700 (Pacific Daylight Time)').toISOString() +const customerId = '1234' + +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' + } + } +} + +const expectedCreateOperation = { + create: { + userIdentifiers: { thirdPartyUserId: 'user_123' } + } +} + +const expectedRemoveOperation = { + remove: { + userIdentifiers: { thirdPartyUserId: 'user_123' } + } +} + +const expectedJobPayload = JSON.stringify({ + job: { + type: 'CUSTOMER_MATCH_USER_LIST', + customerMatchUserListMetadata: { + userList: 'customers/1234/userLists/1234', + consent: { adUserData: 'GRANTED', adPersonalization: 'GRANTED' } + } + } +}) + +const flagCases = [ + { name: 'flag OFF', features: undefined }, + { name: 'flag ON', features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } } +] + +function setupNocksForPerform() { + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) +} + +function setupNocksForBatch(interceptedBodies?: any[]) { + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/, (body: any) => { + interceptedBodies?.push({ type: 'create', body }) + return true + }) + .reply(200, { resourceName: 'customers/1234/userLists/1234' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/, (body: any) => { + interceptedBodies?.push({ type: 'addOperations', body }) + return true + }) + .reply(200, {}) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { done: true }) +} + +function createJourneyEvent( + eventName: string, + computationKey: string +): SegmentEvent { + return createTestEvent({ + timestamp, + type: 'track', + userId: 'user_123', + event: eventName, + properties: { + journey_context: { + 'New Google Enhanced Conversions User': {} + }, + journey_metadata: { + epoch_id: 'epo_64ac13af-4914-4a79-9580-ac928c7fd9b8', + journey_id: 'jver_3DfJQ5k3kviG5O2UbMTG82QnMqX', + journey_name: 'journey3' + } + }, + context: { + personas: { + computation_id: 'journey_123', + computation_key: computationKey, + computation_class: 'journey_step', + namespace: 'spa_abc' + } + } + }) +} + +function createAudienceEvent( + eventName: string, + computationKey: string, + computationKeyValue?: boolean +): SegmentEvent { + const properties: Record = {} + if (typeof computationKeyValue === 'boolean') { + properties[computationKey] = computationKeyValue + } + + return createTestEvent({ + timestamp, + type: 'track', + userId: 'user_123', + event: eventName, + properties, + context: { + personas: { + computation_id: 'aud_123', + computation_key: computationKey, + computation_class: 'audience', + namespace: 'spa_abc' + } + } + }) +} + +describe('GoogleEnhancedConversions userList - Journeys & Audiences', () => { + beforeEach(() => { + nock.cleanAll() + testDestination.responses = [] + }) + afterEach(() => nock.cleanAll()) + + describe.each(flagCases)('Journey tests ($name)', ({ features }) => { + const journeyCases = [ + { syncMode: 'add', expectedResult: 'add' }, + { syncMode: 'mirror', expectedResult: 'add' } + ] + + describe('single event (perform)', () => { + it.each(journeyCases)( + 'syncMode=$syncMode => $expectedResult (properties never contains computation_key)', + async ({ syncMode, expectedResult }) => { + setupNocksForPerform() + + const event = createJourneyEvent('journey3 - Destination', 'journey3 - Destination') + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ...mapping, + __segment_internal_sync_mode: syncMode + }, + useDefaultMappings: true, + settings: { customerId }, + ...(features && { features }) + }) + + // 3 responses = job create + addOperations + job run. + // User is always added for journey_step because: + // - Journey properties never contain properties[computation_key], so audienceMembership is undefined + // - syncMode='add' directly adds, syncMode='mirror' with a non-standard event name + // falls through to the computation_class='journey_step' fallback which always adds + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toEqual(expectedJobPayload) + + const expectedOp = expectedResult === 'add' ? expectedCreateOperation : expectedRemoveOperation + expect(responses[1].options.body).toEqual( + JSON.stringify({ operations: [expectedOp], enable_warnings: true }) + ) + } + ) + }) + + describe('batch (performBatch)', () => { + it.each(journeyCases)( + 'syncMode=$syncMode => $expectedResult (properties never contains computation_key)', + async ({ syncMode, expectedResult }) => { + const interceptedBodies: any[] = [] + setupNocksForBatch(interceptedBodies) + + const event = createJourneyEvent('journey3 - Destination', 'journey3 - Destination') + + const responses = await testDestination.executeBatch('userList', { + events: [event], + mapping: { + ...mapping, + __segment_internal_sync_mode: syncMode + }, + settings: { customerId }, + ...(features && { features }) + }) + + // User is always added for journey_step (same reasoning as perform path above). + // The batch response reports success with the run URL. + expect(responses[0]).toMatchObject({ + status: 200, + sent: '/customers/1234/userLists/1234:run', + body: { done: true } + }) + + // Verify the addOperations call was a 'create' (user added) + const addOpCall = interceptedBodies.find((b) => b.type === 'addOperations') + expect(addOpCall).toBeDefined() + + const expectedOp = expectedResult === 'add' ? expectedCreateOperation : expectedRemoveOperation + expect(addOpCall.body.operations[0]).toEqual(expectedOp) + } + ) + }) + }) + + describe.each(flagCases)('Audience tests - standard events ($name)', ({ features }) => { + const audienceStandardCases = [ + { syncMode: 'add', eventName: 'Audience Entered', computationKeyValue: true, expectedResult: 'add' }, + { syncMode: 'mirror', eventName: 'Audience Entered', computationKeyValue: true, expectedResult: 'add' }, + { syncMode: 'mirror', eventName: 'Audience Exited', computationKeyValue: false, expectedResult: 'remove' }, + { syncMode: 'delete', eventName: 'Audience Exited', computationKeyValue: false, expectedResult: 'remove' } + ] + + describe('single event (perform)', () => { + it.each(audienceStandardCases)( + 'syncMode=$syncMode, event=$eventName => $expectedResult', + async ({ syncMode, eventName, computationKeyValue, expectedResult }) => { + setupNocksForPerform() + + const event = createAudienceEvent(eventName, 'my_audience', computationKeyValue) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ...mapping, + __segment_internal_sync_mode: syncMode + }, + useDefaultMappings: true, + settings: { customerId }, + ...(features && { features }) + }) + + // 3 responses = job create + addOperations + job run. + // 'Audience Entered' and 'Audience Exited' are standard event names that + // directly determine add/remove regardless of syncMode or flag state. + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toEqual(expectedJobPayload) + + const expectedOp = expectedResult === 'add' ? expectedCreateOperation : expectedRemoveOperation + expect(responses[1].options.body).toEqual( + JSON.stringify({ operations: [expectedOp], enable_warnings: true }) + ) + } + ) + }) + + describe('batch (performBatch)', () => { + it.each(audienceStandardCases)( + 'syncMode=$syncMode, event=$eventName => $expectedResult', + async ({ syncMode, eventName, computationKeyValue, expectedResult }) => { + const interceptedBodies: any[] = [] + setupNocksForBatch(interceptedBodies) + + const event = createAudienceEvent(eventName, 'my_audience', computationKeyValue) + + const responses = await testDestination.executeBatch('userList', { + events: [event], + mapping: { + ...mapping, + __segment_internal_sync_mode: syncMode + }, + settings: { customerId }, + ...(features && { features }) + }) + + // 'Audience Entered' always adds, 'Audience Exited' always removes, + // regardless of syncMode or flag state. + expect(responses[0]).toMatchObject({ + status: 200, + sent: '/customers/1234/userLists/1234:run', + body: { done: true } + }) + + const addOpCall = interceptedBodies.find((b) => b.type === 'addOperations') + expect(addOpCall).toBeDefined() + + const expectedOp = expectedResult === 'add' ? expectedCreateOperation : expectedRemoveOperation + expect(addOpCall.body.operations[0]).toEqual(expectedOp) + } + ) + }) + }) + + describe('Audience tests - custom event names (flag OFF)', () => { + const customEventCases = [ + { syncMode: 'mirror' as const, computationKeyValue: undefined as boolean | undefined }, + { syncMode: 'mirror' as const, computationKeyValue: true }, + { syncMode: 'mirror' as const, computationKeyValue: false } + ] + + describe('single event (perform)', () => { + it.each(customEventCases)( + 'syncMode=$syncMode, computationKeyValue=$computationKeyValue => no operations (job created and run only)', + async ({ syncMode, computationKeyValue }) => { + setupNocksForPerform() + + const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', computationKeyValue) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ...mapping, + __segment_internal_sync_mode: syncMode + }, + useDefaultMappings: true, + settings: { customerId } + }) + + // 2 responses = job create + job run only. No addOperations call was made, + // meaning the user was neither added nor removed. + // With the flag OFF, a custom event name (not 'Audience Entered'/'Audience Exited') + // combined with mirror syncMode (event is not 'new'/'updated'/'deleted') cannot + // determine operation type. The perform path silently skips the event. + expect(responses.length).toEqual(2) + } + ) + }) + + describe('batch (performBatch)', () => { + it.each(customEventCases)( + 'syncMode=$syncMode, computationKeyValue=$computationKeyValue => ERROR', + async ({ syncMode, computationKeyValue }) => { + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { resourceName: 'customers/1234/userLists/1234' }) + + const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', computationKeyValue) + + const responses = await testDestination.executeBatch('userList', { + events: [event], + mapping: { + ...mapping, + __segment_internal_sync_mode: syncMode + }, + settings: { customerId } + }) + + // With the flag OFF, audienceMembership is not consulted, so even when + // properties[computation_key] is true/false it doesn't help. The batch path + // explicitly returns an error when operation type cannot be determined. + expect(responses[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Could not determine Operation Type.', + errorreporter: 'INTEGRATIONS' + }) + } + ) + }) + }) + + describe('Audience tests - custom event names (flag ON)', () => { + const features = { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } + + describe('single event (perform)', () => { + it('syncMode=mirror, computationKeyValue=undefined => no operations (job created and run only)', async () => { + setupNocksForPerform() + + const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', undefined) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ...mapping, + __segment_internal_sync_mode: 'mirror' + }, + useDefaultMappings: true, + settings: { customerId }, + features + }) + + // 2 responses = job create + job run only. No addOperations call was made. + // With the flag ON, audienceMembership is consulted but properties[computation_key] + // is undefined (not a boolean), so audienceMembership resolves to undefined. + // computation_class='audience' != 'journey_step', so no fallback applies. + // Operation type cannot be determined; the perform path silently skips. + expect(responses.length).toEqual(2) + }) + + it('syncMode=mirror, computationKeyValue=true => User added', async () => { + setupNocksForPerform() + + const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', true) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ...mapping, + __segment_internal_sync_mode: 'mirror' + }, + useDefaultMappings: true, + settings: { customerId }, + features + }) + + // 3 responses = job create + addOperations + job run. + // With the flag ON, properties[computation_key]=true resolves + // audienceMembership=true, which triggers a 'create' operation (user added). + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toEqual(expectedJobPayload) + expect(responses[1].options.body).toEqual( + JSON.stringify({ operations: [expectedCreateOperation], enable_warnings: true }) + ) + }) + + it('syncMode=mirror, computationKeyValue=false => User removed', async () => { + setupNocksForPerform() + + const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', false) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ...mapping, + __segment_internal_sync_mode: 'mirror' + }, + useDefaultMappings: true, + settings: { customerId }, + features + }) + + // 3 responses = job create + addOperations + job run. + // With the flag ON, properties[computation_key]=false resolves + // audienceMembership=false, which triggers a 'remove' operation (user removed). + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toEqual(expectedJobPayload) + expect(responses[1].options.body).toEqual( + JSON.stringify({ operations: [expectedRemoveOperation], enable_warnings: true }) + ) + }) + }) + + describe('batch (performBatch)', () => { + it('syncMode=mirror, computationKeyValue=undefined => ERROR', async () => { + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { resourceName: 'customers/1234/userLists/1234' }) + + const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', undefined) + + const responses = await testDestination.executeBatch('userList', { + events: [event], + mapping: { + ...mapping, + __segment_internal_sync_mode: 'mirror' + }, + settings: { customerId }, + features + }) + + // With the flag ON but properties[computation_key] undefined, + // audienceMembership is undefined. Custom event name doesn't match any + // standard patterns and computation_class='audience' != 'journey_step'. + // The batch path explicitly returns an error. + expect(responses[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Could not determine Operation Type.', + errorreporter: 'INTEGRATIONS' + }) + }) + + it('syncMode=mirror, computationKeyValue=true => User added', async () => { + const interceptedBodies: any[] = [] + setupNocksForBatch(interceptedBodies) + + const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', true) + + const responses = await testDestination.executeBatch('userList', { + events: [event], + mapping: { + ...mapping, + __segment_internal_sync_mode: 'mirror' + }, + settings: { customerId }, + features + }) + + // With the flag ON, properties[computation_key]=true resolves + // audienceMembership=true, which triggers a 'create' operation (user added). + expect(responses[0]).toMatchObject({ + status: 200, + sent: '/customers/1234/userLists/1234:run', + body: { done: true } + }) + + const addOpCall = interceptedBodies.find((b) => b.type === 'addOperations') + expect(addOpCall).toBeDefined() + expect(addOpCall.body.operations[0]).toEqual(expectedCreateOperation) + }) + + it('syncMode=mirror, computationKeyValue=false => User removed', async () => { + const interceptedBodies: any[] = [] + setupNocksForBatch(interceptedBodies) + + const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', false) + + const responses = await testDestination.executeBatch('userList', { + events: [event], + mapping: { + ...mapping, + __segment_internal_sync_mode: 'mirror' + }, + settings: { customerId }, + features + }) + + // With the flag ON, properties[computation_key]=false resolves + // audienceMembership=false, which triggers a 'remove' operation (user removed). + expect(responses[0]).toMatchObject({ + status: 200, + sent: '/customers/1234/userLists/1234:run', + body: { done: true } + }) + + const addOpCall = interceptedBodies.find((b) => b.type === 'addOperations') + expect(addOpCall).toBeDefined() + expect(addOpCall.body.operations[0]).toEqual(expectedRemoveOperation) + }) + }) + }) + + describe('Mixed batch tests - adds and removes in one batch', () => { + it('syncMode=mirror with Audience Entered and Audience Exited events in the same batch', async () => { + // Two addOperations calls expected: one for adds, one for removes. + const interceptedBodies: any[] = [] + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/, (body: any) => { + interceptedBodies.push({ type: 'create', body }) + return true + }) + .reply(200, { resourceName: 'customers/1234/userLists/1234' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/, (body: any) => { + interceptedBodies.push({ type: 'addOperations', body }) + return true + }) + .times(2) + .reply(200, {}) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { done: true }) + + const enterEvent = createTestEvent({ + timestamp, + type: 'track', + userId: 'user_add_1', + event: 'Audience Entered', + properties: { my_audience: true }, + context: { + personas: { + computation_id: 'aud_123', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_abc' + } + } + }) + + const exitEvent = createTestEvent({ + timestamp, + type: 'track', + userId: 'user_remove_1', + event: 'Audience Exited', + properties: { my_audience: false }, + context: { + personas: { + computation_id: 'aud_123', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_abc' + } + } + }) + + const responses = await testDestination.executeBatch('userList', { + events: [enterEvent, exitEvent], + mapping: { + ...mapping, + __segment_internal_sync_mode: 'mirror' + }, + settings: { customerId }, + features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } + }) + + // Both events succeed — the batch creates one job with two addOperations calls: + // one containing the 'create' (add) operation and one containing the 'remove' operation. + expect(responses[0]).toMatchObject({ status: 200 }) + expect(responses[1]).toMatchObject({ status: 200 }) + + const addOpCalls = interceptedBodies.filter((b) => b.type === 'addOperations') + expect(addOpCalls).toHaveLength(2) + + // First addOperations call contains the 'create' (add) operation + expect(addOpCalls[0].body.operations).toEqual([ + { create: { userIdentifiers: { thirdPartyUserId: 'user_add_1' } } } + ]) + + // Second addOperations call contains the 'remove' operation + expect(addOpCalls[1].body.operations).toEqual([ + { remove: { userIdentifiers: { thirdPartyUserId: 'user_remove_1' } } } + ]) + }) + + it('syncMode=mirror with a mix of valid adds, valid removes, and invalid payloads', async () => { + // Tests that invalid payloads get individual errors while valid adds/removes + // are still processed in the same job. + const interceptedBodies: any[] = [] + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/, (body: any) => { + interceptedBodies.push({ type: 'create', body }) + return true + }) + .reply(200, { resourceName: 'customers/1234/userLists/1234' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/, (body: any) => { + interceptedBodies.push({ type: 'addOperations', body }) + return true + }) + .times(2) + .reply(200, {}) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { done: true }) + + const enterEvent = createTestEvent({ + timestamp, + type: 'track', + userId: 'user_add_1', + event: 'Audience Entered', + properties: { my_audience: true }, + context: { + personas: { + computation_id: 'aud_123', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_abc' + } + } + }) + + const exitEvent = createTestEvent({ + timestamp, + type: 'track', + userId: 'user_remove_1', + event: 'Audience Exited', + properties: { my_audience: false }, + context: { + personas: { + computation_id: 'aud_123', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_abc' + } + } + }) + + // Invalid payload: custom event with undefined computation_key value. + // Operation type cannot be determined → individual 400 error. + const invalidEvent = createTestEvent({ + timestamp, + type: 'track', + userId: 'user_bad_1', + event: 'CUSTOM_EVENT_NAME', + properties: {}, + context: { + personas: { + computation_id: 'aud_123', + computation_key: 'my_audience', + computation_class: 'audience', + namespace: 'spa_abc' + } + } + }) + + const responses = await testDestination.executeBatch('userList', { + events: [enterEvent, invalidEvent, exitEvent], + mapping: { + ...mapping, + __segment_internal_sync_mode: 'mirror' + }, + settings: { customerId }, + features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } + }) + + // Index 0 (enterEvent): valid add → succeeds + expect(responses[0]).toMatchObject({ status: 200 }) + + // Index 1 (invalidEvent): custom event, no properties[computation_key], flag ON → + // audienceMembership is undefined, event name doesn't match standard patterns, + // computation_class='audience' != 'journey_step' → cannot determine operation type → 400 + expect(responses[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Could not determine Operation Type.' + }) + + // Index 2 (exitEvent): valid remove → succeeds + expect(responses[2]).toMatchObject({ status: 200 }) + + // The two valid payloads still produced two addOperations calls + const addOpCalls = interceptedBodies.filter((b) => b.type === 'addOperations') + expect(addOpCalls).toHaveLength(2) + + expect(addOpCalls[0].body.operations).toEqual([ + { create: { userIdentifiers: { thirdPartyUserId: 'user_add_1' } } } + ]) + expect(addOpCalls[1].body.operations).toEqual([ + { remove: { userIdentifiers: { thirdPartyUserId: 'user_remove_1' } } } + ]) + }) + }) +}) 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 928ecb4b462..42a8ae972f7 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 @@ -1945,418 +1945,4 @@ describe('GoogleEnhancedConversions', () => { }) }) }) - - describe('userList - audienceMembership (flag on only)', () => { - const flagOnFeatures = { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } - - beforeEach(() => { - nock.cleanAll() - testDestination.responses = [] - }) - afterEach(() => nock.cleanAll()) - - it('adds user when audienceMembership is true (track event with computation_key in properties)', async () => { - const event = createTestEvent({ - timestamp, - type: 'track', - event: 'Test Event', - properties: { - email: 'test@gmail.com', - phone: '3234567890', - firstName: 'Jane', - lastName: 'Doe', - my_audience: true - }, - context: { - personas: { - computation_id: 'comp-1', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_1234' - } - } - }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - 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: 'CONTACT_INFO' - } - } - }, - useDefaultMappings: true, - settings: { - customerId - }, - features: flagOnFeatures - }) - - expect(responses.length).toEqual(3) - expect(responses[1].options.body).toContain('"create"') - expect(responses[1].options.body).not.toContain('"remove"') - }) - - it('removes user when audienceMembership is false (track event with computation_key = false in properties)', async () => { - const event = createTestEvent({ - timestamp, - type: 'track', - event: 'Test Event', - properties: { - email: 'test@gmail.com', - phone: '3234567890', - firstName: 'Jane', - lastName: 'Doe', - my_audience: false - }, - context: { - personas: { - computation_id: 'comp-1', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_1234' - } - } - }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - 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: 'CONTACT_INFO' - } - } - }, - useDefaultMappings: true, - settings: { - customerId - }, - features: flagOnFeatures - }) - - expect(responses.length).toEqual(3) - expect(responses[1].options.body).toContain('"remove"') - expect(responses[1].options.body).not.toContain('"create"') - }) - - it('adds user when audienceMembership is true (identify event with computation_key in traits)', async () => { - const event = createTestEvent({ - timestamp, - type: 'identify', - traits: { - email: 'test@gmail.com', - phone: '3234567890', - firstName: 'Jane', - lastName: 'Doe', - my_audience: true - }, - context: { - personas: { - computation_id: 'comp-1', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_1234' - } - } - }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - ad_user_data_consent_state: 'GRANTED', - ad_personalization_consent_state: 'GRANTED', - external_audience_id: '1234', - email: { '@path': '$.traits.email' }, - phone: { '@path': '$.traits.phone' }, - firstName: { '@path': '$.traits.firstName' }, - lastName: { '@path': '$.traits.lastName' }, - retlOnMappingSave: { - outputs: { - id: '1234', - name: 'Test List', - external_id_type: 'CONTACT_INFO' - } - } - }, - useDefaultMappings: true, - settings: { - customerId - }, - features: flagOnFeatures - }) - - expect(responses.length).toEqual(3) - expect(responses[1].options.body).toContain('"create"') - expect(responses[1].options.body).not.toContain('"remove"') - }) - - it('adds user for legacy Journeys preset (journey_step with no computation_key)', async () => { - const event = createTestEvent({ - timestamp, - type: 'track', - event: 'Some Journey Event', - properties: { - email: 'test@gmail.com', - phone: '3234567890', - firstName: 'Jane', - lastName: 'Doe' - }, - context: { - personas: { - computation_id: 'comp-1', - computation_class: 'journey_step', - namespace: 'spa_1234' - } - } - }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - 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: 'CONTACT_INFO' - } - } - }, - useDefaultMappings: true, - settings: { - customerId - }, - features: flagOnFeatures - }) - - expect(responses.length).toEqual(3) - expect(responses[1].options.body).toContain('"create"') - expect(responses[1].options.body).not.toContain('"remove"') - }) - - it('batch: adds users when audienceMembership is true', async () => { - const events: SegmentEvent[] = [ - createTestEvent({ - timestamp, - type: 'track', - event: 'Test Event', - properties: { - email: 'test@gmail.com', - phone: '3234567890', - firstName: 'Jane', - lastName: 'Doe', - my_audience: true - }, - context: { - personas: { - computation_id: 'comp-1', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_1234' - } - } - }), - createTestEvent({ - timestamp, - type: 'track', - event: 'Test Event', - properties: { - email: 'test2@gmail.com', - phone: '3234567891', - firstName: 'John', - lastName: 'Doe', - my_audience: true - }, - context: { - personas: { - computation_id: 'comp-1', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_1234' - } - } - }) - ] - - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { resourceName: 'customers/1234/userLists/1234' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { done: true }) - - const responses = await testDestination.executeBatch('userList', { - events, - mapping, - settings: { - customerId - }, - features: flagOnFeatures - }) - - expect(responses[0]).toMatchObject({ status: 200 }) - expect(responses[1]).toMatchObject({ status: 200 }) - }) - - it('batch: removes users when audienceMembership is false', async () => { - const events: SegmentEvent[] = [ - createTestEvent({ - timestamp, - type: 'track', - event: 'Test Event', - properties: { - email: 'test@gmail.com', - phone: '3234567890', - firstName: 'Jane', - lastName: 'Doe', - my_audience: false - }, - context: { - personas: { - computation_id: 'comp-1', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_1234' - } - } - }) - ] - - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { resourceName: 'customers/1234/userLists/1234' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { done: true }) - - const responses = await testDestination.executeBatch('userList', { - events, - mapping, - settings: { - customerId - }, - features: flagOnFeatures - }) - - expect(responses[0]).toMatchObject({ status: 200 }) - }) - - it('batch: adds user for legacy Journeys preset (journey_step with no computation_key)', async () => { - const events: SegmentEvent[] = [ - createTestEvent({ - timestamp, - type: 'track', - event: 'Some Journey Event', - properties: { - email: 'test@gmail.com', - phone: '3234567890', - firstName: 'Jane', - lastName: 'Doe' - }, - context: { - personas: { - computation_id: 'comp-1', - computation_class: 'journey_step', - namespace: 'spa_1234' - } - } - }) - ] - - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { resourceName: 'customers/1234/userLists/1234' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { done: true }) - - const responses = await testDestination.executeBatch('userList', { - events, - mapping, - settings: { - customerId - }, - features: flagOnFeatures - }) - - expect(responses[0]).toMatchObject({ status: 200 }) - }) - }) }) From 93461a4164854d6d57a2cbf3e1ef2419da022ccc Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 14 May 2026 10:21:08 +0100 Subject: [PATCH 024/210] addressing copilot comments --- packages/cli/src/lib/server.ts | 2 +- packages/core/src/destination-kit/index.ts | 2 +- .../src/destinations/google-enhanced-conversions/functions.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/server.ts b/packages/cli/src/lib/server.ts index c27baeafad4..871b3cc4695 100644 --- a/packages/cli/src/lib/server.ts +++ b/packages/cli/src/lib/server.ts @@ -304,7 +304,7 @@ function setupRoutes(def: DestinationDefinition | null): void { return Array.isArray(req.body.payload) ? req.body.payload[0]?.context?.personas : req.body.payload?.context?.personas - })() as Personas + })() as Personas | undefined } if (Array.isArray(eventParams.data)) { diff --git a/packages/core/src/destination-kit/index.ts b/packages/core/src/destination-kit/index.ts index 1e6124e155c..0b866bd7cf5 100644 --- a/packages/core/src/destination-kit/index.ts +++ b/packages/core/src/destination-kit/index.ts @@ -101,7 +101,7 @@ export type AudienceMode = { type: 'realtime' } | { type: 'synced'; full_audienc export type Personas = { computation_id: string computation_key: string - computation_class: string + computation_class?: string namespace: string [key: string]: unknown } 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 cdb4e00904a..5cc43a7dfca 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -949,7 +949,7 @@ const extractBatchUserIdentifiers = ( idType: string, multiStatusResponse: MultiStatusResponse, syncMode?: string, - features?: Features, + features?: Features, audienceMemberships?: AudienceMembership[], personasContext?: Personas ) => { From 21418852dc7f03b41748b9f026c57691a262ec33 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 14 May 2026 16:14:04 +0100 Subject: [PATCH 025/210] Facebook AppendValue support for custom and purchase events --- .../custom/generated-types.ts | 29 +++++ .../custom2/generated-types.ts | 29 +++++ .../purchase/generated-types.ts | 37 ++++++ .../purchase2/generated-types.ts | 37 ++++++ .../shared/constants.ts | 2 + .../facebook-conversions-api/shared/fields.ts | 111 +++++++++++++++++- .../shared/functions.ts | 79 +++++++++++-- .../facebook-conversions-api/shared/types.ts | 23 ++++ 8 files changed, 337 insertions(+), 10 deletions(-) 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..57a157a6c93 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 { + /** + * Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + */ + is_append_event?: boolean + /** + * 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. */ 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..57a157a6c93 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 { + /** + * Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + */ + is_append_event?: boolean + /** + * 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. */ 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..a717862bd07 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 { + /** + * Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + */ + is_append_event?: boolean + /** + * 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. */ @@ -200,6 +229,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..a717862bd07 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 { + /** + * Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + */ + is_append_event?: boolean + /** + * 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. */ @@ -200,6 +229,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/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..1949b834d50 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: 'Append Data to Existing Conversion', + description: + 'Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values.', + type: 'boolean', + default: false, + required: false +} + +export const append_event_details: InputField = { + label: 'Append Event Details', + description: + '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: @@ -367,7 +450,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 +618,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 +640,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 +652,8 @@ export const purchaseFields: Record = { required: true, default: { '@path': '$.properties.revenue' } }, + order_id, + predicted_ltv, net_revenue, content_ids, content_name, @@ -723,6 +830,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..0056f622657 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,20 @@ 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(features?.[FEATURE_FLAG_APPEND_VALUE] && is_append_event && append_event_details) { + return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) + } + return data } @@ -229,10 +237,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 +250,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,7 +260,12 @@ export function getPurchaseEventData(payload: PurchasePayload | Purchase2Payload ...(typeof num_items === 'number' && { num_items }) } } - return data + + if(features?.[FEATURE_FLAG_APPEND_VALUE] && is_append_event && append_event_details) { + return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) + } + + return data } export function getSearchEventData(payload: SearchPayload | Search2Payload): SearchEventData { @@ -294,6 +309,52 @@ 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, net_revenue, 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(!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') + } + + 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, 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..20735c2fd7f 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 @@ -164,6 +164,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 From 8ef9e3fe0762b6266e35145c03202896092ad558 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 14 May 2026 16:42:54 +0100 Subject: [PATCH 026/210] adding additional validation --- .../facebook-conversions-api/shared/functions.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 0056f622657..ea3f9f7aec8 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 @@ -335,6 +335,16 @@ export const convertToAppendValueEventData = ( 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', From c33e50517079ec17ef001e87a554988d3d532b8b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 14 May 2026 16:52:33 +0100 Subject: [PATCH 027/210] adding beta labels for new fields --- .../custom/generated-types.ts | 4 ++-- .../custom2/generated-types.ts | 4 ++-- .../purchase/generated-types.ts | 4 ++-- .../purchase2/generated-types.ts | 4 ++-- .../facebook-conversions-api/shared/fields.ts | 8 ++++---- .../shared/functions.ts | 20 ++++++++++++++----- .../facebook-conversions-api/shared/types.ts | 1 + 7 files changed, 28 insertions(+), 17 deletions(-) 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 57a157a6c93..be433e89029 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 @@ -2,11 +2,11 @@ export interface Payload { /** - * Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + * 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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. */ is_append_event?: boolean /** - * 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. + * 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?: { /** 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 57a157a6c93..be433e89029 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 @@ -2,11 +2,11 @@ export interface Payload { /** - * Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + * 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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. */ is_append_event?: boolean /** - * 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. + * 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?: { /** 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 a717862bd07..3b5ddf10300 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 @@ -2,11 +2,11 @@ export interface Payload { /** - * Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + * 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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. */ is_append_event?: boolean /** - * 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. + * 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?: { /** 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 a717862bd07..3b5ddf10300 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 @@ -2,11 +2,11 @@ export interface Payload { /** - * Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + * 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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. */ is_append_event?: boolean /** - * 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. + * 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?: { /** 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 1949b834d50..c1472bb93be 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,18 +1,18 @@ import { InputField } from '@segment/actions-core' export const is_append_event: InputField = { - label: 'Append Data to Existing Conversion', + label: '[Beta] Append Data to Existing Conversion', description: - 'Turn this on to add new information to a previously sent event. Currently supports late-calculated values for predicted lifetime value (pLTV) or net profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values.', + '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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values.', type: 'boolean', default: false, required: false } export const append_event_details: InputField = { - label: 'Append Event Details', + label: '[Beta] Append Event Details', description: - '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.', + '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: { 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 ea3f9f7aec8..ac152cc6de1 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 @@ -199,8 +199,13 @@ export function getCustomEventData(payload: CustomPayload | Custom2Payload, feat custom_data: { ...custom_data } } - if(features?.[FEATURE_FLAG_APPEND_VALUE] && is_append_event && append_event_details) { - return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) + 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) { + return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) + } } return data @@ -261,11 +266,16 @@ export function getPurchaseEventData(payload: PurchasePayload | Purchase2Payload } } - if(features?.[FEATURE_FLAG_APPEND_VALUE] && is_append_event && append_event_details) { - return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) + 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) { + return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) + } } - return data + return data } export function getSearchEventData(payload: SearchPayload | Search2Payload): SearchEventData { 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 20735c2fd7f..829ba0ddac1 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 From bca1b879512c8eb5beb65a64b5197741fc9a46a0 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 15 May 2026 10:24:52 +0100 Subject: [PATCH 028/210] Adding ctwa_clid field to user_data --- .../facebook-conversions-api/addToCart/generated-types.ts | 4 ++++ .../facebook-conversions-api/addToCart2/generated-types.ts | 4 ++++ .../facebook-conversions-api/custom/generated-types.ts | 4 ++++ .../facebook-conversions-api/custom2/generated-types.ts | 4 ++++ .../initiateCheckout/generated-types.ts | 4 ++++ .../initiateCheckout2/generated-types.ts | 4 ++++ .../facebook-conversions-api/pageView/generated-types.ts | 4 ++++ .../facebook-conversions-api/pageView2/generated-types.ts | 4 ++++ .../facebook-conversions-api/purchase/generated-types.ts | 4 ++++ .../facebook-conversions-api/purchase2/generated-types.ts | 4 ++++ .../facebook-conversions-api/search/generated-types.ts | 4 ++++ .../facebook-conversions-api/search2/generated-types.ts | 4 ++++ .../destinations/facebook-conversions-api/shared/fields.ts | 5 +++++ .../facebook-conversions-api/shared/functions.ts | 6 ++++-- .../facebook-conversions-api/viewContent/generated-types.ts | 4 ++++ .../viewContent2/generated-types.ts | 4 ++++ 16 files changed, 65 insertions(+), 2 deletions(-) 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 be433e89029..9be5bbbfc31 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 @@ -134,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 be433e89029..9be5bbbfc31 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 @@ -134,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 3b5ddf10300..ecd4800ab0f 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 @@ -134,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/purchase2/generated-types.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/purchase2/generated-types.ts index 3b5ddf10300..ecd4800ab0f 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 @@ -134,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/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/fields.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/fields.ts index c1472bb93be..7f28266ddcc 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 @@ -216,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: { 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 ac152cc6de1..0fc4ec2753c 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 @@ -500,7 +500,8 @@ export const getUserData = (payloadUserData: AnyPayload['user_data']): UserData madId, fbLoginID, partner_id, - partner_name + partner_name, + ctwa_clid } = payloadUserData ?? {} const em = cleanAndHash(email) @@ -581,7 +582,8 @@ export const getUserData = (payloadUserData: AnyPayload['user_data']): UserData ...(madId ? { madid: madId } : {}), ...(typeof fbLoginID === 'number' ? { fb_login_id: fbLoginID } : {}), ...(partner_id ? { partner_id } : {}), - ...(partner_name ? { partner_name } : {}) + ...(partner_name ? { partner_name } : {}), + ...(ctwa_clid ? { ctwa_clid } : {}) } return userData } 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). From 9f662f0042a92b1d84262ec8bcbd18dadae3202f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 15 May 2026 10:52:23 +0100 Subject: [PATCH 029/210] unit tests --- .../__tests__/shared-tests/functions.test.ts | 332 +++++++++++++++++- .../shared/functions.ts | 38 +- .../facebook-conversions-api/shared/types.ts | 1 + 3 files changed, 358 insertions(+), 13 deletions(-) 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/shared/functions.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/shared/functions.ts index 0fc4ec2753c..d6da77c9530 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 @@ -477,6 +477,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, @@ -504,6 +510,17 @@ export const getUserData = (payloadUserData: AnyPayload['user_data']): UserData 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 = (() => { @@ -560,7 +577,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 } : {}), @@ -572,18 +588,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 } : {}), - ...(ctwa_clid ? { ctwa_clid } : {}) + ...(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 829ba0ddac1..fd8175d85fa 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 @@ -218,6 +218,7 @@ export interface UserData { fb_login_id?: number partner_id?: string partner_name?: string + ctwa_clid?: string } export type Content = { From c3f50c65523c241d53db9b79ec2b377acabf96ae Mon Sep 17 00:00:00 2001 From: Joe Ayoub <45374896+joe-ayoub-segment@users.noreply.github.com> Date: Fri, 15 May 2026 13:56:26 +0100 Subject: [PATCH 030/210] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/destinations/facebook-conversions-api/shared/fields.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7f28266ddcc..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 @@ -3,7 +3,7 @@ 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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values.', + '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 From 3403cbbf5e83b420fc6242afdd42d2ee22696baf Mon Sep 17 00:00:00 2001 From: Joe Ayoub <45374896+joe-ayoub-segment@users.noreply.github.com> Date: Fri, 15 May 2026 13:58:30 +0100 Subject: [PATCH 031/210] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../destinations/facebook-conversions-api/shared/functions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d6da77c9530..e57a23ec975 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 @@ -329,7 +329,7 @@ export const convertToAppendValueEventData = ( const { event_name, - custom_data: { order_id, net_revenue, predicted_ltv, ...restCustomData } + custom_data: { order_id: _order_id, net_revenue: _net_revenue, predicted_ltv: _predicted_ltv, ...restCustomData } } = data const { From a9bd1604c5faf824490234109269a697e7bc8676 Mon Sep 17 00:00:00 2001 From: Joe Ayoub <45374896+joe-ayoub-segment@users.noreply.github.com> Date: Fri, 15 May 2026 14:05:33 +0100 Subject: [PATCH 032/210] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../facebook-conversions-api/custom2/generated-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9be5bbbfc31..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 @@ -2,7 +2,7 @@ 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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + * 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 /** From 2e9d6d45f6507fc2c2fc4a07d4a065912288afa7 Mon Sep 17 00:00:00 2001 From: Joe Ayoub <45374896+joe-ayoub-segment@users.noreply.github.com> Date: Fri, 15 May 2026 14:05:45 +0100 Subject: [PATCH 033/210] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../facebook-conversions-api/custom/generated-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9be5bbbfc31..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 @@ -2,7 +2,7 @@ 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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + * 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 /** From 77572ee110d69bb89eb7faaf8be67755939a133d Mon Sep 17 00:00:00 2001 From: Joe Ayoub <45374896+joe-ayoub-segment@users.noreply.github.com> Date: Fri, 15 May 2026 14:05:55 +0100 Subject: [PATCH 034/210] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../facebook-conversions-api/purchase2/generated-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ecd4800ab0f..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 @@ -2,7 +2,7 @@ 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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + * 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 /** From 41726edaea6580d66e871eab433cedf72d334218 Mon Sep 17 00:00:00 2001 From: Joe Ayoub <45374896+joe-ayoub-segment@users.noreply.github.com> Date: Fri, 15 May 2026 14:06:05 +0100 Subject: [PATCH 035/210] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../facebook-conversions-api/purchase/generated-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ecd4800ab0f..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 @@ -2,7 +2,7 @@ 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 profit values. Make sure to resubmit all the original data from your conversion event as well as the pLTV or net profit values. + * 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 /** From 11afe236905480a0e2710f415c2ec54bafa813b8 Mon Sep 17 00:00:00 2001 From: Joe Ayoub <45374896+joe-ayoub-segment@users.noreply.github.com> Date: Fri, 15 May 2026 14:06:45 +0100 Subject: [PATCH 036/210] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/destinations/facebook-conversions-api/shared/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fd8175d85fa..e7183ad20d1 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 @@ -169,7 +169,7 @@ export interface AppendValueEventData extends BaseEventData { event_name: 'AppendValue' original_event_data: { event_name: string - event_time?: string + event_time: string order_id?: string event_id?: string } From 50b29b1e915024c66b414ba43610fd97a96ad94d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 15 May 2026 14:32:04 +0100 Subject: [PATCH 037/210] copilot feedback changes --- .../shared/functions.ts | 58 ++++++++++--------- .../facebook-conversions-api/shared/types.ts | 2 +- 2 files changed, 32 insertions(+), 28 deletions(-) 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 e57a23ec975..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 @@ -199,13 +199,11 @@ export function getCustomEventData(payload: CustomPayload | Custom2Payload, feat custom_data: { ...custom_data } } - if(is_append_event) { - if(!features?.[FEATURE_FLAG_APPEND_VALUE]) { + 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) { - return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) - } + return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) } return data @@ -266,13 +264,14 @@ export function getPurchaseEventData(payload: PurchasePayload | Purchase2Payload } } - if(is_append_event) { - if(!features?.[FEATURE_FLAG_APPEND_VALUE]) { + 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) { - return convertToAppendValueEventData(data, append_event_details as AppendEventDetails, statsContext) + 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 @@ -321,7 +320,7 @@ export function getViewContentEventData(payload: ViewContentPayload | ViewConten export const convertToAppendValueEventData = ( data: CustomEventData | PurchaseEventData, - append_event_details: AppendEventDetails, + append_event_details?: AppendEventDetails, statsContext?: StatsContext ): AppendValueEventData => { const statsClient = statsContext?.statsClient @@ -338,37 +337,42 @@ export const convertToAppendValueEventData = ( original_event_id, net_revenue_to_append, predicted_ltv_to_append - } = append_event_details + } = 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) { + 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) { + 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') { + 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} : {}) - } + ...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) 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 e7183ad20d1..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 @@ -181,7 +181,7 @@ export interface AppendValueEventData extends BaseEventData { } export interface AppendEventDetails { - original_event_time?: string + original_event_time: string original_event_order_id?: string original_event_id?: string net_revenue_to_append?: number From 5e6be46cb47fd6a2589ae54699beca7fdd9aca40 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 15 May 2026 15:18:59 +0100 Subject: [PATCH 038/210] adding multistatusResponse support --- .../braze/mergeUsers/functions.ts | 76 +++++++++++++++++-- .../destinations/braze/mergeUsers/index.ts | 6 +- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts index 6038f45af32..a7f9efe044c 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/functions.ts @@ -1,19 +1,79 @@ -import { RequestClient, PayloadValidationError } from '@segment/actions-core' +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 function mergeUsers(request: RequestClient, settings: Settings, payloads: MergeUsersPayload[]) { +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: payloads.map(getJsonItem) + merge_updates: validItems } - return request(`${settings.endpoint}/users/merge`, { - method: 'post', - ...(payloads.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), - json: items - }) + 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 { diff --git a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts index fcefd7ded84..391d0e2a18e 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/index.ts @@ -1,7 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { mergeUsers, getPrioritizationChoices, getSupportedIdentifierChoices } from './functions' +import { send, getPrioritizationChoices, getSupportedIdentifierChoices } from './functions' const action: ActionDefinition = { title: 'Merge Users [Beta]', @@ -212,10 +212,10 @@ const action: ActionDefinition = { } }, perform: (request, { settings, payload }) => { - return mergeUsers(request, settings, [payload]) + return send(request, settings, [payload], false) }, performBatch: (request, { settings, payload }) => { - return mergeUsers(request, settings, payload) + return send(request, settings, payload, true) } } From 24a74a39754946843aa93010ee68cf96107677c2 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 15 May 2026 15:40:04 +0100 Subject: [PATCH 039/210] adding multistatus unit tests --- .../braze/mergeUsers/__tests__/index.test.ts | 123 +++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) 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 index 0bf070d42a6..ecf4b9dcfc6 100644 --- a/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/braze/mergeUsers/__tests__/index.test.ts @@ -1,5 +1,5 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, SegmentEvent, MultiStatusResponse } from '@segment/actions-core' import Destination from '../../index' const testDestination = createTestIntegration(Destination) @@ -576,3 +576,124 @@ describe('Braze.mergeUsers batch', () => { 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' + }) + }) +}) From a07fa1739e9d6bafd955f3dc6f6e0016e8781742 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 13:56:54 +0100 Subject: [PATCH 040/210] starting work on SHA256 support --- .../src/destinations/s3/syncToS3/fields.ts | 19 +++++++++++++ .../src/destinations/s3/syncToS3/functions.ts | 27 +++++++++++++------ .../s3/syncToS3/generated-types.ts | 9 +++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts b/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts index 97fd485b4cf..1e4d6f4064b 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts @@ -233,6 +233,25 @@ export const commonFields: ActionDefinition['fields'] = { ], default: 'csv' }, + columns_to_hash: { + label: 'Columns to SHA256 Hash', + description: 'Columns whose values will be SHA256 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.', + type: 'string', + required: true, + allowNull: false, + disabledInputMethods: ['variable', 'function', 'enrichment'] + } + } + }, batch_keys: { label: 'Batch Keys', description: 'The keys to use for batching the events.', diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index 6018b34b4af..21e84f2a516 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import { Payload } from './generated-types' import { Settings } from '../generated-types' import { Client } from './client' @@ -22,7 +23,11 @@ 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 columnsToHash = new Set( + (payloads[0]?.columns_to_hash ?? []).map((entry) => entry.column_name).filter(Boolean) + ) + + const fileContent = generateFile(payloads, headers, delimiter, actionColName, batchColName, columnsToHash) const s3Client = new Client(settings.s3_aws_region, settings.iam_role_arn, settings.iam_external_id) @@ -43,14 +48,19 @@ export function clean(delimiter: string, str?: string) { return delimiter === 'tab' ? str : str.replace(delimiter, '') } -function processField(value: unknown | undefined): string { - return encodeString( +function processField(value: unknown | undefined, shouldHash: boolean): string { + const str = value === undefined || value === null ? '' : typeof value === 'object' ? String(JSON.stringify(value)) : String(value) - ) + + if (shouldHash && str !== '') { + return encodeString(createHash('sha256').update(str).digest('hex')) + } + + return encodeString(str) } export function generateFile( @@ -58,18 +68,19 @@ export function generateFile( headers: ColumnHeader[], delimiter: string, actionColName?: string, - batchColName?: string + batchColName?: string, + columnsToHash: Set = new Set() ): 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), false) } if (header.originalName === batchColName) { - return processField(payloads.length) + return processField(payloads.length, false) } - return processField(payload.columns[header.originalName]) + return processField(payload.columns[header.originalName], columnsToHash.has(header.originalName)) }) return Buffer.from(`${row.join(delimiter === 'tab' ? '\t' : delimiter)}${isLastRow ? '' : '\n'}`) 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..2c8a939170c 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,15 @@ export interface Payload { * File extension for the uploaded file. */ file_extension: string + /** + * Columns whose values will be SHA256 hashed before writing to the file. + */ + columns_to_hash?: { + /** + * The name of the column to hash. + */ + column_name: string + }[] /** * The keys to use for batching the events. */ From 41fbdda7364a1d06a1cf80216dd86df37176753f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 14:01:51 +0100 Subject: [PATCH 041/210] configure hash type --- .../src/destinations/s3/syncToS3/fields.ts | 13 +++++++++-- .../src/destinations/s3/syncToS3/functions.ts | 23 +++++++++++-------- .../s3/syncToS3/generated-types.ts | 6 ++++- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts b/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts index 1e4d6f4064b..45ae8008f7f 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts @@ -234,8 +234,8 @@ export const commonFields: ActionDefinition['fields'] = { default: 'csv' }, columns_to_hash: { - label: 'Columns to SHA256 Hash', - description: 'Columns whose values will be SHA256 hashed before writing to the file.', + label: 'Columns to Hash', + description: 'Columns whose values will be hashed before writing to the file.', type: 'object', multiple: true, required: false, @@ -249,6 +249,15 @@ export const commonFields: ActionDefinition['fields'] = { required: true, allowNull: false, disabledInputMethods: ['variable', 'function', 'enrichment'] + }, + hash_algorithm: { + label: 'Hash Algorithm', + description: 'The hashing algorithm to apply.', + type: 'string', + required: true, + choices: [{ label: 'SHA256', value: 'sha256' }], + default: 'sha256', + disabledInputMethods: ['variable', 'function', 'enrichment'] } } }, diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index 21e84f2a516..4f783845107 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -23,9 +23,12 @@ export async function send(payloads: Payload[], settings: Settings, rawMapping: headers.push({ cleanName: clean(delimiter, batchColName), originalName: batchColName }) } - const columnsToHash = new Set( - (payloads[0]?.columns_to_hash ?? []).map((entry) => entry.column_name).filter(Boolean) - ) + const columnsToHash = new Map() + for (const entry of payloads[0]?.columns_to_hash ?? []) { + if (entry.column_name) { + columnsToHash.set(entry.column_name, entry.hash_algorithm ?? 'sha256') + } + } const fileContent = generateFile(payloads, headers, delimiter, actionColName, batchColName, columnsToHash) @@ -48,7 +51,7 @@ export function clean(delimiter: string, str?: string) { return delimiter === 'tab' ? str : str.replace(delimiter, '') } -function processField(value: unknown | undefined, shouldHash: boolean): string { +function processField(value: unknown | undefined, hashAlgorithm?: string): string { const str = value === undefined || value === null ? '' @@ -56,8 +59,8 @@ function processField(value: unknown | undefined, shouldHash: boolean): string { ? String(JSON.stringify(value)) : String(value) - if (shouldHash && str !== '') { - return encodeString(createHash('sha256').update(str).digest('hex')) + if (hashAlgorithm && str !== '') { + return encodeString(createHash(hashAlgorithm).update(str).digest('hex')) } return encodeString(str) @@ -69,18 +72,18 @@ export function generateFile( delimiter: string, actionColName?: string, batchColName?: string, - columnsToHash: Set = new Set() + columnsToHash: 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), false) + return processField(getAudienceAction(payload)) } if (header.originalName === batchColName) { - return processField(payloads.length, false) + return processField(payloads.length) } - return processField(payload.columns[header.originalName], columnsToHash.has(header.originalName)) + return processField(payload.columns[header.originalName], columnsToHash.get(header.originalName)) }) return Buffer.from(`${row.join(delimiter === 'tab' ? '\t' : delimiter)}${isLastRow ? '' : '\n'}`) 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 2c8a939170c..6044cbf4b24 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts @@ -114,13 +114,17 @@ export interface Payload { */ file_extension: string /** - * Columns whose values will be SHA256 hashed before writing to the file. + * Columns whose values will be hashed before writing to the file. */ columns_to_hash?: { /** * The name of the column to hash. */ column_name: string + /** + * The hashing algorithm to apply. + */ + hash_algorithm: string }[] /** * The keys to use for batching the events. From 4332176b6f78c8ed904546a607c43c36d7b29aff Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 14:09:29 +0100 Subject: [PATCH 042/210] letting all cols be hashed --- .../src/destinations/s3/syncToS3/functions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index 4f783845107..981871b3629 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -78,10 +78,10 @@ export function generateFile( const isLastRow = index === payloads.length - 1 const row = headers.map((header): string => { if (header.originalName === actionColName) { - return processField(getAudienceAction(payload)) + return processField(getAudienceAction(payload), columnsToHash.get(header.originalName)) } if (header.originalName === batchColName) { - return processField(payloads.length) + return processField(payloads.length, columnsToHash.get(header.originalName)) } return processField(payload.columns[header.originalName], columnsToHash.get(header.originalName)) }) From 0437e2a2e4ee46ed0822904d0b8869a072131882 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 14:15:12 +0100 Subject: [PATCH 043/210] adding validation --- .../src/destinations/s3/syncToS3/functions.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index 981871b3629..09ded5a5a47 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -1,4 +1,5 @@ import { createHash } from 'crypto' +import { PayloadValidationError } from '@segment/actions-core' import { Payload } from './generated-types' import { Settings } from '../generated-types' import { Client } from './client' @@ -25,9 +26,21 @@ export async function send(payloads: Payload[], settings: Settings, rawMapping: const columnsToHash = new Map() for (const entry of payloads[0]?.columns_to_hash ?? []) { - if (entry.column_name) { - columnsToHash.set(entry.column_name, entry.hash_algorithm ?? 'sha256') + if (!entry.column_name) { + throw new PayloadValidationError('columns_to_hash: column_name is required.') } + if (!entry.hash_algorithm) { + throw new PayloadValidationError('columns_to_hash: hash_algorithm is required.') + } + columnsToHash.set(entry.column_name, entry.hash_algorithm) + } + + const headerNames = new Set(headers.map((h) => h.originalName)) + const invalidColumns = [...columnsToHash.keys()].filter((col) => !headerNames.has(col)) + if (invalidColumns.length > 0) { + throw new PayloadValidationError( + `columns_to_hash contains columns that do not exist: ${invalidColumns.join(', ')}` + ) } const fileContent = generateFile(payloads, headers, delimiter, actionColName, batchColName, columnsToHash) From e4d2fc1e5024bdab83e90555d90d8172086c079b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 14:18:49 +0100 Subject: [PATCH 044/210] decompose validation --- .../src/destinations/s3/syncToS3/functions.ts | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index 09ded5a5a47..539de347714 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -24,24 +24,8 @@ export async function send(payloads: Payload[], settings: Settings, rawMapping: headers.push({ cleanName: clean(delimiter, batchColName), originalName: batchColName }) } - const columnsToHash = new Map() - for (const entry of payloads[0]?.columns_to_hash ?? []) { - if (!entry.column_name) { - throw new PayloadValidationError('columns_to_hash: column_name is required.') - } - if (!entry.hash_algorithm) { - throw new PayloadValidationError('columns_to_hash: hash_algorithm is required.') - } - columnsToHash.set(entry.column_name, entry.hash_algorithm) - } - const headerNames = new Set(headers.map((h) => h.originalName)) - const invalidColumns = [...columnsToHash.keys()].filter((col) => !headerNames.has(col)) - if (invalidColumns.length > 0) { - throw new PayloadValidationError( - `columns_to_hash contains columns that do not exist: ${invalidColumns.join(', ')}` - ) - } + const columnsToHash = validateColumnsToHash(payloads[0]?.columns_to_hash ?? [], headerNames) const fileContent = generateFile(payloads, headers, delimiter, actionColName, batchColName, columnsToHash) @@ -119,3 +103,29 @@ export function getAudienceAction(payload: Payload): boolean | undefined { return (payload?.traits_or_props as Record | undefined)?.[payload.computation_key] ?? undefined } + +export function validateColumnsToHash( + entries: { column_name: string; hash_algorithm: string }[], + validColumnNames: Set +): Map { + const columnsToHash = new Map() + + for (const entry of entries) { + if (!entry.column_name) { + throw new PayloadValidationError('columns_to_hash: column_name is required.') + } + if (!entry.hash_algorithm) { + throw new PayloadValidationError('columns_to_hash: hash_algorithm is required.') + } + columnsToHash.set(entry.column_name, entry.hash_algorithm) + } + + const invalidColumns = [...columnsToHash.keys()].filter((col) => !validColumnNames.has(col)) + if (invalidColumns.length > 0) { + throw new PayloadValidationError( + `columns_to_hash contains columns that do not exist: ${invalidColumns.join(', ')}` + ) + } + + return columnsToHash +} From 31fc13e0fd261399b3806b6be71e95c9253e141f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 14:28:03 +0100 Subject: [PATCH 045/210] linting issues --- .../src/destinations/s3/syncToS3/functions.ts | 33 ++++++++++++------- .../src/destinations/s3/syncToS3/types.ts | 6 +++- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index 539de347714..167df965848 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -1,9 +1,9 @@ -import { createHash } from 'crypto' import { PayloadValidationError } 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 } from './types' export async function send(payloads: Payload[], settings: Settings, rawMapping: RawMapping, signal?: AbortSignal) { const delimiter = payloads[0]?.delimiter @@ -48,7 +48,7 @@ export function clean(delimiter: string, str?: string) { return delimiter === 'tab' ? str : str.replace(delimiter, '') } -function processField(value: unknown | undefined, hashAlgorithm?: string): string { +function processField(value: unknown | undefined, hashAlgorithm?: HashAlgorithm): string { const str = value === undefined || value === null ? '' @@ -57,7 +57,7 @@ function processField(value: unknown | undefined, hashAlgorithm?: string): strin : String(value) if (hashAlgorithm && str !== '') { - return encodeString(createHash(hashAlgorithm).update(str).digest('hex')) + return encodeString(processHashing(str, hashAlgorithm, 'hex')) } return encodeString(str) @@ -69,7 +69,7 @@ export function generateFile( delimiter: string, actionColName?: string, batchColName?: string, - columnsToHash: Map = new Map() + columnsToHash: Map = new Map() ): Buffer { const rows = payloads.map((payload, index) => { const isLastRow = index === payloads.length - 1 @@ -104,20 +104,31 @@ export function getAudienceAction(payload: Payload): boolean | undefined { return (payload?.traits_or_props as Record | undefined)?.[payload.computation_key] ?? undefined } +const SUPPORTED_HASH_ALGORITHMS: HashAlgorithm[] = ['sha256'] + export function validateColumnsToHash( - entries: { column_name: string; hash_algorithm: string }[], + entries: NonNullable, validColumnNames: Set -): Map { - const columnsToHash = new Map() +): Map { + const columnsToHash = new Map() for (const entry of entries) { - if (!entry.column_name) { + const columnName = String(entry.column_name ?? '') + const hashAlgorithm = String(entry.hash_algorithm ?? '') + + if (!columnName) { throw new PayloadValidationError('columns_to_hash: column_name is required.') } - if (!entry.hash_algorithm) { + if (!hashAlgorithm) { throw new PayloadValidationError('columns_to_hash: hash_algorithm is required.') } - columnsToHash.set(entry.column_name, entry.hash_algorithm) + const algorithm = SUPPORTED_HASH_ALGORITHMS.find((a) => a === hashAlgorithm) + if (!algorithm) { + throw new PayloadValidationError( + `columns_to_hash: unsupported hash_algorithm "${hashAlgorithm}". Supported: ${SUPPORTED_HASH_ALGORITHMS.join(', ')}` + ) + } + columnsToHash.set(columnName, algorithm) } const invalidColumns = [...columnsToHash.keys()].filter((col) => !validColumnNames.has(col)) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/types.ts b/packages/destination-actions/src/destinations/s3/syncToS3/types.ts index 5e934cf14bd..526d281e61f 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/types.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/types.ts @@ -14,7 +14,11 @@ export interface RawMapping { } } +import type { EncryptionMethod } from '../../../lib/hashing-utils' + +export type HashAlgorithm = Extract + export interface ColumnHeader { - cleanName: string + cleanName: string originalName: string } From 1fd8ccb6e54ddaebc2abbbc6c818a2e24da0bd2d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 14:32:04 +0100 Subject: [PATCH 046/210] unit tests --- .../s3/syncToS3/__tests__/index.test.ts | 130 +++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) 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..5f01a9339ba 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,9 @@ -import { generateFile } from '../functions' // Adjust the import path +import { generateFile, validateColumnsToHash } from '../functions' import { Payload } from '../generated-types' import { clean, encodeString, getAudienceAction } from '../functions' -import { ColumnHeader } from '../types' +import { ColumnHeader, HashAlgorithm } from '../types' +import { PayloadValidationError } from '@segment/actions-core' +import { createHash } from 'crypto' // Mock AWS SDK before any imports to avoid initialization issues jest.mock('@aws-sdk/client-s3', () => ({ @@ -159,4 +161,128 @@ 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', 'sha256']]) + 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 = createHash('sha256').update('test@test.com').digest('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', 'sha256'], + ['user_id', 'sha256'] + ]) + + 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 = createHash('sha256').update('user_1').digest('hex') + expect(dataRow[1]).toBe(`"${expectedHash}"`) + }) + + it('should hash multiple columns independently', () => { + const columnsToHash = new Map([ + ['email', 'sha256'], + ['user_id', 'sha256'] + ]) + 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 = createHash('sha256').update('test@test.com').digest('hex') + const expectedUserIdHash = createHash('sha256').update('user_id_1').digest('hex') + + expect(dataRow[emailIndex]).toBe(`"${expectedEmailHash}"`) + expect(dataRow[userIdIndex]).toBe(`"${expectedUserIdHash}"`) + }) + + it('should not hash columns not in columnsToHash', () => { + const columnsToHash = new Map([['email', 'sha256']]) + 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"') + }) +}) + +describe('validateColumnsToHash', () => { + 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' }, + { column_name: 'user_id', hash_algorithm: 'sha256' } + ] + const result = validateColumnsToHash(entries, validColumnNames) + expect(result.size).toBe(2) + expect(result.get('email')).toBe('sha256') + expect(result.get('user_id')).toBe('sha256') + }) + + it('should throw when column_name is empty', () => { + const entries = [{ column_name: '', hash_algorithm: 'sha256' }] + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('column_name is required') + }) + + it('should throw when hash_algorithm is empty', () => { + const entries = [{ column_name: 'email', hash_algorithm: '' }] + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('hash_algorithm is required') + }) + + it('should throw when hash_algorithm is unsupported', () => { + const entries = [{ column_name: 'email', hash_algorithm: 'md5' }] + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('unsupported hash_algorithm "md5"') + }) + + it('should throw when column_name does not exist in valid columns', () => { + const entries = [{ column_name: 'nonexistent_column', hash_algorithm: 'sha256' }] + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow( + 'columns_to_hash 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' }, + { column_name: 'bad_col_2', hash_algorithm: 'sha256' } + ] + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('bad_col_1, bad_col_2') + }) + + it('should return an empty map when entries is empty', () => { + const result = validateColumnsToHash([], validColumnNames) + expect(result.size).toBe(0) + }) }) From 394c71a6e7f079eb67ceb179a5c9f69f5af47eda Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 14:39:52 +0100 Subject: [PATCH 047/210] adding flag in case feature needs to be disabled --- .../src/destinations/s3/constants.ts | 1 + .../destinations/s3/syncToS3/__tests__/index.test.ts | 10 +++++----- .../src/destinations/s3/syncToS3/functions.ts | 11 +++++++---- .../src/destinations/s3/syncToS3/index.ts | 8 ++++---- 4 files changed, 17 insertions(+), 13 deletions(-) create mode 100644 packages/destination-actions/src/destinations/s3/constants.ts 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/__tests__/index.test.ts b/packages/destination-actions/src/destinations/s3/syncToS3/__tests__/index.test.ts index 5f01a9339ba..e1e57f4fac9 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 @@ -3,7 +3,7 @@ import { Payload } from '../generated-types' import { clean, encodeString, getAudienceAction } from '../functions' import { ColumnHeader, HashAlgorithm } from '../types' import { PayloadValidationError } from '@segment/actions-core' -import { createHash } from 'crypto' +import { processHashing } from '../../../../lib/hashing-utils' // Mock AWS SDK before any imports to avoid initialization issues jest.mock('@aws-sdk/client-s3', () => ({ @@ -170,7 +170,7 @@ describe('generateFile', () => { const dataRow = rows[1].split(',') const emailIndex = headerRow.indexOf('email') - const expectedHash = createHash('sha256').update('test@test.com').digest('hex') + const expectedHash = processHashing('test@test.com', 'sha256', 'hex') expect(dataRow[emailIndex]).toBe(`"${expectedHash}"`) }) @@ -197,7 +197,7 @@ describe('generateFile', () => { const dataRow = rows[1].split(',') expect(dataRow[0]).toBe('""') - const expectedHash = createHash('sha256').update('user_1').digest('hex') + const expectedHash = processHashing('user_1', 'sha256', 'hex') expect(dataRow[1]).toBe(`"${expectedHash}"`) }) @@ -214,8 +214,8 @@ describe('generateFile', () => { const emailIndex = headerRow.indexOf('email') const userIdIndex = headerRow.indexOf('user_id') - const expectedEmailHash = createHash('sha256').update('test@test.com').digest('hex') - const expectedUserIdHash = createHash('sha256').update('user_id_1').digest('hex') + 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}"`) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index 167df965848..07229359a83 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -1,11 +1,12 @@ -import { PayloadValidationError } from '@segment/actions-core' +import { PayloadValidationError, 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, HashAlgorithm } from './types' +import { S3_HASHING_FEATURE_FLAG } 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 @@ -24,8 +25,10 @@ export async function send(payloads: Payload[], settings: Settings, rawMapping: headers.push({ cleanName: clean(delimiter, batchColName), originalName: batchColName }) } - const headerNames = new Set(headers.map((h) => h.originalName)) - const columnsToHash = validateColumnsToHash(payloads[0]?.columns_to_hash ?? [], headerNames) + const columnsToHash = + features && features[S3_HASHING_FEATURE_FLAG] + ? validateColumnsToHash(payloads[0]?.columns_to_hash ?? [], new Set(headers.map((h) => h.originalName))) + : new Map() const fileContent = generateFile(payloads, headers, delimiter, actionColName, batchColName, columnsToHash) 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) } } From 41a5f84b12772339865758b7ba35d948b7ae59ac Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 15:25:32 +0100 Subject: [PATCH 048/210] copilot comments fixed --- .../src/destinations/s3/syncToS3/__tests__/index.test.ts | 3 +-- .../src/destinations/s3/syncToS3/functions.ts | 3 ++- .../src/destinations/s3/syncToS3/types.ts | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) 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 e1e57f4fac9..e3cb208de89 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,6 +1,5 @@ -import { generateFile, validateColumnsToHash } from '../functions' +import { generateFile, validateColumnsToHash, clean, encodeString, getAudienceAction } from '../functions' import { Payload } from '../generated-types' -import { clean, encodeString, getAudienceAction } from '../functions' import { ColumnHeader, HashAlgorithm } from '../types' import { PayloadValidationError } from '@segment/actions-core' import { processHashing } from '../../../../lib/hashing-utils' diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index 07229359a83..a15ded4b4c2 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -1,4 +1,5 @@ -import { PayloadValidationError, Features } from '@segment/actions-core' +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' diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/types.ts b/packages/destination-actions/src/destinations/s3/syncToS3/types.ts index 526d281e61f..af289004655 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/types.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/types.ts @@ -1,3 +1,7 @@ +import type { EncryptionMethod } from '../../../lib/hashing-utils' + +export type HashAlgorithm = Extract + export interface Credentials { accessKeyId: string secretAccessKey: string @@ -14,10 +18,6 @@ export interface RawMapping { } } -import type { EncryptionMethod } from '../../../lib/hashing-utils' - -export type HashAlgorithm = Extract - export interface ColumnHeader { cleanName: string originalName: string From 90ce5073e8f752f555f74ddf70494bc33b37c582 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 20 May 2026 15:46:04 +0100 Subject: [PATCH 049/210] copilot comment fix --- .../src/destinations/s3/syncToS3/__tests__/index.test.ts | 9 +++++++++ .../src/destinations/s3/syncToS3/functions.ts | 3 +++ 2 files changed, 12 insertions(+) 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 e3cb208de89..918e45298bc 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 @@ -280,6 +280,15 @@ describe('validateColumnsToHash', () => { expect(() => validateColumnsToHash(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' }, + { column_name: 'email', hash_algorithm: 'sha256' } + ] + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) + expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('duplicate column_name "email"') + }) + it('should return an empty map when entries is empty', () => { const result = validateColumnsToHash([], validColumnNames) expect(result.size).toBe(0) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index a15ded4b4c2..d83dc3ab412 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -132,6 +132,9 @@ export function validateColumnsToHash( `columns_to_hash: unsupported hash_algorithm "${hashAlgorithm}". Supported: ${SUPPORTED_HASH_ALGORITHMS.join(', ')}` ) } + if (columnsToHash.has(columnName)) { + throw new PayloadValidationError(`columns_to_hash: duplicate column_name "${columnName}".`) + } columnsToHash.set(columnName, algorithm) } From f9433959ff2db3db95ee1e5fb9868b86fb4ceba2 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 21 May 2026 10:36:20 +0100 Subject: [PATCH 050/210] STRATCONN-6824 - [Braze] - add ecommerce addtocart event --- .../destinations/braze/ecommerce/constants.ts | 2 +- .../destinations/braze/ecommerce/functions.ts | 28 ++++++------- .../src/destinations/braze/ecommerce/types.ts | 40 +++++++++++++------ 3 files changed, 42 insertions(+), 28 deletions(-) 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/functions.ts b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts index e2c1a56af06..d309e2ab65c 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, @@ -172,7 +172,7 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings } 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,19 +192,19 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings } switch(name) { - // case EVENT_NAMES.CART_UPDATED: { - // const { cart_id } = payload + 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 - // } + const event: CartUpdatedEvent = { + ...multiProductEvent, + name: EVENT_NAMES.CART_UPDATED, + properties: { + ...multiProductEvent.properties, + cart_id: cart_id as string + } + } + return event + } case EVENT_NAMES.CHECKOUT_STARTED: { const { diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/types.ts b/packages/destination-actions/src/destinations/braze/ecommerce/types.ts index b7882dc20d0..5b887e5060d 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 @@ -85,21 +85,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 +115,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 @@ -123,6 +133,7 @@ export interface OrderRefundedEvent extends MultiProductBaseEvent { name: OrderRefundedEventName properties: MultiProductBaseEvent['properties'] & { order_id: string + subtotal_value?: number total_discounts?: number discounts?: Array<{ code: string @@ -138,6 +149,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<{ From bca9f37bac7de573d74c579bb4a68aa43fcec743 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 21 May 2026 11:40:10 +0100 Subject: [PATCH 051/210] adding add to cart --- .../destinations/braze/ecommerce/fields.ts | 173 +++++++++++++++--- .../destinations/braze/ecommerce/functions.ts | 70 ++++--- .../braze/ecommerce/generated-types.ts | 16 ++ 3 files changed, 210 insertions(+), 49 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts index 541969ebbf0..fa50db95a41 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 }, @@ -201,24 +201,14 @@ const cart_id: InputField = { 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.', type: 'string', default: {'@path': '$.properties.cart_id'}, - // 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 +231,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 +261,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 +290,133 @@ 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 + }, + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.ORDER_REFUNDED + } + ] + } +} + +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.', @@ -558,6 +675,10 @@ export const commonFields = { order_id, cart_id, total_value, + action, + subtotal_value, + tax, + shipping, total_discounts, discounts, currency, diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts index d309e2ab65c..28c33e8a433 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts @@ -193,72 +193,90 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings switch(name) { case EVENT_NAMES.CART_UPDATED: { - const { cart_id } = payload + 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 + 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, + subtotal_value, + total_discounts, discounts - } = payload - + } = payload as Payload + const event: OrderRefundedEvent = { ...multiProductEvent, name: EVENT_NAMES.ORDER_REFUNDED, properties: { ...multiProductEvent.properties, order_id: order_id as string, + ...(typeof subtotal_value === 'number' ? { subtotal_value } : {}), ...(typeof total_discounts === 'number' ? { total_discounts } : {}), ...(discounts ? { discounts } : {}) } @@ -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..f991358aee4 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/generated-types.ts @@ -52,6 +52,22 @@ export interface Payload { * 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. */ From 4418b2486947c64c7c62005d1bc1e458c5f68fc4 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 21 May 2026 11:50:34 +0100 Subject: [PATCH 052/210] some fields not needed for refunds --- .../src/destinations/braze/ecommerce/fields.ts | 5 ----- .../src/destinations/braze/ecommerce/functions.ts | 2 -- .../src/destinations/braze/ecommerce/types.ts | 1 - 3 files changed, 8 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts index fa50db95a41..033c64dc338 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts @@ -341,11 +341,6 @@ const subtotal_value: InputField = { fieldKey: 'name', operator: 'is', value: EVENT_NAMES.ORDER_CANCELLED - }, - { - fieldKey: 'name', - operator: 'is', - value: EVENT_NAMES.ORDER_REFUNDED } ] } diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts index 28c33e8a433..e0da382fc18 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts @@ -265,7 +265,6 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings case EVENT_NAMES.ORDER_REFUNDED: { const { order_id, - subtotal_value, total_discounts, discounts } = payload as Payload @@ -276,7 +275,6 @@ function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings properties: { ...multiProductEvent.properties, order_id: order_id as string, - ...(typeof subtotal_value === 'number' ? { subtotal_value } : {}), ...(typeof total_discounts === 'number' ? { total_discounts } : {}), ...(discounts ? { discounts } : {}) } diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/types.ts b/packages/destination-actions/src/destinations/braze/ecommerce/types.ts index 5b887e5060d..0e5832b5517 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/types.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/types.ts @@ -133,7 +133,6 @@ export interface OrderRefundedEvent extends MultiProductBaseEvent { name: OrderRefundedEventName properties: MultiProductBaseEvent['properties'] & { order_id: string - subtotal_value?: number total_discounts?: number discounts?: Array<{ code: string From bab79f2502da4d83a5319b5e6a2cd67b256f28f9 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 21 May 2026 12:44:58 +0100 Subject: [PATCH 053/210] adding type field for product viewed event --- .../destinations/braze/ecommerce/fields.ts | 27 ++++++++++++++++++- .../destinations/braze/ecommerce/functions.ts | 6 +++-- .../src/destinations/braze/ecommerce/types.ts | 4 ++- .../ecommerceSingleProduct/generated-types.ts | 4 +++ .../braze/ecommerceSingleProduct/index.ts | 4 ++- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts index 033c64dc338..0060e1d177e 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts @@ -639,6 +639,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', @@ -681,4 +704,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 e0da382fc18..3a980ba8622 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts @@ -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,7 +168,8 @@ 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 diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/types.ts b/packages/destination-actions/src/destinations/braze/ecommerce/types.ts index 0e5832b5517..2b17ec687f2 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/types.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/types.ts @@ -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 { 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..07530771487 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/generated-types.ts @@ -71,6 +71,10 @@ export interface Payload { * Source the event is derived from. */ source: string + /** + * Required to use Braze catalog trigger features. Accepted values: price_drop, back_in_stock. + */ + catalog_type?: string[] /** * Additional metadata for 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: { From 602dff76c4195a382269c6f2b3bfbed5aba12f0c Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 21 May 2026 13:16:34 +0100 Subject: [PATCH 054/210] unit tests --- .../braze/ecommerce/__tests__/index.test.ts | 343 ++++++++++++++++-- .../__tests__/index.test.ts | 70 ++++ 2 files changed, 389 insertions(+), 24 deletions(-) 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/ecommerceSingleProduct/__tests__/index.test.ts b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/index.test.ts index 094fbc1c05e..e473cd66389 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 @@ -157,6 +157,76 @@ describe('Braze.ecommerce', () => { }) }) + 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', () => { it('should send batched single product ecommerce events correctly', async () => { From 0827a3896f09eb707bef5fa2b448d83edc9ed6d4 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 21 May 2026 13:26:45 +0100 Subject: [PATCH 055/210] making cart_id conditionally required --- .../destinations/braze/ecommerce/fields.ts | 12 ++++++++- .../braze/ecommerce/generated-types.ts | 2 +- .../ecommerceSingleProduct/generated-types.ts | 26 +++++++++++++++---- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts index 0060e1d177e..e3b1aa4d805 100644 --- a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts +++ b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts @@ -198,9 +198,19 @@ 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 + } + ] + }, depends_on: { match: 'any', conditions: [ 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 f991358aee4..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,7 +45,7 @@ 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 /** 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 07530771487..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. */ @@ -71,10 +87,6 @@ export interface Payload { * Source the event is derived from. */ source: string - /** - * Required to use Braze catalog trigger features. Accepted values: price_drop, back_in_stock. - */ - catalog_type?: string[] /** * Additional metadata for the ecommerce event. */ @@ -89,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. */ From 69f512abd247bdbcdc546c8bbe7341561272a81a Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 21 May 2026 13:51:24 +0100 Subject: [PATCH 056/210] adding presets --- .../__tests__/index.test.ts | 3 +- .../src/destinations/braze/index.ts | 48 ++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) 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 e473cd66389..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,8 +154,7 @@ describe('Braze.ecommerce', () => { }) expect(response.length).toBe(1) - }) - }) + }) it('should send Product Viewed event with catalog_type correctly', async () => { diff --git a/packages/destination-actions/src/destinations/braze/index.ts b/packages/destination-actions/src/destinations/braze/index.ts index 85b600ee994..571fd40957d 100644 --- a/packages/destination-actions/src/destinations/braze/index.ts +++ b/packages/destination-actions/src/destinations/braze/index.ts @@ -101,7 +101,7 @@ const destination: DestinationDefinition = { 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' @@ -162,12 +162,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"', From f18f2b668280f62a1d82ad7d8a54f36a74065bfc Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 22 May 2026 09:59:55 +0100 Subject: [PATCH 057/210] STRATCONN-6789 - [LinkIn Conversions API] - new identifier suppport --- .../linkedin-conversions/api/index.ts | 20 +++++++++++-- .../streamConversion/generated-types.ts | 16 +++++++--- .../streamConversion/index.ts | 29 +++++++++++++++---- 3 files changed, 52 insertions(+), 13 deletions(-) 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/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..2f09015bf38 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 Date: Fri, 22 May 2026 10:02:04 +0100 Subject: [PATCH 058/210] removing default ip address mapping --- .../linkedin-conversions/streamConversion/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 2f09015bf38..8dc67da4b89 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts @@ -269,10 +269,7 @@ const action: ActionDefinition Date: Fri, 22 May 2026 11:45:53 +0100 Subject: [PATCH 059/210] adding unit tests --- .../streamConversion/__tests__/index.test.ts | 226 +++++++++++++++++- .../streamConversion/index.ts | 1 + 2 files changed, 226 insertions(+), 1 deletion(-) 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/index.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts index 8dc67da4b89..f628cf1959e 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts @@ -269,6 +269,7 @@ const action: ActionDefinition Date: Fri, 22 May 2026 14:27:15 +0100 Subject: [PATCH 060/210] fixing string trim bug with rokt capi --- .../destinations/rokt-capi/send/functions.ts | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) 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..240918e9eeb 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,13 @@ function sanitize(obj: Record | undefined, allowedTypes: ('stri return result } +function trimmed(value: string | undefined | null): string | undefined { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + return undefined +} + function maybeHash(value: string | undefined, shouldHash: boolean | undefined, key: string, hashedKey: string, cleaningFunction?: (input: string) => string): Record { if (!value) { return {} From 778280ababd83f4994d25e8086d7298092411062 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 22 May 2026 14:31:54 +0100 Subject: [PATCH 061/210] unit test --- .../rokt-capi/send/__tests__/index.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) 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', From d68ccf826107b8ba0031be1bc3072970a7db4203 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 07:53:59 +0000 Subject: [PATCH 062/210] fix: reuse trimmed value in rokt helper Agent-Logs-Url: https://github.com/segmentio/action-destinations/sessions/9285cc13-1a78-4a51-b398-eff13c077380 --- .../src/destinations/rokt-capi/send/functions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 240918e9eeb..8b5c87b8b7a 100644 --- a/packages/destination-actions/src/destinations/rokt-capi/send/functions.ts +++ b/packages/destination-actions/src/destinations/rokt-capi/send/functions.ts @@ -292,8 +292,9 @@ function sanitize(obj: Record | undefined, allowedTypes: ('stri } function trimmed(value: string | undefined | null): string | undefined { - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim() + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed || undefined } return undefined } From f6841e4725ec6c9799c1c1af46373a48156af4c0 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 25 May 2026 12:07:41 +0100 Subject: [PATCH 063/210] mixpanel secret deprecation --- .../mixpanel/__test__/multistatus.test.ts | 1 - .../mixpanel/alias/__tests__/index.test.ts | 6 ------ .../src/destinations/mixpanel/generated-types.ts | 4 ---- .../groupIdentifyUser/__tests__/index.test.ts | 8 -------- .../mixpanel/identifyUser/__tests__/index.test.ts | 10 ---------- .../incrementProperties/__test__/index.test.ts | 6 ------ .../src/destinations/mixpanel/index.ts | 8 -------- .../mixpanel/trackEvent/__tests__/index.test.ts | 12 ------------ .../src/destinations/mixpanel/trackEvent/index.ts | 2 +- .../mixpanel/trackPurchase/__tests__/index.test.ts | 11 ----------- .../src/destinations/mixpanel/trackPurchase/index.ts | 2 +- 11 files changed, 2 insertions(+), 68 deletions(-) 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..a31c8e9b380 100644 --- a/packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts @@ -10,7 +10,6 @@ beforeEach(() => { const settings = { projectToken: 'test-api-key', - apiSecret: 'test-proj-token', apiRegion: 'US' } 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/generated-types.ts b/packages/destination-actions/src/destinations/mixpanel/generated-types.ts index b79a6b6502d..f21fb3828bb 100644 --- a/packages/destination-actions/src/destinations/mixpanel/generated-types.ts +++ b/packages/destination-actions/src/destinations/mixpanel/generated-types.ts @@ -5,10 +5,6 @@ export interface Settings { * Mixpanel project token. */ projectToken: string - /** - * Mixpanel project secret. - */ - 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/__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/__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/__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..875da6c6a5e 100644 --- a/packages/destination-actions/src/destinations/mixpanel/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/index.ts @@ -81,13 +81,6 @@ 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 - }, apiRegion: { label: 'Data Residency', description: @@ -115,7 +108,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/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/__tests__/index.test.ts index 901b6195e8b..17cf4d4ce93 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 @@ -4,7 +4,6 @@ import Destination from '../../index' import { ApiRegions, StrictMode } 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' @@ -31,7 +30,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -56,7 +54,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.EU } }) @@ -81,7 +78,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.IN } }) @@ -106,7 +102,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) expect(responses.length).toBe(1) @@ -130,7 +125,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, sourceName: 'example segment source name' } }) @@ -157,7 +151,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) expect(responses.length).toBe(1) @@ -183,7 +176,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -208,7 +200,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US, strictMode: StrictMode.ON } @@ -234,7 +225,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US, strictMode: StrictMode.OFF } @@ -272,7 +262,6 @@ describe('Mixpanel.trackEvent', () => { event, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET }, useDefaultMappings: true, mapping: { @@ -306,7 +295,6 @@ describe('Mixpanel.trackEvent', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, 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..5cc8433df84 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts @@ -43,7 +43,7 @@ const callMixpanelApi = async ( method: 'post', json: events, headers: { - authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` + authorization: `Basic ${Buffer.from(`${settings.projectToken}:`).toString('base64')}` }, throwHttpErrors: throwHttpErrors } 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..888fd98696e 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 @@ -5,7 +5,6 @@ import { ApiRegions, StrictMode } 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 timestamp = '2021-08-17T15:21:15.449Z' @@ -64,7 +63,6 @@ const mapping = { const settingsObj = { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } @@ -98,7 +96,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.EU } }) @@ -116,7 +113,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.IN } }) @@ -134,7 +130,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) expect(responses.length).toBe(1) @@ -152,7 +147,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, sourceName } }) @@ -180,7 +174,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET } }) expect(responses.length).toBe(1) @@ -199,7 +192,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US } }) @@ -217,7 +209,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US, strictMode: StrictMode.ON } @@ -236,7 +227,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, apiRegion: ApiRegions.US, strictMode: StrictMode.OFF } @@ -268,7 +258,6 @@ describe('Mixpanel.trackPurchase', () => { useDefaultMappings: true, settings: { projectToken: MIXPANEL_PROJECT_TOKEN, - apiSecret: MIXPANEL_API_SECRET, 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..78ca5e7ee7f 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts @@ -80,7 +80,7 @@ const callMixpanelApi = async ( method: 'post', json: events, headers: { - authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` + authorization: `Basic ${Buffer.from(`${settings.projectToken}:`).toString('base64')}` }, throwHttpErrors: throwHttpErrors } From 62db2c4f2b5ff0722404d4cc86cabd303c01bddb Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 25 May 2026 15:59:30 +0100 Subject: [PATCH 064/210] Iterable - updateEmail --- .../__snapshots__/snapshot.test.ts.snap | 9 +- .../updateUser/__tests__/index.test.ts | 106 ++++++++++++++++++ .../iterable/updateUser/generated-types.ts | 4 + .../destinations/iterable/updateUser/index.ts | 22 +++- .../src/destinations/iterable/utils.ts | 1 + 5 files changed, 134 insertions(+), 8 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap index c1f26759ed4..aa806554a64 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,12 +2,7 @@ exports[`Testing snapshot for Iterable's updateUser destination action: all fields 1`] = ` Object { - "dataFields": Object { - "phoneNumber": "^qMtTUbd*oM", - "testType": "^qMtTUbd*oM", - }, - "email": "lo@de.uz", - "mergeNestedObjects": true, - "userId": "^qMtTUbd*oM", + "currentUserId": "^qMtTUbd*oM", + "newEmail": "higtab@piciwza.tg", } `; 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..b051f4377a1 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,112 @@ describe('Iterable.updateUser', () => { }) }) }) + + describe('perform updateEmail', () => { + it('calls updateEmail endpoint when newEmail is provided with currentEmail', async () => { + const event = createTestEvent({ + type: 'identify', + userId: undefined, + traits: { + email: 'old@example.com' + } + }) + + 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(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual({ + currentEmail: 'old@example.com', + newEmail: 'new@example.com' + }) + }) + + it('calls updateEmail endpoint when newEmail is provided with currentUserId', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'seg_user_01', + traits: { + firstName: 'Test' + } + }) + + 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(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual({ + currentUserId: 'seg_user_01', + newEmail: 'updated@example.com' + }) + }) + + it('prefers currentUserId over currentEmail when both are provided with newEmail', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'seg_user_01', + traits: { + email: 'old@example.com' + } + }) + + 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(1) + expect(responses[0].options.json).toEqual({ + 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/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(1) + expect(responses[0].status).toBe(200) + }) + }) + describe('performBatch', () => { it('works with default mappings', async () => { const events = [createTestEvent({ type: 'identify' }), createTestEvent({ type: 'identify' })] 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..257c8bdeb3c 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. When provided, Segment will call Iterable's updateEmail API to change the user's email. The userId field will be used to identify the user if present, otherwise the email field will be used as the current email identifier. + */ + 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..85475afb2ba 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. When provided, Segment will call Iterable's updateEmail API to change the user's email. The userId field will be used to identify the user if present, otherwise the email field will be used as the current email identifier.", + type: 'string', + format: 'email', + required: false + }, dataFields: { ...USER_DATA_FIELDS }, @@ -97,6 +105,18 @@ const action: ActionDefinition = { throw new PayloadValidationError('Must include email or userId.') } + if (payload.newEmail) { + const endpoint = getRegionalEndpoint('updateEmail', settings.dataCenterLocation as DataCenterLocation) + return request(endpoint, { + method: 'post', + json: { + ...(payload.userId ? { currentUserId: payload.userId } : { currentEmail: payload.email }), + newEmail: payload.newEmail + }, + timeout: Math.max(30_000, DEFAULT_REQUEST_TIMEOUT) + }) + } + const updateUserRequestPayload: UserUpdateRequestPayload = transformIterableUserPayload(payload) const endpoint = getRegionalEndpoint('updateUser', 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..c74d688d8c8 100644 --- a/packages/destination-actions/src/destinations/iterable/utils.ts +++ b/packages/destination-actions/src/destinations/iterable/utils.ts @@ -94,6 +94,7 @@ 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', From bd5f3c5a136f88efe14613205ae3420b00d2bc77 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 25 May 2026 16:39:52 +0100 Subject: [PATCH 065/210] supporting updateEmail for batches --- .../__snapshots__/snapshot.test.ts.snap | 1 + .../updateUser/__tests__/index.test.ts | 105 +++++++++++++++++- .../iterable/updateUser/generated-types.ts | 2 +- .../destinations/iterable/updateUser/index.ts | 19 +++- 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap index aa806554a64..3ed52508005 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Iterable's updateUser destination action: all fields 1`] = ` Object { + "currentEmail": "lo@de.uz", "currentUserId": "^qMtTUbd*oM", "newEmail": "higtab@piciwza.tg", } 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 b051f4377a1..3f1cbb3abe8 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 @@ -160,7 +160,7 @@ describe('Iterable.updateUser', () => { }) describe('perform updateEmail', () => { - it('calls updateEmail endpoint when newEmail is provided with currentEmail', async () => { + it('calls updateEmail endpoint with currentEmail when only email is provided', async () => { const event = createTestEvent({ type: 'identify', userId: undefined, @@ -214,7 +214,7 @@ describe('Iterable.updateUser', () => { }) }) - it('prefers currentUserId over currentEmail when both are provided with newEmail', async () => { + it('sends both currentEmail and currentUserId when both are available', async () => { const event = createTestEvent({ type: 'identify', userId: 'seg_user_01', @@ -235,6 +235,7 @@ describe('Iterable.updateUser', () => { expect(responses.length).toBe(1) expect(responses[0].options.json).toEqual({ + currentEmail: 'old@example.com', currentUserId: 'seg_user_01', newEmail: 'new@example.com' }) @@ -404,5 +405,105 @@ describe('Iterable.updateUser', () => { }) expect(responses[0].options.json).not.toHaveProperty('updateOnly') }) + + it('includes newEmail in dataFields.email for batch processing', async () => { + const events = [ + createTestEvent({ + type: 'identify', + userId: 'seg_user_01', + traits: { + email: 'old1@example.com' + } + }), + createTestEvent({ + type: 'identify', + userId: 'seg_user_02', + traits: { + email: 'old2@example.com' + } + }) + ] + + nock('https://api.iterable.com/api').post('/users/bulkUpdate').reply(200, {}) + + const responses = await testDestination.testBatchAction('updateUser', { + events, + useDefaultMappings: true, + mapping: { + newEmail: { '@path': '$.properties.newEmail' } + }, + settings: { apiKey: 'testApiKey', dataCenterLocation: 'united_states' } + }) + + expect(responses.length).toBe(1) + expect(responses[0].options.json).toMatchObject({ + users: [ + { + email: 'old1@example.com', + userId: 'seg_user_01' + }, + { + email: 'old2@example.com', + userId: 'seg_user_02' + } + ] + }) + }) + + 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' + }, + properties: { + newEmail: 'megaman8@gmail.com' + } + }), + createTestEvent({ + type: 'identify', + userId: 'gen_seg_01', + traits: { + email: 'goldenaxe7@gmail.com' + }, + properties: { + 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': '$.properties.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 257c8bdeb3c..5fc61c46a99 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts @@ -10,7 +10,7 @@ export interface Payload { */ userId?: string /** - * The new email address to assign to the user. When provided, Segment will call Iterable's updateEmail API to change the user's email. The userId field will be used to identify the user if present, otherwise the email field will be used as the current email identifier. + * The new email address to assign to the user. For single event processing, Segment will call Iterable's updateEmail API using both userId and email as identifiers when available. For batch processing, the new email is set via the bulkUpdate API by including it in dataFields — this only works for hybrid projects, not email-only projects. */ newEmail?: string /** diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/index.ts b/packages/destination-actions/src/destinations/iterable/updateUser/index.ts index 85475afb2ba..f6c3af511a0 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/index.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/index.ts @@ -53,7 +53,7 @@ const action: ActionDefinition = { newEmail: { label: 'New Email Address', description: - "The new email address to assign to the user. When provided, Segment will call Iterable's updateEmail API to change the user's email. The userId field will be used to identify the user if present, otherwise the email field will be used as the current email identifier.", + "The new email address to assign to the user. For single event processing, Segment will call Iterable's updateEmail API using both userId and email as identifiers when available. For batch processing, the new email is set via the bulkUpdate API by including it in dataFields — this only works for hybrid projects, not email-only projects.", type: 'string', format: 'email', required: false @@ -110,7 +110,8 @@ const action: ActionDefinition = { return request(endpoint, { method: 'post', json: { - ...(payload.userId ? { currentUserId: payload.userId } : { currentEmail: payload.email }), + ...(payload.email ? { currentEmail: payload.email } : {}), + ...(payload.userId ? { currentUserId: payload.userId } : {}), newEmail: payload.newEmail }, timeout: Math.max(30_000, DEFAULT_REQUEST_TIMEOUT) @@ -128,9 +129,19 @@ const action: ActionDefinition = { }, 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) From 00289c5020341a73eba23819b1723c29fabbabb7 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 25 May 2026 16:48:25 +0100 Subject: [PATCH 066/210] fixing test --- .../iterable/updateUser/__tests__/index.test.ts | 13 +++++-------- .../iterable/updateUser/generated-types.ts | 2 +- .../src/destinations/iterable/updateUser/index.ts | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) 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 3f1cbb3abe8..f3c1795ebb1 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 @@ -406,20 +406,20 @@ describe('Iterable.updateUser', () => { expect(responses[0].options.json).not.toHaveProperty('updateOnly') }) - it('includes newEmail in dataFields.email for batch processing', async () => { + it('does not set newEmail in dataFields.email when newEmail is not provided', async () => { const events = [ createTestEvent({ type: 'identify', userId: 'seg_user_01', traits: { - email: 'old1@example.com' + name: 'User One' } }), createTestEvent({ type: 'identify', userId: 'seg_user_02', traits: { - email: 'old2@example.com' + name: 'User Two' } }) ] @@ -429,9 +429,6 @@ describe('Iterable.updateUser', () => { const responses = await testDestination.testBatchAction('updateUser', { events, useDefaultMappings: true, - mapping: { - newEmail: { '@path': '$.properties.newEmail' } - }, settings: { apiKey: 'testApiKey', dataCenterLocation: 'united_states' } }) @@ -439,15 +436,15 @@ describe('Iterable.updateUser', () => { expect(responses[0].options.json).toMatchObject({ users: [ { - email: 'old1@example.com', userId: 'seg_user_01' }, { - email: 'old2@example.com', 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 () => { 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 5fc61c46a99..d03efb8b71a 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts @@ -10,7 +10,7 @@ export interface Payload { */ userId?: string /** - * The new email address to assign to the user. For single event processing, Segment will call Iterable's updateEmail API using both userId and email as identifiers when available. For batch processing, the new email is set via the bulkUpdate API by including it in dataFields — this only works for hybrid projects, not email-only projects. + * 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 /** diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/index.ts b/packages/destination-actions/src/destinations/iterable/updateUser/index.ts index f6c3af511a0..7e3d1277f66 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/index.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/index.ts @@ -53,7 +53,7 @@ const action: ActionDefinition = { newEmail: { label: 'New Email Address', description: - "The new email address to assign to the user. For single event processing, Segment will call Iterable's updateEmail API using both userId and email as identifiers when available. For batch processing, the new email is set via the bulkUpdate API by including it in dataFields — this only works for hybrid projects, not email-only projects.", + '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 From 70db1f873c662ba2fc01bdc7496c65f7968fa9a8 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 25 May 2026 16:54:45 +0100 Subject: [PATCH 067/210] correcting a test --- .../iterable/updateUser/__tests__/index.test.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 f3c1795ebb1..7f8a94b2b14 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 @@ -453,9 +453,7 @@ describe('Iterable.updateUser', () => { type: 'identify', userId: 'seg_user_01', traits: { - email: 'megaman7@gmail.com' - }, - properties: { + email: 'megaman7@gmail.com', newEmail: 'megaman8@gmail.com' } }), @@ -463,9 +461,7 @@ describe('Iterable.updateUser', () => { type: 'identify', userId: 'gen_seg_01', traits: { - email: 'goldenaxe7@gmail.com' - }, - properties: { + email: 'goldenaxe7@gmail.com', newEmail: 'goldenaxe8@gmail.com' } }) @@ -478,7 +474,7 @@ describe('Iterable.updateUser', () => { mapping: { email: { '@path': '$.traits.email' }, userId: { '@path': '$.userId' }, - newEmail: { '@path': '$.properties.newEmail' } + newEmail: { '@path': '$.traits.newEmail' } } }) From fa95a6709e5a5db2bd1bcee9d40bb51933c5c76d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 25 May 2026 18:43:22 +0100 Subject: [PATCH 068/210] addressing Copilot feedback --- .../__snapshots__/snapshot.test.ts.snap | 10 ++- .../updateUser/__tests__/index.test.ts | 71 +++++++++++++++---- .../destinations/iterable/updateUser/index.ts | 34 ++++----- 3 files changed, 84 insertions(+), 31 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap index 3ed52508005..c1f26759ed4 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,8 +2,12 @@ exports[`Testing snapshot for Iterable's updateUser destination action: all fields 1`] = ` Object { - "currentEmail": "lo@de.uz", - "currentUserId": "^qMtTUbd*oM", - "newEmail": "higtab@piciwza.tg", + "dataFields": Object { + "phoneNumber": "^qMtTUbd*oM", + "testType": "^qMtTUbd*oM", + }, + "email": "lo@de.uz", + "mergeNestedObjects": true, + "userId": "^qMtTUbd*oM", } `; 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 7f8a94b2b14..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 @@ -160,7 +160,7 @@ describe('Iterable.updateUser', () => { }) describe('perform updateEmail', () => { - it('calls updateEmail endpoint with currentEmail when only email is provided', async () => { + it('calls updateUser first, then updateEmail with currentEmail when only email is provided', async () => { const event = createTestEvent({ type: 'identify', userId: undefined, @@ -169,6 +169,7 @@ describe('Iterable.updateUser', () => { } }) + 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', { @@ -179,15 +180,16 @@ describe('Iterable.updateUser', () => { } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toEqual({ + 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 updateEmail endpoint when newEmail is provided with currentUserId', async () => { + it('calls updateUser first, then updateEmail with currentUserId when only userId is provided', async () => { const event = createTestEvent({ type: 'identify', userId: 'seg_user_01', @@ -196,6 +198,7 @@ describe('Iterable.updateUser', () => { } }) + 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', { @@ -206,9 +209,10 @@ describe('Iterable.updateUser', () => { } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toEqual({ + 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' }) @@ -223,6 +227,7 @@ describe('Iterable.updateUser', () => { } }) + 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', { @@ -233,8 +238,48 @@ describe('Iterable.updateUser', () => { } }) - expect(responses.length).toBe(1) - expect(responses[0].options.json).toEqual({ + 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' @@ -249,6 +294,7 @@ describe('Iterable.updateUser', () => { } }) + 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', { @@ -260,8 +306,9 @@ describe('Iterable.updateUser', () => { } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) + expect(responses.length).toBe(2) + expect(responses[0].url).toContain('/users/update') + expect(responses[1].url).toContain('/users/updateEmail') }) }) diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/index.ts b/packages/destination-actions/src/destinations/iterable/updateUser/index.ts index 7e3d1277f66..31a704f828e 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/index.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/index.ts @@ -100,32 +100,34 @@ 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.') } - if (payload.newEmail) { - const endpoint = getRegionalEndpoint('updateEmail', settings.dataCenterLocation as DataCenterLocation) - return request(endpoint, { - method: 'post', - json: { - ...(payload.email ? { currentEmail: payload.email } : {}), - ...(payload.userId ? { currentUserId: payload.userId } : {}), - newEmail: payload.newEmail - }, - timeout: Math.max(30_000, DEFAULT_REQUEST_TIMEOUT) - }) - } - 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] From f0db32ff2d87a8b1397194b472566df0995492ef Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 26 May 2026 11:44:16 +0100 Subject: [PATCH 069/210] STRATCONN-6823 - [Hubspot] - Oauth upgrade --- packages/core/src/destination-kit/index.ts | 9 +++-- .../hubspot/__tests__/index.test.ts | 40 +++++++++++++++++++ .../src/destinations/hubspot/index.ts | 8 ++-- .../destinations/hubspot/versioning-info.ts | 8 +++- 4 files changed, 57 insertions(+), 8 deletions(-) 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/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' From 7032db4dfa82caae3266cc503239f50392388539 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 26 May 2026 14:08:00 +0100 Subject: [PATCH 070/210] STRATCONN-6540 - [Iterable] - new subscription management Action --- .../src/destinations/iterable/index.ts | 4 +- .../__tests__/index.test.ts | 478 ++++++++++++++++++ .../iterable/updateSubscriptions/constants.ts | 1 + .../updateSubscriptions/dynamic-fields.ts | 35 ++ .../iterable/updateSubscriptions/functions.ts | 57 +++ .../updateSubscriptions/generated-types.ts | 50 ++ .../iterable/updateSubscriptions/index.ts | 242 +++++++++ .../iterable/updateSubscriptions/types.ts | 24 + .../src/destinations/iterable/utils.ts | 5 +- 9 files changed, 894 insertions(+), 2 deletions(-) create mode 100644 packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts create mode 100644 packages/destination-actions/src/destinations/iterable/updateSubscriptions/dynamic-fields.ts create mode 100644 packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts create mode 100644 packages/destination-actions/src/destinations/iterable/updateSubscriptions/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts create mode 100644 packages/destination-actions/src/destinations/iterable/updateSubscriptions/types.ts 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/updateSubscriptions/__tests__/index.test.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts new file mode 100644 index 00000000000..d613a8894e5 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts @@ -0,0 +1,478 @@ +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: '' }) + + const response = await testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: { + ...defaultMapping, + identifier: { + email: { '@path': '$.properties.email' }, + userId: { '@path': '$.userId' } + } + } + }) + + expect(response[0].status).toBe(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' }) + + const response = await testDestination.testBatchAction('updateSubscriptions', { + events, + mapping: defaultMapping + }) + + expect(response[0].status).toBe(400) + }) + }) +}) 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..6eecec32c0c --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts @@ -0,0 +1 @@ +export const MAX_SUBSCRIPTION_ITEMS = 6 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..139ade16502 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/dynamic-fields.ts @@ -0,0 +1,35 @@ +import { RequestClient, DynamicFieldResponse } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import { DataCenterLocation } from '../shared-fields' +import { getRegionalEndpoint } from '../utils' +import type { ChannelDetails, MessageTypeDetails, ListDetails } from './types' + +export async function fetchChannels(request: RequestClient, settings: Settings): Promise { + const endpoint = getRegionalEndpoint('getChannels', settings.dataCenterLocation as DataCenterLocation) + const response = await request<{ channels: ChannelDetails[] }>(endpoint, { method: 'get' }) + const choices = response.data.channels.map((channel) => ({ + label: `${channel.name} (${channel.messageMedium} - ${channel.channelType})`, + value: String(channel.id) + })) + return { choices } +} + +export async function fetchMessageTypes(request: RequestClient, settings: Settings): Promise { + const endpoint = getRegionalEndpoint('getMessageTypes', settings.dataCenterLocation as DataCenterLocation) + const response = await request<{ messageTypes: MessageTypeDetails[] }>(endpoint, { method: 'get' }) + const choices = response.data.messageTypes.map((mt) => ({ + label: `${mt.name} (${mt.subscriptionPolicy})`, + value: String(mt.id) + })) + return { choices } +} + +export async function fetchLists(request: RequestClient, settings: Settings): Promise { + const endpoint = getRegionalEndpoint('getLists', settings.dataCenterLocation as DataCenterLocation) + const response = await request<{ lists: ListDetails[] }>(endpoint, { method: 'get' }) + const choices = response.data.lists.map((list) => ({ + label: `${list.name} (${list.listType})`, + value: String(list.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..7e743137f5b --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts @@ -0,0 +1,57 @@ +import { PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DataCenterLocation } from '../shared-fields' +import type { ResolvedIdentifier } from './types' + +export function resolveIdentifier(payload: Payload): ResolvedIdentifier { + const { identifier, user_identifier_preference } = payload + + if (user_identifier_preference === 'userId' && identifier.userId) { + return { userId: identifier.userId } + } + if (user_identifier_preference === 'email' && identifier.email) { + return { email: identifier.email } + } + if (identifier.email) { + return { email: identifier.email } + } + if (identifier.userId) { + return { userId: identifier.userId } + } + throw new PayloadValidationError('Must include email or userId in identifier.') +} + +export function getSubscriptionEndpoint( + settings: Settings, + groupType: string, + groupId: string, + action: string +): string { + const dataCenterLocation = (settings.dataCenterLocation as DataCenterLocation) || 'united_states' + const regionBaseUrls: Record = { + united_states: 'https://api.iterable.com', + europe: 'https://api.eu.iterable.com' + } + const baseUrl = regionBaseUrls[dataCenterLocation] || regionBaseUrls['united_states'] + return `${baseUrl}/api/subscriptions/${groupType}/${groupId}?action=${action}` +} + +export function getSingleUserEndpoint( + settings: Settings, + groupType: string, + groupId: string, + identifier: ResolvedIdentifier +): string { + const dataCenterLocation = (settings.dataCenterLocation as DataCenterLocation) || 'united_states' + const regionBaseUrls: Record = { + united_states: 'https://api.iterable.com', + europe: 'https://api.eu.iterable.com' + } + const baseUrl = regionBaseUrls[dataCenterLocation] || regionBaseUrls['united_states'] + + if (identifier.userId) { + return `${baseUrl}/api/subscriptions/${groupType}/${groupId}/byUserId/${encodeURIComponent(identifier.userId)}` + } + return `${baseUrl}/api/subscriptions/${groupType}/${groupId}/user/${encodeURIComponent(identifier.email!)}` +} 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..120338d2d10 --- /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. + */ + 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. + */ + 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. + */ + subscription_group_id: string + /** + * Whether to subscribe or unsubscribe the user. + */ + 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..e1a1eebb300 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts @@ -0,0 +1,242 @@ +import { ActionDefinition, PayloadValidationError, MultiStatusResponse } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { MAX_SUBSCRIPTION_ITEMS } from './constants' +import { resolveIdentifier, getSubscriptionEndpoint, getSingleUserEndpoint } from './functions' +import { fetchChannels, fetchMessageTypes, fetchLists } 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 }) => { + const index = dynamicFieldContext?.selectedArrayIndex ?? 0 + const groupType = payload?.subscriptions?.[index]?.subscription_group_type + + if (!groupType) { + return { + choices: [], + error: { + code: 'MISSING_GROUP_TYPE', + message: "Select a 'Subscription Group Type' first." + } + } + } + + switch (groupType) { + case 'messageChannel': + return fetchChannels(request, settings) + case 'messageType': + return fetchMessageTypes(request, settings) + case 'emailList': + return fetchLists(request, settings) + default: + return { + choices: [], + error: { + code: 'INVALID_GROUP_TYPE', + message: `Unknown subscription group type: ${groupType}` + } + } + } + } + } + }, + perform: async (request, { payload, settings }) => { + if (payload.subscriptions.length > MAX_SUBSCRIPTION_ITEMS) { + throw new PayloadValidationError(`Maximum of ${MAX_SUBSCRIPTION_ITEMS} subscription items allowed. Received ${payload.subscriptions.length}.`) + } + + const identifier = resolveIdentifier(payload) + + const results = await Promise.all( + payload.subscriptions.map(async (sub) => { + const endpoint = getSingleUserEndpoint(settings, sub.subscription_group_type, sub.subscription_group_id, identifier) + const method = sub.action === 'subscribe' ? 'patch' : 'delete' + return request(endpoint, { method }) + }) + ) + + return results[results.length - 1] + }, + + performBatch: async (request, { settings, payload: payloads }) => { + const multiStatusResponse = new MultiStatusResponse() + const validPayloads: { index: number; identifier: { email?: string; userId?: string } }[] = [] + + payloads.forEach((payload, index) => { + if (payload.subscriptions.length > MAX_SUBSCRIPTION_ITEMS) { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: `Maximum of ${MAX_SUBSCRIPTION_ITEMS} subscription items allowed. Received ${payload.subscriptions.length}.` + }) + 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 + }) + } + }) + + if (validPayloads.length === 0) { + return multiStatusResponse + } + + const subscriptions = payloads[0].subscriptions + + const users = validPayloads.filter((p) => p.identifier.email).map((p) => p.identifier.email!) + const usersByUserId = validPayloads.filter((p) => p.identifier.userId && !p.identifier.email).map((p) => p.identifier.userId!) + + const body: Record = {} + if (users.length > 0) body.users = users + if (usersByUserId.length > 0) body.usersByUserId = usersByUserId + + try { + await Promise.all( + subscriptions.map(async (sub) => { + const endpoint = getSubscriptionEndpoint(settings, sub.subscription_group_type, sub.subscription_group_id, sub.action) + return request(endpoint, { + method: 'put', + json: body + }) + }) + ) + + validPayloads.forEach(({ index }) => { + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: 200, + sent: payloads[index], + body: 'success' + }) + }) + } catch (error) { + validPayloads.forEach(({ index }) => { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: (error as any).status || 500, + errortype: 'API_ERROR', + errormessage: (error as Error).message + }) + }) + } + + return multiStatusResponse + } +} + +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..f99bbda8c61 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/types.ts @@ -0,0 +1,24 @@ +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 +} diff --git a/packages/destination-actions/src/destinations/iterable/utils.ts b/packages/destination-actions/src/destinations/iterable/utils.ts index 837144dda11..2107d7736fd 100644 --- a/packages/destination-actions/src/destinations/iterable/utils.ts +++ b/packages/destination-actions/src/destinations/iterable/utils.ts @@ -99,7 +99,10 @@ export const apiEndpoints = { 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' } /** From f00784b19e5fc82564e8c8719c1af88a9eb49c64 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 26 May 2026 15:02:05 +0100 Subject: [PATCH 071/210] refactoring --- .../updateSubscriptions/dynamic-fields.ts | 77 ++++++--- .../iterable/updateSubscriptions/functions.ts | 161 +++++++++++++++--- .../iterable/updateSubscriptions/index.ts | 122 +------------ .../iterable/updateSubscriptions/types.ts | 23 +++ .../src/destinations/iterable/utils.ts | 4 + 5 files changed, 225 insertions(+), 162 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/dynamic-fields.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/dynamic-fields.ts index 139ade16502..55f84613062 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/dynamic-fields.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/dynamic-fields.ts @@ -1,35 +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 { ChannelDetails, MessageTypeDetails, ListDetails } from './types' +import type { DynamicFieldContext, ChannelsResponse, MessageTypesResponse, ListsResponse } from './types' -export async function fetchChannels(request: RequestClient, settings: Settings): Promise { - const endpoint = getRegionalEndpoint('getChannels', settings.dataCenterLocation as DataCenterLocation) - const response = await request<{ channels: ChannelDetails[] }>(endpoint, { method: 'get' }) - const choices = response.data.channels.map((channel) => ({ - label: `${channel.name} (${channel.messageMedium} - ${channel.channelType})`, - value: String(channel.id) +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 } } -export async function fetchMessageTypes(request: RequestClient, settings: Settings): Promise { - const endpoint = getRegionalEndpoint('getMessageTypes', settings.dataCenterLocation as DataCenterLocation) - const response = await request<{ messageTypes: MessageTypeDetails[] }>(endpoint, { method: 'get' }) - const choices = response.data.messageTypes.map((mt) => ({ - label: `${mt.name} (${mt.subscriptionPolicy})`, - value: String(mt.id) +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 } } -export async function fetchLists(request: RequestClient, settings: Settings): Promise { - const endpoint = getRegionalEndpoint('getLists', settings.dataCenterLocation as DataCenterLocation) - const response = await request<{ lists: ListDetails[] }>(endpoint, { method: 'get' }) - const choices = response.data.lists.map((list) => ({ - label: `${list.name} (${list.listType})`, - value: String(list.id) +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 index 7e743137f5b..5586973ac94 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts @@ -1,39 +1,149 @@ -import { PayloadValidationError } from '@segment/actions-core' +import { PayloadValidationError, RequestClient, MultiStatusResponse, JSONLikeObject, HTTPError } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { DataCenterLocation } from '../shared-fields' -import type { ResolvedIdentifier } from './types' +import { getRegionalBaseUrl } from '../utils' +import { MAX_SUBSCRIPTION_ITEMS } 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 > 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 }) + }) + ) + + 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 > 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 + } + + const subscriptions = payloads[0].subscriptions + + 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 + }) + }) + ) + + 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: sentBody as unknown as JSONLikeObject + }) + }) + } + + return multiStatusResponse +} export function resolveIdentifier(payload: Payload): ResolvedIdentifier { - const { identifier, user_identifier_preference } = payload + const { identifier: { email, userId }, user_identifier_preference } = payload + const trimmedEmail = email?.trim() + const trimmedUserId = userId?.trim() - if (user_identifier_preference === 'userId' && identifier.userId) { - return { userId: identifier.userId } + if (user_identifier_preference === 'userId' && trimmedUserId) { + return { userId: trimmedUserId } } - if (user_identifier_preference === 'email' && identifier.email) { - return { email: identifier.email } + if (user_identifier_preference === 'email' && trimmedEmail) { + return { email: trimmedEmail } } - if (identifier.email) { - return { email: identifier.email } + if (trimmedEmail) { + return { email: trimmedEmail } } - if (identifier.userId) { - return { userId: identifier.userId } + if (trimmedUserId) { + return { userId: trimmedUserId } } throw new PayloadValidationError('Must include email or userId in identifier.') } -export function getSubscriptionEndpoint( +export function getBulkSubscriptionEndpoint( settings: Settings, groupType: string, groupId: string, action: string ): string { - const dataCenterLocation = (settings.dataCenterLocation as DataCenterLocation) || 'united_states' - const regionBaseUrls: Record = { - united_states: 'https://api.iterable.com', - europe: 'https://api.eu.iterable.com' - } - const baseUrl = regionBaseUrls[dataCenterLocation] || regionBaseUrls['united_states'] + const { dataCenterLocation } = settings + const baseUrl = getRegionalBaseUrl(dataCenterLocation as DataCenterLocation) return `${baseUrl}/api/subscriptions/${groupType}/${groupId}?action=${action}` } @@ -43,15 +153,12 @@ export function getSingleUserEndpoint( groupId: string, identifier: ResolvedIdentifier ): string { - const dataCenterLocation = (settings.dataCenterLocation as DataCenterLocation) || 'united_states' - const regionBaseUrls: Record = { - united_states: 'https://api.iterable.com', - europe: 'https://api.eu.iterable.com' - } - const baseUrl = regionBaseUrls[dataCenterLocation] || regionBaseUrls['united_states'] + const { dataCenterLocation } = settings + const baseUrl = getRegionalBaseUrl(dataCenterLocation as DataCenterLocation) + const { email, userId } = identifier - if (identifier.userId) { - return `${baseUrl}/api/subscriptions/${groupType}/${groupId}/byUserId/${encodeURIComponent(identifier.userId)}` + if (userId) { + return `${baseUrl}/api/subscriptions/${groupType}/${groupId}/byUserId/${encodeURIComponent(userId)}` } - return `${baseUrl}/api/subscriptions/${groupType}/${groupId}/user/${encodeURIComponent(identifier.email!)}` + return `${baseUrl}/api/subscriptions/${groupType}/${groupId}/user/${encodeURIComponent(email as string)}` } diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts index e1a1eebb300..b6dced0ae03 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts @@ -1,9 +1,8 @@ -import { ActionDefinition, PayloadValidationError, MultiStatusResponse } from '@segment/actions-core' +import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { MAX_SUBSCRIPTION_ITEMS } from './constants' -import { resolveIdentifier, getSubscriptionEndpoint, getSingleUserEndpoint } from './functions' -import { fetchChannels, fetchMessageTypes, fetchLists } from './dynamic-fields' +import { performUpdateSubscriptions, performBatchUpdateSubscriptions } from './functions' +import { getSubscriptionGroupId } from './dynamic-fields' const action: ActionDefinition = { title: 'Update Subscriptions', @@ -118,124 +117,15 @@ const action: ActionDefinition = { dynamicFields: { subscriptions: { subscription_group_id: async (request, { payload, dynamicFieldContext, settings }) => { - const index = dynamicFieldContext?.selectedArrayIndex ?? 0 - const groupType = payload?.subscriptions?.[index]?.subscription_group_type - - if (!groupType) { - return { - choices: [], - error: { - code: 'MISSING_GROUP_TYPE', - message: "Select a 'Subscription Group Type' first." - } - } - } - - switch (groupType) { - case 'messageChannel': - return fetchChannels(request, settings) - case 'messageType': - return fetchMessageTypes(request, settings) - case 'emailList': - return fetchLists(request, settings) - default: - return { - choices: [], - error: { - code: 'INVALID_GROUP_TYPE', - message: `Unknown subscription group type: ${groupType}` - } - } - } + return getSubscriptionGroupId(request, { payload, dynamicFieldContext, settings }) } } }, perform: async (request, { payload, settings }) => { - if (payload.subscriptions.length > MAX_SUBSCRIPTION_ITEMS) { - throw new PayloadValidationError(`Maximum of ${MAX_SUBSCRIPTION_ITEMS} subscription items allowed. Received ${payload.subscriptions.length}.`) - } - - const identifier = resolveIdentifier(payload) - - const results = await Promise.all( - payload.subscriptions.map(async (sub) => { - const endpoint = getSingleUserEndpoint(settings, sub.subscription_group_type, sub.subscription_group_id, identifier) - const method = sub.action === 'subscribe' ? 'patch' : 'delete' - return request(endpoint, { method }) - }) - ) - - return results[results.length - 1] + return performUpdateSubscriptions(request, payload, settings) }, - performBatch: async (request, { settings, payload: payloads }) => { - const multiStatusResponse = new MultiStatusResponse() - const validPayloads: { index: number; identifier: { email?: string; userId?: string } }[] = [] - - payloads.forEach((payload, index) => { - if (payload.subscriptions.length > MAX_SUBSCRIPTION_ITEMS) { - multiStatusResponse.setErrorResponseAtIndex(index, { - status: 400, - errortype: 'PAYLOAD_VALIDATION_FAILED', - errormessage: `Maximum of ${MAX_SUBSCRIPTION_ITEMS} subscription items allowed. Received ${payload.subscriptions.length}.` - }) - 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 - }) - } - }) - - if (validPayloads.length === 0) { - return multiStatusResponse - } - - const subscriptions = payloads[0].subscriptions - - const users = validPayloads.filter((p) => p.identifier.email).map((p) => p.identifier.email!) - const usersByUserId = validPayloads.filter((p) => p.identifier.userId && !p.identifier.email).map((p) => p.identifier.userId!) - - const body: Record = {} - if (users.length > 0) body.users = users - if (usersByUserId.length > 0) body.usersByUserId = usersByUserId - - try { - await Promise.all( - subscriptions.map(async (sub) => { - const endpoint = getSubscriptionEndpoint(settings, sub.subscription_group_type, sub.subscription_group_id, sub.action) - return request(endpoint, { - method: 'put', - json: body - }) - }) - ) - - validPayloads.forEach(({ index }) => { - multiStatusResponse.setSuccessResponseAtIndex(index, { - status: 200, - sent: payloads[index], - body: 'success' - }) - }) - } catch (error) { - validPayloads.forEach(({ index }) => { - multiStatusResponse.setErrorResponseAtIndex(index, { - status: (error as any).status || 500, - errortype: 'API_ERROR', - errormessage: (error as Error).message - }) - }) - } - - return multiStatusResponse + return performBatchUpdateSubscriptions(request, payloads, settings) } } diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/types.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/types.ts index f99bbda8c61..a7cdf945ba7 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/types.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/types.ts @@ -22,3 +22,26 @@ 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/utils.ts b/packages/destination-actions/src/destinations/iterable/utils.ts index 2107d7736fd..1d7ad0a35d3 100644 --- a/packages/destination-actions/src/destinations/iterable/utils.ts +++ b/packages/destination-actions/src/destinations/iterable/utils.ts @@ -113,6 +113,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' From 429de79f78e6cb825e3b8244d1bbbe2f3395ee9f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 26 May 2026 15:17:53 +0100 Subject: [PATCH 072/210] refactor --- .../iterable/updateSubscriptions/constants.ts | 2 ++ .../iterable/updateSubscriptions/functions.ts | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts index 6eecec32c0c..efb75dffb63 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts @@ -1 +1,3 @@ export const MAX_SUBSCRIPTION_ITEMS = 6 +export const VALID_ACTIONS = ['subscribe', 'unsubscribe'] as const +export const MIN_REQUEST_TIMEOUT = 30_000 diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts index 5586973ac94..249abca52d9 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts @@ -1,9 +1,9 @@ -import { PayloadValidationError, RequestClient, MultiStatusResponse, JSONLikeObject, HTTPError } from '@segment/actions-core' +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 } from './constants' +import { MAX_SUBSCRIPTION_ITEMS, VALID_ACTIONS, MIN_REQUEST_TIMEOUT } from './constants' import type { ResolvedIdentifier, BulkSubscriptionRequestBody } from './types' export async function performUpdateSubscriptions(request: RequestClient, payload: Payload, settings: Settings) { @@ -18,9 +18,10 @@ export async function performUpdateSubscriptions(request: RequestClient, payload const results = await Promise.all( subscriptions.map(async ({ subscription_group_type, subscription_group_id, action }) => { + validateAction(action) const endpoint = getSingleUserEndpoint(settings, subscription_group_type, subscription_group_id, identifier) const method = action === 'subscribe' ? 'patch' : 'delete' - return request(endpoint, { method }) + return request(endpoint, { method, timeout: Math.max(MIN_REQUEST_TIMEOUT, DEFAULT_REQUEST_TIMEOUT) }) }) ) @@ -62,7 +63,7 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa return multiStatusResponse } - const subscriptions = payloads[0].subscriptions + const subscriptions = payloads[validPayloads[0].index].subscriptions 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) @@ -75,10 +76,12 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa try { await Promise.all( subscriptions.map(async ({ subscription_group_type, subscription_group_id, action }) => { + validateAction(action) const endpoint = getBulkSubscriptionEndpoint(settings, subscription_group_type, subscription_group_id, action) return request(endpoint, { method: 'put', - json: body + json: body, + timeout: Math.max(MIN_REQUEST_TIMEOUT, DEFAULT_REQUEST_TIMEOUT) }) }) ) @@ -116,6 +119,12 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa return multiStatusResponse } +function validateAction(action: string): void { + if (!VALID_ACTIONS.includes(action as typeof VALID_ACTIONS[number])) { + throw new PayloadValidationError(`Invalid action: '${action}'. Must be 'subscribe' or 'unsubscribe'.`) + } +} + export function resolveIdentifier(payload: Payload): ResolvedIdentifier { const { identifier: { email, userId }, user_identifier_preference } = payload const trimmedEmail = email?.trim() From e4c76676f705dda822936e9d7e65cc5c307ee5bf Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 26 May 2026 15:27:57 +0100 Subject: [PATCH 073/210] Copilot feedback --- .../__tests__/index.test.ts | 24 +++++++++++++++++-- .../iterable/updateSubscriptions/functions.ts | 17 ++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) 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 index d613a8894e5..da7bbbd5cef 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts @@ -446,7 +446,17 @@ describe('Iterable.updateSubscriptions', () => { } }) - expect(response[0].status).toBe(200) + 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 () => { @@ -472,7 +482,17 @@ describe('Iterable.updateSubscriptions', () => { mapping: defaultMapping }) - expect(response[0].status).toBe(400) + 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' + }) }) }) }) diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts index 249abca52d9..89ba18781ea 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts @@ -10,6 +10,10 @@ export async function performUpdateSubscriptions(request: RequestClient, payload 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}.`) } @@ -36,6 +40,16 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa 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, @@ -111,7 +125,8 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa status, errortype: 'UNKNOWN_ERROR', errormessage, - sent: sentBody as unknown as JSONLikeObject + sent: payloads[index] as unknown as JSONLikeObject, + body: sentBody as unknown as JSONLikeObject }) }) } From c8f2beb6f381e889f1a6a74901db83f39347754c Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 27 May 2026 12:04:17 +0100 Subject: [PATCH 074/210] Copilot comment --- .../__tests__/index.test.ts | 43 +++++++++++++++++++ .../iterable/updateSubscriptions/functions.ts | 17 +++++++- 2 files changed, 59 insertions(+), 1 deletion(-) 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 index da7bbbd5cef..bd0a7842c6f 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts @@ -494,5 +494,48 @@ describe('Iterable.updateSubscriptions', () => { 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' } + }) + ] + + const response = 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') + }) }) }) diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts index 89ba18781ea..1dab6bfa064 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts @@ -79,6 +79,22 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa const subscriptions = payloads[validPayloads[0].index].subscriptions + for (const { action } of subscriptions) { + try { + validateAction(action) + } catch (error) { + validPayloads.forEach(({ index }) => { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: (error as Error).message, + sent: payloads[index] as unknown as JSONLikeObject + }) + }) + return multiStatusResponse + } + } + 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) @@ -90,7 +106,6 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa try { await Promise.all( subscriptions.map(async ({ subscription_group_type, subscription_group_id, action }) => { - validateAction(action) const endpoint = getBulkSubscriptionEndpoint(settings, subscription_group_type, subscription_group_id, action) return request(endpoint, { method: 'put', From 631a87603b63b2ae4af471afbddee1c6da859bff Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 27 May 2026 13:36:45 +0100 Subject: [PATCH 075/210] STRATCONN-6833 - [Customerio] - Multistatus response --- .../src/destinations/customerio/types.ts | 11 +++ .../src/destinations/customerio/utils.ts | 69 +++++++++++++++++-- 2 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 packages/destination-actions/src/destinations/customerio/types.ts 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..eb3782cac36 --- /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 +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/customerio/utils.ts b/packages/destination-actions/src/destinations/customerio/utils.ts index 94718c56a00..6eca2e945f8 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,38 @@ export const sendSingle = (request: Function, optio }) } +export const parseResponse = ( + response: ModifiedResponse, + payloads: Payload[], + batch: Record[] +): MultiStatusResponse => { + const multiStatusResponse = new MultiStatusResponse() + const errorsByIndex = new Set() + + const errors = response.data?.errors ?? [] + for (const error of errors) { + if (error.batch_index != null) { + errorsByIndex.add(error.batch_index) + multiStatusResponse.setErrorResponseAtIndex(error.batch_index, { + status: response.status, + 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 (!errorsByIndex.has(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 } From a2a719a033cfe11302426ab34e1e4dfdccfb845f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 27 May 2026 13:54:36 +0100 Subject: [PATCH 076/210] tests --- .../customerio/__tests__/utils.test.ts | 397 +++++++++++++++++- .../src/destinations/customerio/utils.ts | 4 +- 2 files changed, 397 insertions(+), 4 deletions(-) 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..9d7bd712923 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,402 @@ 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: 207, + errormessage: 'Attribute value too long', + errortype: 'MULTI_STATUS', + 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: 200, + errormessage: 'Name is required', + errortype: 'OK', + 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()).toMatchObject({ + status: 400, + errortype: 'BAD_REQUEST', + body: { person_id: 'user-1', name: 'First' } + }) + expect(response!.getResponseAtIndex(1).value()).toMatchObject({ + status: 400, + errortype: 'BAD_REQUEST', + body: { person_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()).toMatchObject({ + status: 429, + errortype: 'TOO_MANY_REQUESTS' + }) + }) + + 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()).toMatchObject({ + status: 500, + errortype: 'INTERNAL_SERVER_ERROR' + }) + }) + + 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()).toMatchObject({ + status: 401, + errortype: 'UNAUTHORIZED', + body: { person_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()).toMatchObject({ + status: 500, + errormessage: 'Network failure', + errortype: 'INTERNAL_SERVER_ERROR' + }) + }) +}) + +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: 207, + errormessage: 'Name is required', + errortype: 'MULTI_STATUS', + 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({ + 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/utils.ts b/packages/destination-actions/src/destinations/customerio/utils.ts index 6eca2e945f8..28e8ba8911d 100644 --- a/packages/destination-actions/src/destinations/customerio/utils.ts +++ b/packages/destination-actions/src/destinations/customerio/utils.ts @@ -250,12 +250,10 @@ export const parseResponse = ( batch: Record[] ): MultiStatusResponse => { const multiStatusResponse = new MultiStatusResponse() - const errorsByIndex = new Set() const errors = response.data?.errors ?? [] for (const error of errors) { if (error.batch_index != null) { - errorsByIndex.add(error.batch_index) multiStatusResponse.setErrorResponseAtIndex(error.batch_index, { status: response.status, errormessage: error.message || error.reason || 'Unknown error', @@ -266,7 +264,7 @@ export const parseResponse = ( } for (let i = 0; i < payloads.length; i++) { - if (!errorsByIndex.has(i)) { + if (!multiStatusResponse.isErrorResponseAtIndex(i)) { multiStatusResponse.setSuccessResponseAtIndex(i, { status: response.status, sent: batch[i] as unknown as JSONLikeObject, From 5f1b8ba4affd9aae7cc23db0c6c5623f9c5a39bd Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 27 May 2026 14:03:40 +0100 Subject: [PATCH 077/210] fixing tests --- .../destinations/customerio/__tests__/utils.test.ts | 13 +++++++------ .../src/destinations/customerio/utils.ts | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) 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 9d7bd712923..c2ce697251d 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts @@ -119,9 +119,9 @@ describe('sendBatch', () => { sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } }) expect(response!.getResponseAtIndex(1).value()).toEqual({ - status: 207, + status: 400, errormessage: 'Attribute value too long', - errortype: 'MULTI_STATUS', + errortype: 'BAD_REQUEST', body: { person_id: 'user-2', name: 'Second' }, sent: { type: 'person', action: 'event', identifiers: { id: 'user-2' }, name: 'Second' } }) @@ -153,9 +153,9 @@ describe('sendBatch', () => { expect(response).toBeInstanceOf(MultiStatusResponse) expect(response!.getResponseAtIndex(0).value()).toEqual({ - status: 200, + status: 400, errormessage: 'Name is required', - errortype: 'OK', + errortype: 'BAD_REQUEST', body: { person_id: 'user-1', name: 'First' }, sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } }) @@ -358,9 +358,9 @@ describe('parseResponse', () => { sent: { type: 'person', action: 'event', identifiers: { id: 'user-0' } } }, { - status: 207, + status: 400, errormessage: 'Name is required', - errortype: 'MULTI_STATUS', + errortype: 'BAD_REQUEST', body: { person_id: 'user-1' }, sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' } } }, @@ -388,6 +388,7 @@ describe('parseResponse', () => { ) expect(response.getResponseAtIndex(0).value()).toMatchObject({ + status: 400, errormessage: 'invalid' }) }) diff --git a/packages/destination-actions/src/destinations/customerio/utils.ts b/packages/destination-actions/src/destinations/customerio/utils.ts index 28e8ba8911d..6143a9ffb87 100644 --- a/packages/destination-actions/src/destinations/customerio/utils.ts +++ b/packages/destination-actions/src/destinations/customerio/utils.ts @@ -252,10 +252,11 @@ export const parseResponse = ( 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: response.status, + 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 From 08a95b6378813851d36d6dbda60319d661c9e7a1 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 27 May 2026 14:08:57 +0100 Subject: [PATCH 078/210] correcting test --- .../destinations/customerio/__tests__/utils.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 c2ce697251d..19c6f831fc6 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts @@ -224,15 +224,19 @@ describe('sendBatch', () => { expect(response).toBeInstanceOf(MultiStatusResponse) expect(response!.length()).toBe(2) - expect(response!.getResponseAtIndex(0).value()).toMatchObject({ + expect(response!.getResponseAtIndex(0).value()).toEqual({ status: 400, + errormessage: 'Bad Request', errortype: 'BAD_REQUEST', - body: { person_id: 'user-1', name: 'First' } + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } }) - expect(response!.getResponseAtIndex(1).value()).toMatchObject({ + expect(response!.getResponseAtIndex(1).value()).toEqual({ status: 400, + errormessage: 'Bad Request', errortype: 'BAD_REQUEST', - body: { person_id: 'user-2', name: 'Second' } + body: { person_id: 'user-2', name: 'Second' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-2' }, name: 'Second' } }) }) From 0e9aa0e72e90b47990e53f3ae1ed12383cfc4499 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 27 May 2026 14:20:21 +0100 Subject: [PATCH 079/210] copilot review updates --- .../customerio/__tests__/utils.test.ts | 26 +++++++++++++------ .../src/destinations/customerio/types.ts | 2 +- .../src/destinations/customerio/utils.ts | 2 +- 3 files changed, 20 insertions(+), 10 deletions(-) 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 19c6f831fc6..9354705239e 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts @@ -258,9 +258,12 @@ describe('sendBatch', () => { ]) expect(response).toBeInstanceOf(MultiStatusResponse) - expect(response!.getResponseAtIndex(0).value()).toMatchObject({ + expect(response!.getResponseAtIndex(0).value()).toEqual({ status: 429, - errortype: 'TOO_MANY_REQUESTS' + 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' } }) }) @@ -282,9 +285,12 @@ describe('sendBatch', () => { ]) expect(response).toBeInstanceOf(MultiStatusResponse) - expect(response!.getResponseAtIndex(0).value()).toMatchObject({ + expect(response!.getResponseAtIndex(0).value()).toEqual({ status: 500, - errortype: 'INTERNAL_SERVER_ERROR' + 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' } }) }) @@ -306,10 +312,12 @@ describe('sendBatch', () => { ]) expect(response).toBeInstanceOf(MultiStatusResponse) - expect(response!.getResponseAtIndex(0).value()).toMatchObject({ + expect(response!.getResponseAtIndex(0).value()).toEqual({ status: 401, + errormessage: 'Unauthorized', errortype: 'UNAUTHORIZED', - body: { person_id: 'user-1', name: 'First' } + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } }) }) @@ -327,10 +335,12 @@ describe('sendBatch', () => { ]) expect(response).toBeInstanceOf(MultiStatusResponse) - expect(response!.getResponseAtIndex(0).value()).toMatchObject({ + expect(response!.getResponseAtIndex(0).value()).toEqual({ status: 500, errormessage: 'Network failure', - errortype: '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' } }) }) }) diff --git a/packages/destination-actions/src/destinations/customerio/types.ts b/packages/destination-actions/src/destinations/customerio/types.ts index eb3782cac36..17068bf9cc3 100644 --- a/packages/destination-actions/src/destinations/customerio/types.ts +++ b/packages/destination-actions/src/destinations/customerio/types.ts @@ -8,4 +8,4 @@ export type TrackApiError = { reason?: string field?: string message?: string -} \ No newline at end of file +} diff --git a/packages/destination-actions/src/destinations/customerio/utils.ts b/packages/destination-actions/src/destinations/customerio/utils.ts index 6143a9ffb87..e29af2e453b 100644 --- a/packages/destination-actions/src/destinations/customerio/utils.ts +++ b/packages/destination-actions/src/destinations/customerio/utils.ts @@ -199,7 +199,7 @@ export const resolveIdentifiers = ({ } } -export const sendBatch = async (request: Function, options: RequestPayload[]) => { +export const sendBatch = async (request: Function, options: RequestPayload[]) => { if (!options?.length) { return } From c04884410c4ca75f2458c2f3c5fd74c05197cf37 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 27 May 2026 14:45:17 +0100 Subject: [PATCH 080/210] update to createUpdateObject action --- .../__tests__/createUpdateObject.test.ts | 187 ++++++++++++++++++ .../customerio/createUpdateObject/index.ts | 49 +++-- 2 files changed, 223 insertions(+), 13 deletions(-) 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/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 }) => { From 3651dc40e848fceb7abd9e5561d4eb86ae598103 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 11:23:47 +0100 Subject: [PATCH 081/210] feat: add e2e test types and Iterable fixtures Introduce E2EFixture, E2EExpectation, and E2EDestinationConfig types in actions-core for strongly typed end-to-end destination testing. Add initial fixtures for Iterable trackEvent and updateUser actions as a proof of concept. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 113 ++++++++++++++++++ packages/core/src/index.ts | 12 ++ .../destinations/iterable/__e2e__/index.ts | 16 +++ .../trackEvent/__e2e__/missing-identifiers.ts | 26 ++++ .../iterable/trackEvent/__e2e__/success.ts | 30 +++++ .../iterable/updateUser/__e2e__/success.ts | 26 ++++ 6 files changed, 223 insertions(+) create mode 100644 packages/core/src/e2e-types.ts create mode 100644 packages/destination-actions/src/destinations/iterable/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/missing-identifiers.ts create mode 100644 packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/success.ts create mode 100644 packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/success.ts diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts new file mode 100644 index 00000000000..5ad55ceba78 --- /dev/null +++ b/packages/core/src/e2e-types.ts @@ -0,0 +1,113 @@ +import type { SegmentEvent } from './segment-event' +import type { JSONObject } 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?: HttpSuccessCode + bodyContains?: string +} + +/** + * 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: HttpFailureCode + bodyContains?: string +} + +/** + * 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 +} + +export interface E2EFixture { + /** 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 Segment event (track, identify, page, screen, etc.) sent into the action. */ + event: SegmentEvent + /** The expected outcome of executing this fixture. */ + expect: E2EExpectation +} + +export interface E2ESettingsSecretValue { + $env: string +} + +export interface E2EDestinationConfig { + settings: Record +} + +export type HttpSuccessCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 + +export type HttpFailureCode = + | 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 ce662378f41..af6c15a50fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -111,3 +111,15 @@ export { export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' + +export type { + E2EFixture, + E2EExpectation, + E2ESuccessExpectation, + E2EFailureExpectation, + E2EErrorExpectation, + E2EDestinationConfig, + E2ESettingsSecretValue, + HttpSuccessCode, + HttpFailureCode +} from './e2e-types' 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..860b8bd0d17 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts @@ -0,0 +1,16 @@ +import type { E2EDestinationConfig, E2EFixture } from '@segment/actions-core' +import trackEventSuccess from '../trackEvent/__e2e__/success' +import trackEventMissingIdentifiers from '../trackEvent/__e2e__/missing-identifiers' +import updateUserSuccess from '../updateUser/__e2e__/success' + +export const config: E2EDestinationConfig = { + settings: { + apiKey: { $env: 'E2E_ITERABLE_API_KEY' }, + dataCenterLocation: 'united_states' + } +} + +export const fixtures: Record = { + trackEvent: [trackEventSuccess, trackEventMissingIdentifiers], + updateUser: [updateUserSuccess] +} diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/missing-identifiers.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/missing-identifiers.ts new file mode 100644 index 00000000000..35162150ed9 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/missing-identifiers.ts @@ -0,0 +1,26 @@ +import type { E2EFixture } from '@segment/actions-core' + +const fixture: E2EFixture = { + description: 'Rejects event when both email and userId are missing', + subscribe: 'type = "track"', + mapping: { + eventName: { '@path': '$.event' }, + dataFields: { '@path': '$.properties' }, + createdAt: { '@path': '$.timestamp' } + }, + event: { + type: 'track', + event: 'Button Clicked', + timestamp: '2024-01-15T10:30:00.000Z', + properties: { + buttonId: 'cta-hero' + } + }, + expect: { + status: 'error', + errorType: 'PayloadValidationError', + errorMessage: 'Must include email or userId.' + } +} + +export default fixture diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/success.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/success.ts new file mode 100644 index 00000000000..46e4e7ee9a1 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/success.ts @@ -0,0 +1,30 @@ +import type { E2EFixture } from '@segment/actions-core' + +const fixture: E2EFixture = { + description: 'Successfully tracks a purchase event', + subscribe: 'type = "track"', + mapping: { + email: { '@path': '$.properties.email' }, + eventName: { '@path': '$.event' }, + dataFields: { '@path': '$.properties' }, + createdAt: { '@path': '$.timestamp' }, + id: { '@path': '$.messageId' } + }, + event: { + type: 'track', + event: 'Order Completed', + userId: 'e2e-test-user-001', + messageId: 'e2e-msg-001', + timestamp: '2024-01-15T10:30:00.000Z', + properties: { + email: 'e2e-test@segment.com', + orderId: 'test-order-123', + total: 49.99 + } + }, + expect: { + status: 'success' + } +} + +export default fixture diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/success.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/success.ts new file mode 100644 index 00000000000..8a18e1dd951 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/success.ts @@ -0,0 +1,26 @@ +import type { E2EFixture } from '@segment/actions-core' + +const fixture: E2EFixture = { + description: 'Successfully upserts a user with email and data fields', + subscribe: 'type = "identify"', + mapping: { + email: { '@path': '$.traits.email' }, + userId: { '@path': '$.userId' }, + dataFields: { '@path': '$.traits' } + }, + event: { + type: 'identify', + userId: 'e2e-test-user-001', + traits: { + email: 'e2e-test@segment.com', + firstName: 'E2E', + lastName: 'TestUser', + plan: 'premium' + } + }, + expect: { + status: 'success' + } +} + +export default fixture From 9cbda9c809dbe93786e8d853de41065cd6c6ccd8 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 11:25:22 +0100 Subject: [PATCH 082/210] refactor: prefix HTTP status code types with E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename HttpSuccessCode → E2EHttpSuccessCode and HttpFailureCode → E2EHttpFailureCode to namespace them clearly as e2e testing types. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 8 ++++---- packages/core/src/index.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 5ad55ceba78..b81a9a1956d 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -8,7 +8,7 @@ export type E2EExpectation = E2ESuccessExpectation | E2EFailureExpectation | E2E */ export interface E2ESuccessExpectation { status: 'success' - httpStatus?: HttpSuccessCode + httpStatus?: E2EHttpSuccessCode bodyContains?: string } @@ -18,7 +18,7 @@ export interface E2ESuccessExpectation { */ export interface E2EFailureExpectation { status: 'failure' - httpStatus: HttpFailureCode + httpStatus: E2EHttpFailureCode bodyContains?: string } @@ -54,9 +54,9 @@ export interface E2EDestinationConfig { settings: Record } -export type HttpSuccessCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 +export type E2EHttpSuccessCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 -export type HttpFailureCode = +export type E2EHttpFailureCode = | 300 | 301 | 302 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af6c15a50fa..0d652855c17 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -120,6 +120,6 @@ export type { E2EErrorExpectation, E2EDestinationConfig, E2ESettingsSecretValue, - HttpSuccessCode, - HttpFailureCode + E2EHttpSuccessCode, + E2EHttpFailureCode } from './e2e-types' From ae166aca9f77110b994cefde5ccc63cf19033a52 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 11:35:13 +0100 Subject: [PATCH 083/210] refactor: consolidate e2e fixtures to one file per action Replace individual fixture files with a single fixtures.ts per action that exports an array of E2EFixture. Reduces boilerplate in the index. Co-Authored-By: Claude Opus 4.6 --- .../destinations/iterable/__e2e__/index.ts | 9 ++-- .../iterable/trackEvent/__e2e__/fixtures.ts | 54 +++++++++++++++++++ .../trackEvent/__e2e__/missing-identifiers.ts | 26 --------- .../iterable/trackEvent/__e2e__/success.ts | 30 ----------- .../iterable/updateUser/__e2e__/fixtures.ts | 28 ++++++++++ .../iterable/updateUser/__e2e__/success.ts | 26 --------- 6 files changed, 86 insertions(+), 87 deletions(-) create mode 100644 packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts delete mode 100644 packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/missing-identifiers.ts delete mode 100644 packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/success.ts create mode 100644 packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts delete mode 100644 packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/success.ts diff --git a/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts index 860b8bd0d17..8b3d2e39622 100644 --- a/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts @@ -1,7 +1,6 @@ import type { E2EDestinationConfig, E2EFixture } from '@segment/actions-core' -import trackEventSuccess from '../trackEvent/__e2e__/success' -import trackEventMissingIdentifiers from '../trackEvent/__e2e__/missing-identifiers' -import updateUserSuccess from '../updateUser/__e2e__/success' +import trackEventFixtures from '../trackEvent/__e2e__/fixtures' +import updateUserFixtures from '../updateUser/__e2e__/fixtures' export const config: E2EDestinationConfig = { settings: { @@ -11,6 +10,6 @@ export const config: E2EDestinationConfig = { } export const fixtures: Record = { - trackEvent: [trackEventSuccess, trackEventMissingIdentifiers], - updateUser: [updateUserSuccess] + trackEvent: trackEventFixtures, + updateUser: updateUserFixtures } diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts new file mode 100644 index 00000000000..0956957ca1a --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts @@ -0,0 +1,54 @@ +import type { E2EFixture } from '@segment/actions-core' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully tracks a purchase event', + subscribe: 'type = "track"', + mapping: { + email: { '@path': '$.properties.email' }, + eventName: { '@path': '$.event' }, + dataFields: { '@path': '$.properties' }, + createdAt: { '@path': '$.timestamp' }, + id: { '@path': '$.messageId' } + }, + event: { + type: 'track', + event: 'Order Completed', + userId: 'e2e-test-user-001', + messageId: 'e2e-msg-001', + timestamp: '2024-01-15T10:30:00.000Z', + properties: { + email: 'e2e-test@segment.com', + orderId: 'test-order-123', + total: 49.99 + } + }, + expect: { + status: 'success' + } + }, + { + description: 'Rejects event when both email and userId are missing', + subscribe: 'type = "track"', + mapping: { + eventName: { '@path': '$.event' }, + dataFields: { '@path': '$.properties' }, + createdAt: { '@path': '$.timestamp' } + }, + event: { + type: 'track', + event: 'Button Clicked', + timestamp: '2024-01-15T10:30:00.000Z', + 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/trackEvent/__e2e__/missing-identifiers.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/missing-identifiers.ts deleted file mode 100644 index 35162150ed9..00000000000 --- a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/missing-identifiers.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { E2EFixture } from '@segment/actions-core' - -const fixture: E2EFixture = { - description: 'Rejects event when both email and userId are missing', - subscribe: 'type = "track"', - mapping: { - eventName: { '@path': '$.event' }, - dataFields: { '@path': '$.properties' }, - createdAt: { '@path': '$.timestamp' } - }, - event: { - type: 'track', - event: 'Button Clicked', - timestamp: '2024-01-15T10:30:00.000Z', - properties: { - buttonId: 'cta-hero' - } - }, - expect: { - status: 'error', - errorType: 'PayloadValidationError', - errorMessage: 'Must include email or userId.' - } -} - -export default fixture diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/success.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/success.ts deleted file mode 100644 index 46e4e7ee9a1..00000000000 --- a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/success.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { E2EFixture } from '@segment/actions-core' - -const fixture: E2EFixture = { - description: 'Successfully tracks a purchase event', - subscribe: 'type = "track"', - mapping: { - email: { '@path': '$.properties.email' }, - eventName: { '@path': '$.event' }, - dataFields: { '@path': '$.properties' }, - createdAt: { '@path': '$.timestamp' }, - id: { '@path': '$.messageId' } - }, - event: { - type: 'track', - event: 'Order Completed', - userId: 'e2e-test-user-001', - messageId: 'e2e-msg-001', - timestamp: '2024-01-15T10:30:00.000Z', - properties: { - email: 'e2e-test@segment.com', - orderId: 'test-order-123', - total: 49.99 - } - }, - expect: { - status: 'success' - } -} - -export default fixture diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts new file mode 100644 index 00000000000..036f1f7475f --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts @@ -0,0 +1,28 @@ +import type { E2EFixture } from '@segment/actions-core' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully upserts a user with email and data fields', + subscribe: 'type = "identify"', + mapping: { + email: { '@path': '$.traits.email' }, + userId: { '@path': '$.userId' }, + dataFields: { '@path': '$.traits' } + }, + event: { + type: 'identify', + userId: 'e2e-test-user-001', + traits: { + email: 'e2e-test@segment.com', + firstName: 'E2E', + lastName: 'TestUser', + plan: 'premium' + } + }, + expect: { + status: 'success' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/success.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/success.ts deleted file mode 100644 index 8a18e1dd951..00000000000 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/success.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { E2EFixture } from '@segment/actions-core' - -const fixture: E2EFixture = { - description: 'Successfully upserts a user with email and data fields', - subscribe: 'type = "identify"', - mapping: { - email: { '@path': '$.traits.email' }, - userId: { '@path': '$.userId' }, - dataFields: { '@path': '$.traits' } - }, - event: { - type: 'identify', - userId: 'e2e-test-user-001', - traits: { - email: 'e2e-test@segment.com', - firstName: 'E2E', - lastName: 'TestUser', - plan: 'premium' - } - }, - expect: { - status: 'success' - } -} - -export default fixture From 6f472fe04264d1bdd5f414a718d150e268a62204 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 11:37:54 +0100 Subject: [PATCH 084/210] refactor: remove fixture registry from e2e index Runner will discover fixtures via filesystem glob instead of explicit imports. Index now only exports destination config. Co-Authored-By: Claude Opus 4.6 --- .../src/destinations/iterable/__e2e__/index.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts index 8b3d2e39622..1ea0b08efa2 100644 --- a/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts @@ -1,6 +1,4 @@ -import type { E2EDestinationConfig, E2EFixture } from '@segment/actions-core' -import trackEventFixtures from '../trackEvent/__e2e__/fixtures' -import updateUserFixtures from '../updateUser/__e2e__/fixtures' +import type { E2EDestinationConfig } from '@segment/actions-core' export const config: E2EDestinationConfig = { settings: { @@ -8,8 +6,3 @@ export const config: E2EDestinationConfig = { dataCenterLocation: 'united_states' } } - -export const fixtures: Record = { - trackEvent: trackEventFixtures, - updateUser: updateUserFixtures -} From 36f6d0c584346da9b60f58542a7604d99c191ae7 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 11:45:43 +0100 Subject: [PATCH 085/210] feat: add dynamic value markers ($now, $guid) for e2e fixtures Introduce E2EDynamicValue type documenting runner-resolved markers. Update Iterable fixtures to use $now for timestamps, $guid for messageIds, and $guid:orderId for consistent order ID generation. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 16 +++++++++++++++- packages/core/src/index.ts | 1 + .../iterable/trackEvent/__e2e__/fixtures.ts | 9 +++++---- .../iterable/updateUser/__e2e__/fixtures.ts | 2 ++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index b81a9a1956d..1f62bf73503 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -33,6 +33,16 @@ export interface E2EErrorExpectation { 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. + */ +export type E2EDynamicValue = '$now' | `$guid` | `$guid:${string}` + export interface E2EFixture { /** Human-readable name for the test case, shown in runner output. */ description: string @@ -40,7 +50,11 @@ export interface E2EFixture { subscribe: string /** Mapping kit directives that transform the event into the action's payload shape. */ mapping: JSONObject - /** The Segment event (track, identify, page, screen, etc.) sent into the action. */ + /** + * 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 /** The expected outcome of executing this fixture. */ expect: E2EExpectation diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0d652855c17..df865e4999a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -120,6 +120,7 @@ export type { E2EErrorExpectation, E2EDestinationConfig, E2ESettingsSecretValue, + E2EDynamicValue, E2EHttpSuccessCode, E2EHttpFailureCode } from './e2e-types' diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts index 0956957ca1a..83c768733f6 100644 --- a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts @@ -15,11 +15,11 @@ const fixtures: E2EFixture[] = [ type: 'track', event: 'Order Completed', userId: 'e2e-test-user-001', - messageId: 'e2e-msg-001', - timestamp: '2024-01-15T10:30:00.000Z', + messageId: '$guid', + timestamp: '$now', properties: { email: 'e2e-test@segment.com', - orderId: 'test-order-123', + orderId: '$guid:orderId', total: 49.99 } }, @@ -38,7 +38,8 @@ const fixtures: E2EFixture[] = [ event: { type: 'track', event: 'Button Clicked', - timestamp: '2024-01-15T10:30:00.000Z', + messageId: '$guid', + timestamp: '$now', properties: { buttonId: 'cta-hero' } diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts index 036f1f7475f..e3864ed6307 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts @@ -11,6 +11,8 @@ const fixtures: E2EFixture[] = [ }, event: { type: 'identify', + messageId: '$guid', + timestamp: '$now', userId: 'e2e-test-user-001', traits: { email: 'e2e-test@segment.com', From d9657c307292aa4968b83e60d67950868e287671 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 11:52:02 +0100 Subject: [PATCH 086/210] refactor: use defaultValues() for e2e fixture mappings Derive mappings from action field definitions instead of hand-writing them. Tests now stay in sync with field changes automatically. Co-Authored-By: Claude Opus 4.6 --- .../iterable/trackEvent/__e2e__/fixtures.ts | 16 ++++++---------- .../iterable/updateUser/__e2e__/fixtures.ts | 8 +++----- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts index 83c768733f6..32440a7154a 100644 --- a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts @@ -1,16 +1,12 @@ import type { E2EFixture } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import trackEvent from '../index' const fixtures: E2EFixture[] = [ { description: 'Successfully tracks a purchase event', subscribe: 'type = "track"', - mapping: { - email: { '@path': '$.properties.email' }, - eventName: { '@path': '$.event' }, - dataFields: { '@path': '$.properties' }, - createdAt: { '@path': '$.timestamp' }, - id: { '@path': '$.messageId' } - }, + mapping: defaultValues(trackEvent.fields), event: { type: 'track', event: 'Order Completed', @@ -31,9 +27,9 @@ const fixtures: E2EFixture[] = [ description: 'Rejects event when both email and userId are missing', subscribe: 'type = "track"', mapping: { - eventName: { '@path': '$.event' }, - dataFields: { '@path': '$.properties' }, - createdAt: { '@path': '$.timestamp' } + ...defaultValues(trackEvent.fields), + email: undefined, + userId: undefined }, event: { type: 'track', diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts index e3864ed6307..d3bd0801f7c 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts @@ -1,14 +1,12 @@ import type { E2EFixture } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import updateUser from '../index' const fixtures: E2EFixture[] = [ { description: 'Successfully upserts a user with email and data fields', subscribe: 'type = "identify"', - mapping: { - email: { '@path': '$.traits.email' }, - userId: { '@path': '$.userId' }, - dataFields: { '@path': '$.traits' } - }, + mapping: defaultValues(updateUser.fields), event: { type: 'identify', messageId: '$guid', From 43b85044821a227bae746355f77d7ed9406cd563 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 11:57:40 +0100 Subject: [PATCH 087/210] feat: add createE2EEvent helper for fixture event generation Auto-populates messageId ($guid) and timestamp ($now). Fixture authors only provide type, event name, and test-specific overrides. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 14 ++++++++++++++ packages/core/src/index.ts | 1 + .../iterable/trackEvent/__e2e__/fixtures.ts | 18 +++++------------- .../iterable/updateUser/__e2e__/fixtures.ts | 9 +++------ 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 1f62bf73503..3cafaf1008e 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -60,6 +60,20 @@ export interface E2EFixture { expect: E2EExpectation } +export function createE2EEvent( + type: SegmentEvent['type'], + name: string, + overrides?: Partial> +): SegmentEvent { + return { + type, + event: name, + messageId: '$guid', + timestamp: '$now', + ...overrides + } +} + export interface E2ESettingsSecretValue { $env: string } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df865e4999a..a8e6a0ce935 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,6 +112,7 @@ export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' +export { createE2EEvent } from './e2e-types' export type { E2EFixture, E2EExpectation, diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts index 32440a7154a..fdcddc5be65 100644 --- a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts @@ -1,5 +1,5 @@ import type { E2EFixture } from '@segment/actions-core' -import { defaultValues } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' import trackEvent from '../index' const fixtures: E2EFixture[] = [ @@ -7,18 +7,14 @@ const fixtures: E2EFixture[] = [ description: 'Successfully tracks a purchase event', subscribe: 'type = "track"', mapping: defaultValues(trackEvent.fields), - event: { - type: 'track', - event: 'Order Completed', + event: createE2EEvent('track', 'Order Completed', { userId: 'e2e-test-user-001', - messageId: '$guid', - timestamp: '$now', properties: { email: 'e2e-test@segment.com', orderId: '$guid:orderId', total: 49.99 } - }, + }), expect: { status: 'success' } @@ -31,15 +27,11 @@ const fixtures: E2EFixture[] = [ email: undefined, userId: undefined }, - event: { - type: 'track', - event: 'Button Clicked', - messageId: '$guid', - timestamp: '$now', + event: createE2EEvent('track', 'Button Clicked', { properties: { buttonId: 'cta-hero' } - }, + }), expect: { status: 'error', errorType: 'PayloadValidationError', diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts index d3bd0801f7c..c15552ccc50 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts @@ -1,5 +1,5 @@ import type { E2EFixture } from '@segment/actions-core' -import { defaultValues } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' import updateUser from '../index' const fixtures: E2EFixture[] = [ @@ -7,10 +7,7 @@ const fixtures: E2EFixture[] = [ description: 'Successfully upserts a user with email and data fields', subscribe: 'type = "identify"', mapping: defaultValues(updateUser.fields), - event: { - type: 'identify', - messageId: '$guid', - timestamp: '$now', + event: createE2EEvent('identify', 'Identify', { userId: 'e2e-test-user-001', traits: { email: 'e2e-test@segment.com', @@ -18,7 +15,7 @@ const fixtures: E2EFixture[] = [ lastName: 'TestUser', plan: 'premium' } - }, + }), expect: { status: 'success' } From 729b9571349782058240188e1afb710149f96d6b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 12:01:36 +0100 Subject: [PATCH 088/210] refactor: separate e2e types from helper functions Move createE2EEvent to e2e-helpers.ts, keeping e2e-types.ts as pure type definitions. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-helpers.ts | 15 +++++++++++++++ packages/core/src/e2e-types.ts | 14 -------------- packages/core/src/index.ts | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/e2e-helpers.ts diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts new file mode 100644 index 00000000000..d78754eb99b --- /dev/null +++ b/packages/core/src/e2e-helpers.ts @@ -0,0 +1,15 @@ +import type { SegmentEvent } from './segment-event' + +export function createE2EEvent( + type: SegmentEvent['type'], + name: string, + overrides?: Partial> +): SegmentEvent { + return { + type, + event: name, + messageId: '$guid', + timestamp: '$now', + ...overrides + } +} diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 3cafaf1008e..1f62bf73503 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -60,20 +60,6 @@ export interface E2EFixture { expect: E2EExpectation } -export function createE2EEvent( - type: SegmentEvent['type'], - name: string, - overrides?: Partial> -): SegmentEvent { - return { - type, - event: name, - messageId: '$guid', - timestamp: '$now', - ...overrides - } -} - export interface E2ESettingsSecretValue { $env: string } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a8e6a0ce935..2d91b044548 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,7 +112,7 @@ export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' -export { createE2EEvent } from './e2e-types' +export { createE2EEvent } from './e2e-helpers' export type { E2EFixture, E2EExpectation, From 79e483e5a87a9d5450358716eee31bb5e190373a Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 15:49:05 +0100 Subject: [PATCH 089/210] fix: omit email/userId from mapping instead of setting undefined JSONObject doesn't accept undefined values. Use destructuring to exclude the keys entirely. Co-Authored-By: Claude Opus 4.6 --- .../destinations/iterable/trackEvent/__e2e__/fixtures.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts index fdcddc5be65..dcc56cdaccd 100644 --- a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts @@ -22,11 +22,10 @@ const fixtures: E2EFixture[] = [ { description: 'Rejects event when both email and userId are missing', subscribe: 'type = "track"', - mapping: { - ...defaultValues(trackEvent.fields), - email: undefined, - userId: undefined - }, + mapping: (() => { + const { email, userId, ...rest } = defaultValues(trackEvent.fields) + return rest + })(), event: createE2EEvent('track', 'Button Clicked', { properties: { buttonId: 'cta-hero' From e28b1e4159687d2b2a3b74d802b058bedadae2c7 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 28 May 2026 16:07:45 +0100 Subject: [PATCH 090/210] feat: add e2e fixtures for Amplitude logEventV2 and identifyUser Co-Authored-By: Claude Opus 4.6 --- .../destinations/amplitude/__e2e__/index.ts | 9 ++++ .../identifyUser/__e2e__/fixtures.ts | 24 +++++++++++ .../amplitude/logEventV2/__e2e__/fixtures.ts | 41 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 packages/destination-actions/src/destinations/amplitude/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.ts create mode 100644 packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.ts 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.ts b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.ts new file mode 100644 index 00000000000..01dc599dcaf --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.ts @@ -0,0 +1,24 @@ +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), + event: createE2EEvent('identify', 'Identify', { + 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.ts b/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.ts new file mode 100644 index 00000000000..1a439354cf3 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.ts @@ -0,0 +1,41 @@ +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), + 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), + 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 From f17604c41ab07977536cea32b7d50a4c51e3ff95 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 09:37:45 +0100 Subject: [PATCH 091/210] feat: add createE2EEngageAudienceEvent helper Generates fully-formed Engage audience event payloads for e2e testing. Handles context.personas structure, membership booleans, email placement, audienceFields, and enrichedTraits for both track and identify types. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-helpers.ts | 77 ++++++++++++++++++++++++++++++++ packages/core/src/e2e-types.ts | 13 ++++++ packages/core/src/index.ts | 3 +- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index d78754eb99b..9784a8b1d49 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -1,4 +1,6 @@ import type { SegmentEvent } from './segment-event' +import type { JSONValue } from './json-object' +import type { E2EEngageAudienceEventOptions } from './e2e-types' export function createE2EEvent( type: SegmentEvent['type'], @@ -13,3 +15,78 @@ export function createE2EEvent( ...overrides } } + +export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOptions): SegmentEvent { + const { + type, + action, + computationKey, + computationId, + externalAudienceId, + userId, + anonymousId, + email, + audienceFields, + enrichedTraits + } = options + + const membership = action === 'add' + + const personas: Record = { + computation_class: 'audience', + computation_key: computationKey, + computation_id: computationId + } + + if (externalAudienceId) { + personas.external_audience_id = externalAudienceId + } + + const context: Record = { personas } + + if (audienceFields) { + context.audienceFields = audienceFields + } + + if (type === 'track') { + const properties: { [k: string]: JSONValue } = { + [computationKey]: membership, + ...(enrichedTraits as { [k: string]: JSONValue }) + } + + if (email) { + properties.email = email + context.traits = { email } + } + + return { + type: 'track', + event: computationKey, + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + context, + properties + } + } + + const traits: { [k: string]: JSONValue } = { + [computationKey]: membership, + ...(enrichedTraits as { [k: string]: JSONValue }) + } + + if (email) { + traits.email = email + } + + return { + type: 'identify', + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + context, + traits + } +} diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 1f62bf73503..1aa0ebc1461 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -60,6 +60,19 @@ export interface E2EFixture { expect: E2EExpectation } +export interface E2EEngageAudienceEventOptions { + type: 'track' | 'identify' + action: 'add' | 'remove' + computationKey: string + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + export interface E2ESettingsSecretValue { $env: string } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2d91b044548..e3dbcc5bd29 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,7 +112,7 @@ export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' -export { createE2EEvent } from './e2e-helpers' +export { createE2EEvent, createE2EEngageAudienceEvent } from './e2e-helpers' export type { E2EFixture, E2EExpectation, @@ -122,6 +122,7 @@ export type { E2EDestinationConfig, E2ESettingsSecretValue, E2EDynamicValue, + E2EEngageAudienceEventOptions, E2EHttpSuccessCode, E2EHttpFailureCode } from './e2e-types' From 8ef242602fabfaa1e2854a4b1583d538ba0c4f7d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 10:05:40 +0100 Subject: [PATCH 092/210] refactor: improve createE2EEngageAudienceEvent - Return restricted E2EEngageAudienceEvent type instead of SegmentEvent - Add optional eventName param (defaults to 'Test Engage Audience Membership Event') - Remove email from track properties (stays in context.traits only) - Rename traits param to enrichedTraits - Flatten function to single const with spreading Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-helpers.ts | 101 ++++++++++--------------------- packages/core/src/e2e-types.ts | 36 ++++++++++- packages/core/src/index.ts | 4 ++ 3 files changed, 72 insertions(+), 69 deletions(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index 9784a8b1d49..d7dfec29f6c 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -1,6 +1,6 @@ import type { SegmentEvent } from './segment-event' import type { JSONValue } from './json-object' -import type { E2EEngageAudienceEventOptions } from './e2e-types' +import type { E2EEngageAudienceEventOptions, E2EEngageAudienceEvent } from './e2e-types' export function createE2EEvent( type: SegmentEvent['type'], @@ -16,77 +16,42 @@ export function createE2EEvent( } } -export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOptions): SegmentEvent { - const { - type, - action, - computationKey, - computationId, - externalAudienceId, - userId, - anonymousId, - email, - audienceFields, - enrichedTraits - } = options - +export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOptions): E2EEngageAudienceEvent { + const { type, action, computationKey, computationId, externalAudienceId, eventName, userId, anonymousId, email, audienceFields, enrichedTraits } = options const membership = action === 'add' - const personas: Record = { - computation_class: 'audience', - computation_key: computationKey, - computation_id: computationId - } - - if (externalAudienceId) { - personas.external_audience_id = externalAudienceId - } - - const context: Record = { personas } - - if (audienceFields) { - context.audienceFields = audienceFields - } - - if (type === 'track') { - const properties: { [k: string]: JSONValue } = { - [computationKey]: membership, - ...(enrichedTraits as { [k: string]: JSONValue }) - } - - if (email) { - properties.email = email - context.traits = { email } - } - - return { - type: 'track', - event: computationKey, - messageId: '$guid', - timestamp: '$now', - ...(userId && { userId }), - ...(anonymousId && { anonymousId }), - context, - properties - } - } - - const traits: { [k: string]: JSONValue } = { - [computationKey]: membership, - ...(enrichedTraits as { [k: string]: JSONValue }) - } - - if (email) { - traits.email = email - } - - return { - type: 'identify', + const event = { messageId: '$guid', timestamp: '$now', ...(userId && { userId }), ...(anonymousId && { anonymousId }), - context, - traits - } + context: { + personas: { + computation_class: 'audience', + computation_key: computationKey, + computation_id: computationId, + ...(externalAudienceId && { external_audience_id: externalAudienceId }) + }, + ...(audienceFields && { audienceFields }), + ...(type === 'track' && email && { traits: { email } }) + }, + ...(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 } diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 1aa0ebc1461..a3c1563b727 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -1,5 +1,5 @@ import type { SegmentEvent } from './segment-event' -import type { JSONObject } from './json-object' +import type { JSONObject, JSONValue } from './json-object' export type E2EExpectation = E2ESuccessExpectation | E2EFailureExpectation | E2EErrorExpectation @@ -66,6 +66,7 @@ export interface E2EEngageAudienceEventOptions { computationKey: string computationId: string externalAudienceId?: string + eventName?: string userId?: string anonymousId?: string email?: string @@ -73,6 +74,39 @@ export interface E2EEngageAudienceEventOptions { enrichedTraits?: Record } +export interface E2EEngageAudiencePersonas { + computation_class: 'audience' + computation_key: string + computation_id: string + external_audience_id?: string +} + +export interface E2EEngageAudienceTrackEvent extends SegmentEvent { + type: 'track' + event: string + messageId: string + timestamp: string + context: { + personas: E2EEngageAudiencePersonas + traits?: { email?: string } + audienceFields?: Record + } + properties: { [k: string]: JSONValue } +} + +export interface E2EEngageAudienceIdentifyEvent extends SegmentEvent { + type: 'identify' + messageId: string + timestamp: string + context: { + personas: E2EEngageAudiencePersonas + audienceFields?: Record + } + traits: { [k: string]: JSONValue } +} + +export type E2EEngageAudienceEvent = E2EEngageAudienceTrackEvent | E2EEngageAudienceIdentifyEvent + export interface E2ESettingsSecretValue { $env: string } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e3dbcc5bd29..90ba5ea2b7c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -123,6 +123,10 @@ export type { E2ESettingsSecretValue, E2EDynamicValue, E2EEngageAudienceEventOptions, + E2EEngageAudienceEvent, + E2EEngageAudienceTrackEvent, + E2EEngageAudienceIdentifyEvent, + E2EEngageAudiencePersonas, E2EHttpSuccessCode, E2EHttpFailureCode } from './e2e-types' From d25fb334b80d7299c707b0d8135625b6d6325d0d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 10:15:35 +0100 Subject: [PATCH 093/210] feat: add generic K to audience event types Properties/traits now show the computation_key as a required boolean field. Developers can see at a glance that the membership key is expected in the payload. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-helpers.ts | 4 ++-- packages/core/src/e2e-types.ts | 24 +++++++++++++----------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index d7dfec29f6c..ef463d95821 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -16,7 +16,7 @@ export function createE2EEvent( } } -export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOptions): E2EEngageAudienceEvent { +export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOptions): E2EEngageAudienceEvent { const { type, action, computationKey, computationId, externalAudienceId, eventName, userId, anonymousId, email, audienceFields, enrichedTraits } = options const membership = action === 'add' @@ -53,5 +53,5 @@ export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOpti }) } - return event as E2EEngageAudienceEvent + return event as E2EEngageAudienceEvent } diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index a3c1563b727..3a677b5951c 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -60,10 +60,10 @@ export interface E2EFixture { expect: E2EExpectation } -export interface E2EEngageAudienceEventOptions { +export interface E2EEngageAudienceEventOptions { type: 'track' | 'identify' action: 'add' | 'remove' - computationKey: string + computationKey: K computationId: string externalAudienceId?: string eventName?: string @@ -74,38 +74,40 @@ export interface E2EEngageAudienceEventOptions { enrichedTraits?: Record } -export interface E2EEngageAudiencePersonas { +export interface E2EEngageAudiencePersonas { computation_class: 'audience' - computation_key: string + computation_key: K computation_id: string external_audience_id?: string } -export interface E2EEngageAudienceTrackEvent extends SegmentEvent { +export interface E2EEngageAudienceTrackEvent extends SegmentEvent { type: 'track' event: string messageId: string timestamp: string context: { - personas: E2EEngageAudiencePersonas + personas: E2EEngageAudiencePersonas traits?: { email?: string } audienceFields?: Record } - properties: { [k: string]: JSONValue } + properties: { [key in K]: boolean } & { [k: string]: JSONValue } } -export interface E2EEngageAudienceIdentifyEvent extends SegmentEvent { +export interface E2EEngageAudienceIdentifyEvent extends SegmentEvent { type: 'identify' messageId: string timestamp: string context: { - personas: E2EEngageAudiencePersonas + personas: E2EEngageAudiencePersonas audienceFields?: Record } - traits: { [k: string]: JSONValue } + traits: { [key in K]: boolean } & { [k: string]: JSONValue } } -export type E2EEngageAudienceEvent = E2EEngageAudienceTrackEvent | E2EEngageAudienceIdentifyEvent +export type E2EEngageAudienceEvent = + | E2EEngageAudienceTrackEvent + | E2EEngageAudienceIdentifyEvent export interface E2ESettingsSecretValue { $env: string From 1179102cf08e6f164bfa67641f919ca7b491eda5 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 10:22:43 +0100 Subject: [PATCH 094/210] refactor: rename generic K to ComputationKey for clarity Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-helpers.ts | 4 ++-- packages/core/src/e2e-types.ts | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index ef463d95821..8e8251baef0 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -16,7 +16,7 @@ export function createE2EEvent( } } -export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOptions): E2EEngageAudienceEvent { +export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOptions): E2EEngageAudienceEvent { const { type, action, computationKey, computationId, externalAudienceId, eventName, userId, anonymousId, email, audienceFields, enrichedTraits } = options const membership = action === 'add' @@ -53,5 +53,5 @@ export function createE2EEngageAudienceEvent(options: E2EEngag }) } - return event as E2EEngageAudienceEvent + return event as E2EEngageAudienceEvent } diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 3a677b5951c..3a856fe1a93 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -60,10 +60,10 @@ export interface E2EFixture { expect: E2EExpectation } -export interface E2EEngageAudienceEventOptions { +export interface E2EEngageAudienceEventOptions { type: 'track' | 'identify' action: 'add' | 'remove' - computationKey: K + computationKey: ComputationKey computationId: string externalAudienceId?: string eventName?: string @@ -74,40 +74,40 @@ export interface E2EEngageAudienceEventOptions { enrichedTraits?: Record } -export interface E2EEngageAudiencePersonas { +export interface E2EEngageAudiencePersonas { computation_class: 'audience' - computation_key: K + computation_key: ComputationKey computation_id: string external_audience_id?: string } -export interface E2EEngageAudienceTrackEvent extends SegmentEvent { +export interface E2EEngageAudienceTrackEvent extends SegmentEvent { type: 'track' event: string messageId: string timestamp: string context: { - personas: E2EEngageAudiencePersonas + personas: E2EEngageAudiencePersonas traits?: { email?: string } audienceFields?: Record } - properties: { [key in K]: boolean } & { [k: string]: JSONValue } + properties: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } } -export interface E2EEngageAudienceIdentifyEvent extends SegmentEvent { +export interface E2EEngageAudienceIdentifyEvent extends SegmentEvent { type: 'identify' messageId: string timestamp: string context: { - personas: E2EEngageAudiencePersonas + personas: E2EEngageAudiencePersonas audienceFields?: Record } - traits: { [key in K]: boolean } & { [k: string]: JSONValue } + traits: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } } -export type E2EEngageAudienceEvent = - | E2EEngageAudienceTrackEvent - | E2EEngageAudienceIdentifyEvent +export type E2EEngageAudienceEvent = + | E2EEngageAudienceTrackEvent + | E2EEngageAudienceIdentifyEvent export interface E2ESettingsSecretValue { $env: string From 279ff90f4832f7bc3ef6805feae0b362ae51277f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 10:25:59 +0100 Subject: [PATCH 095/210] feat: add audience fixture types and Amplitude Cohorts e2e fixtures Introduce E2EAudienceFixture, E2EAudienceStep, and related types for testing audience destination lifecycles. Add Amplitude Cohorts fixtures covering createAudience, syncAudience (single + batch), and getAudience for both track and identify event types. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 46 ++++++++++ packages/core/src/index.ts | 7 ++ .../amplitude-cohorts/__e2e__/index.ts | 85 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 3a856fe1a93..de57817c2d9 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -109,6 +109,52 @@ export type E2EEngageAudienceEvent = | E2EEngageAudienceTrackEvent | E2EEngageAudienceIdentifyEvent +export interface E2EAudienceFixture { + description: string + audienceSettings: Record + audienceName: string + steps: E2EAudienceStep[] +} + +export type E2EAudienceStep = + | E2ECreateAudienceStep + | E2EGetAudienceStep + | E2ESyncAudienceSingleStep + | E2ESyncAudienceBatchStep + +export interface E2ECreateAudienceStep { + type: 'createAudience' + expect: E2EExpectation +} + +export interface E2EGetAudienceStep { + type: 'getAudience' + expect: E2EExpectation +} + +export interface E2ESyncAudienceSingleStep { + type: 'syncAudience' + mode: 'single' + event: E2EAudienceSyncEvent + expect: E2EExpectation +} + +export interface E2ESyncAudienceBatchStep { + type: 'syncAudience' + mode: 'batch' + events: E2EAudienceSyncEvent[] + expect: E2EExpectation +} + +export interface E2EAudienceSyncEvent { + eventType: 'track' | 'identify' + action: 'add' | 'remove' + userId?: string + anonymousId?: string + email?: string + enrichedTraits?: Record +} + export interface E2ESettingsSecretValue { $env: string } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 90ba5ea2b7c..d0ae5f8337b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,6 +127,13 @@ export type { E2EEngageAudienceTrackEvent, E2EEngageAudienceIdentifyEvent, E2EEngageAudiencePersonas, + E2EAudienceFixture, + E2EAudienceStep, + E2ECreateAudienceStep, + E2EGetAudienceStep, + E2ESyncAudienceSingleStep, + E2ESyncAudienceBatchStep, + E2EAudienceSyncEvent, 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..c8839dae05c --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts @@ -0,0 +1,85 @@ +import type { E2EDestinationConfig, E2EAudienceFixture } from '@segment/actions-core' + +export const config: E2EDestinationConfig = { + 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' + } +} + +export const audienceFixtures: E2EAudienceFixture[] = [ + { + description: 'Full audience lifecycle with track events', + audienceName: 'e2e_test_audience_track', + audienceSettings: { + id_type: 'BY_USER_ID' + }, + steps: [ + { + type: 'createAudience', + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'single', + event: { eventType: 'track', action: 'add', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'single', + event: { eventType: 'track', action: 'remove', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'batch', + events: [ + { eventType: 'track', action: 'add', userId: 'e2e-amp-user-002', email: 'e2e-user-002@segment.com' }, + { eventType: 'track', action: 'add', userId: 'e2e-amp-user-003', email: 'e2e-user-003@segment.com' }, + { eventType: 'track', action: 'remove', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' } + ], + expect: { status: 'success' } + }, + { + type: 'getAudience', + expect: { status: 'success' } + } + ] + }, + { + description: 'Full audience lifecycle with identify events', + audienceName: 'e2e_test_audience_identify', + audienceSettings: { + id_type: 'BY_USER_ID' + }, + steps: [ + { + type: 'createAudience', + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'single', + event: { eventType: 'identify', action: 'add', userId: 'e2e-amp-user-004', email: 'e2e-user-004@segment.com' }, + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'batch', + events: [ + { eventType: 'identify', action: 'add', userId: 'e2e-amp-user-005', email: 'e2e-user-005@segment.com' }, + { eventType: 'identify', action: 'remove', userId: 'e2e-amp-user-004', email: 'e2e-user-004@segment.com' } + ], + expect: { status: 'success' } + }, + { + type: 'getAudience', + expect: { status: 'success' } + } + ] + } +] From d9fa0d213cfab9afb754ca220acbdf45e8327f9b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 10:29:49 +0100 Subject: [PATCH 096/210] feat: add subscribe and mapping fields to E2EAudienceFixture Audience fixtures now include the FQL subscription and mapping, derived from the action's defaultValues. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 2 ++ .../src/destinations/amplitude-cohorts/__e2e__/index.ts | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index de57817c2d9..b3b56cd51bd 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -113,6 +113,8 @@ export interface E2EAudienceFixture { description: string audienceSettings: Record audienceName: string + subscribe: string + mapping: JSONObject steps: E2EAudienceStep[] } diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts index c8839dae05c..5990d718ee4 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts @@ -1,4 +1,6 @@ import type { E2EDestinationConfig, E2EAudienceFixture } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import syncAudience from '../syncAudience' export const config: E2EDestinationConfig = { settings: { @@ -17,6 +19,8 @@ export const audienceFixtures: E2EAudienceFixture[] = [ audienceSettings: { id_type: 'BY_USER_ID' }, + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), steps: [ { type: 'createAudience', @@ -56,6 +60,8 @@ export const audienceFixtures: E2EAudienceFixture[] = [ audienceSettings: { id_type: 'BY_USER_ID' }, + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), steps: [ { type: 'createAudience', From 9471c9da5755dedeb835399fb7e22440f9915e34 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 10:31:50 +0100 Subject: [PATCH 097/210] feat: add E2ETeardownAudienceStep type Optional step for cleaning up audiences after test lifecycle completes. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 6 ++++++ packages/core/src/index.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index b3b56cd51bd..e29b7222460 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -123,6 +123,7 @@ export type E2EAudienceStep = | E2EGetAudienceStep | E2ESyncAudienceSingleStep | E2ESyncAudienceBatchStep + | E2ETeardownAudienceStep export interface E2ECreateAudienceStep { type: 'createAudience' @@ -148,6 +149,11 @@ export interface E2ESyncAudienceBatchStep { expect: E2EExpectation } +export interface E2ETeardownAudienceStep { + type: 'teardownAudience' + expect: E2EExpectation +} + export interface E2EAudienceSyncEvent { eventType: 'track' | 'identify' action: 'add' | 'remove' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0ae5f8337b..713d4b6eab1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -133,6 +133,7 @@ export type { E2EGetAudienceStep, E2ESyncAudienceSingleStep, E2ESyncAudienceBatchStep, + E2ETeardownAudienceStep, E2EAudienceSyncEvent, E2EHttpSuccessCode, E2EHttpFailureCode From c376880de989ce7199a1d171ac93e2ff3b6e87bf Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 11:30:12 +0100 Subject: [PATCH 098/210] refactor: move audience fixtures to syncAudience/__e2e__/fixtures.ts Keep root __e2e__/index.ts as config only. Fixtures live in the action folder, same pattern as regular destinations. Co-Authored-By: Claude Opus 4.6 --- .../amplitude-cohorts/__e2e__/index.ts | 82 +----------------- .../syncAudience/__e2e__/fixtures.ts | 83 +++++++++++++++++++ 2 files changed, 84 insertions(+), 81 deletions(-) create mode 100644 packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts index 5990d718ee4..6bd11563b0a 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts @@ -1,6 +1,4 @@ -import type { E2EDestinationConfig, E2EAudienceFixture } from '@segment/actions-core' -import { defaultValues } from '@segment/actions-core' -import syncAudience from '../syncAudience' +import type { E2EDestinationConfig } from '@segment/actions-core' export const config: E2EDestinationConfig = { settings: { @@ -11,81 +9,3 @@ export const config: E2EDestinationConfig = { endpoint: 'north_america' } } - -export const audienceFixtures: E2EAudienceFixture[] = [ - { - description: 'Full audience lifecycle with track events', - audienceName: 'e2e_test_audience_track', - audienceSettings: { - id_type: 'BY_USER_ID' - }, - subscribe: 'type = "identify" or type = "track"', - mapping: defaultValues(syncAudience.fields), - steps: [ - { - type: 'createAudience', - expect: { status: 'success' } - }, - { - type: 'syncAudience', - mode: 'single', - event: { eventType: 'track', action: 'add', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, - expect: { status: 'success' } - }, - { - type: 'syncAudience', - mode: 'single', - event: { eventType: 'track', action: 'remove', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, - expect: { status: 'success' } - }, - { - type: 'syncAudience', - mode: 'batch', - events: [ - { eventType: 'track', action: 'add', userId: 'e2e-amp-user-002', email: 'e2e-user-002@segment.com' }, - { eventType: 'track', action: 'add', userId: 'e2e-amp-user-003', email: 'e2e-user-003@segment.com' }, - { eventType: 'track', action: 'remove', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' } - ], - expect: { status: 'success' } - }, - { - type: 'getAudience', - expect: { status: 'success' } - } - ] - }, - { - description: 'Full audience lifecycle with identify events', - audienceName: 'e2e_test_audience_identify', - audienceSettings: { - id_type: 'BY_USER_ID' - }, - subscribe: 'type = "identify" or type = "track"', - mapping: defaultValues(syncAudience.fields), - steps: [ - { - type: 'createAudience', - expect: { status: 'success' } - }, - { - type: 'syncAudience', - mode: 'single', - event: { eventType: 'identify', action: 'add', userId: 'e2e-amp-user-004', email: 'e2e-user-004@segment.com' }, - expect: { status: 'success' } - }, - { - type: 'syncAudience', - mode: 'batch', - events: [ - { eventType: 'identify', action: 'add', userId: 'e2e-amp-user-005', email: 'e2e-user-005@segment.com' }, - { eventType: 'identify', action: 'remove', userId: 'e2e-amp-user-004', email: 'e2e-user-004@segment.com' } - ], - expect: { status: 'success' } - }, - { - type: 'getAudience', - expect: { status: 'success' } - } - ] - } -] diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts new file mode 100644 index 00000000000..72ad38d5e68 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -0,0 +1,83 @@ +import type { E2EAudienceFixture } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import syncAudience from '../index' + +const fixtures: E2EAudienceFixture[] = [ + { + description: 'Full audience lifecycle with track events', + audienceName: 'e2e_test_audience_track', + audienceSettings: { + id_type: 'BY_USER_ID' + }, + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + steps: [ + { + type: 'createAudience', + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'single', + event: { eventType: 'track', action: 'add', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'single', + event: { eventType: 'track', action: 'remove', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'batch', + events: [ + { eventType: 'track', action: 'add', userId: 'e2e-amp-user-002', email: 'e2e-user-002@segment.com' }, + { eventType: 'track', action: 'add', userId: 'e2e-amp-user-003', email: 'e2e-user-003@segment.com' }, + { eventType: 'track', action: 'remove', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' } + ], + expect: { status: 'success' } + }, + { + type: 'getAudience', + expect: { status: 'success' } + } + ] + }, + { + description: 'Full audience lifecycle with identify events', + audienceName: 'e2e_test_audience_identify', + audienceSettings: { + id_type: 'BY_USER_ID' + }, + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + steps: [ + { + type: 'createAudience', + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'single', + event: { eventType: 'identify', action: 'add', userId: 'e2e-amp-user-004', email: 'e2e-user-004@segment.com' }, + expect: { status: 'success' } + }, + { + type: 'syncAudience', + mode: 'batch', + events: [ + { eventType: 'identify', action: 'add', userId: 'e2e-amp-user-005', email: 'e2e-user-005@segment.com' }, + { eventType: 'identify', action: 'remove', userId: 'e2e-amp-user-004', email: 'e2e-user-004@segment.com' } + ], + expect: { status: 'success' } + }, + { + type: 'getAudience', + expect: { status: 'success' } + } + ] + } +] + +export default fixtures From 552c73724302f1a44537089e1c18937f390cb250 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 11:41:56 +0100 Subject: [PATCH 099/210] feat: add description field to audience step types Each step now has a human-readable description for reporting output. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 5 +++ .../syncAudience/__e2e__/fixtures.ts | 39 +++---------------- 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index e29b7222460..7a104663d2e 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -127,16 +127,19 @@ export type E2EAudienceStep = export interface E2ECreateAudienceStep { type: 'createAudience' + description: string expect: E2EExpectation } export interface E2EGetAudienceStep { type: 'getAudience' + description: string expect: E2EExpectation } export interface E2ESyncAudienceSingleStep { type: 'syncAudience' + description: string mode: 'single' event: E2EAudienceSyncEvent expect: E2EExpectation @@ -144,6 +147,7 @@ export interface E2ESyncAudienceSingleStep { export interface E2ESyncAudienceBatchStep { type: 'syncAudience' + description: string mode: 'batch' events: E2EAudienceSyncEvent[] expect: E2EExpectation @@ -151,6 +155,7 @@ export interface E2ESyncAudienceBatchStep { export interface E2ETeardownAudienceStep { type: 'teardownAudience' + description: string expect: E2EExpectation } diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts index 72ad38d5e68..f579e14fa0a 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -14,22 +14,26 @@ const fixtures: E2EAudienceFixture[] = [ steps: [ { type: 'createAudience', + description: 'Create cohort on Amplitude', expect: { status: 'success' } }, { type: 'syncAudience', + description: 'Add a single user via track event', mode: 'single', event: { eventType: 'track', action: 'add', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, expect: { status: 'success' } }, { type: 'syncAudience', + description: 'Remove a single user via track event', mode: 'single', event: { eventType: 'track', action: 'remove', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, expect: { status: 'success' } }, { type: 'syncAudience', + description: 'Batch add and remove users', mode: 'batch', events: [ { eventType: 'track', action: 'add', userId: 'e2e-amp-user-002', email: 'e2e-user-002@segment.com' }, @@ -40,40 +44,7 @@ const fixtures: E2EAudienceFixture[] = [ }, { type: 'getAudience', - expect: { status: 'success' } - } - ] - }, - { - description: 'Full audience lifecycle with identify events', - audienceName: 'e2e_test_audience_identify', - audienceSettings: { - id_type: 'BY_USER_ID' - }, - subscribe: 'type = "identify" or type = "track"', - mapping: defaultValues(syncAudience.fields), - steps: [ - { - type: 'createAudience', - expect: { status: 'success' } - }, - { - type: 'syncAudience', - mode: 'single', - event: { eventType: 'identify', action: 'add', userId: 'e2e-amp-user-004', email: 'e2e-user-004@segment.com' }, - expect: { status: 'success' } - }, - { - type: 'syncAudience', - mode: 'batch', - events: [ - { eventType: 'identify', action: 'add', userId: 'e2e-amp-user-005', email: 'e2e-user-005@segment.com' }, - { eventType: 'identify', action: 'remove', userId: 'e2e-amp-user-004', email: 'e2e-user-004@segment.com' } - ], - expect: { status: 'success' } - }, - { - type: 'getAudience', + description: 'Verify cohort still exists', expect: { status: 'success' } } ] From 34a66943a1d7636b45cd61375414d76d48d70298 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 11:53:14 +0100 Subject: [PATCH 100/210] feat: add E2EExecutionMode type for single/batch step mode Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 6 ++++-- packages/core/src/index.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 7a104663d2e..f1b50c8c8e6 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -137,10 +137,12 @@ export interface E2EGetAudienceStep { expect: E2EExpectation } +export type E2EExecutionMode = 'single' | 'batch' + export interface E2ESyncAudienceSingleStep { type: 'syncAudience' description: string - mode: 'single' + mode: Extract event: E2EAudienceSyncEvent expect: E2EExpectation } @@ -148,7 +150,7 @@ export interface E2ESyncAudienceSingleStep { export interface E2ESyncAudienceBatchStep { type: 'syncAudience' description: string - mode: 'batch' + mode: Extract events: E2EAudienceSyncEvent[] expect: E2EExpectation } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 713d4b6eab1..0b6399b62d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -134,6 +134,7 @@ export type { E2ESyncAudienceSingleStep, E2ESyncAudienceBatchStep, E2ETeardownAudienceStep, + E2EExecutionMode, E2EAudienceSyncEvent, E2EHttpSuccessCode, E2EHttpFailureCode From 6055ef242bafa16740c4678db350eb1f958b2b82 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 12:00:50 +0100 Subject: [PATCH 101/210] refactor: use full SegmentEvent in audience sync steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps now contain complete events built with createE2EEngageAudienceEvent, matching the pattern of regular E2EFixture. Remove E2EAudienceSyncEvent type — no longer needed. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 13 +---- packages/core/src/index.ts | 1 - .../syncAudience/__e2e__/fixtures.ts | 52 ++++++++++++++++--- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index f1b50c8c8e6..6f281805934 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -143,7 +143,7 @@ export interface E2ESyncAudienceSingleStep { type: 'syncAudience' description: string mode: Extract - event: E2EAudienceSyncEvent + event: SegmentEvent expect: E2EExpectation } @@ -151,7 +151,7 @@ export interface E2ESyncAudienceBatchStep { type: 'syncAudience' description: string mode: Extract - events: E2EAudienceSyncEvent[] + events: SegmentEvent[] expect: E2EExpectation } @@ -161,15 +161,6 @@ export interface E2ETeardownAudienceStep { expect: E2EExpectation } -export interface E2EAudienceSyncEvent { - eventType: 'track' | 'identify' - action: 'add' | 'remove' - userId?: string - anonymousId?: string - email?: string - enrichedTraits?: Record -} - export interface E2ESettingsSecretValue { $env: string } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0b6399b62d8..62365e23721 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -135,7 +135,6 @@ export type { E2ESyncAudienceBatchStep, E2ETeardownAudienceStep, E2EExecutionMode, - E2EAudienceSyncEvent, E2EHttpSuccessCode, E2EHttpFailureCode } from './e2e-types' diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts index f579e14fa0a..76beffa860c 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -1,11 +1,14 @@ import type { E2EAudienceFixture } from '@segment/actions-core' -import { defaultValues } 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 fixtures: E2EAudienceFixture[] = [ { description: 'Full audience lifecycle with track events', - audienceName: 'e2e_test_audience_track', + audienceName: COMPUTATION_KEY, audienceSettings: { id_type: 'BY_USER_ID' }, @@ -21,14 +24,28 @@ const fixtures: E2EAudienceFixture[] = [ type: 'syncAudience', description: 'Add a single user via track event', mode: 'single', - event: { eventType: 'track', action: 'add', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-amp-user-001', + email: 'e2e-user-001@segment.com' + }), expect: { status: 'success' } }, { type: 'syncAudience', description: 'Remove a single user via track event', mode: 'single', - event: { eventType: 'track', action: 'remove', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }, + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-amp-user-001', + email: 'e2e-user-001@segment.com' + }), expect: { status: 'success' } }, { @@ -36,9 +53,30 @@ const fixtures: E2EAudienceFixture[] = [ description: 'Batch add and remove users', mode: 'batch', events: [ - { eventType: 'track', action: 'add', userId: 'e2e-amp-user-002', email: 'e2e-user-002@segment.com' }, - { eventType: 'track', action: 'add', userId: 'e2e-amp-user-003', email: 'e2e-user-003@segment.com' }, - { eventType: 'track', action: 'remove', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' } + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-amp-user-002', + email: 'e2e-user-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-amp-user-003', + email: 'e2e-user-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-amp-user-001', + email: 'e2e-user-001@segment.com' + }) ], expect: { status: 'success' } }, From 06454ac32e2eb525c311c53ac8b714128316f5c7 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 12:02:51 +0100 Subject: [PATCH 102/210] fix: use E2EEngageAudienceEvent type in audience sync steps Steps now require properly typed audience events instead of generic SegmentEvent. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 6f281805934..eb2036bbd16 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -143,7 +143,7 @@ export interface E2ESyncAudienceSingleStep { type: 'syncAudience' description: string mode: Extract - event: SegmentEvent + event: E2EEngageAudienceEvent expect: E2EExpectation } @@ -151,7 +151,7 @@ export interface E2ESyncAudienceBatchStep { type: 'syncAudience' description: string mode: Extract - events: SegmentEvent[] + events: E2EEngageAudienceEvent[] expect: E2EExpectation } From 16a6d8ca0aedd248d34b733026800d37e78dafed Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 12:12:42 +0100 Subject: [PATCH 103/210] feat: add $externalAudienceId dynamic value marker Runner resolves this after createAudience returns the destination's audience ID. Fixtures use it to reference the ID without knowing it at definition time. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 11 ++++++----- .../syncAudience/__e2e__/fixtures.ts | 4 ++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index eb2036bbd16..0df8f97d9fb 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -36,12 +36,13 @@ export interface E2EErrorExpectation { /** * 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. + * - '$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}` +export type E2EDynamicValue = '$now' | '$guid' | `$guid:${string}` | '$externalAudienceId' export interface E2EFixture { /** Human-readable name for the test case, shown in runner output. */ diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts index 76beffa860c..1d29cd71446 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -29,6 +29,7 @@ const fixtures: E2EAudienceFixture[] = [ action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }), @@ -43,6 +44,7 @@ const fixtures: E2EAudienceFixture[] = [ action: 'remove', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', userId: 'e2e-amp-user-001', email: 'e2e-user-001@segment.com' }), @@ -58,6 +60,7 @@ const fixtures: E2EAudienceFixture[] = [ action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', userId: 'e2e-amp-user-002', email: 'e2e-user-002@segment.com' }), @@ -66,6 +69,7 @@ const fixtures: E2EAudienceFixture[] = [ action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', userId: 'e2e-amp-user-003', email: 'e2e-user-003@segment.com' }), From b05c06a1b48f8c5e99307298c91d7c4eb444a25b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 12:32:11 +0100 Subject: [PATCH 104/210] refactor: simplify audience testing to config + regular fixtures Replace step-based E2EAudienceFixture with E2EAudienceDestinationConfig. Audience lifecycle (createAudience, getAudience, teardown) is configured as flags in the config. Sync fixtures are regular E2EFixture[] using createE2EEngageAudienceEvent. Runner handles lifecycle around fixtures. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 64 ++------- packages/core/src/index.ts | 10 +- .../amplitude-cohorts/__e2e__/index.ts | 11 +- .../syncAudience/__e2e__/fixtures.ts | 126 ++++++------------ 4 files changed, 66 insertions(+), 145 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 0df8f97d9fb..7ee25373a2e 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -110,58 +110,6 @@ export type E2EEngageAudienceEvent = | E2EEngageAudienceTrackEvent | E2EEngageAudienceIdentifyEvent -export interface E2EAudienceFixture { - description: string - audienceSettings: Record - audienceName: string - subscribe: string - mapping: JSONObject - steps: E2EAudienceStep[] -} - -export type E2EAudienceStep = - | E2ECreateAudienceStep - | E2EGetAudienceStep - | E2ESyncAudienceSingleStep - | E2ESyncAudienceBatchStep - | E2ETeardownAudienceStep - -export interface E2ECreateAudienceStep { - type: 'createAudience' - description: string - expect: E2EExpectation -} - -export interface E2EGetAudienceStep { - type: 'getAudience' - description: string - expect: E2EExpectation -} - -export type E2EExecutionMode = 'single' | 'batch' - -export interface E2ESyncAudienceSingleStep { - type: 'syncAudience' - description: string - mode: Extract - event: E2EEngageAudienceEvent - expect: E2EExpectation -} - -export interface E2ESyncAudienceBatchStep { - type: 'syncAudience' - description: string - mode: Extract - events: E2EEngageAudienceEvent[] - expect: E2EExpectation -} - -export interface E2ETeardownAudienceStep { - type: 'teardownAudience' - description: string - expect: E2EExpectation -} - export interface E2ESettingsSecretValue { $env: string } @@ -170,6 +118,18 @@ export interface E2EDestinationConfig { settings: Record } +export interface E2EAudienceConfig { + audienceName: string + audienceSettings: Record + createAudience: boolean + getAudience: boolean + teardown: boolean +} + +export interface E2EAudienceDestinationConfig extends E2EDestinationConfig { + audience: E2EAudienceConfig +} + export type E2EHttpSuccessCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 export type E2EHttpFailureCode = diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 62365e23721..fefe4c2c312 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -120,6 +120,8 @@ export type { E2EFailureExpectation, E2EErrorExpectation, E2EDestinationConfig, + E2EAudienceDestinationConfig, + E2EAudienceConfig, E2ESettingsSecretValue, E2EDynamicValue, E2EEngageAudienceEventOptions, @@ -127,14 +129,6 @@ export type { E2EEngageAudienceTrackEvent, E2EEngageAudienceIdentifyEvent, E2EEngageAudiencePersonas, - E2EAudienceFixture, - E2EAudienceStep, - E2ECreateAudienceStep, - E2EGetAudienceStep, - E2ESyncAudienceSingleStep, - E2ESyncAudienceBatchStep, - E2ETeardownAudienceStep, - E2EExecutionMode, 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 index 6bd11563b0a..ddb88bf9ac3 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts @@ -1,11 +1,18 @@ -import type { E2EDestinationConfig } from '@segment/actions-core' +import type { E2EAudienceDestinationConfig } from '@segment/actions-core' -export const config: E2EDestinationConfig = { +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/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts index 1d29cd71446..69a618d015a 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -1,95 +1,55 @@ -import type { E2EAudienceFixture } from '@segment/actions-core' +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 fixtures: E2EAudienceFixture[] = [ +const fixtures: E2EFixture[] = [ { - description: 'Full audience lifecycle with track events', - audienceName: COMPUTATION_KEY, - audienceSettings: { - id_type: 'BY_USER_ID' - }, + description: 'Add a single user via track event', subscribe: 'type = "identify" or type = "track"', mapping: defaultValues(syncAudience.fields), - steps: [ - { - type: 'createAudience', - description: 'Create cohort on Amplitude', - expect: { status: 'success' } - }, - { - type: 'syncAudience', - description: 'Add a single user via track event', - mode: 'single', - event: createE2EEngageAudienceEvent({ - type: 'track', - action: 'add', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-001', - email: 'e2e-user-001@segment.com' - }), - expect: { status: 'success' } - }, - { - type: 'syncAudience', - description: 'Remove a single user via track event', - mode: 'single', - event: createE2EEngageAudienceEvent({ - type: 'track', - action: 'remove', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-001', - email: 'e2e-user-001@segment.com' - }), - expect: { status: 'success' } - }, - { - type: 'syncAudience', - description: 'Batch add and remove users', - mode: 'batch', - events: [ - createE2EEngageAudienceEvent({ - type: 'track', - action: 'add', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-002', - email: 'e2e-user-002@segment.com' - }), - createE2EEngageAudienceEvent({ - type: 'track', - action: 'add', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-003', - email: 'e2e-user-003@segment.com' - }), - createE2EEngageAudienceEvent({ - type: 'track', - action: 'remove', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - userId: 'e2e-amp-user-001', - email: 'e2e-user-001@segment.com' - }) - ], - expect: { status: 'success' } - }, - { - type: 'getAudience', - description: 'Verify cohort still exists', - expect: { status: 'success' } - } - ] + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-amp-user-001', + email: 'e2e-user-001@segment.com' + }), + expect: { status: 'success' } + }, + { + description: 'Remove a single user via track event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-amp-user-001', + email: 'e2e-user-001@segment.com' + }), + expect: { status: 'success' } + }, + { + description: 'Batch add and remove users', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-amp-user-002', + email: 'e2e-user-002@segment.com' + }), + expect: { status: 'success' } } ] From c2d3ca1836ca0d247e56d09a2872fbf0aa7a2706 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 12:32:58 +0100 Subject: [PATCH 105/210] docs: add JSDoc comments to E2EAudienceConfig fields Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 7ee25373a2e..f03415c39fe 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -119,10 +119,15 @@ export interface E2EDestinationConfig { } 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 true, the runner calls the teardown function after all tests to clean up the audience. */ teardown: boolean } From 8615ccec12f06180146c1d84616f412cc87c5282 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 12:39:30 +0100 Subject: [PATCH 106/210] feat: split E2EFixture into discriminated union with mode field E2EFixture is now E2ESingleFixture | E2EBatchFixture, discriminated by mode: 'single' | 'batch'. Single uses onEvent() with one event, batch uses onBatch() with an events array. All existing fixtures updated to include mode: 'single'. Amplitude Cohorts batch fixture uses mode: 'batch'. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 25 ++++++++++- packages/core/src/index.ts | 2 + .../syncAudience/__e2e__/fixtures.ts | 41 +++++++++++++++---- .../identifyUser/__e2e__/fixtures.ts | 1 + .../amplitude/logEventV2/__e2e__/fixtures.ts | 2 + .../iterable/trackEvent/__e2e__/fixtures.ts | 2 + .../iterable/updateUser/__e2e__/fixtures.ts | 1 + 7 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index f03415c39fe..37f319bb206 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -44,13 +44,17 @@ export interface E2EErrorExpectation { */ export type E2EDynamicValue = '$now' | '$guid' | `$guid:${string}` | '$externalAudienceId' -export interface E2EFixture { +export type E2EFixture = E2ESingleFixture | E2EBatchFixture + +export interface E2ESingleFixture { /** 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 + /** 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 @@ -61,6 +65,25 @@ export interface E2EFixture { expect: E2EExpectation } +export interface E2EBatchFixture { + /** 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 + /** Executes via onBatch() with multiple events. */ + 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[] + /** The expected outcome of executing this fixture. */ + expect: E2EExpectation +} + export interface E2EEngageAudienceEventOptions { type: 'track' | 'identify' action: 'add' | 'remove' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fefe4c2c312..35ea0068c1b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -115,6 +115,8 @@ export { FLAGS } from './flags' export { createE2EEvent, createE2EEngageAudienceEvent } from './e2e-helpers' export type { E2EFixture, + E2ESingleFixture, + E2EBatchFixture, E2EExpectation, E2ESuccessExpectation, E2EFailureExpectation, diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts index 69a618d015a..503bd2c6bb3 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -10,6 +10,7 @@ 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', @@ -25,6 +26,7 @@ const fixtures: E2EFixture[] = [ 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', @@ -40,15 +42,36 @@ const fixtures: E2EFixture[] = [ description: 'Batch add and remove users', subscribe: 'type = "identify" or type = "track"', mapping: defaultValues(syncAudience.fields), - event: createE2EEngageAudienceEvent({ - type: 'track', - action: 'add', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-002', - email: 'e2e-user-002@segment.com' - }), + mode: 'batch', + events: [ + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-amp-user-002', + email: 'e2e-user-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-amp-user-003', + email: 'e2e-user-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-amp-user-001', + email: 'e2e-user-001@segment.com' + }) + ], expect: { status: 'success' } } ] diff --git a/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.ts index 01dc599dcaf..570bc0836f2 100644 --- a/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.ts @@ -7,6 +7,7 @@ const fixtures: E2EFixture[] = [ description: 'Successfully identifies a user with traits', subscribe: 'type = "identify"', mapping: defaultValues(identifyUser.fields), + mode: 'single', event: createE2EEvent('identify', 'Identify', { userId: 'e2e-test-user-amplitude-001', traits: { diff --git a/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.ts index 1a439354cf3..dbd50821891 100644 --- a/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.ts @@ -7,6 +7,7 @@ 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: { @@ -22,6 +23,7 @@ const fixtures: E2EFixture[] = [ 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: { diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts index dcc56cdaccd..08ef33b02b4 100644 --- a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts @@ -7,6 +7,7 @@ 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: { @@ -22,6 +23,7 @@ const fixtures: E2EFixture[] = [ { 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 diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts index c15552ccc50..d33b642cd94 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts @@ -7,6 +7,7 @@ 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', 'Identify', { userId: 'e2e-test-user-001', traits: { From dfe39067a1e4f7482198f5c8e937b33895daa3c1 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 13:35:28 +0100 Subject: [PATCH 107/210] adding latest amplitude cohorts --- .../amplitude-cohorts/__e2e__/index.ts | 18 - .../__tests__/functions.test.ts | 594 +++++++++++++++++- .../amplitude-cohorts/__tests__/index.test.ts | 183 +++++- .../amplitude-cohorts/constants.ts | 3 + .../amplitude-cohorts/functions.ts | 149 ++++- .../amplitude-cohorts/generated-types.ts | 4 + .../destinations/amplitude-cohorts/index.ts | 55 +- .../syncAudience/__e2e__/fixtures.ts | 79 --- .../syncAudience/__tests__/functions.test.ts | 238 ++++++- .../syncAudience/__tests__/getIds.test.ts | 2 +- .../syncAudience/__tests__/index.test.ts | 56 +- .../syncAudience/functions.ts | 23 +- .../amplitude-cohorts/syncAudience/types.ts | 14 - .../destinations/amplitude-cohorts/types.ts | 35 +- 14 files changed, 1213 insertions(+), 240 deletions(-) delete mode 100644 packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts delete mode 100644 packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts deleted file mode 100644 index ddb88bf9ac3..00000000000 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -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..cc61e8ea7f8 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. This is required to create the cohort. The user will be added during creation and immediately removed. If no value is provided, Segment will attempt to search for a valid User ID automatically, but this may fail if 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..bf64b98c559 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. This is required to create the cohort. The user will be added during creation and immediately removed. If no value is provided, Segment will attempt to search for a valid User ID automatically, but this may fail if no users are found.', + type: 'string', + required: false } }, audienceConfig: { @@ -117,12 +124,22 @@ const destination: AudienceDestinationDefinition = { const { audienceName, settings, - audienceSettings: { owner_email, audience_name, id_type } = {} + 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 +152,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.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts deleted file mode 100644 index 503bd2c6bb3..00000000000 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 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: 'e2e-amp-user-001', - email: 'e2e-user-001@segment.com' - }), - expect: { status: 'success' } - }, - { - 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: 'e2e-amp-user-001', - email: 'e2e-user-001@segment.com' - }), - expect: { status: 'success' } - }, - { - description: 'Batch add and remove users', - subscribe: 'type = "identify" or type = "track"', - mapping: defaultValues(syncAudience.fields), - mode: 'batch', - events: [ - createE2EEngageAudienceEvent({ - type: 'track', - action: 'add', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-002', - email: 'e2e-user-002@segment.com' - }), - createE2EEngageAudienceEvent({ - type: 'track', - action: 'add', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-003', - email: 'e2e-user-003@segment.com' - }), - createE2EEngageAudienceEvent({ - type: 'track', - action: 'remove', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-001', - email: 'e2e-user-001@segment.com' - }) - ], - expect: { status: 'success' } - } -] - -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 9c495dc99ce..83be0868516 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' } ] @@ -92,7 +92,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user456'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'REMOVE' } ] @@ -296,7 +296,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user1', 'user2'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -319,7 +319,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user3'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'REMOVE' } ] @@ -391,7 +391,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['valid_user', 'invalid_user'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -443,7 +443,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['eu_user'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -560,7 +560,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') @@ -575,7 +575,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', @@ -584,7 +584,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', @@ -626,7 +626,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') @@ -641,7 +641,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', @@ -650,7 +650,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', @@ -705,13 +705,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, { @@ -729,7 +729,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', @@ -738,7 +738,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', @@ -747,7 +747,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', @@ -860,7 +860,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') @@ -876,7 +876,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', @@ -885,7 +885,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', @@ -927,7 +927,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') @@ -942,7 +942,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', @@ -1008,7 +1008,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') @@ -1023,7 +1023,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', @@ -1038,7 +1038,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', @@ -1082,7 +1082,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') @@ -1102,7 +1102,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', @@ -1206,7 +1206,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..79359d318b9 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts @@ -1,14 +1,14 @@ -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. + 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 + 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 } @@ -17,7 +17,30 @@ export type CreateAudienceResponse = { } export type GetAudienceResponse = { - cohortId: string + cohort_id: string + request_id: string +} + +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 IDType = keyof typeof ID_TYPES \ No newline at end of file +export type UserSearchResponse = { + matches: Array<{ + user_id?: string | null + amplitude_id?: number + [key: string]: unknown + }> +} \ No newline at end of file From f2e5498637f63c11381eb4b391bb3a42e3b040c2 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 13:38:39 +0100 Subject: [PATCH 108/210] fix: restore amplitude-cohorts e2e fixtures Co-Authored-By: Claude Opus 4.6 --- .../syncAudience/__e2e__/fixtures.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts new file mode 100644 index 00000000000..503bd2c6bb3 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -0,0 +1,79 @@ +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 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: 'e2e-amp-user-001', + email: 'e2e-user-001@segment.com' + }), + expect: { status: 'success' } + }, + { + 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: 'e2e-amp-user-001', + email: 'e2e-user-001@segment.com' + }), + expect: { status: 'success' } + }, + { + description: 'Batch add and remove users', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'batch', + events: [ + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-amp-user-002', + email: 'e2e-user-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-amp-user-003', + email: 'e2e-user-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-amp-user-001', + email: 'e2e-user-001@segment.com' + }) + ], + expect: { status: 'success' } + } +] + +export default fixtures From cd7771ade8877c1a16c7da884608471144625d84 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 13:43:18 +0100 Subject: [PATCH 109/210] fix: restore amplitude-cohorts e2e config Co-Authored-By: Claude Opus 4.6 --- .../amplitude-cohorts/__e2e__/index.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts 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 + } +} From 0d039390d0c272106a3e7c23c2f5f7f22a3f3f6b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 14:07:41 +0100 Subject: [PATCH 110/210] feat: add verboseFailureHint to fixture types Shows a diagnostic hint in verbose mode when a fixture fails. Amplitude Cohorts fixtures include hint about user IDs needing to exist in the project. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 4 ++++ .../syncAudience/__e2e__/fixtures.ts | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 37f319bb206..f4dc70a6941 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -63,6 +63,8 @@ export interface E2ESingleFixture { event: SegmentEvent /** 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 } export interface E2EBatchFixture { @@ -82,6 +84,8 @@ export interface E2EBatchFixture { events: SegmentEvent[] /** 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 } export interface E2EEngageAudienceEventOptions { diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts index 503bd2c6bb3..1c2d1f30b52 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -5,6 +5,8 @@ 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', @@ -17,10 +19,11 @@ const fixtures: E2EFixture[] = [ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-001', + userId: 'segment-e2e-test-user-1', email: 'e2e-user-001@segment.com' }), - expect: { status: 'success' } + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT }, { description: 'Remove a single user via track event', @@ -33,10 +36,11 @@ const fixtures: E2EFixture[] = [ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-001', + userId: 'segment-e2e-test-user-2', email: 'e2e-user-001@segment.com' }), - expect: { status: 'success' } + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT }, { description: 'Batch add and remove users', @@ -50,7 +54,7 @@ const fixtures: E2EFixture[] = [ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-002', + userId: 'segment-e2e-test-user-3', email: 'e2e-user-002@segment.com' }), createE2EEngageAudienceEvent({ @@ -59,7 +63,7 @@ const fixtures: E2EFixture[] = [ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-003', + userId: 'segment-e2e-test-user-4', email: 'e2e-user-003@segment.com' }), createE2EEngageAudienceEvent({ @@ -68,11 +72,12 @@ const fixtures: E2EFixture[] = [ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', - userId: 'e2e-amp-user-001', + userId: 'segment-e2e-test-user-5', email: 'e2e-user-001@segment.com' }) ], - expect: { status: 'success' } + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT } ] From df6746e5f60a4affa2c91b17d431e702f75ca485 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 14:26:28 +0100 Subject: [PATCH 111/210] feat: add jsonContains to E2EExpectation types Partial deep match against JSON response body. Arrays must match length, objects are partial-matched (extra fields ignored). Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index f4dc70a6941..4300801ae53 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -10,6 +10,8 @@ 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 } /** @@ -20,6 +22,8 @@ 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 } /** From d63b1980fd484d5012e42c6a4b45e9d7e8fc4fee Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 14:43:48 +0100 Subject: [PATCH 112/210] refactor: replace responseType with 3 execution modes + BaseFixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit single, batch, batchWithMultistatus — the mode fully describes both how to call the action and how to inspect the response. Extract E2EBaseFixture with common fields (description, subscribe, mapping, expect, verboseFailureHint). Concrete fixtures extend it. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 42 +++++++++++-------- packages/core/src/index.ts | 3 ++ .../syncAudience/__e2e__/fixtures.ts | 2 +- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 4300801ae53..41333a55731 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -48,15 +48,24 @@ export interface E2EErrorExpectation { */ export type E2EDynamicValue = '$now' | '$guid' | `$guid:${string}` | '$externalAudienceId' -export type E2EFixture = E2ESingleFixture | E2EBatchFixture +export type E2EExecutionMode = 'single' | 'batch' | 'batchWithMultistatus' -export interface E2ESingleFixture { +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 +} + +export interface E2ESingleFixture extends E2EBaseFixture { /** Executes via onEvent() with a single event. */ mode: 'single' /** @@ -65,20 +74,10 @@ export interface E2ESingleFixture { * runner resolves before execution. */ event: SegmentEvent - /** 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 } -export interface E2EBatchFixture { - /** 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 - /** Executes via onBatch() with multiple events. */ +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. @@ -86,10 +85,17 @@ export interface E2EBatchFixture { * runner resolves before execution. */ events: SegmentEvent[] - /** 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 +} + +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 E2EEngageAudienceEventOptions { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 35ea0068c1b..7217aa01219 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -115,8 +115,11 @@ export { FLAGS } from './flags' export { createE2EEvent, createE2EEngageAudienceEvent } from './e2e-helpers' export type { E2EFixture, + E2EBaseFixture, E2ESingleFixture, E2EBatchFixture, + E2EBatchWithMultistatusFixture, + E2EExecutionMode, E2EExpectation, E2ESuccessExpectation, E2EFailureExpectation, diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts index 1c2d1f30b52..257b25c4502 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -46,7 +46,7 @@ const fixtures: E2EFixture[] = [ description: 'Batch add and remove users', subscribe: 'type = "identify" or type = "track"', mapping: defaultValues(syncAudience.fields), - mode: 'batch', + mode: 'batchWithMultistatus', events: [ createE2EEngageAudienceEvent({ type: 'track', From f74927ea797ba3dc198b3b0d2af6661da2d233b7 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 29 May 2026 14:50:38 +0100 Subject: [PATCH 113/210] feat: add jsonContains assertion to batch fixture Verifies each item in the multistatus response has status 200. Co-Authored-By: Claude Opus 4.6 --- .../amplitude-cohorts/syncAudience/__e2e__/fixtures.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts index 257b25c4502..3fb6415322b 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts @@ -76,7 +76,14 @@ const fixtures: E2EFixture[] = [ email: 'e2e-user-001@segment.com' }) ], - expect: { status: 'success' }, + expect: { + status: 'success', + jsonContains: [ + { status: 200 }, + { status: 200 }, + { status: 200 } + ] + }, verboseFailureHint: FAILURE_HINT } ] From 5e1163ed44d929acb9e7a0692881a156bb46cfbd Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 10:03:12 +0100 Subject: [PATCH 114/210] feat: add e2e fixtures for Iterable Audiences destination Covers audience lifecycle (createAudience, getAudience) plus syncAudience action with single subscribe, single unsubscribe, and batch with multistatus assertions. Co-Authored-By: Claude Opus 4.6 --- .../iterable-audiences/__e2e__/index.ts | 15 +++ .../syncAudience/__e2e__/fixtures.ts | 91 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts 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..772eb5db27f --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts @@ -0,0 +1,15 @@ +import type { E2EAudienceDestinationConfig } from '@segment/actions-core' + +export const config: E2EAudienceDestinationConfig = { + settings: { + apiKey: { $env: 'E2E_ITERABLE_AUDIENCES_API_KEY' }, + iterableProjectType: 'hybrid' + }, + audience: { + audienceName: 'e2e_test_audience', + audienceSettings: {}, + createAudience: true, + getAudience: true, + teardown: false + } +} diff --git a/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts new file mode 100644 index 00000000000..df1e735f648 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.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 }, + { status: 200 }, + { status: 200 } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures From 5da931624295a92f391646ab44a244c13c1cf61e Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 10:06:52 +0100 Subject: [PATCH 115/210] feat: add sent/body assertions to Iterable Audiences batch fixture Verifies per-item multistatus includes correct email, userId, and segmentAudienceKey in sent/body fields. Co-Authored-By: Claude Opus 4.6 --- .../iterable-audiences/syncAudience/__e2e__/fixtures.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts index df1e735f648..9bd657da23f 100644 --- a/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts @@ -79,9 +79,9 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 }, - { status: 200 } + { status: 200, sent: { email: 'e2e-aud-test-002@segment.com', userId: 'e2e-iterable-aud-user-002' }, body: { segmentAudienceKey: COMPUTATION_KEY } }, + { status: 200, sent: { email: 'e2e-aud-test-003@segment.com', userId: 'e2e-iterable-aud-user-003' }, body: { segmentAudienceKey: COMPUTATION_KEY } }, + { status: 200, sent: { email: 'e2e-aud-test-001@segment.com', userId: 'e2e-iterable-aud-user-001' }, body: { segmentAudienceKey: COMPUTATION_KEY } } ] }, verboseFailureHint: FAILURE_HINT From 8dccb65dca91018c00553f4da15eb458239a4090 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 10:15:27 +0100 Subject: [PATCH 116/210] fix: fix Iterable Audiences batch assertion and disable getAudience - Remove body assertion (body is empty), keep sent email/userId checks - Disable getAudience temporarily (list not found after creation, needs investigation) Co-Authored-By: Claude Opus 4.6 --- .../src/destinations/iterable-audiences/__e2e__/index.ts | 2 +- .../iterable-audiences/syncAudience/__e2e__/fixtures.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts index 772eb5db27f..c689b33ce10 100644 --- a/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts @@ -9,7 +9,7 @@ export const config: E2EAudienceDestinationConfig = { audienceName: 'e2e_test_audience', audienceSettings: {}, createAudience: true, - getAudience: true, + getAudience: false, teardown: false } } diff --git a/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts index 9bd657da23f..0a161b41430 100644 --- a/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts @@ -79,9 +79,9 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200, sent: { email: 'e2e-aud-test-002@segment.com', userId: 'e2e-iterable-aud-user-002' }, body: { segmentAudienceKey: COMPUTATION_KEY } }, - { status: 200, sent: { email: 'e2e-aud-test-003@segment.com', userId: 'e2e-iterable-aud-user-003' }, body: { segmentAudienceKey: COMPUTATION_KEY } }, - { status: 200, sent: { email: 'e2e-aud-test-001@segment.com', userId: 'e2e-iterable-aud-user-001' }, body: { segmentAudienceKey: COMPUTATION_KEY } } + { 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 From 672ba45bd62619a7b4c889ab739e10e672645016 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 10:28:01 +0100 Subject: [PATCH 117/210] iterable fixture --- .../src/destinations/iterable-audiences/__e2e__/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts index c689b33ce10..772eb5db27f 100644 --- a/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts @@ -9,7 +9,7 @@ export const config: E2EAudienceDestinationConfig = { audienceName: 'e2e_test_audience', audienceSettings: {}, createAudience: true, - getAudience: false, + getAudience: true, teardown: false } } From a04f9f2a1b029bca6bef00c6a070b3ee82c124cb Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 13:15:15 +0100 Subject: [PATCH 118/210] feat: add e2e fixtures for Google Enhanced Conversions userList OAuth destination with createAudience/getAudience lifecycle. Settings support nested objects (oauth.refresh_token) via updated E2ESettingsObject type. Requires env vars: - E2E_GOOGLE_ADS_CUSTOMER_ID - E2E_GOOGLE_ADS_REFRESH_TOKEN - E2E_GOOGLE_ADS_CLIENT_ID - E2E_GOOGLE_ADS_CLIENT_SECRET - GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID - GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET - ADWORDS_DEVELOPER_TOKEN Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-types.ts | 6 +- .../__e2e__/index.ts | 23 ++++++++ .../userList/__e2e__/fixtures.ts | 57 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 41333a55731..119aa51a384 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -151,8 +151,12 @@ export interface E2ESettingsSecretValue { $env: string } +export interface E2ESettingsObject { + [key: string]: string | number | boolean | E2ESettingsSecretValue | E2ESettingsObject +} + export interface E2EDestinationConfig { - settings: Record + settings: E2ESettingsObject } export interface E2EAudienceConfig { 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..35f13df388d --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts @@ -0,0 +1,23 @@ +import type { E2EAudienceDestinationConfig } from '@segment/actions-core' + +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: 'e2e_test_user_list', + audienceSettings: { + supports_conversions: false, + external_id_type: 'CONTACT_INFO' + }, + createAudience: true, + getAudience: true, + teardown: false + } +} diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts new file mode 100644 index 00000000000..f045f6a77ac --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts @@ -0,0 +1,57 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } 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.' + +const fixtures: E2EFixture[] = [ + { + description: '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: 'Remove a user from 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: '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 + } +] + +export default fixtures From 41b73637fdc378230f664d94bb9f3dd51d9676d0 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 13:18:38 +0100 Subject: [PATCH 119/210] feat: add batch test for Google Enhanced Conversions userList Batch add/remove with batchWithMultistatus mode, asserting per-item status 200 via jsonContains. Co-Authored-By: Claude Opus 4.6 --- .../userList/__e2e__/fixtures.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts index f045f6a77ac..117282c98b1 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts @@ -51,6 +51,57 @@ const fixtures: E2EFixture[] = [ }), expect: { status: 'success' }, verboseFailureHint: FAILURE_HINT + }, + { + description: '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 }, + { status: 200 }, + { status: 200 } + ] + }, + verboseFailureHint: FAILURE_HINT } ] From b01b5fcab7ffd2f0ba3ca7e8e0871a8a99de53e0 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 13:23:14 +0100 Subject: [PATCH 120/210] feat: assert sent/body objects in Google userList batch multistatus Verifies each item has status 200 with sent and body objects present. Co-Authored-By: Claude Opus 4.6 --- .../userList/__e2e__/fixtures.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts index 117282c98b1..45edc15cbe1 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts @@ -96,9 +96,9 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 }, - { status: 200 } + { status: 200, sent: {}, body: {} }, + { status: 200, sent: {}, body: {} }, + { status: 200, sent: {}, body: {} } ] }, verboseFailureHint: FAILURE_HINT From 3ff0efb04ff9321c7887fcafce85d140bcc6610c Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 15:52:26 +0100 Subject: [PATCH 121/210] adding google enhanced conversions example test --- .../google-enhanced-conversions/__e2e__/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 35f13df388d..fbb2578d434 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts @@ -1,8 +1,11 @@ import type { E2EAudienceDestinationConfig } from '@segment/actions-core' +const audienceName = `e2e_test_user_list_${Date.now()}` // to ensure the audience name is unique across test runs + export const config: E2EAudienceDestinationConfig = { settings: { customerId: { $env: 'E2E_GOOGLE_ADS_CUSTOMER_ID' }, + //loginCustomerId: { $env: 'E2E_GOOGLE_ADS_LOGIN_CUSTOMER_ID' }, oauth: { access_token: 'will_be_refreshed', refresh_token: { $env: 'E2E_GOOGLE_ADS_REFRESH_TOKEN' }, @@ -11,7 +14,7 @@ export const config: E2EAudienceDestinationConfig = { } }, audience: { - audienceName: 'e2e_test_user_list', + audienceName, audienceSettings: { supports_conversions: false, external_id_type: 'CONTACT_INFO' From a4135c0f0c2b49fc99bcf835071a05f6d32785c8 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 16:08:46 +0100 Subject: [PATCH 122/210] adding comment to e2e test --- .../google-enhanced-conversions/__e2e__/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 index fbb2578d434..799218e5a54 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts @@ -1,3 +1,13 @@ +/** + * 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 } from '@segment/actions-core' const audienceName = `e2e_test_user_list_${Date.now()}` // to ensure the audience name is unique across test runs From c59590acecae3d13e04be35e7f1e9028c2467ee6 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 16:19:34 +0100 Subject: [PATCH 123/210] adding JourneysV1 helpers --- packages/core/src/e2e-helpers.ts | 42 +++++++++++++++++++++++++++++++- packages/core/src/e2e-types.ts | 26 ++++++++++++++++++++ packages/core/src/index.ts | 4 ++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index 8e8251baef0..1d6e76cf1b1 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -1,7 +1,10 @@ import type { SegmentEvent } from './segment-event' import type { JSONValue } from './json-object' -import type { E2EEngageAudienceEventOptions, E2EEngageAudienceEvent } from './e2e-types' +import type { E2EEngageAudienceEventOptions, E2EEngageAudienceEvent, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent } from './e2e-types' +/* + * Regular Segment Connections event + */ export function createE2EEvent( type: SegmentEvent['type'], name: string, @@ -16,6 +19,10 @@ export function createE2EEvent( } } +/* + * Engage Audience event + * Supports identify and track events + */ export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOptions): E2EEngageAudienceEvent { const { type, action, computationKey, computationId, externalAudienceId, eventName, userId, anonymousId, email, audienceFields, enrichedTraits } = options const membership = action === 'add' @@ -55,3 +62,36 @@ export function createE2EEngageAudienceEvent(opti 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, eventName, userId, anonymousId, email, audienceFields, enrichedTraits } = options + + const event = { + 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 }), + ...(email && { traits: { email } }) + }, + type: 'track', + event: eventName ?? 'Test Journeys V1 Audience Membership Event', + properties: { + ...(enrichedTraits as { [k: string]: JSONValue }) + } + } + + return event as E2EJourneysV1AudienceTrackEvent +} diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 119aa51a384..3549c74b770 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -112,6 +112,32 @@ export interface E2EEngageAudienceEventOptions } +export interface E2EJourneysV1AudienceEventOptions { + action: 'add' | 'remove' + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + eventName?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2EJourneysV1AudienceTrackEvent extends SegmentEvent { + type: 'track' + event: string + messageId: string + timestamp: string + context: { + personas: E2EEngageAudiencePersonas + traits?: { email?: string } + audienceFields?: Record + } + properties: { [k: string]: JSONValue } +} + export interface E2EEngageAudiencePersonas { computation_class: 'audience' computation_key: ComputationKey diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7217aa01219..6150bff3b6c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,7 +112,7 @@ export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' -export { createE2EEvent, createE2EEngageAudienceEvent } from './e2e-helpers' +export { createE2EEvent, createE2EEngageAudienceEvent, createE2EJourneysV1AudienceEvent } from './e2e-helpers' export type { E2EFixture, E2EBaseFixture, @@ -134,6 +134,8 @@ export type { E2EEngageAudienceTrackEvent, E2EEngageAudienceIdentifyEvent, E2EEngageAudiencePersonas, + E2EJourneysV1AudienceEventOptions, + E2EJourneysV1AudienceTrackEvent, E2EHttpSuccessCode, E2EHttpFailureCode } from './e2e-types' From 01365f6d61c1950313790355d76e6786ec3ce13d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 16:36:27 +0100 Subject: [PATCH 124/210] new file convention for e2e tests --- .../__e2e__/{fixtures.ts => fixtures.e2e.ts} | 0 .../__e2e__/{fixtures.ts => fixtures.e2e.ts} | 0 .../__e2e__/{fixtures.ts => fixtures.e2e.ts} | 0 .../__e2e__/{fixtures.ts => fixtures.e2e.ts} | 6 +- .../userList/__e2e__/journeysV1.e2e.ts | 82 +++++++++++++++++++ .../__e2e__/{fixtures.ts => fixtures.e2e.ts} | 0 .../__e2e__/{fixtures.ts => fixtures.e2e.ts} | 0 .../__e2e__/{fixtures.ts => fixtures.e2e.ts} | 0 8 files changed, 85 insertions(+), 3 deletions(-) rename packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/{fixtures.ts => fixtures.e2e.ts} (100%) rename packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/{fixtures.ts => fixtures.e2e.ts} (100%) rename packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/{fixtures.ts => fixtures.e2e.ts} (100%) rename packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/{fixtures.ts => fixtures.e2e.ts} (92%) create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV1.e2e.ts rename packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/{fixtures.ts => fixtures.e2e.ts} (100%) rename packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/{fixtures.ts => fixtures.e2e.ts} (100%) rename packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/{fixtures.ts => fixtures.e2e.ts} (100%) diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.e2e.ts similarity index 100% rename from packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.ts rename to packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.e2e.ts diff --git a/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts similarity index 100% rename from packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.ts rename to packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts diff --git a/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.e2e.ts similarity index 100% rename from packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.ts rename to packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.e2e.ts diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.e2e.ts similarity index 92% rename from packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts rename to packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.e2e.ts index 45edc15cbe1..59e6bc0fc0b 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.e2e.ts @@ -9,7 +9,7 @@ const FAILURE_HINT = 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHAN const fixtures: E2EFixture[] = [ { - description: 'Add a user to the customer match list via track event', + 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), @@ -31,7 +31,7 @@ const fixtures: E2EFixture[] = [ verboseFailureHint: FAILURE_HINT }, { - description: 'Remove a user from the customer match list via track event', + description: 'Engage Audience: Remove a user from the customer match list via track event', subscribe: 'event = "Audience Entered" or event = "Audience Exited"', mapping: { ...defaultValues(userList.fields), @@ -53,7 +53,7 @@ const fixtures: E2EFixture[] = [ verboseFailureHint: FAILURE_HINT }, { - description: 'Batch add and remove users from the customer match list', + 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), 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..426262fe530 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV1.e2e.ts @@ -0,0 +1,82 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EJourneysV1AudienceEvent } 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.' + +const fixtures: 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({ + action: 'add', + eventName: 'Audience Entered', + 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({ + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-journeys-user-002', + email: 'e2e-google-journeys-test-002@segment.com' + }), + createE2EJourneysV1AudienceEvent({ + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-journeys-user-003', + email: 'e2e-google-journeys-test-003@segment.com' + }), + createE2EJourneysV1AudienceEvent({ + action: 'add', + eventName: 'Audience Entered', + 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: {}, body: {} }, + { status: 200, sent: {}, body: {} }, + { status: 200, sent: {}, body: {} } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.e2e.ts similarity index 100% rename from packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.ts rename to packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.e2e.ts diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.e2e.ts similarity index 100% rename from packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.ts rename to packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.e2e.ts diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts similarity index 100% rename from packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.ts rename to packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts From d050fab5e42808c8b75c8fae7d91ad46e5c31762 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 17:45:33 +0100 Subject: [PATCH 125/210] adding retl initial tests --- packages/core/src/e2e-helpers.ts | 37 +++- packages/core/src/e2e-types.ts | 12 ++ packages/core/src/index.ts | 3 +- .../userList/__e2e__/fixtures.e2e.ts | 2 +- .../userList/__e2e__/journeysV1.e2e.ts | 40 +++++ .../userList/__e2e__/retl.e2e.ts | 161 ++++++++++++++++++ 6 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/retl.e2e.ts diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index 1d6e76cf1b1..84de322cec9 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -1,6 +1,6 @@ import type { SegmentEvent } from './segment-event' import type { JSONValue } from './json-object' -import type { E2EEngageAudienceEventOptions, E2EEngageAudienceEvent, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent } from './e2e-types' +import type { E2EEngageAudienceEventOptions, E2EEngageAudienceEvent, E2EEngageAudienceTrackEvent, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, E2ERetlAudienceEventOptions } from './e2e-types' /* * Regular Segment Connections event @@ -95,3 +95,38 @@ export function createE2EJourneysV1AudienceEvent( return event as E2EJourneysV1AudienceTrackEvent } + +/* + * 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): E2EEngageAudienceTrackEvent { + const { eventName, computationKey, computationId, externalAudienceId, userId, anonymousId, email, audienceFields, enrichedTraits } = options + const membership = eventName !== 'deleted' + + const event = { + 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 }), + ...(email && { traits: { email } }) + }, + type: 'track', + event: eventName, + properties: { + [computationKey]: membership, + ...(enrichedTraits as { [k: string]: JSONValue }) + } + } + + return event as E2EEngageAudienceTrackEvent +} diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 3549c74b770..274b1baa501 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -125,6 +125,18 @@ export interface E2EJourneysV1AudienceEventOptions } +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 E2EJourneysV1AudienceTrackEvent extends SegmentEvent { type: 'track' event: string diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6150bff3b6c..c376eba45d6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,7 +112,7 @@ export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' -export { createE2EEvent, createE2EEngageAudienceEvent, createE2EJourneysV1AudienceEvent } from './e2e-helpers' +export { createE2EEvent, createE2EEngageAudienceEvent, createE2EJourneysV1AudienceEvent, createE2ERetlAudienceEvent } from './e2e-helpers' export type { E2EFixture, E2EBaseFixture, @@ -136,6 +136,7 @@ export type { E2EEngageAudiencePersonas, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, + E2ERetlAudienceEventOptions, E2EHttpSuccessCode, E2EHttpFailureCode } from './e2e-types' diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.e2e.ts index 59e6bc0fc0b..f79e49b0602 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.e2e.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.e2e.ts @@ -32,7 +32,7 @@ const fixtures: E2EFixture[] = [ }, { description: 'Engage Audience: Remove a user from the customer match list via track event', - subscribe: 'event = "Audience Entered" or event = "Audience Exited"', + subscribe: 'event = "Audience Entered"', mapping: { ...defaultValues(userList.fields), ad_user_data_consent_state: 'GRANTED', 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 index 426262fe530..874fc4f98fa 100644 --- 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 @@ -76,6 +76,46 @@ const fixtures: E2EFixture[] = [ ] }, verboseFailureHint: FAILURE_HINT + }, + { + description: 'JourneysV1 Audience: Batch add users even when properties[computation_key] is false', + 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({ + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-journeys-user-005', + email: 'e2e-google-journeys-test-005@segment.com', + enrichedTraits: { [COMPUTATION_KEY]: false } + }), + createE2EJourneysV1AudienceEvent({ + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-journeys-user-006', + email: 'e2e-google-journeys-test-006@segment.com', + enrichedTraits: { [COMPUTATION_KEY]: false } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { email: 'e2e-google-journeys-test-005@segment.com' }, body: {} }, + { status: 200, sent: { email: 'e2e-google-journeys-test-006@segment.com' }, body: {} } + ] + }, + verboseFailureHint: FAILURE_HINT } ] 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..f0e24e33514 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/retl.e2e.ts @@ -0,0 +1,161 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2ERetlAudienceEvent } 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.' + +const fixtures: 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: { email: 'e2e-google-retl-001@segment.com' }, body: {} }, + { status: 200, sent: { email: 'e2e-google-retl-002@segment.com' }, body: {} } + ] + }, + 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: { email: 'e2e-google-retl-003@segment.com' }, body: {} }, + { status: 200, sent: { email: 'e2e-google-retl-004@segment.com' }, body: {} } + ] + }, + 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: { email: 'e2e-google-retl-005@segment.com' }, body: {} }, + { status: 200, sent: { email: 'e2e-google-retl-006@segment.com' }, body: {} } + ] + }, + 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: { email: 'e2e-google-retl-007@segment.com' }, body: {} }, + { status: 200, sent: { email: 'e2e-google-retl-008@segment.com' }, body: {} } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures From 59e8bbc1b9cf8b5fdbd75e0e96c655245c6d6444 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 2 Jun 2026 17:48:05 +0100 Subject: [PATCH 126/210] types --- packages/core/src/e2e-helpers.ts | 6 +++--- packages/core/src/e2e-types.ts | 13 +++++++++++++ packages/core/src/index.ts | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index 84de322cec9..ce4ca38b763 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -1,6 +1,6 @@ import type { SegmentEvent } from './segment-event' import type { JSONValue } from './json-object' -import type { E2EEngageAudienceEventOptions, E2EEngageAudienceEvent, E2EEngageAudienceTrackEvent, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, E2ERetlAudienceEventOptions } from './e2e-types' +import type { E2EEngageAudienceEventOptions, E2EEngageAudienceEvent, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, E2ERetlAudienceEventOptions, E2ERetlAudienceTrackEvent } from './e2e-types' /* * Regular Segment Connections event @@ -101,7 +101,7 @@ export function createE2EJourneysV1AudienceEvent( * 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): E2EEngageAudienceTrackEvent { +export function createE2ERetlAudienceEvent(options: E2ERetlAudienceEventOptions): E2ERetlAudienceTrackEvent { const { eventName, computationKey, computationId, externalAudienceId, userId, anonymousId, email, audienceFields, enrichedTraits } = options const membership = eventName !== 'deleted' @@ -128,5 +128,5 @@ export function createE2ERetlAudienceEvent(option } } - return event as E2EEngageAudienceTrackEvent + return event as E2ERetlAudienceTrackEvent } diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 274b1baa501..8aea9e3ced7 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -137,6 +137,19 @@ export interface E2ERetlAudienceEventOptions } +export interface E2ERetlAudienceTrackEvent extends SegmentEvent { + type: 'track' + event: 'new' | 'updated' | 'deleted' + messageId: string + timestamp: string + context: { + personas: E2EEngageAudiencePersonas + traits?: { email?: string } + audienceFields?: Record + } + properties: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + export interface E2EJourneysV1AudienceTrackEvent extends SegmentEvent { type: 'track' event: string diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c376eba45d6..a66c052775a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -137,6 +137,7 @@ export type { E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, E2ERetlAudienceEventOptions, + E2ERetlAudienceTrackEvent, E2EHttpSuccessCode, E2EHttpFailureCode } from './e2e-types' From 835dc74748ce571e96d2264be3cb1eddc8f3de39 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 10:07:32 +0100 Subject: [PATCH 127/210] refactor: extract shared builder in e2e helpers, remove unused action field - Extract buildAudienceEventBase() to eliminate duplicated context/personas construction across all three audience event helpers - Remove unused `action` field from E2EJourneysV1AudienceEventOptions and all fixture call sites Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-helpers.ts | 111 ++++++++++-------- packages/core/src/e2e-types.ts | 1 - .../userList/__e2e__/journeysV1.e2e.ts | 9 +- 3 files changed, 66 insertions(+), 55 deletions(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index ce4ca38b763..63e9ab4f4b7 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -1,9 +1,16 @@ import type { SegmentEvent } from './segment-event' import type { JSONValue } from './json-object' -import type { E2EEngageAudienceEventOptions, E2EEngageAudienceEvent, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, E2ERetlAudienceEventOptions, E2ERetlAudienceTrackEvent } from './e2e-types' +import type { + E2EEngageAudienceEventOptions, + E2EEngageAudienceEvent, + E2EJourneysV1AudienceEventOptions, + E2EJourneysV1AudienceTrackEvent, + E2ERetlAudienceEventOptions, + E2ERetlAudienceTrackEvent +} from './e2e-types' /* - * Regular Segment Connections event + * Regular Segment Connections event */ export function createE2EEvent( type: SegmentEvent['type'], @@ -19,15 +26,29 @@ export function createE2EEvent( } } -/* - * Engage Audience event - * Supports identify and track events - */ -export function createE2EEngageAudienceEvent(options: E2EEngageAudienceEventOptions): E2EEngageAudienceEvent { - const { type, action, computationKey, computationId, externalAudienceId, eventName, userId, anonymousId, email, audienceFields, enrichedTraits } = options - const membership = action === 'add' +interface AudienceEventBase { + computationKey: string + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + includeContextTraits?: boolean +} - const event = { +function buildAudienceEventBase(options: AudienceEventBase) { + const { + computationKey, + computationId, + externalAudienceId, + userId, + anonymousId, + email, + audienceFields, + includeContextTraits = true + } = options + return { messageId: '$guid', timestamp: '$now', ...(userId && { userId }), @@ -40,8 +61,24 @@ export function createE2EEngageAudienceEvent(opti ...(externalAudienceId && { external_audience_id: externalAudienceId }) }, ...(audienceFields && { audienceFields }), - ...(type === 'track' && email && { traits: { email } }) - }, + ...(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 + 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', @@ -63,29 +100,19 @@ export function createE2EEngageAudienceEvent(opti 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. +/* + * 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, eventName, userId, anonymousId, email, audienceFields, enrichedTraits } = options +export function createE2EJourneysV1AudienceEvent( + options: E2EJourneysV1AudienceEventOptions +): E2EJourneysV1AudienceTrackEvent { + const { eventName, enrichedTraits } = options + const base = buildAudienceEventBase(options) const event = { - 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 }), - ...(email && { traits: { email } }) - }, + ...base, type: 'track', event: eventName ?? 'Test Journeys V1 Audience Membership Event', properties: { @@ -101,25 +128,15 @@ export function createE2EJourneysV1AudienceEvent( * 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, computationId, externalAudienceId, userId, anonymousId, email, audienceFields, enrichedTraits } = options +export function createE2ERetlAudienceEvent( + options: E2ERetlAudienceEventOptions +): E2ERetlAudienceTrackEvent { + const { eventName, computationKey, enrichedTraits } = options const membership = eventName !== 'deleted' + const base = buildAudienceEventBase(options) const event = { - 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 }), - ...(email && { traits: { email } }) - }, + ...base, type: 'track', event: eventName, properties: { diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 8aea9e3ced7..469604f1ef3 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -113,7 +113,6 @@ export interface E2EEngageAudienceEventOptions { - action: 'add' | 'remove' computationKey: ComputationKey computationId: string externalAudienceId?: string 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 index 874fc4f98fa..b19ca26fc52 100644 --- 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 @@ -5,7 +5,8 @@ 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.' +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[] = [ { @@ -18,7 +19,6 @@ const fixtures: E2EFixture[] = [ }, mode: 'single', event: createE2EJourneysV1AudienceEvent({ - action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, @@ -40,7 +40,6 @@ const fixtures: E2EFixture[] = [ mode: 'batchWithMultistatus', events: [ createE2EJourneysV1AudienceEvent({ - action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, @@ -49,7 +48,6 @@ const fixtures: E2EFixture[] = [ email: 'e2e-google-journeys-test-002@segment.com' }), createE2EJourneysV1AudienceEvent({ - action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, @@ -58,7 +56,6 @@ const fixtures: E2EFixture[] = [ email: 'e2e-google-journeys-test-003@segment.com' }), createE2EJourneysV1AudienceEvent({ - action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, @@ -88,7 +85,6 @@ const fixtures: E2EFixture[] = [ mode: 'batchWithMultistatus', events: [ createE2EJourneysV1AudienceEvent({ - action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, @@ -98,7 +94,6 @@ const fixtures: E2EFixture[] = [ enrichedTraits: { [COMPUTATION_KEY]: false } }), createE2EJourneysV1AudienceEvent({ - action: 'add', eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, From b59c9dd351496dca971dee2205e83233904b6c68 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 10:33:09 +0100 Subject: [PATCH 128/210] refactor: move AudienceEventBase to e2e-types.ts Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-helpers.ts | 14 ++------------ packages/core/src/e2e-types.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index 63e9ab4f4b7..2e05dbeb8d6 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -1,6 +1,7 @@ import type { SegmentEvent } from './segment-event' import type { JSONValue } from './json-object' import type { + E2EAudienceEventBase, E2EEngageAudienceEventOptions, E2EEngageAudienceEvent, E2EJourneysV1AudienceEventOptions, @@ -26,18 +27,7 @@ export function createE2EEvent( } } -interface AudienceEventBase { - computationKey: string - computationId: string - externalAudienceId?: string - userId?: string - anonymousId?: string - email?: string - audienceFields?: Record - includeContextTraits?: boolean -} - -function buildAudienceEventBase(options: AudienceEventBase) { +function buildAudienceEventBase(options: E2EAudienceEventBase) { const { computationKey, computationId, diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 469604f1ef3..6d3bcdf7690 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -98,6 +98,17 @@ export interface E2EBatchWithMultistatusFixture extends E2EBaseFixture { 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' From fd57a5bca9def9ee28abe7b65d25d75ed03916ce Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 10:35:27 +0100 Subject: [PATCH 129/210] rename fixture file --- .../userList/__e2e__/{fixtures.e2e.ts => engage.e2e.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/{fixtures.e2e.ts => engage.e2e.ts} (100%) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/engage.e2e.ts similarity index 100% rename from packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/fixtures.e2e.ts rename to packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/engage.e2e.ts From 369897782f6efc17e760efe0fec3fb8c515f7dd1 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 11:32:20 +0100 Subject: [PATCH 130/210] STRATCONN-6780 - [Pinterest] - new events --- .../pinterest-conversions/constants.ts | 15 +- .../pinterest-capi-custom-data.ts | 42 ++- .../__snapshots__/index.test.ts.snap | 38 +++ .../__tests__/index.test.ts | 12 +- .../reportConversionEvent/generated-types.ts | 182 ++++++++++++- .../reportConversionEvent/index.ts | 240 +++++++++++++++++- 6 files changed, 518 insertions(+), 11 deletions(-) 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/pinterest-capi-custom-data.ts b/packages/destination-actions/src/destinations/pinterest-conversions/pinterest-capi-custom-data.ts index 00560fafb83..6631429ce20 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 @@ -42,6 +42,26 @@ export const custom_data_field: InputField = { label: 'quantity', type: 'integer', 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 +83,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.', + "opt_out_type is the field where we accept opt outs for your users' privacy preference. It 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: { 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 index 4830f19ea8d..b625dfb7d90 100644 --- 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 @@ -5,21 +5,40 @@ Object { "data": Array [ Object { "action_source": "web", + "advertiser_tracking_enabled": true, "app_id": undefined, + "app_info": Object { + "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", + }, "app_name": "InitechGlobal", "app_version": "545", "custom_data": Object { + "content_brand": undefined, + "content_category": undefined, "content_ids": undefined, + "content_name": undefined, "contents": undefined, "currency": undefined, + "np": "ss-segment", "num_items": 2, "opt_out_type": undefined, "order_id": undefined, + "predicted_ltv": undefined, "search_string": undefined, "value": "2000", }, "device_brand": undefined, "device_carrier": undefined, + "device_info": Object { + "model": "iPhone7,2", + "os_family": "iPhone OS", + "os_version": "8.1.3", + "timezone": "Europe/Amsterdam", + "type": "ios", + }, "device_model": "iPhone7,2", "device_type": "ios", "event_id": "test-message-rocnz07d5e8", @@ -83,21 +102,40 @@ Object { "data": Array [ Object { "action_source": "web", + "advertiser_tracking_enabled": true, "app_id": undefined, + "app_info": Object { + "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", + }, "app_name": "InitechGlobal", "app_version": "545", "custom_data": Object { + "content_brand": undefined, + "content_category": undefined, "content_ids": undefined, + "content_name": undefined, "contents": undefined, "currency": undefined, + "np": "ss-segment", "num_items": undefined, "opt_out_type": undefined, "order_id": undefined, + "predicted_ltv": undefined, "search_string": undefined, "value": undefined, }, "device_brand": undefined, "device_carrier": undefined, + "device_info": Object { + "model": "iPhone7,2", + "os_family": "iPhone OS", + "os_version": "8.1.3", + "timezone": "Europe/Amsterdam", + "type": "ios", + }, "device_model": "iPhone7,2", "device_type": "ios", "event_id": "test-message-rocnz07d5e8", 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..87c7effbcff 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 @@ -218,9 +218,15 @@ describe('PinterestConversionApi', () => { 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"}]}' - ) + const body = JSON.parse(responses[0].options.body as string) + expect(body.data[0].event_name).toBe('checkout') + expect(body.data[0].partner_name).toBe('ss-segment') + expect(body.data[0].advertiser_tracking_enabled).toBe(true) + expect(body.data[0].custom_data.np).toBe('ss-segment') + expect(body.data[0].user_data.em).toEqual(['c551027f06bd3f307ccd6abb61edc500def2680944c010e932ab5b27a3a8f151']) + expect(body.data[0].user_data.fn).toEqual(['44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d']) + expect(body.data[0].app_info.app_name).toBe('InitechGlobal') + expect(body.data[0].device_info.model).toBe('iPhone7,2') }) }) }) 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..d1e35857f33 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 @@ -25,6 +25,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.. */ @@ -126,6 +130,22 @@ export interface Payload { * 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 +160,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. + * opt_out_type is the field where we accept 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 } /** * The app store app ID. @@ -161,7 +197,7 @@ export interface Payload { */ device_brand?: string /** - * User device’s mobile carrier. + * User device's mobile carrier. */ device_carrier?: string /** @@ -184,4 +220,146 @@ export interface Payload { * Two-character ISO-639-1 language code indicating the user's language. */ language?: 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. Unix timestamp in seconds. + */ + install_time?: 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 + } } 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..501fb6f106d 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -20,15 +20,28 @@ const action: ActionDefinition = { type: 'string', required: true, choices: [ + { label: 'Add Payment Info', value: 'add_payment_info' }, { label: 'Add to Cart', value: 'add_to_cart' }, + { label: 'Add to Wishlist', value: 'add_to_wishlist' }, + { label: 'App Install', value: 'app_install' }, + { label: 'App Open', value: 'app_open' }, { label: 'Checkout', value: 'checkout' }, + { label: 'Contact', value: 'contact' }, + { label: 'Custom', value: 'custom' }, + { label: 'Customize Product', value: 'customize_product' }, + { label: 'Find Location', value: 'find_location' }, + { label: 'Initiate Checkout', value: 'initiate_checkout' }, { label: 'Lead', value: 'lead' }, { label: 'Page Visit', value: 'page_visit' }, + { label: 'Schedule', value: 'schedule' }, { label: 'Search', value: 'search' }, { label: 'Sign Up', value: 'signup' }, + { label: 'Start Trial', value: 'start_trial' }, + { label: 'Submit Application', value: 'submit_application' }, + { label: 'Subscribe', value: 'subscribe' }, { label: 'View Category', value: 'view_category' }, - { label: 'Watch Video', value: 'watch_video' }, - { label: 'Custom', value: 'custom' } + { label: 'View Content', value: 'view_content' }, + { label: 'Watch Video', value: 'watch_video' } ] }, action_source: { @@ -80,6 +93,14 @@ 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, app_id: { @@ -117,7 +138,7 @@ const action: ActionDefinition = { }, device_carrier: { label: 'Device Carrier', - description: 'User device’s mobile carrier. ', + description: "User device's mobile carrier.", type: 'string', default: { '@path': 'context.device.carrier' @@ -159,6 +180,206 @@ const action: ActionDefinition = { label: 'Language', description: "Two-character ISO-639-1 language code indicating the user's language.", type: 'string' + }, + app_info: { + label: 'App Info', + description: 'Object containing information about the application where event occurred.', + type: 'object', + 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: 'integer', + description: 'App install time. Unix timestamp in seconds.' + }, + 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_id: { '@path': '$.context.app.id' }, + app_name: { '@path': '$.context.app.name' }, + app_package_name: { '@path': '$.context.app.namespace' }, + app_version: { '@path': '$.context.app.version' }, + user_agent: { '@path': '$.context.userAgent' } + } + }, + device_info: { + label: 'Device Info', + description: 'Object containing information about the device where event occurred.', + type: 'object', + 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.brand' }, + carrier: { '@path': '$.context.device.carrier' }, + model: { '@path': '$.context.device.model' }, + type: { '@path': '$.context.device.type' }, + os_family: { '@path': '$.context.os.name' }, + 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' } + } } }, perform: async (request, { settings, payload }) => { @@ -197,6 +418,7 @@ 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, @@ -209,16 +431,26 @@ function createPinterestPayload(payload: Payload) { 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 + 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 }, app_id: payload.app_id, app_name: payload.app_name, app_version: payload.app_version, + app_info: payload.app_info, 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, + device_info: payload.device_info, wifi: payload.wifi, language: payload.language } From f589a84afa1e574010ac1ea17fcbaa2e27e3f4ad Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 12:10:00 +0100 Subject: [PATCH 131/210] tweaks to default mappings --- .../reportConversionEvent/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 501fb6f106d..42933d9a652 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -233,11 +233,13 @@ const action: ActionDefinition = { } }, default: { - app_id: { '@path': '$.context.app.id' }, + app_id: { '@path': '$.context.app.build' }, app_name: { '@path': '$.context.app.name' }, app_package_name: { '@path': '$.context.app.namespace' }, app_version: { '@path': '$.context.app.version' }, - user_agent: { '@path': '$.context.userAgent' } + user_agent: { '@path': '$.context.userAgent' }, + window_height: { '@path': '$.context.screen.height' }, + window_width: { '@path': '$.context.screen.width' } } }, device_info: { @@ -368,8 +370,8 @@ const action: ActionDefinition = { } }, default: { - brand: { '@path': '$.context.device.brand' }, - carrier: { '@path': '$.context.device.carrier' }, + brand: { '@path': '$.context.device.manufacturer' }, + carrier: { '@path': '$.context.network.carrier' }, model: { '@path': '$.context.device.model' }, type: { '@path': '$.context.device.type' }, os_family: { '@path': '$.context.os.name' }, From 1942c9c41dda86d261d5abadb04efc85e064993b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 12:29:07 +0100 Subject: [PATCH 132/210] fix default mappings and install_time type - Map app_info.app_id to context.app.build - Map device_info.brand to context.device.manufacturer - Map device_info.carrier to context.network.carrier - Add window_height/window_width defaults to app_info - Change install_time to datetime with unix conversion Co-Authored-By: Claude Opus 4.6 --- .../__tests__/__snapshots__/index.test.ts.snap | 6 ++++++ .../reportConversionEvent/generated-types.ts | 4 ++-- .../reportConversionEvent/index.ts | 14 ++++++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) 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 index b625dfb7d90..8dc11a32c41 100644 --- 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 @@ -8,9 +8,11 @@ Object { "advertiser_tracking_enabled": true, "app_id": undefined, "app_info": Object { + "app_id": "3.0.1.545", "app_name": "InitechGlobal", "app_package_name": "com.production.segment", "app_version": "545", + "install_time": undefined, "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", }, "app_name": "InitechGlobal", @@ -33,6 +35,7 @@ Object { "device_brand": undefined, "device_carrier": undefined, "device_info": Object { + "brand": "Apple", "model": "iPhone7,2", "os_family": "iPhone OS", "os_version": "8.1.3", @@ -105,9 +108,11 @@ Object { "advertiser_tracking_enabled": true, "app_id": undefined, "app_info": Object { + "app_id": "3.0.1.545", "app_name": "InitechGlobal", "app_package_name": "com.production.segment", "app_version": "545", + "install_time": undefined, "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", }, "app_name": "InitechGlobal", @@ -130,6 +135,7 @@ Object { "device_brand": undefined, "device_carrier": undefined, "device_info": Object { + "brand": "Apple", "model": "iPhone7,2", "os_family": "iPhone OS", "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 d1e35857f33..8106c47946d 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 @@ -245,9 +245,9 @@ export interface Payload { */ app_version?: string /** - * App install time. Unix timestamp in seconds. + * App install time. Will be converted to Unix timestamp in seconds before sending. */ - install_time?: number + install_time?: string | number /** * User Agent request header. */ 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 42933d9a652..fb05e7d004a 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -213,8 +213,8 @@ const action: ActionDefinition = { }, install_time: { label: 'Install Time', - type: 'integer', - description: 'App install time. Unix timestamp in seconds.' + type: 'datetime', + description: 'App install time. Will be converted to Unix timestamp in seconds before sending.' }, user_agent: { label: 'User Agent', @@ -306,7 +306,8 @@ const action: ActionDefinition = { network_type: { label: 'Network Type', type: 'string', - description: 'Network type (wifi, cellular_2g, cellular_3g, cellular_4g, cellular_5g, cellular_6g, ethernet, unknown).' + description: + 'Network type (wifi, cellular_2g, cellular_3g, cellular_4g, cellular_5g, cellular_6g, ethernet, unknown).' }, os_family: { label: 'OS Family', @@ -446,7 +447,12 @@ function createPinterestPayload(payload: Payload) { app_id: payload.app_id, app_name: payload.app_name, app_version: payload.app_version, - app_info: payload.app_info, + app_info: payload.app_info + ? { + ...payload.app_info, + install_time: payload.app_info.install_time ? dayjs.utc(payload.app_info.install_time).unix() : undefined + } + : undefined, device_brand: payload.device_brand, device_carrier: payload.device_carrier, device_model: payload.device_model, From 5c7733bdf9b65fc3ddc77fba310b5eb9191cdc55 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 12:34:24 +0100 Subject: [PATCH 133/210] build app_info externally and omit if empty Co-Authored-By: Claude Opus 4.6 --- .../reportConversionEvent/index.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 fb05e7d004a..88d5ef69737 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -411,6 +411,15 @@ async function processPayload(request: RequestClient, settings: Settings, payloa }) } +function buildAppInfo(payload: Payload) { + const appInfo = { + ...payload.app_info, + install_time: payload.app_info?.install_time ? dayjs.utc(payload.app_info.install_time).unix() : undefined + } + const hasContent = Object.values(appInfo).some((v) => v !== undefined && v !== null) + return hasContent ? appInfo : undefined +} + function createPinterestPayload(payload: Payload) { return [ { @@ -447,12 +456,7 @@ function createPinterestPayload(payload: Payload) { app_id: payload.app_id, app_name: payload.app_name, app_version: payload.app_version, - app_info: payload.app_info - ? { - ...payload.app_info, - install_time: payload.app_info.install_time ? dayjs.utc(payload.app_info.install_time).unix() : undefined - } - : undefined, + app_info: buildAppInfo(payload), device_brand: payload.device_brand, device_carrier: payload.device_carrier, device_model: payload.device_model, From 30845f78e3b9f58c75b6d8946277a031576ac5a8 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 12:43:27 +0100 Subject: [PATCH 134/210] restore full body assertion for hashed data test Co-Authored-By: Claude Opus 4.6 --- .../__tests__/index.test.ts | 65 ++++++++++++++++--- 1 file changed, 56 insertions(+), 9 deletions(-) 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 87c7effbcff..d8f54936b50 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 @@ -217,16 +217,63 @@ describe('PinterestConversionApi', () => { }) expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) - expect(JSON.parse(responses[0]?.options?.body as string)?.data?.length).toBe(1) const body = JSON.parse(responses[0].options.body as string) - expect(body.data[0].event_name).toBe('checkout') - expect(body.data[0].partner_name).toBe('ss-segment') - expect(body.data[0].advertiser_tracking_enabled).toBe(true) - expect(body.data[0].custom_data.np).toBe('ss-segment') - expect(body.data[0].user_data.em).toEqual(['c551027f06bd3f307ccd6abb61edc500def2680944c010e932ab5b27a3a8f151']) - expect(body.data[0].user_data.fn).toEqual(['44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d']) - expect(body.data[0].app_info.app_name).toBe('InitechGlobal') - expect(body.data[0].device_info.model).toBe('iPhone7,2') + expect(body.data.length).toBe(1) + expect(body.data[0]).toEqual({ + 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, + advertiser_tracking_enabled: 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, + np: 'ss-segment' + }, + app_name: 'InitechGlobal', + app_version: '545', + app_info: { + app_id: '3.0.1.545', + 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' + }, + device_model: 'iPhone7,2', + device_type: 'ios', + os_version: '8.1.3', + device_info: { + brand: 'Apple', + model: 'iPhone7,2', + type: 'ios', + os_family: 'iPhone OS', + os_version: '8.1.3', + timezone: 'Europe/Amsterdam' + } + }) }) }) }) From 70cf765bb5746a3afb2f78c39be78bde97df1199 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 13:09:34 +0100 Subject: [PATCH 135/210] add data_format toggle to switch between legacy and structured fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a `data_format` choice field that controls field visibility: - "Structured Fields" (default for new instances): shows flat custom data fields, contents array, app_info, and device_info objects - "Legacy Fields" (or undefined for existing instances): shows the original nested custom_data object and flat app/device fields Existing integrations are unaffected — they have no value for data_format (undefined), which is treated as legacy via depends_on conditions. Co-Authored-By: Claude Opus 4.6 --- .../pinterest-capi-custom-data.ts | 12 +- .../__snapshots__/index.test.ts.snap | 201 --------- .../__tests__/index.test.ts | 420 +++++++++++------- .../reportConversionEvent/generated-types.ts | 93 +++- .../reportConversionEvent/index.ts | 323 +++++++++++--- 5 files changed, 631 insertions(+), 418 deletions(-) delete mode 100644 packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/__snapshots__/index.test.ts.snap 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 6631429ce20..549fc8d2607 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,11 @@ -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 custom_data_field = (dependsOn: DependsOnConditions): InputField => ({ + label: '[Legacy] Custom Data', + description: + 'Object containing custom event data. This is the legacy format — use the new individual fields (Custom Data, Contents) when "Use Structured Fields" is selected.', type: 'object', + depends_on: dependsOn, properties: { currency: { label: 'Currency', @@ -125,4 +127,4 @@ export const custom_data_field: InputField = { '@path': '$.properties.currency' } } -} +}) 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 8dc11a32c41..00000000000 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/__snapshots__/index.test.ts.snap +++ /dev/null @@ -1,201 +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", - "advertiser_tracking_enabled": true, - "app_id": undefined, - "app_info": Object { - "app_id": "3.0.1.545", - "app_name": "InitechGlobal", - "app_package_name": "com.production.segment", - "app_version": "545", - "install_time": undefined, - "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", - }, - "app_name": "InitechGlobal", - "app_version": "545", - "custom_data": Object { - "content_brand": undefined, - "content_category": undefined, - "content_ids": undefined, - "content_name": undefined, - "contents": undefined, - "currency": undefined, - "np": "ss-segment", - "num_items": 2, - "opt_out_type": undefined, - "order_id": undefined, - "predicted_ltv": undefined, - "search_string": undefined, - "value": "2000", - }, - "device_brand": undefined, - "device_carrier": undefined, - "device_info": Object { - "brand": "Apple", - "model": "iPhone7,2", - "os_family": "iPhone OS", - "os_version": "8.1.3", - "timezone": "Europe/Amsterdam", - "type": "ios", - }, - "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", - "advertiser_tracking_enabled": true, - "app_id": undefined, - "app_info": Object { - "app_id": "3.0.1.545", - "app_name": "InitechGlobal", - "app_package_name": "com.production.segment", - "app_version": "545", - "install_time": undefined, - "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", - }, - "app_name": "InitechGlobal", - "app_version": "545", - "custom_data": Object { - "content_brand": undefined, - "content_category": undefined, - "content_ids": undefined, - "content_name": undefined, - "contents": undefined, - "currency": undefined, - "np": "ss-segment", - "num_items": undefined, - "opt_out_type": undefined, - "order_id": undefined, - "predicted_ltv": undefined, - "search_string": undefined, - "value": undefined, - }, - "device_brand": undefined, - "device_carrier": undefined, - "device_info": Object { - "brand": "Apple", - "model": "iPhone7,2", - "os_family": "iPhone OS", - "os_version": "8.1.3", - "timezone": "Europe/Amsterdam", - "type": "ios", - }, - "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 d8f54936b50..7c50b9b2241 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,138 +99,290 @@ 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, {}) + 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: { - 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' + 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].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() }) - 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, {}) + 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: { - 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 + 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() }) - 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]).toEqual({ - 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, - advertiser_tracking_enabled: 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, - np: 'ss-segment' - }, - app_name: 'InitechGlobal', - app_version: '545', - app_info: { + + 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() + }) + }) + + describe('structured mode', () => { + it('should send event using structured 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: 'structured', + 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' + }, + 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].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_id: '3.0.1.545', 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' - }, - device_model: 'iPhone7,2', - device_type: 'ios', - os_version: '8.1.3', - device_info: { + }) + expect(body.data[0].device_info).toEqual({ brand: 'Apple', model: 'iPhone7,2', type: 'ios', os_family: 'iPhone OS', 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 structured 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: 'structured', + 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' + }, + 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') }) }) }) 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 8106c47946d..36180a73435 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 { + /** + * Controls which fields are displayed. "Structured Fields" uses the new app_info, device_info, and flat custom data fields. "Legacy Fields" uses the original nested custom_data object and flat app/device 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). */ @@ -99,7 +103,7 @@ export interface Payload { partner_id?: string | null } /** - * Object containing customer information data. + * Object containing custom event data. This is the legacy format — use the new individual fields (Custom Data, Contents) when "Use Structured Fields" is selected. */ custom_data?: { /** @@ -187,7 +191,7 @@ export interface Payload { /** * Name of the app. */ - app_name: string + app_name?: string /** * Version of the app. */ @@ -213,13 +217,82 @@ export interface Payload { */ os_version?: string /** - * Whether the event occurred when the user device was connected to wifi. + * ISO-4217 currency code. If not provided, it will default to the currency set for the ad account. */ - wifi?: boolean + currency?: string /** - * Two-character ISO-639-1 language code indicating the user's language. + * 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. */ - language?: string + value?: number + /** + * Product IDs as an array of strings. + */ + content_ids?: string[] + /** + * 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 + }[] + /** + * 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 /** * Object containing information about the application where event occurred. */ @@ -362,4 +435,12 @@ export interface Payload { */ type?: string } + /** + * Whether the event occurred when the user device was connected to wifi. + */ + wifi?: boolean + /** + * Two-character ISO-639-1 language code indicating the user's language. + */ + language?: string } 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 88d5ef69737..9f765342516 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -1,5 +1,6 @@ import type { ActionDefinition, RequestClient } from '@segment/actions-core' import { IntegrationError } from '@segment/actions-core' +import type { DependsOnConditions } from '@segment/actions-core/destination-kit/types' import { API_VERSION, PARTNER_NAME } from '../constants' import type { Settings } from '../generated-types' import { custom_data_field } from '../pinterest-capi-custom-data' @@ -8,11 +9,34 @@ import type { Payload } from './generated-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_STRUCTURED: DependsOnConditions = { + conditions: [{ fieldKey: 'data_format', operator: 'is', value: 'structured' }] +} + 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: 'Data Format', + description: + 'Controls which fields are displayed. "Structured Fields" uses the new app_info, device_info, and flat custom data fields. "Legacy Fields" uses the original nested custom_data object and flat app/device fields.', + type: 'string', + choices: [ + { label: 'Structured Fields', value: 'structured' }, + { label: 'Legacy Fields', value: 'legacy' } + ], + default: 'structured' + }, event_name: { label: 'Event Name', description: @@ -102,89 +126,236 @@ const action: ActionDefinition = { } }, user_data: user_data_field, - custom_data: custom_data_field, + + // --- Legacy fields (shown when data_format is 'legacy' or undefined) --- + custom_data: custom_data_field(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, + 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', + 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' } }, - wifi: { - label: 'Wifi', - description: 'Whether the event occurred when the user device was connected to wifi.', - type: 'boolean', + + // --- Structured fields (shown when data_format is 'structured') --- + currency: { + label: 'Currency', + description: 'ISO-4217 currency code. If not provided, it will default to the currency set for the ad account.', + type: 'string', + depends_on: DEPENDS_ON_STRUCTURED, default: { - '@path': '$.context.network.wifi' + '@path': '$.properties.currency' } }, - language: { - label: 'Language', - description: "Two-character ISO-639-1 language code indicating the user's language.", - type: 'string' + value: { + label: 'Value', + 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.', + type: 'number', + depends_on: DEPENDS_ON_STRUCTURED, + default: { + '@if': { + exists: { '@path': '$.properties.price' }, + then: { '@path': '$.properties.price' }, + else: { '@path': '$.properties.value' } + } + } + }, + content_ids: { + label: 'Content IDs', + description: 'Product IDs as an array of strings.', + type: 'string', + multiple: true, + depends_on: DEPENDS_ON_STRUCTURED, + default: { + '@path': '$.properties.content_ids' + } + }, + contents: { + label: 'Contents', + description: 'A list of objects containing information about products.', + type: 'object', + multiple: true, + depends_on: DEPENDS_ON_STRUCTURED, + 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' } + } + ] + } + }, + num_items: { + label: 'Number of Items', + description: 'Total number of products in the event.', + type: 'integer', + depends_on: DEPENDS_ON_STRUCTURED, + default: { + '@path': '$.properties.num_items' + } + }, + order_id: { + label: 'Order ID', + description: 'The order ID.', + type: 'string', + depends_on: DEPENDS_ON_STRUCTURED, + default: { + '@path': '$.properties.order_id' + } + }, + search_string: { + label: 'Search String', + description: 'Search string related to the conversion event.', + type: 'string', + depends_on: DEPENDS_ON_STRUCTURED, + default: { + '@path': '$.properties.query' + } + }, + opt_out_type: { + label: 'Opt Out Type', + description: + "The field where Pinterest accepts opt outs for your users' privacy preference. It can handle multiple values with commas separated.", + type: 'string', + depends_on: DEPENDS_ON_STRUCTURED + }, + content_brand: { + label: 'Content Brand', + description: 'The brand of the content associated with the event.', + type: 'string', + depends_on: DEPENDS_ON_STRUCTURED + }, + content_category: { + label: 'Content Category', + description: 'The category of the content associated with the event.', + type: 'string', + depends_on: DEPENDS_ON_STRUCTURED + }, + content_name: { + label: 'Content Name', + description: 'The name of the page or product associated with the event.', + type: 'string', + depends_on: DEPENDS_ON_STRUCTURED + }, + predicted_ltv: { + label: 'Predicted LTV', + description: 'Predicted lifetime value of user associated with the event.', + type: 'number', + depends_on: DEPENDS_ON_STRUCTURED }, app_info: { label: 'App Info', description: 'Object containing information about the application where event occurred.', type: 'object', + depends_on: DEPENDS_ON_STRUCTURED, properties: { app_id: { label: 'App ID', @@ -246,6 +417,7 @@ const action: ActionDefinition = { label: 'Device Info', description: 'Object containing information about the device where event occurred.', type: 'object', + depends_on: DEPENDS_ON_STRUCTURED, properties: { battery_level: { label: 'Battery Level', @@ -383,12 +555,28 @@ const action: ActionDefinition = { 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.', + type: 'boolean', + default: { + '@path': '$.context.network.wifi' + } + }, + language: { + label: 'Language', + description: "Two-character ISO-639-1 language code indicating the user's language.", + type: 'string' } }, perform: async (request, { settings, payload }) => { return processPayload(request, settings, payload) } } + async function processPayload(request: RequestClient, settings: Settings, payload: Payload) { if ( isEmpty(payload.user_data?.email) && @@ -420,7 +608,60 @@ function buildAppInfo(payload: Payload) { 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) { + const isStructured = payload.data_format === 'structured' + + if (isStructured) { + return { + currency: payload.currency, + value: typeof payload.value === 'number' ? String(payload.value) : undefined, + content_ids: payload.content_ids, + contents: payload.contents?.map((item) => ({ + ...item, + item_price: typeof item.item_price === 'number' ? String(item.item_price) : undefined + })), + num_items: payload.num_items, + order_id: payload.order_id, + search_string: payload.search_string, + opt_out_type: payload.opt_out_type, + content_brand: payload.content_brand, + content_category: payload.content_category, + content_name: payload.content_name, + predicted_ltv: typeof payload.predicted_ltv === 'number' ? String(payload.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) { + const isStructured = payload.data_format === 'structured' + return [ { event_name: payload.event_name, @@ -432,37 +673,17 @@ function createPinterestPayload(payload: Payload) { 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, - 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 - }, - app_id: payload.app_id, - app_name: payload.app_name, - app_version: payload.app_version, - app_info: buildAppInfo(payload), - 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, - device_info: payload.device_info, + 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 } From 23c2f35d5867c973bbad3fde7e5b27c5ff1d0923 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 13:23:08 +0100 Subject: [PATCH 136/210] extract custom_data_2 and contents as separate structured fields - custom_data_2: object field with all custom data properties except contents - contents: standalone array field with proper @arrayPath default mapping - Both defined with depends_on DEPENDS_ON_STRUCTURED - custom_data_field_2 exported from pinterest-capi-custom-data.ts Co-Authored-By: Claude Opus 4.6 --- .../pinterest-capi-custom-data.ts | 79 ++++++++++++ .../__tests__/index.test.ts | 14 +- .../reportConversionEvent/generated-types.ts | 89 +++++++------ .../reportConversionEvent/index.ts | 120 +++--------------- 4 files changed, 152 insertions(+), 150 deletions(-) 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 549fc8d2607..7e68a16ddf9 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 @@ -128,3 +128,82 @@ export const custom_data_field = (dependsOn: DependsOnConditions): InputField => } } }) + +export const custom_data_field_2 = (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' } + } +}) 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 7c50b9b2241..a15a1562205 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 @@ -282,9 +282,11 @@ describe('PinterestConversionApi', () => { 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' }, - value: 2000, - num_items: 2, - currency: 'USD', + custom_data_2: { + value: 2000, + num_items: 2, + currency: 'USD' + }, contents: [ { id: 'sku_123', @@ -368,8 +370,10 @@ describe('PinterestConversionApi', () => { 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' }, - value: 2000, - num_items: 2 + custom_data_2: { + value: 2000, + num_items: 2 + } } }) expect(responses.length).toBe(1) 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 36180a73435..b574b14e798 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 @@ -217,17 +217,54 @@ export interface Payload { */ os_version?: string /** - * ISO-4217 currency code. If not provided, it will default to the currency set for the ad account. + * Object containing custom event data. */ - 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[] + 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. */ @@ -261,38 +298,6 @@ export interface Payload { */ item_name?: 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 /** * Object containing information about the application where event occurred. */ 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 9f765342516..a84c5cc8eb6 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -3,7 +3,7 @@ import { IntegrationError } from '@segment/actions-core' import type { DependsOnConditions } from '@segment/actions-core/destination-kit/types' import { API_VERSION, PARTNER_NAME } from '../constants' import type { Settings } from '../generated-types' -import { custom_data_field } from '../pinterest-capi-custom-data' +import { custom_data_field, custom_data_field_2 } from '../pinterest-capi-custom-data' import { user_data_field, hash_user_data } from '../pinterset-capi-user-data' import type { Payload } from './generated-types' import isEmpty from 'lodash/isEmpty' @@ -142,6 +142,7 @@ const action: ActionDefinition = { label: '[Legacy] App Name', description: 'Name of the app.', type: 'string', + required: DEPENDS_ON_LEGACY, depends_on: DEPENDS_ON_LEGACY, default: { '@path': '$.context.app.name' @@ -203,39 +204,7 @@ const action: ActionDefinition = { }, // --- Structured fields (shown when data_format is 'structured') --- - currency: { - label: 'Currency', - description: 'ISO-4217 currency code. If not provided, it will default to the currency set for the ad account.', - type: 'string', - depends_on: DEPENDS_ON_STRUCTURED, - default: { - '@path': '$.properties.currency' - } - }, - value: { - label: 'Value', - 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.', - type: 'number', - depends_on: DEPENDS_ON_STRUCTURED, - default: { - '@if': { - exists: { '@path': '$.properties.price' }, - then: { '@path': '$.properties.price' }, - else: { '@path': '$.properties.value' } - } - } - }, - content_ids: { - label: 'Content IDs', - description: 'Product IDs as an array of strings.', - type: 'string', - multiple: true, - depends_on: DEPENDS_ON_STRUCTURED, - default: { - '@path': '$.properties.content_ids' - } - }, + custom_data_2: custom_data_field_2(DEPENDS_ON_STRUCTURED), contents: { label: 'Contents', description: 'A list of objects containing information about products.', @@ -293,64 +262,6 @@ const action: ActionDefinition = { ] } }, - num_items: { - label: 'Number of Items', - description: 'Total number of products in the event.', - type: 'integer', - depends_on: DEPENDS_ON_STRUCTURED, - default: { - '@path': '$.properties.num_items' - } - }, - order_id: { - label: 'Order ID', - description: 'The order ID.', - type: 'string', - depends_on: DEPENDS_ON_STRUCTURED, - default: { - '@path': '$.properties.order_id' - } - }, - search_string: { - label: 'Search String', - description: 'Search string related to the conversion event.', - type: 'string', - depends_on: DEPENDS_ON_STRUCTURED, - default: { - '@path': '$.properties.query' - } - }, - opt_out_type: { - label: 'Opt Out Type', - description: - "The field where Pinterest accepts opt outs for your users' privacy preference. It can handle multiple values with commas separated.", - type: 'string', - depends_on: DEPENDS_ON_STRUCTURED - }, - content_brand: { - label: 'Content Brand', - description: 'The brand of the content associated with the event.', - type: 'string', - depends_on: DEPENDS_ON_STRUCTURED - }, - content_category: { - label: 'Content Category', - description: 'The category of the content associated with the event.', - type: 'string', - depends_on: DEPENDS_ON_STRUCTURED - }, - content_name: { - label: 'Content Name', - description: 'The name of the page or product associated with the event.', - type: 'string', - depends_on: DEPENDS_ON_STRUCTURED - }, - predicted_ltv: { - label: 'Predicted LTV', - description: 'Predicted lifetime value of user associated with the event.', - type: 'number', - depends_on: DEPENDS_ON_STRUCTURED - }, app_info: { label: 'App Info', description: 'Object containing information about the application where event occurred.', @@ -619,21 +530,24 @@ function buildCustomData(payload: Payload) { if (isStructured) { return { - currency: payload.currency, - value: typeof payload.value === 'number' ? String(payload.value) : undefined, - content_ids: payload.content_ids, + 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.num_items, - order_id: payload.order_id, - search_string: payload.search_string, - opt_out_type: payload.opt_out_type, - content_brand: payload.content_brand, - content_category: payload.content_category, - content_name: payload.content_name, - predicted_ltv: typeof payload.predicted_ltv === 'number' ? String(payload.predicted_ltv) : 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 } } From 5bd93607ba0fd87aa164082bf1077c2f633fc940 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 13:29:44 +0100 Subject: [PATCH 137/210] move contents_field to pinterest-capi-custom-data.ts Co-Authored-By: Claude Opus 4.6 --- .../pinterest-capi-custom-data.ts | 61 ++++++++++++++++++- .../reportConversionEvent/index.ts | 60 +----------------- 2 files changed, 61 insertions(+), 60 deletions(-) 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 7e68a16ddf9..3c09b6d2eab 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 @@ -2,8 +2,7 @@ import { InputField, DependsOnConditions } from '@segment/actions-core/destinati export const custom_data_field = (dependsOn: DependsOnConditions): InputField => ({ label: '[Legacy] Custom Data', - description: - 'Object containing custom event data. This is the legacy format — use the new individual fields (Custom Data, Contents) when "Use Structured Fields" is selected.', + description: 'Object containing custom event data.', type: 'object', depends_on: dependsOn, properties: { @@ -207,3 +206,61 @@ export const custom_data_field_2 = (dependsOn: DependsOnConditions): InputField search_string: { '@path': '$.properties.query' } } }) + +export const contents_field = (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/index.ts b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts index a84c5cc8eb6..90bd5662419 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -3,7 +3,7 @@ import { IntegrationError } from '@segment/actions-core' import type { DependsOnConditions } from '@segment/actions-core/destination-kit/types' import { API_VERSION, PARTNER_NAME } from '../constants' import type { Settings } from '../generated-types' -import { custom_data_field, custom_data_field_2 } from '../pinterest-capi-custom-data' +import { custom_data_field, custom_data_field_2, contents_field } from '../pinterest-capi-custom-data' import { user_data_field, hash_user_data } from '../pinterset-capi-user-data' import type { Payload } from './generated-types' import isEmpty from 'lodash/isEmpty' @@ -205,63 +205,7 @@ const action: ActionDefinition = { // --- Structured fields (shown when data_format is 'structured') --- custom_data_2: custom_data_field_2(DEPENDS_ON_STRUCTURED), - contents: { - label: 'Contents', - description: 'A list of objects containing information about products.', - type: 'object', - multiple: true, - depends_on: DEPENDS_ON_STRUCTURED, - 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' } - } - ] - } - }, + contents: contents_field(DEPENDS_ON_STRUCTURED), app_info: { label: 'App Info', description: 'Object containing information about the application where event occurred.', From 94688779b4084d7188501118f76e293ce8ede9be Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 13:42:21 +0100 Subject: [PATCH 138/210] rename to latest, rename field factories to getX pattern Co-Authored-By: Claude Opus 4.6 --- .../pinterest-capi-custom-data.ts | 6 ++-- .../__tests__/index.test.ts | 10 +++---- .../reportConversionEvent/index.ts | 28 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) 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 3c09b6d2eab..2d7fcf8b353 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,6 +1,6 @@ import { InputField, DependsOnConditions } from '@segment/actions-core/destination-kit/types' -export const custom_data_field = (dependsOn: DependsOnConditions): InputField => ({ +export const getCustomDataField = (dependsOn: DependsOnConditions): InputField => ({ label: '[Legacy] Custom Data', description: 'Object containing custom event data.', type: 'object', @@ -128,7 +128,7 @@ export const custom_data_field = (dependsOn: DependsOnConditions): InputField => } }) -export const custom_data_field_2 = (dependsOn: DependsOnConditions): InputField => ({ +export const getCustomDataField2 = (dependsOn: DependsOnConditions): InputField => ({ label: 'Custom Data', description: 'Object containing custom event data.', type: 'object', @@ -207,7 +207,7 @@ export const custom_data_field_2 = (dependsOn: DependsOnConditions): InputField } }) -export const contents_field = (dependsOn: DependsOnConditions): InputField => ({ +export const getContentsField = (dependsOn: DependsOnConditions): InputField => ({ label: 'Contents', description: 'A list of objects containing information about products.', type: 'object', 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 a15a1562205..7f8e23ad124 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 @@ -250,8 +250,8 @@ describe('PinterestConversionApi', () => { }) }) - describe('structured mode', () => { - it('should send event using structured fields with app_info and device_info', async () => { + 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, {}) @@ -261,7 +261,7 @@ describe('PinterestConversionApi', () => { settings: authData, useDefaultMappings: true, mapping: { - data_format: 'structured', + data_format: 'latest', event_name: 'checkout', user_data: { first_name: ['Gaurav'], @@ -339,7 +339,7 @@ describe('PinterestConversionApi', () => { expect(body.data[0].device_model).toBeUndefined() }) - it('should detect pre-hashed data in structured mode', async () => { + 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, {}) @@ -349,7 +349,7 @@ describe('PinterestConversionApi', () => { settings: authData, useDefaultMappings: true, mapping: { - data_format: 'structured', + data_format: 'latest', event_name: 'checkout', user_data: { first_name: ['44104fcaef8476724152090d6d7bd9afa8ca5b385f6a99d3c6cf36b943b9872d'], 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 90bd5662419..6dfc5c8c097 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -3,7 +3,7 @@ import { IntegrationError } from '@segment/actions-core' import type { DependsOnConditions } from '@segment/actions-core/destination-kit/types' import { API_VERSION, PARTNER_NAME } from '../constants' import type { Settings } from '../generated-types' -import { custom_data_field, custom_data_field_2, contents_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 isEmpty from 'lodash/isEmpty' @@ -17,8 +17,8 @@ const DEPENDS_ON_LEGACY: DependsOnConditions = { ] } -const DEPENDS_ON_STRUCTURED: DependsOnConditions = { - conditions: [{ fieldKey: 'data_format', operator: 'is', value: 'structured' }] +const DEPENDS_ON_LATEST: DependsOnConditions = { + conditions: [{ fieldKey: 'data_format', operator: 'is', value: 'latest' }] } const action: ActionDefinition = { @@ -29,13 +29,13 @@ const action: ActionDefinition = { data_format: { label: 'Data Format', description: - 'Controls which fields are displayed. "Structured Fields" uses the new app_info, device_info, and flat custom data fields. "Legacy Fields" uses the original nested custom_data object and flat app/device fields.', + 'Controls which fields are displayed. "Latest Fields" uses the new app_info, device_info, and custom data fields. "Legacy Fields" uses the original nested custom_data object and flat app/device fields.', type: 'string', choices: [ - { label: 'Structured Fields', value: 'structured' }, + { label: 'Latest Fields', value: 'latest' }, { label: 'Legacy Fields', value: 'legacy' } ], - default: 'structured' + default: 'latest' }, event_name: { label: 'Event Name', @@ -128,7 +128,7 @@ const action: ActionDefinition = { user_data: user_data_field, // --- Legacy fields (shown when data_format is 'legacy' or undefined) --- - custom_data: custom_data_field(DEPENDS_ON_LEGACY), + custom_data: getCustomDataField(DEPENDS_ON_LEGACY), app_id: { label: '[Legacy] App ID', description: 'The app store app ID.', @@ -203,14 +203,14 @@ const action: ActionDefinition = { } }, - // --- Structured fields (shown when data_format is 'structured') --- - custom_data_2: custom_data_field_2(DEPENDS_ON_STRUCTURED), - contents: contents_field(DEPENDS_ON_STRUCTURED), + // --- 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_STRUCTURED, + depends_on: DEPENDS_ON_LATEST, properties: { app_id: { label: 'App ID', @@ -272,7 +272,7 @@ const action: ActionDefinition = { label: 'Device Info', description: 'Object containing information about the device where event occurred.', type: 'object', - depends_on: DEPENDS_ON_STRUCTURED, + depends_on: DEPENDS_ON_LATEST, properties: { battery_level: { label: 'Battery Level', @@ -470,7 +470,7 @@ function buildDeviceInfo(payload: Payload) { } function buildCustomData(payload: Payload) { - const isStructured = payload.data_format === 'structured' + const isStructured = payload.data_format === 'latest' if (isStructured) { return { @@ -518,7 +518,7 @@ function buildCustomData(payload: Payload) { } function createPinterestPayload(payload: Payload) { - const isStructured = payload.data_format === 'structured' + const isStructured = payload.data_format === 'latest' return [ { From 8c984d57c528598cb1642c6f7371d62f2c08934b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 13:52:00 +0100 Subject: [PATCH 139/210] rename toggle label to Use Latest Fields with simpler description Co-Authored-By: Claude Opus 4.6 --- .../pinterest-conversions/reportConversionEvent/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6dfc5c8c097..9823c5bdef6 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -27,9 +27,9 @@ const action: ActionDefinition = { '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: 'Data Format', + label: 'Use Latest Fields', description: - 'Controls which fields are displayed. "Latest Fields" uses the new app_info, device_info, and custom data fields. "Legacy Fields" uses the original nested custom_data object and flat app/device fields.', + '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' }, From 703ecb2a94040bba03dc70188ce11b2155fa8e66 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 14:10:11 +0100 Subject: [PATCH 140/210] types --- .../reportConversionEvent/generated-types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 b574b14e798..784d5249286 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 @@ -2,7 +2,7 @@ export interface Payload { /** - * Controls which fields are displayed. "Structured Fields" uses the new app_info, device_info, and flat custom data fields. "Legacy Fields" uses the original nested custom_data object and flat app/device fields. + * Switch between the latest field configuration and the legacy fields. New instances default to the latest fields. */ data_format?: string /** @@ -103,7 +103,7 @@ export interface Payload { partner_id?: string | null } /** - * Object containing custom event data. This is the legacy format — use the new individual fields (Custom Data, Contents) when "Use Structured Fields" is selected. + * Object containing custom event data. */ custom_data?: { /** From 528cfadb15eaf580cb4ee8e52cbb5dc4150bd5a7 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 14:28:07 +0100 Subject: [PATCH 141/210] more unit tests --- .../__tests__/index.test.ts | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) 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 7f8e23ad124..3d67c80dc6e 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 @@ -389,5 +389,177 @@ describe('PinterestConversionApi', () => { 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() + }) + }) }) }) From 896ccc4012aa0422eb3b9c16904f975e31dc1706 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 15:25:49 +0100 Subject: [PATCH 142/210] Copilot review changes --- .../__tests__/index.test.ts | 43 ++++++++++++++++++- .../reportConversionEvent/index.ts | 12 ++++-- 2 files changed, 50 insertions(+), 5 deletions(-) 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 3d67c80dc6e..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 @@ -141,6 +141,7 @@ describe('PinterestConversionApi', () => { 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') @@ -250,6 +251,45 @@ describe('PinterestConversionApi', () => { }) }) + 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`) @@ -303,6 +343,7 @@ describe('PinterestConversionApi', () => { 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') @@ -318,7 +359,6 @@ describe('PinterestConversionApi', () => { } ]) expect(body.data[0].app_info).toEqual({ - app_id: '3.0.1.545', app_name: 'InitechGlobal', app_package_name: 'com.production.segment', app_version: '545', @@ -329,7 +369,6 @@ describe('PinterestConversionApi', () => { brand: 'Apple', model: 'iPhone7,2', type: 'ios', - os_family: 'iPhone OS', os_version: '8.1.3', timezone: 'Europe/Amsterdam' }) 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 9823c5bdef6..e06e3770f80 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -259,7 +259,6 @@ const action: ActionDefinition = { } }, default: { - app_id: { '@path': '$.context.app.build' }, app_name: { '@path': '$.context.app.name' }, app_package_name: { '@path': '$.context.app.namespace' }, app_version: { '@path': '$.context.app.version' }, @@ -402,7 +401,6 @@ const action: ActionDefinition = { carrier: { '@path': '$.context.network.carrier' }, model: { '@path': '$.context.device.model' }, type: { '@path': '$.context.device.type' }, - os_family: { '@path': '$.context.os.name' }, os_version: { '@path': '$.context.os.version' }, locale: { '@path': '$.context.locale' }, screen_density: { '@path': '$.context.screen.density' }, @@ -454,10 +452,18 @@ async function processPayload(request: RequestClient, settings: Settings, payloa }) } +function convertInstallTime(value: string | number | undefined | null): number | undefined { + if (!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: payload.app_info?.install_time ? dayjs.utc(payload.app_info.install_time).unix() : undefined + install_time: convertInstallTime(payload.app_info?.install_time) } const hasContent = Object.values(appInfo).some((v) => v !== undefined && v !== null) return hasContent ? appInfo : undefined From a44dac8f5e5da6c0df157ace422772e7d23f3cd4 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 15:37:26 +0100 Subject: [PATCH 143/210] copilot fix --- .../pinterest-conversions/reportConversionEvent/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e06e3770f80..4a67c259bde 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -453,7 +453,7 @@ async function processPayload(request: RequestClient, settings: Settings, payloa } function convertInstallTime(value: string | number | undefined | null): number | undefined { - if (!value) return 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 From d268f8ae0a33d2da7b66d877405a914feff53b1c Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 3 Jun 2026 17:41:57 +0100 Subject: [PATCH 144/210] add Pinterest API payload types and address Copilot feedback - Define PinterestEventPayload, LegacyPinterestEventPayload, CustomData, UserData, ContentsItem, AppInfo, DeviceInfo in types.ts - Wire up return types on buildCustomData and createPinterestPayload - Fix convertInstallTime to handle 0 correctly - Remove app_id and os_family default mappings - Add advertiser_tracking_enabled assertions to tests - Add test for undefined data_format (existing subscriptions) Co-Authored-By: Claude Opus 4.6 --- .../reportConversionEvent/index.ts | 5 +- .../pinterest-conversions/types.ts | 129 ++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) 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 4a67c259bde..2e4e7265d6b 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/index.ts @@ -6,6 +6,7 @@ import type { Settings } from '../generated-types' 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' @@ -475,7 +476,7 @@ function buildDeviceInfo(payload: Payload) { return hasContent ? payload.device_info : undefined } -function buildCustomData(payload: Payload) { +function buildCustomData(payload: Payload): CustomData { const isStructured = payload.data_format === 'latest' if (isStructured) { @@ -523,7 +524,7 @@ function buildCustomData(payload: Payload) { } } -function createPinterestPayload(payload: Payload) { +function createPinterestPayload(payload: Payload): (PinterestEventPayload | LegacyPinterestEventPayload)[] { const isStructured = payload.data_format === 'latest' return [ 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 +} From 2dc3c324c1d3193840f58f883e4634725a54b279 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 4 Jun 2026 09:25:49 +0100 Subject: [PATCH 145/210] addressing Copilot comments --- .../pinterest-conversions/index.ts | 98 +++++++++++++++++-- .../pinterest-capi-custom-data.ts | 6 +- .../reportConversionEvent/generated-types.ts | 4 +- .../reportConversionEvent/index.ts | 46 ++++----- 4 files changed, 117 insertions(+), 37 deletions(-) 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 2d7fcf8b353..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 @@ -40,9 +40,9 @@ export const getCustomDataField = (dependsOn: DependsOnConditions): 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', @@ -84,7 +84,7 @@ export const getCustomDataField = (dependsOn: DependsOnConditions): 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: { 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 784d5249286..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 @@ -131,7 +131,7 @@ export interface Payload { */ item_price?: number /** - * The number of items purchased + * The number of items purchased. */ quantity?: number /** @@ -164,7 +164,7 @@ 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 /** 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 2e4e7265d6b..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,7 +1,7 @@ import type { ActionDefinition, RequestClient } from '@segment/actions-core' import { IntegrationError } from '@segment/actions-core' import type { DependsOnConditions } from '@segment/actions-core/destination-kit/types' -import { API_VERSION, PARTNER_NAME } from '../constants' +import { API_VERSION, PARTNER_NAME, EVENT_NAME } from '../constants' import type { Settings } from '../generated-types' import { getCustomDataField, getCustomDataField2, getContentsField } from '../pinterest-capi-custom-data' import { user_data_field, hash_user_data } from '../pinterset-capi-user-data' @@ -45,28 +45,28 @@ const action: ActionDefinition = { type: 'string', required: true, choices: [ - { label: 'Add Payment Info', value: 'add_payment_info' }, - { label: 'Add to Cart', value: 'add_to_cart' }, - { label: 'Add to Wishlist', value: 'add_to_wishlist' }, - { label: 'App Install', value: 'app_install' }, - { label: 'App Open', value: 'app_open' }, - { label: 'Checkout', value: 'checkout' }, - { label: 'Contact', value: 'contact' }, - { label: 'Custom', value: 'custom' }, - { label: 'Customize Product', value: 'customize_product' }, - { label: 'Find Location', value: 'find_location' }, - { label: 'Initiate Checkout', value: 'initiate_checkout' }, - { label: 'Lead', value: 'lead' }, - { label: 'Page Visit', value: 'page_visit' }, - { label: 'Schedule', value: 'schedule' }, - { label: 'Search', value: 'search' }, - { label: 'Sign Up', value: 'signup' }, - { label: 'Start Trial', value: 'start_trial' }, - { label: 'Submit Application', value: 'submit_application' }, - { label: 'Subscribe', value: 'subscribe' }, - { label: 'View Category', value: 'view_category' }, - { label: 'View Content', value: 'view_content' }, - { label: 'Watch Video', value: 'watch_video' } + { 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: { From 65c3ad44a06ad93340f39d10864ad6bbb8522c5a Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 4 Jun 2026 10:37:37 +0100 Subject: [PATCH 146/210] add Pinterest e2e tests, fix createE2EEvent for page/identify types - Add e2e config and 9 fixtures for pinterest-conversions - Fix createE2EEvent: use event for track, name for page/screen, throw for identify/group/alias when name is passed - Fix createE2EEngageAudienceEvent: throw if eventName passed with type=identify - Update iterable and amplitude identify fixtures accordingly - Fix legacy custom_data quantity label and opt_out_type description - Add presets for new event types, reference EVENT_NAME constants - Add Pinterest API payload types and wire return types Co-Authored-By: Claude Opus 4.6 --- packages/core/src/e2e-helpers.ts | 36 ++- .../identifyUser/__e2e__/fixtures.e2e.ts | 2 +- .../updateUser/__e2e__/fixtures.e2e.ts | 2 +- .../pinterest-conversions/__e2e__/index.ts | 8 + .../__e2e__/fixtures.e2e.ts | 259 ++++++++++++++++++ 5 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 packages/destination-actions/src/destinations/pinterest-conversions/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__e2e__/fixtures.e2e.ts diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index 2e05dbeb8d6..ba13ea8cd28 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -15,12 +15,37 @@ import type { */ export function createE2EEvent( type: SegmentEvent['type'], - name: string, - overrides?: Partial> + 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, - event: name, messageId: '$guid', timestamp: '$now', ...overrides @@ -64,6 +89,11 @@ 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' }) 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 index 570bc0836f2..2cc07b3417d 100644 --- a/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts +++ b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts @@ -8,7 +8,7 @@ const fixtures: E2EFixture[] = [ subscribe: 'type = "identify"', mapping: defaultValues(identifyUser.fields), mode: 'single', - event: createE2EEvent('identify', 'Identify', { + event: createE2EEvent('identify', undefined, { userId: 'e2e-test-user-amplitude-001', traits: { email: 'e2e-test@segment.com', 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 index d33b642cd94..6ccfb75eda7 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts @@ -8,7 +8,7 @@ const fixtures: E2EFixture[] = [ subscribe: 'type = "identify"', mapping: defaultValues(updateUser.fields), mode: 'single', - event: createE2EEvent('identify', 'Identify', { + event: createE2EEvent('identify', undefined, { userId: 'e2e-test-user-001', traits: { email: 'e2e-test@segment.com', 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/reportConversionEvent/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..7839a217a58 --- /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: 'PayloadValidationError' + } + }, + { + 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: 'PayloadValidationError' + } + }, + { + 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: 400 + }, + verboseFailureHint: 'Pinterest rejects events with event_time older than 7 days.' + } +] + +export default fixtures From 3dfc67dcfb3cda28c9ecc5f9b8d7fdf6f2ec4ff4 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 4 Jun 2026 11:12:08 +0100 Subject: [PATCH 147/210] fix e2e fixture expectations for validation errors and timestamp - Use PAYLOAD_VALIDATION_FAILED error code for choices validation - Pinterest returns 422 for stale timestamps, not 400 Co-Authored-By: Claude Opus 4.6 --- .../reportConversionEvent/__e2e__/fixtures.e2e.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 7839a217a58..25736d857e8 100644 --- 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 @@ -196,7 +196,7 @@ const fixtures: E2EFixture[] = [ }), expect: { status: 'error', - errorType: 'PayloadValidationError' + errorType: 'PAYLOAD_VALIDATION_FAILED' } }, { @@ -223,7 +223,7 @@ const fixtures: E2EFixture[] = [ }), expect: { status: 'error', - errorType: 'PayloadValidationError' + errorType: 'PAYLOAD_VALIDATION_FAILED' } }, { @@ -250,7 +250,7 @@ const fixtures: E2EFixture[] = [ }), expect: { status: 'failure', - httpStatus: 400 + httpStatus: 422 }, verboseFailureHint: 'Pinterest rejects events with event_time older than 7 days.' } From 22fd0db0128a494533c509c18e3bda26e17bc8b3 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 4 Jun 2026 13:15:03 +0100 Subject: [PATCH 148/210] pinterest tests passing --- .../reportConversionEvent/__e2e__/fixtures.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 25736d857e8..d144244919b 100644 --- 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 @@ -196,7 +196,7 @@ const fixtures: E2EFixture[] = [ }), expect: { status: 'error', - errorType: 'PAYLOAD_VALIDATION_FAILED' + errorType: 'AggregateAjvError' } }, { @@ -223,7 +223,7 @@ const fixtures: E2EFixture[] = [ }), expect: { status: 'error', - errorType: 'PAYLOAD_VALIDATION_FAILED' + errorType: 'AggregateAjvError' } }, { From 183bada7ed326a9b568fcb972476f1b9a4527082 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 4 Jun 2026 14:33:08 +0100 Subject: [PATCH 149/210] adding teardown for google enhanced conversions --- packages/core/src/e2e-types.ts | 13 ++++- packages/core/src/index.ts | 2 + .../__e2e__/index.ts | 48 +++++++++++++++++-- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 6d3bcdf7690..248f67d313d 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -220,6 +220,15 @@ 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 @@ -229,8 +238,8 @@ export interface E2EAudienceConfig { createAudience: boolean /** When true, the runner calls getAudience after fixtures to verify the audience still exists. */ getAudience: boolean - /** When true, the runner calls the teardown function after all tests to clean up the audience. */ - teardown: 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 { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a66c052775a..f9058878290 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,6 +127,8 @@ export type { E2EDestinationConfig, E2EAudienceDestinationConfig, E2EAudienceConfig, + E2ETeardownContext, + E2ETeardownAudienceContext, E2ESettingsSecretValue, E2EDynamicValue, E2EEngageAudienceEventOptions, 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 index 799218e5a54..b7e049dbe7f 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts @@ -8,14 +8,13 @@ * - 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 } from '@segment/actions-core' +import type { E2EAudienceDestinationConfig, E2ETeardownAudienceContext } from '@segment/actions-core' -const audienceName = `e2e_test_user_list_${Date.now()}` // to ensure the audience name is unique across test runs +const audienceName = `e2e_test_user_list_${Date.now()}` export const config: E2EAudienceDestinationConfig = { settings: { customerId: { $env: 'E2E_GOOGLE_ADS_CUSTOMER_ID' }, - //loginCustomerId: { $env: 'E2E_GOOGLE_ADS_LOGIN_CUSTOMER_ID' }, oauth: { access_token: 'will_be_refreshed', refresh_token: { $env: 'E2E_GOOGLE_ADS_REFRESH_TOKEN' }, @@ -31,6 +30,47 @@ export const config: E2EAudienceDestinationConfig = { }, createAudience: true, getAudience: true, - teardown: false + 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}`) + } + } } } From cc77ba75c96e31f210900f5353e2154aacf41630 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 4 Jun 2026 14:56:56 +0100 Subject: [PATCH 150/210] updating e2e for iterable audiences --- .../iterable-audiences/__e2e__/index.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts index 772eb5db27f..bf7febbcc61 100644 --- a/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts @@ -1,4 +1,6 @@ -import type { E2EAudienceDestinationConfig } from '@segment/actions-core' +import type { E2EAudienceDestinationConfig, E2ETeardownAudienceContext } from '@segment/actions-core' + +const audienceName = `e2e_test_audience_${Date.now()}` export const config: E2EAudienceDestinationConfig = { settings: { @@ -6,10 +8,23 @@ export const config: E2EAudienceDestinationConfig = { iterableProjectType: 'hybrid' }, audience: { - audienceName: 'e2e_test_audience', + audienceName, audienceSettings: {}, createAudience: true, - getAudience: true, - teardown: false + 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}`) + } + } } } From 7c7250cdf974dc5cf972cf92fa3dc36d8da4a4fe Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 9 Jun 2026 13:19:45 +0100 Subject: [PATCH 151/210] adding facebook conversions api e2e - untested --- .../__e2e__/index.ts | 25 ++ .../sync/__e2e__/fixtures.e2e.ts | 255 ++++++++++++++++++ .../sync/__e2e__/journeys.e2e.ts | 77 ++++++ .../sync/__e2e__/retl.e2e.ts | 164 +++++++++++ 4 files changed, 521 insertions(+) create mode 100644 packages/destination-actions/src/destinations/facebook-custom-audiences/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/fixtures.e2e.ts create mode 100644 packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts create mode 100644 packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.e2e.ts 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/sync/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..22f2a53cc8d --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/fixtures.e2e.ts @@ -0,0 +1,255 @@ +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.' + +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', + jsonContains: [{ status: 200 }, { status: 200 }, { status: 200 }] + }, + 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', + jsonContains: [{ status: 200 }, { status: 200 }, { status: 200 }] + }, + 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', + jsonContains: [ + { status: 200 }, + { status: 200 }, + { status: 200 }, + { status: 200 }, + { status: 200 } + ] + }, + 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' + }), + { + type: 'identify' as const, + messageId: '$guid', + timestamp: '$now', + userId: 'e2e-fb-user-011', + traits: { + [COMPUTATION_KEY]: undefined as unknown as boolean, + email: 'e2e-fb-test-011@segment.com' + }, + context: { + personas: { + computation_class: 'audience', + computation_key: COMPUTATION_KEY, + computation_id: COMPUTATION_ID, + external_audience_id: '$externalAudienceId' + } + } + } + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200 }, + { status: 200 }, + { status: 200 }, + { status: 400, errormessage: 'Audience membership details missing' } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts new file mode 100644 index 00000000000..efef86ceae5 --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts @@ -0,0 +1,77 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues } 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 facebook-custom-audience-actions-journeys-support feature flag must be enabled.' + +function createJourneysEvent(options: { userId: string; email: string; externalAudienceId: string }) { + return { + type: 'track' as const, + event: 'Journeys Step Entered', + messageId: '$guid', + timestamp: '$now', + userId: options.userId, + properties: { + email: options.email + }, + context: { + personas: { + computation_class: 'journey_step', + computation_key: COMPUTATION_KEY, + computation_id: COMPUTATION_ID, + external_audience_id: options.externalAudienceId + }, + traits: { email: options.email } + } + } +} + +const fixtures: E2EFixture[] = [ + { + description: 'Journeys: single event adds user regardless of property value', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'single', + event: createJourneysEvent({ + userId: 'e2e-fb-journeys-user-001', + email: 'e2e-fb-journeys-001@segment.com', + externalAudienceId: '$externalAudienceId' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Journeys: batch adds all users (membership forced to true)', + subscribe: 'type = "track" or type = "identify"', + mapping: defaultValues(sync.fields), + mode: 'batchWithMultistatus', + events: [ + createJourneysEvent({ + userId: 'e2e-fb-journeys-user-002', + email: 'e2e-fb-journeys-002@segment.com', + externalAudienceId: '$externalAudienceId' + }), + createJourneysEvent({ + userId: 'e2e-fb-journeys-user-003', + email: 'e2e-fb-journeys-003@segment.com', + externalAudienceId: '$externalAudienceId' + }), + createJourneysEvent({ + userId: 'e2e-fb-journeys-user-004', + email: 'e2e-fb-journeys-004@segment.com', + externalAudienceId: '$externalAudienceId' + }) + ], + expect: { + status: 'success', + jsonContains: [{ status: 200 }, { status: 200 }, { status: 200 }] + }, + 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..869feb59dbe --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.e2e.ts @@ -0,0 +1,164 @@ +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.' + +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', + jsonContains: [{ status: 200 }, { status: 200 }] + }, + 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', + jsonContains: [{ status: 200 }, { status: 200 }] + }, + 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', + jsonContains: [{ status: 200 }, { status: 200 }, { status: 200 }, { status: 200 }] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures From 88b32ba35f308c1f69dc93af4294b1997500591a Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 9 Jun 2026 15:35:13 +0100 Subject: [PATCH 152/210] facebook custom audiences - update e2e --- .../{fixtures.e2e.ts => engage.e2e.ts} | 49 ++++++++++++------- .../__e2e__/{journeys.e2e.ts => journeys.ts} | 1 + .../sync/__e2e__/{retl.e2e.ts => retl.ts} | 0 3 files changed, 31 insertions(+), 19 deletions(-) rename packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/{fixtures.e2e.ts => engage.e2e.ts} (88%) rename packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/{journeys.e2e.ts => journeys.ts} (98%) rename packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/{retl.e2e.ts => retl.ts} (100%) diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/engage.e2e.ts similarity index 88% rename from packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/fixtures.e2e.ts rename to packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/engage.e2e.ts index 22f2a53cc8d..40b4abf6cbd 100644 --- a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/fixtures.e2e.ts +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/engage.e2e.ts @@ -221,31 +221,42 @@ const fixtures: E2EFixture[] = [ email: 'e2e-fb-test-010@segment.com' }), { - type: 'identify' as const, - messageId: '$guid', - timestamp: '$now', - userId: 'e2e-fb-user-011', - traits: { - [COMPUTATION_KEY]: undefined as unknown as boolean, + ...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' - }, - context: { - personas: { - computation_class: 'audience', - computation_key: COMPUTATION_KEY, - computation_id: COMPUTATION_ID, - external_audience_id: '$externalAudienceId' - } - } + }), + userId: undefined as unknown as string } ], expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 }, - { status: 200 }, - { status: 400, errormessage: 'Audience membership details missing' } + { + status: 200, + body: {}, + sent: { externalId: 'e2e-fb-user-008', email: 'e2e-fb-test-008@segment.com' } + }, + { + status: 200, + body: {}, + sent: { externalId: 'e2e-fb-user-009', email: 'e2e-fb-test-009@segment.com' } + }, + { + status: 200, + body: {}, + sent: { externalId: 'e2e-fb-user-010', email: 'e2e-fb-test-010@segment.com' } + }, + { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'externalId'.", + errorreporter: 'INTEGRATIONS' + } ] }, verboseFailureHint: FAILURE_HINT diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.ts similarity index 98% rename from packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts rename to packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.ts index efef86ceae5..55f9c268d4f 100644 --- a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.ts @@ -16,6 +16,7 @@ function createJourneysEvent(options: { userId: string; email: string; externalA timestamp: '$now', userId: options.userId, properties: { + [COMPUTATION_KEY]: true, email: options.email }, context: { 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.ts similarity index 100% rename from packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.e2e.ts rename to packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.ts From feae74533595418ffbfef4a9c5b801884017f88d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 10 Jun 2026 12:12:55 +0100 Subject: [PATCH 153/210] updating tests --- .../sync/__e2e__/engage.e2e.ts | 18 +++--------------- .../userList/__e2e__/engage.e2e.ts | 6 +++--- .../userList/__e2e__/journeysV1.e2e.ts | 10 +++++----- .../userList/__e2e__/retl.e2e.ts | 16 ++++++++-------- 4 files changed, 19 insertions(+), 31 deletions(-) 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 index 40b4abf6cbd..7b89c45c7a6 100644 --- 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 @@ -236,21 +236,9 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { - status: 200, - body: {}, - sent: { externalId: 'e2e-fb-user-008', email: 'e2e-fb-test-008@segment.com' } - }, - { - status: 200, - body: {}, - sent: { externalId: 'e2e-fb-user-009', email: 'e2e-fb-test-009@segment.com' } - }, - { - status: 200, - body: {}, - sent: { externalId: 'e2e-fb-user-010', email: 'e2e-fb-test-010@segment.com' } - }, + { status: 200 }, + { status: 200 }, + { status: 200 }, { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', 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 index f79e49b0602..0f42a0ee4ec 100644 --- 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 @@ -96,9 +96,9 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200, sent: {}, body: {} }, - { status: 200, sent: {}, body: {} }, - { status: 200, sent: {}, body: {} } + { status: 200 }, + { status: 200 }, + { status: 200 } ] }, verboseFailureHint: FAILURE_HINT 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 index b19ca26fc52..3d777b42f64 100644 --- 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 @@ -67,9 +67,9 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200, sent: {}, body: {} }, - { status: 200, sent: {}, body: {} }, - { status: 200, sent: {}, body: {} } + { status: 200 }, + { status: 200 }, + { status: 200 } ] }, verboseFailureHint: FAILURE_HINT @@ -106,8 +106,8 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200, sent: { email: 'e2e-google-journeys-test-005@segment.com' }, body: {} }, - { status: 200, sent: { email: 'e2e-google-journeys-test-006@segment.com' }, body: {} } + { status: 200 }, + { status: 200 } ] }, verboseFailureHint: FAILURE_HINT 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 index f0e24e33514..83ccd7bfe0a 100644 --- 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 @@ -39,8 +39,8 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200, sent: { email: 'e2e-google-retl-001@segment.com' }, body: {} }, - { status: 200, sent: { email: 'e2e-google-retl-002@segment.com' }, body: {} } + { status: 200 }, + { status: 200 } ] }, verboseFailureHint: FAILURE_HINT @@ -76,8 +76,8 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200, sent: { email: 'e2e-google-retl-003@segment.com' }, body: {} }, - { status: 200, sent: { email: 'e2e-google-retl-004@segment.com' }, body: {} } + { status: 200 }, + { status: 200 } ] }, verboseFailureHint: FAILURE_HINT @@ -113,8 +113,8 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200, sent: { email: 'e2e-google-retl-005@segment.com' }, body: {} }, - { status: 200, sent: { email: 'e2e-google-retl-006@segment.com' }, body: {} } + { status: 200 }, + { status: 200 } ] }, verboseFailureHint: FAILURE_HINT @@ -150,8 +150,8 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200, sent: { email: 'e2e-google-retl-007@segment.com' }, body: {} }, - { status: 200, sent: { email: 'e2e-google-retl-008@segment.com' }, body: {} } + { status: 200 }, + { status: 200 } ] }, verboseFailureHint: FAILURE_HINT From 58e8ecd645d89c4b1b6ee5f89951db68054870ee Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 11 Jun 2026 13:45:08 +0100 Subject: [PATCH 154/210] Add Journeys V2 e2e support and google-enhanced-conversions journeysV2 fixtures - Add createE2EJourneysV2AudienceEvent helper + types (journey_step class, journey_context/journey_metadata) to the e2e framework - Add journeysV2.e2e.ts fixtures for google-enhanced-conversions userList, covering the Journeys V2 path added by gec-journeys2-support --- packages/core/src/e2e-helpers.ts | 58 ++++++++++++ packages/core/src/e2e-types.ts | 37 ++++++++ packages/core/src/index.ts | 11 ++- .../userList/__e2e__/journeysV2.e2e.ts | 88 +++++++++++++++++++ 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV2.e2e.ts diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index ba13ea8cd28..3b7682cc5c3 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -6,6 +6,8 @@ import type { E2EEngageAudienceEvent, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, + E2EJourneysV2AudienceEventOptions, + E2EJourneysV2AudienceTrackEvent, E2ERetlAudienceEventOptions, E2ERetlAudienceTrackEvent } from './e2e-types' @@ -143,6 +145,62 @@ export function createE2EJourneysV1AudienceEvent( return event as E2EJourneysV1AudienceTrackEvent } +/* + * Journeys V2 events use computation_class 'journey_step' (not 'audience') and carry + * journey_context / journey_metadata in properties instead of properties[]. + * Journeys V2 events never contain a membership boolean, so the action treats them as "add" + * via its journey_step fallback. Only track events supported. + */ +export function createE2EJourneysV2AudienceEvent( + options: E2EJourneysV2AudienceEventOptions +): E2EJourneysV2AudienceTrackEvent { + const { + computationKey, + computationId, + externalAudienceId, + eventName, + journeyId, + journeyName, + userId, + anonymousId, + email, + audienceFields, + enrichedTraits + } = options + + const event = { + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + type: 'track', + event: eventName ?? 'Test Journeys V2 Audience Membership Event', + properties: { + 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' diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 248f67d313d..792cc0077d2 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -135,6 +135,20 @@ export interface E2EJourneysV1AudienceEventOptions } +export interface E2EJourneysV2AudienceEventOptions { + 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 @@ -180,6 +194,29 @@ export interface E2EEngageAudiencePersonas { + computation_class: 'journey_step' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string +} + +export interface E2EJourneysV2AudienceTrackEvent extends SegmentEvent { + type: 'track' + event: string + messageId: string + timestamp: string + context: { + personas: E2EJourneysV2AudiencePersonas + traits?: { email?: string } + audienceFields?: Record + } + properties: { + journey_context: { [k: string]: JSONValue } + journey_metadata: { journey_id: string; journey_name: string; [k: string]: JSONValue } + } & { [k: string]: JSONValue } +} + export interface E2EEngageAudienceTrackEvent extends SegmentEvent { type: 'track' event: string diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ff125c9a642..62f8d8550d9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,7 +113,13 @@ export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' -export { createE2EEvent, createE2EEngageAudienceEvent, createE2EJourneysV1AudienceEvent, createE2ERetlAudienceEvent } from './e2e-helpers' +export { + createE2EEvent, + createE2EEngageAudienceEvent, + createE2EJourneysV1AudienceEvent, + createE2EJourneysV2AudienceEvent, + createE2ERetlAudienceEvent +} from './e2e-helpers' export type { E2EFixture, E2EBaseFixture, @@ -139,6 +145,9 @@ export type { E2EEngageAudiencePersonas, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, + E2EJourneysV2AudienceEventOptions, + E2EJourneysV2AudienceTrackEvent, + E2EJourneysV2AudiencePersonas, E2ERetlAudienceEventOptions, E2ERetlAudienceTrackEvent, E2EHttpSuccessCode, 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..2c89711acf3 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV2.e2e.ts @@ -0,0 +1,88 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EJourneysV2AudienceEvent } from '@segment/actions-core' +import userList from '../index' + +const COMPUTATION_KEY = 'e2e_test_user_list' +const COMPUTATION_ID = 'journey_e2e_google_v2_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.' + +const fixtures: E2EFixture[] = [ + { + description: 'JourneysV2 Audience: Add a user to the customer match list via journey_step track event', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'single', + event: createE2EJourneysV2AudienceEvent({ + 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: Add a user with default syncMode (mirror) via journey_step track event', + 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', + event: createE2EJourneysV2AudienceEvent({ + 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: Batch add users to the customer match list 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', + events: [ + createE2EJourneysV2AudienceEvent({ + 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({ + 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 }, { status: 200 }] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures From ade536bbd8d0d0cddad00a939fbb99656f0071d8 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 11 Jun 2026 14:36:12 +0100 Subject: [PATCH 155/210] Fix journey_step computation_class and membership for V1/V2 e2e events - V1 and V2 Journeys events use computation_class 'journey_step' (not 'audience'); give V1 its own E2EJourneysV1AudiencePersonas type and build the event inline instead of via buildAudienceEventBase (which stays 'audience' for Engage/RETL) - V2 events carry properties[computationKey] membership boolean (add=true, remove=false); add 'action' option and a remove fixture for g-e-c --- packages/core/src/e2e-helpers.ts | 38 ++++++++++++++++--- packages/core/src/e2e-types.ts | 13 ++++++- packages/core/src/index.ts | 1 + .../userList/__e2e__/journeysV2.e2e.ts | 3 +- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index 3b7682cc5c3..111b5a19f11 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -130,15 +130,37 @@ export function createE2EEngageAudienceEvent( export function createE2EJourneysV1AudienceEvent( options: E2EJourneysV1AudienceEventOptions ): E2EJourneysV1AudienceTrackEvent { - const { eventName, enrichedTraits } = options - const base = buildAudienceEventBase(options) + const { + computationKey, + computationId, + externalAudienceId, + eventName, + userId, + anonymousId, + email, + audienceFields, + enrichedTraits + } = options const event = { - ...base, + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), type: 'track', event: eventName ?? 'Test Journeys V1 Audience Membership Event', 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 } }) } } @@ -147,14 +169,15 @@ export function createE2EJourneysV1AudienceEvent( /* * Journeys V2 events use computation_class 'journey_step' (not 'audience') and carry - * journey_context / journey_metadata in properties instead of properties[]. - * Journeys V2 events never contain a membership boolean, so the action treats them as "add" - * via its journey_step fallback. Only track events supported. + * 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, @@ -168,6 +191,8 @@ export function createE2EJourneysV2AudienceEvent( enrichedTraits } = options + const membership = action === 'add' + const event = { messageId: '$guid', timestamp: '$now', @@ -176,6 +201,7 @@ export function createE2EJourneysV2AudienceEvent( type: 'track', event: eventName ?? 'Test Journeys V2 Audience Membership Event', properties: { + [computationKey]: membership, journey_context: { [computationKey]: {} }, diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 792cc0077d2..ca2f84b56a7 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -136,6 +136,8 @@ export interface E2EJourneysV1AudienceEventOptions { + /** 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 @@ -174,13 +176,20 @@ export interface E2ERetlAudienceTrackEvent { + computation_class: 'journey_step' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string +} + export interface E2EJourneysV1AudienceTrackEvent extends SegmentEvent { type: 'track' event: string messageId: string timestamp: string context: { - personas: E2EEngageAudiencePersonas + personas: E2EJourneysV1AudiencePersonas traits?: { email?: string } audienceFields?: Record } @@ -214,7 +223,7 @@ export interface E2EJourneysV2AudienceTrackEvent extends SegmentEvent { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 62f8d8550d9..81712108b93 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -145,6 +145,7 @@ export type { E2EEngageAudiencePersonas, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, + E2EJourneysV1AudiencePersonas, E2EJourneysV2AudienceEventOptions, E2EJourneysV2AudienceTrackEvent, E2EJourneysV2AudiencePersonas, 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 index 2c89711acf3..31431dba0f4 100644 --- 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 @@ -30,7 +30,7 @@ const fixtures: E2EFixture[] = [ verboseFailureHint: FAILURE_HINT }, { - description: 'JourneysV2 Audience: Add a user with default syncMode (mirror) via journey_step track event', + description: 'JourneysV2 Audience: Remove a user with default syncMode (mirror) via journey_step track event', subscribe: 'type = "track"', mapping: { ...defaultValues(userList.fields), @@ -40,6 +40,7 @@ const fixtures: E2EFixture[] = [ }, mode: 'single', event: createE2EJourneysV2AudienceEvent({ + action: 'remove', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', From 8d356010c723e1bb9661fc248829e8acc39cb296 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 11 Jun 2026 14:45:20 +0100 Subject: [PATCH 156/210] Simplify e2e types: inline personas shapes into track/identify events Remove the standalone E2EEngageAudiencePersonas, E2EJourneysV1AudiencePersonas, and E2EJourneysV2AudiencePersonas interfaces and inline their shapes directly into the event interfaces that used them. Drop them from the core index exports. --- packages/core/src/e2e-types.ts | 56 ++++++++++++++++++---------------- packages/core/src/index.ts | 3 -- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index ca2f84b56a7..7b8669c2acf 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -169,54 +169,48 @@ export interface E2ERetlAudienceTrackEvent + 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 E2EJourneysV1AudiencePersonas { - computation_class: 'journey_step' - computation_key: ComputationKey - computation_id: string - external_audience_id?: string -} - export interface E2EJourneysV1AudienceTrackEvent extends SegmentEvent { type: 'track' event: string messageId: string timestamp: string context: { - personas: E2EJourneysV1AudiencePersonas + 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 E2EEngageAudiencePersonas { - computation_class: 'audience' - computation_key: ComputationKey - computation_id: string - external_audience_id?: string -} - -export interface E2EJourneysV2AudiencePersonas { - computation_class: 'journey_step' - computation_key: ComputationKey - computation_id: string - external_audience_id?: string -} - export interface E2EJourneysV2AudienceTrackEvent extends SegmentEvent { type: 'track' event: string messageId: string timestamp: string context: { - personas: E2EJourneysV2AudiencePersonas + personas: { + computation_class: 'journey_step' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } traits?: { email?: string } audienceFields?: Record } @@ -232,7 +226,12 @@ export interface E2EEngageAudienceTrackEvent + personas: { + computation_class: 'audience' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } traits?: { email?: string } audienceFields?: Record } @@ -244,7 +243,12 @@ export interface E2EEngageAudienceIdentifyEvent + personas: { + computation_class: 'audience' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } audienceFields?: Record } traits: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 81712108b93..2694896e2a6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -142,13 +142,10 @@ export type { E2EEngageAudienceEvent, E2EEngageAudienceTrackEvent, E2EEngageAudienceIdentifyEvent, - E2EEngageAudiencePersonas, E2EJourneysV1AudienceEventOptions, E2EJourneysV1AudienceTrackEvent, - E2EJourneysV1AudiencePersonas, E2EJourneysV2AudienceEventOptions, E2EJourneysV2AudienceTrackEvent, - E2EJourneysV2AudiencePersonas, E2ERetlAudienceEventOptions, E2ERetlAudienceTrackEvent, E2EHttpSuccessCode, From 11e369ee053d20496bd9126227a0d4a7ff0f4765 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 11 Jun 2026 14:56:49 +0100 Subject: [PATCH 157/210] Rename facebook-custom-audiences e2e fixtures to *.e2e.ts so they are discovered journeys.ts -> journeys.e2e.ts and retl.ts -> retl.e2e.ts. Both export E2EFixture[] but were not matched by the *.e2e.ts discovery glob. --- .../sync/__e2e__/{journeys.ts => journeys.e2e.ts} | 0 .../sync/__e2e__/{retl.ts => retl.e2e.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/{journeys.ts => journeys.e2e.ts} (100%) rename packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/{retl.ts => retl.e2e.ts} (100%) diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts similarity index 100% rename from packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.ts rename to packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.e2e.ts similarity index 100% rename from packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.ts rename to packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.e2e.ts From 6499b145b7cf5c5d27f8a888db75a5fb38f3633e Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 11 Jun 2026 14:57:55 +0100 Subject: [PATCH 158/210] Rename facebook journeys e2e fixture to journeysV1 for clarity These are Journeys V1-style events (journey_step class, no journey_context/ journey_metadata). Rename file journeys.e2e.ts -> journeysV1.e2e.ts and the internal helper createJourneysEvent -> createJourneysV1Event. V2 journey tests remain in their own separate files. --- .../__e2e__/{journeys.e2e.ts => journeysV1.e2e.ts} | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) rename packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/{journeys.e2e.ts => journeysV1.e2e.ts} (84%) diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV1.e2e.ts similarity index 84% rename from packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts rename to packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV1.e2e.ts index 55f9c268d4f..d491f3fa9f5 100644 --- a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.e2e.ts +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV1.e2e.ts @@ -8,7 +8,7 @@ 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 facebook-custom-audience-actions-journeys-support feature flag must be enabled.' -function createJourneysEvent(options: { userId: string; email: string; externalAudienceId: string }) { +function createJourneysV1Event(options: { userId: string; email: string; externalAudienceId: string }) { return { type: 'track' as const, event: 'Journeys Step Entered', @@ -33,11 +33,11 @@ function createJourneysEvent(options: { userId: string; email: string; externalA const fixtures: E2EFixture[] = [ { - description: 'Journeys: single event adds user regardless of property value', + description: 'JourneysV1: single event adds user regardless of property value', subscribe: 'type = "track" or type = "identify"', mapping: defaultValues(sync.fields), mode: 'single', - event: createJourneysEvent({ + event: createJourneysV1Event({ userId: 'e2e-fb-journeys-user-001', email: 'e2e-fb-journeys-001@segment.com', externalAudienceId: '$externalAudienceId' @@ -46,22 +46,22 @@ const fixtures: E2EFixture[] = [ verboseFailureHint: FAILURE_HINT }, { - description: 'Journeys: batch adds all users (membership forced to true)', + description: 'JourneysV1: batch adds all users (membership forced to true)', subscribe: 'type = "track" or type = "identify"', mapping: defaultValues(sync.fields), mode: 'batchWithMultistatus', events: [ - createJourneysEvent({ + createJourneysV1Event({ userId: 'e2e-fb-journeys-user-002', email: 'e2e-fb-journeys-002@segment.com', externalAudienceId: '$externalAudienceId' }), - createJourneysEvent({ + createJourneysV1Event({ userId: 'e2e-fb-journeys-user-003', email: 'e2e-fb-journeys-003@segment.com', externalAudienceId: '$externalAudienceId' }), - createJourneysEvent({ + createJourneysV1Event({ userId: 'e2e-fb-journeys-user-004', email: 'e2e-fb-journeys-004@segment.com', externalAudienceId: '$externalAudienceId' From a8e8cf66067b491e6773988640f119f3968b4ab8 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 11 Jun 2026 15:20:18 +0100 Subject: [PATCH 159/210] Use createE2EJourneysV1AudienceEvent helper in facebook journeysV1 fixture Remove the hand-rolled createJourneysV1Event in favor of the shared helper. Membership boolean (properties[computation_key]=true) preserved via enrichedTraits. --- .../sync/__e2e__/journeysV1.e2e.ts | 57 ++++++++----------- 1 file changed, 25 insertions(+), 32 deletions(-) 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 index d491f3fa9f5..5c65b6ce19b 100644 --- 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 @@ -1,5 +1,5 @@ import type { E2EFixture } from '@segment/actions-core' -import { defaultValues } from '@segment/actions-core' +import { defaultValues, createE2EJourneysV1AudienceEvent } from '@segment/actions-core' import sync from '../index' const COMPUTATION_KEY = 'e2e_test_facebook_journeys' @@ -8,39 +8,20 @@ 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 facebook-custom-audience-actions-journeys-support feature flag must be enabled.' -function createJourneysV1Event(options: { userId: string; email: string; externalAudienceId: string }) { - return { - type: 'track' as const, - event: 'Journeys Step Entered', - messageId: '$guid', - timestamp: '$now', - userId: options.userId, - properties: { - [COMPUTATION_KEY]: true, - email: options.email - }, - context: { - personas: { - computation_class: 'journey_step', - computation_key: COMPUTATION_KEY, - computation_id: COMPUTATION_ID, - external_audience_id: options.externalAudienceId - }, - traits: { email: options.email } - } - } -} - const fixtures: E2EFixture[] = [ { description: 'JourneysV1: single event adds user regardless of property value', subscribe: 'type = "track" or type = "identify"', mapping: defaultValues(sync.fields), mode: 'single', - event: createJourneysV1Event({ + event: createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + eventName: 'Journeys Step Entered', userId: 'e2e-fb-journeys-user-001', email: 'e2e-fb-journeys-001@segment.com', - externalAudienceId: '$externalAudienceId' + enrichedTraits: { [COMPUTATION_KEY]: true } }), expect: { status: 'success' }, verboseFailureHint: FAILURE_HINT @@ -51,20 +32,32 @@ const fixtures: E2EFixture[] = [ mapping: defaultValues(sync.fields), mode: 'batchWithMultistatus', events: [ - createJourneysV1Event({ + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + eventName: 'Journeys Step Entered', userId: 'e2e-fb-journeys-user-002', email: 'e2e-fb-journeys-002@segment.com', - externalAudienceId: '$externalAudienceId' + enrichedTraits: { [COMPUTATION_KEY]: true } }), - createJourneysV1Event({ + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + eventName: 'Journeys Step Entered', userId: 'e2e-fb-journeys-user-003', email: 'e2e-fb-journeys-003@segment.com', - externalAudienceId: '$externalAudienceId' + enrichedTraits: { [COMPUTATION_KEY]: true } }), - createJourneysV1Event({ + createE2EJourneysV1AudienceEvent({ + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + eventName: 'Journeys Step Entered', userId: 'e2e-fb-journeys-user-004', email: 'e2e-fb-journeys-004@segment.com', - externalAudienceId: '$externalAudienceId' + enrichedTraits: { [COMPUTATION_KEY]: true } }) ], expect: { From a4bdb5569f3ec1c072e718f65bbdc4db833bd7ca Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 12 Jun 2026 09:34:16 +0100 Subject: [PATCH 160/210] e2e framework: journey_step V1 events + facebook journeysV1 fixture - V1 journey events use computation_class 'journey_step' and a fixed 'Audience Entered' event name (no eventName option) - Update facebook journeysV1 fixture to match the V1 helper API --- packages/core/src/e2e-helpers.ts | 3 +-- packages/core/src/e2e-types.ts | 3 +-- .../facebook-custom-audiences/sync/__e2e__/journeysV1.e2e.ts | 4 ---- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts index 111b5a19f11..5d041631673 100644 --- a/packages/core/src/e2e-helpers.ts +++ b/packages/core/src/e2e-helpers.ts @@ -134,7 +134,6 @@ export function createE2EJourneysV1AudienceEvent( computationKey, computationId, externalAudienceId, - eventName, userId, anonymousId, email, @@ -148,7 +147,7 @@ export function createE2EJourneysV1AudienceEvent( ...(userId && { userId }), ...(anonymousId && { anonymousId }), type: 'track', - event: eventName ?? 'Test Journeys V1 Audience Membership Event', + event: 'Audience Entered', properties: { ...(enrichedTraits as { [k: string]: JSONValue }) }, diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 7b8669c2acf..21b934696b9 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -127,7 +127,6 @@ export interface E2EJourneysV1AudienceEventOptions extends SegmentEvent { type: 'track' - event: string + event: "Audience Entered" messageId: string timestamp: string context: { 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 index 5c65b6ce19b..2f363203649 100644 --- 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 @@ -18,7 +18,6 @@ const fixtures: E2EFixture[] = [ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', - eventName: 'Journeys Step Entered', userId: 'e2e-fb-journeys-user-001', email: 'e2e-fb-journeys-001@segment.com', enrichedTraits: { [COMPUTATION_KEY]: true } @@ -36,7 +35,6 @@ const fixtures: E2EFixture[] = [ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', - eventName: 'Journeys Step Entered', userId: 'e2e-fb-journeys-user-002', email: 'e2e-fb-journeys-002@segment.com', enrichedTraits: { [COMPUTATION_KEY]: true } @@ -45,7 +43,6 @@ const fixtures: E2EFixture[] = [ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', - eventName: 'Journeys Step Entered', userId: 'e2e-fb-journeys-user-003', email: 'e2e-fb-journeys-003@segment.com', enrichedTraits: { [COMPUTATION_KEY]: true } @@ -54,7 +51,6 @@ const fixtures: E2EFixture[] = [ computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', - eventName: 'Journeys Step Entered', userId: 'e2e-fb-journeys-user-004', email: 'e2e-fb-journeys-004@segment.com', enrichedTraits: { [COMPUTATION_KEY]: true } From 768fc9e8d2c1ac5dc02e9ff414361714bb5edd72 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 12 Jun 2026 12:18:25 +0100 Subject: [PATCH 161/210] Populate sent operation in g-e-c batch multistatus + flag on/off e2e coverage - functions.ts: per-payload sent now carries the actual add/remove operation ({create}/{remove}) via operationByIndex; guard journey_step add fallback to audienceMembership===undefined (flag-on only); document partial-failure index bug - userList.test.ts: assert exact sent operation/identifiers on success nodes - e2e fixtures (engage/retl/journeysV1/journeysV2): assert per-index sent ops with exact hashes; run flag-independent scenarios both flag-off and flag-on via a generator; journeysV2 is flag-on only; engage adds flag-on-only custom-event-name fixtures proving audience membership resolution; 9-event mixed add/remove/invalid batches verify per-index mapping - core e2e-types: add optional features field to E2EBaseFixture --- packages/core/src/e2e-types.ts | 2 + .../__tests__/userList.test.ts | 20 ++- .../google-enhanced-conversions/functions.ts | 70 +++++---- .../userList/__e2e__/engage.e2e.ts | 129 +++++++++++++++- .../userList/__e2e__/journeysV1.e2e.ts | 80 ++++++---- .../userList/__e2e__/journeysV2.e2e.ts | 104 ++++++++++++- .../userList/__e2e__/retl.e2e.ts | 140 ++++++++++++++++-- 7 files changed, 467 insertions(+), 78 deletions(-) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index 21b934696b9..c38cd6cc876 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -63,6 +63,8 @@ export interface E2EBaseFixture { 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 } export interface E2ESingleFixture extends E2EBaseFixture { 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 42a8ae972f7..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 @@ -1454,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 } }) @@ -1606,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 } 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 5cc43a7dfca..0d46fe41f8c 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -589,9 +589,9 @@ const extractUserIdentifiers = ( ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) } - else if (computation_class === 'journey_step') { - // For legacy Journeys preset journeys_step_entered_track which omits properties[] - // Should always adds the user, never delete + else if (computation_class === 'journey_step' && audienceMembership === undefined) { + // Journeys V1 only: legacy journeys_step_entered_track omits properties[computation_key], + // so there is no membership signal — always add. addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) } } else { @@ -609,8 +609,8 @@ const extractUserIdentifiers = ( ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) } else if (computation_class === 'journey_step') { - // For legacy Journeys preset journeys_step_entered_track which omits properties[] - // Should always adds the user, never delete + // Journeys V1 only: legacy journeys_step_entered_track omits properties[computation_key], + // so there is no membership signal — always add. addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) } } @@ -842,13 +842,16 @@ const updateMultiStatusResponseWithSuccess = ( validPayloadIndicesBitmap: number[], multiStatusResponse: MultiStatusResponse, sentBody: JSONLikeObject | string, - failedPayloadIndices: Set + failedPayloadIndices: Set, + operationByIndex: Map ) => { validPayloadIndicesBitmap.forEach((index) => { if (!failedPayloadIndices.has(index)) { multiStatusResponse.setSuccessResponseAtIndex(index, { status: 200, - sent: sentBody, + // Surface the actual operation sent to Google for this payload ({ create: ... } or + // { remove: ... }) so add/remove is observable per item. Falls back to sentBody defensively. + sent: operationByIndex.get(index) ?? sentBody, body: executedJob.data as JSONLikeObject }) } @@ -869,6 +872,13 @@ export const handlePartialFailureResponse = ( )?.index if (failedIndex >= 0) { + // KNOWN BUG (pre-existing; out of scope for this PR — fix tracked in ): + // 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 @@ -956,6 +966,10 @@ const extractBatchUserIdentifiers = ( 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) @@ -994,13 +1008,17 @@ const extractBatchUserIdentifiers = ( validPayloadIndicesBitmap.push(index) if (operationType === true) { - addUserIdentifiers.push({ create: { userIdentifiers } }) + 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 @@ -1021,9 +1039,9 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea audienceMembership === false ) { return false - } else if (computation_class === 'journey_step') { - // For legacy Journeys preset journeys_step_entered_track which omits properties[] - // Should always adds the user, never delete + } else if (computation_class === 'journey_step' && audienceMembership === undefined) { + // Journeys V1 only: legacy journeys_step_entered_track omits properties[computation_key], + // so there is no membership signal — always add. return true } } @@ -1041,8 +1059,8 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea ) { return false } else if (computation_class === 'journey_step') { - // For legacy Journeys preset journeys_step_entered_track which omits properties[] - // Should always adds the user, never delete + // Journeys V1 only: legacy journeys_step_entered_track omits properties[computation_key], + // so there is no membership signal — always add. return true } } @@ -1082,15 +1100,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, - audienceMemberships, - personasContext - ) + const { addUserIdentifiers, removeUserIdentifiers, validPayloadIndicesBitmap, operationByIndex } = + extractBatchUserIdentifiers( + payloads, + id_type, + multiStatusResponse, + syncMode, + features, + audienceMemberships, + personasContext + ) // Create offline user data job payload const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId) // Step1 :- Create an Offline user data job @@ -1152,7 +1171,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 index 0f42a0ee4ec..10b221a3443 100644 --- 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 @@ -1,5 +1,5 @@ import type { E2EFixture } from '@segment/actions-core' -import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent, FLAGS } from '@segment/actions-core' import userList from '../index' const COMPUTATION_KEY = 'e2e_test_user_list' @@ -7,7 +7,11 @@ 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.' -const fixtures: E2EFixture[] = [ +// 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"', @@ -96,13 +100,128 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 }, - { status: 200 } + { + 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 index 3d777b42f64..a8356eb705b 100644 --- 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 @@ -1,5 +1,5 @@ import type { E2EFixture } from '@segment/actions-core' -import { defaultValues, createE2EJourneysV1AudienceEvent } from '@segment/actions-core' +import { defaultValues, createE2EJourneysV1AudienceEvent, FLAGS } from '@segment/actions-core' import userList from '../index' const COMPUTATION_KEY = 'e2e_test_user_list' @@ -8,7 +8,11 @@ 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.' -const fixtures: E2EFixture[] = [ +// 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"', @@ -19,7 +23,6 @@ const fixtures: E2EFixture[] = [ }, mode: 'single', event: createE2EJourneysV1AudienceEvent({ - eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', @@ -40,7 +43,6 @@ const fixtures: E2EFixture[] = [ mode: 'batchWithMultistatus', events: [ createE2EJourneysV1AudienceEvent({ - eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', @@ -48,7 +50,6 @@ const fixtures: E2EFixture[] = [ email: 'e2e-google-journeys-test-002@segment.com' }), createE2EJourneysV1AudienceEvent({ - eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', @@ -56,7 +57,6 @@ const fixtures: E2EFixture[] = [ email: 'e2e-google-journeys-test-003@segment.com' }), createE2EJourneysV1AudienceEvent({ - eventName: 'Audience Entered', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', @@ -67,15 +67,30 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 }, - { status: 200 } + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: 'd2307079cffc9d57323b96b8c42ec0a0319761d49246d574d7aaa69977381fba' }] } } + }, + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: '496b2e198a53783256ff19936a68661751b1f681a3aba56832a7e86ef4c07aa6' }] } } + }, + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: 'd489341851a7f0e6b6f0f40bbd00435b663e71a45ac2e8daf7ca4f92e81896ef' }] } } + } ] }, verboseFailureHint: FAILURE_HINT }, { - description: 'JourneysV1 Audience: Batch add users even when properties[computation_key] is false', + // 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), @@ -84,34 +99,39 @@ const fixtures: E2EFixture[] = [ }, mode: 'batchWithMultistatus', events: [ - createE2EJourneysV1AudienceEvent({ - eventName: 'Audience Entered', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-google-journeys-user-005', - email: 'e2e-google-journeys-test-005@segment.com', - enrichedTraits: { [COMPUTATION_KEY]: false } - }), - createE2EJourneysV1AudienceEvent({ - eventName: 'Audience Entered', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - externalAudienceId: '$externalAudienceId', - userId: 'e2e-google-journeys-user-006', - email: 'e2e-google-journeys-test-006@segment.com', - enrichedTraits: { [COMPUTATION_KEY]: false } - }) + 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 }, - { status: 200 } + { 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 index 31431dba0f4..9adbc1f3319 100644 --- 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 @@ -1,16 +1,20 @@ import type { E2EFixture } from '@segment/actions-core' -import { defaultValues, createE2EJourneysV2AudienceEvent } 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: Add a user to the customer match list via journey_step track event', + description: 'JourneysV2 Audience (flag ON): Add a user via journey_step track event (computation_key=true)', subscribe: 'type = "track"', mapping: { ...defaultValues(userList.fields), @@ -18,7 +22,9 @@ const fixtures: E2EFixture[] = [ ad_personalization_consent_state: 'GRANTED' }, mode: 'single', + features: FEATURES, event: createE2EJourneysV2AudienceEvent({ + action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', @@ -30,7 +36,7 @@ const fixtures: E2EFixture[] = [ verboseFailureHint: FAILURE_HINT }, { - description: 'JourneysV2 Audience: Remove a user with default syncMode (mirror) via journey_step track event', + description: 'JourneysV2 Audience (flag ON): Remove a user via journey_step track event (computation_key=false)', subscribe: 'type = "track"', mapping: { ...defaultValues(userList.fields), @@ -39,6 +45,7 @@ const fixtures: E2EFixture[] = [ __segment_internal_sync_mode: 'mirror' }, mode: 'single', + features: FEATURES, event: createE2EJourneysV2AudienceEvent({ action: 'remove', computationKey: COMPUTATION_KEY, @@ -52,7 +59,7 @@ const fixtures: E2EFixture[] = [ verboseFailureHint: FAILURE_HINT }, { - description: 'JourneysV2 Audience: Batch add users to the customer match list via journey_step track events', + description: 'JourneysV2 Audience (flag ON): Batch add users via journey_step track events', subscribe: 'type = "track"', mapping: { ...defaultValues(userList.fields), @@ -60,8 +67,10 @@ const fixtures: E2EFixture[] = [ ad_personalization_consent_state: 'GRANTED' }, mode: 'batchWithMultistatus', + features: FEATURES, events: [ createE2EJourneysV2AudienceEvent({ + action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', @@ -70,6 +79,7 @@ const fixtures: E2EFixture[] = [ email: 'e2e-google-journeysv2-test-003@segment.com' }), createE2EJourneysV2AudienceEvent({ + action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', @@ -80,7 +90,91 @@ const fixtures: E2EFixture[] = [ ], expect: { status: 'success', - jsonContains: [{ status: 200 }, { status: 200 }] + 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 } 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 index 83ccd7bfe0a..19dd4b083f3 100644 --- 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 @@ -1,5 +1,5 @@ import type { E2EFixture } from '@segment/actions-core' -import { defaultValues, createE2ERetlAudienceEvent } from '@segment/actions-core' +import { defaultValues, createE2ERetlAudienceEvent, FLAGS } from '@segment/actions-core' import userList from '../index' const COMPUTATION_KEY = 'e2e_test_user_list' @@ -7,7 +7,11 @@ 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.' -const fixtures: E2EFixture[] = [ +// 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"', @@ -39,8 +43,14 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 } + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: 'da38a4fe5619680740b842adecb9ea5120150e4f6ce0c0d0aa7a0ca91c364630' }] } } + }, + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: '5cd717f1a25782ca98b12590144f4c8583a4b810401b328a52fbf4a231c4e6f8' }] } } + } ] }, verboseFailureHint: FAILURE_HINT @@ -76,8 +86,14 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 } + { + status: 200, + sent: { remove: { userIdentifiers: [{ hashedEmail: '4e98dfa8c84b29862aa03b0d110b00b7d905c7b9f7c294f68c0e88ee3f29f518' }] } } + }, + { + status: 200, + sent: { remove: { userIdentifiers: [{ hashedEmail: 'b33339bcc552b3d387990463eb7c58a3133112a98ad2e013efd1a796dc7d9644' }] } } + } ] }, verboseFailureHint: FAILURE_HINT @@ -113,8 +129,14 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 } + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: '7ce632dee60b40e07cd7ec6208b86b87315b055e0392e56bba58804ef2754a61' }] } } + }, + { + status: 200, + sent: { create: { userIdentifiers: [{ hashedEmail: '3bef4fd9f1da3b027692be905e7a2da5ca5ec118d467d6ff36a16fd8f30a9833' }] } } + } ] }, verboseFailureHint: FAILURE_HINT @@ -150,12 +172,110 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 } + { + 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 From 304553296e59195aed905010cef3f9d60e747698 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 12 Jun 2026 13:17:47 +0100 Subject: [PATCH 162/210] updating unit tests --- packages/cli/src/lib/server.ts | 10 +- packages/core/src/destination-kit/index.ts | 1 - .../__helpers__/userList-audiences-helpers.ts | 204 +++++ .../__tests__/userList-engage.test.ts | 134 ++++ .../userList-journeys-audiences.test.ts | 743 ------------------ .../__tests__/userList-journeysV1.test.ts | 91 +++ .../__tests__/userList-journeysV2.test.ts | 127 +++ .../google-enhanced-conversions/functions.ts | 43 +- .../userList/index.ts | 10 +- 9 files changed, 571 insertions(+), 792 deletions(-) create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/__helpers__/userList-audiences-helpers.ts create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-engage.test.ts delete mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeys-audiences.test.ts create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeysV1.test.ts create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeysV2.test.ts diff --git a/packages/cli/src/lib/server.ts b/packages/cli/src/lib/server.ts index 871b3cc4695..b03bb8c2fce 100644 --- a/packages/cli/src/lib/server.ts +++ b/packages/cli/src/lib/server.ts @@ -21,8 +21,7 @@ import { ActionHookType, ActionHookResponse, AudienceDestinationConfigurationWithCreateGet, - RequestFn, - Personas + RequestFn } from '@segment/actions-core/destination-kit' interface ResponseError extends Error { status?: number @@ -299,12 +298,7 @@ function setupRoutes(def: DestinationDefinition | null): void { mapping: mapping || req.body.payload || {}, auth: req.body.auth || {}, features: req.body.features || {}, - subscriptionMetadata: req.body.subscriptionMetadata || {}, - personasContext: (() => { - return Array.isArray(req.body.payload) - ? req.body.payload[0]?.context?.personas - : req.body.payload?.context?.personas - })() as Personas | undefined + subscriptionMetadata: req.body.subscriptionMetadata || {} } if (Array.isArray(eventParams.data)) { diff --git a/packages/core/src/destination-kit/index.ts b/packages/core/src/destination-kit/index.ts index 500ce2674a9..2d82a87e402 100644 --- a/packages/core/src/destination-kit/index.ts +++ b/packages/core/src/destination-kit/index.ts @@ -101,7 +101,6 @@ export type AudienceMode = { type: 'realtime' } | { type: 'synced'; full_audienc export type Personas = { computation_id: string computation_key: string - computation_class?: string namespace: string [key: string]: unknown } 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-journeys-audiences.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeys-audiences.test.ts deleted file mode 100644 index fe3937f1421..00000000000 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList-journeys-audiences.test.ts +++ /dev/null @@ -1,743 +0,0 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration, FLAGS } from '@segment/actions-core' -import GoogleEnhancedConversions from '../index' -import { API_VERSION } from '../functions' -import { SegmentEvent } from '@segment/actions-core' - -const testDestination = createTestIntegration(GoogleEnhancedConversions) -const timestamp = new Date('Thu Jun 10 2021 11:08:04 GMT-0700 (Pacific Daylight Time)').toISOString() -const customerId = '1234' - -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' - } - } -} - -const expectedCreateOperation = { - create: { - userIdentifiers: { thirdPartyUserId: 'user_123' } - } -} - -const expectedRemoveOperation = { - remove: { - userIdentifiers: { thirdPartyUserId: 'user_123' } - } -} - -const expectedJobPayload = JSON.stringify({ - job: { - type: 'CUSTOMER_MATCH_USER_LIST', - customerMatchUserListMetadata: { - userList: 'customers/1234/userLists/1234', - consent: { adUserData: 'GRANTED', adPersonalization: 'GRANTED' } - } - } -}) - -const flagCases = [ - { name: 'flag OFF', features: undefined }, - { name: 'flag ON', features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } } -] - -function setupNocksForPerform() { - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { data: 'offlineDataJob' }) -} - -function setupNocksForBatch(interceptedBodies?: any[]) { - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/, (body: any) => { - interceptedBodies?.push({ type: 'create', body }) - return true - }) - .reply(200, { resourceName: 'customers/1234/userLists/1234' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/, (body: any) => { - interceptedBodies?.push({ type: 'addOperations', body }) - return true - }) - .reply(200, {}) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { done: true }) -} - -function createJourneyEvent( - eventName: string, - computationKey: string -): SegmentEvent { - return createTestEvent({ - timestamp, - type: 'track', - userId: 'user_123', - event: eventName, - properties: { - journey_context: { - 'New Google Enhanced Conversions User': {} - }, - journey_metadata: { - epoch_id: 'epo_64ac13af-4914-4a79-9580-ac928c7fd9b8', - journey_id: 'jver_3DfJQ5k3kviG5O2UbMTG82QnMqX', - journey_name: 'journey3' - } - }, - context: { - personas: { - computation_id: 'journey_123', - computation_key: computationKey, - computation_class: 'journey_step', - namespace: 'spa_abc' - } - } - }) -} - -function createAudienceEvent( - eventName: string, - computationKey: string, - computationKeyValue?: boolean -): SegmentEvent { - const properties: Record = {} - if (typeof computationKeyValue === 'boolean') { - properties[computationKey] = computationKeyValue - } - - return createTestEvent({ - timestamp, - type: 'track', - userId: 'user_123', - event: eventName, - properties, - context: { - personas: { - computation_id: 'aud_123', - computation_key: computationKey, - computation_class: 'audience', - namespace: 'spa_abc' - } - } - }) -} - -describe('GoogleEnhancedConversions userList - Journeys & Audiences', () => { - beforeEach(() => { - nock.cleanAll() - testDestination.responses = [] - }) - afterEach(() => nock.cleanAll()) - - describe.each(flagCases)('Journey tests ($name)', ({ features }) => { - const journeyCases = [ - { syncMode: 'add', expectedResult: 'add' }, - { syncMode: 'mirror', expectedResult: 'add' } - ] - - describe('single event (perform)', () => { - it.each(journeyCases)( - 'syncMode=$syncMode => $expectedResult (properties never contains computation_key)', - async ({ syncMode, expectedResult }) => { - setupNocksForPerform() - - const event = createJourneyEvent('journey3 - Destination', 'journey3 - Destination') - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - ...mapping, - __segment_internal_sync_mode: syncMode - }, - useDefaultMappings: true, - settings: { customerId }, - ...(features && { features }) - }) - - // 3 responses = job create + addOperations + job run. - // User is always added for journey_step because: - // - Journey properties never contain properties[computation_key], so audienceMembership is undefined - // - syncMode='add' directly adds, syncMode='mirror' with a non-standard event name - // falls through to the computation_class='journey_step' fallback which always adds - expect(responses.length).toEqual(3) - expect(responses[0].options.body).toEqual(expectedJobPayload) - - const expectedOp = expectedResult === 'add' ? expectedCreateOperation : expectedRemoveOperation - expect(responses[1].options.body).toEqual( - JSON.stringify({ operations: [expectedOp], enable_warnings: true }) - ) - } - ) - }) - - describe('batch (performBatch)', () => { - it.each(journeyCases)( - 'syncMode=$syncMode => $expectedResult (properties never contains computation_key)', - async ({ syncMode, expectedResult }) => { - const interceptedBodies: any[] = [] - setupNocksForBatch(interceptedBodies) - - const event = createJourneyEvent('journey3 - Destination', 'journey3 - Destination') - - const responses = await testDestination.executeBatch('userList', { - events: [event], - mapping: { - ...mapping, - __segment_internal_sync_mode: syncMode - }, - settings: { customerId }, - ...(features && { features }) - }) - - // User is always added for journey_step (same reasoning as perform path above). - // The batch response reports success with the run URL. - expect(responses[0]).toMatchObject({ - status: 200, - sent: '/customers/1234/userLists/1234:run', - body: { done: true } - }) - - // Verify the addOperations call was a 'create' (user added) - const addOpCall = interceptedBodies.find((b) => b.type === 'addOperations') - expect(addOpCall).toBeDefined() - - const expectedOp = expectedResult === 'add' ? expectedCreateOperation : expectedRemoveOperation - expect(addOpCall.body.operations[0]).toEqual(expectedOp) - } - ) - }) - }) - - describe.each(flagCases)('Audience tests - standard events ($name)', ({ features }) => { - const audienceStandardCases = [ - { syncMode: 'add', eventName: 'Audience Entered', computationKeyValue: true, expectedResult: 'add' }, - { syncMode: 'mirror', eventName: 'Audience Entered', computationKeyValue: true, expectedResult: 'add' }, - { syncMode: 'mirror', eventName: 'Audience Exited', computationKeyValue: false, expectedResult: 'remove' }, - { syncMode: 'delete', eventName: 'Audience Exited', computationKeyValue: false, expectedResult: 'remove' } - ] - - describe('single event (perform)', () => { - it.each(audienceStandardCases)( - 'syncMode=$syncMode, event=$eventName => $expectedResult', - async ({ syncMode, eventName, computationKeyValue, expectedResult }) => { - setupNocksForPerform() - - const event = createAudienceEvent(eventName, 'my_audience', computationKeyValue) - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - ...mapping, - __segment_internal_sync_mode: syncMode - }, - useDefaultMappings: true, - settings: { customerId }, - ...(features && { features }) - }) - - // 3 responses = job create + addOperations + job run. - // 'Audience Entered' and 'Audience Exited' are standard event names that - // directly determine add/remove regardless of syncMode or flag state. - expect(responses.length).toEqual(3) - expect(responses[0].options.body).toEqual(expectedJobPayload) - - const expectedOp = expectedResult === 'add' ? expectedCreateOperation : expectedRemoveOperation - expect(responses[1].options.body).toEqual( - JSON.stringify({ operations: [expectedOp], enable_warnings: true }) - ) - } - ) - }) - - describe('batch (performBatch)', () => { - it.each(audienceStandardCases)( - 'syncMode=$syncMode, event=$eventName => $expectedResult', - async ({ syncMode, eventName, computationKeyValue, expectedResult }) => { - const interceptedBodies: any[] = [] - setupNocksForBatch(interceptedBodies) - - const event = createAudienceEvent(eventName, 'my_audience', computationKeyValue) - - const responses = await testDestination.executeBatch('userList', { - events: [event], - mapping: { - ...mapping, - __segment_internal_sync_mode: syncMode - }, - settings: { customerId }, - ...(features && { features }) - }) - - // 'Audience Entered' always adds, 'Audience Exited' always removes, - // regardless of syncMode or flag state. - expect(responses[0]).toMatchObject({ - status: 200, - sent: '/customers/1234/userLists/1234:run', - body: { done: true } - }) - - const addOpCall = interceptedBodies.find((b) => b.type === 'addOperations') - expect(addOpCall).toBeDefined() - - const expectedOp = expectedResult === 'add' ? expectedCreateOperation : expectedRemoveOperation - expect(addOpCall.body.operations[0]).toEqual(expectedOp) - } - ) - }) - }) - - describe('Audience tests - custom event names (flag OFF)', () => { - const customEventCases = [ - { syncMode: 'mirror' as const, computationKeyValue: undefined as boolean | undefined }, - { syncMode: 'mirror' as const, computationKeyValue: true }, - { syncMode: 'mirror' as const, computationKeyValue: false } - ] - - describe('single event (perform)', () => { - it.each(customEventCases)( - 'syncMode=$syncMode, computationKeyValue=$computationKeyValue => no operations (job created and run only)', - async ({ syncMode, computationKeyValue }) => { - setupNocksForPerform() - - const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', computationKeyValue) - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - ...mapping, - __segment_internal_sync_mode: syncMode - }, - useDefaultMappings: true, - settings: { customerId } - }) - - // 2 responses = job create + job run only. No addOperations call was made, - // meaning the user was neither added nor removed. - // With the flag OFF, a custom event name (not 'Audience Entered'/'Audience Exited') - // combined with mirror syncMode (event is not 'new'/'updated'/'deleted') cannot - // determine operation type. The perform path silently skips the event. - expect(responses.length).toEqual(2) - } - ) - }) - - describe('batch (performBatch)', () => { - it.each(customEventCases)( - 'syncMode=$syncMode, computationKeyValue=$computationKeyValue => ERROR', - async ({ syncMode, computationKeyValue }) => { - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { resourceName: 'customers/1234/userLists/1234' }) - - const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', computationKeyValue) - - const responses = await testDestination.executeBatch('userList', { - events: [event], - mapping: { - ...mapping, - __segment_internal_sync_mode: syncMode - }, - settings: { customerId } - }) - - // With the flag OFF, audienceMembership is not consulted, so even when - // properties[computation_key] is true/false it doesn't help. The batch path - // explicitly returns an error when operation type cannot be determined. - expect(responses[0]).toMatchObject({ - status: 400, - errortype: 'PAYLOAD_VALIDATION_FAILED', - errormessage: 'Could not determine Operation Type.', - errorreporter: 'INTEGRATIONS' - }) - } - ) - }) - }) - - describe('Audience tests - custom event names (flag ON)', () => { - const features = { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } - - describe('single event (perform)', () => { - it('syncMode=mirror, computationKeyValue=undefined => no operations (job created and run only)', async () => { - setupNocksForPerform() - - const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', undefined) - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - ...mapping, - __segment_internal_sync_mode: 'mirror' - }, - useDefaultMappings: true, - settings: { customerId }, - features - }) - - // 2 responses = job create + job run only. No addOperations call was made. - // With the flag ON, audienceMembership is consulted but properties[computation_key] - // is undefined (not a boolean), so audienceMembership resolves to undefined. - // computation_class='audience' != 'journey_step', so no fallback applies. - // Operation type cannot be determined; the perform path silently skips. - expect(responses.length).toEqual(2) - }) - - it('syncMode=mirror, computationKeyValue=true => User added', async () => { - setupNocksForPerform() - - const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', true) - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - ...mapping, - __segment_internal_sync_mode: 'mirror' - }, - useDefaultMappings: true, - settings: { customerId }, - features - }) - - // 3 responses = job create + addOperations + job run. - // With the flag ON, properties[computation_key]=true resolves - // audienceMembership=true, which triggers a 'create' operation (user added). - expect(responses.length).toEqual(3) - expect(responses[0].options.body).toEqual(expectedJobPayload) - expect(responses[1].options.body).toEqual( - JSON.stringify({ operations: [expectedCreateOperation], enable_warnings: true }) - ) - }) - - it('syncMode=mirror, computationKeyValue=false => User removed', async () => { - setupNocksForPerform() - - const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', false) - - const responses = await testDestination.testAction('userList', { - event, - mapping: { - ...mapping, - __segment_internal_sync_mode: 'mirror' - }, - useDefaultMappings: true, - settings: { customerId }, - features - }) - - // 3 responses = job create + addOperations + job run. - // With the flag ON, properties[computation_key]=false resolves - // audienceMembership=false, which triggers a 'remove' operation (user removed). - expect(responses.length).toEqual(3) - expect(responses[0].options.body).toEqual(expectedJobPayload) - expect(responses[1].options.body).toEqual( - JSON.stringify({ operations: [expectedRemoveOperation], enable_warnings: true }) - ) - }) - }) - - describe('batch (performBatch)', () => { - it('syncMode=mirror, computationKeyValue=undefined => ERROR', async () => { - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/) - .reply(200, { resourceName: 'customers/1234/userLists/1234' }) - - const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', undefined) - - const responses = await testDestination.executeBatch('userList', { - events: [event], - mapping: { - ...mapping, - __segment_internal_sync_mode: 'mirror' - }, - settings: { customerId }, - features - }) - - // With the flag ON but properties[computation_key] undefined, - // audienceMembership is undefined. Custom event name doesn't match any - // standard patterns and computation_class='audience' != 'journey_step'. - // The batch path explicitly returns an error. - expect(responses[0]).toMatchObject({ - status: 400, - errortype: 'PAYLOAD_VALIDATION_FAILED', - errormessage: 'Could not determine Operation Type.', - errorreporter: 'INTEGRATIONS' - }) - }) - - it('syncMode=mirror, computationKeyValue=true => User added', async () => { - const interceptedBodies: any[] = [] - setupNocksForBatch(interceptedBodies) - - const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', true) - - const responses = await testDestination.executeBatch('userList', { - events: [event], - mapping: { - ...mapping, - __segment_internal_sync_mode: 'mirror' - }, - settings: { customerId }, - features - }) - - // With the flag ON, properties[computation_key]=true resolves - // audienceMembership=true, which triggers a 'create' operation (user added). - expect(responses[0]).toMatchObject({ - status: 200, - sent: '/customers/1234/userLists/1234:run', - body: { done: true } - }) - - const addOpCall = interceptedBodies.find((b) => b.type === 'addOperations') - expect(addOpCall).toBeDefined() - expect(addOpCall.body.operations[0]).toEqual(expectedCreateOperation) - }) - - it('syncMode=mirror, computationKeyValue=false => User removed', async () => { - const interceptedBodies: any[] = [] - setupNocksForBatch(interceptedBodies) - - const event = createAudienceEvent('CUSTOM_EVENT_NAME', 'my_audience', false) - - const responses = await testDestination.executeBatch('userList', { - events: [event], - mapping: { - ...mapping, - __segment_internal_sync_mode: 'mirror' - }, - settings: { customerId }, - features - }) - - // With the flag ON, properties[computation_key]=false resolves - // audienceMembership=false, which triggers a 'remove' operation (user removed). - expect(responses[0]).toMatchObject({ - status: 200, - sent: '/customers/1234/userLists/1234:run', - body: { done: true } - }) - - const addOpCall = interceptedBodies.find((b) => b.type === 'addOperations') - expect(addOpCall).toBeDefined() - expect(addOpCall.body.operations[0]).toEqual(expectedRemoveOperation) - }) - }) - }) - - describe('Mixed batch tests - adds and removes in one batch', () => { - it('syncMode=mirror with Audience Entered and Audience Exited events in the same batch', async () => { - // Two addOperations calls expected: one for adds, one for removes. - const interceptedBodies: any[] = [] - - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/, (body: any) => { - interceptedBodies.push({ type: 'create', body }) - return true - }) - .reply(200, { resourceName: 'customers/1234/userLists/1234' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/, (body: any) => { - interceptedBodies.push({ type: 'addOperations', body }) - return true - }) - .times(2) - .reply(200, {}) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { done: true }) - - const enterEvent = createTestEvent({ - timestamp, - type: 'track', - userId: 'user_add_1', - event: 'Audience Entered', - properties: { my_audience: true }, - context: { - personas: { - computation_id: 'aud_123', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_abc' - } - } - }) - - const exitEvent = createTestEvent({ - timestamp, - type: 'track', - userId: 'user_remove_1', - event: 'Audience Exited', - properties: { my_audience: false }, - context: { - personas: { - computation_id: 'aud_123', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_abc' - } - } - }) - - const responses = await testDestination.executeBatch('userList', { - events: [enterEvent, exitEvent], - mapping: { - ...mapping, - __segment_internal_sync_mode: 'mirror' - }, - settings: { customerId }, - features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } - }) - - // Both events succeed — the batch creates one job with two addOperations calls: - // one containing the 'create' (add) operation and one containing the 'remove' operation. - expect(responses[0]).toMatchObject({ status: 200 }) - expect(responses[1]).toMatchObject({ status: 200 }) - - const addOpCalls = interceptedBodies.filter((b) => b.type === 'addOperations') - expect(addOpCalls).toHaveLength(2) - - // First addOperations call contains the 'create' (add) operation - expect(addOpCalls[0].body.operations).toEqual([ - { create: { userIdentifiers: { thirdPartyUserId: 'user_add_1' } } } - ]) - - // Second addOperations call contains the 'remove' operation - expect(addOpCalls[1].body.operations).toEqual([ - { remove: { userIdentifiers: { thirdPartyUserId: 'user_remove_1' } } } - ]) - }) - - it('syncMode=mirror with a mix of valid adds, valid removes, and invalid payloads', async () => { - // Tests that invalid payloads get individual errors while valid adds/removes - // are still processed in the same job. - const interceptedBodies: any[] = [] - - nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) - .post(/.*/, (body: any) => { - interceptedBodies.push({ type: 'create', body }) - return true - }) - .reply(200, { resourceName: 'customers/1234/userLists/1234' }) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) - .post(/.*/, (body: any) => { - interceptedBodies.push({ type: 'addOperations', body }) - return true - }) - .times(2) - .reply(200, {}) - - nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) - .post(/.*/) - .reply(200, { done: true }) - - const enterEvent = createTestEvent({ - timestamp, - type: 'track', - userId: 'user_add_1', - event: 'Audience Entered', - properties: { my_audience: true }, - context: { - personas: { - computation_id: 'aud_123', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_abc' - } - } - }) - - const exitEvent = createTestEvent({ - timestamp, - type: 'track', - userId: 'user_remove_1', - event: 'Audience Exited', - properties: { my_audience: false }, - context: { - personas: { - computation_id: 'aud_123', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_abc' - } - } - }) - - // Invalid payload: custom event with undefined computation_key value. - // Operation type cannot be determined → individual 400 error. - const invalidEvent = createTestEvent({ - timestamp, - type: 'track', - userId: 'user_bad_1', - event: 'CUSTOM_EVENT_NAME', - properties: {}, - context: { - personas: { - computation_id: 'aud_123', - computation_key: 'my_audience', - computation_class: 'audience', - namespace: 'spa_abc' - } - } - }) - - const responses = await testDestination.executeBatch('userList', { - events: [enterEvent, invalidEvent, exitEvent], - mapping: { - ...mapping, - __segment_internal_sync_mode: 'mirror' - }, - settings: { customerId }, - features: { [FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]: true } - }) - - // Index 0 (enterEvent): valid add → succeeds - expect(responses[0]).toMatchObject({ status: 200 }) - - // Index 1 (invalidEvent): custom event, no properties[computation_key], flag ON → - // audienceMembership is undefined, event name doesn't match standard patterns, - // computation_class='audience' != 'journey_step' → cannot determine operation type → 400 - expect(responses[1]).toMatchObject({ - status: 400, - errortype: 'PAYLOAD_VALIDATION_FAILED', - errormessage: 'Could not determine Operation Type.' - }) - - // Index 2 (exitEvent): valid remove → succeeds - expect(responses[2]).toMatchObject({ status: 200 }) - - // The two valid payloads still produced two addOperations calls - const addOpCalls = interceptedBodies.filter((b) => b.type === 'addOperations') - expect(addOpCalls).toHaveLength(2) - - expect(addOpCalls[0].body.operations).toEqual([ - { create: { userIdentifiers: { thirdPartyUserId: 'user_add_1' } } } - ]) - expect(addOpCalls[1].body.operations).toEqual([ - { remove: { userIdentifiers: { thirdPartyUserId: 'user_remove_1' } } } - ]) - }) - }) -}) 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/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 0d46fe41f8c..19ada626ac8 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -30,7 +30,7 @@ import { AudienceMembership, FLAGS } from '@segment/actions-core' -import { StatsContext, Personas } from '@segment/actions-core/destination-kit' +import { StatsContext } from '@segment/actions-core/destination-kit' import { fullFormats } from 'ajv-formats/dist/formats' import { HTTPError } from '@segment/actions-core' import type { Payload as UserListPayload } from './userList/generated-types' @@ -528,8 +528,7 @@ const extractUserIdentifiers = ( syncMode?: string, features?: Features | undefined, statsContext?: StatsContext | undefined, - audienceMembership?: AudienceMembership, - personasContext?: Personas + audienceMembership?: AudienceMembership ) => { const removeUserIdentifiers = [] const addUserIdentifiers = [] @@ -571,7 +570,6 @@ const extractUserIdentifiers = ( } } - const { computation_class } = personasContext || {} for (const payload of payloads) { if (features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP]) { if ( @@ -589,11 +587,6 @@ const extractUserIdentifiers = ( ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) } - else if (computation_class === 'journey_step' && audienceMembership === undefined) { - // Journeys V1 only: legacy journeys_step_entered_track omits properties[computation_key], - // so there is no membership signal — always add. - addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) - } } else { // Map user data to Google Ads API format if ( @@ -608,10 +601,6 @@ const extractUserIdentifiers = ( (syncMode === 'mirror' && payload.event_name === 'deleted') ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) - } else if (computation_class === 'journey_step') { - // Journeys V1 only: legacy journeys_step_entered_track omits properties[computation_key], - // so there is no membership signal — always add. - addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) } } } @@ -729,8 +718,7 @@ export const handleUpdate = async ( syncMode?: string, features?: Features | undefined, statsContext?: StatsContext, - audienceMembership?: AudienceMembership, - personasContext?: Personas + audienceMembership?: AudienceMembership ) => { const externalAudienceId: string | undefined = hookListId || payloads[0]?.external_audience_id if (!externalAudienceId) { @@ -744,8 +732,7 @@ export const handleUpdate = async ( syncMode, features, statsContext, - audienceMembership, - personasContext + audienceMembership ) const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId) // Create an offline user data job @@ -960,8 +947,7 @@ const extractBatchUserIdentifiers = ( multiStatusResponse: MultiStatusResponse, syncMode?: string, features?: Features, - audienceMemberships?: AudienceMembership[], - personasContext?: Personas + audienceMemberships?: AudienceMembership[] ) => { const removeUserIdentifiers: any[] = [] const addUserIdentifiers: any[] = [] @@ -996,7 +982,7 @@ const extractBatchUserIdentifiers = ( }) return } - const operationType = determineOperationType(payload, syncMode, features, audienceMemberships?.[index], personasContext) + const operationType = determineOperationType(payload, syncMode, features, audienceMemberships?.[index]) if (operationType === undefined) { multiStatusResponse.setErrorResponseAtIndex(index, { status: 400, @@ -1022,8 +1008,7 @@ const extractBatchUserIdentifiers = ( } // Helper function to determine operation type -const determineOperationType = (payload: UserListPayload, syncMode?: string, features?: Features, audienceMembership?: AudienceMembership, personasContext?: Personas): boolean | undefined => { - const { computation_class } = personasContext || {} +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' || @@ -1039,10 +1024,6 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea audienceMembership === false ) { return false - } else if (computation_class === 'journey_step' && audienceMembership === undefined) { - // Journeys V1 only: legacy journeys_step_entered_track omits properties[computation_key], - // so there is no membership signal — always add. - return true } } else { @@ -1058,10 +1039,6 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea (syncMode === 'mirror' && payload.event_name === 'deleted') ) { return false - } else if (computation_class === 'journey_step') { - // Journeys V1 only: legacy journeys_step_entered_track omits properties[computation_key], - // so there is no membership signal — always add. - return true } } return undefined @@ -1090,8 +1067,7 @@ export const processBatchPayload = async ( syncMode?: string, features?: Features | undefined, statsContext?: StatsContext, - audienceMemberships?: AudienceMembership[], - personasContext?: Personas + audienceMemberships?: AudienceMembership[] ) => { const externalAudienceId = hookListId || payloads[0]?.external_audience_id if (!externalAudienceId) { @@ -1107,8 +1083,7 @@ export const processBatchPayload = async ( multiStatusResponse, syncMode, features, - audienceMemberships, - personasContext + audienceMemberships ) // Create offline user data job payload const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId) 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 ef37f18293e..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 @@ -303,7 +303,7 @@ const action: ActionDefinition = { } } }, - perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features, audienceMembership, personasContext }) => { + perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features, audienceMembership }) => { settings.customerId = verifyCustomerId(settings.customerId) return await handleUpdate( request, @@ -315,13 +315,12 @@ const action: ActionDefinition = { syncMode, features, statsContext, - audienceMembership, - personasContext + audienceMembership ) }, performBatch: async ( request, - { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features, audienceMembership, personasContext } + { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features, audienceMembership } ) => { settings.customerId = verifyCustomerId(settings.customerId) return await processBatchPayload( @@ -334,8 +333,7 @@ const action: ActionDefinition = { syncMode, features, statsContext, - audienceMembership, - personasContext + audienceMembership ) } } From 36fa68cf7e5311a33b070c2665ad20d99527de1f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 12 Jun 2026 13:23:46 +0100 Subject: [PATCH 163/210] adding link to JIRA for known bug --- .../src/destinations/google-enhanced-conversions/functions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 19ada626ac8..81738d8fcb0 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -859,7 +859,7 @@ export const handlePartialFailureResponse = ( )?.index if (failedIndex >= 0) { - // KNOWN BUG (pre-existing; out of scope for this PR — fix tracked in ): + // 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 From e97267c13382ec8e9aa56ab3d38d8e3a00549d2d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 12 Jun 2026 13:47:34 +0100 Subject: [PATCH 164/210] fixing tests --- .../__snapshots__/snapshot.test.ts.snap | 18 +++++++----------- .../__snapshots__/snapshot.test.ts.snap | 3 +++ .../sync/functions.ts | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) 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/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/facebook-custom-audiences/sync/functions.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/functions.ts index 5c399bdcf4a..d1ebc52ae3e 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 @@ -70,7 +70,7 @@ export async function send( 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() From 266fa679ce77baedd2435c50b7dde9ac20eac431 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 12 Jun 2026 14:58:11 +0100 Subject: [PATCH 165/210] facebook-custom-audiences: remove journeys feature flag, rename to getJourneysV1Memberships The journeys-support flag is fully rolled out (all users on flag=true), so the gating is removed and journey membership resolution runs unconditionally. Rename getJourneysMemberships -> getJourneysV1Memberships and clarify comments explaining the V1 (always-add) vs V2 (membership-driven) distinction. --- .../facebook-custom-audiences/constants.ts | 2 -- .../sync/functions.ts | 26 +++++++++---------- 2 files changed, 12 insertions(+), 16 deletions(-) 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/functions.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/functions.ts index d1ebc52ae3e..7da612c6bbe 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,17 +12,15 @@ 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. + * If events contain computation_class === 'journey_step' the payloads may be for JourneysV1. + * journeysV1 always adds users to the audience. */ -export function getJourneysMemberships(rawDatas: RawData[] | undefined): boolean[] | undefined { +export function getJourneysV1Memberships(rawDatas: RawData[] | undefined): boolean[] | undefined { if (!rawDatas || (Array.isArray(rawDatas) && rawDatas.length === 0)) { return undefined } @@ -55,14 +53,14 @@ export async function send( rawData?: RawData[] ): 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 - } + + const journeyV1Memberships = getJourneysV1Memberships(rawData) + if (Array.isArray(journeyV1Memberships) && journeyV1Memberships.length > 0) { + if (!audienceMemberships?.every((m) => typeof m === 'boolean')) { + // If audienceMemberships is already populated with booleans then we can assume JourneysV2. + // Otherwise we assume JourneysV1 and overwrite the audienceMemberships with all true since JourneysV1 only adds users to audiences. + // JourneysV2 allows users to be added and removed from audiences. + audienceMemberships = journeyV1Memberships } } From a3c70fdc97b9c4386d273da154edcd1124b03500 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 12 Jun 2026 16:07:53 +0100 Subject: [PATCH 166/210] adding e2e test coverage and fixing some other code issues --- .../sync/__e2e__/engage.e2e.ts | 37 ++- .../sync/__e2e__/errors.e2e.ts | 139 ++++++++ .../sync/__e2e__/identifiers.e2e.ts | 299 ++++++++++++++++++ .../sync/__e2e__/journeysV1.e2e.ts | 83 ++++- .../sync/__e2e__/journeysV2.e2e.ts | 156 +++++++++ .../sync/__e2e__/retl.e2e.ts | 82 ++++- .../sync/__tests__/journeys.test.ts | 67 ++-- .../sync/functions.ts | 23 +- 8 files changed, 807 insertions(+), 79 deletions(-) create mode 100644 packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/errors.e2e.ts create mode 100644 packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/identifiers.e2e.ts create mode 100644 packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeysV2.e2e.ts 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 index 7b89c45c7a6..4bdf3fc84d4 100644 --- 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 @@ -8,6 +8,10 @@ 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', @@ -79,7 +83,12 @@ const fixtures: E2EFixture[] = [ ], expect: { status: 'success', - jsonContains: [{ status: 200 }, { status: 200 }, { status: 200 }] + // 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 }, @@ -119,7 +128,12 @@ const fixtures: E2EFixture[] = [ ], expect: { status: 'success', - jsonContains: [{ status: 200 }, { status: 200 }, { status: 200 }] + // 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 }, @@ -177,12 +191,15 @@ const fixtures: E2EFixture[] = [ ], 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 }, - { status: 200 }, - { status: 200 }, - { status: 200 }, - { status: 200 } + { 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 @@ -236,9 +253,9 @@ const fixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, - { status: 200 }, - { status: 200 }, + { 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', 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..2e8d2c8072e --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/identifiers.e2e.ts @@ -0,0 +1,299 @@ +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 also mapped and sent (they land in the request body's + // app_ids/page_ids/ig_account_ids arrays), but they are NOT surfaced in the per-item `sent` + // object, so there is no stable value to assert for them here — the row below covers the 15 + // schema slots that `sent.data` exposes. + 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' }, + appId: { '@path': '$.traits.app_id' }, + pageId: { '@path': '$.traits.page_id' }, + igAccountIds: { '@path': '$.traits.ig_ids' } + }, + 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', + app_id: 'app_999', + page_id: 'page_888', + ig_ids: 'ig_777' + } + }) + ], + 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 index 2f363203649..b6904fd4cf8 100644 --- 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 @@ -6,11 +6,19 @@ 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 facebook-custom-audience-actions-journeys-support feature flag must be enabled.' + '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 event adds user regardless of property value', + description: 'JourneysV1: single journey_step event adds the user', subscribe: 'type = "track" or type = "identify"', mapping: defaultValues(sync.fields), mode: 'single', @@ -19,14 +27,13 @@ const fixtures: E2EFixture[] = [ computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'e2e-fb-journeys-user-001', - email: 'e2e-fb-journeys-001@segment.com', - enrichedTraits: { [COMPUTATION_KEY]: true } + email: 'e2e-fb-journeys-001@segment.com' }), expect: { status: 'success' }, verboseFailureHint: FAILURE_HINT }, { - description: 'JourneysV1: batch adds all users (membership forced to true)', + description: 'JourneysV1: batch adds all users (membership defaulted to true)', subscribe: 'type = "track" or type = "identify"', mapping: defaultValues(sync.fields), mode: 'batchWithMultistatus', @@ -36,29 +43,81 @@ const fixtures: E2EFixture[] = [ computationId: COMPUTATION_ID, externalAudienceId: '$externalAudienceId', userId: 'e2e-fb-journeys-user-002', - email: 'e2e-fb-journeys-002@segment.com', - enrichedTraits: { [COMPUTATION_KEY]: true } + 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', - enrichedTraits: { [COMPUTATION_KEY]: true } + 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', - enrichedTraits: { [COMPUTATION_KEY]: true } + email: 'e2e-fb-journeys-004@segment.com' }) ], expect: { status: 'success', - jsonContains: [{ status: 200 }, { status: 200 }, { status: 200 }] + // 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 } 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 index 869feb59dbe..a38daf1def5 100644 --- 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 @@ -8,6 +8,10 @@ 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)', @@ -75,7 +79,12 @@ const fixtures: E2EFixture[] = [ ], expect: { status: 'success', - jsonContains: [{ status: 200 }, { status: 200 }] + // 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 }, @@ -107,7 +116,11 @@ const fixtures: E2EFixture[] = [ ], expect: { status: 'success', - jsonContains: [{ status: 200 }, { status: 200 }] + // 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 }, @@ -155,7 +168,70 @@ const fixtures: E2EFixture[] = [ ], expect: { status: 'success', - jsonContains: [{ status: 200 }, { status: 200 }, { status: 200 }, { status: 200 }] + // 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 } 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..09e90fa099a 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,9 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -import { getJourneysMemberships } from '../functions' +import { getJourneysV1Memberships } 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,15 +51,15 @@ function makeJourneyEvent(email: string, userId: string) { } // --------------------------------------------------------------------------- -// getJourneysMemberships (unit tests) +// getJourneysV1Memberships (unit tests) // --------------------------------------------------------------------------- -describe('getJourneysMemberships', () => { +describe('getJourneysV1Memberships', () => { it('returns undefined when rawDatas is undefined', () => { - expect(getJourneysMemberships(undefined)).toBeUndefined() + expect(getJourneysV1Memberships(undefined)).toBeUndefined() }) it('returns undefined when rawDatas is an empty array', () => { - expect(getJourneysMemberships([])).toBeUndefined() + expect(getJourneysV1Memberships([])).toBeUndefined() }) it('returns undefined when no events are journey_step', () => { @@ -67,7 +67,7 @@ describe('getJourneysMemberships', () => { { context: { personas: { computation_class: 'audience' } } }, { context: { personas: { computation_class: 'audience' } } } ] - expect(getJourneysMemberships(rawDatas)).toBeUndefined() + expect(getJourneysV1Memberships(rawDatas)).toBeUndefined() }) it('returns an array of true values when all events are journey_step', () => { @@ -76,17 +76,17 @@ describe('getJourneysMemberships', () => { { 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) + const result = getJourneysV1Memberships(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', () => { + it('throws InvalidAudienceMembershipError 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( + expect(() => getJourneysV1Memberships(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.' ) }) @@ -95,7 +95,7 @@ describe('getJourneysMemberships', () => { const rawDatas: RawData[] = [ { context: { personas: { computation_class: 'journey_step', computation_key: 'my_audience' } }, properties: {} } ] - const result = getJourneysMemberships(rawDatas) + const result = getJourneysV1Memberships(rawDatas) expect(result).toEqual([true]) }) @@ -105,25 +105,23 @@ describe('getJourneysMemberships', () => { { 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) + const result = getJourneysV1Memberships(rawDatas) expect(result).toEqual([true, true, true]) }) }) // --------------------------------------------------------------------------- -// Feature flag gating (integration tests) +// journey_step batch behavior (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 +137,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 +145,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 +207,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 +216,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 7da612c6bbe..e75d744a9a0 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 @@ -53,15 +53,20 @@ export async function send( rawData?: RawData[] ): Promise { const msResponse = new MultiStatusResponse() - - const journeyV1Memberships = getJourneysV1Memberships(rawData) - if (Array.isArray(journeyV1Memberships) && journeyV1Memberships.length > 0) { - if (!audienceMemberships?.every((m) => typeof m === 'boolean')) { - // If audienceMemberships is already populated with booleans then we can assume JourneysV2. - // Otherwise we assume JourneysV1 and overwrite the audienceMemberships with all true since JourneysV1 only adds users to audiences. - // JourneysV2 allows users to be added and removed from audiences. - audienceMemberships = journeyV1Memberships - } + + // getJourneysV1Memberships also throws if the batch mixes journey_step and non-journey_step events. + const isJourney = getJourneysV1Memberships(rawData) !== undefined + 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) From e148908c7387ec629b44d20efb069bc07fe82eb4 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 12 Jun 2026 16:25:32 +0100 Subject: [PATCH 167/210] fixing e2e test --- .../sync/__e2e__/identifiers.e2e.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) 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 index 2e8d2c8072e..98959850fbd 100644 --- 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 @@ -22,10 +22,10 @@ const fixtures: E2EFixture[] = [ // 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 also mapped and sent (they land in the request body's - // app_ids/page_ids/ig_account_ids arrays), but they are NOT surfaced in the per-item `sent` - // object, so there is no stable value to assert for them here — the row below covers the 15 - // schema slots that `sent.data` exposes. + // 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: { @@ -40,10 +40,7 @@ const fixtures: E2EFixture[] = [ last: { '@path': '$.traits.last_name' }, firstInitial: { '@path': '$.traits.first_initial' } }, - mobileAdId: { '@path': '$.traits.madid' }, - appId: { '@path': '$.traits.app_id' }, - pageId: { '@path': '$.traits.page_id' }, - igAccountIds: { '@path': '$.traits.ig_ids' } + mobileAdId: { '@path': '$.traits.madid' } }, mode: 'batchWithMultistatus', events: [ @@ -68,10 +65,7 @@ const fixtures: E2EFixture[] = [ state: 'California', postal_code: '94105-1234', country: 'United States', - madid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', - app_id: 'app_999', - page_id: 'page_888', - ig_ids: 'ig_777' + madid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } }) ], From 9f888d6dc3dd98f4d6dc741c8b205d2e6f6d7ad4 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 15 Jun 2026 19:35:16 +0100 Subject: [PATCH 168/210] tightening fbca journeys handling --- .../sync/__tests__/functions.test.ts | 42 +++++++++++++++++++ .../sync/functions.ts | 16 +++---- 2 files changed, 50 insertions(+), 8 deletions(-) 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/functions.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/functions.ts index e75d744a9a0..651509f0858 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 @@ -17,12 +17,12 @@ import { parseFacebookError, getApiVersion } from '../functions' import { FacebookResponseError } from '../types' /* - * If events contain computation_class === 'journey_step' the payloads may be for JourneysV1. - * journeysV1 always adds users to the audience. + * 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 getJourneysV1Memberships(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') @@ -36,10 +36,10 @@ export function getJourneysV1Memberships(rawDatas: RawData[] | undefined): boole } if (noneJourney) { - return undefined + return false } - return new Array(rawDatas.length).fill(true) + return true } export async function send( @@ -54,8 +54,8 @@ export async function send( ): Promise { const msResponse = new MultiStatusResponse() - // getJourneysV1Memberships also throws if the batch mixes journey_step and non-journey_step events. - const isJourney = getJourneysV1Memberships(rawData) !== undefined + // isJourneyPayloads also throws if the batch mixes journey_step and non-journey_step events. + const isJourney = isJourneyPayloads(rawData) !== undefined 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) From a667e67a976b20f0aa14194f7202f9a8d2f3eb94 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 16 Jun 2026 12:59:42 +0100 Subject: [PATCH 169/210] google-enhanced-conversions: add error stats for audience-membership changes Add stats counters on the new failure/edge paths introduced by the audience-membership flag and the 'mirror' sync default: - error.undetermined_operation_type (batch: neither add nor remove resolved) - extractUserIdentifiers.no_operation (single-event silent drop) - partial_failure.mixed_batch (partial failure on a mixed add+remove batch; quantifies blast radius of the known STRATCONN-6862 index-misattribution bug) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../google-enhanced-conversions/functions.ts | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) 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 81738d8fcb0..e2226d1bb3c 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -586,6 +586,11 @@ const extractUserIdentifiers = ( 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('extractUserIdentifiers.no_operation', 1, statsContext?.tags) } } else { // Map user data to Google Ads API format @@ -601,6 +606,8 @@ const extractUserIdentifiers = ( (syncMode === 'mirror' && payload.event_name === 'deleted') ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) + } else { + statsContext?.statsClient?.incr('extractUserIdentifiers.no_operation', 1, statsContext?.tags) } } } @@ -682,7 +689,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) @@ -698,6 +706,12 @@ 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('partial_failure.mixed_batch', 1, statsContext?.tags) + } handlePartialFailureResponse( partialFailureError, validPayloadIndicesBitmap, @@ -834,10 +848,11 @@ const updateMultiStatusResponseWithSuccess = ( ) => { 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, - // Surface the actual operation sent to Google for this payload ({ create: ... } or - // { remove: ... }) so add/remove is observable per item. Falls back to sentBody defensively. sent: operationByIndex.get(index) ?? sentBody, body: executedJob.data as JSONLikeObject }) @@ -947,7 +962,8 @@ const extractBatchUserIdentifiers = ( multiStatusResponse: MultiStatusResponse, syncMode?: string, features?: Features, - audienceMemberships?: AudienceMembership[] + audienceMemberships?: AudienceMembership[], + statsContext?: StatsContext ) => { const removeUserIdentifiers: any[] = [] const addUserIdentifiers: any[] = [] @@ -984,6 +1000,10 @@ const extractBatchUserIdentifiers = ( } 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('error.undetermined_operation_type', 1, statsContext?.tags) multiStatusResponse.setErrorResponseAtIndex(index, { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', @@ -1008,7 +1028,12 @@ const extractBatchUserIdentifiers = ( } // Helper function to determine operation type -const determineOperationType = (payload: UserListPayload, syncMode?: string, features?: Features, audienceMembership?: AudienceMembership): boolean | undefined => { +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' || @@ -1025,8 +1050,7 @@ const determineOperationType = (payload: UserListPayload, syncMode?: string, fea ) { return false } - } - else { + } else { if ( payload.event_name === 'Audience Entered' || syncMode === 'add' || @@ -1083,7 +1107,8 @@ export const processBatchPayload = async ( multiStatusResponse, syncMode, features, - audienceMemberships + audienceMemberships, + statsContext ) // Create offline user data job payload const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId) @@ -1103,6 +1128,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( @@ -1113,7 +1141,8 @@ export const processBatchPayload = async ( failedPayloadIndices, multiStatusResponse, features, - statsContext + statsContext, + isMixedBatch ) } @@ -1126,7 +1155,8 @@ export const processBatchPayload = async ( failedPayloadIndices, multiStatusResponse, features, - statsContext + statsContext, + isMixedBatch ) } if (failedPayloadIndices.size === validPayloadIndicesBitmap.length) { From e3a4472afea9b15e3c8f6d77e4dc802cca39a86f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 16 Jun 2026 13:06:45 +0100 Subject: [PATCH 170/210] facebook-custom-audiences: remove dead getJourneysV1Memberships unit tests getJourneysV1Memberships was renamed to isJourneyPayloads with different behavior. Its unit tests in journeys.test.ts imported the deleted function and are now redundant with the isJourneyPayloads suite in functions.test.ts. Remove the dead block and unused imports; keep the JourneysV1/V2 integration tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sync/__tests__/journeys.test.ts | 65 +------------------ 1 file changed, 3 insertions(+), 62 deletions(-) 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 09e90fa099a..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,8 +1,6 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -import { getJourneysV1Memberships } from '../functions' -import { RawData } from '../types' import { BASE_URL } from '../../constants' let testDestination = createTestIntegration(Destination) @@ -50,68 +48,11 @@ function makeJourneyEvent(email: string, userId: string) { }) } -// --------------------------------------------------------------------------- -// getJourneysV1Memberships (unit tests) -// --------------------------------------------------------------------------- -describe('getJourneysV1Memberships', () => { - it('returns undefined when rawDatas is undefined', () => { - expect(getJourneysV1Memberships(undefined)).toBeUndefined() - }) - - it('returns undefined when rawDatas is an empty array', () => { - expect(getJourneysV1Memberships([])).toBeUndefined() - }) - - it('returns undefined when no events are journey_step', () => { - const rawDatas: RawData[] = [ - { context: { personas: { computation_class: 'audience' } } }, - { context: { personas: { computation_class: 'audience' } } } - ] - expect(getJourneysV1Memberships(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 = getJourneysV1Memberships(rawDatas) - expect(result).toEqual([true, true, true]) - expect(result).toHaveLength(3) - }) - - it('throws InvalidAudienceMembershipError 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(() => getJourneysV1Memberships(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 = getJourneysV1Memberships(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 = getJourneysV1Memberships(rawDatas) - expect(result).toEqual([true, true, true]) - }) -}) - // --------------------------------------------------------------------------- // 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('FacebookCustomAudiences.sync - journey_step', () => { beforeEach(() => { From 21f0f936668d7d9696bd6da6600893913348004a Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 16 Jun 2026 13:09:18 +0100 Subject: [PATCH 171/210] facebook-custom-audiences: fix isJourney always-true after rename isJourneyPayloads now returns a boolean, but the call site still used `!== undefined` (leftover from when the function returned an array), making isJourney always true. Non-journey batches were wrongly treated as JourneysV1 and had their memberships overwritten with all-true, bypassing the "Audience membership details missing" validation. Use the boolean directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../destinations/facebook-custom-audiences/sync/functions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 651509f0858..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 @@ -17,7 +17,7 @@ import { parseFacebookError, getApiVersion } from '../functions' import { FacebookResponseError } from '../types' /* - * Payloads with computation_class === 'journey_step' are Journeys payloads. + * 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 isJourneyPayloads(rawDatas: RawData[] | undefined): boolean { @@ -55,7 +55,7 @@ export async function send( const msResponse = new MultiStatusResponse() // isJourneyPayloads also throws if the batch mixes journey_step and non-journey_step events. - const isJourney = isJourneyPayloads(rawData) !== undefined + 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) From 59ec5d0df0f137253c22849a6efe77d7ae4084e3 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 16 Jun 2026 13:47:58 +0100 Subject: [PATCH 172/210] google-enhanced-conversions: tag audience-membership error stats with feature_flag Add a feature_flag:on|off tag to the three audience-membership error/edge counters (error.undetermined_operation_type, extractUserIdentifiers.no_operation, partial_failure.mixed_batch) so flag-on vs flag-off error rates are directly comparable on a dashboard during rollout. Tags are built as fresh arrays to avoid mutating the shared statsContext.tags. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../google-enhanced-conversions/functions.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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 e2226d1bb3c..6c3d84e8ddb 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -590,7 +590,10 @@ const extractUserIdentifiers = ( // 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('extractUserIdentifiers.no_operation', 1, statsContext?.tags) + statsContext?.statsClient?.incr('extractUserIdentifiers.no_operation', 1, [ + ...(statsContext?.tags ?? []), + 'feature_flag:on' + ]) } } else { // Map user data to Google Ads API format @@ -607,7 +610,10 @@ const extractUserIdentifiers = ( ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) } else { - statsContext?.statsClient?.incr('extractUserIdentifiers.no_operation', 1, statsContext?.tags) + statsContext?.statsClient?.incr('extractUserIdentifiers.no_operation', 1, [ + ...(statsContext?.tags ?? []), + 'feature_flag:off' + ]) } } } @@ -710,7 +716,10 @@ const processOperations = async ( // 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('partial_failure.mixed_batch', 1, statsContext?.tags) + statsContext?.statsClient?.incr('partial_failure.mixed_batch', 1, [ + ...(statsContext?.tags ?? []), + `feature_flag:${features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP] ? 'on' : 'off'}` + ]) } handlePartialFailureResponse( partialFailureError, @@ -1003,7 +1012,10 @@ const extractBatchUserIdentifiers = ( // 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('error.undetermined_operation_type', 1, statsContext?.tags) + statsContext?.statsClient?.incr('error.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', From 4c468712e18964b744d7300c621018176bf569f3 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 16 Jun 2026 15:28:44 +0100 Subject: [PATCH 173/210] google-enhanced-conversions: namespace new error stats with slug+action Rename the three audience-membership error/edge counters to carry the destination slug and action in the metric name (google_ec.user_list.*), matching the hubspot.custom_event.* convention. This makes the resulting integration_actions.google_ec.user_list.* metrics self-identifying in Datadog so they can be charted without depending on partnerAction tag indexing. - extractUserIdentifiers.no_operation -> google_ec.user_list.no_operation - partial_failure.mixed_batch -> google_ec.user_list.mixed_batch_partial_failure - error.undetermined_operation_type -> google_ec.user_list.undetermined_operation_type Co-Authored-By: Claude Opus 4.8 (1M context) --- .../destinations/google-enhanced-conversions/functions.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 6c3d84e8ddb..66ead1c045b 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -590,7 +590,7 @@ const extractUserIdentifiers = ( // 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('extractUserIdentifiers.no_operation', 1, [ + statsContext?.statsClient?.incr('google_ec.user_list.no_operation', 1, [ ...(statsContext?.tags ?? []), 'feature_flag:on' ]) @@ -610,7 +610,7 @@ const extractUserIdentifiers = ( ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) } else { - statsContext?.statsClient?.incr('extractUserIdentifiers.no_operation', 1, [ + statsContext?.statsClient?.incr('google_ec.user_list.no_operation', 1, [ ...(statsContext?.tags ?? []), 'feature_flag:off' ]) @@ -716,7 +716,7 @@ const processOperations = async ( // 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('partial_failure.mixed_batch', 1, [ + 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'}` ]) @@ -1012,7 +1012,7 @@ const extractBatchUserIdentifiers = ( // 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('error.undetermined_operation_type', 1, [ + statsContext?.statsClient?.incr('google_ec.user_list.undetermined_operation_type', 1, [ ...(statsContext?.tags ?? []), `feature_flag:${features?.[FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP] ? 'on' : 'off'}` ]) From 3ee0f5f9ceb6a46bd993bde7feefcb4ac2b3c2e0 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 12:03:13 +0100 Subject: [PATCH 174/210] marketo-private: copy destination into e2e branch for testing Copies the Marketo Private destination (oauth2 / client_credentials) from mdb_marketo so it can be exercised by the e2e framework. No other files are touched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__snapshots__/snapshot.test.ts.snap | 34 ++++ .../marketo-private/__tests__/index.test.ts | 36 +++++ .../__tests__/snapshot.test.ts | 85 ++++++++++ .../destinations/marketo-private/constants.ts | 1 + .../destinations/marketo-private/functions.ts | 17 ++ .../marketo-private/generated-types.ts | 16 ++ .../src/destinations/marketo-private/index.ts | 55 +++++++ .../__snapshots__/snapshot.test.ts.snap | 34 ++++ .../sendForm/__tests__/index.test.ts | 148 ++++++++++++++++++ .../sendForm/__tests__/snapshot.test.ts | 85 ++++++++++ .../marketo-private/sendForm/constants.ts | 51 ++++++ .../marketo-private/sendForm/functions.ts | 110 +++++++++++++ .../sendForm/generated-types.ts | 32 ++++ .../marketo-private/sendForm/index.ts | 66 ++++++++ .../marketo-private/sendForm/types.ts | 45 ++++++ .../src/destinations/marketo-private/types.ts | 3 + 16 files changed, 818 insertions(+) create mode 100644 packages/destination-actions/src/destinations/marketo-private/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/marketo-private/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/constants.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/functions.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/index.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/sendForm/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/sendForm/constants.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/sendForm/functions.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/sendForm/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/sendForm/index.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/sendForm/types.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/types.ts diff --git a/packages/destination-actions/src/destinations/marketo-private/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/marketo-private/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 00000000000..93a24024401 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-marketo-private destination: sendForm action - all fields 1`] = ` +Object { + "formId": "ojgz9UfSl@Q9BXikZk", + "input": Array [ + Object { + "cookie": "ojgz9UfSl@Q9BXikZk", + "leadFormFields": Object { + "testType": "ojgz9UfSl@Q9BXikZk", + }, + "visitorData": Object { + "testType": "ojgz9UfSl@Q9BXikZk", + }, + }, + ], +} +`; + +exports[`Testing snapshot for actions-marketo-private destination: sendForm action - required fields 1`] = ` +Object { + "formId": "ojgz9UfSl@Q9BXikZk", + "input": Array [ + Object { + "leadFormFields": Object { + "testType": "ojgz9UfSl@Q9BXikZk", + }, + "visitorData": Object { + "testType": "ojgz9UfSl@Q9BXikZk", + }, + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/marketo-private/__tests__/index.test.ts b/packages/destination-actions/src/destinations/marketo-private/__tests__/index.test.ts new file mode 100644 index 00000000000..cf2b85b1130 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/__tests__/index.test.ts @@ -0,0 +1,36 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +const settings = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + marketo_api_domain: 'https://123-ABC-456.mktorest.com' +} + +describe('Marketo Private', () => { + 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/marketo-private/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/marketo-private/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..d5042a4b70a --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/__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 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) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + 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 + }) + + 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(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + 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/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/__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 +} From d3c14bb162564988a2d59b3cfe9ed4f050ced4fd Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 12:20:51 +0100 Subject: [PATCH 175/210] marketo-private: add e2e config and sendForm fixtures Adds the e2e test harness config (settings -> E2E_MARKETO_PRIVATE_* env vars) and three sendForm fixtures: a happy-path submitForm success, a local AggregateAjvError for a missing required formId, and an API-error path for a non-existent form ID. Documents the required env vars in .env.example. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 11 ++ .../marketo-private/__e2e__/index.ts | 10 ++ .../sendForm/__e2e__/fixtures.e2e.ts | 101 ++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 packages/destination-actions/src/destinations/marketo-private/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/marketo-private/sendForm/__e2e__/fixtures.e2e.ts diff --git a/.env.example b/.env.example index c0d6652113c..89bd6cb685e 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,12 @@ NODE_ENV=development + +# E2E test credentials. Copy this file to .env (gitignored) and fill in real values. + +# Marketo Private (actions-marketo-private) +# client_id / client_secret come from a Custom Service in Marketo LaunchPoint. +# api_domain is the REST base WITHOUT /rest, e.g. https://123-ABC-456.mktorest.com +# form_id is the numeric ID of an approved form in your Marketo instance. +E2E_MARKETO_PRIVATE_CLIENT_ID= +E2E_MARKETO_PRIVATE_CLIENT_SECRET= +E2E_MARKETO_PRIVATE_API_DOMAIN= +E2E_MARKETO_PRIVATE_FORM_ID= diff --git a/packages/destination-actions/src/destinations/marketo-private/__e2e__/index.ts b/packages/destination-actions/src/destinations/marketo-private/__e2e__/index.ts new file mode 100644 index 00000000000..5a999326a6c --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/__e2e__/index.ts @@ -0,0 +1,10 @@ +import type { E2EDestinationConfig } from '@segment/actions-core' + +export const config: E2EDestinationConfig = { + settings: { + client_id: { $env: 'E2E_MARKETO_PRIVATE_CLIENT_ID' }, + client_secret: { $env: 'E2E_MARKETO_PRIVATE_CLIENT_SECRET' }, + // Base REST domain WITHOUT the trailing /rest, e.g. https://123-ABC-456.mktorest.com + marketo_api_domain: { $env: 'E2E_MARKETO_PRIVATE_API_DOMAIN' } + } +} 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..431f34e3890 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-private/sendForm/__e2e__/fixtures.e2e.ts @@ -0,0 +1,101 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import sendForm from '../index' + +// The success fixture submits 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.' + +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', + subscribe: 'event = "Form Submitted" or event = "Registration Succeeded"', + mapping: { + ...defaultValues(sendForm.fields), + formId: FORM_ID, + leadFormFields: { + Email: 'e2e-marketo-private-001@segment.com', + FirstName: 'E2E', + LastName: 'Tester' + }, + visitorData: { + email: 'e2e-marketo-private-001@segment.com', + pageURL: 'https://example.com/segment-e2e' + } + }, + mode: 'single', + event: createE2EEvent('track', 'Form Submitted', { + properties: { + email: 'e2e-marketo-private-001@segment.com' + } + }), + 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-marketo-private-002@segment.com' + }, + visitorData: { + email: 'e2e-marketo-private-002@segment.com' + } + }, + mode: 'single', + event: createE2EEvent('track', 'Form Submitted', { + properties: { + email: 'e2e-marketo-private-002@segment.com' + } + }), + 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-marketo-private-003@segment.com' + }, + visitorData: { + email: 'e2e-marketo-private-003@segment.com' + } + }, + mode: 'single', + event: createE2EEvent('track', 'Form Submitted', { + properties: { + email: 'e2e-marketo-private-003@segment.com' + } + }), + expect: { + status: 'error', + errorType: 'IntegrationError' + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures From ec926a8e5b014f51619cb566288e196ddd71c9f6 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 12:33:51 +0100 Subject: [PATCH 176/210] marketo-private: use REST field names in e2e success fixture submitForm matches leadFormFields keys against Marketo REST field names (email/firstName/lastName), not the form's display field IDs. Verified against a live Marketo instance: the camelCase keys create a lead, the capitalized form IDs are skipped with code 1006. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../marketo-private/sendForm/__e2e__/fixtures.e2e.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 431f34e3890..52aec2beb79 100644 --- 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 @@ -19,13 +19,14 @@ const fixtures: E2EFixture[] = [ 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-marketo-private-001@segment.com', - FirstName: 'E2E', - LastName: 'Tester' + email: 'e2e-marketo-private-001@segment.com', + firstName: 'E2E', + lastName: 'Tester' }, visitorData: { - email: 'e2e-marketo-private-001@segment.com', pageURL: 'https://example.com/segment-e2e' } }, From ccd05fba1948548d41f6e6d01566b5efb3e75732 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 12:54:24 +0100 Subject: [PATCH 177/210] mixpanel: flag-gate /import auth (apiSecret vs projectToken) + add e2e tests Re-add the apiSecret setting and gate the Import Events API credential behind the actions-mixpanel-project-token-auth feature flag: OFF (default) uses the API Secret, ON uses the Project Token. Flag defined once in common/utils.ts. Add e2e fixtures for all five actions (identifyUser, trackEvent, trackPurchase, groupIdentifyUser, incrementProperties), including single, batch, and batchWithMultistatus coverage with pre-performBatch validation failures and Mixpanel strict-mode server-side rejections. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../destinations/mixpanel/__e2e__/index.ts | 17 ++ .../src/destinations/mixpanel/common/utils.ts | 10 ++ .../destinations/mixpanel/generated-types.ts | 4 + .../groupIdentifyUser/__e2e__/fixtures.e2e.ts | 49 ++++++ .../identifyUser/__e2e__/fixtures.e2e.ts | 46 +++++ .../__e2e__/fixtures.e2e.ts | 48 ++++++ .../src/destinations/mixpanel/index.ts | 6 + .../trackEvent/__e2e__/fixtures.e2e.ts | 157 ++++++++++++++++++ .../destinations/mixpanel/trackEvent/index.ts | 10 +- .../trackPurchase/__e2e__/fixtures.e2e.ts | 154 +++++++++++++++++ .../mixpanel/trackPurchase/index.ts | 16 +- 11 files changed, 509 insertions(+), 8 deletions(-) create mode 100644 packages/destination-actions/src/destinations/mixpanel/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__e2e__/fixtures.e2e.ts create mode 100644 packages/destination-actions/src/destinations/mixpanel/identifyUser/__e2e__/fixtures.e2e.ts create mode 100644 packages/destination-actions/src/destinations/mixpanel/incrementProperties/__e2e__/fixtures.e2e.ts create mode 100644 packages/destination-actions/src/destinations/mixpanel/trackEvent/__e2e__/fixtures.e2e.ts create mode 100644 packages/destination-actions/src/destinations/mixpanel/trackPurchase/__e2e__/fixtures.e2e.ts 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/common/utils.ts b/packages/destination-actions/src/destinations/mixpanel/common/utils.ts index a64691e9ac3..8c7422eb387 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,10 @@ export enum ApiRegions { IN = 'IN 🇮🇳' } +export function getImportApiCredential(settings: Settings, features?: Features): string | undefined { + return features && features[FLAGS.PROJECT_TOKEN_AUTH] ? settings.projectToken : settings.apiSecret +} + 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 f21fb3828bb..a20d9c974a8 100644 --- a/packages/destination-actions/src/destinations/mixpanel/generated-types.ts +++ b/packages/destination-actions/src/destinations/mixpanel/generated-types.ts @@ -5,6 +5,10 @@ export interface Settings { * Mixpanel project token. */ projectToken: string + /** + * Mixpanel project secret. + */ + 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/identifyUser/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/mixpanel/identifyUser/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..914113599d9 --- /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 aliases an anonymousId to a userId', + 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/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/index.ts b/packages/destination-actions/src/destinations/mixpanel/index.ts index 875da6c6a5e..0727ca34c9c 100644 --- a/packages/destination-actions/src/destinations/mixpanel/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/index.ts @@ -81,6 +81,12 @@ const destination: DestinationDefinition = { type: 'string', required: true }, + apiSecret: { + label: 'Secret Key', + description: 'Mixpanel project secret.', + type: 'password', + required: false + }, apiRegion: { label: 'Data Residency', description: 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..7c7a6cf3e1c --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/__e2e__/fixtures.e2e.ts @@ -0,0 +1,157 @@ +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. +// +// 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. + +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] +} + +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' + } + }, + { + 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 }, { status: 200 }] + } + }, + { + // 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 }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }, + { status: 200 } + ] + } + }, + { + // 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 FUTURE timestamp (year 2100) -> Mixpanel strict mode rejects it + // server-side into failed_records, surfaced with errorreporter DESTINATION. + { + ...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: [ + { status: 200 }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }, + { status: 400, errorreporter: 'DESTINATION' } + ] + } + } +] + +const fixtures: E2EFixture[] = baseFixtures.flatMap(withAuthFlagVariants) + +export default fixtures diff --git a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts index 5cc8433df84..3296d5e9b15 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts @@ -2,7 +2,7 @@ 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 } from '../common/utils' import { getEventProperties } from './functions' import { eventProperties } from '../mixpanel-properties' import { MixpanelTrackApiResponseType, handleMixPanelApiResponse } from '../common/utils' @@ -24,7 +24,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 +35,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.projectToken}:`).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..d7276573beb --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__e2e__/fixtures.e2e.ts @@ -0,0 +1,154 @@ +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 every fixture 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). +// +// 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). + +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] +} + +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' + } + }, + { + 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: '$guid:order1', total: 19.99, currency: 'USD' } + }), + createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-mixpanel-purchase-batch-002', + properties: { order_id: '$guid:order2', total: 29.99, currency: 'USD' } + }) + ], + expect: { + status: 'success', + jsonContains: [{ status: 200 }, { status: 200 }] + } + }, + { + // 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: '$guid:orderMix1', 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: '$guid:orderMix3', total: 14.99, currency: 'USD' } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200 }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }, + { status: 200 } + ] + } + }, + { + // 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: '$guid:orderSrv1', 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, FUTURE timestamp -> 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: [ + { status: 200 }, + { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }, + { status: 400, errorreporter: 'DESTINATION' } + ] + } + } +] + +const fixtures: E2EFixture[] = baseFixtures.flatMap(withAuthFlagVariants) + +export default fixtures diff --git a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts index 78ca5e7ee7f..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.projectToken}:`).toString('base64')}` + authorization: `Basic ${Buffer.from(`${credential}:`).toString('base64')}` }, throwHttpErrors: throwHttpErrors } From c35d9b82f2dd60f67b4d9eda282fa56879233f2e Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 13:02:10 +0100 Subject: [PATCH 178/210] Remove .env.example Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index 89bd6cb685e..00000000000 --- a/.env.example +++ /dev/null @@ -1,12 +0,0 @@ -NODE_ENV=development - -# E2E test credentials. Copy this file to .env (gitignored) and fill in real values. - -# Marketo Private (actions-marketo-private) -# client_id / client_secret come from a Custom Service in Marketo LaunchPoint. -# api_domain is the REST base WITHOUT /rest, e.g. https://123-ABC-456.mktorest.com -# form_id is the numeric ID of an approved form in your Marketo instance. -E2E_MARKETO_PRIVATE_CLIENT_ID= -E2E_MARKETO_PRIVATE_CLIENT_SECRET= -E2E_MARKETO_PRIVATE_API_DOMAIN= -E2E_MARKETO_PRIVATE_FORM_ID= From ea1ff4648aaf996f875b4123d9a14b901417af91 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 13:03:05 +0100 Subject: [PATCH 179/210] Restore .env.example to original content Reverts the marketo and deletion changes to .env.example, leaving it as the original 'NODE_ENV=development'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 1 + 1 file changed, 1 insertion(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 00000000000..c0d6652113c --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +NODE_ENV=development From c18567ead42caa8b92c290770eb367175088533d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 13:15:32 +0100 Subject: [PATCH 180/210] marketo-private: seed oauth in e2e config so reactive token refresh persists The destination uses the oauth2 scheme and mints its token reactively on the first 401. The platform persists the refreshed token into settings.oauth.access_token via updateOAuthSettings, which only writes when settings.oauth already exists. The e2e config seeded only client_id/client_secret/marketo_api_domain, so the minted token was dropped and every request retried with no token (601: Access token invalid). Seed an empty oauth object (mirroring the google-enhanced-conversions e2e config) so the minted token has somewhere to land. Test-config only; no destination or core code changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/destinations/marketo-private/__e2e__/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/destination-actions/src/destinations/marketo-private/__e2e__/index.ts b/packages/destination-actions/src/destinations/marketo-private/__e2e__/index.ts index 5a999326a6c..bb50370f8a5 100644 --- a/packages/destination-actions/src/destinations/marketo-private/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/marketo-private/__e2e__/index.ts @@ -5,6 +5,15 @@ export const config: E2EDestinationConfig = { client_id: { $env: 'E2E_MARKETO_PRIVATE_CLIENT_ID' }, client_secret: { $env: 'E2E_MARKETO_PRIVATE_CLIENT_SECRET' }, // Base REST domain WITHOUT the trailing /rest, e.g. https://123-ABC-456.mktorest.com - marketo_api_domain: { $env: 'E2E_MARKETO_PRIVATE_API_DOMAIN' } + marketo_api_domain: { $env: 'E2E_MARKETO_PRIVATE_API_DOMAIN' }, + // The destination uses the oauth2 scheme and mints a token reactively on the first 401. + // The platform persists the refreshed token into settings.oauth.access_token via + // updateOAuthSettings, which only writes if settings.oauth already exists. Seed an empty + // oauth object so the minted token has somewhere to land (mirrors the google-enhanced-conversions + // e2e config's `access_token: 'will_be_refreshed'` placeholder). + oauth: { + access_token: 'will_be_refreshed', + refresh_token: 'unused' + } } } From b576f80db6a54496c71155e369ef63734640ffcc Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 13:58:13 +0100 Subject: [PATCH 181/210] marketo-private: use one shared dynamic lead across sendForm e2e fixtures Generate a single random email at module load and reuse it across all sendForm fixtures (Form Submitted + Registration Succeeded), so each run exercises one dynamically-created lead rather than a static upserted record. Also add an explicit Registration Succeeded success fixture alongside Form Submitted. Minor: reword a mixpanel identifyUser fixture description. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sendForm/__e2e__/fixtures.e2e.ts | 60 +++++++++++++++---- .../identifyUser/__e2e__/fixtures.e2e.ts | 2 +- 2 files changed, 50 insertions(+), 12 deletions(-) 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 index 52aec2beb79..8b6ec3b169a 100644 --- 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 @@ -1,8 +1,9 @@ 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 fixture submits to a real, approved Marketo form. The form ID is per-instance, +// 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 ?? '' @@ -11,10 +12,19 @@ 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', + // 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), @@ -22,7 +32,7 @@ const fixtures: E2EFixture[] = [ // 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-marketo-private-001@segment.com', + email: E2E_USER_EMAIL, firstName: 'E2E', lastName: 'Tester' }, @@ -33,7 +43,35 @@ const fixtures: E2EFixture[] = [ mode: 'single', event: createE2EEvent('track', 'Form Submitted', { properties: { - email: 'e2e-marketo-private-001@segment.com' + 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: { @@ -50,16 +88,16 @@ const fixtures: E2EFixture[] = [ ...defaultValues(sendForm.fields), // formId intentionally omitted leadFormFields: { - Email: 'e2e-marketo-private-002@segment.com' + email: E2E_USER_EMAIL }, visitorData: { - email: 'e2e-marketo-private-002@segment.com' + email: E2E_USER_EMAIL } }, mode: 'single', event: createE2EEvent('track', 'Form Submitted', { properties: { - email: 'e2e-marketo-private-002@segment.com' + email: E2E_USER_EMAIL } }), expect: { @@ -79,16 +117,16 @@ const fixtures: E2EFixture[] = [ ...defaultValues(sendForm.fields), formId: '99999999', // well-formed but non-existent form leadFormFields: { - Email: 'e2e-marketo-private-003@segment.com' + email: E2E_USER_EMAIL }, visitorData: { - email: 'e2e-marketo-private-003@segment.com' + email: E2E_USER_EMAIL } }, mode: 'single', event: createE2EEvent('track', 'Form Submitted', { properties: { - email: 'e2e-marketo-private-003@segment.com' + email: E2E_USER_EMAIL } }), expect: { 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 index 914113599d9..045e27f5926 100644 --- a/packages/destination-actions/src/destinations/mixpanel/identifyUser/__e2e__/fixtures.e2e.ts +++ b/packages/destination-actions/src/destinations/mixpanel/identifyUser/__e2e__/fixtures.e2e.ts @@ -26,7 +26,7 @@ const fixtures: E2EFixture[] = [ } }, { - description: 'Successfully identifies a user and aliases an anonymousId to a userId', + description: 'Successfully identifies a user and adds an anonymousId', subscribe: 'type = "identify"', mapping: defaultValues(identifyUser.fields), mode: 'single', From 44425254208cdaa71741510951e2a51f887d07fb Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 14:43:02 +0100 Subject: [PATCH 182/210] mixpanel: expand trackEvent/trackPurchase e2e field coverage + multistatus assertions Add all-fields single-mode success fixtures that populate every input field of trackEvent and trackPurchase (including all top-level purchase fields and every product sub-field), so each field is exercised in at least one successful request. Enrich batch (multistatus) fixtures to assert the full per-item response: status, body, and the transformed sent payload. Add a trackPurchase fan-out fixture (generatePurchaseEventPerProduct) asserting the per-product events. All events use dynamic messageId ($guid) and timestamp ($now); the fixed future timestamp in the server-side-reject case is the deliberately-invalid value under test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../trackEvent/__e2e__/fixtures.e2e.ts | 162 +++++++++++++- .../trackPurchase/__e2e__/fixtures.e2e.ts | 203 ++++++++++++++++-- 2 files changed, 344 insertions(+), 21 deletions(-) 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 index 7c7a6cf3e1c..04a096f22ea 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackEvent/__e2e__/fixtures.e2e.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/__e2e__/fixtures.e2e.ts @@ -10,7 +10,9 @@ import { FLAGS } from '../../common/utils' // 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. +// 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 @@ -22,6 +24,10 @@ import { FLAGS } from '../../common/utils' // 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' @@ -40,6 +46,57 @@ function withAuthFlagVariants(fixture: E2EFixture): E2EFixture[] { 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', @@ -57,6 +114,17 @@ const baseFixtures: E2EFixture[] = [ 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"', @@ -75,7 +143,34 @@ const baseFixtures: E2EFixture[] = [ ], expect: { status: 'success', - jsonContains: [{ status: 200 }, { status: 200 }] + 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' + } + } + } + ] } }, { @@ -104,9 +199,29 @@ const baseFixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, + { + 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 } + { + status: 200, + body: 'OK', + sent: { + event: 'E2E Valid Event 3', + properties: { + distinct_id: 'e2e-test-user-mixpanel-track-mix-003', + ok: true + } + } + } ] } }, @@ -129,8 +244,10 @@ const baseFixtures: E2EFixture[] = [ createE2EEvent('track', undefined, { userId: 'e2e-test-user-mixpanel-track-srv-002' }), - // [2] valid schema but a FUTURE timestamp (year 2100) -> Mixpanel strict mode rejects it - // server-side into failed_records, surfaced with errorreporter DESTINATION. + // [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', @@ -144,9 +261,38 @@ const baseFixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, + { + // 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, errorreporter: 'DESTINATION' } + { + 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" + } + } ] } } 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 index d7276573beb..285f4df1a35 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__e2e__/fixtures.e2e.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__e2e__/fixtures.e2e.ts @@ -6,16 +6,26 @@ 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 every fixture so that each input payload maps to +// 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). +// 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' @@ -37,6 +47,51 @@ function withAuthFlagVariants(fixture: E2EFixture): E2EFixture[] { 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', @@ -56,6 +111,57 @@ const baseFixtures: E2EFixture[] = [ 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"', @@ -65,16 +171,47 @@ const baseFixtures: E2EFixture[] = [ events: [ createE2EEvent('track', 'Order Completed', { userId: 'e2e-test-user-mixpanel-purchase-batch-001', - properties: { order_id: '$guid:order1', total: 19.99, currency: 'USD' } + 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: '$guid:order2', total: 29.99, currency: 'USD' } + properties: { order_id: 'E2E-ORDER-BATCH-002', total: 29.99, currency: 'USD' } }) ], expect: { status: 'success', - jsonContains: [{ status: 200 }, { status: 200 }] + 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' + } + } + ] + } + ] } }, { @@ -87,7 +224,7 @@ const baseFixtures: E2EFixture[] = [ events: [ createE2EEvent('track', 'Order Completed', { userId: 'e2e-test-user-mixpanel-purchase-mix-001', - properties: { order_id: '$guid:orderMix1', total: 9.99, currency: 'USD' } + properties: { order_id: 'E2E-ORDER-MIX-001', total: 9.99, currency: 'USD' } }), // No event name -> required `event` missing -> fails before performBatch. createE2EEvent('track', undefined, { @@ -96,15 +233,33 @@ const baseFixtures: E2EFixture[] = [ }), createE2EEvent('track', 'Order Completed', { userId: 'e2e-test-user-mixpanel-purchase-mix-003', - properties: { order_id: '$guid:orderMix3', total: 14.99, currency: 'USD' } + properties: { order_id: 'E2E-ORDER-MIX-003', total: 14.99, currency: 'USD' } }) ], expect: { status: 'success', jsonContains: [ - { status: 200 }, + { + 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 } + { + status: 200, + body: 'OK', + sent: [ + { + event: 'Order Completed', + properties: { order_id: 'E2E-ORDER-MIX-003', total: 14.99 } + } + ] + } ] } }, @@ -120,14 +275,15 @@ const baseFixtures: E2EFixture[] = [ // [0] valid -> delivered createE2EEvent('track', 'Order Completed', { userId: 'e2e-test-user-mixpanel-purchase-srv-001', - properties: { order_id: '$guid:orderSrv1', total: 24.99, currency: 'USD' } + 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, FUTURE timestamp -> Mixpanel strict-mode server-side reject (DESTINATION) + // [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', @@ -141,9 +297,30 @@ const baseFixtures: E2EFixture[] = [ expect: { status: 'success', jsonContains: [ - { status: 200 }, + { + // 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, errorreporter: 'DESTINATION' } + { + 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" + } + } ] } } From e7fcf49d10ab5410a3b0851cce4a8cd8af8675d9 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 15:56:42 +0100 Subject: [PATCH 183/210] mixpanel: sync /import auth fallback + credential tests from production branch Mirror the mixpanel-secret-deprecation change onto this branch so the production code and unit tests are identical across both: - getImportApiCredential falls back to the project token when the flag is OFF and no apiSecret is set (avoids a 'Basic undefined:' header / 401 for new connections). - Add trackEvent unit tests asserting the /import Authorization header per path. The __e2e__ fixtures remain on this branch and continue to pass (28/28 live). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/destinations/mixpanel/common/utils.ts | 11 +++- .../trackEvent/__tests__/index.test.ts | 58 +++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/destination-actions/src/destinations/mixpanel/common/utils.ts b/packages/destination-actions/src/destinations/mixpanel/common/utils.ts index 8c7422eb387..6b9fa3294a6 100644 --- a/packages/destination-actions/src/destinations/mixpanel/common/utils.ts +++ b/packages/destination-actions/src/destinations/mixpanel/common/utils.ts @@ -12,8 +12,15 @@ export enum ApiRegions { IN = 'IN 🇮🇳' } -export function getImportApiCredential(settings: Settings, features?: Features): string | undefined { - return features && features[FLAGS.PROJECT_TOKEN_AUTH] ? settings.projectToken : settings.apiSecret +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 { 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 17cf4d4ce93..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,12 +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_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', @@ -101,7 +107,7 @@ describe('Mixpanel.trackEvent', () => { event, useDefaultMappings: true, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, + projectToken: MIXPANEL_PROJECT_TOKEN } }) expect(responses.length).toBe(1) @@ -150,7 +156,7 @@ describe('Mixpanel.trackEvent', () => { event, useDefaultMappings: true, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, + projectToken: MIXPANEL_PROJECT_TOKEN } }) expect(responses.length).toBe(1) @@ -261,7 +267,7 @@ describe('Mixpanel.trackEvent', () => { const responses = await testDestination.testAction('trackEvent', { event, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, + projectToken: MIXPANEL_PROJECT_TOKEN }, useDefaultMappings: true, mapping: { @@ -313,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 + }) + }) +}) From e7b47967ec7f585b3b2447c0cd5a3d8ee1a09709 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 16:52:32 +0100 Subject: [PATCH 184/210] more tests --- .../mixpanel/__test__/multistatus.test.ts | 65 ++++++++++++++++++- .../trackPurchase/__tests__/index.test.ts | 57 +++++++++++++++- 2 files changed, 117 insertions(+), 5 deletions(-) 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 a31c8e9b380..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,18 +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', + 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' @@ -451,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/trackPurchase/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__tests__/index.test.ts index 888fd98696e..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,13 +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_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', @@ -129,7 +135,7 @@ describe('Mixpanel.trackPurchase', () => { event, useDefaultMappings: true, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, + projectToken: MIXPANEL_PROJECT_TOKEN } }) expect(responses.length).toBe(1) @@ -173,7 +179,7 @@ describe('Mixpanel.trackPurchase', () => { mapping, useDefaultMappings: true, settings: { - projectToken: MIXPANEL_PROJECT_TOKEN, + projectToken: MIXPANEL_PROJECT_TOKEN } }) expect(responses.length).toBe(1) @@ -435,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 + }) + }) +}) From b42e6fd286ead9c763ef5fe111692a070dee207f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 17 Jun 2026 16:55:52 +0100 Subject: [PATCH 185/210] copilot feedback --- .../src/destinations/mixpanel/trackEvent/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts index 3296d5e9b15..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, getImportApiCredential } 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 => { From a472aa38cb68b111a5412f6f00b91b9036c508b0 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 19 Jun 2026 11:56:42 +0100 Subject: [PATCH 186/210] iterable: add updateUser newEmail e2e fixtures Cover the updateUser newEmail feature (PR #3811) with e2e fixtures: - email -> newEmail single (update + updateEmail) - userId-only newEmail single (currentUserId branch) - batch newEmail folded into dataFields.email (per-item multistatus) Emails are generated per-run via randomUUID so the full create-then-rename path is exercised with fresh identifiers. Batch assertions verify both the per-item sent payload and the bulkUpdate successCount. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../updateUser/__e2e__/fixtures.e2e.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) 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 index 6ccfb75eda7..e004f126b25 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts @@ -1,7 +1,26 @@ 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', @@ -20,6 +39,134 @@ const fixtures: E2EFixture[] = [ 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.' } ] From f4df0922583ffbcd3d9c4944806d0bd2019897ad Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 19 Jun 2026 12:38:50 +0100 Subject: [PATCH 187/210] pendo-audiences: fix sent/body in multistatus response items The syncAudience action populated the MultiStatusResponse `sent` and `body` fields with the wrong values. Corrected so that: - `sent` carries the input payload (the JSON sent into the action) - `body` carries the patch request sent to Pendo Validation errors (missing segment ID / visitor ID) now report the input payload as `sent` and omit `body`, since nothing was sent to Pendo. Renamed the helper to `buildPendoRequest` and generalized it to accept an array of visitor IDs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/perform-batch.test.ts | 188 ++++++++++++++---- .../pendo-audiences/syncAudience/functions.ts | 95 +++++++-- 2 files changed, 224 insertions(+), 59 deletions(-) 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__/perform-batch.test.ts index 297a3680a0c..d39ab75bb64 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__/perform-batch.test.ts @@ -64,18 +64,33 @@ 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user3'] }] } }) }) }) @@ -99,13 +114,23 @@ 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', + traitsOrProperties: { test_audience: false }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + 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', + traitsOrProperties: { test_audience: false }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] } }) }) }) @@ -129,7 +154,12 @@ describe('Pendo Audiences - syncAudience', () => { }) const responses = await testDestination.executeBatch('syncAudience', { - events: [makeEvent('user1', true), makeEvent('user2', false), makeEvent('user3', true), makeEvent('user4', false)], + events: [ + makeEvent('user1', true), + makeEvent('user2', false), + makeEvent('user3', true), + makeEvent('user4', false) + ], settings, mapping: batchMapping }) @@ -137,23 +167,43 @@ 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + 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', + traitsOrProperties: { test_audience: false }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + 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', + traitsOrProperties: { test_audience: false }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user4'] }] } }) }) }) @@ -190,8 +240,13 @@ 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', + traitsOrProperties: { test_audience: true, customId: 'user1' }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user1'] }] } }) expect(responses[1]).toMatchObject({ status: 400, @@ -213,8 +268,15 @@ 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: '', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + } }) + // 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,13 +290,25 @@ 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: '' + } }) + 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', + traitsOrProperties: { test_audience: false }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: '' + } }) + expect(responses[1]).not.toHaveProperty('body') }) it('should return a schema error for all payloads when segmentAudienceId is missing', async () => { @@ -246,8 +320,16 @@ describe('Pendo Audiences - syncAudience', () => { }) 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'." + }) }) }) @@ -265,14 +347,24 @@ 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + body: { patch: [{ op: 'add', path: '/visitors', value: ['user2'] }] } }) }) @@ -289,14 +381,24 @@ 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + 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', + traitsOrProperties: { test_audience: false }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] } }) }) @@ -320,13 +422,23 @@ 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', + traitsOrProperties: { test_audience: true }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + 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', + traitsOrProperties: { test_audience: false }, + segmentAudienceKey: 'test_audience', + segmentAudienceId: SEGMENT_ID + }, + body: { patch: [{ op: 'remove', path: '/visitors', value: ['user2'] }] } }) }) }) 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..d341ae04b04 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,37 @@ -import { IntegrationError, ErrorCodes, getErrorCodeFromHttpStatus, RequestClient, MultiStatusResponse, JSONLikeObject, PayloadValidationError } from '@segment/actions-core' +import { + IntegrationError, + ErrorCodes, + getErrorCodeFromHttpStatus, + RequestClient, + MultiStatusResponse, + JSONLikeObject, + PayloadValidationError +} 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 +): 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 } @@ -21,7 +42,15 @@ export async function send(request: RequestClient, region: string, payload: Payl payload.forEach((p, index) => { const { visitorId, traitsOrProperties, segmentAudienceKey } = 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]) @@ -55,7 +84,6 @@ export async function send(request: RequestClient, region: string, payload: Payl } try { - const response = await request( `${getDomain(region)}/${SEGMENT_ENDPOINT}/${segmentId}/visitor`, { @@ -75,16 +103,24 @@ 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.' @@ -92,33 +128,50 @@ export async function send(request: RequestClient, region: string, payload: Payl 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', + 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 { + 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 }) }) } From 05607aaa20d9fcb7aa72da597755318ea1c8c38d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 19 Jun 2026 13:26:41 +0100 Subject: [PATCH 188/210] updating pendo-audiences to use native audience membership --- .../updateSubscriptions/generated-types.ts | 8 +- ...orm-batch.test.ts => engage-batch.test.ts} | 277 ++++++++++-------- ...{perform.test.ts => engage-single.test.ts} | 87 ++++-- .../pendo-audiences/syncAudience/fields.ts | 24 -- .../pendo-audiences/syncAudience/functions.ts | 29 +- .../syncAudience/generated-types.ts | 10 - .../pendo-audiences/syncAudience/index.ts | 8 +- 7 files changed, 242 insertions(+), 201 deletions(-) rename packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/{perform-batch.test.ts => engage-batch.test.ts} (62%) rename packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/{perform.test.ts => engage-single.test.ts} (51%) diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/generated-types.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/generated-types.ts index 120338d2d10..88e20fbf811 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/generated-types.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/generated-types.ts @@ -2,7 +2,7 @@ export interface Payload { /** - * User identifier - provide email, userId, or both. + * User identifier - provide email, userId, or both. At least one is required. */ identifier: { /** @@ -15,7 +15,7 @@ export interface Payload { userId?: string } /** - * When both email and userId are provided, this determines which identifier is sent to Iterable. + * 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 /** @@ -27,11 +27,11 @@ export interface Payload { */ subscription_group_type: string /** - * The ID of the subscription group. + * 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. + * Whether to subscribe or unsubscribe the user from this group. */ action: string }[] 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 62% 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 d39ab75bb64..0d101e8aa4e 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 @@ -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,32 +62,17 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses.length).toBe(3) expect(responses[0]).toMatchObject({ status: 200, - sent: { - 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: { - 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: { - 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'] }] } }) }) @@ -114,22 +97,12 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { - 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: { - 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'] }] } }) }) @@ -154,12 +127,7 @@ describe('Pendo Audiences - syncAudience', () => { }) const responses = await testDestination.executeBatch('syncAudience', { - events: [ - makeEvent('user1', true), - makeEvent('user2', false), - makeEvent('user3', true), - makeEvent('user4', false) - ], + events: [makeEvent('user1', true), makeEvent('user2', false), makeEvent('user3', true), makeEvent('user4', false)], settings, mapping: batchMapping }) @@ -167,47 +135,107 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses.length).toBe(4) expect(responses[0]).toMatchObject({ status: 200, - sent: { - 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: { - 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: { - 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: { - 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 = { @@ -220,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 } + } }) ] @@ -240,12 +274,7 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { - 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({ @@ -257,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, @@ -268,12 +297,7 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 400, errormessage: 'Visitor ID is required', - sent: { - 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') @@ -290,33 +314,25 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 400, errormessage: 'Missing Pendo Segment ID', - sent: { - 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', - sent: { - 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) @@ -331,6 +347,45 @@ describe('Pendo Audiences - syncAudience', () => { 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') + }) }) describe('executeBatch - API error handling', () => { @@ -347,23 +402,13 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 500, errormessage: 'Internal Server Error', - sent: { - 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: { - 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'] }] } }) }) @@ -381,23 +426,13 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 403, errormessage: 'Forbidden', - sent: { - 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: { - 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'] }] } }) }) @@ -422,22 +457,12 @@ describe('Pendo Audiences - syncAudience', () => { expect(responses[0]).toMatchObject({ status: 400, errormessage: 'Error adding visitor to segment', - sent: { - 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: { - 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'] }] } }) }) 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__/engage-single.test.ts similarity index 51% rename from packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/perform.test.ts rename to packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-single.test.ts index 7b1a1a26a4c..5a45ccb5c3e 100644 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/perform.test.ts +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-single.test.ts @@ -15,12 +15,26 @@ const segmentBase = `/${SEGMENT_ENDPOINT}/${SEGMENT_ID}` const baseMapping = { visitorId: { '@path': '$.userId' }, - traitsOrProperties: { '@path': '$.traits' }, - segmentAudienceKey: 'test_audience', 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() @@ -31,14 +45,8 @@ describe('Pendo Audiences - syncAudience perform', () => { .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, + event: makeEvent('user1', true), settings, mapping: baseMapping }) @@ -51,14 +59,22 @@ describe('Pendo Audiences - syncAudience perform', () => { .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: 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, + event: makeEvent('user1', true, 'track'), settings, mapping: baseMapping }) @@ -66,30 +82,35 @@ describe('Pendo Audiences - syncAudience perform', () => { 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 } } + 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, + event: makeEvent('user1', true), 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' }) - + it('should throw a validation error when audience membership cannot be determined', async () => { const event = createTestEvent({ + type: 'identify', userId: 'user1', - traits: { test_audience: true }, + // computation_class is not "audience", so core cannot resolve membership context: { personas: { computation_key: 'test_audience', external_audience_id: SEGMENT_ID } } }) @@ -99,6 +120,18 @@ describe('Pendo Audiences - syncAudience perform', () => { settings, mapping: baseMapping }) - ).rejects.toThrow("Internal Server Error") + ).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') }) }) 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 d341ae04b04..2f217b51528 100644 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/functions.ts +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/functions.ts @@ -5,7 +5,9 @@ import { RequestClient, MultiStatusResponse, JSONLikeObject, - PayloadValidationError + PayloadValidationError, + InvalidAudienceMembershipError, + AudienceMembership } from '@segment/actions-core' import type { Payload } from './generated-types' import type { AddMap, RemoveMap, PatchBodyJSON, BatchPatchResponse, BatchMultistatusItem } from './types' @@ -16,7 +18,8 @@ export async function send( request: RequestClient, region: string, payload: Payload[], - isBatch: boolean + isBatch: boolean, + audienceMemberships: AudienceMembership[] ): Promise { const msResponse = new MultiStatusResponse() const segmentId = payload[0]?.segmentAudienceId @@ -40,7 +43,7 @@ export async function send( const removes: RemoveMap = new Map() payload.forEach((p, index) => { - const { visitorId, traitsOrProperties, segmentAudienceKey } = p + const { visitorId } = p if (!visitorId) { handleError( 'PayloadValidationError', @@ -53,8 +56,20 @@ export async function send( ) 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) @@ -152,7 +167,7 @@ function buildPendoRequest(op: 'add' | 'remove', visitorIds: string[]): JSONLike } function handleError( - errType: 'PayloadValidationError' | 'IntegrationError', + errType: 'PayloadValidationError' | 'IntegrationError' | 'InvalidAudienceMembershipError', message: string, isBatch: boolean, msResponse: MultiStatusResponse, @@ -164,6 +179,8 @@ function handleError( if (!isBatch) { if (errType === 'PayloadValidationError') { throw new PayloadValidationError(message) + } else if (errType === 'InvalidAudienceMembershipError' ) { + throw new InvalidAudienceMembershipError(message) } else { throw new IntegrationError(message, getErrorCodeFromHttpStatus(status) || ErrorCodes.UNKNOWN_ERROR, status) } 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 ?? []) } } From ae7d5cefeb3c3446c8966074a34ff642429279f5 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 19 Jun 2026 14:09:11 +0100 Subject: [PATCH 189/210] pendo-audiences: add e2e config and syncAudience fixtures Adds the e2e destination config (createAudience/getAudience/teardown against real Pendo) and syncAudience fixtures covering single add/remove plus a batch with successful add + remove alongside a missing-visitorId validation failure and an unresolvable-membership failure. Visitor IDs use $guid markers so each run targets fresh visitors, which avoids Pendo's inconsistent 200/202 success codes. Must be run with --fixtureConcurrency 1 to avoid 409 conflicts from concurrent writes to the same freshly-created segment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pendo-audiences/__e2e__/index.ts | 41 ++++++ .../syncAudience/__e2e__/fixtures.e2e.ts | 117 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 packages/destination-actions/src/destinations/pendo-audiences/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__e2e__/fixtures.e2e.ts 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..300cacd8f60 --- /dev/null +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__e2e__/fixtures.e2e.ts @@ -0,0 +1,117 @@ +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' + }), + 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' + }), + 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 stable shape of each row + // (the add/remove operation and the validation errors) rather than exact IDs. The per-row + // success status is also intentionally omitted: Pendo returns 200 or 202 interchangeably. + expect: { + status: 'success', + jsonContains: [ + { body: { patch: [{ op: 'add', path: '/visitors' }] } }, + { 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 + } +] + +export default fixtures From 2b4e6962b96f25d790624f47af1fcd17e2157ba8 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 19 Jun 2026 14:36:31 +0100 Subject: [PATCH 190/210] pendo-audiences: e2e batch-only fixture with per-operation success codes Removes the single-event fixtures and keeps a single batch fixture that verifies real success: asserts 200 for a successful add and 202 for a successful remove (Pendo's documented per-operation codes), alongside the missing-visitorId and unresolvable-membership validation failures. A freshly-created segment briefly returns 409 ("operation in progress"), so the fixture uses retries to let it settle before the writes succeed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../syncAudience/__e2e__/fixtures.e2e.ts | 44 +++---------------- 1 file changed, 7 insertions(+), 37 deletions(-) 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 index 300cacd8f60..b63bb8e38e2 100644 --- 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 @@ -29,38 +29,6 @@ function createNoMembershipEvent(userId: string): 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' - }), - 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' - }), - expect: { status: 'success' }, - verboseFailureHint: FAILURE_HINT - }, { description: 'Batch with successful add + remove, a missing-visitorId failure, and an unresolvable-membership failure', @@ -98,14 +66,16 @@ const fixtures: E2EFixture[] = [ // idx 3 — has a visitorId but no membership boolean → InvalidAudienceMembershipError createNoMembershipEvent('$guid:batchNoMembershipUser') ], - // Visitor IDs are random per run ($guid), so we assert the stable shape of each row - // (the add/remove operation and the validation errors) rather than exact IDs. The per-row - // success status is also intentionally omitted: Pendo returns 200 or 202 interchangeably. + // 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: 5, expect: { status: 'success', jsonContains: [ - { body: { patch: [{ op: 'add', path: '/visitors' }] } }, - { body: { patch: [{ op: 'remove', path: '/visitors' }] } }, + { 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' } ] From 0c9c8be67611def597f567d3a122563d3cf120d7 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 19 Jun 2026 14:48:10 +0100 Subject: [PATCH 191/210] adding retries to types --- packages/core/src/e2e-types.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts index c38cd6cc876..accbef68fc5 100644 --- a/packages/core/src/e2e-types.ts +++ b/packages/core/src/e2e-types.ts @@ -65,6 +65,12 @@ export interface E2EBaseFixture { 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 { From 811cd7886b218fa22580ecd48be79e843589924e Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 19 Jun 2026 15:26:44 +0100 Subject: [PATCH 192/210] adding e2e tests for pendo-audiences --- .../syncAudience/__e2e__/fixtures.e2e.ts | 91 ++++++++++++++++++- .../__tests__/engage-batch.test.ts | 18 +++- .../__tests__/engage-single.test.ts | 16 +++- .../pendo-audiences/syncAudience/functions.ts | 9 ++ 4 files changed, 131 insertions(+), 3 deletions(-) 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 index b63bb8e38e2..ea8abbc7724 100644 --- 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 @@ -29,6 +29,42 @@ function createNoMembershipEvent(userId: string): 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', @@ -70,7 +106,7 @@ const fixtures: E2EFixture[] = [ // 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: 5, + retries: 9, expect: { status: 'success', jsonContains: [ @@ -81,6 +117,59 @@ const fixtures: E2EFixture[] = [ ] }, 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 } ] diff --git a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-batch.test.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-batch.test.ts index 0d101e8aa4e..53bb74a7f73 100644 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/__tests__/engage-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' @@ -466,5 +466,21 @@ describe('Pendo Audiences - syncAudience', () => { 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 index 5a45ccb5c3e..97e75e3dcb4 100644 --- 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 @@ -1,5 +1,5 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, RetryableError } from '@segment/actions-core' import Destination from '../../index' import { REGIONS, SEGMENT_ENDPOINT } from '../../constants' @@ -134,4 +134,18 @@ describe('Pendo Audiences - syncAudience perform', () => { }) ).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/functions.ts b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/functions.ts index 2f217b51528..46fab531c08 100644 --- a/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/functions.ts +++ b/packages/destination-actions/src/destinations/pendo-audiences/syncAudience/functions.ts @@ -7,6 +7,7 @@ import { JSONLikeObject, PayloadValidationError, InvalidAudienceMembershipError, + RetryableError, AudienceMembership } from '@segment/actions-core' import type { Payload } from './generated-types' @@ -139,6 +140,14 @@ export async function send( 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) From 58d7ec23eee01d280cff339b6b82d86f9f451b00 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 2 Jul 2026 12:16:21 +0100 Subject: [PATCH 193/210] sync s3 destination from s3-shah256-support Brings the s3 destination in this e2e integration branch up to date with s3-shah256-support: adds the flag-off guard that throws when columns_to_hash is configured but the actions-s3-hashing feature flag is off, plus the send-level tests covering flag on/off. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../s3/syncToS3/__tests__/index.test.ts | 55 ++++++++++++++++++- .../src/destinations/s3/syncToS3/functions.ts | 32 ++++++++--- 2 files changed, 76 insertions(+), 11 deletions(-) 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 918e45298bc..0aa970d9adf 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,9 @@ -import { generateFile, validateColumnsToHash, clean, encodeString, getAudienceAction } from '../functions' +import { generateFile, validateColumnsToHash, clean, encodeString, getAudienceAction, send } from '../functions' import { Payload } from '../generated-types' -import { ColumnHeader, HashAlgorithm } from '../types' +import { Settings } from '../../generated-types' +import { ColumnHeader, HashAlgorithm, 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 @@ -17,6 +19,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') @@ -294,3 +304,44 @@ describe('validateColumnsToHash', () => { 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_hash: [{ column_name: 'email', hash_algorithm: 'sha256' }] + } + + it('should throw when columns_to_hash 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 is currently not enabled for your Segment workspace. Remove the columns to hash 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_hash is configured and the feature flag is on', async () => { + await expect( + send([payloadWithHashing], settings, rawMapping, { [S3_HASHING_FEATURE_FLAG]: true }) + ).resolves.not.toThrow() + }) +}) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index d83dc3ab412..a3e01cd27e2 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -7,7 +7,13 @@ import { Client } from './client' import { RawMapping, ColumnHeader, HashAlgorithm } from './types' import { S3_HASHING_FEATURE_FLAG } from '../constants' -export async function send(payloads: Payload[], settings: Settings, rawMapping: RawMapping, features?: Features, 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 @@ -26,10 +32,18 @@ export async function send(payloads: Payload[], settings: Settings, rawMapping: headers.push({ cleanName: clean(delimiter, batchColName), originalName: batchColName }) } - const columnsToHash = - features && features[S3_HASHING_FEATURE_FLAG] - ? validateColumnsToHash(payloads[0]?.columns_to_hash ?? [], new Set(headers.map((h) => h.originalName))) - : new Map() + const configuredColumnsToHash = payloads[0]?.columns_to_hash ?? [] + const flagEnabled = Boolean(features && features[S3_HASHING_FEATURE_FLAG]) + + if (!flagEnabled && configuredColumnsToHash.length > 0) { + throw new PayloadValidationError( + 'Column hashing is currently not enabled for your Segment workspace. Remove the columns to hash or contact Segment by emailing friends@segment.com to enable the feature.' + ) + } + + const columnsToHash = flagEnabled + ? validateColumnsToHash(configuredColumnsToHash, new Set(headers.map((h) => h.originalName))) + : new Map() const fileContent = generateFile(payloads, headers, delimiter, actionColName, batchColName, columnsToHash) @@ -129,7 +143,9 @@ export function validateColumnsToHash( const algorithm = SUPPORTED_HASH_ALGORITHMS.find((a) => a === hashAlgorithm) if (!algorithm) { throw new PayloadValidationError( - `columns_to_hash: unsupported hash_algorithm "${hashAlgorithm}". Supported: ${SUPPORTED_HASH_ALGORITHMS.join(', ')}` + `columns_to_hash: unsupported hash_algorithm "${hashAlgorithm}". Supported: ${SUPPORTED_HASH_ALGORITHMS.join( + ', ' + )}` ) } if (columnsToHash.has(columnName)) { @@ -140,9 +156,7 @@ export function validateColumnsToHash( const invalidColumns = [...columnsToHash.keys()].filter((col) => !validColumnNames.has(col)) if (invalidColumns.length > 0) { - throw new PayloadValidationError( - `columns_to_hash contains columns that do not exist: ${invalidColumns.join(', ')}` - ) + throw new PayloadValidationError(`columns_to_hash contains columns that do not exist: ${invalidColumns.join(', ')}`) } return columnsToHash From da2f80da8c2d5e7b113e5f9cac78f92d8c383f2d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 2 Jul 2026 18:19:42 +0100 Subject: [PATCH 194/210] Add S3 destination e2e fixtures (hashing, batching, fields, audiences) Adds __e2e__ config for the s3 destination and four fixture files for the syncToS3 action: - hashing: flag ON single/batch SHA256, flag ON no-hash, flag OFF throws - batching: single (onEvent) vs batch (onBatch) paths - fields: column/delimiter/extension combinations - audiences: Engage/RETL/Journeys V2 audience events Verified against a real S3 bucket via action-destinations-e2e: 17/17 pass, SHA256 hashing confirmed by matching file contents to computed hashes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/destinations/s3/__e2e__/index.ts | 27 ++++ .../s3/syncToS3/__e2e__/audiences.e2e.ts | 129 +++++++++++++++++ .../s3/syncToS3/__e2e__/batching.e2e.ts | 84 +++++++++++ .../s3/syncToS3/__e2e__/fields.e2e.ts | 132 ++++++++++++++++++ .../s3/syncToS3/__e2e__/hashing.e2e.ts | 111 +++++++++++++++ 5 files changed, 483 insertions(+) create mode 100644 packages/destination-actions/src/destinations/s3/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/audiences.e2e.ts create mode 100644 packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/batching.e2e.ts create mode 100644 packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/fields.e2e.ts create mode 100644 packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/hashing.e2e.ts 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/syncToS3/__e2e__/audiences.e2e.ts b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/audiences.e2e.ts new file mode 100644 index 00000000000..bc7c1df297e --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/audiences.e2e.ts @@ -0,0 +1,129 @@ +/** + * E2E fixtures for audience events across the source types that feed S3. + * + * 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.) + * + * Covers Engage (track + identify), RETL (new/deleted), and Journeys V2 (add/remove), in single and + * batch modes. Success = the upload did not throw; inspect the bucket to confirm the audience columns + * and audience_action values. + */ +import type { E2EFixture } from '@segment/actions-core' +import { + defaultValues, + createE2EEngageAudienceEvent, + createE2ERetlAudienceEvent, + createE2EJourneysV2AudienceEvent +} 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 (Engage track): add a user (audience_action = enter)', + subscribe: 'type = "track"', + mode: 'single', + mapping: baseMapping, + 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 (Engage identify): remove a user (audience_action = exit)', + subscribe: 'type = "identify"', + mode: 'single', + mapping: baseMapping, + 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 (RETL): new row enters the audience', + subscribe: 'type = "track"', + mode: 'single', + mapping: baseMapping, + event: createE2ERetlAudienceEvent({ + eventName: 'new', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-s3-aud-user-003', + email: 'e2e-s3-aud-003@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Audience (Journeys V2): add via journey_step event', + subscribe: 'type = "track"', + mode: 'single', + mapping: baseMapping, + event: createE2EJourneysV2AudienceEvent({ + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + journeyName: 'e2e s3 journey', + userId: 'e2e-s3-aud-user-004', + email: 'e2e-s3-aud-004@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Audience (Engage track): batch of enter + exit events', + subscribe: 'type = "track"', + mode: 'batch', + mapping: baseMapping, + events: [ + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-s3-aud-user-005', + email: 'e2e-s3-aud-005@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + userId: 'e2e-s3-aud-user-006', + email: 'e2e-s3-aud-006@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..832f17a1a9f --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/batching.e2e.ts @@ -0,0 +1,84 @@ +/** + * 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, + 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, + 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, + 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..b421f76430f --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/fields.e2e.ts @@ -0,0 +1,132 @@ +/** + * E2E fixtures exercising many combinations of columns, delimiters, and file extensions. + * + * `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. Success = the upload did not throw; inspect the bucket to confirm + * headers, delimiters, 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: ',', + 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: ',' + }, + 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: ',', + 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 + }, + { + description: 'Fields: tab delimiter with txt file extension', + subscribe: 'type = "track"', + mode: 'single', + mapping: { + ...baseMapping, + delimiter: 'tab', + file_extension: 'txt' + }, + event: richEvent(), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Fields: pipe delimiter, batch of events', + subscribe: 'type = "track"', + mode: 'batch', + mapping: { + ...baseMapping, + delimiter: '|' + }, + events: [ + createE2EEvent('track', 'E2E Fields Pipe A', { + userId: 'e2e-s3-fields-user-004', + properties: { email: 'e2e-s3-fields-004@segment.com' } + }), + createE2EEvent('track', 'E2E Fields Pipe B', { + userId: 'e2e-s3-fields-user-005', + properties: { email: 'e2e-s3-fields-005@segment.com' } + }) + ], + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + } +] + +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..32cb1937bce --- /dev/null +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/hashing.e2e.ts @@ -0,0 +1,111 @@ +/** + * E2E fixtures for the SHA256 column-hashing capability. + * + * Hashing is gated behind the `actions-s3-hashing` feature flag (S3_HASHING_FEATURE_FLAG): + * - flag ON + columns_to_hash configured -> file is written with those columns hashed (success) + * - flag ON + no columns_to_hash -> file written as-is (success) + * - flag OFF + columns_to_hash configured -> PayloadValidationError thrown, nothing uploaded (error) + * + * 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 hashed columns are actually hashed. + */ +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, + columns_to_hash: [ + { column_name: 'email', hash_algorithm: 'sha256' }, + { column_name: 'user_id', hash_algorithm: 'sha256' } + ] + }, + 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): batch of events with email hashed via SHA256', + subscribe: 'type = "track"', + mode: 'batch', + features: FLAG_ON, + mapping: { + ...baseMapping, + columns_to_hash: [{ column_name: 'email', hash_algorithm: 'sha256' }] + }, + events: [ + createE2EEvent('track', 'E2E Hashing Batch A', { + userId: 'e2e-s3-hash-user-002', + properties: { email: 'e2e-s3-hash-002@segment.com' }, + traits: { email: 'e2e-s3-hash-002@segment.com' } + }), + createE2EEvent('track', 'E2E Hashing Batch B', { + 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): no columns_to_hash configured — file written unhashed', + subscribe: 'type = "track"', + mode: 'single', + features: FLAG_ON, + mapping: { + ...baseMapping + }, + event: createE2EEvent('track', 'E2E No Hashing', { + userId: 'e2e-s3-hash-user-004', + properties: { email: 'e2e-s3-hash-004@segment.com' }, + traits: { email: 'e2e-s3-hash-004@segment.com' } + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Hashing (flag OFF): columns_to_hash configured — throws PayloadValidationError, no upload', + subscribe: 'type = "track"', + mode: 'single', + // no `features` — flag is off + mapping: { + ...baseMapping, + columns_to_hash: [{ column_name: 'email', hash_algorithm: 'sha256' }] + }, + event: createE2EEvent('track', 'E2E Hashing Flag Off', { + userId: 'e2e-s3-hash-user-005', + properties: { email: 'e2e-s3-hash-005@segment.com' }, + traits: { email: 'e2e-s3-hash-005@segment.com' } + }), + expect: { status: 'error', errorType: 'PayloadValidationError' }, + verboseFailureHint: + 'Expected a PayloadValidationError because hashing 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 From ba2493498f2fe6e6d33104d3b9441712c96c7f1e Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 2 Jul 2026 19:02:49 +0100 Subject: [PATCH 195/210] Refine S3 e2e fixtures: dedicated formats file, simpler audiences, labeled filenames - Add formats.e2e.ts covering all 5 delimiters + both extensions, with a value that embeds every delimiter to verify quoting keeps it one field. - Simplify audiences.e2e.ts to Engage events only (drop RETL/Journeys V2 helpers); source type is invisible to S3, so add/remove + single/batch is the real coverage. - Trim fields.e2e.ts to column-combination cases (delimiter/extension moved to formats). - Set a descriptive filename_prefix per fixture so each output file names what it tests (e.g. hash-single, delim-pipe, nonbatch-single); also avoids same-key overwrites within a run. Verified: 20/20 pass against a real S3 bucket; SHA256 and delimiter quoting confirmed by inspecting downloaded files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../s3/syncToS3/__e2e__/audiences.e2e.ts | 67 ++++----------- .../s3/syncToS3/__e2e__/batching.e2e.ts | 3 + .../s3/syncToS3/__e2e__/fields.e2e.ts | 46 ++--------- .../s3/syncToS3/__e2e__/formats.e2e.ts | 81 +++++++++++++++++++ .../s3/syncToS3/__e2e__/hashing.e2e.ts | 5 +- 5 files changed, 112 insertions(+), 90 deletions(-) create mode 100644 packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/formats.e2e.ts 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 index bc7c1df297e..65998bf14da 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/audiences.e2e.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/audiences.e2e.ts @@ -1,5 +1,5 @@ /** - * E2E fixtures for audience events across the source types that feed S3. + * E2E fixtures for audience events. * * The audience columns are populated from context.personas: * - audience_name <- computation_key @@ -8,17 +8,13 @@ * * (audience_space_id is left empty — the e2e audience helpers don't set personas.space_id.) * - * Covers Engage (track + identify), RETL (new/deleted), and Journeys V2 (add/remove), in single and - * batch modes. Success = the upload did not throw; inspect the bucket to confirm the audience columns - * and audience_action values. + * 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, - createE2ERetlAudienceEvent, - createE2EJourneysV2AudienceEvent -} from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' import syncToS3 from '../index' const COMPUTATION_KEY = 'e2e_s3_audience' @@ -36,10 +32,10 @@ const baseMapping = { const fixtures: E2EFixture[] = [ { - description: 'Audience (Engage track): add a user (audience_action = enter)', + description: 'Audience (track): add a user (audience_action = enter)', subscribe: 'type = "track"', mode: 'single', - mapping: baseMapping, + mapping: { ...baseMapping, filename_prefix: 'aud-add' }, event: createE2EEngageAudienceEvent({ type: 'track', action: 'add', @@ -52,10 +48,10 @@ const fixtures: E2EFixture[] = [ verboseFailureHint: FAILURE_HINT }, { - description: 'Audience (Engage identify): remove a user (audience_action = exit)', + description: 'Audience (identify): remove a user (audience_action = exit)', subscribe: 'type = "identify"', mode: 'single', - mapping: baseMapping, + mapping: { ...baseMapping, filename_prefix: 'aud-remove' }, event: createE2EEngageAudienceEvent({ type: 'identify', action: 'remove', @@ -68,57 +64,26 @@ const fixtures: E2EFixture[] = [ verboseFailureHint: FAILURE_HINT }, { - description: 'Audience (RETL): new row enters the audience', - subscribe: 'type = "track"', - mode: 'single', - mapping: baseMapping, - event: createE2ERetlAudienceEvent({ - eventName: 'new', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - userId: 'e2e-s3-aud-user-003', - email: 'e2e-s3-aud-003@segment.com' - }), - expect: { status: 'success' }, - verboseFailureHint: FAILURE_HINT - }, - { - description: 'Audience (Journeys V2): add via journey_step event', - subscribe: 'type = "track"', - mode: 'single', - mapping: baseMapping, - event: createE2EJourneysV2AudienceEvent({ - action: 'add', - computationKey: COMPUTATION_KEY, - computationId: COMPUTATION_ID, - journeyName: 'e2e s3 journey', - userId: 'e2e-s3-aud-user-004', - email: 'e2e-s3-aud-004@segment.com' - }), - expect: { status: 'success' }, - verboseFailureHint: FAILURE_HINT - }, - { - description: 'Audience (Engage track): batch of enter + exit events', + description: 'Audience (track): batch of enter + exit events', subscribe: 'type = "track"', mode: 'batch', - mapping: baseMapping, + mapping: { ...baseMapping, filename_prefix: 'aud-batch' }, events: [ createE2EEngageAudienceEvent({ type: 'track', action: 'add', computationKey: COMPUTATION_KEY, computationId: COMPUTATION_ID, - userId: 'e2e-s3-aud-user-005', - email: 'e2e-s3-aud-005@segment.com' + 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-006', - email: 'e2e-s3-aud-006@segment.com' + userId: 'e2e-s3-aud-user-004', + email: 'e2e-s3-aud-004@segment.com' }) ], expect: { status: 'success' }, 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 index 832f17a1a9f..5bef9164bd3 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/batching.e2e.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/batching.e2e.ts @@ -28,6 +28,7 @@ const fixtures: E2EFixture[] = [ mode: 'single', mapping: { ...baseMapping, + filename_prefix: 'nonbatch-single', enable_batching: false }, event: createE2EEvent('track', 'E2E Single Non-Batch', { @@ -43,6 +44,7 @@ const fixtures: E2EFixture[] = [ mode: 'batch', mapping: { ...baseMapping, + filename_prefix: 'batch-multi', enable_batching: true }, events: [ @@ -68,6 +70,7 @@ const fixtures: E2EFixture[] = [ mode: 'batch', mapping: { ...baseMapping, + filename_prefix: 'batch-one', enable_batching: true }, events: [ 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 index b421f76430f..bf33f157147 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/fields.e2e.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/fields.e2e.ts @@ -1,10 +1,11 @@ /** - * E2E fixtures exercising many combinations of columns, delimiters, and file extensions. + * 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. Success = the upload did not throw; inspect the bucket to confirm - * headers, delimiters, and object serialization. + * 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' @@ -42,6 +43,7 @@ const fixtures: E2EFixture[] = [ mapping: { ...baseMapping, delimiter: ',', + filename_prefix: 'fields-minimal', columns: { user_id: { '@path': '$.userId' }, email: { '@path': '$.properties.email' } @@ -60,7 +62,8 @@ const fixtures: E2EFixture[] = [ mode: 'single', mapping: { ...baseMapping, - delimiter: ',' + delimiter: ',', + filename_prefix: 'fields-all' }, event: richEvent(), expect: { status: 'success' }, @@ -73,6 +76,7 @@ const fixtures: E2EFixture[] = [ mapping: { ...baseMapping, delimiter: ',', + filename_prefix: 'fields-custom', columns: { user_id: { '@path': '$.userId' }, email: { '@path': '$.properties.email' }, @@ -92,40 +96,6 @@ const fixtures: E2EFixture[] = [ }), expect: { status: 'success' }, verboseFailureHint: FAILURE_HINT - }, - { - description: 'Fields: tab delimiter with txt file extension', - subscribe: 'type = "track"', - mode: 'single', - mapping: { - ...baseMapping, - delimiter: 'tab', - file_extension: 'txt' - }, - event: richEvent(), - expect: { status: 'success' }, - verboseFailureHint: FAILURE_HINT - }, - { - description: 'Fields: pipe delimiter, batch of events', - subscribe: 'type = "track"', - mode: 'batch', - mapping: { - ...baseMapping, - delimiter: '|' - }, - events: [ - createE2EEvent('track', 'E2E Fields Pipe A', { - userId: 'e2e-s3-fields-user-004', - properties: { email: 'e2e-s3-fields-004@segment.com' } - }), - createE2EEvent('track', 'E2E Fields Pipe B', { - userId: 'e2e-s3-fields-user-005', - properties: { email: 'e2e-s3-fields-005@segment.com' } - }) - ], - expect: { status: 'success' }, - verboseFailureHint: FAILURE_HINT } ] 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 index 32cb1937bce..d4e9b1ed1cd 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/hashing.e2e.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/hashing.e2e.ts @@ -35,6 +35,7 @@ const fixtures: E2EFixture[] = [ features: FLAG_ON, mapping: { ...baseMapping, + filename_prefix: 'hash-single', columns_to_hash: [ { column_name: 'email', hash_algorithm: 'sha256' }, { column_name: 'user_id', hash_algorithm: 'sha256' } @@ -55,6 +56,7 @@ const fixtures: E2EFixture[] = [ features: FLAG_ON, mapping: { ...baseMapping, + filename_prefix: 'hash-batch', columns_to_hash: [{ column_name: 'email', hash_algorithm: 'sha256' }] }, events: [ @@ -78,7 +80,8 @@ const fixtures: E2EFixture[] = [ mode: 'single', features: FLAG_ON, mapping: { - ...baseMapping + ...baseMapping, + filename_prefix: 'hash-none' }, event: createE2EEvent('track', 'E2E No Hashing', { userId: 'e2e-s3-hash-user-004', From f93853e9d2027d5e0cb269fa4d7ed705c9160a50 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 3 Jul 2026 17:40:56 +0100 Subject: [PATCH 196/210] trim column_name and hash_algorithm in validateColumnsToHash Whitespace-only values now correctly fail the required checks, and leading/trailing spaces are stripped before matching against valid column names. Addresses Copilot review comment on PR #3802. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/destinations/s3/syncToS3/functions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts index a3e01cd27e2..ae710c58689 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -131,8 +131,8 @@ export function validateColumnsToHash( const columnsToHash = new Map() for (const entry of entries) { - const columnName = String(entry.column_name ?? '') - const hashAlgorithm = String(entry.hash_algorithm ?? '') + const columnName = String(entry.column_name ?? '').trim() + const hashAlgorithm = String(entry.hash_algorithm ?? '').trim() if (!columnName) { throw new PayloadValidationError('columns_to_hash: column_name is required.') From 34f7c416cc815566a5f1a20a5f10789ad941d649 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 6 Jul 2026 12:08:28 +0100 Subject: [PATCH 197/210] Remove redundant validateAction from iterable updateSubscriptions The action field is required with choices [subscribe, unsubscribe], so the framework's AJV enum validation rejects out-of-choice values before perform/ performBatch run. The hand-rolled validateAction check was therefore dead code. The batch-path block also carried a latent bug (only inspected the first valid payload's subscriptions while erroring all payloads). Removed the checks, the validateAction helper, and the now-unused VALID_ACTIONS constant. Also lock the action field to its dropdown choices via disabledInputMethods. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../iterable/updateSubscriptions/constants.ts | 1 - .../iterable/updateSubscriptions/functions.ts | 51 ++++++++----------- .../iterable/updateSubscriptions/index.ts | 6 ++- 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts index efb75dffb63..95f710684f0 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/constants.ts @@ -1,3 +1,2 @@ export const MAX_SUBSCRIPTION_ITEMS = 6 -export const VALID_ACTIONS = ['subscribe', 'unsubscribe'] as const export const MIN_REQUEST_TIMEOUT = 30_000 diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts index 1dab6bfa064..63d5d28fc82 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts @@ -1,9 +1,16 @@ -import { PayloadValidationError, RequestClient, MultiStatusResponse, JSONLikeObject, HTTPError, DEFAULT_REQUEST_TIMEOUT } from '@segment/actions-core' +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, VALID_ACTIONS, MIN_REQUEST_TIMEOUT } from './constants' +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) { @@ -15,14 +22,15 @@ export async function performUpdateSubscriptions(request: RequestClient, payload } if (subscriptionCount > MAX_SUBSCRIPTION_ITEMS) { - throw new PayloadValidationError(`Maximum of ${MAX_SUBSCRIPTION_ITEMS} subscription items allowed. Received ${subscriptionCount}.`) + 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 }) => { - validateAction(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) }) @@ -79,24 +87,12 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa const subscriptions = payloads[validPayloads[0].index].subscriptions - for (const { action } of subscriptions) { - try { - validateAction(action) - } catch (error) { - validPayloads.forEach(({ index }) => { - multiStatusResponse.setErrorResponseAtIndex(index, { - status: 400, - errortype: 'PAYLOAD_VALIDATION_FAILED', - errormessage: (error as Error).message, - sent: payloads[index] as unknown as JSONLikeObject - }) - }) - return multiStatusResponse - } - } - - 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 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 }), @@ -149,14 +145,11 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa return multiStatusResponse } -function validateAction(action: string): void { - if (!VALID_ACTIONS.includes(action as typeof VALID_ACTIONS[number])) { - throw new PayloadValidationError(`Invalid action: '${action}'. Must be 'subscribe' or 'unsubscribe'.`) - } -} - export function resolveIdentifier(payload: Payload): ResolvedIdentifier { - const { identifier: { email, userId }, user_identifier_preference } = payload + const { + identifier: { email, userId }, + user_identifier_preference + } = payload const trimmedEmail = email?.trim() const trimmedUserId = userId?.trim() diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts index b6dced0ae03..c90c9fd5419 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts @@ -6,7 +6,8 @@ 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.', + 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: { @@ -85,7 +86,8 @@ const action: ActionDefinition = { choices: [ { label: 'Subscribe', value: 'subscribe' }, { label: 'Unsubscribe', value: 'unsubscribe' } - ] + ], + disabledInputMethods: ['variable', 'function', 'freeform', 'enrichment'] } } }, From c1ca2e85bebb43e1f5320c21c026abdeab915f7d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 6 Jul 2026 12:19:23 +0100 Subject: [PATCH 198/210] Assert batch subscriptions invariant in iterable updateSubscriptions performBatchUpdateSubscriptions issues one bulk request per subscription using the first payload's subscriptions as the reference for the whole batch. That is only safe because the `subscriptions` batch key groups every payload in a batch to share the same subscriptions config. Make that assumption explicit with a comment and assert it: if payloads ever arrive with differing subscriptions (e.g. batch_keys overridden or platform grouping changes), throw a PayloadValidationError rather than silently applying the first payload's config to all users. Add a test covering the mismatch case; drop unused response vars flagged by lint in the touched test file. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/index.test.ts | 41 +++++++++++++++++-- .../iterable/updateSubscriptions/functions.ts | 15 +++++++ 2 files changed, 53 insertions(+), 3 deletions(-) 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 index bd0a7842c6f..9c273ed0d16 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/__tests__/index.test.ts @@ -435,7 +435,7 @@ describe('Iterable.updateSubscriptions', () => { .put('/api/subscriptions/messageChannel/123?action=subscribe') .reply(200, { code: 'Success', msg: '' }) - const response = await testDestination.testBatchAction('updateSubscriptions', { + await testDestination.testBatchAction('updateSubscriptions', { events, mapping: { ...defaultMapping, @@ -477,7 +477,7 @@ describe('Iterable.updateSubscriptions', () => { .put('/api/subscriptions/messageChannel/123?action=subscribe') .reply(400, { code: 'BadParams', msg: 'Invalid subscription group' }) - const response = await testDestination.testBatchAction('updateSubscriptions', { + await testDestination.testBatchAction('updateSubscriptions', { events, mapping: defaultMapping }) @@ -509,7 +509,7 @@ describe('Iterable.updateSubscriptions', () => { }) ] - const response = await testDestination.testBatchAction('updateSubscriptions', { + await testDestination.testBatchAction('updateSubscriptions', { events, mapping: { ...defaultMapping, @@ -537,5 +537,40 @@ describe('Iterable.updateSubscriptions', () => { }) 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/functions.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts index 63d5d28fc82..10547142f0e 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts @@ -85,7 +85,22 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa 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. We rely on + // that invariant to issue one bulk request per subscription on behalf of all users in the batch, using + // the first payload's subscriptions as the reference. Assert it explicitly: if it is ever violated + // (e.g. `batch_keys` is overridden or platform grouping changes), using the first payload's config for + // everyone would silently apply the wrong subscription changes to the other users, so fail loudly. 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) From 6be97059037c5c23760192b9c6b8b272beeeb244 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 6 Jul 2026 15:20:00 +0100 Subject: [PATCH 199/210] Add e2e fixtures for iterable updateSubscriptions Cover single subscribe/unsubscribe (by email and by userId), batch bulk subscribe with per-item multistatus, and the client-side validation paths (missing identifier, >6 items, differing subscriptions in a batch). Group IDs are read from env (E2E_ITERABLE_MESSAGE_CHANNEL_ID / _MESSAGE_TYPE_ID / _EMAIL_LIST_ID) rather than hardcoded; document all required env vars in the destination's __e2e__/index.ts. The /api/subscriptions/{group}/{id}/... endpoints are gated per Iterable project and must be enabled by Iterable, so the HTTP fixtures fail with a 404 "Endpoint not found for project" until that is done. The verboseFailureHint on those fixtures explains this so the failure is self-diagnosing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../destinations/iterable/__e2e__/index.ts | 11 + .../__e2e__/fixtures.e2e.ts | 235 ++++++++++++++++++ .../iterable/updateSubscriptions/functions.ts | 6 +- .../iterable/updateSubscriptions/index.ts | 3 +- 4 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 packages/destination-actions/src/destinations/iterable/updateSubscriptions/__e2e__/fixtures.e2e.ts diff --git a/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts index 1ea0b08efa2..c28ec0b1662 100644 --- a/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts +++ b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts @@ -1,5 +1,16 @@ 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' }, 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/functions.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts index 10547142f0e..fbc480ccd7d 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/functions.ts @@ -86,11 +86,7 @@ export async function performBatchUpdateSubscriptions(request: RequestClient, pa } // 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. We rely on - // that invariant to issue one bulk request per subscription on behalf of all users in the batch, using - // the first payload's subscriptions as the reference. Assert it explicitly: if it is ever violated - // (e.g. `batch_keys` is overridden or platform grouping changes), using the first payload's config for - // everyone would silently apply the wrong subscription changes to the other users, so fail loudly. + // 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( diff --git a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts index c90c9fd5419..e0613e1f82f 100644 --- a/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts +++ b/packages/destination-actions/src/destinations/iterable/updateSubscriptions/index.ts @@ -86,8 +86,7 @@ const action: ActionDefinition = { choices: [ { label: 'Subscribe', value: 'subscribe' }, { label: 'Unsubscribe', value: 'unsubscribe' } - ], - disabledInputMethods: ['variable', 'function', 'freeform', 'enrichment'] + ] } } }, From df93f272a52d2b80ecae7534949e62955037ec66 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 6 Jul 2026 17:09:14 +0100 Subject: [PATCH 200/210] [Amplitude Cohorts] e2e: cover nonexistent user in batch cohort add Update the 'Batch add and remove users' e2e fixture so one user ID does not exist in Amplitude. Verifies the valid users still succeed (200) while the nonexistent user is returned in skipped_ids and surfaced as a per-item 400 with the correct error message. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../syncAudience/__e2e__/fixtures.e2e.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 index 3fb6415322b..077b5dc484e 100644 --- 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 @@ -5,7 +5,8 @@ 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 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[] = [ { @@ -43,6 +44,9 @@ const fixtures: E2EFixture[] = [ 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), @@ -57,13 +61,14 @@ const fixtures: E2EFixture[] = [ 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-test-user-4', + userId: 'segment-e2e-nonexistent-user', email: 'e2e-user-003@segment.com' }), createE2EEngageAudienceEvent({ @@ -80,7 +85,12 @@ const fixtures: E2EFixture[] = [ status: 'success', jsonContains: [ { status: 200 }, - { 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 } ] }, From 3c82d93e71064fc3a27a57addef31f8e100e7b4f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 7 Jul 2026 14:41:39 +0100 Subject: [PATCH 201/210] Port updateCompanyAudience action from PR #3856 into e2e branch Copies the LinkedIn DMP Company Segment sync action (account-based marketing) from linkedin-company-audience-hooks (PR #3856) so we can build e2e-framework tests against it. - New updateCompanyAudience action + api.ts methods + index.ts registration - Deletes the destination-level snapshot.test.ts (incompatible with hook-gated actions), matching the source PR. - Intentionally omits metadata.json: this branch deliberately strips metadata.json from all destinations (kept only for amplitude). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__snapshots__/snapshot.test.ts.snap | 23 -- .../__tests__/snapshot.test.ts | 121 ------ .../destinations/linkedin-audiences/api.ts | 66 ++++ .../destinations/linkedin-audiences/index.ts | 4 +- .../__snapshots__/snapshot.test.ts.snap | 24 ++ .../__tests__/index.test.ts | 343 ++++++++++++++++++ .../__tests__/snapshot.test.ts | 94 +++++ .../updateCompanyAudience/constants.ts | 15 + .../updateCompanyAudience/fields.ts | 64 ++++ .../updateCompanyAudience/functions.ts | 156 ++++++++ .../updateCompanyAudience/generated-types.ts | 81 +++++ .../updateCompanyAudience/hooks.ts | 143 ++++++++ .../updateCompanyAudience/index.ts | 25 ++ .../updateCompanyAudience/types.ts | 53 +++ 14 files changed, 1067 insertions(+), 145 deletions(-) delete mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/__tests__/__snapshots__/snapshot.test.ts.snap delete mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/constants.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/fields.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/hooks.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/index.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/types.ts 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/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/snapshot.test.ts deleted file mode 100644 index 79c504825a8..00000000000 --- a/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/snapshot.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -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 destinationSlug = 'actions-linkedin-audiences' - -describe(`Testing snapshot for ${destinationSlug} destination:`, () => { - for (const actionSlug in destination.actions) { - it(`${actionSlug} action - required fields`, async () => { - const seedName = `${destinationSlug}#${actionSlug}` - 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) - - const event = createTestEvent({ - properties: eventData - }) - - 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() - }) - - 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) - - const event = createTestEvent({ - properties: eventData - }) - - 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() - } - }) - } -}) diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/api.ts b/packages/destination-actions/src/destinations/linkedin-audiences/api.ts index c7cb597003c..2fbbee4b4cd 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,63 @@ 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 + }, + 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 + } + }) + } + + async createCompanyDmpSegment(settings: Settings, segmentName: string): Promise { + return this.request(`${BASE_URL}/dmpSegments`, { + method: 'POST', + headers: { + 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION + }, + 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/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..bc953fd35a3 --- /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..c76199f8fef --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/index.test.ts @@ -0,0 +1,343 @@ +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 } 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('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' } + }) + }) + }) + + 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' } + ] + }) + }) + }) +}) 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..7b6c17c8511 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts @@ -0,0 +1,156 @@ +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, linkedInCompanyId } = payload.identifiers ?? {} + 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, index }) + } + }) + + return validPayloads +} + +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 json = buildJSON(validPayloads) + 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) { + handleRequestError(response.status, statsContext) + } + + if (!isBatch) { + return response + } + + const resultElements = response.data?.elements ?? [] + validPayloads.forEach((payload, i) => { + const result = resultElements[i] + if (result && result.status >= 200 && result.status < 300) { + msResponse.setSuccessResponseAtIndex(payload.index, { + status: result.status, + sent: json.elements[i] as unknown as JSONLikeObject, + body: payload as unknown as JSONLikeObject + }) + } else { + msResponse.setErrorResponseAtIndex(payload.index, { + status: result?.status ?? 400, + errortype: 'BAD_REQUEST', + errormessage: result?.error?.message || 'LinkedIn did not return a result for this company.', + sent: json.elements[i] as unknown as JSONLikeObject, + 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 } } From 09c16efe3b3913726c1341e071f2878973e1dcb4 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 7 Jul 2026 16:56:32 +0100 Subject: [PATCH 202/210] tests working --- .../linkedin-audiences/__e2e__/index.ts | 32 +++ .../__e2e__/fixtures.e2e.ts | 184 ++++++++++++++++++ .../linkedin-audiences/versioning-info.ts | 2 +- 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/__e2e__/index.ts create mode 100644 packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__e2e__/fixtures.e2e.ts 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/updateCompanyAudience/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..f2c6297b2bb --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__e2e__/fixtures.e2e.ts @@ -0,0 +1,184 @@ +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) + +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 + } +] + +export default fixtures 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). From 2300c1810a4031f981201ea19a5e676fdee86f17 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 7 Jul 2026 17:30:28 +0100 Subject: [PATCH 203/210] Dedupe same-company payloads before sending to LinkedIn Collapse duplicate company+action payloads within a batch into a single element sent to LinkedIn (keyed on normalized domain + organization URN, so case, whitespace, and bare-id-vs-URN forms of the same company match). LinkedIn's per-item result is fanned back out to every original payload index via a parallel index-list, so each input row still gets its own MultiStatusResponse entry. Expands the e2e duplicate fixture to interleave non-contiguous dupes, all three normalization paths, and two invalid rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__e2e__/fixtures.e2e.ts | 46 ++++++++++++++ .../updateCompanyAudience/functions.ts | 62 ++++++++++++++----- 2 files changed, 91 insertions(+), 17 deletions(-) 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 index f2c6297b2bb..3937ca3fbf1 100644 --- 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 @@ -178,6 +178,52 @@ const fixtures: E2EFixture[] = [ 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 } ] diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts index 7b6c17c8511..617d6c36a8a 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts @@ -50,6 +50,16 @@ export function validate( return validPayloads } +export function companyKey(payload: ValidCompanyPayload): string { + const { companyDomain, linkedInCompanyId } = payload.identifiers ?? {} + const domainTrimmed = companyDomain ? companyDomain.trim().toLowerCase() : '' + const domain = domainTrimmed ? domainTrimmed : '' + const linkedinCompanyIdTrimmed = linkedInCompanyId ? linkedInCompanyId.trim() : '' + const urn = linkedinCompanyIdTrimmed ? toOrganizationUrn(linkedinCompanyIdTrimmed) : '' + 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 ?? {} @@ -92,7 +102,22 @@ export async function send( throw new PayloadValidationError('No valid payloads to process after validation.') } - const json = buildJSON(validPayloads) + 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, [ @@ -111,23 +136,26 @@ export async function send( } const resultElements = response.data?.elements ?? [] - validPayloads.forEach((payload, i) => { + uniquePayloads.forEach((payload, i) => { const result = resultElements[i] - if (result && result.status >= 200 && result.status < 300) { - msResponse.setSuccessResponseAtIndex(payload.index, { - status: result.status, - sent: json.elements[i] as unknown as JSONLikeObject, - body: payload as unknown as JSONLikeObject - }) - } else { - msResponse.setErrorResponseAtIndex(payload.index, { - status: result?.status ?? 400, - errortype: 'BAD_REQUEST', - errormessage: result?.error?.message || 'LinkedIn did not return a result for this company.', - sent: json.elements[i] as unknown as JSONLikeObject, - body: payload as unknown as JSONLikeObject - }) - } + 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 From a00f9eb0b75c4fcd01bc33a5446e1b695ab92daa Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 7 Jul 2026 18:16:22 +0100 Subject: [PATCH 204/210] Unit test: assert exact per-item MultiStatusResponse for company dedup Covers the same scenario as the e2e duplicate fixture: 12 input rows collapse to 4 unique company+action elements, and each LinkedIn result (success and error) is fanned back to every original index. Asserts the exact request body and the full per-item response array, exercising case/whitespace/bare-id-vs-URN normalization and interleaved no-identifier validation failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/index.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) 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 index c76199f8fef..d24e28cb513 100644 --- 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 @@ -195,6 +195,122 @@ describe('LinkedinAudiences.updateCompanyAudience', () => { }) 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. + 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`) From 3450b0f9319e84520b3111f9f6f9c641cfa4041e Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 8 Jul 2026 11:02:30 +0100 Subject: [PATCH 205/210] Address Copilot review: version header, 2xx check, identifier normalization Ports the fixes made on linkedin-company-audience-hooks (PR #3856): - api.ts: add LinkedIn-Version header to the DMP segment hook endpoints (listDmpSegmentsByAccount, getDmpSegmentById, createCompanyDmpSegment); without it LinkedIn returns 400 VERSION_MISSING. - functions.ts: treat any 2xx as success (was strict === 200); normalize identifiers once in validate() (trim + lowercase domain, trim id, whitespace-only treated as missing) so dedupe key and sent JSON agree. - Update dedup unit test + regenerate snapshots for the normalized (lower-cased) domain and the 202506 version. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/destinations/linkedin-audiences/api.ts | 9 ++++++--- .../__tests__/__snapshots__/snapshot.test.ts.snap | 2 +- .../__tests__/__snapshots__/snapshot.test.ts.snap | 2 +- .../updateCompanyAudience/__tests__/index.test.ts | 9 +++++---- .../updateCompanyAudience/functions.ts | 13 ++++++------- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/api.ts b/packages/destination-actions/src/destinations/linkedin-audiences/api.ts index 2fbbee4b4cd..697027cac7d 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/api.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/api.ts @@ -84,7 +84,8 @@ export class LinkedInAudiences { return this.request(`${BASE_URL}/dmpSegments`, { method: 'GET', headers: { - 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION + 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION, + 'LinkedIn-Version': getApiVersion(this.features) }, searchParams: { q: 'account', @@ -98,7 +99,8 @@ export class LinkedInAudiences { return this.request(`${BASE_URL}/dmpSegments/${dmpSegmentId}`, { method: 'GET', headers: { - 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION + 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION, + 'LinkedIn-Version': getApiVersion(this.features) } }) } @@ -107,7 +109,8 @@ export class LinkedInAudiences { return this.request(`${BASE_URL}/dmpSegments`, { method: 'POST', headers: { - 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION + 'X-Restli-Protocol-Version': LINKEDIN_PROTOCOL_VERSION, + 'LinkedIn-Version': getApiVersion(this.features) }, json: { name: segmentName, 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/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/__tests__/__snapshots__/snapshot.test.ts.snap index bc953fd35a3..f550f9e1e1c 100644 --- 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 @@ -5,7 +5,7 @@ Object { "elements": Array [ Object { "action": "ADD", - "companyWebsiteDomain": "t$O9PpW&", + "companyWebsiteDomain": "t$o9ppw&", "organizationUrn": "urn:li:organization:t$O9PpW&", }, ], 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 index d24e28cb513..bc5e06a35ea 100644 --- 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 @@ -250,10 +250,11 @@ describe('LinkedinAudiences.updateCompanyAudience', () => { mapping: perEventMapping }) - // Only 4 unique elements are sent to LinkedIn despite 12 input rows. + // 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: 'adobe.com', organizationUrn: 'urn:li:organization:1480' }, { action: 'ADD', companyWebsiteDomain: 'oracle.com' }, { action: 'ADD', organizationUrn: 'urn:li:organization:1476' }, { action: 'ADD', companyWebsiteDomain: 'ibm.com' } @@ -262,8 +263,8 @@ describe('LinkedinAudiences.updateCompanyAudience', () => { 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 } + 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, diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts index 617d6c36a8a..535d3e57c22 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateCompanyAudience/functions.ts @@ -27,7 +27,8 @@ export function validate( const validPayloads: ValidCompanyPayload[] = [] payloads.forEach((payload, index) => { - const { companyDomain, linkedInCompanyId } = payload.identifiers ?? {} + 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." @@ -43,7 +44,7 @@ export function validate( throw new PayloadValidationError(message) } } else { - validPayloads.push({ ...payload, index }) + validPayloads.push({ ...payload, identifiers: { companyDomain, linkedInCompanyId }, index }) } }) @@ -52,10 +53,8 @@ export function validate( export function companyKey(payload: ValidCompanyPayload): string { const { companyDomain, linkedInCompanyId } = payload.identifiers ?? {} - const domainTrimmed = companyDomain ? companyDomain.trim().toLowerCase() : '' - const domain = domainTrimmed ? domainTrimmed : '' - const linkedinCompanyIdTrimmed = linkedInCompanyId ? linkedInCompanyId.trim() : '' - const urn = linkedinCompanyIdTrimmed ? toOrganizationUrn(linkedinCompanyIdTrimmed) : '' + 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}` } @@ -127,7 +126,7 @@ export async function send( const response = await linkedinApiClient.batchUpdateCompanies(segmentId, json) - if (response.status !== 200) { + if (response.status < 200 || response.status >= 300) { handleRequestError(response.status, statsContext) } From d6823884d7559e33fc15944b5494ba1322f94884 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 8 Jul 2026 12:44:55 +0100 Subject: [PATCH 206/210] Add 350-company large-batch e2e fixture for updateCompanyAudience Adds a batchWithMultistatus fixture that syncs 350 real company domains to the DMP Company Segment in a single request (well under LinkedIn's 5000-per-batch max), asserting a 201 for every row. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__e2e__/fixtures.e2e.ts | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) 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 index 3937ca3fbf1..4dd72516cca 100644 --- 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 @@ -46,6 +46,360 @@ function companyEvent(properties: Record = {}): SegmentEvent { // 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', @@ -224,6 +578,26 @@ const fixtures: E2EFixture[] = [ ] }, 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 } ] From 51b40c0d3146d6c55a85e5abd40527496c4f32c6 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 8 Jul 2026 14:48:19 +0100 Subject: [PATCH 207/210] Refine amplitude-cohorts user_id copy and narrow id_type Ports from amplitudecohorts-fixes: clearer user_id field description (temporary seed user, automatic discovery, manual only as fallback) and narrows CreateAudienceJSON.id_type to 'BY_USER_ID' (the only supported value when creating a cohort). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amplitude-cohorts/generated-types.ts | 2 +- .../destinations/amplitude-cohorts/index.ts | 9 +--- .../destinations/amplitude-cohorts/types.ts | 46 +++++++++---------- 3 files changed, 26 insertions(+), 31 deletions(-) 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 cc61e8ea7f8..e1c572bbac9 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/generated-types.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/generated-types.ts @@ -38,7 +38,7 @@ export interface AudienceSettings { */ audience_name?: string /** - * A valid User ID that exists in your Amplitude project. This is required to create the cohort. The user will be added during creation and immediately removed. If no value is provided, Segment will attempt to search for a valid User ID automatically, but this may fail if no users are found. + * 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 bf64b98c559..356169f6bef 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/index.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/index.ts @@ -110,7 +110,7 @@ const destination: AudienceDestinationDefinition = { user_id: { label: 'User ID', description: - 'A valid User ID that exists in your Amplitude project. This is required to create the cohort. The user will be added during creation and immediately removed. If no value is provided, Segment will attempt to search for a valid User ID automatically, but this may fail if no users are found.', + '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 } @@ -121,12 +121,7 @@ const destination: AudienceDestinationDefinition = { full_audience_sync: false }, async createAudience(request, createAudienceInput) { - const { - audienceName, - settings, - audienceSettings, - statsContext - } = 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 diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts index 79359d318b9..2ba0981e9f3 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts @@ -3,22 +3,22 @@ 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. 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 + 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 = { - cohort_id: string - request_id: string + cohort_id: string + request_id: string } export type IDType = keyof typeof ID_TYPES @@ -28,19 +28,19 @@ 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 - }> + 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 - }> -} \ No newline at end of file + matches: Array<{ + user_id?: string | null + amplitude_id?: number + [key: string]: unknown + }> +} From a15c4a4aca55631301b3be14fe717278a0a5d345 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 8 Jul 2026 15:39:23 +0100 Subject: [PATCH 208/210] Fill updateCompanyAudience hook unit-test gaps Adds coverage for the previously-untested hook error/edge paths: - create segment returns 201 but no x-restli-id -> CREATE_SEGMENT_FAILURE - create segment request throws -> error result, no savedData - fetch existing segment throws -> error result, no savedData - getCompanyAudiences (dropdown) throws -> empty choices + error - companyAudienceHook.performHook wrapper: delegates to performCompanyHook and defaults hookInputs to {} when undefined Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/index.test.ts | 83 ++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) 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 index bc5e06a35ea..0ec118e2c78 100644 --- 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 @@ -4,7 +4,7 @@ import createRequestClient from '../../../../../../core/src/create-request-clien import Destination from '../../index' import { BASE_URL } from '../../constants' import { toOrganizationUrn } from '../functions' -import { performCompanyHook, getCompanyAudiences } from '../hooks' +import { performCompanyHook, getCompanyAudiences, companyAudienceHook } from '../hooks' import type { Settings } from '../../generated-types' const testDestination = createTestIntegration(Destination) @@ -432,6 +432,42 @@ describe('LinkedinAudiences.updateCompanyAudience', () => { 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', () => { @@ -456,5 +492,50 @@ describe('LinkedinAudiences.updateCompanyAudience', () => { ] }) }) + + 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' } + }) + }) }) }) From cdbcfcc6788db826eee55137b9bb5fb492108f95 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 9 Jul 2026 12:12:07 +0100 Subject: [PATCH 209/210] S3 syncToS3: add per-column normalization + optional hashing Renames the columns_to_hash field to columns_to_transform and lets each column select a hash_algorithm ('none' or 'sha256') and a normalize mode ('none' | 'lowercase' | 'trim' | 'lowercase_trim'), both defaulting to 'none'. Normalization runs before hashing via processHashing's cleaning function, so already-hashed values are never normalized; hash_algorithm 'none' normalizes without hashing. All gated behind the existing actions-s3-hashing flag. - Extract SUPPORTED_HASH_ALGORITHMS / SUPPORTED_NORMALIZATIONS to constants.ts; generate field choices from them. - Lock the choice sub-fields to dropdown/typed input only (disabledInputMethods). - Unit tests for every normalize mode, normalize-then-hash, hash with normalize none, already-hashed skip, and resolveColumnTransforms validation; e2e fixtures for normalize-then-hash and normalize-only. Verified end-to-end against the real S3 bucket: recomputed every SHA256 and confirmed normalize-only writes transformed plaintext. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../s3/syncToS3/__e2e__/hashing.e2e.ts | 93 +++++-- .../s3/syncToS3/__tests__/index.test.ts | 234 ++++++++++++++---- .../src/destinations/s3/syncToS3/constants.ts | 5 + .../src/destinations/s3/syncToS3/fields.ts | 29 ++- .../src/destinations/s3/syncToS3/functions.ts | 102 +++++--- .../s3/syncToS3/generated-types.ts | 12 +- .../src/destinations/s3/syncToS3/types.ts | 7 + 7 files changed, 364 insertions(+), 118 deletions(-) create mode 100644 packages/destination-actions/src/destinations/s3/syncToS3/constants.ts 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 index d4e9b1ed1cd..983933cc45c 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/hashing.e2e.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/__e2e__/hashing.e2e.ts @@ -1,14 +1,19 @@ /** - * E2E fixtures for the SHA256 column-hashing capability. + * E2E fixtures for the SHA256 column hashing / normalization capability. * - * Hashing is gated behind the `actions-s3-hashing` feature flag (S3_HASHING_FEATURE_FLAG): - * - flag ON + columns_to_hash configured -> file is written with those columns hashed (success) - * - flag ON + no columns_to_hash -> file written as-is (success) - * - flag OFF + columns_to_hash configured -> PayloadValidationError thrown, nothing uploaded (error) + * 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 hashed columns are actually hashed. + * 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' @@ -36,9 +41,9 @@ const fixtures: E2EFixture[] = [ mapping: { ...baseMapping, filename_prefix: 'hash-single', - columns_to_hash: [ - { column_name: 'email', hash_algorithm: 'sha256' }, - { column_name: 'user_id', hash_algorithm: 'sha256' } + 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', { @@ -49,6 +54,42 @@ const fixtures: E2EFixture[] = [ 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"', @@ -57,25 +98,25 @@ const fixtures: E2EFixture[] = [ mapping: { ...baseMapping, filename_prefix: 'hash-batch', - columns_to_hash: [{ column_name: 'email', hash_algorithm: 'sha256' }] + columns_to_transform: [{ column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }] }, events: [ createE2EEvent('track', 'E2E Hashing Batch A', { - userId: 'e2e-s3-hash-user-002', - properties: { email: 'e2e-s3-hash-002@segment.com' }, - traits: { email: 'e2e-s3-hash-002@segment.com' } + 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-003', - properties: { email: 'e2e-s3-hash-003@segment.com' }, - traits: { email: 'e2e-s3-hash-003@segment.com' } + 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_hash configured — file written unhashed', + description: 'Hashing (flag ON): no columns_to_transform configured — file written unchanged', subscribe: 'type = "track"', mode: 'single', features: FLAG_ON, @@ -84,30 +125,30 @@ const fixtures: E2EFixture[] = [ filename_prefix: 'hash-none' }, event: createE2EEvent('track', 'E2E No Hashing', { - userId: 'e2e-s3-hash-user-004', - properties: { email: 'e2e-s3-hash-004@segment.com' }, - traits: { email: 'e2e-s3-hash-004@segment.com' } + 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_hash configured — throws PayloadValidationError, no upload', + 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_hash: [{ column_name: 'email', hash_algorithm: 'sha256' }] + columns_to_transform: [{ column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }] }, event: createE2EEvent('track', 'E2E Hashing Flag Off', { - userId: 'e2e-s3-hash-user-005', - properties: { email: 'e2e-s3-hash-005@segment.com' }, - traits: { email: 'e2e-s3-hash-005@segment.com' } + 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 hashing is configured while the actions-s3-hashing flag is off. If this uploaded instead, the flag guard in send() is not firing.' + '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.' } ] 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 0aa970d9adf..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,15 @@ -import { generateFile, validateColumnsToHash, clean, encodeString, getAudienceAction, send } from '../functions' +import { + generateFile, + resolveColumnTransforms, + getNormalizer, + clean, + encodeString, + getAudienceAction, + send +} from '../functions' import { Payload } from '../generated-types' import { Settings } from '../../generated-types' -import { ColumnHeader, HashAlgorithm, RawMapping } from '../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' @@ -172,7 +180,7 @@ describe('generateFile', () => { }) it('should hash specified columns with sha256', () => { - const columnsToHash = new Map([['email', '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(',') @@ -196,9 +204,9 @@ describe('generateFile', () => { { cleanName: 'email', originalName: 'email' }, { cleanName: 'user_id', originalName: 'user_id' } ] - const columnsToHash = new Map([ - ['email', 'sha256'], - ['user_id', 'sha256'] + const columnsToHash = new Map([ + ['email', { algorithm: 'sha256', normalize: 'none' }], + ['user_id', { algorithm: 'sha256', normalize: 'none' }] ]) const result = generateFile(payloadWithEmpty, testHeaders, ',', undefined, undefined, columnsToHash) @@ -211,9 +219,9 @@ describe('generateFile', () => { }) it('should hash multiple columns independently', () => { - const columnsToHash = new Map([ - ['email', 'sha256'], - ['user_id', 'sha256'] + 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') @@ -231,7 +239,7 @@ describe('generateFile', () => { }) it('should not hash columns not in columnsToHash', () => { - const columnsToHash = new Map([['email', '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(',') @@ -240,67 +248,194 @@ describe('generateFile', () => { 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('validateColumnsToHash', () => { +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' }, - { column_name: 'user_id', hash_algorithm: 'sha256' } + { column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }, + { column_name: 'user_id', hash_algorithm: 'sha256', normalize: 'lowercase' } ] - const result = validateColumnsToHash(entries, validColumnNames) + const result = resolveColumnTransforms(entries, validColumnNames) expect(result.size).toBe(2) - expect(result.get('email')).toBe('sha256') - expect(result.get('user_id')).toBe('sha256') + expect(result.get('email')).toEqual({ algorithm: 'sha256', normalize: 'none' }) + expect(result.get('user_id')).toEqual({ algorithm: 'sha256', normalize: 'lowercase' }) }) - it('should throw when column_name is empty', () => { - const entries = [{ column_name: '', hash_algorithm: 'sha256' }] - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('column_name is required') + 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 throw when hash_algorithm is empty', () => { - const entries = [{ column_name: 'email', hash_algorithm: '' }] - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('hash_algorithm is required') + 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' }] - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('unsupported hash_algorithm "md5"') + 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' }] - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow( - 'columns_to_hash contains columns that do not exist: nonexistent_column' + 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' }, - { column_name: 'bad_col_2', hash_algorithm: 'sha256' } + { column_name: 'bad_col_1', hash_algorithm: 'sha256', normalize: 'none' }, + { column_name: 'bad_col_2', hash_algorithm: 'sha256', normalize: 'none' } ] - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('bad_col_1, bad_col_2') + 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' }, - { column_name: 'email', hash_algorithm: 'sha256' } + { column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }, + { column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' } ] - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow(PayloadValidationError) - expect(() => validateColumnsToHash(entries, validColumnNames)).toThrow('duplicate column_name "email"') + 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 = validateColumnsToHash([], validColumnNames) + const result = resolveColumnTransforms([], validColumnNames) expect(result.size).toBe(0) }) }) @@ -319,13 +454,13 @@ describe('send with hashing feature flag', () => { delimiter: ',', enable_batching: true, file_extension: 'csv', - columns_to_hash: [{ column_name: 'email', hash_algorithm: 'sha256' }] + columns_to_transform: [{ column_name: 'email', hash_algorithm: 'sha256', normalize: 'none' }] } - it('should throw when columns_to_hash is configured but the feature flag is off', async () => { + 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 is currently not enabled for your Segment workspace. Remove the columns to hash or contact Segment by emailing friends@segment.com to enable the feature.' + '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.' ) }) @@ -339,9 +474,22 @@ describe('send with hashing feature flag', () => { await expect(send([payloadNoHashing], settings, rawMapping, {})).resolves.not.toThrow() }) - it('should not throw when columns_to_hash is configured and the feature flag is on', async () => { + 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 45ae8008f7f..9ab05d6ee82 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,9 +234,9 @@ export const commonFields: ActionDefinition['fields'] = { ], default: 'csv' }, - columns_to_hash: { - label: 'Columns to Hash', - description: 'Columns whose values will be hashed before writing to the file.', + 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, @@ -244,7 +245,7 @@ export const commonFields: ActionDefinition['fields'] = { properties: { column_name: { label: 'Column Name', - description: 'The name of the column to hash.', + description: 'The name of the column to hash or normalize.', type: 'string', required: true, allowNull: false, @@ -252,12 +253,22 @@ export const commonFields: ActionDefinition['fields'] = { }, hash_algorithm: { label: 'Hash Algorithm', - description: 'The hashing algorithm to apply.', + description: "The hashing algorithm to apply. Select 'none' to normalize the value without hashing it.", type: 'string', required: true, - choices: [{ label: 'SHA256', value: 'sha256' }], - default: 'sha256', - disabledInputMethods: ['variable', 'function', 'enrichment'] + 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. Already-hashed values are never normalized. 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'] } } }, @@ -268,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 ae710c58689..50fbdb2dfc8 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/functions.ts @@ -4,8 +4,9 @@ import { processHashing } from '../../../lib/hashing-utils' import { Payload } from './generated-types' import { Settings } from '../generated-types' import { Client } from './client' -import { RawMapping, ColumnHeader, HashAlgorithm } 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[], @@ -32,20 +33,20 @@ export async function send( headers.push({ cleanName: clean(delimiter, batchColName), originalName: batchColName }) } - const configuredColumnsToHash = payloads[0]?.columns_to_hash ?? [] + const configuredColumnTransforms = payloads[0]?.columns_to_transform ?? [] const flagEnabled = Boolean(features && features[S3_HASHING_FEATURE_FLAG]) - if (!flagEnabled && configuredColumnsToHash.length > 0) { + if (!flagEnabled && configuredColumnTransforms.length > 0) { throw new PayloadValidationError( - 'Column hashing is currently not enabled for your Segment workspace. Remove the columns to hash or contact Segment by emailing friends@segment.com to enable the feature.' + '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 columnsToHash = flagEnabled - ? validateColumnsToHash(configuredColumnsToHash, new Set(headers.map((h) => h.originalName))) - : new Map() + const columnTransforms = flagEnabled + ? resolveColumnTransforms(configuredColumnTransforms, new Set(headers.map((h) => h.originalName))) + : new Map() - const fileContent = generateFile(payloads, headers, delimiter, actionColName, batchColName, columnsToHash) + 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) @@ -66,7 +67,20 @@ export function clean(delimiter: string, str?: string) { return delimiter === 'tab' ? str : str.replace(delimiter, '') } -function processField(value: unknown | undefined, hashAlgorithm?: HashAlgorithm): string { +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 ? '' @@ -74,11 +88,17 @@ function processField(value: unknown | undefined, hashAlgorithm?: HashAlgorithm) ? String(JSON.stringify(value)) : String(value) - if (hashAlgorithm && str !== '') { - return encodeString(processHashing(str, hashAlgorithm, 'hex')) + if (str === '' || !transform) { + return encodeString(str) } - 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( @@ -87,18 +107,18 @@ export function generateFile( delimiter: string, actionColName?: string, batchColName?: string, - columnsToHash: Map = new Map() + 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), columnsToHash.get(header.originalName)) + return processField(getAudienceAction(payload), columnTransforms.get(header.originalName)) } if (header.originalName === batchColName) { - return processField(payloads.length, columnsToHash.get(header.originalName)) + return processField(payloads.length, columnTransforms.get(header.originalName)) } - return processField(payload.columns[header.originalName], columnsToHash.get(header.originalName)) + return processField(payload.columns[header.originalName], columnTransforms.get(header.originalName)) }) return Buffer.from(`${row.join(delimiter === 'tab' ? '\t' : delimiter)}${isLastRow ? '' : '\n'}`) @@ -122,42 +142,52 @@ export function getAudienceAction(payload: Payload): boolean | undefined { return (payload?.traits_or_props as Record | undefined)?.[payload.computation_key] ?? undefined } -const SUPPORTED_HASH_ALGORITHMS: HashAlgorithm[] = ['sha256'] - -export function validateColumnsToHash( - entries: NonNullable, +export function resolveColumnTransforms( + entries: NonNullable, validColumnNames: Set -): Map { - const columnsToHash = new Map() +): 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_hash: column_name is required.') + throw new PayloadValidationError('columns_to_transform: column_name is required.') } - if (!hashAlgorithm) { - throw new PayloadValidationError('columns_to_hash: hash_algorithm 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(', ')}` + ) + } } - const algorithm = SUPPORTED_HASH_ALGORITHMS.find((a) => a === hashAlgorithm) - if (!algorithm) { + + if (!SUPPORTED_NORMALIZATIONS.includes(normalize as Normalization)) { throw new PayloadValidationError( - `columns_to_hash: unsupported hash_algorithm "${hashAlgorithm}". Supported: ${SUPPORTED_HASH_ALGORITHMS.join( - ', ' - )}` + `columns_to_transform: unsupported normalize "${normalize}". Supported: ${SUPPORTED_NORMALIZATIONS.join(', ')}` ) } - if (columnsToHash.has(columnName)) { - throw new PayloadValidationError(`columns_to_hash: duplicate column_name "${columnName}".`) + + if (columnTransforms.has(columnName)) { + throw new PayloadValidationError(`columns_to_transform: duplicate column_name "${columnName}".`) } - columnsToHash.set(columnName, algorithm) + columnTransforms.set(columnName, { algorithm, normalize: normalize as Normalization }) } - const invalidColumns = [...columnsToHash.keys()].filter((col) => !validColumnNames.has(col)) + const invalidColumns = [...columnTransforms.keys()].filter((col) => !validColumnNames.has(col)) if (invalidColumns.length > 0) { - throw new PayloadValidationError(`columns_to_hash contains columns that do not exist: ${invalidColumns.join(', ')}`) + throw new PayloadValidationError( + `columns_to_transform contains columns that do not exist: ${invalidColumns.join(', ')}` + ) } - return columnsToHash + 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 6044cbf4b24..f45b321787e 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts @@ -114,17 +114,21 @@ export interface Payload { */ file_extension: string /** - * Columns whose values will be hashed before writing to the file. + * Columns whose values will be normalized and/or hashed before writing to the file. */ - columns_to_hash?: { + columns_to_transform?: { /** - * The name of the column to hash. + * The name of the column to hash or normalize. */ column_name: string /** - * The hashing algorithm to apply. + * The hashing algorithm to apply. Select None to normalize the value without hashing it. */ hash_algorithm: string + /** + * How to normalize the value before hashing. Already-hashed values are never normalized. 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/types.ts b/packages/destination-actions/src/destinations/s3/syncToS3/types.ts index af289004655..733d79acb6e 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/types.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/types.ts @@ -2,6 +2,13 @@ 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 From 194b6ad6bdf5a2185dbd16056157ad2293009bf5 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 9 Jul 2026 16:09:22 +0100 Subject: [PATCH 210/210] S3 syncToS3: clarify normalize field description (re-hash guarantee) Ports the copilot-comment fix from s3-shah256-support: the normalize field said "Already-hashed values are never normalized", which is inaccurate for the normalize-only path. Corrected to "Values that are already hashed are never re-hashed" (the guarantee processHashing actually provides). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../destination-actions/src/destinations/s3/syncToS3/fields.ts | 2 +- .../src/destinations/s3/syncToS3/generated-types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts b/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts index 9ab05d6ee82..0196350d261 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/fields.ts @@ -263,7 +263,7 @@ export const commonFields: ActionDefinition['fields'] = { normalize: { label: 'Normalize', description: - "How to normalize the value before hashing. Already-hashed values are never normalized. Select 'none' to leave the value unchanged.", + "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 })), 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 f45b321787e..ba2feffe55a 100644 --- a/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts +++ b/packages/destination-actions/src/destinations/s3/syncToS3/generated-types.ts @@ -126,7 +126,7 @@ export interface Payload { */ hash_algorithm: string /** - * How to normalize the value before hashing. Already-hashed values are never normalized. Select None to leave the value unchanged. + * 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 }[]