diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/_tests_/multistatus.test.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/_tests_/multistatus.test.ts new file mode 100644 index 0000000000..6e74fad141 --- /dev/null +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/_tests_/multistatus.test.ts @@ -0,0 +1,356 @@ +import { SegmentEvent, createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import sfmc from '../index' +import { Settings } from '../generated-types' + +beforeEach(() => nock.cleanAll()) + +const testDestination = createTestIntegration(sfmc) +const settings: Settings = { + subdomain: 'test123', + client_id: 'test123', + client_secret: 'test123', + account_id: 'test123' +} +const requestUrl = `https://${settings.subdomain}.rest.marketingcloudapis.com/hub/v1/dataevents/1234567890/rowset` +const mapping = { + id: { '@path': '$.properties.id' }, + keys: { '@path': '$.properties.keys' }, + values: { '@path': '$.properties.values' } +} + +describe('Multistatus', () => { + describe('dataExtension', () => { + it('should successfully handle a batch of events with complete success response from SFMC API', async () => { + nock(requestUrl).post('').reply(200, {}) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'track', + userId: 'harry-1', + properties: { + id: '1234567890', + keys: { + id: 'HS1' + }, + values: { + name: 'Harry Styles' + } + } + }), + // Valid Event + createTestEvent({ + type: 'track', + userId: 'harry-1', + properties: { + id: '1234567890', + keys: { + id: 'HS1' + }, + values: { + name: 'Harry Styles' + } + } + }) + ] + + const response = await testDestination.executeBatch('dataExtension', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 200, + body: {} + }) + + expect(response[1]).toMatchObject({ + status: 200, + body: {} + }) + }) + it('should handle the case where both key and id are missing', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + type: 'track', + userId: 'harry-1', + properties: { + keys: { + id: 'HS1' + }, + values: { + name: 'Harry Styles' + } + } + }), + createTestEvent({ + type: 'track', + userId: 'harry-2', + properties: { + keys: { + id: 'HP1' + }, + values: { + name: 'Harry Potter' + } + } + }) // No key and id provided + ] + + const response = await testDestination.executeBatch('dataExtension', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: + 'In order to send an event to a data extension either Data Extension ID or Data Extension Key must be defined.', + errorreporter: 'INTEGRATIONS' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: + 'In order to send an event to a data extension either Data Extension ID or Data Extension Key must be defined.', + errorreporter: 'INTEGRATIONS' + }) + }) + it('should handle multistatus errors when some events fail with specific error messages', async () => { + const errorResponse = { + status: 400, + message: 'Invalid keys for ID: HS1', + additionalErrors: [ + { + message: 'No record found for ID: HS2' + } + ] + } + + nock(requestUrl).post('').reply(400, errorResponse) + + const events: SegmentEvent[] = [ + createTestEvent({ + type: 'track', + userId: 'harry-1', + properties: { + id: '1234567890', + keys: { + id: 'HS1' // Valid key + }, + values: { + name: 'Harry Styles' + } + } + }), + createTestEvent({ + type: 'track', + userId: 'harry-2', + properties: { + id: '1234567890', + keys: { + id: 'HS2' // Invalid key + }, + values: { + name: 'Harry Potter' + } + } + }) + ] + + const response = await testDestination.executeBatch('dataExtension', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'BAD_REQUEST', + errormessage: 'No record found for ID: HS2', + errorreporter: 'DESTINATION' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'BAD_REQUEST', + errormessage: 'No record found for ID: HS2', + errorreporter: 'DESTINATION' + }) + }) + }) + + describe('contactDataExtension', () => { + it('should successfully handle a batch of events with complete success response from SFMC API', async () => { + nock(requestUrl).post('').reply(200, {}) + + const events: SegmentEvent[] = [ + createTestEvent({ + type: 'track', + properties: { + id: '1234567890', + keys: { + contactKey: 'harry-1', + id: 'HS1' + }, + values: { + name: 'Harry Styles' + } + } + }), + createTestEvent({ + type: 'track', + properties: { + id: '1234567890', + keys: { + contactKey: 'harry-2', + id: 'HP1' + }, + values: { + name: 'Harry Potter' + } + } + }) + ] + + const response = await testDestination.executeBatch('contactDataExtension', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 200, + body: {} + }) + + expect(response[1]).toMatchObject({ + status: 200, + body: {} + }) + }) + + it('should handle the case where both key and id are missing', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + type: 'track', + userId: 'harry-1', + properties: { + keys: { + contactKey: 'harry-1', + id: 'HS1' + }, + values: { + name: 'Harry Styles' + } + } + }), + createTestEvent({ + type: 'track', + userId: 'harry-2', + properties: { + keys: { + contactKey: 'harry-2', + id: 'HP1' + }, + values: { + name: 'Harry Potter' + } + } + }) // No key and id provided + ] + + const response = await testDestination.executeBatch('contactDataExtension', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: + 'In order to send an event to a data extension either Data Extension ID or Data Extension Key must be defined.', + errorreporter: 'INTEGRATIONS' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: + 'In order to send an event to a data extension either Data Extension ID or Data Extension Key must be defined.', + errorreporter: 'INTEGRATIONS' + }) + }) + + it('should handle multistatus errors when some events fail with specific error messages', async () => { + const errorResponse = { + status: 400, + message: 'Invalid keys for ID: HS1', + additionalErrors: [ + { + message: 'No record found for ID: HS2' + } + ] + } + + nock(requestUrl).post('').reply(400, errorResponse) + + const events: SegmentEvent[] = [ + createTestEvent({ + type: 'track', + userId: 'harry-1', + properties: { + id: '1234567890', + keys: { + contactKey: 'harry-1', + id: 'HS1' + }, + values: { + name: 'Harry Styles' + } + } + }), + createTestEvent({ + type: 'track', + userId: 'harry-2', + properties: { + id: '1234567890', + keys: { + contactKey: 'harry-2', + id: 'HS2' + }, + values: { + name: 'Harry Potter' + } + } + }) + ] + + const response = await testDestination.executeBatch('contactDataExtension', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'BAD_REQUEST', + errormessage: 'No record found for ID: HS2', + errorreporter: 'DESTINATION' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'BAD_REQUEST', + errormessage: 'No record found for ID: HS2', + errorreporter: 'DESTINATION' + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/index.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/index.ts index fc6e0f2c04..4607abd1e7 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/index.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/index.ts @@ -2,7 +2,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { key, id, keys, enable_batching, batch_size, values_contactFields } from '../sfmc-properties' -import { upsertRows } from '../sfmc-operations' +import { executeUpsertWithMultiStatus, upsertRows } from '../sfmc-operations' const action: ActionDefinition = { title: 'Send Contact to Data Extension', @@ -33,24 +33,8 @@ const action: ActionDefinition = { perform: async (request, { settings, payload }) => { return upsertRows(request, settings.subdomain, [payload]) }, - performBatch: async (request, { settings, payload, statsContext, features }) => { - if (features && features['enable-sfmc-id-key-stats']) { - const statsClient = statsContext?.statsClient - const tags = statsContext?.tags - const setKey = new Set() - const setId = new Set() - payload.forEach((profile) => { - if (profile.id != undefined && profile.id != null) { - setId.add(profile.id) - } - if (profile.key != undefined && profile.key != null) { - setKey.add(profile.key) - } - }) - statsClient?.histogram(`sfmc_id`, setId.size, tags) - statsClient?.histogram(`sfmc_key`, setKey.size, tags) - } - return upsertRows(request, settings.subdomain, payload) + performBatch: async (request, { settings, payload }) => { + return executeUpsertWithMultiStatus(request, settings.subdomain, payload) } } diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/index.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/index.ts index 0974b77c12..6635ac64ba 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/index.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/index.ts @@ -2,7 +2,7 @@ import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { key, id, keys, enable_batching, batch_size, values_dataExtensionFields } from '../sfmc-properties' -import { upsertRows } from '../sfmc-operations' +import { executeUpsertWithMultiStatus, upsertRows } from '../sfmc-operations' const action: ActionDefinition = { title: 'Send Event to Data Extension', @@ -18,24 +18,8 @@ const action: ActionDefinition = { perform: async (request, { settings, payload }) => { return upsertRows(request, settings.subdomain, [payload]) }, - performBatch: async (request, { settings, payload, statsContext, features }) => { - if (features && features['enable-sfmc-id-key-stats']) { - const statsClient = statsContext?.statsClient - const tags = statsContext?.tags - const setKey = new Set() - const setId = new Set() - payload.forEach((profile) => { - if (profile.id != undefined && profile.id != null) { - setId.add(profile.id) - } - if (profile.key != undefined && profile.key != null) { - setKey.add(profile.key) - } - }) - statsClient?.histogram(`sfmc_id`, setId.size, tags) - statsClient?.histogram(`sfmc_key`, setKey.size, tags) - } - return upsertRows(request, settings.subdomain, payload) + performBatch: async (request, { settings, payload }) => { + return executeUpsertWithMultiStatus(request, settings.subdomain, payload) } } diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-operations.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-operations.ts index b4e2a312be..53a2d569b5 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-operations.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-operations.ts @@ -1,6 +1,13 @@ -import { RequestClient, IntegrationError } from '@segment/actions-core' +import { + RequestClient, + MultiStatusResponse, + JSONLikeObject, + ModifiedResponse, + IntegrationError +} from '@segment/actions-core' import { Payload as payload_dataExtension } from './dataExtension/generated-types' import { Payload as payload_contactDataExtension } from './contactDataExtension/generated-types' +import { ErrorResponse } from './types' export function upsertRows( request: RequestClient, @@ -34,3 +41,53 @@ export function upsertRows( }) } } + +export async function executeUpsertWithMultiStatus( + request: RequestClient, + subdomain: String, + payloads: payload_dataExtension[] | payload_contactDataExtension[] +): Promise { + const multiStatusResponse = new MultiStatusResponse() + let response: ModifiedResponse | undefined + try { + response = await upsertRows(request, subdomain, payloads) + payloads.forEach((payload, index) => { + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: 200, + sent: payload as Object as JSONLikeObject, + body: response?.data as JSONLikeObject + }) + }) + } catch (error) { + if (error instanceof IntegrationError && error.code === 'Misconfigured required field') { + payloads.forEach((_, index) => { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: `In order to send an event to a data extension either Data Extension ID or Data Extension Key must be defined.` + }) + }) + return multiStatusResponse + } + const err = error as ErrorResponse + if (err?.response?.status === 401) { + throw error + } + + const errData = err?.response?.data + const additionalError = + err?.response?.data?.additionalErrors && + err.response.data.additionalErrors.length > 0 && + err.response.data.additionalErrors + + payloads.forEach((payload, index) => { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errormessage: additionalError ? additionalError[0].message : errData?.message || '', + sent: payload as Object as JSONLikeObject, + body: additionalError ? (additionalError as Object as JSONLikeObject) : (errData as Object as JSONLikeObject) + }) + }) + } + return multiStatusResponse +} diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/types.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/types.ts new file mode 100644 index 0000000000..48c995caa9 --- /dev/null +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/types.ts @@ -0,0 +1,22 @@ +interface AdditionalError { + message: string + errorcode: number + documentation: string +} + +interface ErrorData { + additionalErrors: AdditionalError[] + message: string +} + +interface ErrorResponseData { + message: string + errorcode: number + documentation: string + data: ErrorData + status: number +} + +export interface ErrorResponse { + response: ErrorResponseData +}