diff --git a/packages/destination-actions/src/destinations/amazon-amc/function.ts b/packages/destination-actions/src/destinations/amazon-amc/function.ts index f599520b4f..5958e6afa1 100644 --- a/packages/destination-actions/src/destinations/amazon-amc/function.ts +++ b/packages/destination-actions/src/destinations/amazon-amc/function.ts @@ -3,7 +3,15 @@ import { JSONLikeObject, MultiStatusResponse, PayloadValidationError, RequestCli import { AudienceSettings, Settings } from './generated-types' import type { Payload } from './syncAudiencesToDSP/generated-types' import { MaybeString, AudienceRecord, UserConsent, HashedPIIObject } from './types' -import { FLAG_CONSENT_REQUIRED, FLAG_CONSENT_ENABLE_ERRORS, CONSTANTS, RecordsResponseType, REGEX_EXTERNALUSERID, COUNTRY_CODES, UK_EEA_COUNTRY_CODES } from './utils' +import { + FLAG_CONSENT_REQUIRED, + FLAG_CONSENT_ENABLE_ERRORS, + CONSTANTS, + RecordsResponseType, + REGEX_EXTERNALUSERID, + COUNTRY_CODES, + UK_EEA_COUNTRY_CODES +} from './utils' import { processHashing } from '../../lib/hashing-utils' import { AMAZON_AMC_API_VERSION } from './versioning-info' @@ -15,17 +23,32 @@ function getUserConsent(payloadConsent: Payload['consent'], countryCode: string, const { ipAddress, amznAdStorage, amznUserData, tcf, gpp } = payloadConsent || {} const enableErrors = features?.[FLAG_CONSENT_ENABLE_ERRORS] - if(!COUNTRY_CODES.includes(countryCode)){ + if (!COUNTRY_CODES.includes(countryCode)) { if (enableErrors) { - throw new PayloadValidationError(`Invalid country code: ${countryCode}. Country code must be a valid ISO 3166-1 alpha-2 code.`) + throw new PayloadValidationError( + `Invalid country code: ${countryCode}. Country code must be a valid ISO 3166-1 alpha-2 code.` + ) } } - const amzn: NonNullable['amzn'] | undefined = hasStringValue(amznAdStorage as MaybeString) && hasStringValue(amznUserData as MaybeString) ? { amznAdStorage: amznAdStorage === 'GRANTED' ? 'GRANTED' : 'DENIED', amznUserData: amznUserData === 'GRANTED' ? 'GRANTED' : 'DENIED' } : undefined - - if(UK_EEA_COUNTRY_CODES.includes(countryCode) && !amzn && !hasStringValue(tcf as MaybeString) && !hasStringValue(gpp as MaybeString)){ + const amzn: NonNullable['amzn'] | undefined = + hasStringValue(amznAdStorage as MaybeString) && hasStringValue(amznUserData as MaybeString) + ? { + amznAdStorage: amznAdStorage === 'GRANTED' ? 'GRANTED' : 'DENIED', + amznUserData: amznUserData === 'GRANTED' ? 'GRANTED' : 'DENIED' + } + : undefined + + if ( + UK_EEA_COUNTRY_CODES.includes(countryCode) && + !amzn && + !hasStringValue(tcf as MaybeString) && + !hasStringValue(gpp as MaybeString) + ) { if (enableErrors) { - throw new PayloadValidationError(`Consent required when sending data with UK and EEA country code ${countryCode}. Please provide valid consent for amznAdStorage and amznUserData or TCF or GPP.`) + throw new PayloadValidationError( + `Consent required when sending data with UK and EEA country code ${countryCode}. Please provide valid consent for amznAdStorage and amznUserData or TCF or GPP.` + ) } } @@ -44,7 +67,7 @@ function getUserConsent(payloadConsent: Payload['consent'], countryCode: string, geo, ...(Object.keys(consent).length > 0 && { consent }) } - + return consentData } @@ -90,7 +113,7 @@ export async function processPayload( */ export function createPayloadToUploadRecords( payloads: Payload[], - audienceSettings: AudienceSettings, + audienceSettings: AudienceSettings, features?: Features ) { const records: AudienceRecord[] = [] @@ -100,7 +123,9 @@ export function createPayloadToUploadRecords( if (!REGEX_EXTERNALUSERID.test(payload.externalUserId)) { return // Skip to the next iteration } - const userConsent = features?.[FLAG_CONSENT_REQUIRED] ? getUserConsent(payload.consent, audienceSettings.countryCode, features) : undefined + const userConsent = features?.[FLAG_CONSENT_REQUIRED] + ? getUserConsent(payload.consent, audienceSettings.countryCode, features) + : undefined const hashedPII = hashedPayload(payload) const payloadRecord: AudienceRecord = { externalUserId: payload.externalUserId, @@ -146,9 +171,10 @@ function validateAndPreparePayload( let userConsent: UserConsent | undefined try { - userConsent = features?.[FLAG_CONSENT_REQUIRED] ? getUserConsent(payload.consent, audienceSettings.countryCode, features) : undefined - } - catch (error) { + userConsent = features?.[FLAG_CONSENT_REQUIRED] + ? getUserConsent(payload.consent, audienceSettings.countryCode, features) + : undefined + } catch (error) { multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { status: error.status || 400, errortype: 'PAYLOAD_VALIDATION_FAILED', @@ -197,7 +223,7 @@ export async function processBatchPayload( const { filteredPayloads, validPayloadIndicesBitmap } = validateAndPreparePayload( payloads, multiStatusResponse, - audienceSettings, + audienceSettings, features ) diff --git a/packages/destination-actions/src/destinations/amazon-amc/syncAudiencesToDSP/__tests__/enable-errors-flag-off.test.ts b/packages/destination-actions/src/destinations/amazon-amc/syncAudiencesToDSP/__tests__/enable-errors-flag-off.test.ts index bd64e1d18a..5ad2d32989 100644 --- a/packages/destination-actions/src/destinations/amazon-amc/syncAudiencesToDSP/__tests__/enable-errors-flag-off.test.ts +++ b/packages/destination-actions/src/destinations/amazon-amc/syncAudiencesToDSP/__tests__/enable-errors-flag-off.test.ts @@ -81,7 +81,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -113,7 +113,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -128,7 +128,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -166,7 +166,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -186,7 +186,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -230,7 +230,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -250,7 +250,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -313,7 +313,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'XX' } } @@ -345,7 +345,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'XX' } } @@ -360,7 +360,7 @@ describe('AmazonAds.syncAudiencesToDSP (enable errors flag off)', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'XX' } } diff --git a/packages/destination-actions/src/destinations/amazon-amc/syncAudiencesToDSP/__tests__/index.test.ts b/packages/destination-actions/src/destinations/amazon-amc/syncAudiencesToDSP/__tests__/index.test.ts index ebab25ec06..aa9e31d266 100644 --- a/packages/destination-actions/src/destinations/amazon-amc/syncAudiencesToDSP/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/amazon-amc/syncAudiencesToDSP/__tests__/index.test.ts @@ -502,7 +502,7 @@ describe('AmazonAds.syncAudiencesToDSP', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -517,7 +517,7 @@ describe('AmazonAds.syncAudiencesToDSP', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -569,7 +569,7 @@ describe('AmazonAds.syncAudiencesToDSP', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -589,7 +589,7 @@ describe('AmazonAds.syncAudiencesToDSP', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -632,7 +632,7 @@ describe('AmazonAds.syncAudiencesToDSP', () => { personas: { ...event.context!.personas, audience_settings: { - ...event.context!.personas!.audience_settings, + ...event.context!.personas.audience_settings, countryCode: 'DE' } } @@ -645,8 +645,8 @@ describe('AmazonAds.syncAudiencesToDSP', () => { useDefaultMappings: true, features }) - ).rejects.toThrowError('Consent required when sending data with UK and EEA country code DE. Please provide valid consent for amznAdStorage and amznUserData or TCF or GPP.') + ).rejects.toThrowError( + 'Consent required when sending data with UK and EEA country code DE. Please provide valid consent for amznAdStorage and amznUserData or TCF or GPP.' + ) }) - - }) diff --git a/packages/destination-actions/src/destinations/amazon-amc/utils.ts b/packages/destination-actions/src/destinations/amazon-amc/utils.ts index 1965a7526d..f7759bec1d 100644 --- a/packages/destination-actions/src/destinations/amazon-amc/utils.ts +++ b/packages/destination-actions/src/destinations/amazon-amc/utils.ts @@ -53,27 +53,290 @@ export const FLAG_CONSENT_REQUIRED = 'actions-amazon-amc-consent' export const FLAG_CONSENT_ENABLE_ERRORS = 'actions-amazon-amc-consent-enable-errors' export const COUNTRY_CODES = [ - "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", - "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", - "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", - "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", - "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", - "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", - "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", - "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", - "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", - "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", - "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", - "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", - "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", - "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", - "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", - "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" + 'AD', + 'AE', + 'AF', + 'AG', + 'AI', + 'AL', + 'AM', + 'AO', + 'AQ', + 'AR', + 'AS', + 'AT', + 'AU', + 'AW', + 'AX', + 'AZ', + 'BA', + 'BB', + 'BD', + 'BE', + 'BF', + 'BG', + 'BH', + 'BI', + 'BJ', + 'BL', + 'BM', + 'BN', + 'BO', + 'BQ', + 'BR', + 'BS', + 'BT', + 'BV', + 'BW', + 'BY', + 'BZ', + 'CA', + 'CC', + 'CD', + 'CF', + 'CG', + 'CH', + 'CI', + 'CK', + 'CL', + 'CM', + 'CN', + 'CO', + 'CR', + 'CU', + 'CV', + 'CW', + 'CX', + 'CY', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DM', + 'DO', + 'DZ', + 'EC', + 'EE', + 'EG', + 'EH', + 'ER', + 'ES', + 'ET', + 'FI', + 'FJ', + 'FK', + 'FM', + 'FO', + 'FR', + 'GA', + 'GB', + 'GD', + 'GE', + 'GF', + 'GG', + 'GH', + 'GI', + 'GL', + 'GM', + 'GN', + 'GP', + 'GQ', + 'GR', + 'GS', + 'GT', + 'GU', + 'GW', + 'GY', + 'HK', + 'HM', + 'HN', + 'HR', + 'HT', + 'HU', + 'ID', + 'IE', + 'IL', + 'IM', + 'IN', + 'IO', + 'IQ', + 'IR', + 'IS', + 'IT', + 'JE', + 'JM', + 'JO', + 'JP', + 'KE', + 'KG', + 'KH', + 'KI', + 'KM', + 'KN', + 'KP', + 'KR', + 'KW', + 'KY', + 'KZ', + 'LA', + 'LB', + 'LC', + 'LI', + 'LK', + 'LR', + 'LS', + 'LT', + 'LU', + 'LV', + 'LY', + 'MA', + 'MC', + 'MD', + 'ME', + 'MF', + 'MG', + 'MH', + 'MK', + 'ML', + 'MM', + 'MN', + 'MO', + 'MP', + 'MQ', + 'MR', + 'MS', + 'MT', + 'MU', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + 'NA', + 'NC', + 'NE', + 'NF', + 'NG', + 'NI', + 'NL', + 'NO', + 'NP', + 'NR', + 'NU', + 'NZ', + 'OM', + 'PA', + 'PE', + 'PF', + 'PG', + 'PH', + 'PK', + 'PL', + 'PM', + 'PN', + 'PR', + 'PS', + 'PT', + 'PW', + 'PY', + 'QA', + 'RE', + 'RO', + 'RS', + 'RU', + 'RW', + 'SA', + 'SB', + 'SC', + 'SD', + 'SE', + 'SG', + 'SH', + 'SI', + 'SJ', + 'SK', + 'SL', + 'SM', + 'SN', + 'SO', + 'SR', + 'SS', + 'ST', + 'SV', + 'SX', + 'SY', + 'SZ', + 'TC', + 'TD', + 'TF', + 'TG', + 'TH', + 'TJ', + 'TK', + 'TL', + 'TM', + 'TN', + 'TO', + 'TR', + 'TT', + 'TV', + 'TW', + 'TZ', + 'UA', + 'UG', + 'UM', + 'US', + 'UY', + 'UZ', + 'VA', + 'VC', + 'VE', + 'VG', + 'VI', + 'VN', + 'VU', + 'WF', + 'WS', + 'YE', + 'YT', + 'ZA', + 'ZM', + 'ZW' ] export const UK_EEA_COUNTRY_CODES = [ - "GB", "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", - "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", "LI", "NO", "CH" + 'GB', + 'AT', + 'BE', + 'BG', + 'HR', + 'CY', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'DE', + 'GR', + 'HU', + 'IE', + 'IT', + 'LV', + 'LT', + 'LU', + 'MT', + 'NL', + 'PL', + 'PT', + 'RO', + 'SK', + 'SI', + 'ES', + 'SE', + 'IS', + 'LI', + 'NO', + 'CH' ] export const REGEX_AUDIENCEID = /"audienceId":(\d+)/ diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts index 88aab5d388..7260752104 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts @@ -1,4 +1,12 @@ -import { convertValidTimestamp, resolveIdentifiers, isIsoDate } from '../utils' +import { HTTPError, IntegrationError, MultiStatusResponse } from '@segment/actions-core' +import { + convertValidTimestamp, + isIsoDate, + parseTrackApiErrors, + parseTrackApiMultiStatusResponse, + resolveIdentifiers, + sendBatch +} from '../utils' describe('isIsoDate', () => { it('should return true for valid ISO date with fractional seconds from 1-9 digits', () => { @@ -80,8 +88,364 @@ describe('resolveIdentifiers', () => { }) }) +describe('sendBatch', () => { + it('should parse 207 multi-status Track API responses', async () => { + const request = jest.fn().mockResolvedValue({ + status: 207, + data: { + errors: [ + { + batch_index: 1, + reason: 'invalid', + message: 'Attribute value too long' + } + ] + } + }) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + }, + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-2', name: 'Second' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response.length()).toBe(2) + expect(response.getResponseAtIndex(0).value()).toEqual({ + status: 200, + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }) + expect(response.getResponseAtIndex(1).value()).toEqual({ + status: 400, + errormessage: 'Attribute value too long', + errortype: 'PAYLOAD_VALIDATION_FAILED', + body: { person_id: 'user-2', name: 'Second' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-2' }, name: 'Second' } + }) + }) + + it('should parse 200 Track API responses that still contain batch errors', async () => { + const request = jest.fn().mockResolvedValue({ + status: 200, + data: { + errors: [ + { + batch_index: 0, + reason: 'required', + field: 'name', + message: 'Name is required' + } + ] + } + }) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response.getResponseAtIndex(0).value()).toEqual({ + status: 400, + errormessage: 'Name is required', + errortype: 'PAYLOAD_VALIDATION_FAILED', + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }) + }) + + it('should return all-success responses when the Track API reports an empty errors array', async () => { + const request = jest.fn().mockResolvedValue({ + status: 207, + data: { + errors: [] + } + }) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + }, + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-2', name: 'Second' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response.getAllResponses().map((result) => result.value())).toEqual([ + { + status: 200, + body: { person_id: 'user-1', name: 'First' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' }, name: 'First' } + }, + { + status: 200, + body: { person_id: 'user-2', name: 'Second' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-2' }, name: 'Second' } + } + ]) + }) + + it('should rethrow retryable HTTP errors (429) so the framework can retry them', async () => { + const error = new HTTPError({ status: 429, statusText: 'Too Many Requests' } as any, {} as any, {} as any) + const request = jest.fn().mockRejectedValue(error) + + await expect( + sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + ).rejects.toBe(error) + }) + + it('should rethrow retryable HTTP errors (500) so the framework can retry them', async () => { + const error = new HTTPError({ status: 500, statusText: 'Internal Server Error' } as any, {} as any, {} as any) + const request = jest.fn().mockRejectedValue(error) + + await expect( + sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + ).rejects.toBe(error) + }) + + it('should convert non-retryable HTTP errors into per-item MultiStatusResponse entries', async () => { + const error = new HTTPError( + { status: 400, statusText: 'Bad Request' } as any, + { url: 'https://track.customer.io/api/v2/batch' } as any, + {} as any + ) + const request = jest.fn().mockRejectedValue(error) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + }, + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-2', name: 'Second' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response.length()).toBe(2) + expect(response.getResponseAtIndex(0).value()).toMatchObject({ + status: 400, + errortype: 'BAD_REQUEST', + body: { person_id: 'user-1', name: 'First' } + }) + expect(response.getResponseAtIndex(1).value()).toMatchObject({ + status: 400, + errortype: 'BAD_REQUEST', + body: { person_id: 'user-2', name: 'Second' } + }) + }) + + it('should convert non-retryable auth errors (401) into per-item INTEGRATION_ERROR entries', async () => { + const error = new HTTPError( + { status: 401, statusText: 'Unauthorized' } as any, + { url: 'https://track.customer.io/api/v2/batch' } as any, + {} as any + ) + const request = jest.fn().mockRejectedValue(error) + + const response = await sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect(response.getResponseAtIndex(0).value()).toMatchObject({ + status: 401, + errortype: 'UNAUTHORIZED', + body: { person_id: 'user-1', name: 'First' } + }) + }) + + it('should throw when the batch endpoint returns an unexpected response shape', async () => { + const request = jest.fn().mockResolvedValue({ + status: 200, + data: { + ok: true + } + }) + + await expect( + sendBatch(request, [ + { + type: 'person', + action: 'event', + settings: {}, + payload: { person_id: 'user-1', name: 'First' } + } + ]) + ).rejects.toThrow(IntegrationError) + }) +}) + +describe('parseTrackApiErrors', () => { + it('should throw when errors contain an unindexable batch_index', () => { + const options = [{ type: 'person', action: 'event', settings: {}, payload: { person_id: 'user-0' } }] + const batch = [{ type: 'person', action: 'event', identifiers: { id: 'user-0' } }] + + expect(() => + parseTrackApiErrors( + [{ reason: 'invalid', message: 'some error' }], // no batch_index + options, + batch + ) + ).toThrow(IntegrationError) + + expect(() => + parseTrackApiErrors( + [{ batch_index: 99, reason: 'invalid', message: 'out of range' }], // out of range + options, + batch + ) + ).toThrow(IntegrationError) + }) + it('should fill success entries for items without errors', () => { + const options = [ + { type: 'person', action: 'event', settings: {}, payload: { person_id: 'user-0' } }, + { type: 'person', action: 'event', settings: {}, payload: { person_id: 'user-1' } }, + { type: 'person', action: 'event', settings: {}, payload: { person_id: 'user-2' } } + ] + const batch = [ + { type: 'person', action: 'event', identifiers: { id: 'user-0' } }, + { type: 'person', action: 'event', identifiers: { id: 'user-1' } }, + { type: 'person', action: 'event', identifiers: { id: 'user-2' } } + ] + + const response = parseTrackApiErrors( + [ + { + batch_index: 1, + reason: 'required', + field: 'name', + message: 'Name is required' + } + ], + options, + batch + ) + + expect(response.getAllResponses().map((result) => result.value())).toEqual([ + { + status: 200, + body: { person_id: 'user-0' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-0' } } + }, + { + status: 400, + errormessage: 'Name is required', + errortype: 'PAYLOAD_VALIDATION_FAILED', + body: { person_id: 'user-1' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' } } + }, + { + status: 200, + body: { person_id: 'user-2' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-2' } } + } + ]) + }) + + it('should preserve multiple errors for the same batch index and default unknown reasons', () => { + const options = [{ type: 'person', action: 'event', settings: {}, payload: { person_id: 'user-1' } }] + const batch = [{ type: 'person', action: 'event', identifiers: { id: 'user-1' } }] + + const response = parseTrackApiErrors( + [ + { + batch_index: 0, + reason: 'duplicate', + field: 'email', + message: 'Email already exists' + }, + { + batch_index: 0, + reason: 'duplicate', + field: 'id', + message: 'ID already exists' + } + ], + options, + batch + ) + + expect(response.getResponseAtIndex(0).value()).toEqual({ + status: 400, + errormessage: 'Email already exists; ID already exists', + errortype: 'UNKNOWN_ERROR', + body: { person_id: 'user-1' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-1' } } + }) + }) +}) + +describe('parseTrackApiMultiStatusResponse', () => { + it('should return null for non-Track API response bodies', () => { + const options = [{ type: 'person', action: 'event', settings: {}, payload: { person_id: 'user-0' } }] + const batch = [{ type: 'person', action: 'event', identifiers: { id: 'user-0' } }] + expect(parseTrackApiMultiStatusResponse({ ok: true }, options, batch)).toBeNull() + }) + + it('should treat an empty Track API errors array as an all-success response', () => { + const options = [{ type: 'person', action: 'event', settings: {}, payload: { person_id: 'user-0' } }] + const batch = [{ type: 'person', action: 'event', identifiers: { id: 'user-0' } }] + + const response = parseTrackApiMultiStatusResponse({ errors: [] }, options, batch) + + expect(response).toBeInstanceOf(MultiStatusResponse) + expect((response as MultiStatusResponse).getResponseAtIndex(0).value()).toEqual({ + status: 200, + body: { person_id: 'user-0' }, + sent: { type: 'person', action: 'event', identifiers: { id: 'user-0' } } + }) + }) +}) + describe('convertValidTimestamp', () => { it('should leave decimal unix timestamps unchanged', () => { expect(convertValidTimestamp('1712345678.123')).toBe('1712345678.123') }) }) + diff --git a/packages/destination-actions/src/destinations/customerio/utils.ts b/packages/destination-actions/src/destinations/customerio/utils.ts index 94718c56a0..e76038bdbf 100644 --- a/packages/destination-actions/src/destinations/customerio/utils.ts +++ b/packages/destination-actions/src/destinations/customerio/utils.ts @@ -1,6 +1,14 @@ import dayjs from '../../lib/dayjs' import isPlainObject from 'lodash/isPlainObject' import { fullFormats } from 'ajv-formats/dist/formats' +import { + ErrorCodes, + getErrorCodeFromHttpStatus, + HTTPError, + IntegrationError, + MultiStatusResponse, + RequestClient +} from '@segment/actions-core' import { CUSTOMERIO_TRACK_API_VERSION } from './versioning-info' const isEmail = (value: string): boolean => { @@ -197,23 +205,184 @@ export const resolveIdentifiers = ({ } } -export const sendBatch = (request: Function, options: RequestPayload[]) => { +export const sendBatch = async ( + request: RequestClient, + options: RequestPayload[] +): Promise => { if (!options?.length) { - return + return new MultiStatusResponse() } const [{ settings }] = options const batch = options.map((opts) => buildPayload(opts)) - return request(`${trackApiEndpoint(settings)}/api/${CUSTOMERIO_TRACK_API_VERSION}/batch`, { - method: 'post', - json: { - batch + try { + const response = await request( + `${trackApiEndpoint(settings)}/api/${CUSTOMERIO_TRACK_API_VERSION}/batch`, + { + method: 'post', + json: { + batch + } + } + ) + + const parsedResults = parseTrackApiMultiStatusResponse(response.data, options, batch) + if (parsedResults) { + return parsedResults } - }) + + throw new IntegrationError( + 'Customer.io Track API batch response did not include an errors array', + 'INVALID_RESPONSE', + 502 + ) + } catch (err) { + // Retryable HTTP errors (408 Request Timeout, 429 Too Many Requests, 5xx Server Errors) + // and unexpected non-HTTP errors should be rethrown so the framework's retry wrapper + // can handle them. Only convert to per-item errors for non-retryable HTTP failures. + if (err instanceof HTTPError) { + const status = err.response.status + if (status === 408 || status === 429 || status >= 500) { + throw err + } + + const responseBody = err.response?.data as { message?: string } | undefined + const message = responseBody?.message ?? err.message ?? 'Unknown error' + const errortype = mapHttpStatusToErrorCode(status) + const multiStatusResponse = new MultiStatusResponse() + for (let i = 0; i < options.length; i++) { + multiStatusResponse.setErrorResponseAtIndex(i, { + status, + errortype, + errormessage: message, + body: options[i].payload, + sent: batch[i] + }) + } + return multiStatusResponse + } + + // Non-HTTP errors are unexpected - rethrow so the framework can handle/retry them + throw err + } +} + +interface TrackApiError { + batch_index?: number + reason?: string + field?: string + message?: string +} + +interface CustomerIOBatchResponse { + errors?: TrackApiError[] +} + +function mapHttpStatusToErrorCode(status: number): keyof typeof ErrorCodes { + return getErrorCodeFromHttpStatus(status) +} + +function mapTrackApiReasonToErrorCode(reason: string | undefined) { + switch (reason?.toLowerCase()) { + case 'invalid': + case 'required': + return ErrorCodes.PAYLOAD_VALIDATION_FAILED + default: + return ErrorCodes.UNKNOWN_ERROR + } +} + +export function parseTrackApiErrors( + errors: TrackApiError[], + options: RequestPayload[], + batch: Record[] +): MultiStatusResponse { + const multiStatusResponse = new MultiStatusResponse() + const errorMap = new Map() + const unindexableErrors: TrackApiError[] = [] + + for (const error of errors) { + const batchIndex = error.batch_index + + if (!Number.isInteger(batchIndex) || (batchIndex as number) < 0 || (batchIndex as number) >= options.length) { + unindexableErrors.push(error) + continue + } + + const existing = errorMap.get(batchIndex as number) + if (existing) { + existing.push(error) + } else { + errorMap.set(batchIndex as number, [error]) + } + } + + if (unindexableErrors.length > 0) { + const errormessage = unindexableErrors + .map((e) => { + const indexDesc = typeof e.batch_index === 'number' ? `batch_index ${e.batch_index}` : 'missing batch_index' + return e.message || `${e.reason || 'ERROR'}: ${e.field || 'unknown field'} (${indexDesc})` + }) + .join('; ') + + throw new IntegrationError( + `Customer.io returned batch errors that could not be mapped to request items: ${errormessage}`, + 'INVALID_RESPONSE', + 502 + ) + } + + for (let i = 0; i < options.length; i++) { + const indexErrors = errorMap.get(i) + + if (!indexErrors) { + multiStatusResponse.setSuccessResponseAtIndex(i, { + status: 200, + body: options[i].payload, + sent: batch[i] + }) + continue + } + + const errormessage = indexErrors + .map((e) => e.message || `${e.reason || 'ERROR'}: ${e.field || 'unknown field'}`) + .join('; ') + + // Use the reason from the first error for error code mapping (all errors for the same + // batch_index are expected to share the same underlying reason class) + const errortype = mapTrackApiReasonToErrorCode(indexErrors[0].reason) + + multiStatusResponse.setErrorResponseAtIndex(i, { + status: 400, + errormessage, + errortype, + body: options[i].payload, + sent: batch[i] + }) + } + + return multiStatusResponse +} + +export function parseTrackApiMultiStatusResponse( + responseBody: CustomerIOBatchResponse | undefined, + options: RequestPayload[], + batch: Record[] +): MultiStatusResponse | null { + if (!isRecord(responseBody)) { + return null + } + + const { errors } = responseBody as CustomerIOBatchResponse + if (!Array.isArray(errors)) { + return null + } + + return parseTrackApiErrors(errors, options, batch) } -export const sendSingle = (request: Function, options: RequestPayload) => { +export const sendSingle = (request: RequestClient, options: RequestPayload) => { const json = buildPayload(options) return request(`${trackApiEndpoint(options.settings)}/api/${CUSTOMERIO_TRACK_API_VERSION}/entity`, { method: 'post', diff --git a/packages/destination-actions/src/destinations/hubspot/customEvent/__tests__/validate.test.ts b/packages/destination-actions/src/destinations/hubspot/customEvent/__tests__/validate.test.ts index a0cad359b5..d9f75a552d 100644 --- a/packages/destination-actions/src/destinations/hubspot/customEvent/__tests__/validate.test.ts +++ b/packages/destination-actions/src/destinations/hubspot/customEvent/__tests__/validate.test.ts @@ -102,11 +102,7 @@ describe('Hubspot.customEvent', () => { validate(payloadWithEmptyString, statsContext, logger, subscriptionMetadata) - expect(statsClient.incr).toHaveBeenCalledWith( - 'hubspot.custom_event.empty_string_to_number', - 1, - statsContext.tags - ) + expect(statsClient.incr).toHaveBeenCalledWith('hubspot.custom_event.empty_string_to_number', 1, statsContext.tags) expect(logger.warn).toHaveBeenCalledWith( 'hubspot.custom_event.empty_string_to_number destinationConfigId: dest-123 sourceId: src-456' ) diff --git a/packages/destination-actions/src/destinations/memora/upsertProfile/__tests__/index.test.ts b/packages/destination-actions/src/destinations/memora/upsertProfile/__tests__/index.test.ts index 4d057cc0e3..16e383e793 100644 --- a/packages/destination-actions/src/destinations/memora/upsertProfile/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/memora/upsertProfile/__tests__/index.test.ts @@ -908,7 +908,7 @@ describe('Memora.upsertProfile', () => { throw new Error('performBatch is not defined') } - const result = (await action.performBatch(mockRequest, executeInput)) as any + const result = await action.performBatch(mockRequest, executeInput) // Verify MultiStatusResponse structure expect(result.length()).toBe(3) @@ -983,7 +983,7 @@ describe('Memora.upsertProfile', () => { throw new Error('performBatch is not defined') } - const result = (await action.performBatch(mockRequest, executeInput)) as any + const result = await action.performBatch(mockRequest, executeInput) // Verify MultiStatusResponse structure - all profiles should have error status expect(result.length()).toBe(3) @@ -1038,7 +1038,7 @@ describe('Memora.upsertProfile', () => { throw new Error('performBatch is not defined') } - const result = (await action.performBatch(mockRequest, executeInput)) as any + const result = await action.performBatch(mockRequest, executeInput) // Should return MultiStatusResponse (not throw), even with single payload expect(result.length()).toBe(1) @@ -1192,7 +1192,7 @@ describe('Memora.upsertProfile', () => { throw new Error('performBatch is not defined') } - const result = (await action.performBatch(mockRequest, executeInput)) as any + const result = await action.performBatch(mockRequest, executeInput) // Verify MultiStatusResponse structure expect(result.length()).toBe(3) @@ -1259,7 +1259,7 @@ describe('Memora.upsertProfile', () => { throw new Error('performBatch is not defined') } - const result = (await action.performBatch(mockRequest, executeInput)) as any + const result = await action.performBatch(mockRequest, executeInput) // Verify MultiStatusResponse structure expect(result.length()).toBe(3)