diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts new file mode 100644 index 00000000000..ba13ea8cd28 --- /dev/null +++ b/packages/core/src/e2e-helpers.ts @@ -0,0 +1,169 @@ +import type { SegmentEvent } from './segment-event' +import type { JSONValue } from './json-object' +import type { + E2EAudienceEventBase, + E2EEngageAudienceEventOptions, + E2EEngageAudienceEvent, + E2EJourneysV1AudienceEventOptions, + E2EJourneysV1AudienceTrackEvent, + E2ERetlAudienceEventOptions, + E2ERetlAudienceTrackEvent +} from './e2e-types' + +/* + * Regular Segment Connections event + */ +export function createE2EEvent( + type: SegmentEvent['type'], + name?: string, + overrides?: Partial> +): SegmentEvent { + if (type === 'track') { + return { + type, + event: name, + messageId: '$guid', + timestamp: '$now', + ...overrides + } + } + + if (type === 'page' || type === 'screen') { + return { + type, + name, + messageId: '$guid', + timestamp: '$now', + ...overrides + } + } + + if (name) { + throw new Error( + `createE2EEvent: "name" is not supported for "${type}" events. Only track, page, and screen accept a name.` + ) + } + + return { + type, + messageId: '$guid', + timestamp: '$now', + ...overrides + } +} + +function buildAudienceEventBase(options: E2EAudienceEventBase) { + const { + computationKey, + computationId, + externalAudienceId, + userId, + anonymousId, + email, + audienceFields, + includeContextTraits = true + } = options + return { + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + context: { + personas: { + computation_class: 'audience', + computation_key: computationKey, + computation_id: computationId, + ...(externalAudienceId && { external_audience_id: externalAudienceId }) + }, + ...(audienceFields && { audienceFields }), + ...(includeContextTraits && email && { traits: { email } }) + } + } +} + +/* + * Engage Audience event + * Supports identify and track events + */ +export function createE2EEngageAudienceEvent( + options: E2EEngageAudienceEventOptions +): E2EEngageAudienceEvent { + const { type, action, computationKey, eventName, email, enrichedTraits } = options + + if (type === 'identify' && eventName) { + throw new Error('createE2EEngageAudienceEvent: "eventName" is not supported for identify events.') + } + + const membership = action === 'add' + const base = buildAudienceEventBase({ ...options, includeContextTraits: type === 'track' }) + + const event = { + ...base, + ...(type === 'track' && { + type: 'track', + event: eventName ?? 'Test Engage Audience Membership Event', + properties: { + [computationKey]: membership, + ...(enrichedTraits as { [k: string]: JSONValue }) + } + }), + ...(type === 'identify' && { + type: 'identify', + traits: { + [computationKey]: membership, + ...(enrichedTraits as { [k: string]: JSONValue }), + ...(email && { email }) + } + }) + } + + return event as E2EEngageAudienceEvent +} + +/* + * Journeys V1 events (preset journeys_step_entered_track) do not have properties[] value. + * All Journeys V1 events enter the user to the audience, never remove them. + * Only track events supported + */ +export function createE2EJourneysV1AudienceEvent( + options: E2EJourneysV1AudienceEventOptions +): E2EJourneysV1AudienceTrackEvent { + const { eventName, enrichedTraits } = options + const base = buildAudienceEventBase(options) + + const event = { + ...base, + type: 'track', + event: eventName ?? 'Test Journeys V1 Audience Membership Event', + properties: { + ...(enrichedTraits as { [k: string]: JSONValue }) + } + } + + 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 +): E2ERetlAudienceTrackEvent { + const { eventName, computationKey, enrichedTraits } = options + const membership = eventName !== 'deleted' + const base = buildAudienceEventBase(options) + + const event = { + ...base, + type: 'track', + event: eventName, + properties: { + [computationKey]: membership, + ...(enrichedTraits as { [k: string]: JSONValue }) + } + } + + return event as E2ERetlAudienceTrackEvent +} diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts new file mode 100644 index 00000000000..248f67d313d --- /dev/null +++ b/packages/core/src/e2e-types.ts @@ -0,0 +1,305 @@ +import type { SegmentEvent } from './segment-event' +import type { JSONObject, JSONValue } from './json-object' + +export type E2EExpectation = E2ESuccessExpectation | E2EFailureExpectation | E2EErrorExpectation + +/** + * The HTTP request was sent and the destination API returned a 2xx response. + */ +export interface E2ESuccessExpectation { + status: 'success' + httpStatus?: E2EHttpSuccessCode + bodyContains?: string + /** Partial deep match against the JSON response body. Arrays must match length and each item is partial-matched. */ + jsonContains?: unknown +} + +/** + * The HTTP request was sent and the destination API returned a non-2xx response. + * Use this to verify that the destination rejects specific inputs (e.g., bad auth, invalid payload). + */ +export interface E2EFailureExpectation { + status: 'failure' + httpStatus: E2EHttpFailureCode + bodyContains?: string + /** Partial deep match against the JSON response body. Arrays must match length and each item is partial-matched. */ + jsonContains?: unknown +} + +/** + * Our action code threw before making an HTTP request. + * The request never left. Use this to verify client-side validation + * (e.g., PayloadValidationError when required fields are missing). + */ +export interface E2EErrorExpectation { + status: 'error' + errorType: string + errorMessage?: string +} + +/** + * Dynamic value markers that the runner resolves at execution time. + * + * - '$now' → current ISO 8601 timestamp (e.g., '2026-05-28T14:32:01.000Z') + * - '$guid' → fresh UUID v4, unique each occurrence + * - '$guid:' → UUID v4, consistent within a single fixture execution. + * All occurrences of the same name resolve to the same value. + * - '$externalAudienceId' → resolved after createAudience step returns the destination's audience ID + */ +export type E2EDynamicValue = '$now' | '$guid' | `$guid:${string}` | '$externalAudienceId' + +export type E2EExecutionMode = 'single' | 'batch' | 'batchWithMultistatus' + +export type E2EFixture = E2ESingleFixture | E2EBatchFixture | E2EBatchWithMultistatusFixture + +export interface E2EBaseFixture { + /** Human-readable name for the test case, shown in runner output. */ + description: string + /** FQL query that determines whether the event matches this subscription. */ + subscribe: string + /** Mapping kit directives that transform the event into the action's payload shape. */ + mapping: JSONObject + /** The expected outcome of executing this fixture. */ + expect: E2EExpectation + /** Hint shown in verbose mode when this fixture fails. Helps developers diagnose common issues. */ + verboseFailureHint?: string +} + +export interface E2ESingleFixture extends E2EBaseFixture { + /** Executes via onEvent() with a single event. */ + mode: 'single' + /** + * The Segment event (track, identify, page, screen, etc.) sent into the action. + * String values may use dynamic markers ($now, $guid, $guid:) that the + * runner resolves before execution. + */ + event: SegmentEvent +} + +export interface E2EBatchFixture extends E2EBaseFixture { + /** Executes via onBatch() with multiple events. Response is a standard HTTP response. */ + mode: 'batch' + /** + * Array of Segment events sent into the action as a batch. + * String values may use dynamic markers ($now, $guid, $guid:) that the + * runner resolves before execution. + */ + events: SegmentEvent[] +} + +export interface E2EBatchWithMultistatusFixture extends E2EBaseFixture { + /** Executes via onBatch(). Response is a per-item MultiStatusResponse array. */ + mode: 'batchWithMultistatus' + /** + * Array of Segment events sent into the action as a batch. + * String values may use dynamic markers ($now, $guid, $guid:) that the + * runner resolves before execution. + */ + events: SegmentEvent[] +} + +export interface E2EAudienceEventBase { + computationKey: string + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + includeContextTraits?: boolean +} + +export interface E2EEngageAudienceEventOptions { + type: 'track' | 'identify' + action: 'add' | 'remove' + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + eventName?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2EJourneysV1AudienceEventOptions { + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + eventName?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2ERetlAudienceEventOptions { + eventName: 'new' | 'updated' | 'deleted' + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2ERetlAudienceTrackEvent extends SegmentEvent { + type: 'track' + event: 'new' | 'updated' | 'deleted' + messageId: string + timestamp: string + context: { + personas: E2EEngageAudiencePersonas + traits?: { email?: string } + audienceFields?: Record + } + properties: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +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 + 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: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export interface E2EEngageAudienceIdentifyEvent extends SegmentEvent { + type: 'identify' + messageId: string + timestamp: string + context: { + personas: E2EEngageAudiencePersonas + audienceFields?: Record + } + traits: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export type E2EEngageAudienceEvent = + | E2EEngageAudienceTrackEvent + | E2EEngageAudienceIdentifyEvent + +export interface E2ESettingsSecretValue { + $env: string +} + +export interface E2ESettingsObject { + [key: string]: string | number | boolean | E2ESettingsSecretValue | E2ESettingsObject +} + +export interface E2EDestinationConfig { + settings: E2ESettingsObject +} + +export interface E2ETeardownContext { + settings: Record +} + +export interface E2ETeardownAudienceContext extends E2ETeardownContext { + externalAudienceId: string + audienceSettings: Record +} + +export interface E2EAudienceConfig { + /** Name of the audience to create/test against. Used as the audienceName param for createAudience. */ + audienceName: string + /** Audience-level settings passed to createAudience and getAudience (e.g., id_type, owner_email). */ + audienceSettings: Record + /** When true, the runner calls createAudience before executing fixtures and captures the externalAudienceId. */ + createAudience: boolean + /** When true, the runner calls getAudience after fixtures to verify the audience still exists. */ + getAudience: boolean + /** When a function, the runner calls it after all tests to clean up the audience. Set to false to skip. */ + teardown: false | ((context: E2ETeardownAudienceContext) => Promise) +} + +export interface E2EAudienceDestinationConfig extends E2EDestinationConfig { + audience: E2EAudienceConfig +} + +export type E2EHttpSuccessCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 + +export type E2EHttpFailureCode = + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 306 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 421 + | 422 + | 423 + | 424 + | 425 + | 426 + | 428 + | 429 + | 431 + | 451 + | 499 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 506 + | 507 + | 508 + | 509 + | 510 + | 511 + | 529 + | 598 + | 599 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26a3884eaba..ff125c9a642 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,3 +112,35 @@ export { export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' + +export { createE2EEvent, createE2EEngageAudienceEvent, createE2EJourneysV1AudienceEvent, createE2ERetlAudienceEvent } from './e2e-helpers' +export type { + E2EFixture, + E2EBaseFixture, + E2ESingleFixture, + E2EBatchFixture, + E2EBatchWithMultistatusFixture, + E2EExecutionMode, + E2EExpectation, + E2ESuccessExpectation, + E2EFailureExpectation, + E2EErrorExpectation, + E2EDestinationConfig, + E2EAudienceDestinationConfig, + E2EAudienceConfig, + E2ETeardownContext, + E2ETeardownAudienceContext, + E2ESettingsSecretValue, + E2EDynamicValue, + E2EEngageAudienceEventOptions, + E2EEngageAudienceEvent, + E2EEngageAudienceTrackEvent, + E2EEngageAudienceIdentifyEvent, + E2EEngageAudiencePersonas, + E2EJourneysV1AudienceEventOptions, + E2EJourneysV1AudienceTrackEvent, + E2ERetlAudienceEventOptions, + E2ERetlAudienceTrackEvent, + E2EHttpSuccessCode, + E2EHttpFailureCode +} from './e2e-types' diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts new file mode 100644 index 00000000000..ddb88bf9ac3 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__e2e__/index.ts @@ -0,0 +1,18 @@ +import type { E2EAudienceDestinationConfig } from '@segment/actions-core' + +export const config: E2EAudienceDestinationConfig = { + settings: { + api_key: { $env: 'E2E_AMPLITUDE_COHORTS_API_KEY' }, + secret_key: { $env: 'E2E_AMPLITUDE_COHORTS_SECRET_KEY' }, + app_id: { $env: 'E2E_AMPLITUDE_COHORTS_APP_ID' }, + default_owner_email: { $env: 'E2E_AMPLITUDE_COHORTS_OWNER_EMAIL' }, + endpoint: 'north_america' + }, + audience: { + audienceName: 'e2e_test_audience_track', + audienceSettings: { id_type: 'BY_USER_ID' }, + createAudience: true, + getAudience: true, + teardown: false + } +} diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/functions.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/functions.test.ts index 7413577f050..777e435349c 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/functions.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/functions.test.ts @@ -1,15 +1,599 @@ -import { getEndpointByRegion } from '../functions' +import nock from 'nock' +import { createRequestClient } from '@segment/actions-core' +import { getEndpointByRegion, fetchSeedUserId, removeSeedUser, createAudience, getAudience } from '../functions' + +const requestClient = createRequestClient() + +const settings = { + api_key: 'test_api_key', + secret_key: 'test_secret_key', + app_id: 'test_app_id', + default_owner_email: 'owner@example.com', + endpoint: 'north_america' +} describe('Amplitude Cohorts functions', () => { + afterEach(() => { + nock.cleanAll() + }) + describe('getEndpointByRegion', () => { it('should return north america endpoint by default', () => { - const result = getEndpointByRegion('cohorts_upload') - expect(result).toBe('https://amplitude.com/api/3/cohorts/upload') + expect(getEndpointByRegion('cohorts_upload')).toBe('https://amplitude.com/api/3/cohorts/upload') + }) + + it('should return north america endpoint for undefined region', () => { + expect(getEndpointByRegion('cohorts_upload', undefined)).toBe('https://amplitude.com/api/3/cohorts/upload') }) it('should return europe endpoint when specified', () => { - const result = getEndpointByRegion('cohorts_membership', 'europe') - expect(result).toBe('https://analytics.eu.amplitude.com/api/3/cohorts/membership') + expect(getEndpointByRegion('cohorts_membership', 'europe')).toBe( + 'https://analytics.eu.amplitude.com/api/3/cohorts/membership' + ) + }) + + it('should return north america endpoint for unknown region', () => { + expect(getEndpointByRegion('cohorts_membership', 'unknown_region')).toBe( + 'https://amplitude.com/api/3/cohorts/membership' + ) + }) + + it('should return usersearch endpoint for north america', () => { + expect(getEndpointByRegion('usersearch', 'north_america')).toBe('https://amplitude.com/api/2/usersearch') + }) + + it('should return usersearch endpoint for europe', () => { + expect(getEndpointByRegion('usersearch', 'europe')).toBe( + 'https://analytics.eu.amplitude.com/api/2/usersearch' + ) + }) + + it('should return cohorts_get_one endpoint for north america', () => { + expect(getEndpointByRegion('cohorts_get_one', 'north_america')).toBe( + 'https://amplitude.com/api/5/cohorts/request' + ) + }) + + it('should return cohorts_get_one endpoint for europe', () => { + expect(getEndpointByRegion('cohorts_get_one', 'europe')).toBe( + 'https://analytics.eu.amplitude.com/api/5/cohorts/request' + ) + }) + }) + + describe('fetchSeedUserId', () => { + it('should return the first user_id found in the first batch', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'found_user_1', amplitude_id: 12345 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { + matches: [] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { + matches: [] + }) + + const result = await fetchSeedUserId(request, 'north_america') + expect(result).toBe('found_user_1') + }) + + it('should skip matches without user_id and find one in later results', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: null, amplitude_id: 111 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { + matches: [{ user_id: null, amplitude_id: 222 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { + matches: [{ user_id: 'real_user', amplitude_id: 333 }] + }) + + const result = await fetchSeedUserId(request, 'north_america') + expect(result).toBe('real_user') + }) + + it('should search second batch if first batch yields no user_id', async () => { + const request = requestClient + + // First batch returns no user_ids + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + // Second batch has a match + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '4' }) + .reply(200, { + matches: [{ user_id: 'batch2_user', amplitude_id: 444 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '5' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '6' }) + .reply(200, { matches: [] }) + + const result = await fetchSeedUserId(request, 'north_america') + expect(result).toBe('batch2_user') + }) + + it('should throw IntegrationError when no users are found in any batch', async () => { + const request = requestClient + + // All 9 searches return empty + for (const prefix of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) { + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: prefix }) + .reply(200, { matches: [] }) + } + + await expect(fetchSeedUserId(request, 'north_america')).rejects.toThrowError( + 'Unable to fetch a seed user from Amplitude. The project must contain at least one user with a User ID.' + ) + }) + + it('should use europe endpoint when configured', async () => { + const request = requestClient + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'eu_user', amplitude_id: 100 }] + }) + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + const result = await fetchSeedUserId(request, 'europe') + expect(result).toBe('eu_user') + }) + }) + + describe('removeSeedUser', () => { + it('should send removal request and not throw on success', async () => { + const request = requestClient + + const expectedBody = { + cohort_id: 'cohort_abc', + skip_invalid_ids: true, + memberships: [{ + ids: ['seed_user_1'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', expectedBody) + .reply(200, { + cohort_id: 'cohort_abc', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + await expect( + removeSeedUser(request, 'cohort_abc', 'north_america', 'seed_user_1') + ).resolves.not.toThrow() + }) + + it('should not throw when removal request fails', async () => { + const request = requestClient + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership') + .reply(500, { error: 'Internal Server Error' }) + + await expect( + removeSeedUser(request, 'cohort_abc', 'north_america', 'seed_user_1') + ).resolves.not.toThrow() + }) + + it('should use europe endpoint when configured', async () => { + const request = requestClient + + const expectedBody = { + cohort_id: 'cohort_eu', + skip_invalid_ids: true, + memberships: [{ + ids: ['eu_seed_user'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://analytics.eu.amplitude.com') + .post('/api/3/cohorts/membership', expectedBody) + .reply(200, { + cohort_id: 'cohort_eu', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + await expect( + removeSeedUser(request, 'cohort_eu', 'europe', 'eu_seed_user') + ).resolves.not.toThrow() + }) + }) + + describe('createAudience', () => { + it('should fetch a seed user, create the cohort, and remove the seed user', async () => { + const request = requestClient + + // User Search - first batch + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user_1', amplitude_id: 12345 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + // Cohort creation + const expectedUploadBody = { + name: 'My Cohort', + app_id: 'test_app_id', + id_type: 'BY_USER_ID', + ids: ['seed_user_1'], + owner: 'custom@example.com', + published: true + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', expectedUploadBody) + .reply(200, { cohortId: 'new_cohort_id' }) + + // Seed user removal + const expectedRemovalBody = { + cohort_id: 'new_cohort_id', + skip_invalid_ids: true, + memberships: [{ + ids: ['seed_user_1'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', expectedRemovalBody) + .reply(200, { + cohort_id: 'new_cohort_id', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await createAudience( + request, + settings, + 'My Cohort', + 'BY_USER_ID', + 'custom@example.com' + ) + + expect(result).toBe('new_cohort_id') + }) + + it('should use provided user_id and skip User Search', async () => { + const request = requestClient + + const expectedUploadBody = { + name: 'Override Cohort', + app_id: 'test_app_id', + id_type: 'BY_USER_ID', + ids: ['provided_user'], + owner: 'owner@example.com', + published: true + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', expectedUploadBody) + .reply(200, { cohortId: 'override_cohort_id' }) + + // Seed user removal + const expectedRemovalBody = { + cohort_id: 'override_cohort_id', + skip_invalid_ids: true, + memberships: [{ + ids: ['provided_user'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', expectedRemovalBody) + .reply(200, { + cohort_id: 'override_cohort_id', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await createAudience( + request, + settings, + 'Override Cohort', + 'BY_USER_ID', + undefined, + 'provided_user' + ) + + expect(result).toBe('override_cohort_id') + }) + + it('should use default_owner_email when owner_email is not provided', async () => { + const request = requestClient + + const expectedUploadBody = { + name: 'Default Owner Cohort', + app_id: 'test_app_id', + id_type: 'BY_USER_ID', + ids: ['provided_user'], + owner: 'owner@example.com', + published: true + } + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', expectedUploadBody) + .reply(200, { cohortId: 'default_owner_cohort_id' }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership') + .reply(200, { + cohort_id: 'default_owner_cohort_id', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await createAudience( + request, + settings, + 'Default Owner Cohort', + 'BY_USER_ID', + undefined, + 'provided_user' + ) + + expect(result).toBe('default_owner_cohort_id') + }) + + it('should throw IntegrationError when name is missing', async () => { + const request = requestClient + + await expect( + createAudience(request, settings, '', 'BY_USER_ID', 'owner@example.com') + ).rejects.toThrowError('Missing audience name value') + }) + + it('should throw IntegrationError when id_type is missing', async () => { + const request = requestClient + + await expect( + createAudience(request, settings, 'My Cohort', '' as any, 'owner@example.com') + ).rejects.toThrowError('Missing id_type value') + }) + + it('should throw IntegrationError when Amplitude returns no cohortId', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user', amplitude_id: 100 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload') + .reply(200, {}) + + await expect( + createAudience(request, settings, 'My Cohort', 'BY_USER_ID', 'owner@example.com') + ).rejects.toThrowError( + 'Invalid response from Amplitude Cohorts API when attempting to create new Cohort: Missing cohortId' + ) + }) + + it('should throw when cohort creation request fails', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user', amplitude_id: 100 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload') + .reply(400, { error: 'Bad Request' }) + + await expect( + createAudience(request, settings, 'My Cohort', 'BY_USER_ID', 'owner@example.com') + ).rejects.toThrowError('Bad Request') + }) + + it('should use europe endpoints when configured', async () => { + const request = requestClient + const euSettings = { ...settings, endpoint: 'europe' } + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'eu_seed', amplitude_id: 100 }] + }) + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://analytics.eu.amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + const expectedUploadBody = { + name: 'EU Cohort', + app_id: 'test_app_id', + id_type: 'BY_USER_ID', + ids: ['eu_seed'], + owner: 'owner@example.com', + published: true + } + + nock('https://analytics.eu.amplitude.com') + .post('/api/3/cohorts/upload', expectedUploadBody) + .reply(200, { cohortId: 'eu_cohort_id' }) + + const expectedRemovalBody = { + cohort_id: 'eu_cohort_id', + skip_invalid_ids: true, + memberships: [{ + ids: ['eu_seed'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + nock('https://analytics.eu.amplitude.com') + .post('/api/3/cohorts/membership', expectedRemovalBody) + .reply(200, { + cohort_id: 'eu_cohort_id', + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await createAudience( + request, + euSettings, + 'EU Cohort', + 'BY_USER_ID', + undefined, + undefined + ) + + expect(result).toBe('eu_cohort_id') + }) + }) + + describe('getAudience', () => { + it('should resolve when cohort exists and matches externalId', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/5/cohorts/request/cohort_789') + .reply(200, { cohort_id: 'cohort_789', request_id: 'req_123' }) + + await expect(getAudience(request, settings, 'cohort_789')).resolves.not.toThrow() + }) + + it('should throw when API returns mismatched cohort_id', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/5/cohorts/request/expected_id') + .reply(200, { cohort_id: 'different_id', request_id: 'req_456' }) + + await expect(getAudience(request, settings, 'expected_id')).rejects.toThrowError( + 'Cohort with id expected_id not found' + ) + }) + + it('should throw when API returns no cohort_id', async () => { + const request = requestClient + + nock('https://amplitude.com') + .get('/api/5/cohorts/request/missing_id') + .reply(200, {}) + + await expect(getAudience(request, settings, 'missing_id')).rejects.toThrowError( + 'Invalid response from Amplitude Cohorts API when attempting to get Cohort: Missing cohort_id' + ) + }) + + it('should use europe endpoint when configured', async () => { + const request = requestClient + const euSettings = { ...settings, endpoint: 'europe' } + + nock('https://analytics.eu.amplitude.com') + .get('/api/5/cohorts/request/eu_cohort') + .reply(200, { cohort_id: 'eu_cohort', request_id: 'req_eu' }) + + await expect(getAudience(request, euSettings, 'eu_cohort')).resolves.not.toThrow() }) }) }) diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/index.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/index.test.ts index eccf31ab8ae..a695f56ecd5 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/__tests__/index.test.ts @@ -13,6 +13,10 @@ const settings = { } describe('Amplitude Cohorts', () => { + afterEach(() => { + nock.cleanAll() + }) + describe('testAuthentication', () => { it('should validate authentication inputs', async () => { nock('https://amplitude.com') @@ -25,20 +29,53 @@ describe('Amplitude Cohorts', () => { }) describe('createAudience', () => { - it('should create an audience with user_id type', async () => { + it('should create an audience with user_id type using fetched seed user', async () => { const audienceId = 'test_cohort_123' + // User Search API - first batch + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user_1', amplitude_id: 12345 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + // Cohort creation - always uses BY_USER_ID and the seed user nock('https://amplitude.com') .post('/api/3/cohorts/upload', { name: 'Test Audience', app_id: settings.app_id, id_type: 'BY_USER_ID', - ids: [], + ids: ['seed_user_1'], owner: 'owner@example.com', published: true }) + .reply(200, { cohortId: audienceId }) + + // Seed user removal + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', { + cohort_id: audienceId, + skip_invalid_ids: true, + memberships: [{ + ids: ['seed_user_1'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + }) .reply(200, { - cohortId: audienceId + cohort_id: audienceId, + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] }) const result = await testDestination.createAudience({ @@ -53,20 +90,53 @@ describe('Amplitude Cohorts', () => { expect(result).toEqual({ externalId: audienceId }) }) - it('should create an audience with amplitude_id type', async () => { + it('should create an audience with amplitude_id type using fetched seed user', async () => { const audienceId = 'test_cohort_456' + // User Search API + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user_amp', amplitude_id: 99999 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + // Cohort creation - always uses BY_USER_ID regardless of audience id_type nock('https://amplitude.com') .post('/api/3/cohorts/upload', { name: 'Test Amplitude ID Audience', app_id: settings.app_id, - id_type: 'BY_AMP_ID', - ids: [], + id_type: 'BY_USER_ID', + ids: ['seed_user_amp'], owner: 'custom@example.com', published: true }) + .reply(200, { cohortId: audienceId }) + + // Seed user removal + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', { + cohort_id: audienceId, + skip_invalid_ids: true, + memberships: [{ + ids: ['seed_user_amp'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + }) .reply(200, { - cohortId: audienceId + cohort_id: audienceId, + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] }) const result = await testDestination.createAudience({ @@ -80,6 +150,99 @@ describe('Amplitude Cohorts', () => { expect(result).toEqual({ externalId: audienceId }) }) + + it('should use user_id override and skip User Search', async () => { + const audienceId = 'override_cohort' + + // No User Search mocks needed - it should not be called + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', { + name: 'Override Audience', + app_id: settings.app_id, + id_type: 'BY_USER_ID', + ids: ['my_known_user'], + owner: 'owner@example.com', + published: true + }) + .reply(200, { cohortId: audienceId }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership', { + cohort_id: audienceId, + skip_invalid_ids: true, + memberships: [{ + ids: ['my_known_user'], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + }) + .reply(200, { + cohort_id: audienceId, + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await testDestination.createAudience({ + settings, + audienceName: 'Override Audience', + audienceSettings: { + id_type: 'BY_USER_ID', + user_id: 'my_known_user' + } + }) + + expect(result).toEqual({ externalId: audienceId }) + }) + + it('should use audience_name override when provided', async () => { + const audienceId = 'custom_name_cohort' + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '1' }) + .reply(200, { + matches: [{ user_id: 'seed_user', amplitude_id: 100 }] + }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '2' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .get('/api/2/usersearch') + .query({ user: '3' }) + .reply(200, { matches: [] }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/upload', { + name: 'Custom Cohort Name', + app_id: settings.app_id, + id_type: 'BY_USER_ID', + ids: ['seed_user'], + owner: 'owner@example.com', + published: true + }) + .reply(200, { cohortId: audienceId }) + + nock('https://amplitude.com') + .post('/api/3/cohorts/membership') + .reply(200, { + cohort_id: audienceId, + memberships_result: [{ skipped_ids: [], operation: 'REMOVE' }] + }) + + const result = await testDestination.createAudience({ + settings, + audienceName: 'Original Audience Name', + audienceSettings: { + id_type: 'BY_USER_ID', + audience_name: 'Custom Cohort Name' + } + }) + + expect(result).toEqual({ externalId: audienceId }) + }) }) describe('getAudience', () => { @@ -87,7 +250,8 @@ describe('Amplitude Cohorts', () => { const externalId = 'cohort_789' nock('https://amplitude.com').get(`/api/5/cohorts/request/${externalId}`).reply(200, { - cohortId: externalId + cohort_id: externalId, + request_id: 'test_req_id' }) const result = await testDestination.getAudience({ @@ -102,7 +266,8 @@ describe('Amplitude Cohorts', () => { const externalId = 'nonexistent_cohort' nock('https://amplitude.com').get(`/api/5/cohorts/request/${externalId}`).reply(200, { - cohortId: 'different_id' + cohort_id: 'different_id', + request_id: 'test_req_id' }) await expect( diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/constants.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/constants.ts index a33a90e5b41..3b7aad9bc20 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/constants.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/constants.ts @@ -10,11 +10,14 @@ export const ID_TYPES = { BY_AMP_ID: 'BY_AMP_ID' } + export const OPERATIONS = { ADD: 'ADD', REMOVE: 'REMOVE' } +export const REMOVAL_AWAIT_THRESHOLD_MS = 3000 + export const endpoints = { usersearch: { north_america: `https://amplitude.com/api/${AMPLITUDE_API_USER_SEARCH_VERSION}/usersearch`, diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/functions.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/functions.ts index 5769ec95c13..14ee26aef20 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/functions.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/functions.ts @@ -1,8 +1,8 @@ -import { Region, CreateAudienceJSON, CreateAudienceResponse } from './types' +import { Region, CreateAudienceJSON, CreateAudienceResponse, GetAudienceResponse, IDType, UserSearchResponse } from './types' import { RequestClient, IntegrationError } from '@segment/actions-core' +import { StatsContext } from '@segment/actions-core/destination-kit' import { Settings } from './generated-types' -import { endpoints } from './constants' -import { IDType } from './types' +import { endpoints, REMOVAL_AWAIT_THRESHOLD_MS } from './constants' export function getEndpointByRegion(endpoint: keyof typeof endpoints, region?: string): string { return endpoints[endpoint][region as Region] ?? endpoints[endpoint]['north_america'] @@ -13,56 +13,165 @@ export async function createAudience( settings: Settings, name: string, id_type: IDType, - owner_email?: string + owner_email?: string, + user_id?: string, + statsContext?: StatsContext ): Promise { const { endpoint, app_id, default_owner_email } = settings + const { statsClient, tags } = statsContext || {} + const statsName = 'actions_amplitude_cohorts' + const startTime = Date.now() if (!name) { + statsClient?.incr(`${statsName}.create_audience.error.missing_name`, 1, tags) throw new IntegrationError('Missing audience name value', 'MISSING_REQUIRED_FIELD', 400) } if (!id_type) { + statsClient?.incr(`${statsName}.create_audience.error.missing_id_type`, 1, tags) throw new IntegrationError('Missing id_type value', 'MISSING_REQUIRED_FIELD', 400) } + let seedUserId: string + const trimmedUserId = user_id?.trim() + if (trimmedUserId) { + seedUserId = trimmedUserId + statsClient?.incr(`${statsName}.create_audience.seed_user_provided`, 1, tags) + } else { + try { + seedUserId = await fetchSeedUserId(request, endpoint) + statsClient?.incr(`${statsName}.create_audience.seed_user_fetched`, 1, tags) + } catch (e) { + statsClient?.incr(`${statsName}.create_audience.error.seed_user_fetch_failed`, 1, tags) + throw e + } + } + const url = getEndpointByRegion('cohorts_upload', endpoint) + // Amplitude's upload endpoint requires BY_USER_ID with a valid user_id to create a cohort, + // regardless of the id_type used for subsequent membership operations. const json: CreateAudienceJSON = { name, app_id, - id_type, - ids: [], + id_type: 'BY_USER_ID', + ids: [seedUserId], owner: owner_email ?? default_owner_email, published: true } - const response = await request(url, { - method: 'post', - json - }) + try { + const response = await request(url, { + method: 'post', + json + }) - const id = response?.data?.cohortId + const id = response?.data?.cohortId - if (!id) { - throw new IntegrationError( - 'Invalid response from Amplitude Cohorts API when attempting to create new Cohort: Missing cohortId', - 'INVALID_RESPONSE', - 500 + if (!id) { + statsClient?.incr(`${statsName}.create_audience.error.missing_cohort_id`, 1, tags) + throw new IntegrationError( + 'Invalid response from Amplitude Cohorts API when attempting to create new Cohort: Missing cohortId', + 'INVALID_RESPONSE', + 500 + ) + } + + statsClient?.incr(`${statsName}.create_audience.success`, 1, tags) + + const elapsed = Date.now() - startTime + if (elapsed < REMOVAL_AWAIT_THRESHOLD_MS) { + await removeSeedUser(request, id, endpoint, seedUserId, statsContext) + } else { + void removeSeedUser(request, id, endpoint, seedUserId, statsContext) + } + + return id + } catch (e) { + statsClient?.incr(`${statsName}.create_audience.error.cohort_creation_failed`, 1, tags) + throw e + } +} + +/** + * Amplitude's Cohorts API requires at least one valid user ID in the `ids` array when creating a cohort. + * Empty arrays are rejected. Since at cohort-creation time we don't yet have a real audience member to + * reference, we search for any existing user in the Amplitude project to use as a temporary "seed" user. + * This seed user is added during creation and immediately removed afterward. + * + * We use Amplitude's User Search API with single-digit numeric prefixes ('1', '2', '3', etc.) because + * prefix matching is broad enough to find a user in most projects. Searches are batched in groups of 3 + * to balance parallelism against rate limits. We validate that `match.user_id` is non-null because the + * search endpoint can return phantom matches (amplitude_id-only results with null user_id) which are not + * valid for cohort creation. + */ +export async function fetchSeedUserId(request: RequestClient, endpoint: string): Promise { + const url = getEndpointByRegion('usersearch', endpoint) + const batches = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9']] + + for (const batch of batches) { + const results = await Promise.all( + batch.map(async (prefix) => { + const response = await request(`${url}?user=${prefix}`) + const matches = response?.data?.matches || [] + for (const match of matches) { + if (match.user_id) { + return match.user_id + } + } + return undefined + }) ) + + const found = results.find((id) => id !== undefined) + if (found) { + return found + } + } + + throw new IntegrationError( + 'Unable to fetch a seed user from Amplitude. The project must contain at least one user with a User ID.', + 'INVALID_RESPONSE', + 400 + ) +} + +export async function removeSeedUser(request: RequestClient, cohortId: string, endpoint: string, seedUserId: string, statsContext?: StatsContext): Promise { + const { statsClient, tags } = statsContext || {} + const statsName = 'actions_amplitude_cohorts' + const url = getEndpointByRegion('cohorts_membership', endpoint) + + const json = { + cohort_id: cohortId, + skip_invalid_ids: true, + memberships: [{ + ids: [seedUserId], + id_type: 'BY_NAME', + operation: 'REMOVE' + }] + } + + try { + await request(url, { + method: 'POST', + json + }) + statsClient?.incr(`${statsName}.create_audience.seed_user_removal.success`, 1, tags) + } catch { + statsClient?.incr(`${statsName}.create_audience.seed_user_removal.error`, 1, tags) } - return id } export async function getAudience(request: RequestClient, settings: Settings, externalId: string): Promise { const { endpoint } = settings const url = `${getEndpointByRegion('cohorts_get_one', endpoint)}/${externalId}` - const response = await request(url) - const id = response?.data?.cohortId + const response = await request(url) + const id = response?.data?.cohort_id if (!id) { throw new IntegrationError( - 'Invalid response from Amplitude Cohorts API when attempting to get Cohort: Missing cohortId', + 'Invalid response from Amplitude Cohorts API when attempting to get Cohort: Missing cohort_id', 'INVALID_RESPONSE', 500 ) diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/generated-types.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/generated-types.ts index 3ac5e92dfe7..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.e2e.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..3fb6415322b --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__e2e__/fixtures.e2e.ts @@ -0,0 +1,91 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' +import syncAudience from '../index' + +const COMPUTATION_KEY = 'e2e_test_audience_track' +const COMPUTATION_ID = 'aud_e2e_test_001' + +const FAILURE_HINT = 'User IDs must exist in the Amplitude project before they can be added to or removed from a cohort. Ensure the test users have been created in Amplitude first.' + +const fixtures: E2EFixture[] = [ + { + description: 'Add a single user via track event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-test-user-1', + email: 'e2e-user-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Remove a single user via track event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-test-user-2', + email: 'e2e-user-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch add and remove users', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-test-user-3', + email: 'e2e-user-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-test-user-4', + email: 'e2e-user-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'segment-e2e-test-user-5', + email: 'e2e-user-001@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200 }, + { status: 200 }, + { status: 200 } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/functions.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/functions.test.ts index 2b1af4d00ad..c3f7475d04d 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/functions.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/functions.test.ts @@ -1,4 +1,5 @@ -import { getId, getIdTypeName } from '../functions' +import { MultiStatusResponse } from '@segment/actions-core' +import { getId, getIdTypeName, getMembershipIdType, getJSON, failAllPayloads, handleError } from '../functions' import type { Payload } from '../generated-types' describe('syncAudience functions', () => { @@ -6,33 +7,52 @@ describe('syncAudience functions', () => { it('should return user_id when id_type is BY_USER_ID', () => { const payload: Payload = { user_id: 'user123', - engage_fields: { - segment_computation_class: 'audience', - traits_or_properties: {}, - segment_audience_key: 'test_audience', - segment_external_audience_id: 'cohort123' - }, + segment_external_audience_id: 'cohort123', batch_size: 100 } - const result = getId(payload, 'BY_USER_ID') - expect(result).toBe('user123') + expect(getId(payload, 'BY_USER_ID')).toBe('user123') }) it('should return amplitude_id when id_type is BY_AMP_ID', () => { const payload: Payload = { amplitude_id: 'amp456', - engage_fields: { - segment_computation_class: 'audience', - traits_or_properties: {}, - segment_audience_key: 'test_audience', - segment_external_audience_id: 'cohort123' - }, + segment_external_audience_id: 'cohort123', batch_size: 100 } - const result = getId(payload, 'BY_AMP_ID') - expect(result).toBe('amp456') + expect(getId(payload, 'BY_AMP_ID')).toBe('amp456') + }) + + it('should return undefined when user_id is missing and id_type is BY_USER_ID', () => { + const payload: Payload = { + amplitude_id: 'amp456', + segment_external_audience_id: 'cohort123', + batch_size: 100 + } + + expect(getId(payload, 'BY_USER_ID')).toBeUndefined() + }) + + it('should return undefined when amplitude_id is missing and id_type is BY_AMP_ID', () => { + const payload: Payload = { + user_id: 'user123', + segment_external_audience_id: 'cohort123', + batch_size: 100 + } + + expect(getId(payload, 'BY_AMP_ID')).toBeUndefined() + }) + + it('should return undefined for an unknown id_type', () => { + const payload: Payload = { + user_id: 'user123', + amplitude_id: 'amp456', + segment_external_audience_id: 'cohort123', + batch_size: 100 + } + + expect(getId(payload, 'UNKNOWN' as any)).toBeUndefined() }) }) @@ -45,4 +65,188 @@ describe('syncAudience functions', () => { expect(getIdTypeName('BY_AMP_ID')).toBe('Amplitude ID') }) }) + + describe('getMembershipIdType', () => { + it('should return BY_NAME for BY_USER_ID', () => { + expect(getMembershipIdType('BY_USER_ID')).toBe('BY_NAME') + }) + + it('should return BY_AMP_ID for BY_AMP_ID', () => { + expect(getMembershipIdType('BY_AMP_ID')).toBe('BY_AMP_ID') + }) + }) + + describe('getJSON', () => { + it('should return correct JSON structure for ADD operation with BY_USER_ID', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { user_id: 'user2', segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + const map = new Map(payloads.map((p, i) => [i, p])) + + const result = getJSON(map, 'BY_USER_ID', 'cohort_1', msResponse, 'ADD', true) + + expect(result).toEqual({ + cohort_id: 'cohort_1', + skip_invalid_ids: true, + memberships: [{ + ids: ['user1', 'user2'], + id_type: 'BY_NAME', + operation: 'ADD' + }] + }) + }) + + it('should return correct JSON structure for REMOVE operation with BY_AMP_ID', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { amplitude_id: 'amp1', segment_external_audience_id: 'cohort_2', batch_size: 100 }, + { amplitude_id: 'amp2', segment_external_audience_id: 'cohort_2', batch_size: 100 } + ] + const map = new Map(payloads.map((p, i) => [i, p])) + + const result = getJSON(map, 'BY_AMP_ID', 'cohort_2', msResponse, 'REMOVE', true) + + expect(result).toEqual({ + cohort_id: 'cohort_2', + skip_invalid_ids: true, + memberships: [{ + ids: ['amp1', 'amp2'], + id_type: 'BY_AMP_ID', + operation: 'REMOVE' + }] + }) + }) + + it('should return undefined when all payloads have missing IDs', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + const map = new Map(payloads.map((p, i) => [i, p])) + + const result = getJSON(map, 'BY_USER_ID', 'cohort_1', msResponse, 'ADD', true) + + expect(result).toBeUndefined() + }) + + it('should exclude duplicate IDs and set errors for duplicates', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { user_id: 'user2', segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + const map = new Map(payloads.map((p, i) => [i, p])) + + const result = getJSON(map, 'BY_USER_ID', 'cohort_1', msResponse, 'ADD', true) + + expect(result).toEqual({ + cohort_id: 'cohort_1', + skip_invalid_ids: true, + memberships: [{ + ids: ['user1', 'user2'], + id_type: 'BY_NAME', + operation: 'ADD' + }] + }) + + expect(msResponse.getResponseAtIndex(1)).toMatchObject({ + data: { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Duplicate ID user1 of type User ID found in payload batch. The duplicate payload has been rejected. Each payload must have a unique ID for the specified ID Type.', + body: { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + } + }) + }) + }) + + describe('handleError', () => { + it('should set error on msResponse in batch mode', () => { + const msResponse = new MultiStatusResponse() + const payload: Payload = { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + + handleError(payload, msResponse, 0, true, 'Test error message', 'PAYLOAD_VALIDATION_FAILED') + + expect(msResponse.getResponseAtIndex(0)).toMatchObject({ + data: { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Test error message', + body: { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + } + }) + }) + + it('should include sent data when provided in batch mode', () => { + const msResponse = new MultiStatusResponse() + const payload: Payload = { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + const sent = { cohort_id: 'cohort_1', memberships: [{ ids: ['user1'] }] } + + handleError(payload, msResponse, 0, true, 'Test error', 'UNKNOWN_ERROR', sent as any) + + expect(msResponse.getResponseAtIndex(0)).toMatchObject({ + data: { + status: 400, + errortype: 'UNKNOWN_ERROR', + errormessage: 'Test error', + body: { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + sent: { cohort_id: 'cohort_1', memberships: [{ ids: ['user1'] }] } + } + }) + }) + + it('should throw PayloadValidationError in non-batch mode', () => { + const msResponse = new MultiStatusResponse() + const payload: Payload = { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + + expect(() => { + handleError(payload, msResponse, 0, false, 'Validation failed', 'PAYLOAD_VALIDATION_FAILED') + }).toThrowError('Validation failed') + }) + }) + + describe('failAllPayloads', () => { + it('should set errors on all payloads in batch mode and return msResponse', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 }, + { user_id: 'user2', segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + + const result = failAllPayloads(payloads, msResponse, true, 'All failed') + + expect(result).toBe(msResponse) + expect(msResponse.getResponseAtIndex(0)).toMatchObject({ + data: { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'All failed', + body: { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + } + }) + expect(msResponse.getResponseAtIndex(1)).toMatchObject({ + data: { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'All failed', + body: { user_id: 'user2', segment_external_audience_id: 'cohort_1', batch_size: 100 } + } + }) + }) + + it('should throw PayloadValidationError in non-batch mode', () => { + const msResponse = new MultiStatusResponse() + const payloads: Payload[] = [ + { user_id: 'user1', segment_external_audience_id: 'cohort_1', batch_size: 100 } + ] + + expect(() => { + failAllPayloads(payloads, msResponse, false, 'All failed') + }).toThrowError('All failed') + }) + }) }) diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/getIds.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/getIds.test.ts index d1d9569a86c..f76f46170e1 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/getIds.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/getIds.test.ts @@ -40,7 +40,7 @@ describe('getIds function', () => { const payloads: Payload[] = [ { user_id: 'user1', - segment_external_audience_id: 'cohort123', + segment_external_audience_id: 'cohort123', batch_size: 100 }, { diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/index.test.ts index a7af7a13171..17fdfa190e7 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/__tests__/index.test.ts @@ -40,7 +40,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user123'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -91,7 +91,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user456'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'REMOVE' } ] @@ -291,7 +291,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user1', 'user2'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -314,7 +314,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['user3'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'REMOVE' } ] @@ -385,7 +385,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['valid_user', 'invalid_user'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -436,7 +436,7 @@ describe('Amplitude Cohorts - syncAudience', () => { memberships: [ { ids: ['eu_user'], - id_type: 'BY_USER_ID', + id_type: 'BY_NAME', operation: 'ADD' } ] @@ -551,7 +551,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') @@ -566,7 +566,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'user1', segment_external_audience_id: 'cohort_123', @@ -575,7 +575,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[1]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'user2', segment_external_audience_id: 'cohort_123', @@ -617,7 +617,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] + memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_NAME', operation: 'REMOVE' }] } nock('https://amplitude.com') @@ -632,7 +632,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'REMOVE' }] }, body: { user_id: 'user1', segment_external_audience_id: 'cohort_123', @@ -641,7 +641,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[1]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_NAME', operation: 'REMOVE' }] }, body: { user_id: 'user2', segment_external_audience_id: 'cohort_123', @@ -696,13 +696,13 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedAddJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['add_user1', 'add_user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['add_user1', 'add_user2'], id_type: 'BY_NAME', operation: 'ADD' }] } const expectedRemoveJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['remove_user1'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] + memberships: [{ ids: ['remove_user1'], id_type: 'BY_NAME', operation: 'REMOVE' }] } nock('https://amplitude.com').post('/api/3/cohorts/membership', expectedAddJson).reply(200, { @@ -720,7 +720,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(3) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedAddJson, memberships: [{ ids: ['add_user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedAddJson, memberships: [{ ids: ['add_user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'add_user1', segment_external_audience_id: 'cohort_123', @@ -729,7 +729,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[1]).toMatchObject({ status: 200, - sent: { ...expectedAddJson, memberships: [{ ids: ['add_user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedAddJson, memberships: [{ ids: ['add_user2'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'add_user2', segment_external_audience_id: 'cohort_123', @@ -738,7 +738,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[2]).toMatchObject({ status: 200, - sent: { ...expectedRemoveJson, memberships: [{ ids: ['remove_user1'], id_type: 'BY_USER_ID', operation: 'REMOVE' }] }, + sent: { ...expectedRemoveJson, memberships: [{ ids: ['remove_user1'], id_type: 'BY_NAME', operation: 'REMOVE' }] }, body: { user_id: 'remove_user1', segment_external_audience_id: 'cohort_123', @@ -851,7 +851,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_eu', skip_invalid_ids: true, - memberships: [{ ids: ['eu_user1', 'eu_user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['eu_user1', 'eu_user2'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://analytics.eu.amplitude.com') @@ -867,7 +867,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['eu_user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['eu_user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'eu_user1', segment_external_audience_id: 'cohort_eu', @@ -876,7 +876,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[1]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['eu_user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['eu_user2'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'eu_user2', segment_external_audience_id: 'cohort_eu', @@ -918,7 +918,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['valid_user', 'skipped_user'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['valid_user', 'skipped_user'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') @@ -933,7 +933,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['valid_user'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['valid_user'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'valid_user', segment_external_audience_id: 'cohort_123', @@ -999,7 +999,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') @@ -1014,7 +1014,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(3) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'user1', segment_external_audience_id: 'cohort_123', @@ -1029,7 +1029,7 @@ describe('Amplitude Cohorts - syncAudience', () => { }) expect(responses[2]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user2'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { user_id: 'user2', segment_external_audience_id: 'cohort_123', @@ -1073,7 +1073,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') @@ -1092,7 +1092,7 @@ describe('Amplitude Cohorts - syncAudience', () => { expect(responses.length).toBe(2) expect(responses[0]).toMatchObject({ status: 200, - sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_USER_ID', operation: 'ADD' }] }, + sent: { ...expectedRequestJson, memberships: [{ ids: ['user1'], id_type: 'BY_NAME', operation: 'ADD' }] }, body: { batch_size: 100, segment_external_audience_id: 'cohort_123', @@ -1196,7 +1196,7 @@ describe('Amplitude Cohorts - syncAudience', () => { const expectedRequestJson = { cohort_id: 'cohort_123', skip_invalid_ids: true, - memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_USER_ID', operation: 'ADD' }] + memberships: [{ ids: ['user1', 'user2'], id_type: 'BY_NAME', operation: 'ADD' }] } nock('https://amplitude.com') diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/functions.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/functions.ts index 7f5b1d40b72..f4293689fc3 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/functions.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/functions.ts @@ -1,10 +1,10 @@ import { RequestClient, MultiStatusResponse, JSONLikeObject, PayloadValidationError, ErrorCodes, AudienceMembership } from '@segment/actions-core' import type { Settings, AudienceSettings } from '../generated-types' import type { Payload } from './generated-types' -import { PayloadMap, Operation, UploadToCohortJSON, UploadToCohortResponse, ResponseError, PossibleErrorCodes } from './types' +import { PayloadMap, UploadToCohortResponse, ResponseError, PossibleErrorCodes } from './types' import { ID_TYPES } from '../constants' import { getEndpointByRegion } from '../functions' -import { IDType } from '../types' +import { IDType, MembershipIdType, Operation, UploadToCohortJSON } from '../types' export async function send(request: RequestClient, payloads: Payload[], settings: Settings, isBatch: boolean, audienceSettings?: AudienceSettings, audienceMemberships?: AudienceMembership[]) { const { @@ -51,31 +51,32 @@ export async function send(request: RequestClient, payloads: Payload[], settings } }) - const requests: Promise[] = [] + // Amplitude returns 429 if a cohort is accessed concurrently, so ADD and REMOVE must run sequentially. + let hasRequests = false if (addMap.size > 0) { const json = getJSON(addMap, id_type, audienceId, msResponse, 'ADD', isBatch) if(json){ - requests.push(sendRequest(request, addMap, msResponse, json, id_type, endpoint, isBatch)) + hasRequests = true + await sendRequest(request, addMap, msResponse, json, id_type, endpoint, isBatch) } } if (deleteMap.size > 0) { const json = getJSON(deleteMap, id_type, audienceId, msResponse, 'REMOVE', isBatch) if(json){ - requests.push(sendRequest(request, deleteMap, msResponse, json, id_type, endpoint, isBatch)) + hasRequests = true + await sendRequest(request, deleteMap, msResponse, json, id_type, endpoint, isBatch) } } - if(requests.length === 0) { + if(!hasRequests) { if(isBatch) { return msResponse } throw new PayloadValidationError("The payload is invalid and cannot be sent to Amplitude. Check that it contains the correct type of identifier") } - await Promise.all(requests) - if (isBatch) { return msResponse } @@ -98,6 +99,10 @@ export function failAllPayloads(payloads: Payload[], msResponse: MultiStatusResp throw new PayloadValidationError(message) } +export function getMembershipIdType(id_type: IDType): MembershipIdType { + return id_type === ID_TYPES.BY_USER_ID ? 'BY_NAME' : 'BY_AMP_ID' +} + export function getJSON(map: PayloadMap, id_type: IDType, audienceId: string, msResponse: MultiStatusResponse, operation: Operation, isBatch: boolean): UploadToCohortJSON | undefined { const ids: string[] = getIds(map, id_type, msResponse, isBatch) if(ids.length === 0){ @@ -108,7 +113,7 @@ export function getJSON(map: PayloadMap, id_type: IDType, audienceId: string, ms skip_invalid_ids: true, memberships: [{ ids, - id_type, + id_type: getMembershipIdType(id_type), operation }] } diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/types.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/types.ts index dd7fd526e98..199c6025b5b 100644 --- a/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/types.ts +++ b/packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/types.ts @@ -1,22 +1,8 @@ -import { OPERATIONS } from '../constants' import { Payload } from './generated-types' import { ErrorCodes } from '@segment/actions-core' -import { IDType } from '../types' - -export type UploadToCohortJSON = { - cohort_id: string - skip_invalid_ids: true - memberships: Array<{ - ids: Array - id_type: IDType - operation: Operation - }> -} export type PayloadMap = Map -export type Operation = keyof typeof OPERATIONS - export type PossibleErrorCodes = keyof typeof ErrorCodes | 'PAYLOAD_VALIDATION_FAILED' | 'UNKNOWN_ERROR' export type UploadToCohortResponse = { diff --git a/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts b/packages/destination-actions/src/destinations/amplitude-cohorts/types.ts index 7cabac7a2e7..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 diff --git a/packages/destination-actions/src/destinations/amplitude/__e2e__/index.ts b/packages/destination-actions/src/destinations/amplitude/__e2e__/index.ts new file mode 100644 index 00000000000..ff5094b7930 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/__e2e__/index.ts @@ -0,0 +1,9 @@ +import type { E2EDestinationConfig } from '@segment/actions-core' + +export const config: E2EDestinationConfig = { + settings: { + apiKey: { $env: 'E2E_AMPLITUDE_API_KEY' }, + secretKey: { $env: 'E2E_AMPLITUDE_SECRET_KEY' }, + endpoint: 'north_america' + } +} diff --git a/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..2cc07b3417d --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/identifyUser/__e2e__/fixtures.e2e.ts @@ -0,0 +1,25 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import identifyUser from '../index' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully identifies a user with traits', + subscribe: 'type = "identify"', + mapping: defaultValues(identifyUser.fields), + mode: 'single', + event: createE2EEvent('identify', undefined, { + userId: 'e2e-test-user-amplitude-001', + traits: { + email: 'e2e-test@segment.com', + plan: 'enterprise', + company: 'Segment' + } + }), + expect: { + status: 'success' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..dbd50821891 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/logEventV2/__e2e__/fixtures.e2e.ts @@ -0,0 +1,43 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import logEventV2 from '../index' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully logs a track event', + subscribe: 'type = "track"', + mapping: defaultValues(logEventV2.fields), + mode: 'single', + event: createE2EEvent('track', 'Button Clicked', { + userId: 'e2e-test-user-amplitude-001', + properties: { + buttonId: 'cta-signup', + page: '/pricing' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully logs event with products array', + subscribe: 'type = "track"', + mapping: defaultValues(logEventV2.fields), + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-amplitude-001', + properties: { + revenue: 99.98, + products: [ + { price: 49.99, quantity: 1, productId: 'prod-001', name: 'Widget' }, + { price: 49.99, quantity: 1, productId: 'prod-002', name: 'Gadget' } + ] + } + }), + expect: { + status: 'success' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/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__/engage.e2e.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/engage.e2e.ts new file mode 100644 index 00000000000..7b89c45c7a6 --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/engage.e2e.ts @@ -0,0 +1,254 @@ +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' + }), + { + ...createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-fb-user-011', + email: 'e2e-fb-test-011@segment.com' + }), + userId: undefined as unknown as string + } + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200 }, + { status: 200 }, + { status: 200 }, + { + 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__/journeys.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.ts new file mode 100644 index 00000000000..55f9c268d4f --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/journeys.ts @@ -0,0 +1,78 @@ +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: { + [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: '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.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.ts new file mode 100644 index 00000000000..869feb59dbe --- /dev/null +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/__e2e__/retl.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 diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts new file mode 100644 index 00000000000..b7e049dbe7f --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__e2e__/index.ts @@ -0,0 +1,76 @@ +/** + * Required environment variables: + * - E2E_GOOGLE_ADS_CUSTOMER_ID: Google Ads customer ID + * - E2E_GOOGLE_ADS_REFRESH_TOKEN: OAuth refresh token for Google Ads API + * - E2E_GOOGLE_ADS_CLIENT_ID: OAuth client ID + * - E2E_GOOGLE_ADS_CLIENT_SECRET: OAuth client secret + * - ADWORDS_DEVELOPER_TOKEN: Google Ads API developer token (used in request headers) + * - GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID: Client ID used for token refresh at runtime + * - GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET: Client secret used for token refresh at runtime + */ +import type { E2EAudienceDestinationConfig, E2ETeardownAudienceContext } from '@segment/actions-core' + +const audienceName = `e2e_test_user_list_${Date.now()}` + +export const config: E2EAudienceDestinationConfig = { + settings: { + customerId: { $env: 'E2E_GOOGLE_ADS_CUSTOMER_ID' }, + oauth: { + access_token: 'will_be_refreshed', + refresh_token: { $env: 'E2E_GOOGLE_ADS_REFRESH_TOKEN' }, + clientId: { $env: 'E2E_GOOGLE_ADS_CLIENT_ID' }, + clientSecret: { $env: 'E2E_GOOGLE_ADS_CLIENT_SECRET' } + } + }, + audience: { + audienceName, + audienceSettings: { + supports_conversions: false, + external_id_type: 'CONTACT_INFO' + }, + createAudience: true, + getAudience: true, + teardown: async (context: E2ETeardownAudienceContext) => { + const { settings, externalAudienceId } = context + const oauth = settings.oauth as { refresh_token: string; clientId: string; clientSecret: string } + const customerId = settings.customerId as string + + const tokenResponse = await fetch('https://www.googleapis.com/oauth2/v4/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + refresh_token: oauth.refresh_token, + client_id: process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID ?? oauth.clientId, + client_secret: process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET ?? oauth.clientSecret, + grant_type: 'refresh_token' + }) + }) + + if (!tokenResponse.ok) { + throw new Error(`Failed to refresh token: ${tokenResponse.statusText}`) + } + + const { access_token } = (await tokenResponse.json()) as { access_token: string } + + const response = await fetch( + `https://googleads.googleapis.com/v21/customers/${customerId}/userLists:mutate`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'developer-token': process.env.ADWORDS_DEVELOPER_TOKEN ?? '', + authorization: `Bearer ${access_token}` + }, + body: JSON.stringify({ + operations: [{ remove: `customers/${customerId}/userLists/${externalAudienceId}` }] + }) + } + ) + + if (!response.ok) { + const body = await response.text() + throw new Error(`Failed to delete user list ${externalAudienceId}: ${response.status} ${body}`) + } + } + } +} diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/engage.e2e.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/engage.e2e.ts new file mode 100644 index 00000000000..0f42a0ee4ec --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/engage.e2e.ts @@ -0,0 +1,108 @@ +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: 'Engage Audience: Add a user to the customer match list via track event', + subscribe: 'event = "Audience Entered" or event = "Audience Exited"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-001', + email: 'e2e-google-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Engage Audience: Remove a user from the customer match list via track event', + subscribe: 'event = "Audience Entered"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + eventName: 'Audience Exited', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-001', + email: 'e2e-google-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Engage Audience: Batch add and remove users from the customer match list', + subscribe: 'event = "Audience Entered" or event = "Audience Exited"', + mapping: { + ...defaultValues(userList.fields), + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED' + }, + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-002', + email: 'e2e-google-test-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'add', + eventName: 'Audience Entered', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-003', + email: 'e2e-google-test-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'track', + action: 'remove', + eventName: 'Audience Exited', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-google-user-001', + email: 'e2e-google-test-001@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200 }, + { status: 200 }, + { status: 200 } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV1.e2e.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV1.e2e.ts new file mode 100644 index 00000000000..3d777b42f64 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/journeysV1.e2e.ts @@ -0,0 +1,117 @@ +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({ + 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({ + 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({ + 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({ + 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 }, + { status: 200 }, + { status: 200 } + ] + }, + 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({ + 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 } + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200 }, + { status: 200 } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/retl.e2e.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__e2e__/retl.e2e.ts new file mode 100644 index 00000000000..83ccd7bfe0a --- /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 }, + { status: 200 } + ] + }, + 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 }, + { status: 200 } + ] + }, + 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 }, + { status: 200 } + ] + }, + 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 }, + { status: 200 } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts new file mode 100644 index 00000000000..bf7febbcc61 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable-audiences/__e2e__/index.ts @@ -0,0 +1,30 @@ +import type { E2EAudienceDestinationConfig, E2ETeardownAudienceContext } from '@segment/actions-core' + +const audienceName = `e2e_test_audience_${Date.now()}` + +export const config: E2EAudienceDestinationConfig = { + settings: { + apiKey: { $env: 'E2E_ITERABLE_AUDIENCES_API_KEY' }, + iterableProjectType: 'hybrid' + }, + audience: { + audienceName, + audienceSettings: {}, + createAudience: true, + getAudience: false, // Iterable is only eventually consistent, so we can't reliably get the audience immediately after creating it + teardown: async (context: E2ETeardownAudienceContext) => { + const { settings, externalAudienceId } = context + const apiKey = settings.apiKey as string + + const response = await fetch(`https://api.iterable.com/api/lists/${externalAudienceId}`, { + method: 'DELETE', + headers: { 'Api-Key': apiKey } + }) + + if (!response.ok) { + const body = await response.text() + throw new Error(`Failed to delete list ${externalAudienceId}: ${response.status} ${body}`) + } + } + } +} diff --git a/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..0a161b41430 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable-audiences/syncAudience/__e2e__/fixtures.e2e.ts @@ -0,0 +1,91 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEngageAudienceEvent } from '@segment/actions-core' +import syncAudience from '../index' + +const COMPUTATION_KEY = 'e2e_test_audience' +const COMPUTATION_ID = 'aud_e2e_iterable_001' + +const FAILURE_HINT = 'Ensure the Iterable API key has server-side permissions and the project is configured as hybrid.' + +const fixtures: E2EFixture[] = [ + { + description: 'Subscribe a user to the list via identify event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-001', + email: 'e2e-aud-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Unsubscribe a user from the list via identify event', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'single', + event: createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-001', + email: 'e2e-aud-test-001@segment.com' + }), + expect: { status: 'success' }, + verboseFailureHint: FAILURE_HINT + }, + { + description: 'Batch subscribe and unsubscribe users', + subscribe: 'type = "identify" or type = "track"', + mapping: defaultValues(syncAudience.fields), + mode: 'batchWithMultistatus', + events: [ + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-002', + email: 'e2e-aud-test-002@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'add', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-003', + email: 'e2e-aud-test-003@segment.com' + }), + createE2EEngageAudienceEvent({ + type: 'identify', + action: 'remove', + computationKey: COMPUTATION_KEY, + computationId: COMPUTATION_ID, + externalAudienceId: '$externalAudienceId', + userId: 'e2e-iterable-aud-user-001', + email: 'e2e-aud-test-001@segment.com' + }) + ], + expect: { + status: 'success', + jsonContains: [ + { status: 200, sent: { email: 'e2e-aud-test-002@segment.com', userId: 'e2e-iterable-aud-user-002' } }, + { status: 200, sent: { email: 'e2e-aud-test-003@segment.com', userId: 'e2e-iterable-aud-user-003' } }, + { status: 200, sent: { email: 'e2e-aud-test-001@segment.com', userId: 'e2e-iterable-aud-user-001' } } + ] + }, + verboseFailureHint: FAILURE_HINT + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts new file mode 100644 index 00000000000..1ea0b08efa2 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/__e2e__/index.ts @@ -0,0 +1,8 @@ +import type { E2EDestinationConfig } from '@segment/actions-core' + +export const config: E2EDestinationConfig = { + settings: { + apiKey: { $env: 'E2E_ITERABLE_API_KEY' }, + dataCenterLocation: 'united_states' + } +} diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..08ef33b02b4 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__e2e__/fixtures.e2e.ts @@ -0,0 +1,44 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import trackEvent from '../index' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully tracks a purchase event', + subscribe: 'type = "track"', + mapping: defaultValues(trackEvent.fields), + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-001', + properties: { + email: 'e2e-test@segment.com', + orderId: '$guid:orderId', + total: 49.99 + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Rejects event when both email and userId are missing', + subscribe: 'type = "track"', + mode: 'single', + mapping: (() => { + const { email, userId, ...rest } = defaultValues(trackEvent.fields) + return rest + })(), + event: createE2EEvent('track', 'Button Clicked', { + properties: { + buttonId: 'cta-hero' + } + }), + expect: { + status: 'error', + errorType: 'PayloadValidationError', + errorMessage: 'Must include email or userId.' + } + } +] + +export default fixtures diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts new file mode 100644 index 00000000000..6ccfb75eda7 --- /dev/null +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__e2e__/fixtures.e2e.ts @@ -0,0 +1,26 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } 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: defaultValues(updateUser.fields), + mode: 'single', + event: createE2EEvent('identify', undefined, { + userId: 'e2e-test-user-001', + traits: { + email: 'e2e-test@segment.com', + firstName: 'E2E', + lastName: 'TestUser', + plan: 'premium' + } + }), + expect: { + status: 'success' + } + } +] + +export default fixtures 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..d144244919b --- /dev/null +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__e2e__/fixtures.e2e.ts @@ -0,0 +1,259 @@ +import type { E2EFixture } from '@segment/actions-core' +import { defaultValues, createE2EEvent } from '@segment/actions-core' +import reportConversionEvent from '../index' + +const fixtures: E2EFixture[] = [ + { + description: 'Successfully sends a checkout event with order details', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'checkout' + }, + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-pinterest-001', + properties: { + email: 'e2e-test@segment.com', + order_id: '$guid:orderId', + value: 149.98, + currency: 'USD' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.10', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', + page: { + url: 'https://example.com/checkout' + } + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully sends a page visit event', + subscribe: 'type = "page"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'page_visit' + }, + mode: 'single', + event: createE2EEvent('page', 'Home', { + userId: 'e2e-test-user-pinterest-002', + properties: { + url: 'https://example.com/home' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.11', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + page: { + url: 'https://example.com/home' + } + }, + traits: { + email: 'e2e-page@segment.com' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully sends an add_to_cart event with product data', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'add_to_cart' + }, + mode: 'single', + event: createE2EEvent('track', 'Product Added', { + userId: 'e2e-test-user-pinterest-003', + properties: { + email: 'e2e-cart@segment.com', + price: 74.99, + currency: 'USD', + content_ids: ['sku-001'], + num_items: 1 + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.12', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully sends a search event with query string', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'search' + }, + mode: 'single', + event: createE2EEvent('track', 'Products Searched', { + userId: 'e2e-test-user-pinterest-004', + properties: { + email: 'e2e-search@segment.com', + query: 'summer dresses' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.13', + userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Successfully sends a signup event', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'signup' + }, + mode: 'single', + event: createE2EEvent('track', 'Signed Up', { + userId: 'e2e-test-user-pinterest-005', + properties: { + email: 'e2e-signup@segment.com' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.14', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15' + } + }), + expect: { + status: 'success' + } + }, + { + description: 'Rejects event when user_data is missing email, hashed_maids, and IP+UA pair', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'checkout', + user_data: {} + }, + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-pinterest-006', + properties: { + order_id: 'order-123' + }, + context: { + app: { + name: 'E2E Test App' + } + } + }), + expect: { + status: 'error', + errorType: 'IntegrationError', + errorMessage: + 'User data must contain values for Email or Phone Number or Mobile Ad Identifier or both IP Address and User Agent fields' + } + }, + { + description: 'Rejects event with invalid event_name not in choices list', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'invalid_event_name' + }, + mode: 'single', + event: createE2EEvent('track', 'Some Event', { + userId: 'e2e-test-user-pinterest-007', + properties: { + email: 'e2e-invalid@segment.com' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.15', + userAgent: 'Mozilla/5.0' + } + }), + expect: { + status: 'error', + errorType: 'AggregateAjvError' + } + }, + { + description: 'Rejects event with invalid action_source not in choices list', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'checkout', + action_source: 'invalid_source' + }, + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-pinterest-008', + properties: { + email: 'e2e-invalid-source@segment.com' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.16', + userAgent: 'Mozilla/5.0' + } + }), + expect: { + status: 'error', + errorType: 'AggregateAjvError' + } + }, + { + description: 'Pinterest rejects event with timestamp too far in the past', + subscribe: 'type = "track"', + mapping: { + ...defaultValues(reportConversionEvent.fields), + event_name: 'checkout', + event_time: '2020-01-01T00:00:00.000Z' + }, + mode: 'single', + event: createE2EEvent('track', 'Order Completed', { + userId: 'e2e-test-user-pinterest-009', + properties: { + email: 'e2e-old-event@segment.com' + }, + context: { + app: { + name: 'E2E Test App' + }, + ip: '203.0.113.17', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' + } + }), + expect: { + status: 'failure', + httpStatus: 422 + }, + verboseFailureHint: 'Pinterest rejects events with event_time older than 7 days.' + } +] + +export default fixtures