diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 00000000000..3d83d8775f9 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Braze's ecommerce destination action: all fields 1`] = ` +Object { + "events": Array [ + Object { + "_update_existing_only": true, + "app_id": "tZlmdH(v3%S", + "braze_id": "tZlmdH(v3%S", + "email": "urbapwid@avo.dz", + "external_id": "tZlmdH(v3%S", + "name": "ecommerce.checkout_started", + "phone": "tZlmdH(v3%S", + "properties": Object { + "cart_id": "tZlmdH(v3%S", + "checkout_id": "tZlmdH(v3%S", + "currency": "HTG", + "metadata": Object { + "testType": "tZlmdH(v3%S", + }, + "products": Array [ + Object { + "image_url": "http://vaci.va/inu", + "price": -17122291498352.64, + "product_id": "tZlmdH(v3%S", + "product_name": "tZlmdH(v3%S", + "product_url": "http://vaci.va/inu", + "quantity": -17122291498352.64, + "variant_id": "tZlmdH(v3%S", + }, + ], + "source": "tZlmdH(v3%S", + "total_value": -17122291498352.64, + }, + "time": "2025-01-01T00:00:00.000Z", + "user_alias": Object { + "alias_label": "tZlmdH(v3%S", + "alias_name": "tZlmdH(v3%S", + }, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's ecommerce destination action: required fields 1`] = ` +Object { + "events": Array [ + Object { + "app_id": "tZlmdH(v3%S", + "braze_id": "tZlmdH(v3%S", + "external_id": "tZlmdH(v3%S", + "name": "ecommerce.checkout_started", + "properties": Object { + "cart_id": "tZlmdH(v3%S", + "checkout_id": "tZlmdH(v3%S", + "currency": "HTG", + "products": Array [ + Object { + "price": -17122291498352.64, + "product_id": "tZlmdH(v3%S", + "product_name": "tZlmdH(v3%S", + "quantity": -17122291498352.64, + "variant_id": "tZlmdH(v3%S", + }, + ], + "source": "tZlmdH(v3%S", + "total_value": -17122291498352.64, + }, + "time": "2025-01-01T00:00:00.000Z", + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/index.test.ts b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/index.test.ts new file mode 100644 index 00000000000..c3af024729d --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/index.test.ts @@ -0,0 +1,1674 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' +import Definition from '../../index' +import { Settings } from '../../generated-types' +import { EVENT_NAMES } from '../constants' + +let testDestination = createTestIntegration(Definition) + +const settings: Settings = { + api_key: 'test_api_key', + app_id: 'test_app_id', + endpoint: 'https://rest.iad-01.braze.com' +} + +const payload = { + event: 'TEST', + type: 'track', + userId: 'userId1', + timestamp: '2024-06-10T12:00:00.000Z', + properties: { + email: 'email@email.com', + user_alias: { + alias_name: 'alias_name_1', + alias_label: 'alias_label_1' + }, + phone: '+14155551234', + braze_id: 'braze_id_1', + reason: "I didn't like it", + order_id: 'order_id_1', + cart_id: 'cart_id_1', + checkout_id: 'checkout_id_1', + total: 100.0, + discount: 10, + discount_items: [ + { + code: 'SUMMER21', + amount: 5 + }, + { + code: 'VIPCUSTOMER', + amount: 5 + } + ], + currency: 'USD', + source: 'test_source', + products: [ + { + product_id: 'prod_1', + name: 'Product 1', + variant: 'Size M', + image_url: 'https://example.com/prod1.jpg', + product_url: 'https://example.com/prod1', + quantity: 2, + price: 25.0, + metadata: { color: 'red', size: 'M' } + }, + { + product_id: 'prod_2', + name: 'Product 2', + variant: 'Size L', + image_url: 'https://example.com/prod2.jpg', + product_url: 'https://example.com/prod2', + quantity: 1, + price: 50.0 + } + ], + product: { + product_id: 'prod_1', + name: 'Product 1', + variant: 'Size M', + image_url: 'https://example.com/prod1.jpg', + product_url: 'https://example.com/prod1', + price: 25.0, + }, + metadata: { + custom_field_1: 'custom_value_1', + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ['a', 'b', 'c'], + custom_field_5: { nested_key: 'nested_value' }, + checkout_url: 'https://example.com/checkout', + order_status_url: 'https://example.com/order/status' + }, + type: 'testType', + } +} as Partial + +const mapping = { + name: EVENT_NAMES.ORDER_PLACED, + external_id: { '@path': '$.userId' }, + user_alias: { '@path': '$.properties.user_alias' }, + email: { '@path': '$.properties.email' }, + phone: { '@path': '$.properties.phone' }, + braze_id: { '@path': '$.properties.braze_id' }, + cancel_reason: { '@path': '$.properties.reason' }, + time: { '@path': '$.timestamp' }, + checkout_id: { '@path': '$.properties.checkout_id' }, + order_id: { '@path': '$.properties.order_id' }, + cart_id: { '@path': '$.properties.cart_id' }, + total_value: { '@path': '$.properties.total' }, + total_discounts: { '@path': '$.properties.discount' }, + discounts: {'@path': '$.properties.discount_items' }, + currency: { '@path': '$.properties.currency' }, + source: { '@path': '$.properties.source' }, + products: { + '@arrayPath': [ + '$.properties.products', + { + product_id: { '@path': '$.product_id' }, + product_name: { '@path': '$.name' }, + variant_id: { '@path': '$.variant'}, + image_url: {'@path': '$.image_url'}, + product_url: {'@path': '$.url'}, + quantity: {'@path': '$.quantity'}, + price: {'@path': '$.price'}, + metadata: { '@path': '$.metadata' } + } + ] + }, + product: { + product_id: { '@path': '$.properties.product.product_id' }, + product_name: { '@path': '$.properties.product.name' }, + variant_id: { '@path': '$.properties.product.variant'}, + image_url: {'@path': '$.properties.product.image_url'}, + product_url: {'@path': '$.properties.product.url'}, + price: {'@path': '$.properties.product.price'} + }, + metadata: { '@path': '$.properties.metadata' }, + type: { '@path': '$.properties.type' }, + _update_existing_only: false, + enable_batching: true, + batch_size: 75 +} + +beforeEach((done) => { + testDestination = createTestIntegration(Definition) + jest.clearAllMocks() + nock.cleanAll() + done() +}) + +afterEach(() => { + nock.cleanAll() +}) + +describe('Braze.ecommerce', () => { + + describe('single event', () => { + it('should send Order Completed event correctly', async () => { + const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) + const e = createTestEvent(deepCopy) + delete e.properties?.product + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { + alias_name: "alias_name_1", + alias_label: "alias_label_1" + }, + app_id: "test_app_id", + name: "ecommerce.order_placed", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { + color: "red", + size: "M" + } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + order_id: "order_id_1", + total_discounts: 10, + discounts: [ + { code: "SUMMER21", amount: 5 }, + { code: "VIPCUSTOMER", amount: 5 } + ], + cart_id: "cart_id_1", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { + nested_key: "nested_value" + }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + } + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.testAction('ecommerce', { + event: e, + settings, + useDefaultMappings: true, + mapping + }) + + expect(response.length).toBe(1) + }) + + it('should send Checkout Started event correctly', async () => { + + const mapping2 = { + ...mapping, + name: EVENT_NAMES.CHECKOUT_STARTED + } + + const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) + const e = createTestEvent(deepCopy) + delete e.properties?.product + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { + alias_name: "alias_name_1", + alias_label: "alias_label_1" + }, + app_id: "test_app_id", + name: "ecommerce.checkout_started", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { + color: "red", + size: "M" + } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + checkout_id: "checkout_id_1", + cart_id: "cart_id_1", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + } + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.testAction('ecommerce', { + event: e, + settings, + useDefaultMappings: true, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + }) + + it('should send Order Refunded event correctly', async () => { + + const mapping2 = { + ...mapping, + name: EVENT_NAMES.ORDER_REFUNDED + } + + const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) + const e = createTestEvent(deepCopy) + delete e.properties?.product + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { + alias_name: "alias_name_1", + alias_label: "alias_label_1" + }, + app_id: "test_app_id", + name: "ecommerce.order_refunded", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { + color: "red", + size: "M" + } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + order_id: "order_id_1", + total_discounts: 10, + discounts: [ + { code: "SUMMER21", amount: 5 }, + { code: "VIPCUSTOMER", amount: 5 } + ], + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + } + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.testAction('ecommerce', { + event: e, + settings, + useDefaultMappings: true, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + }) + + it('should send Order Cancelled event correctly', async () => { + + const mapping2 = { + ...mapping, + name: EVENT_NAMES.ORDER_CANCELLED + } + + const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) + const e = createTestEvent(deepCopy) + delete e.properties?.product + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { + alias_name: "alias_name_1", + alias_label: "alias_label_1" + }, + app_id: "test_app_id", + name: "ecommerce.order_cancelled", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { + color: "red", + size: "M" + } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + order_id: "order_id_1", + cancel_reason: "I didn't like it", + total_discounts: 10, + discounts: [ + { code: "SUMMER21", amount: 5 }, + { code: "VIPCUSTOMER", amount: 5 } + ], + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + } + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.testAction('ecommerce', { + event: e, + settings, + useDefaultMappings: true, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + }) + + it('should send Cart Updated event correctly', async () => { + + const mapping2 = { + ...mapping, + name: EVENT_NAMES.CART_UPDATED + } + + const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) + const e = createTestEvent(deepCopy) + //delete e.properties?.product + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { + alias_name: "alias_name_1", + alias_label: "alias_label_1" + }, + app_id: "test_app_id", + name: "ecommerce.cart_updated", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { + color: "red", + size: "M" + } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + cart_id: "cart_id_1" + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.testAction('ecommerce', { + event: e, + settings, + useDefaultMappings: true, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + }) + + it('should send Product Viewed event correctly', async () => { + + const mapping2 = { + ...mapping, + name: EVENT_NAMES.PRODUCT_VIEWED + } + + const payload2 = JSON.parse(JSON.stringify(payload)) + payload2.properties.products = undefined + + const deepCopy: Partial = JSON.parse(JSON.stringify(payload2)) + const e = createTestEvent(deepCopy) + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.product_viewed", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + price: 25, + type: ["testType"] + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.testAction('ecommerceSingleProduct', { + event: e, + settings, + useDefaultMappings: true, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + }) + + it('should throw an error if missing identifier', async () => { + + const deepCopy: Partial = JSON.parse(JSON.stringify(payload)) + const e = createTestEvent(deepCopy) + + e.userId = undefined + delete e.properties?.email + delete e.properties?.phone + delete e.properties?.braze_id + delete e.anonymousId + delete e.properties?.user_alias + + await expect( + testDestination.testAction('ecommerce', { + event: e, + settings, + useDefaultMappings: true, + mapping + }) + ).rejects.toThrowError(new Error('One of "external_id" or "user_alias" or "braze_id" or "email" or "phone" is required.')) + }) + }) + + describe('batch events', () => { + it('should send batched multi product ecommerce events correctly', async () => { + + const deepCopy1: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy2: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy3: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy4: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy5: Partial = JSON.parse(JSON.stringify(payload)) + + const e1 = createTestEvent({...deepCopy1, userId: 'userId1', event: 'ecommerce.order_placed' }) + const e2 = createTestEvent({...deepCopy2, userId: 'userId2', event: 'ecommerce.order_refunded' }) + const e3 = createTestEvent({...deepCopy3, userId: 'userId3', event: 'ecommerce.checkout_started' }) + const e4 = createTestEvent({...deepCopy4, userId: 'userId4', event: 'ecommerce.cart_updated' }) + const e5 = createTestEvent({...deepCopy5, userId: 'userId5', event: 'ecommerce.order_cancelled' }) + const events = [e1, e2, e3, e4, e5] + + const mapping2 = { + ...mapping, + name: { '@path': '$.event' }, + } + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.order_placed", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { color: "red", size: "M" } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + order_id: "order_id_1", + total_discounts: 10, + discounts: [ + { code: "SUMMER21", amount: 5 }, + { code: "VIPCUSTOMER", amount: 5 } + ], + cart_id: "cart_id_1" + }, + _update_existing_only: true + }, + { + external_id: "userId2", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.order_refunded", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + metadata: { color: "red", size: "M" }, + price: 25 + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + order_id: "order_id_1", + total_discounts: 10, + discounts: [ + { code: "SUMMER21", amount: 5 }, + { code: "VIPCUSTOMER", amount: 5 } + ] + }, + _update_existing_only: true + }, + { + external_id: "userId3", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.checkout_started", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { color: "red", size: "M" } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + checkout_id: "checkout_id_1", + cart_id: "cart_id_1" + }, + _update_existing_only: true + }, + { + external_id: "userId4", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.cart_updated", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { color: "red", size: "M" } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + cart_id: "cart_id_1" + }, + _update_existing_only: true + }, + { + external_id: "userId5", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.order_cancelled", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { color: "red", size: "M" } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + order_id: "order_id_1", + cancel_reason: "I didn't like it", + total_discounts: 10, + discounts: [ + { code: "SUMMER21", amount: 5 }, + { code: "VIPCUSTOMER", amount: 5 } + ] + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .matchHeader('X-Braze-Batch', 'true') + .reply(200) + + const response = await testDestination.testBatchAction('ecommerce', { + events, + settings, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + + }) + + it('should send batched single product ecommerce events correctly', async () => { + + const deepCopy1: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy2: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy3: Partial = JSON.parse(JSON.stringify(payload)) + + const e1 = createTestEvent({...deepCopy1, userId: 'userId1', event: 'ecommerce.product_viewed' }) + const e2 = createTestEvent({...deepCopy2, userId: 'userId2', event: 'ecommerce.product_viewed' }) + const e3 = createTestEvent({...deepCopy3, userId: 'userId3', event: 'ecommerce.product_viewed' }) + const events = [e1, e2, e3] + + const mapping2 = { + ...mapping, + name: { '@path': '$.event' }, + } + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.product_viewed", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + type: ["testType"] + }, + _update_existing_only: true + }, + { + external_id: "userId2", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.product_viewed", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + type: ["testType"] + }, + _update_existing_only: true + }, + { + external_id: "userId3", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.product_viewed", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + type: ["testType"] + }, + _update_existing_only: true + } + ] + } + + nock(settings.endpoint) + .post('/users/track', json) + .matchHeader('X-Braze-Batch', 'true') + .reply(200) + + const response = await testDestination.testBatchAction('ecommerce', { + events, + settings, + mapping: mapping2 + }) + + expect(response.length).toBe(1) + + }) + + it('should return correct multistatus response if there is a bad event', async () => { + + const deepCopy1: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy2: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy3: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy4: Partial = JSON.parse(JSON.stringify(payload)) + const deepCopy5: Partial = JSON.parse(JSON.stringify(payload)) + + const e1 = createTestEvent({...deepCopy1, userId: 'userId1', event: 'ecommerce.order_refunded' }) + + const e2 = createTestEvent({...deepCopy2}) + e2.userId = undefined + delete e2.properties?.email + delete e2.properties?.phone + delete e2.properties?.braze_id + delete e2.anonymousId + delete e2.properties?.user_alias + e2.event = 'ecommerce.order_placed' + + const e3 = createTestEvent({...deepCopy3, userId: 'userId3', event: 'ecommerce.checkout_started' }) + const e4 = createTestEvent({...deepCopy4, userId: 'userId4', event: 'ecommerce.cart_updated' }) + const e5 = createTestEvent({...deepCopy5, userId: 'userId5', event: 'ecommerce.order_cancelled' }) + + const events = [e1, e2, e3, e4, e5] + + const json = { + events: [ + { + external_id: "userId1", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.order_refunded", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { color: "red", size: "M" } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + order_id: "order_id_1", + total_discounts: 10, + discounts: [ + { code: "SUMMER21", amount: 5 }, + { code: "VIPCUSTOMER", amount: 5 } + ] + }, + _update_existing_only: true + }, + { + external_id: "userId3", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.checkout_started", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { color: "red", size: "M" } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + checkout_id: "checkout_id_1", + cart_id: "cart_id_1" + }, + _update_existing_only: true + }, + { + external_id: "userId4", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.cart_updated", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { color: "red", size: "M" } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + cart_id: "cart_id_1" + }, + _update_existing_only: true + }, + { + external_id: "userId5", + braze_id: "braze_id_1", + email: "email@email.com", + phone: "+14155551234", + user_alias: { alias_name: "alias_name_1", alias_label: "alias_label_1" }, + app_id: "test_app_id", + name: "ecommerce.order_cancelled", + time: "2024-06-10T12:00:00.000Z", + properties: { + currency: "USD", + source: "test_source", + metadata: { + custom_field_1: "custom_value_1", + custom_field_2: 100, + custom_field_3: true, + custom_field_4: ["a", "b", "c"], + custom_field_5: { nested_key: "nested_value" }, + checkout_url: "https://example.com/checkout", + order_status_url: "https://example.com/order/status" + }, + products: [ + { + product_id: "prod_1", + product_name: "Product 1", + variant_id: "Size M", + image_url: "https://example.com/prod1.jpg", + quantity: 2, + price: 25, + metadata: { color: "red", size: "M" } + }, + { + product_id: "prod_2", + product_name: "Product 2", + variant_id: "Size L", + image_url: "https://example.com/prod2.jpg", + quantity: 1, + price: 50 + } + ], + total_value: 100, + order_id: "order_id_1", + cancel_reason: "I didn't like it", + total_discounts: 10, + discounts: [ + { code: "SUMMER21", amount: 5 }, + { code: "VIPCUSTOMER", amount: 5 } + ] + }, + _update_existing_only: true + } + ] + } + + const mapping2 = { + ...mapping, + name: { '@path': '$.event' }, + } + + const responseJSON = [ + { + "status": 200, + "sent": { + "name": "ecommerce.order_refunded", + "external_id": "userId1", + "user_alias": { + "alias_name": "alias_name_1", + "alias_label": "alias_label_1" + }, + "email": "email@email.com", + "phone": "+14155551234", + "braze_id": "braze_id_1", + "cancel_reason": "I didn't like it", + "time": "2024-06-10T12:00:00.000Z", + "checkout_id": "checkout_id_1", + "order_id": "order_id_1", + "cart_id": "cart_id_1", + "total_value": 100, + "total_discounts": 10, + "discounts": [ + { + "code": "SUMMER21", + "amount": 5 + }, + { + "code": "VIPCUSTOMER", + "amount": 5 + } + ], + "currency": "USD", + "source": "test_source", + "products": [ + { + "product_id": "prod_1", + "product_name": "Product 1", + "variant_id": "Size M", + "image_url": "https://example.com/prod1.jpg", + "quantity": 2, + "price": 25, + "metadata": { + "color": "red", + "size": "M" + } + }, + { + "product_id": "prod_2", + "product_name": "Product 2", + "variant_id": "Size L", + "image_url": "https://example.com/prod2.jpg", + "quantity": 1, + "price": 50 + } + ], + "metadata": { + "custom_field_1": "custom_value_1", + "custom_field_2": 100, + "custom_field_3": true, + "custom_field_4": [ + "a", + "b", + "c" + ], + "custom_field_5": { + "nested_key": "nested_value" + }, + "checkout_url": "https://example.com/checkout", + "order_status_url": "https://example.com/order/status" + }, + "type": [ + "testType" + ], + "_update_existing_only": false, + "enable_batching": true, + "batch_size": 75, + "index": 0 + }, + "body": "{\"external_id\":\"userId1\",\"braze_id\":\"braze_id_1\",\"email\":\"email@email.com\",\"phone\":\"+14155551234\",\"user_alias\":{\"alias_name\":\"alias_name_1\",\"alias_label\":\"alias_label_1\"},\"app_id\":\"test_app_id\",\"name\":\"ecommerce.order_refunded\",\"time\":\"2024-06-10T12:00:00.000Z\",\"properties\":{\"currency\":\"USD\",\"source\":\"test_source\",\"metadata\":{\"custom_field_1\":\"custom_value_1\",\"custom_field_2\":100,\"custom_field_3\":true,\"custom_field_4\":[\"a\",\"b\",\"c\"],\"custom_field_5\":{\"nested_key\":\"nested_value\"},\"checkout_url\":\"https://example.com/checkout\",\"order_status_url\":\"https://example.com/order/status\"},\"products\":[{\"product_id\":\"prod_1\",\"product_name\":\"Product 1\",\"variant_id\":\"Size M\",\"image_url\":\"https://example.com/prod1.jpg\",\"quantity\":2,\"price\":25,\"metadata\":{\"color\":\"red\",\"size\":\"M\"}},{\"product_id\":\"prod_2\",\"product_name\":\"Product 2\",\"variant_id\":\"Size L\",\"image_url\":\"https://example.com/prod2.jpg\",\"quantity\":1,\"price\":50}],\"total_value\":100,\"order_id\":\"order_id_1\",\"total_discounts\":10,\"discounts\":[{\"code\":\"SUMMER21\",\"amount\":5},{\"code\":\"VIPCUSTOMER\",\"amount\":5}]},\"_update_existing_only\":true}" + }, + { + "status": 400, + "errormessage": "One of \"external_id\" or \"user_alias\" or \"braze_id\" or \"email\" or \"phone\" is required.", + "sent": { + "name": "ecommerce.order_placed", + "cancel_reason": "I didn't like it", + "time": "2024-06-10T12:00:00.000Z", + "checkout_id": "checkout_id_1", + "order_id": "order_id_1", + "cart_id": "cart_id_1", + "total_value": 100, + "total_discounts": 10, + "discounts": [ + { + "code": "SUMMER21", + "amount": 5 + }, + { + "code": "VIPCUSTOMER", + "amount": 5 + } + ], + "currency": "USD", + "source": "test_source", + "products": [ + { + "product_id": "prod_1", + "product_name": "Product 1", + "variant_id": "Size M", + "image_url": "https://example.com/prod1.jpg", + "quantity": 2, + "price": 25, + "metadata": { + "color": "red", + "size": "M" + } + }, + { + "product_id": "prod_2", + "product_name": "Product 2", + "variant_id": "Size L", + "image_url": "https://example.com/prod2.jpg", + "quantity": 1, + "price": 50 + } + ], + "metadata": { + "custom_field_1": "custom_value_1", + "custom_field_2": 100, + "custom_field_3": true, + "custom_field_4": [ + "a", + "b", + "c" + ], + "custom_field_5": { + "nested_key": "nested_value" + }, + "checkout_url": "https://example.com/checkout", + "order_status_url": "https://example.com/order/status" + }, + "type": [ + "testType" + ], + "_update_existing_only": false, + "enable_batching": true, + "batch_size": 75 + }, + "errortype": "BAD_REQUEST", + "errorreporter": "DESTINATION" + }, + { + "status": 200, + "sent": { + "name": "ecommerce.checkout_started", + "external_id": "userId3", + "user_alias": { + "alias_name": "alias_name_1", + "alias_label": "alias_label_1" + }, + "email": "email@email.com", + "phone": "+14155551234", + "braze_id": "braze_id_1", + "cancel_reason": "I didn't like it", + "time": "2024-06-10T12:00:00.000Z", + "checkout_id": "checkout_id_1", + "order_id": "order_id_1", + "cart_id": "cart_id_1", + "total_value": 100, + "total_discounts": 10, + "discounts": [ + { + "code": "SUMMER21", + "amount": 5 + }, + { + "code": "VIPCUSTOMER", + "amount": 5 + } + ], + "currency": "USD", + "source": "test_source", + "products": [ + { + "product_id": "prod_1", + "product_name": "Product 1", + "variant_id": "Size M", + "image_url": "https://example.com/prod1.jpg", + "quantity": 2, + "price": 25, + "metadata": { + "color": "red", + "size": "M" + } + }, + { + "product_id": "prod_2", + "product_name": "Product 2", + "variant_id": "Size L", + "image_url": "https://example.com/prod2.jpg", + "quantity": 1, + "price": 50 + } + ], + "metadata": { + "custom_field_1": "custom_value_1", + "custom_field_2": 100, + "custom_field_3": true, + "custom_field_4": [ + "a", + "b", + "c" + ], + "custom_field_5": { + "nested_key": "nested_value" + }, + "checkout_url": "https://example.com/checkout", + "order_status_url": "https://example.com/order/status" + }, + "type": [ + "testType" + ], + "_update_existing_only": false, + "enable_batching": true, + "batch_size": 75, + "index": 1 + }, + "body": "{\"external_id\":\"userId3\",\"braze_id\":\"braze_id_1\",\"email\":\"email@email.com\",\"phone\":\"+14155551234\",\"user_alias\":{\"alias_name\":\"alias_name_1\",\"alias_label\":\"alias_label_1\"},\"app_id\":\"test_app_id\",\"name\":\"ecommerce.checkout_started\",\"time\":\"2024-06-10T12:00:00.000Z\",\"properties\":{\"currency\":\"USD\",\"source\":\"test_source\",\"metadata\":{\"custom_field_1\":\"custom_value_1\",\"custom_field_2\":100,\"custom_field_3\":true,\"custom_field_4\":[\"a\",\"b\",\"c\"],\"custom_field_5\":{\"nested_key\":\"nested_value\"},\"checkout_url\":\"https://example.com/checkout\",\"order_status_url\":\"https://example.com/order/status\"},\"products\":[{\"product_id\":\"prod_1\",\"product_name\":\"Product 1\",\"variant_id\":\"Size M\",\"image_url\":\"https://example.com/prod1.jpg\",\"quantity\":2,\"price\":25,\"metadata\":{\"color\":\"red\",\"size\":\"M\"}},{\"product_id\":\"prod_2\",\"product_name\":\"Product 2\",\"variant_id\":\"Size L\",\"image_url\":\"https://example.com/prod2.jpg\",\"quantity\":1,\"price\":50}],\"total_value\":100,\"checkout_id\":\"checkout_id_1\",\"cart_id\":\"cart_id_1\"},\"_update_existing_only\":true}" + }, + { + "status": 200, + "sent": { + "name": "ecommerce.cart_updated", + "external_id": "userId4", + "user_alias": { + "alias_name": "alias_name_1", + "alias_label": "alias_label_1" + }, + "email": "email@email.com", + "phone": "+14155551234", + "braze_id": "braze_id_1", + "cancel_reason": "I didn't like it", + "time": "2024-06-10T12:00:00.000Z", + "checkout_id": "checkout_id_1", + "order_id": "order_id_1", + "cart_id": "cart_id_1", + "total_value": 100, + "total_discounts": 10, + "discounts": [ + { + "code": "SUMMER21", + "amount": 5 + }, + { + "code": "VIPCUSTOMER", + "amount": 5 + } + ], + "currency": "USD", + "source": "test_source", + "products": [ + { + "product_id": "prod_1", + "product_name": "Product 1", + "variant_id": "Size M", + "image_url": "https://example.com/prod1.jpg", + "quantity": 2, + "price": 25, + "metadata": { + "color": "red", + "size": "M" + } + }, + { + "product_id": "prod_2", + "product_name": "Product 2", + "variant_id": "Size L", + "image_url": "https://example.com/prod2.jpg", + "quantity": 1, + "price": 50 + } + ], + "metadata": { + "custom_field_1": "custom_value_1", + "custom_field_2": 100, + "custom_field_3": true, + "custom_field_4": [ + "a", + "b", + "c" + ], + "custom_field_5": { + "nested_key": "nested_value" + }, + "checkout_url": "https://example.com/checkout", + "order_status_url": "https://example.com/order/status" + }, + "type": [ + "testType" + ], + "_update_existing_only": false, + "enable_batching": true, + "batch_size": 75, + "index": 2 + }, + "body": "{\"external_id\":\"userId4\",\"braze_id\":\"braze_id_1\",\"email\":\"email@email.com\",\"phone\":\"+14155551234\",\"user_alias\":{\"alias_name\":\"alias_name_1\",\"alias_label\":\"alias_label_1\"},\"app_id\":\"test_app_id\",\"name\":\"ecommerce.cart_updated\",\"time\":\"2024-06-10T12:00:00.000Z\",\"properties\":{\"currency\":\"USD\",\"source\":\"test_source\",\"metadata\":{\"custom_field_1\":\"custom_value_1\",\"custom_field_2\":100,\"custom_field_3\":true,\"custom_field_4\":[\"a\",\"b\",\"c\"],\"custom_field_5\":{\"nested_key\":\"nested_value\"},\"checkout_url\":\"https://example.com/checkout\",\"order_status_url\":\"https://example.com/order/status\"},\"products\":[{\"product_id\":\"prod_1\",\"product_name\":\"Product 1\",\"variant_id\":\"Size M\",\"image_url\":\"https://example.com/prod1.jpg\",\"quantity\":2,\"price\":25,\"metadata\":{\"color\":\"red\",\"size\":\"M\"}},{\"product_id\":\"prod_2\",\"product_name\":\"Product 2\",\"variant_id\":\"Size L\",\"image_url\":\"https://example.com/prod2.jpg\",\"quantity\":1,\"price\":50}],\"total_value\":100,\"cart_id\":\"cart_id_1\"},\"_update_existing_only\":true}" + }, + { + "status": 200, + "sent": { + "name": "ecommerce.order_cancelled", + "external_id": "userId5", + "user_alias": { + "alias_name": "alias_name_1", + "alias_label": "alias_label_1" + }, + "email": "email@email.com", + "phone": "+14155551234", + "braze_id": "braze_id_1", + "cancel_reason": "I didn't like it", + "time": "2024-06-10T12:00:00.000Z", + "checkout_id": "checkout_id_1", + "order_id": "order_id_1", + "cart_id": "cart_id_1", + "total_value": 100, + "total_discounts": 10, + "discounts": [ + { + "code": "SUMMER21", + "amount": 5 + }, + { + "code": "VIPCUSTOMER", + "amount": 5 + } + ], + "currency": "USD", + "source": "test_source", + "products": [ + { + "product_id": "prod_1", + "product_name": "Product 1", + "variant_id": "Size M", + "image_url": "https://example.com/prod1.jpg", + "quantity": 2, + "price": 25, + "metadata": { + "color": "red", + "size": "M" + } + }, + { + "product_id": "prod_2", + "product_name": "Product 2", + "variant_id": "Size L", + "image_url": "https://example.com/prod2.jpg", + "quantity": 1, + "price": 50 + } + ], + "metadata": { + "custom_field_1": "custom_value_1", + "custom_field_2": 100, + "custom_field_3": true, + "custom_field_4": [ + "a", + "b", + "c" + ], + "custom_field_5": { + "nested_key": "nested_value" + }, + "checkout_url": "https://example.com/checkout", + "order_status_url": "https://example.com/order/status" + }, + "type": [ + "testType" + ], + "_update_existing_only": false, + "enable_batching": true, + "batch_size": 75, + "index": 3 + }, + "body": "{\"external_id\":\"userId5\",\"braze_id\":\"braze_id_1\",\"email\":\"email@email.com\",\"phone\":\"+14155551234\",\"user_alias\":{\"alias_name\":\"alias_name_1\",\"alias_label\":\"alias_label_1\"},\"app_id\":\"test_app_id\",\"name\":\"ecommerce.order_cancelled\",\"time\":\"2024-06-10T12:00:00.000Z\",\"properties\":{\"currency\":\"USD\",\"source\":\"test_source\",\"metadata\":{\"custom_field_1\":\"custom_value_1\",\"custom_field_2\":100,\"custom_field_3\":true,\"custom_field_4\":[\"a\",\"b\",\"c\"],\"custom_field_5\":{\"nested_key\":\"nested_value\"},\"checkout_url\":\"https://example.com/checkout\",\"order_status_url\":\"https://example.com/order/status\"},\"products\":[{\"product_id\":\"prod_1\",\"product_name\":\"Product 1\",\"variant_id\":\"Size M\",\"image_url\":\"https://example.com/prod1.jpg\",\"quantity\":2,\"price\":25,\"metadata\":{\"color\":\"red\",\"size\":\"M\"}},{\"product_id\":\"prod_2\",\"product_name\":\"Product 2\",\"variant_id\":\"Size L\",\"image_url\":\"https://example.com/prod2.jpg\",\"quantity\":1,\"price\":50}],\"total_value\":100,\"order_id\":\"order_id_1\",\"cancel_reason\":\"I didn't like it\",\"total_discounts\":10,\"discounts\":[{\"code\":\"SUMMER21\",\"amount\":5},{\"code\":\"VIPCUSTOMER\",\"amount\":5}]},\"_update_existing_only\":true}" + } + ] + + nock(settings.endpoint) + .post('/users/track', json) + .reply(200) + + const response = await testDestination.executeBatch('ecommerce', { + events, + settings, + mapping: mapping2 + }) + + expect(response).toEqual(responseJSON) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..97c3d531990 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerce/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'ecommerce' +const destinationSlug = 'Braze' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { ...event.properties, batch_size: 4, time: '2025-01-01T00:00:00Z' }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { ...event.properties, batch_size: 4, time: '2025-01-01T00:00:00Z' }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/constants.ts b/packages/destination-actions/src/destinations/braze/ecommerce/constants.ts new file mode 100644 index 00000000000..ea60e06e9e0 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerce/constants.ts @@ -0,0 +1,8 @@ +export const EVENT_NAMES = { + PRODUCT_VIEWED: 'ecommerce.product_viewed', + CHECKOUT_STARTED: 'ecommerce.checkout_started', + CART_UPDATED: 'ecommerce.cart_updated', + ORDER_PLACED: 'ecommerce.order_placed', + ORDER_CANCELLED: 'ecommerce.order_cancelled', + ORDER_REFUNDED: 'ecommerce.order_refunded' +} as const \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts new file mode 100644 index 00000000000..bce5c83f422 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerce/fields.ts @@ -0,0 +1,523 @@ +import { InputField } from '@segment/actions-core' +import { currencies } from './functions' +import { EVENT_NAMES } from './constants' + +const name: InputField = { + label: 'Ecommerce Event Name', + description: 'The name of the Braze ecommerce recommended event', + type: 'string', + required: true, + choices: [ + { label: 'Product Viewed', value: EVENT_NAMES.PRODUCT_VIEWED }, + { label: 'Cart Updated', value: EVENT_NAMES.CART_UPDATED }, + { label: 'Checkout Started', value: EVENT_NAMES.CHECKOUT_STARTED }, + { label: 'Order Placed', value: EVENT_NAMES.ORDER_PLACED }, + { label: 'Order Cancelled', value: EVENT_NAMES.ORDER_CANCELLED }, + { label: 'Order Refunded', value: EVENT_NAMES.ORDER_REFUNDED } + ] +} + +const external_id: InputField = { + label: 'External User ID', + description: 'The unique user identifier', + type: 'string', + default: { '@path': '$.userId' } +} + +const user_alias: InputField = { + label: 'User Alias Object', + description: 'A user alias object. See [the docs](https://www.braze.com/docs/api/objects_filters/user_alias_object/).', + type: 'object', + defaultObjectUI: 'keyvalue', + additionalProperties: false, + properties: { + alias_name: { + label: 'Alias Name', + type: 'string', + required: true + }, + alias_label: { + label: 'Alias Label', + type: 'string', + required: true + } + } +} + +const _update_existing_only: InputField = { + label: 'Update Existing Only', + description: 'When this flag is set to true, Braze will only update existing profiles and will not create any new ones.', + type: 'boolean', + default: false +} + +const email: InputField = { + label: 'Email', + description: 'The user email', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + } +} + +const phone: InputField = { + label: 'Phone Number', + description: "The user's phone number", + type: 'string', + allowNull: true, + default: { + '@if': { + exists: { '@path': '$.context.traits.phone' }, + then: { '@path': '$.context.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + } +} + +const braze_id: InputField ={ + label: 'Braze User Identifier', + description: 'The unique user identifier', + type: 'string', + allowNull: true, + default: { + '@if': { + exists: { '@path': '$.context.traits.braze_id' }, + then: { '@path': '$.context.traits.braze_id' }, + else: { '@path': '$.properties.braze_id' } + } + } +} + +const cancel_reason: InputField = { + label: 'Cancel Reason', + description: 'Reason why the order was cancelled.', + type: 'string', + default: {'@path': '$.properties.reason'}, + required: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.ORDER_CANCELLED + } + ] + }, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.ORDER_CANCELLED + } + ] + } +} + +const time: InputField = { + label: 'Time', + description: 'Timestamp for when the event occurred.', + type: 'string', + format: 'date-time', + required: true, + default: {'@path': '$.timestamp'} +} + +const checkout_id: InputField = { + label: 'Checkout ID', + description: 'Unique identifier for the checkout.', + type: 'string', + default: {'@path': '$.properties.checkout_id'}, + required: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CHECKOUT_STARTED + } + ] + }, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CHECKOUT_STARTED + } + ] + } +} + +const order_id: InputField = { + label: 'Order ID', + description: 'Unique identifier for the order placed.', + type: 'string', + default: {'@path': '$.properties.order_id'}, + required: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.ORDER_PLACED, EVENT_NAMES.ORDER_CANCELLED, EVENT_NAMES.ORDER_REFUNDED] + } + ] + }, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.ORDER_PLACED, EVENT_NAMES.ORDER_CANCELLED, EVENT_NAMES.ORDER_REFUNDED] + } + ] + } +} + +const cart_id: InputField = { + label: 'Cart ID', + description: 'Unique identifier for the cart. If no value is passed, Braze will determine a default value (shared across cart, checkout, and order events) for the user cart mapping.', + type: 'string', + default: {'@path': '$.properties.cart_id'}, + required: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.CART_UPDATED] + } + ] + }, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.CART_UPDATED, EVENT_NAMES.CHECKOUT_STARTED, EVENT_NAMES.ORDER_PLACED] + } + ] + } +} + +const total_value: InputField = { + label: 'Total Value', + description: 'Total monetary value of the cart.', + type: 'number', + default: { '@path': '$.properties.total'}, + required: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.CHECKOUT_STARTED, EVENT_NAMES.CART_UPDATED, EVENT_NAMES.ORDER_PLACED, EVENT_NAMES.ORDER_CANCELLED, EVENT_NAMES.ORDER_REFUNDED] + } + ] + }, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.CHECKOUT_STARTED, EVENT_NAMES.CART_UPDATED, EVENT_NAMES.ORDER_PLACED, EVENT_NAMES.ORDER_CANCELLED, EVENT_NAMES.ORDER_REFUNDED] + } + ] + } +} + +const total_discounts: InputField = { + label: 'Total Discounts', + description: 'Total amount of discounts applied to the order.', + type: 'number', + default: { '@path': '$.properties.discount'}, + required: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.ORDER_PLACED, EVENT_NAMES.ORDER_CANCELLED, EVENT_NAMES.ORDER_REFUNDED] + } + ] + }, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.ORDER_PLACED, EVENT_NAMES.ORDER_CANCELLED, EVENT_NAMES.ORDER_REFUNDED] + } + ] + } +} + +const discounts: InputField = { + label: 'Discounts', + description: 'Details of all discounts applied to the order.', + type: 'object', + multiple: true, + defaultObjectUI: 'keyvalue', + additionalProperties: false, + properties: { + code: { + label: 'Discount Code', + type: 'string', + required: true + }, + amount: { + label: 'Discount Amount', + type: 'number', + required: true + } + }, + default: { + '@arrayPath': [ + '$.properties,discount_items', + { + code: {'@path': '$.code'}, + amount: {'@path': '$.amount'} + } + ] + }, + required: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.ORDER_PLACED, EVENT_NAMES.ORDER_CANCELLED, EVENT_NAMES.ORDER_REFUNDED] + } + ] + }, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: [EVENT_NAMES.ORDER_PLACED, EVENT_NAMES.ORDER_CANCELLED, EVENT_NAMES.ORDER_REFUNDED] + } + ] + } +} + +const currency: InputField = { + label: 'Currency', + description: 'Currency code for the transaction. Defaults to USD if no value passed.', + type: 'string', + required: true, + default: {'@path': '$.properties.currency'}, + choices: currencies() +} + +const source: InputField = { + label: 'Source', + description: 'Source the event is derived from.', + type: 'string', + required: true, + default: { '@path': '$.properties.source' } +} + +export const products: InputField = { + label: 'Products', + description: 'List of products associated with the ecommerce event.', + type: 'object', + multiple: true, + additionalProperties: true, + required: true, + defaultObjectUI: 'keyvalue', + properties: { + product_id: { + label: 'Product ID', + description: 'A unique identifier for the product that was viewed. This value be can be the product ID or SKU', + type: 'string', + required: true + }, + product_name: { + label: 'Product Name', + description: 'The name of the product that was viewed.', + type: 'string', + required: true + }, + variant_id: { + label: 'Variant ID', + description: 'A unique identifier for the product variant. An example is shirt_medium_blue', + type: 'string', + required: true + }, + image_url: { + label: 'Image URL', + description: 'The URL of the product image.', + type: 'string', + format: 'uri' + }, + product_url: { + label: 'Product URL', + description: 'URL to the product page for more details.', + type: 'string', + format: 'uri' + }, + quantity: { + label: 'Quantity', + description: 'Number of units of the product in the cart.', + type: 'number', + required: true + }, + price: { + label: 'Price', + description: 'The variant unit price of the product at the time of viewing.', + type: 'number', + required: true + } + }, + default: { + '@arrayPath': [ + '$.properties.products', + { + product_id: { '@path': '$.product_id' }, + product_name: { '@path': '$.name' }, + variant_id: { '@path': '$.variant'}, + image_url: {'@path': '$.image_url'}, + product_url: {'@path': '$.url'}, + quantity: {'@path': '$.quantity'}, + price: {'@path': '$.price'} + } + ] + } +} + +export const product: InputField = { + label: 'Product', + description: 'Product details associated with the ecommerce event.', + type: 'object', + additionalProperties: false, + required: true, + defaultObjectUI: 'keyvalue', + properties: { + product_id: { + label: 'Product ID', + description: 'A unique identifier for the product that was viewed. This value be can be the product ID or SKU', + type: 'string', + required: true + }, + product_name: { + label: 'Product Name', + description: 'The name of the product that was viewed.', + type: 'string', + required: true + }, + variant_id: { + label: 'Variant ID', + description: 'A unique identifier for the product variant. An example is shirt_medium_blue', + type: 'string', + required: true + }, + image_url: { + label: 'Image URL', + description: 'The URL of the product image.', + type: 'string', + format: 'uri' + }, + product_url: { + label: 'Product URL', + description: 'URL to the product page for more details.', + type: 'string', + format: 'uri' + }, + price: { + label: 'Price', + description: 'The variant unit price of the product at the time of viewing.', + type: 'number', + required: true + } + }, + default: { + product_id: { '@path': '$.properties.product_id' }, + product_name: { '@path': '$.properties.name' }, + variant_id: { '@path': '$.properties.variant'}, + image_url: {'@path': '$.properties.image_url'}, + product_url: {'@path': '$.properties.url'}, + price: {'@path': '$.properties.price'} + } +} + +const metadata: InputField = { + label: 'Metadata', + description: 'Additional metadata for the ecommerce event.', + type: 'object', + additionalProperties: true, + defaultObjectUI: 'keyvalue' +} + +const type: InputField = { + label: 'Product Type', + description: 'TODO: description in docs ambiguous.', + type: 'string', + multiple: true, + default: { '@path': '$.properties.type' }, + required: false, + depends_on: { + match: 'any', + conditions: [ + { + fieldKey: 'name', + operator: 'is', + value: EVENT_NAMES.CART_UPDATED + } + ] + } +} + +const enable_batching: InputField = { + type: 'boolean', + label: 'Batch Data to Braze', + description: 'If true, Segment will batch events before sending to Braze’s user track endpoint.', + required: true, + default: true +} + +const batch_size: InputField ={ + label: 'Maximum Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + required: true, + default: 75, + minimum: 2, + maximum: 75 +} + +export const commonFields = { + name, + external_id, + user_alias, + _update_existing_only, + email, + phone, + braze_id, + cancel_reason, + time, + checkout_id, + order_id, + cart_id, + total_value, + total_discounts, + discounts, + currency, + source, + metadata, + type, + enable_batching, + batch_size +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts new file mode 100644 index 00000000000..fd699fc0f23 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerce/functions.ts @@ -0,0 +1,342 @@ +import { Payload } from './generated-types' +import { Payload as SingleProductPayload } from '../ecommerceSingleProduct/generated-types' +import { Settings } from '../generated-types' +import { BrazeTrackUserAPIResponse } from '../utils' +import { + JSONLikeObject, + RequestClient, + PayloadValidationError, + MultiStatusResponse +} from '@segment/actions-core' +import type { + BaseEvent, + EcommerceEvents, + EcommerceEvent, + MultiPropertyEventName, + ProductViewedEventName, + ProductViewedEvent, + MultiProductBaseEvent, + CartUpdatedEvent, + CheckoutStartedEvent, + OrderPlacedEvent, + OrderRefundedEvent, + OrderCancelledEvent, + PayloadWithIndex +} from './types' +import { EVENT_NAMES } from './constants' +import dayjs from 'dayjs' + + +export async function send(request: RequestClient, payloads: (Payload | SingleProductPayload)[], settings: Settings, isBatch: boolean) { + const msResponse = new MultiStatusResponse() + const { endpoint } = settings + const { json, payloadsWithIndexes } = getJSON(payloads, settings, isBatch, msResponse) + const url = `${endpoint}/users/track` + + const response = await request(url, { + method: 'POST', + ...(isBatch ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + json + }) + + const errors = Array.isArray(response.data.errors) ? response.data.errors : [] + + payloadsWithIndexes.forEach((payload, index) => { + + const error = errors.find(e => e.index === index) + + if(error){ + msResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'BAD_REQUEST', + errormessage: error.type, + sent: payload as object as JSONLikeObject, + body: JSON.stringify(json.events[index]) + }) + } + }) + + + return isBatch ? msResponse : response +} + +function getJSON(payloads: (Payload | SingleProductPayload)[], settings: Settings, isBatch: boolean, msResponse: MultiStatusResponse): { json: EcommerceEvents, payloadsWithIndexes: PayloadWithIndex[] } { + const payloadsWithIndexes: PayloadWithIndex[] = [ ...payloads ] + const events: EcommerceEvent[] = [] + payloadsWithIndexes.forEach((payload, index) => { + const message = validate(payload, isBatch) + if(message) { + payload.index = undefined + msResponse.setErrorResponseAtIndex( + index, + { + status: 400, + errormessage: message, + sent: payload as object as JSONLikeObject + } + ) + } + else { + // assume valid payload - we'll overwrite later if Braze responds with an error for this index + const event = getJSONItem(payload, settings) + payload.index = events.length + events.push(event) + msResponse.setSuccessResponseAtIndex(index, { + status: 200, + sent: payload as object as JSONLikeObject, + body: JSON.stringify(event) + }) + } + }) + + return { json: { events }, payloadsWithIndexes } +} + +function getJSONItem(payload: Payload | SingleProductPayload, settings: Settings): EcommerceEvent { + + const { app_id } = settings + + const { + external_id, + braze_id, + email, + phone, + user_alias, + name, + time: payloadTime, + currency, + source, + _update_existing_only, + metadata + } = payload + + const time = dayjs(payloadTime).toISOString() + + const updateExistingOnly = (() => { + if ( user_alias?.alias_label && user_alias?.alias_name ) { + return true + } + if ( typeof _update_existing_only === 'boolean' ) { + return _update_existing_only + } + return undefined + })() + + const baseEvent: BaseEvent = { + ...(external_id? { external_id } : {} ), + ...(braze_id? { braze_id } : {} ), + ...(email? { email } : {} ), + ...(phone? { phone } : {} ), + ...(user_alias? { user_alias } : {} ), + ...(app_id ? { app_id } : {} ), + name: name as ProductViewedEventName | MultiPropertyEventName, + time, + properties: { + currency, + source, + ...(metadata ? { metadata } : {}) + }, + ...(typeof updateExistingOnly === 'boolean' ? { _update_existing_only: updateExistingOnly } : {} ) + } + + switch(name) { + case EVENT_NAMES.PRODUCT_VIEWED: { + const { + product, + type + } = payload as SingleProductPayload + + const event: ProductViewedEvent = { + ...baseEvent, + name: EVENT_NAMES.PRODUCT_VIEWED, + properties: { + ...baseEvent.properties, + ...product, + type + } + } + return event + } + case EVENT_NAMES.CART_UPDATED: + case EVENT_NAMES.CHECKOUT_STARTED: + case EVENT_NAMES.ORDER_PLACED: + case EVENT_NAMES.ORDER_CANCELLED: + case EVENT_NAMES.ORDER_REFUNDED: { + const { + products, + total_value + } = payload as Payload + + const multiProductEvent: MultiProductBaseEvent = { + ...baseEvent, + name: name as MultiPropertyEventName, + properties: { + ...baseEvent.properties, + products, + total_value: total_value as number + } + } + + switch(name) { + case EVENT_NAMES.CART_UPDATED: { + const { cart_id } = payload + + const event: CartUpdatedEvent = { + ...multiProductEvent, + name: EVENT_NAMES.CART_UPDATED, + properties: { + ...multiProductEvent.properties, + cart_id: cart_id as string + } + } + return event + } + + case EVENT_NAMES.CHECKOUT_STARTED: { + const { + checkout_id, + cart_id, + metadata + } = payload + + const event: CheckoutStartedEvent = { + ...multiProductEvent, + name: EVENT_NAMES.CHECKOUT_STARTED, + properties: { + ...multiProductEvent.properties, + checkout_id: checkout_id as string, + ...(cart_id ? { cart_id } : {}), + ...(metadata ? { metadata } : {}) + } + } + return event + } + + case EVENT_NAMES.ORDER_PLACED: { + const { + order_id, + cart_id, + total_discounts, + discounts, + metadata + } = payload + + const event: OrderPlacedEvent = { + ...multiProductEvent, + name: EVENT_NAMES.ORDER_PLACED, + properties: { + ...multiProductEvent.properties, + order_id: order_id as string, + ...(typeof total_discounts === 'number' ? { total_discounts } : {}), + ...(discounts ? { discounts } : {}), + ...(cart_id ? { cart_id } : {}), + ...(metadata ? { metadata } : {}) + } + } + return event + } + + case EVENT_NAMES.ORDER_REFUNDED: { + const { + order_id, + total_discounts, + discounts, + metadata + } = payload + + const event: OrderRefundedEvent = { + ...multiProductEvent, + name: EVENT_NAMES.ORDER_REFUNDED, + properties: { + ...multiProductEvent.properties, + order_id: order_id as string, + ...(typeof total_discounts === 'number' ? { total_discounts } : {}), + ...(discounts ? { discounts } : {}), + ...(metadata ? { metadata } : {}) + } + } + return event + } + + case EVENT_NAMES.ORDER_CANCELLED: { + const { + order_id, + cancel_reason, + total_discounts, + discounts, + metadata + } = payload + + const event: OrderCancelledEvent = { + ...multiProductEvent, + name: EVENT_NAMES.ORDER_CANCELLED, + properties: { + ...multiProductEvent.properties, + order_id: order_id as string, + cancel_reason: cancel_reason as string, + ...(typeof total_discounts === 'number' ? { total_discounts } : {}), + ...(discounts ? { discounts } : {}), + ...(metadata ? { metadata } : {}) + } + } + return event + } + + default: { + throw new PayloadValidationError(`Unsupported event name: ${name}`) + } + } + } + default: { + throw new PayloadValidationError(`Unsupported event name: ${name}`) + } + } +} + +function validate(payload: Payload | SingleProductPayload, isBatch: boolean): string | void { + const { braze_id, user_alias, external_id, email, phone } = payload + if (!braze_id && !user_alias && !external_id && !email && !phone) { + const message = 'One of "external_id" or "user_alias" or "braze_id" or "email" or "phone" is required.' + if(!isBatch) { + throw new PayloadValidationError(message) + } + else { + return message + } + } +} + +export function currencies() { + const codes = [ + "AFN","EUR","ALL","DZD","USD","EUR","AOA","XCD","XCD","XAD","ARS","AMD", + "AWG","AUD","EUR","AZN","BSD","BHD","BDT","BBD","BYN","EUR","BZD","XOF", + "BMD","INR","BTN","BOB","BOV","USD","BAM","BWP","NOK","BRL","USD","BND", + "BGN","XOF","BIF","CVE","KHR","XAF","CAD","KYD","XAF","XAF","CLP","CLF", + "CNY","AUD","AUD","COP","COU","KMF","CDF","XAF","NZD","CRC","XOF","EUR", + "CUP","XCG","EUR","CZK","DKK","DJF","XCD","DOP","USD","EGP","SVC","USD", + "XAF","ERN","EUR","SZL","ETB","EUR","FKP","DKK","FJD","EUR","EUR","EUR", + "XPF","EUR","XAF","GMD","GEL","EUR","GHS","GIP","EUR","DKK","XCD","EUR", + "USD","GTQ","GBP","GNF","XOF","GYD","HTG","USD","AUD","EUR","HNL","HKD", + "HUF","ISK","INR","IDR","XDR","IRR","IQD","EUR","GBP","ILS","EUR","JMD", + "JPY","GBP","JOD","KZT","KES","AUD","KPW","KRW","KWD","KGS","LAK","EUR", + "LBP","LSL","ZAR","LRD","LYD","CHF","EUR","EUR","MOP","MGA","MWK","MYR", + "MVR","XOF","EUR","USD","EUR","MRU","MUR","EUR","XUA","MXN","MXV","USD", + "MDL","EUR","MNT","EUR","XCD","MAD","MZN","MMK","NAD","ZAR","AUD","NPR", + "EUR","XPF","NZD","NIO","XOF","NGN","NZD","AUD","MKD","USD","NOK","OMR", + "PKR","USD","PAB","USD","PGK","PYG","PEN","PHP","NZD","PLN","EUR","USD", + "QAR","EUR","RON","RUB","RWF","EUR","SHP","XCD","XCD","EUR","EUR","XCD", + "WST","EUR","STN","SAR","XOF","RSD","SCR","SLE","SGD","XCG","XSU","EUR", + "EUR","SBD","SOS","ZAR","SSP","EUR","LKR","SDG","SRD","NOK","SEK","CHF", + "CHE","CHW","SYP","TWD","TJS","TZS","THB","USD","XOF","NZD","TOP","TTD", + "TND","TRY","TMT","USD","AUD","UGX","UAH","AED","GBP","USD","USD","USN", + "UYU","UYI","UYW","UZS","VUV","VES","VED","VND","USD","USD","XPF","MAD", + "YER","ZMW","ZWG" + ] + + const unique = Array.from(new Set(codes)) + + return unique.map(code => ({ + label: code, + value: code + })) +} diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/generated-types.ts b/packages/destination-actions/src/destinations/braze/ecommerce/generated-types.ts new file mode 100644 index 00000000000..ceb82240046 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerce/generated-types.ts @@ -0,0 +1,130 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the Braze ecommerce recommended event + */ + name: string + /** + * The unique user identifier + */ + external_id?: string + /** + * A user alias object. See [the docs](https://www.braze.com/docs/api/objects_filters/user_alias_object/). + */ + user_alias?: { + alias_name: string + alias_label: string + } + /** + * When this flag is set to true, Braze will only update existing profiles and will not create any new ones. + */ + _update_existing_only?: boolean + /** + * The user email + */ + email?: string + /** + * The user's phone number + */ + phone?: string | null + /** + * The unique user identifier + */ + braze_id?: string | null + /** + * Reason why the order was cancelled. + */ + cancel_reason?: string + /** + * Timestamp for when the event occurred. + */ + time: string + /** + * Unique identifier for the checkout. + */ + checkout_id?: string + /** + * Unique identifier for the order placed. + */ + order_id?: string + /** + * Unique identifier for the cart. If no value is passed, Braze will determine a default value (shared across cart, checkout, and order events) for the user cart mapping. + */ + cart_id?: string + /** + * Total monetary value of the cart. + */ + total_value?: number + /** + * Total amount of discounts applied to the order. + */ + total_discounts?: number + /** + * Details of all discounts applied to the order. + */ + discounts?: { + code: string + amount: number + }[] + /** + * Currency code for the transaction. Defaults to USD if no value passed. + */ + currency: string + /** + * Source the event is derived from. + */ + source: string + /** + * Additional metadata for the ecommerce event. + */ + metadata?: { + [k: string]: unknown + } + /** + * TODO: description in docs ambiguous. + */ + type?: string[] + /** + * If true, Segment will batch events before sending to Braze’s user track endpoint. + */ + enable_batching: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size: number + /** + * List of products associated with the ecommerce event. + */ + products: { + /** + * A unique identifier for the product that was viewed. This value be can be the product ID or SKU + */ + product_id: string + /** + * The name of the product that was viewed. + */ + product_name: string + /** + * A unique identifier for the product variant. An example is shirt_medium_blue + */ + variant_id: string + /** + * The URL of the product image. + */ + image_url?: string + /** + * URL to the product page for more details. + */ + product_url?: string + /** + * Number of units of the product in the cart. + */ + quantity: number + /** + * The variant unit price of the product at the time of viewing. + */ + price: number + [k: string]: unknown + }[] +} diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/index.ts b/packages/destination-actions/src/destinations/braze/ecommerce/index.ts new file mode 100644 index 00000000000..3f56c3cf406 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerce/index.ts @@ -0,0 +1,26 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { + commonFields, + products, +} from './fields' +import { send } from './functions' + + +const action: ActionDefinition = { + title: 'Ecommerce Event (multi product)', + description: 'Send a multi product ecommerce event to Braze', + fields: { + ...commonFields, + products + }, + perform: async (request, {payload, settings}) => { + return await send(request, [payload], settings, false) + }, + performBatch: async (request, {payload, settings}) => { + return await send(request, payload, settings, true) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/braze/ecommerce/types.ts b/packages/destination-actions/src/destinations/braze/ecommerce/types.ts new file mode 100644 index 00000000000..d4f63b33a20 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerce/types.ts @@ -0,0 +1,151 @@ +import { EVENT_NAMES } from './constants' +import { Payload } from './generated-types' +import { Payload as SingleProductPayload } from '../ecommerceSingleProduct/generated-types' + +export type PayloadWithIndex = (Payload | SingleProductPayload) & { + index?: number +} + +export type ProductViewedEventName = typeof EVENT_NAMES.PRODUCT_VIEWED +export type CheckoutStartedEventName = typeof EVENT_NAMES.CHECKOUT_STARTED +export type CartUpdatedEventName = typeof EVENT_NAMES.CART_UPDATED +export type OrderPlacedEventName = typeof EVENT_NAMES.ORDER_PLACED +export type OrderCancelledEventName = typeof EVENT_NAMES.ORDER_CANCELLED +export type OrderRefundedEventName = typeof EVENT_NAMES.ORDER_REFUNDED + +export type MultiPropertyEventName = + CheckoutStartedEventName | + CartUpdatedEventName | + OrderPlacedEventName | + OrderCancelledEventName | + OrderRefundedEventName + +export interface EcommerceEvents { + events: Array +} + +export type EcommerceEvent = + | ProductViewedEvent + | CartUpdatedEvent + | CheckoutStartedEvent + | OrderPlacedEvent + | OrderRefundedEvent + | OrderCancelledEvent + +export interface BaseEvent { + external_id?: string + braze_id?: string + email?: string + phone?: string + user_alias?: { + alias_name: string + alias_label: string + }, + app_id?: string + name: ProductViewedEventName | MultiPropertyEventName + time: string + properties: { + currency: string + source: string + metadata?: { + [key: string]: unknown + } + }, + _update_existing_only?: boolean +} + +export interface ProductViewedEvent extends BaseEvent { + name: ProductViewedEventName + properties: BaseEvent['properties'] & BaseProduct & { + type?: Array + } +} + +export interface MultiProductBaseEvent extends BaseEvent { + name: MultiPropertyEventName + properties: BaseEvent['properties'] & { + total_value: number + products: Array + } +} + +export interface Product extends BaseProduct { + quantity: number + metadata?: { + [key: string]: unknown + } +} + +export interface BaseProduct { + product_id: string + product_name: string + variant_id: string + price: number + image_url?: string + product_url?: string +} + +export interface CartUpdatedEvent extends MultiProductBaseEvent { + name: CartUpdatedEventName + properties: MultiProductBaseEvent['properties'] & { + cart_id: string + } +} + +export interface CheckoutStartedEvent extends MultiProductBaseEvent { + name: CheckoutStartedEventName + properties: MultiProductBaseEvent['properties'] & { + checkout_id: string + cart_id?: string + metadata?: BaseEvent['properties']['metadata'] & { + checkout_url?: string + } + } +} + +export interface OrderPlacedEvent extends MultiProductBaseEvent { + name: OrderPlacedEventName + properties: MultiProductBaseEvent['properties'] & { + order_id: string + cart_id?: string + total_discounts?: number + discounts?: Array<{ + code: string + amount: number + }> + metadata?: BaseEvent['properties']['metadata'] & { + order_status_url?: string + } + } +} + +export interface OrderRefundedEvent extends MultiProductBaseEvent { + name: OrderRefundedEventName + properties: MultiProductBaseEvent['properties'] & { + order_id: string + total_discounts?: number + discounts?: Array<{ + code: string + amount: number + }> + metadata?: BaseEvent['properties']['metadata'] & { + order_status_url?: string + } + } +} + +export interface OrderCancelledEvent extends MultiProductBaseEvent { + name: OrderCancelledEventName + properties: MultiProductBaseEvent['properties'] & { + order_id: string + cancel_reason: string + total_discounts?: number + discounts?: Array<{ + code: string + amount: number + }> + metadata?: BaseEvent['properties']['metadata'] & { + order_status_url?: string + } + } +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 00000000000..64a02d896f2 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Braze's ecommerceSingleProduct destination action: all fields 1`] = ` +Object { + "events": Array [ + Object { + "_update_existing_only": true, + "app_id": "Q#t9o", + "braze_id": "Q#t9o", + "email": "ritpaz@ihfizje.zm", + "external_id": "Q#t9o", + "name": "ecommerce.product_viewed", + "phone": "Q#t9o", + "properties": Object { + "currency": "XAD", + "image_url": "http://egu.ci/ritpaz", + "metadata": Object { + "testType": "Q#t9o", + }, + "price": -81870429660119.05, + "product_id": "Q#t9o", + "product_name": "Q#t9o", + "product_url": "http://egu.ci/ritpaz", + "source": "Q#t9o", + "type": Array [ + "Q#t9o", + ], + "variant_id": "Q#t9o", + }, + "time": "2083-01-27T06:50:11.564Z", + "user_alias": Object { + "alias_label": "Q#t9o", + "alias_name": "Q#t9o", + }, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's ecommerceSingleProduct destination action: required fields 1`] = ` +Object { + "events": Array [ + Object { + "app_id": "Q#t9o", + "braze_id": "Q#t9o", + "external_id": "Q#t9o", + "name": "ecommerce.product_viewed", + "properties": Object { + "currency": "XAD", + "price": -81870429660119.05, + "product_id": "Q#t9o", + "product_name": "Q#t9o", + "source": "Q#t9o", + "variant_id": "Q#t9o", + }, + "time": "2083-01-27T06:50:11.564Z", + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..e12ced6cf8c --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'ecommerceSingleProduct' +const destinationSlug = 'Braze' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { ...event.properties, batch_size: 4 }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { ...event.properties, batch_size: 4 }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/generated-types.ts b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/generated-types.ts new file mode 100644 index 00000000000..167be6b23ee --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/generated-types.ts @@ -0,0 +1,125 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the Braze ecommerce recommended event + */ + name: string + /** + * The unique user identifier + */ + external_id?: string + /** + * A user alias object. See [the docs](https://www.braze.com/docs/api/objects_filters/user_alias_object/). + */ + user_alias?: { + alias_name: string + alias_label: string + } + /** + * When this flag is set to true, Braze will only update existing profiles and will not create any new ones. + */ + _update_existing_only?: boolean + /** + * The user email + */ + email?: string + /** + * The user's phone number + */ + phone?: string | null + /** + * The unique user identifier + */ + braze_id?: string | null + /** + * Reason why the order was cancelled. + */ + cancel_reason?: string + /** + * Timestamp for when the event occurred. + */ + time: string + /** + * Unique identifier for the checkout. + */ + checkout_id?: string + /** + * Unique identifier for the order placed. + */ + order_id?: string + /** + * Unique identifier for the cart. If no value is passed, Braze will determine a default value (shared across cart, checkout, and order events) for the user cart mapping. + */ + cart_id?: string + /** + * Total monetary value of the cart. + */ + total_value?: number + /** + * Total amount of discounts applied to the order. + */ + total_discounts?: number + /** + * Details of all discounts applied to the order. + */ + discounts?: { + code: string + amount: number + }[] + /** + * Currency code for the transaction. Defaults to USD if no value passed. + */ + currency: string + /** + * Source the event is derived from. + */ + source: string + /** + * Additional metadata for the ecommerce event. + */ + metadata?: { + [k: string]: unknown + } + /** + * TODO: description in docs ambiguous. + */ + type?: string[] + /** + * If true, Segment will batch events before sending to Braze’s user track endpoint. + */ + enable_batching: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size: number + /** + * Product details associated with the ecommerce event. + */ + product: { + /** + * A unique identifier for the product that was viewed. This value be can be the product ID or SKU + */ + product_id: string + /** + * The name of the product that was viewed. + */ + product_name: string + /** + * A unique identifier for the product variant. An example is shirt_medium_blue + */ + variant_id: string + /** + * The URL of the product image. + */ + image_url?: string + /** + * URL to the product page for more details. + */ + product_url?: string + /** + * The variant unit price of the product at the time of viewing. + */ + price: number + } +} diff --git a/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/index.ts b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/index.ts new file mode 100644 index 00000000000..abde24f592b --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/ecommerceSingleProduct/index.ts @@ -0,0 +1,25 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { + commonFields, + product, +} from '../ecommerce/fields' +import { send } from '../ecommerce/functions' + +const action: ActionDefinition = { + title: 'Ecommerce Event (single product)', + description: 'Send a single product ecommerce event to Braze', + fields: { + ...commonFields, + product + }, + perform: async (request, {payload, settings}) => { + return await send(request, [payload], settings, false) + }, + performBatch: async (request, {payload, settings}) => { + return await send(request, payload, settings, true) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/braze/index.ts b/packages/destination-actions/src/destinations/braze/index.ts index e34f383838a..d522e20f8f8 100644 --- a/packages/destination-actions/src/destinations/braze/index.ts +++ b/packages/destination-actions/src/destinations/braze/index.ts @@ -1,6 +1,8 @@ import type { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' import { DEFAULT_REQUEST_TIMEOUT, defaultValues } from '@segment/actions-core' +import ecommerce from './ecommerce' +import ecommerceSingleProduct from './ecommerceSingleProduct' import createAlias from './createAlias' import createAlias2 from './createAlias2' import identifyUser from './identifyUser' @@ -13,7 +15,7 @@ import trackPurchase2 from './trackPurchase2' import updateUserProfile2 from './updateUserProfile2' import triggerCampaign from './triggerCampaign' import triggerCanvas from './triggerCanvas' - +import { EVENT_NAMES } from './ecommerce/constants' import upsertCatalogItem from './upsertCatalogItem' const destination: DestinationDefinition = { @@ -91,21 +93,78 @@ const destination: DestinationDefinition = { createAlias2, upsertCatalogItem, triggerCampaign, - triggerCanvas + triggerCanvas, + ecommerce, + ecommerceSingleProduct }, presets: [ { name: 'Track Calls', - subscribe: 'type = "track" and event != "Order Completed"', + subscribe: 'type = "track" and event != "Order Completed" and event != "Checkout Started" and event != "Order Refunded" and event != "Order Cancelled" and event != "Product Viewed"', partnerAction: 'trackEvent', mapping: defaultValues(trackEvent.fields), type: 'automatic' }, { - name: 'Order Completed Calls', + name: 'Order Placed', subscribe: 'event = "Order Completed"', - partnerAction: 'trackPurchase', - mapping: defaultValues(trackPurchase.fields), + partnerAction: 'ecommerce', + mapping: { + ...defaultValues(ecommerce.fields), + name: EVENT_NAMES.ORDER_PLACED, + metadata: { + order_status_url: { '@path': '$.properties.order_status_url' } + } + }, + type: 'automatic' + }, + { + name: 'Checkout Started', + subscribe: 'event = "Checkout Started"', + partnerAction: 'ecommerce', + mapping: { + ...defaultValues(ecommerce.fields), + name: EVENT_NAMES.CHECKOUT_STARTED, + metadata: { + checkout_url: { '@path': '$.properties.checkout_url' } + } + }, + type: 'automatic' + }, + { + name: 'Order Refunded', + subscribe: 'event = "Order Refunded"', + partnerAction: 'ecommerce', + mapping: { + ...defaultValues(ecommerce.fields), + name: EVENT_NAMES.ORDER_REFUNDED, + metadata: { + order_status_url: { '@path': '$.properties.order_status_url' } + } + }, + type: 'automatic' + }, + { + name: 'Order Cancelled', + subscribe: 'event = "Order Cancelled"', + partnerAction: 'ecommerce', + mapping: { + ...defaultValues(ecommerce.fields), + name: EVENT_NAMES.ORDER_CANCELLED, + metadata: { + order_status_url: { '@path': '$.properties.order_status_url' } + } + }, + type: 'automatic' + }, + { + name: 'Product Viewed', + subscribe: 'event = "Product Viewed"', + partnerAction: 'ecommerceSingleProduct', + mapping: { + ...defaultValues(ecommerceSingleProduct.fields), + name: EVENT_NAMES.PRODUCT_VIEWED + }, type: 'automatic' }, {