diff --git a/packages/destination-actions/src/destinations/taguchi/__tests__/index.test.ts b/packages/destination-actions/src/destinations/taguchi/__tests__/index.test.ts index 12b31f0a04c..183fa63aa01 100644 --- a/packages/destination-actions/src/destinations/taguchi/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/taguchi/__tests__/index.test.ts @@ -1,18 +1,98 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestIntegration } from '@segment/actions-core' import Definition from '../index' const testDestination = createTestIntegration(Definition) describe('Taguchi', () => { + beforeEach(() => { + nock.cleanAll() + }) + describe('testAuthentication', () => { - it('should validate authentication inputs', async () => { - nock('https://your.destination.endpoint').get('*').reply(200, {}) + it('should validate authentication with correct credentials', async () => { + // Mock the test endpoint (URL with /prod replaced by /test) + nock('https://api.taguchi.com.au') + .post('/test/subscriber') + .reply(200, [ + { + code: 200, + name: 'Success', + description: 'Authentication successful' + } + ]) - // This should match your authentication.fields - const authData = {} + const authData = { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au/prod', + organizationId: '123' + } await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() }) + + it('should handle authentication failure', async () => { + // Mock authentication failure + nock('https://api.taguchi.com.au').post('/test/subscriber').reply(401, { + code: 401, + name: 'Unauthorized', + description: 'Invalid API key' + }) + + const authData = { + apiKey: 'invalid-api-key', + integrationURL: 'https://api.taguchi.com.au/prod', + organizationId: '123' + } + + await expect(testDestination.testAuthentication(authData)).rejects.toThrowError() + }) + + it('should handle invalid integration URL', async () => { + // Mock network error for invalid URL + nock('https://invalid.endpoint.com') + .post('/test/subscriber') + .replyWithError('getaddrinfo ENOTFOUND invalid.endpoint.com') + + const authData = { + apiKey: 'test-api-key', + integrationURL: 'https://invalid.endpoint.com/prod', + organizationId: '123' + } + + await expect(testDestination.testAuthentication(authData)).rejects.toThrowError() + }) + + it('should replace /prod with /test in URL', async () => { + // Verify that the test endpoint is called correctly + const scope = nock('https://api.taguchi.com.au') + .post('/test/subscriber', (body) => { + // Verify the request body structure + expect(Array.isArray(body)).toBe(true) + expect(body[0]).toHaveProperty('profile') + expect(body[0].profile).toHaveProperty('organizationId', 123) + expect(body[0].profile).toHaveProperty('ref', 'test-connection') + expect(body[0].profile).toHaveProperty('firstname', 'Test') + return true + }) + .reply(200, [ + { + code: 200, + name: 'Success', + description: 'Test connection successful' + } + ]) + + const authData = { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au/prod', + organizationId: '123' + } + + await testDestination.testAuthentication(authData) + + // Verify that the correct endpoint was called + expect(scope.isDone()).toBe(true) + }) }) }) diff --git a/packages/destination-actions/src/destinations/taguchi/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/taguchi/__tests__/snapshot.test.ts index 58fb8bc96cf..ac3c7d8750c 100644 --- a/packages/destination-actions/src/destinations/taguchi/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/taguchi/__tests__/snapshot.test.ts @@ -1,77 +1,202 @@ +import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import { generateTestData } from '../../../lib/test-data' import destination from '../index' -import nock from 'nock' const testDestination = createTestIntegration(destination) -const destinationSlug = 'actions-taguchi' -describe(`Testing snapshot for ${destinationSlug} destination:`, () => { - for (const actionSlug in destination.actions) { - it(`${actionSlug} action - required fields`, async () => { - const seedName = `${destinationSlug}#${actionSlug}` - const action = destination.actions[actionSlug] - const [eventData, settingsData] = generateTestData(seedName, destination, action, true) +describe('Taguchi Destination Snapshots', () => { + beforeEach(() => { + nock.cleanAll() + }) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) + describe('syncAudience action', () => { + it('should match snapshot with required fields', async () => { + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [{ code: 200, name: 'Success', description: 'Subscriber processed' }]) const event = createTestEvent({ - properties: eventData + type: 'identify', + userId: 'test-user-123', + traits: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe' + } }) - const responses = await testDestination.testAction(actionSlug, { - event: event, - mapping: event.properties, - settings: settingsData, - auth: undefined + const { data } = await testDestination.testAction('syncAudience', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + identifiers: { + ref: event.userId, + email: event.traits?.email + }, + traits: { + firstname: event.traits?.first_name, + lastname: event.traits?.last_name + }, + timestamp: event.timestamp + } }) - const request = responses[0].request - const rawBody = await request.text() + expect(data).toMatchSnapshot() + }) - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + it('should match snapshot with all fields', async () => { + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [{ code: 200, name: 'Success', description: 'Subscriber processed' }]) - expect(request.headers).toMatchSnapshot() - }) + const event = createTestEvent({ + type: 'identify', + userId: 'test-user-123', + traits: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + title: 'Mr', + birthday: '1990-01-01T00:00:00.000Z', + street: '123 Main St', + city: 'Test City', + state: 'Test State', + country: 'Test Country', + postal_code: '12345', + phone: '555-1234', + gender: 'Male' + } + }) + + const { data } = await testDestination.testAction('syncAudience', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + identifiers: { + ref: event.userId, + email: event.traits?.email + }, + traits: { + title: event.traits?.title, + firstname: event.traits?.first_name, + lastname: event.traits?.last_name, + dob: event.traits?.birthday, + address: event.traits?.street, + suburb: event.traits?.city, + state: event.traits?.state, + country: event.traits?.country, + postcode: event.traits?.postal_code, + phone: event.traits?.phone, + gender: event.traits?.gender + }, + subscribeLists: ['123', '456'], + unsubscribeLists: ['789'], + timestamp: event.timestamp + } + }) - it(`${actionSlug} action - all fields`, async () => { - const seedName = `${destinationSlug}#${actionSlug}` - const action = destination.actions[actionSlug] - const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + expect(data).toMatchSnapshot() + }) + }) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) + describe('syncEvent action', () => { + it('should match snapshot with required fields', async () => { + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [{ code: 200, name: 'Success', description: 'Event processed' }]) const event = createTestEvent({ - properties: eventData + type: 'track', + event: 'Order Completed', + userId: 'test-user-123', + properties: { + total: 123.5, + products: [{ sku: '1290W', price: 123.5 }], + email: 'test@example.com' + } + }) + + const { data } = await testDestination.testAction('syncEvent', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + target: { + ref: event.userId, + email: event.properties?.email + }, + eventType: 'p', + eventData: { + total: event.properties?.total, + products: event.properties?.products + } + } }) - const responses = await testDestination.testAction(actionSlug, { - event: event, - mapping: event.properties, - settings: settingsData, - auth: undefined + expect(data).toMatchSnapshot() + }) + + it('should match snapshot with all fields', async () => { + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [{ code: 200, name: 'Success', description: 'Event processed' }]) + + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + userId: 'test-user-123', + properties: { + total: 123.5, + products: [ + { + sku: '1290W', + price: 123.5, + quantity: 1, + name: 'Test Product', + category: 'Electronics' + } + ], + currency: 'USD', + order_id: 'order-123', + email: 'test@example.com' + } }) - const request = responses[0].request - const rawBody = await request.text() + const { data } = await testDestination.testAction('syncEvent', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + target: { + ref: event.userId, + email: event.properties?.email + }, + isTest: false, + eventType: 'p', + eventData: { + total: event.properties?.total, + products: event.properties?.products, + currency: event.properties?.currency, + order_id: event.properties?.order_id + } + } + }) - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + expect(data).toMatchSnapshot() }) - } + }) }) diff --git a/packages/destination-actions/src/destinations/taguchi/index.ts b/packages/destination-actions/src/destinations/taguchi/index.ts index 601632a87c7..33bbeb13d5b 100644 --- a/packages/destination-actions/src/destinations/taguchi/index.ts +++ b/packages/destination-actions/src/destinations/taguchi/index.ts @@ -1,6 +1,7 @@ import { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' import syncAudience from './syncAudience' +import syncEvent from './syncEvent' const destination: DestinationDefinition = { name: 'Taguchi', @@ -12,15 +13,16 @@ const destination: DestinationDefinition = { fields: { apiKey: { label: 'API Key', - description: 'Taguchi API Key used to authenticate requests to the Taguchi API.', + description: 'Taguchi API Key used to authenticate requests to the Taguchi API.', type: 'string', required: true }, integrationURL: { label: 'Integration URL', - description: "The Taguchi URL Segment will send data to. This should be created in the Taguchi User Interface by navigating to 'Taguchi Integrations' then 'Integrations Setup.'", + description: + "The Taguchi URL Segment will send data to. This should be created in the Taguchi User Interface by navigating to 'Taguchi Integrations' then 'Integrations Setup.'", type: 'string', - required: true + required: true }, organizationId: { label: 'Organization ID', @@ -29,19 +31,35 @@ const destination: DestinationDefinition = { required: true } }, - testAuthentication: (request, {settings}) => { - // Return a request that tests/validates the user's credentials. - // If you do not have a way to validate the authentication fields safely, - // you can remove the `testAuthentication` function, though discouraged. + testAuthentication: (request, { settings }) => { + // Replace /prod with /test in the integration URL for testing + const testUrl = settings.integrationURL.replace('/prod', '/test') + return request(`${testUrl}/subscriber`, { + method: 'POST', + json: [ + { + profile: { + organizationId: Number(settings.organizationId), + ref: 'test-connection', + firstname: 'Test' + } + } + ], + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${settings.apiKey}` + } + }) } }, - onDelete: async (request, { settings, payload }) => { + onDelete: async (_request, { settings: _settings, payload: _payload }) => { // Return a request that performs a GDPR delete for the provided Segment userId or anonymousId // provided in the payload. If your destination does not support GDPR deletion you should not // implement this function and should remove it completely. }, actions: { - syncAudience + syncAudience, + syncEvent } } diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/index.test.ts index 74c56c9e42f..8b302baff21 100644 --- a/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/index.test.ts @@ -5,5 +5,237 @@ import Destination from '../../index' const testDestination = createTestIntegration(Destination) describe('Taguchi.syncAudience', () => { - // TODO: Test your action + beforeEach(() => { + nock.cleanAll() + }) + + it('should work with required fields', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'test-user-123', + traits: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe' + } + }) + + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [ + { + code: 200, + name: 'Success', + description: 'Subscriber processed successfully' + } + ]) + + const responses = await testDestination.testAction('syncAudience', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + identifiers: { + ref: event.userId, + email: event.traits?.email + }, + traits: { + firstname: event.traits?.first_name, + lastname: event.traits?.last_name + }, + timestamp: event.timestamp + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should handle batch requests', async () => { + const events = [ + createTestEvent({ + type: 'identify', + userId: 'user-1', + traits: { + email: 'user1@example.com', + first_name: 'John', + last_name: 'Doe' + } + }), + createTestEvent({ + type: 'identify', + userId: 'user-2', + traits: { + email: 'user2@example.com', + first_name: 'Jane', + last_name: 'Smith' + } + }) + ] + + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [ + { code: 200, name: 'Success', description: 'User 1 processed' }, + { code: 200, name: 'Success', description: 'User 2 processed' } + ]) + + const responses = await testDestination.testBatchAction('syncAudience', { + events, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + identifiers: { + ref: { '@path': '$.userId' }, + email: { '@path': '$.traits.email' } + }, + traits: { + firstname: { '@path': '$.traits.first_name' }, + lastname: { '@path': '$.traits.last_name' } + }, + timestamp: { '@path': '$.timestamp' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should validate required identifiers', async () => { + const event = createTestEvent({ + type: 'identify', + traits: { + first_name: 'John', + last_name: 'Doe' + } + }) + + await expect( + testDestination.testAction('syncAudience', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + identifiers: {}, + traits: { + firstname: event.traits?.first_name as string, + lastname: event.traits?.last_name as string + }, + timestamp: event.timestamp + } + }) + ).rejects.toThrowError('At least one identifier is required.') + }) + + it('should work with list subscriptions', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'test-user-123', + traits: { + email: 'test@example.com' + } + }) + + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [ + { + code: 200, + name: 'Success', + description: 'Subscriber processed successfully' + } + ]) + + const responses = await testDestination.testAction('syncAudience', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + identifiers: { + ref: event.userId as string, + email: event.traits?.email as string + }, + subscribeLists: ['123', '456'], + unsubscribeLists: ['789'], + timestamp: event.timestamp + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should work with all traits fields', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'test-user-123', + traits: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + title: 'Mr', + birthday: '1990-01-01T00:00:00.000Z', + street: '123 Main St', + city: 'Test City', + state: 'Test State', + country: 'Test Country', + postal_code: '12345', + phone: '555-1234', + gender: 'Male' + } + }) + + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [ + { + code: 200, + name: 'Success', + description: 'Subscriber processed successfully' + } + ]) + + const responses = await testDestination.testAction('syncAudience', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + identifiers: { + ref: event.userId as string, + email: event.traits?.email as string + }, + traits: { + title: event.traits?.title as string, + firstname: event.traits?.first_name as string, + lastname: event.traits?.last_name as string, + dob: event.traits?.birthday as string, + address: event.traits?.street as string, + suburb: event.traits?.city as string, + state: event.traits?.state as string, + country: event.traits?.country as string, + postcode: event.traits?.postal_code as string, + gender: event.traits?.gender as string + }, + timestamp: event.timestamp + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/snapshot.test.ts index 784769d8a1d..a06f4a8c0a8 100644 --- a/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/snapshot.test.ts @@ -23,8 +23,19 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const responses = await testDestination.testAction(actionSlug, { event: event, - mapping: event.properties, - settings: settingsData, + mapping: { + ...event.properties, + identifiers: { + ref: 'test-user-123', + email: 'test@example.com' + } + }, + settings: { + ...settingsData, + integrationURL: 'https://api.taguchi.com.au', + apiKey: 'test-api-key', + organizationId: '123' + }, auth: undefined }) @@ -56,8 +67,19 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const responses = await testDestination.testAction(actionSlug, { event: event, - mapping: event.properties, - settings: settingsData, + mapping: { + ...event.properties, + identifiers: { + ref: 'test-user-123', + email: 'test@example.com' + } + }, + settings: { + ...settingsData, + integrationURL: 'https://api.taguchi.com.au', + apiKey: 'test-api-key', + organizationId: '123' + }, auth: undefined }) diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/generated-types.ts index 75de8970075..cd209b22005 100644 --- a/packages/destination-actions/src/destinations/taguchi/syncAudience/generated-types.ts +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/generated-types.ts @@ -13,14 +13,6 @@ export interface Payload { * Email address of the Subscriber. */ email?: string - /** - * Phone number of the Subscriber. - */ - phone?: string - /** - * The internal Taguchi ID of the Subscriber. usually not visible ourside the Taguchi platform. - */ - id?: number } /** * Standard traits for the Subscriber. All text fields. No specific formats for any of them. @@ -54,6 +46,10 @@ export interface Payload { * Tertiary address line for the Subscriber. */ address3?: string + /** + * Phone number of the Subscriber. + */ + phone?: string /** * Suburb of the Subscriber. */ @@ -84,6 +80,8 @@ export interface Payload { * Array or comma delimited list of Taguchi List IDs to unsubscribe the Subscriber from. */ unsubscribeLists?: string[] - + /** + * The timestamp of the event in ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ). Defaults to the current time if not provided. + */ timestamp: string } diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/index.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/index.ts index 3b924cf3cb9..4a9c24b95d5 100644 --- a/packages/destination-actions/src/destinations/taguchi/syncAudience/index.ts +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/index.ts @@ -9,7 +9,8 @@ const action: ActionDefinition = { fields: { identifiers: { label: 'Subscriber Identifiers', - description: 'At least one identifier is required. Any identifiers sent will then become required for fugure updates to that Subscriber.', + description: + 'At least one identifier is required. Any identifiers sent will then become required for fugure updates to that Subscriber.', type: 'object', required: true, properties: { @@ -23,17 +24,11 @@ const action: ActionDefinition = { description: 'Email address of the Subscriber.', type: 'string', format: 'email' - }, - phone: { - label: 'Phone Number', - description: 'Phone number of the Subscriber.', - type: 'string' - }, - id: { - label: 'ID', - description: 'The internal Taguchi ID of the Subscriber. usually not visible ourside the Taguchi platform.', - type: 'integer' - }, + } + }, + default: { + ref: { '@path': '$.userId' }, + email: { '@path': '$.email' } } }, traits: { @@ -63,30 +58,34 @@ const action: ActionDefinition = { }, dob: { label: 'Date of Birth', - description: - "Date of birth of the Subscriber in ISO 8601 format (YYYY-MM-DD).", + description: 'Date of birth of the Subscriber in ISO 8601 format (YYYY-MM-DD).', type: 'string', format: 'date-time', required: false }, address: { label: 'Address Line 1', - description: - "Primary address line for the Subscriber.", + description: 'Primary address line for the Subscriber.', type: 'string', required: false }, - address2:{ - label:'Address Line 2', - description:'Secondary address line for the Subscriber.', - type:'string', - required:false + address2: { + label: 'Address Line 2', + description: 'Secondary address line for the Subscriber.', + type: 'string', + required: false + }, + address3: { + label: 'Address Line 3', + description: 'Tertiary address line for the Subscriber.', + type: 'string', + required: false }, - address3:{ - label:'Address Line 3', - description:'Tertiary address line for the Subscriber.', - type:'string', - required:false + phone: { + label: 'Phone Number', + description: 'Phone number of the Subscriber.', + type: 'string', + required: false }, suburb: { label: 'Suburb', @@ -131,6 +130,8 @@ const action: ActionDefinition = { state: { '@path': '$.traits.state' }, country: { '@path': '$.traits.country' }, postcode: { '@path': '$.traits.postal_code' }, + phone: { '@path': '$.traits.phone' }, + gender: { '@path': '$.traits.gender' } } }, subscribeLists: { @@ -147,14 +148,15 @@ const action: ActionDefinition = { }, timestamp: { label: 'Timestamp', - description: 'The timestamp of the event in ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ). Defaults to the current time if not provided.', + description: + 'The timestamp of the event in ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ). Defaults to the current time if not provided.', type: 'string', format: 'date-time', required: true, default: { '@path': '$.timestamp' } } }, - perform: async (request, {payload, settings}) => { + perform: async (request, { payload, settings }) => { await send(request, [payload], settings, false) }, performBatch: async (request, { payload, settings }) => { diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/types.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/types.ts index f8bb8266e30..4292dc6b4f0 100644 --- a/packages/destination-actions/src/destinations/taguchi/syncAudience/types.ts +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/types.ts @@ -1,48 +1,45 @@ export interface JSONItem { - profile: { - // The Taguchi ID of the organization to which this subscriber belongs. Must be an integer - organizationId: number + profile: { + // The Taguchi ID of the organization to which this subscriber belongs. Must be an integer + organizationId: number - // Customer must select which of the 4 id types below to send. That ID type then becomes required. More than one ID can be sent at a time. - ref?: string - email?: string - phone?: string - id?: number + // Customer must select which of the 4 id types below to send. That ID type then becomes required. More than one ID can be sent at a time. + ref?: string + email?: string + phone?: string + id?: number - // Standard traits. All text fields. No specific formats for any of them. - title?: string - firstname?: string - lastname?: string - dob?: string - address?: string - address2?: string - address3?: string - suburb?: string - state?: string - country?: string - postcode?: string - gender?: string + // Standard traits. All text fields. No specific formats for any of them. + title?: string + firstname?: string + lastname?: string + dob?: string + address?: string + address2?: string + address3?: string + suburb?: string + state?: string + country?: string + postcode?: string + gender?: string - - // Custom traits. Including Computed Traits. - custom?: { - [key: string]: unknown - } - - // Audience details. Optional. - lists?: { - listId: number - subscribedTimestamp?: string - unsubscribedTimestamp?: string - subscriptionOption: string | null // A customer-defined text or serialized JSON field. e.g. List description - }[] + // Custom traits. Including Computed Traits. + custom?: { + [key: string]: unknown } + + // Audience details. Optional. + lists?: { + listId: number + unsubscribedTimestamp?: string | null + }[] + } } -export type JSON = JSONItem[] +export type TaguchiJSON = JSONItem[] export type ResponseJSON = { - code: number - name: string - description: string -}[] \ No newline at end of file + code: number + name: string + description: string +}[] diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/utils.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/utils.ts index aa6232c4395..0fa95eeac86 100644 --- a/packages/destination-actions/src/destinations/taguchi/syncAudience/utils.ts +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/utils.ts @@ -1,115 +1,132 @@ import { Payload } from './generated-types' import { Settings } from '../generated-types' import { RequestClient, PayloadValidationError, MultiStatusResponse, JSONLikeObject } from '@segment/actions-core' -import { JSON, JSONItem, ResponseJSON } from './types' +import { TaguchiJSON, JSONItem, ResponseJSON } from './types' -export function validate(payloads: Payload[]): Payload[]{ - if (payloads.length === 1) { - const p = payloads[0] - if (!p.identifiers || Object.keys(p.identifiers).length === 0) { - throw new PayloadValidationError('At least one identifier is required.') - } +export function validate(payloads: Payload[]): Payload[] { + if (payloads.length === 1) { + const p = payloads[0] + if (!p.identifiers || Object.keys(p.identifiers).length === 0) { + throw new PayloadValidationError('At least one identifier is required.') } - return payloads + } + return payloads } export async function send(request: RequestClient, payloads: Payload[], settings: Settings, isBatch: boolean) { + validate(payloads) - validate(payloads) - - const json: JSON = payloads.map(payload => buildJSON(payload, settings.organizationId)) + const json: TaguchiJSON = payloads.map((payload) => buildJSON(payload, settings.organizationId)) - const response = await request(`${settings.integrationURL}/subscriber`, { - method: 'POST', - json, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${settings.apiKey}` - } - }) + const response = await request(`${settings.integrationURL}/subscriber`, { + method: 'POST', + json, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${settings.apiKey}` + } + }) - if(isBatch){ - const multiStatusResponse = new MultiStatusResponse() - response.data.forEach((res, index) => { - if (res.code >= 200 && res.code < 300) { - multiStatusResponse.setSuccessResponseAtIndex(index, { - status: res.code, - sent: json[index] as unknown as JSONLikeObject, - body: res as unknown as JSONLikeObject - }); - } else { - multiStatusResponse.setErrorResponseAtIndex(index, { - status: res.code, - sent: json[index] as unknown as JSONLikeObject, - body: res as unknown as JSONLikeObject, - errormessage: res.description - }) - } + if (isBatch) { + const multiStatusResponse = new MultiStatusResponse() + response.data.forEach((res, index) => { + if (res.code >= 200 && res.code < 300) { + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: res.code, + sent: json[index] as unknown as JSONLikeObject, + body: res as unknown as JSONLikeObject }) - return multiStatusResponse - } - - return response + } else { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: res.code, + sent: json[index] as unknown as JSONLikeObject, + body: res as unknown as JSONLikeObject, + errormessage: res.description + }) + } + }) + return multiStatusResponse + } + + return response } function buildJSON(payload: Payload, organizationId: string): JSONItem { - const { - identifiers, - traits: { - title, - firstname, - lastname, - dob, - address, - address2, - address3, - suburb, - state, - country, - postcode, - gender, - ...customTraits - } = {} - } = payload + const { + identifiers, + traits: { + title, + firstname, + lastname, + dob, + address, + address2, + address3, + suburb, + state, + country, + postcode, + gender, + ...customTraits + } = {} + } = payload - const json: JSONItem = { - profile: { - organizationId: Number(organizationId), - ...identifiers, - ...buildCustom(customTraits), - ...buildLists(payload) - } + const json: JSONItem = { + profile: { + organizationId: Number(organizationId), + // Add identifiers + ...identifiers, + // Add standard traits + ...(title && { title }), + ...(firstname && { firstname }), + ...(lastname && { lastname }), + ...(dob && { dob }), + ...(address && { address }), + ...(address2 && { address2 }), + ...(address3 && { address3 }), + ...(suburb && { suburb }), + ...(state && { state }), + ...(country && { country }), + ...(postcode && { postcode }), + ...(gender && { gender }), + // Add custom traits + ...buildCustom(customTraits), + // Add lists as proper array + ...buildLists(payload) } - return json + } + return json } -function buildCustom(customTraits: Payload['traits']): JSONItem['profile']['custom'] | undefined { - if (customTraits && Object.keys(customTraits).length === 0) { - return { - custom: customTraits - } +function buildCustom(customTraits: Payload['traits']): { custom?: Record } { + if (customTraits && Object.keys(customTraits).length > 0) { + return { + custom: customTraits } - return undefined + } + return {} } -function buildLists(payload: Payload): JSONItem['profile']['lists'] | undefined { - const lists = [] +function buildLists(payload: Payload): { lists?: Array<{ listId: number; unsubscribedTimestamp?: string | null }> } { + const lists = [] - if (payload.subscribeLists && payload.subscribeLists.length > 0) { - lists.push(...payload.subscribeLists.map(list => ({ - listId: Number(list), - subscribedTimestamp: payload.timestamp, - subscriptionOption: null - }))) - } + if (payload.subscribeLists && payload.subscribeLists.length > 0) { + lists.push( + ...payload.subscribeLists.map((list) => ({ + listId: Number(list), + unsubscribedTimestamp: null + })) + ) + } - if (payload.unsubscribeLists && payload.unsubscribeLists.length > 0) { - lists.push(...payload.unsubscribeLists.map(list => ({ - listId: Number(list), - unsubscribedTimestamp: payload.timestamp, - subscriptionOption: null - }))) - } + if (payload.unsubscribeLists && payload.unsubscribeLists.length > 0) { + lists.push( + ...payload.unsubscribeLists.map((list) => ({ + listId: Number(list), + unsubscribedTimestamp: payload.timestamp + })) + ) + } - return lists.length > 0 ? lists : undefined -} \ No newline at end of file + return lists.length > 0 ? { lists } : {} +} diff --git a/packages/destination-actions/src/destinations/taguchi/syncEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/taguchi/syncEvent/__tests__/index.test.ts new file mode 100644 index 00000000000..75fe766f52a --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncEvent/__tests__/index.test.ts @@ -0,0 +1,184 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('Taguchi.syncEvent', () => { + beforeEach(() => { + nock.cleanAll() + }) + + it('should work with required fields', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + userId: 'test-user-123', + properties: { + total: 123.5, + products: [ + { + sku: '1290W', + price: 123.5 + } + ], + email: 'test@example.com' + } + }) + + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [ + { + code: 200, + name: 'Success', + description: 'Event processed successfully' + } + ]) + + const responses = await testDestination.testAction('syncEvent', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + target: { + ref: event.userId as string, + email: event.properties?.email as string + }, + eventType: 'p', + eventData: { + total: event.properties?.total as number, + products: event.properties?.products as Array<{ sku: string; price: number }> + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should handle batch requests', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'Order Completed', + userId: 'user-1', + properties: { + total: 100.0, + products: [{ sku: 'SKU1', price: 100.0 }], + email: 'user1@example.com' + } + }), + createTestEvent({ + type: 'track', + event: 'Order Completed', + userId: 'user-2', + properties: { + total: 200.0, + products: [{ sku: 'SKU2', price: 200.0 }], + email: 'user2@example.com' + } + }) + ] + + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [ + { code: 200, name: 'Success', description: 'Event 1 processed' }, + { code: 200, name: 'Success', description: 'Event 2 processed' } + ]) + + const responses = await testDestination.testBatchAction('syncEvent', { + events, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + target: { + ref: { '@path': '$.userId' }, + email: { '@path': '$.properties.email' } + }, + eventType: 'p', + eventData: { + total: { '@path': '$.properties.total' }, + products: { '@path': '$.properties.products' } + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should validate required target identifier', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + properties: { + total: 123.5, + products: [{ sku: '1290W', price: 123.5 }] + } + }) + + await expect( + testDestination.testAction('syncEvent', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + target: {}, + eventType: 'p', + eventData: { + total: event.properties?.total as number, + products: event.properties?.products as Array<{ sku: string; price: number }> + } + } + }) + ).rejects.toThrowError('At least one target identifier is required.') + }) + + it('should work with empty event data', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Custom Event', + userId: 'test-user-123' + }) + + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [ + { + code: 200, + name: 'Success', + description: 'Event processed successfully' + } + ]) + + const responses = await testDestination.testAction('syncEvent', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + target: { + ref: event.userId as string + }, + eventType: 'p', + eventData: {} + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/taguchi/syncEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/taguchi/syncEvent/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..abe60f820d5 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncEvent/__tests__/snapshot.test.ts @@ -0,0 +1,105 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import destination from '../../index' + +const testDestination = createTestIntegration(destination) + +describe('Taguchi.syncEvent', () => { + beforeEach(() => { + nock.cleanAll() + }) + + describe('snapshot', () => { + it('should match snapshot', async () => { + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [{ code: 200, name: 'Success', description: 'Event processed' }]) + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + userId: 'test-user-123', + properties: { + total: 123.5, + products: [ + { + sku: '1290W', + price: 123.5, + quantity: 1, + name: 'Test Product', + category: 'Electronics' + } + ], + currency: 'USD', + order_id: 'order-123', + email: 'test@example.com' + } + }) + + const { data } = await testDestination.testAction('syncEvent', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + target: { + ref: event.userId, + email: event.properties?.email + }, + isTest: false, + eventType: 'p', + eventData: { + total: event.properties?.total, + products: event.properties?.products, + currency: event.properties?.currency, + order_id: event.properties?.order_id + } + } + }) + + expect(data).toMatchSnapshot() + }) + + it('should match snapshot with minimal fields', async () => { + nock('https://api.taguchi.com.au') + .post('/subscriber') + .reply(200, [{ code: 200, name: 'Success', description: 'Event processed' }]) + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + userId: 'test-user-123', + properties: { + total: 50.0, + products: [ + { + sku: 'SIMPLE-SKU', + price: 50.0 + } + ] + } + }) + + const { data } = await testDestination.testAction('syncEvent', { + event, + settings: { + apiKey: 'test-api-key', + integrationURL: 'https://api.taguchi.com.au', + organizationId: '123' + }, + mapping: { + target: { + ref: event.userId + }, + eventType: 'p', + eventData: { + total: event.properties?.total, + products: event.properties?.products + } + } + }) + + expect(data).toMatchSnapshot() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/taguchi/syncEvent/generated-types.ts b/packages/destination-actions/src/destinations/taguchi/syncEvent/generated-types.ts new file mode 100644 index 00000000000..c0dff1f29a5 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncEvent/generated-types.ts @@ -0,0 +1,75 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Target identifier for the event. At least one identifier is required. + */ + target: { + /** + * A unique identifier for the target. + */ + ref?: string + /** + * Email address of the target. + */ + email?: string + /** + * Phone number of the target. + */ + phone?: string + /** + * Numeric ID of the target. + */ + id?: number + } + /** + * Whether this is a test event. + */ + isTest?: boolean + /** + * Type of event being sent. + */ + eventType: string + /** + * Ecommerce event data including total and products. + */ + eventData?: { + /** + * Total value of the transaction. + */ + total?: number + /** + * Array of products in the transaction. + */ + products?: { + /** + * Product SKU. + */ + sku?: string + /** + * Product price. + */ + price?: number + /** + * Product quantity. + */ + quantity?: number + /** + * Product name. + */ + name?: string + /** + * Product category. + */ + category?: string + }[] + /** + * Currency code for the transaction. + */ + currency?: string + /** + * Unique identifier for the order. + */ + order_id?: string + } +} diff --git a/packages/destination-actions/src/destinations/taguchi/syncEvent/index.ts b/packages/destination-actions/src/destinations/taguchi/syncEvent/index.ts new file mode 100644 index 00000000000..3cc5b7a0de9 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncEvent/index.ts @@ -0,0 +1,138 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { send } from './utils' + +const action: ActionDefinition = { + title: 'Sync Event', + description: 'Sync ecommerce events to Taguchi.', + fields: { + target: { + label: 'Event Target', + description: 'Target identifier for the event. At least one identifier is required.', + type: 'object', + required: true, + properties: { + ref: { + label: 'Reference', + description: 'A unique identifier for the target.', + type: 'string' + }, + email: { + label: 'Email', + description: 'Email address of the target.', + type: 'string', + format: 'email' + }, + phone: { + label: 'Phone', + description: 'Phone number of the target.', + type: 'string' + }, + id: { + label: 'ID', + description: 'Numeric ID of the target.', + type: 'integer' + } + }, + default: { + ref: { '@path': '$.userId' }, + email: { '@path': '$.properties.email' } + } + }, + isTest: { + label: 'Is Test Event', + description: 'Whether this is a test event.', + type: 'boolean', + required: false, + default: false + }, + eventType: { + label: 'Event Type', + description: 'Type of event being sent.', + type: 'string', + required: true, + default: 'p' + }, + eventData: { + label: 'Event Data', + description: 'Ecommerce event data including total and products.', + type: 'object', + required: false, + properties: { + total: { + label: 'Total', + description: 'Total value of the transaction.', + type: 'number', + required: false + }, + products: { + label: 'Products', + description: 'Array of products in the transaction.', + type: 'object', + multiple: true, + required: false, + properties: { + sku: { + label: 'SKU', + description: 'Product SKU.', + type: 'string', + required: false + }, + price: { + label: 'Price', + description: 'Product price.', + type: 'number', + required: false + }, + quantity: { + label: 'Quantity', + description: 'Product quantity.', + type: 'integer', + required: false + }, + name: { + label: 'Product Name', + description: 'Product name.', + type: 'string', + required: false + }, + category: { + label: 'Category', + description: 'Product category.', + type: 'string', + required: false + } + } + }, + currency: { + label: 'Currency', + description: 'Currency code for the transaction.', + type: 'string', + required: false + }, + order_id: { + label: 'Order ID', + description: 'Unique identifier for the order.', + type: 'string', + required: false + } + }, + additionalProperties: false, + default: { + total: { '@path': '$.properties.total' }, + products: { '@path': '$.properties.products' }, + currency: { '@path': '$.properties.currency' }, + order_id: { '@path': '$.properties.order_id' } + } + } + }, + perform: async (request, { payload, settings }) => { + await send(request, [payload], settings, false) + }, + performBatch: async (request, { payload, settings }) => { + await send(request, payload, settings, true) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/taguchi/syncEvent/types.ts b/packages/destination-actions/src/destinations/taguchi/syncEvent/types.ts new file mode 100644 index 00000000000..50b17857654 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncEvent/types.ts @@ -0,0 +1,39 @@ +export interface Product { + sku: string + price: number + quantity?: number + name?: string + category?: string +} + +export interface EventData { + total?: number + products?: Product[] + currency?: string + order_id?: string + [key: string]: unknown +} + +export interface EventTarget { + ref?: string + email?: string + phone?: string + id?: number +} + +export interface EventItem { + event: { + target: EventTarget + isTest: boolean + data: EventData + type: string + } +} + +export type TaguchiEventJSON = EventItem[] + +export type EventResponseJSON = { + code: number + name: string + description: string +}[] diff --git a/packages/destination-actions/src/destinations/taguchi/syncEvent/utils.ts b/packages/destination-actions/src/destinations/taguchi/syncEvent/utils.ts new file mode 100644 index 00000000000..3bb0a589688 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncEvent/utils.ts @@ -0,0 +1,64 @@ +import { Payload } from './generated-types' +import { Settings } from '../generated-types' +import { RequestClient, PayloadValidationError, MultiStatusResponse, JSONLikeObject } from '@segment/actions-core' +import { TaguchiEventJSON, EventItem, EventResponseJSON, EventData } from './types' + +export function validate(payloads: Payload[]): Payload[] { + for (const payload of payloads) { + if (!payload.target || Object.keys(payload.target).length === 0) { + throw new PayloadValidationError('At least one target identifier is required.') + } + } + return payloads +} + +export async function send(request: RequestClient, payloads: Payload[], settings: Settings, isBatch: boolean) { + validate(payloads) + + const json: TaguchiEventJSON = payloads.map((payload) => buildEventJSON(payload)) + + const response = await request(`${settings.integrationURL}/subscriber`, { + method: 'POST', + json, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${settings.apiKey}` + } + }) + + if (isBatch) { + const multiStatusResponse = new MultiStatusResponse() + response.data.forEach((res, index) => { + if (res.code >= 200 && res.code < 300) { + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: res.code, + sent: json[index] as unknown as JSONLikeObject, + body: res as unknown as JSONLikeObject + }) + } else { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: res.code, + sent: json[index] as unknown as JSONLikeObject, + body: res as unknown as JSONLikeObject, + errormessage: res.description + }) + } + }) + return multiStatusResponse + } + + return response +} + +function buildEventJSON(payload: Payload): EventItem { + const eventItem: EventItem = { + event: { + target: payload.target, + isTest: payload.isTest || false, + data: (payload.eventData || {}) as EventData, + type: payload.eventType || 'p' + } + } + + return eventItem +}