diff --git a/packages/destination-actions/src/destinations/altertable/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/altertable/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..590acabbf0 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,77 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-altertable destination: event action - all fields 1`] = ` +Object { + "device_id": "UM[e12E", + "distinct_id": "UM[e12E", + "environment": "UM[e12E", + "event": "UM[e12E", + "properties": Object { + "$ip": "UM[e12E", + "$lib": "UM[e12E", + "$lib_version": "UM[e12E", + "$os": "UM[e12E", + "$referer": "http://puc.pn/uzzucet", + "$url": "http://puc.pn/uzzucet", + "$user_agent": "UM[e12E", + "$utm_campaign": "UM[e12E", + "$utm_content": "UM[e12E", + "$utm_medium": "UM[e12E", + "$utm_source": "UM[e12E", + "$utm_term": "UM[e12E", + "$viewport": "-62074548073267.2x-62074548073267.2", + "testType": "UM[e12E", + }, + "timestamp": "2024-06-01T12:00:00.000Z", +} +`; + +exports[`Testing snapshot for actions-altertable destination: event action - required fields 1`] = ` +Object { + "distinct_id": "UM[e12E", + "environment": "UM[e12E", + "event": "UM[e12E", + "properties": Object { + "$lib": "altertable-segment", + "testType": "UM[e12E", + }, + "timestamp": "2024-06-01T12:00:00.000Z", +} +`; + +exports[`Testing snapshot for actions-altertable destination: identify action - all fields 1`] = ` +Object { + "device_id": "&03lz0I0LT8Hl)", + "distinct_id": "&03lz0I0LT8Hl)", + "environment": "&03lz0I0LT8Hl)", + "timestamp": "2024-06-01T12:00:00.000Z", + "traits": Object { + "$ip": "&03lz0I0LT8Hl)", + "$lib": "&03lz0I0LT8Hl)", + "$lib_version": "&03lz0I0LT8Hl)", + "$os": "&03lz0I0LT8Hl)", + "$referer": "http://wodwi.pg/alaken", + "$url": "http://wodwi.pg/alaken", + "$user_agent": "&03lz0I0LT8Hl)", + "$utm_campaign": "&03lz0I0LT8Hl)", + "$utm_content": "&03lz0I0LT8Hl)", + "$utm_medium": "&03lz0I0LT8Hl)", + "$utm_source": "&03lz0I0LT8Hl)", + "$utm_term": "&03lz0I0LT8Hl)", + "$viewport": "19449315013427.2x19449315013427.2", + "testType": "&03lz0I0LT8Hl)", + }, +} +`; + +exports[`Testing snapshot for actions-altertable destination: identify action - required fields 1`] = ` +Object { + "distinct_id": "&03lz0I0LT8Hl)", + "environment": "&03lz0I0LT8Hl)", + "timestamp": "2024-06-01T12:00:00.000Z", + "traits": Object { + "$lib": "altertable-segment", + "testType": "&03lz0I0LT8Hl)", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/altertable/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/altertable/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..3bf672dc0c --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/__tests__/snapshot.test.ts @@ -0,0 +1,83 @@ +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-altertable' + +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) + + 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, + timestamp: "2024-06-01T12:00:00.000Z" + }, + 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(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + 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, + timestamp: "2024-06-01T12:00:00.000Z" + }, + 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/altertable/event/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/altertable/event/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..09727db08e --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Altertable's event destination action: all fields 1`] = ` +Object { + "device_id": "CHXbRcxq]U", + "distinct_id": "CHXbRcxq]U", + "environment": "CHXbRcxq]U", + "event": "CHXbRcxq]U", + "properties": Object { + "$ip": "CHXbRcxq]U", + "$lib": "CHXbRcxq]U", + "$lib_version": "CHXbRcxq]U", + "$os": "CHXbRcxq]U", + "$referer": "http://no.co.uk/ketek", + "$url": "http://no.co.uk/ketek", + "$user_agent": "CHXbRcxq]U", + "$utm_campaign": "CHXbRcxq]U", + "$utm_content": "CHXbRcxq]U", + "$utm_medium": "CHXbRcxq]U", + "$utm_source": "CHXbRcxq]U", + "$utm_term": "CHXbRcxq]U", + "$viewport": "-31203495918960.64x-31203495918960.64", + "testType": "CHXbRcxq]U", + }, + "timestamp": "2024-06-01T12:00:00.000Z", +} +`; diff --git a/packages/destination-actions/src/destinations/altertable/event/__tests__/index.test.ts b/packages/destination-actions/src/destinations/altertable/event/__tests__/index.test.ts new file mode 100644 index 0000000000..d485e7575e --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/__tests__/index.test.ts @@ -0,0 +1,164 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('Altertable.event', () => { + const endpoint = 'https://api.altertable.ai' + const apiKey = 'test-api-key' + const environment = 'test-environment' + + beforeEach(() => nock.cleanAll()) + + it('should send event to Altertable', async () => { + const event = createTestEvent({ + timestamp: '2026-01-05T09:35:42.275Z', + properties: { + testProperty: 'test-value' + }, + context: { + device: { + id: 'test-device-id' + } + } + }) + + nock(endpoint).post('/track').reply(200, {}) + + const responses = await testDestination.testAction('event', { + event, + useDefaultMappings: true, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const payload = JSON.parse(responses[0].options.body as string) + expect(payload).toMatchObject({ + anonymous_id: "anonId1234", + device_id: "test-device-id", + distinct_id: "user1234", + environment: "test-environment", + event: "Test Event", + properties: { + $lib: "altertable-segment", + testProperty: "test-value" + }, + timestamp: "2026-01-05T09:35:42.275Z" + }) + }) + + it('should throw error if required fields are missing', async () => { + const event = createTestEvent({ + properties: { + testProperty: 'test-value' + } + }) + + await expect( + testDestination.testAction('event', { + event, + mapping: { + // Only provide properties, missing required event, userId, and timestamp + properties: { + '@path': '$.properties' + } + }, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + ).rejects.toThrow() + }) + + it('should correctly map Segment context properties to Altertable format', async () => { + const event: SegmentEvent = { + type: 'track', + properties: { + customProp: 'custom-value' + }, + context: { + ip: '192.168.1.1', + page: { + url: 'https://example.com/page', + referrer: 'https://google.com' + }, + os: { + name: 'iOS' + }, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)', + screen: { + width: 375, + height: 667 + }, + campaign: { + name: 'summer-sale', + source: 'google', + medium: 'cpc', + term: 'shoes', + content: 'ad-variant-a' + }, + library: { + name: 'analytics.js', + version: '3.0.0' + }, + device: { + id: 'device-123' + } + } + } + + nock(endpoint).post('/track').reply(200, {}) + + const responses = await testDestination.testAction('event', { + event, + useDefaultMappings: true, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const payload = JSON.parse(responses[0].options.body as string) + + // Verify context mappings + expect(payload.properties).toMatchObject({ + // Direct context mappings + $ip: '192.168.1.1', + $url: 'https://example.com/page', + $referer: 'https://google.com', + $os: 'iOS', + $user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)', + + // Screen dimensions -> viewport + $viewport: '375x667', + + // Campaign mappings + $utm_campaign: 'summer-sale', + $utm_source: 'google', + $utm_medium: 'cpc', + $utm_term: 'shoes', + $utm_content: 'ad-variant-a', + + // Library mappings + $lib: 'analytics.js', + $lib_version: '3.0.0', + + // Event properties should still be present + customProp: 'custom-value' + }) + + // Verify device_id is extracted correctly + expect(payload.device_id).toBe('device-123') + }) +}) diff --git a/packages/destination-actions/src/destinations/altertable/event/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/altertable/event/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..aadefaa3b2 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/__tests__/snapshot.test.ts @@ -0,0 +1,45 @@ +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 = 'event' +const destinationSlug = 'Altertable' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + 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, + timestamp: "2024-06-01T12:00:00.000Z" + }, + 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/altertable/event/generated-types.ts b/packages/destination-actions/src/destinations/altertable/event/generated-types.ts new file mode 100644 index 0000000000..81595de90a --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/generated-types.ts @@ -0,0 +1,95 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The Segment event type + */ + type: string + /** + * The name of the event to track. Only required for `track` event type. + */ + event?: string + /** + * The properties of the event + */ + properties: { + [k: string]: unknown + } + /** + * The ID of the user + */ + userId: string + /** + * The anonymous ID of the user + */ + anonymousId?: string + /** + * The context properties to send with the event + */ + context?: { + /** + * The IP address of the user + */ + ip?: string + /** + * The URL of the page + */ + url?: string + /** + * The referrer URL + */ + referrer?: string + /** + * The name of the operating system + */ + os?: string + /** + * The user agent string + */ + user_agent?: string + /** + * The UTM campaign name + */ + utm_campaign?: string + /** + * The UTM source + */ + utm_source?: string + /** + * The UTM medium + */ + utm_medium?: string + /** + * The UTM term + */ + utm_term?: string + /** + * The UTM content + */ + utm_content?: string + /** + * The width of the screen + */ + screen_width?: number + /** + * The height of the screen + */ + screen_height?: number + /** + * The name of the library sending the event + */ + library_name?: string + /** + * The version of the library sending the event + */ + library_version?: string + /** + * The device ID of the user + */ + device_id?: string + } + /** + * The timestamp of the event + */ + timestamp: string | number +} diff --git a/packages/destination-actions/src/destinations/altertable/event/index.ts b/packages/destination-actions/src/destinations/altertable/event/index.ts new file mode 100644 index 0000000000..2ddbf8a964 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/index.ts @@ -0,0 +1,67 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../fields' +import { send } from '../utils' + +const action: ActionDefinition = { + title: 'Track Event', + description: 'Track a single event in Altertable.', + defaultSubscription: 'type = "track"', + fields: { + type: { + label: 'Segment Event Type', + description: 'The Segment event type', + type: 'string', + choices: [ + { label: 'track', value: 'track' }, + { label: 'page', value: 'page' }, + { label: 'screen', value: 'screen' } + ], + required: true, + default: 'track' + }, + event: { + label: 'Event Name', + description: 'The name of the event to track. Only required for `track` event type.', + type: 'string', + default: { + '@path': '$.event' + }, + required: { + conditions: [ + { + fieldKey: 'eventType', + operator: 'is', + value: 'track' + } + ] + }, + depends_on: { + conditions: [ + { + fieldKey: 'eventType', + operator: 'is', + value: 'track' + } + ] + } + }, + properties: { + label: 'Properties', + description: 'The properties of the event', + type: 'object', + required: true, + default: { + '@path': '$.properties' + } + }, + ...commonFields + }, + perform: (request, { settings, payload }) => { + return send(request, settings, payload) + } +} + + +export default action diff --git a/packages/destination-actions/src/destinations/altertable/fields.ts b/packages/destination-actions/src/destinations/altertable/fields.ts new file mode 100644 index 0000000000..5dac9410e9 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/fields.ts @@ -0,0 +1,163 @@ +import type { InputField } from '@segment/actions-core' + +export const commonFields: Record = { + userId: { + label: 'User ID', + description: 'The ID of the user', + type: 'string', + default: { + '@path': '$.userId' + }, + required: true + }, + anonymousId: { + label: 'Anonymous ID', + description: 'The anonymous ID of the user', + type: 'string', + default: { + '@path': '$.anonymousId' + }, + required: false + }, + context: { + label: 'Context', + description: 'The context properties to send with the event', + type: 'object', + properties: { + ip: { + label: 'IP Address', + description: 'The IP address of the user', + type: 'string' + }, + url: { + label: 'Page URL', + description: 'The URL of the page', + type: 'string', + format: 'uri' + }, + referrer: { + label: 'Referrer', + description: 'The referrer URL', + type: 'string', + format: 'uri' + }, + os: { + label: 'Operating System', + description: 'The name of the operating system', + type: 'string' + }, + user_agent: { + label: 'User Agent', + description: 'The user agent string', + type: 'string' + }, + utm_campaign: { + label: 'UTM Campaign / Name', + description: 'The UTM campaign name', + type: 'string' + }, + utm_source: { + label: 'UTM Source', + description: 'The UTM source', + type: 'string' + }, + utm_medium: { + label: 'UTM Medium', + description: 'The UTM medium', + type: 'string' + }, + utm_term: { + label: 'UTM Term', + description: 'The UTM term', + type: 'string' + }, + utm_content: { + label: 'UTM Content', + description: 'The UTM content', + type: 'string' + }, + screen_width: { + label: 'Screen Width', + description: 'The width of the screen', + type: 'number' + }, + screen_height: { + label: 'Screen Height', + description: 'The height of the screen', + type: 'number' + }, + library_name: { + label: 'Library Name', + description: 'The name of the library sending the event', + type: 'string' + }, + library_version: { + label: 'Library Version', + description: 'The version of the library sending the event', + type: 'string' + }, + device_id: { + label: 'Device ID', + description: 'The device ID of the user', + type: 'string' + } + }, + default: { + ip: { + '@path': '$.context.ip' + }, + url: { + '@path': '$.context.page.url' + }, + referrer: { + '@path': '$.context.page.referrer' + }, + os: { + '@path': '$.context.os.name' + }, + user_agent: { + '@path': '$.context.userAgent' + }, + utm_campaign: { + '@path': '$.context.campaign.name' + }, + utm_source: { + '@path': '$.context.campaign.source' + }, + utm_medium: { + '@path': '$.context.campaign.medium' + }, + utm_term: { + '@path': '$.context.campaign.term' + }, + utm_content: { + '@path': '$.context.campaign.content' + }, + screen_width: { + '@path': '$.context.screen.width' + }, + screen_height: { + '@path': '$.context.screen.height' + }, + library_name: { + '@path': '$.context.library.name' + }, + library_version: { + '@path': '$.context.library.version' + }, + device_id: { + '@path': '$.context.device.id' + } + }, + required: false + }, + timestamp: { + label: 'Timestamp', + description: 'The timestamp of the event', + type: 'datetime', + default: { + '@path': '$.timestamp' + }, + required: true + } +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/altertable/generated-types.ts b/packages/destination-actions/src/destinations/altertable/generated-types.ts new file mode 100644 index 0000000000..076348627d --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Altertable API key + */ + apiKey: string + /** + * The environment to send events to + */ + environment: string + /** + * The endpoint to send events to + */ + endpoint: string +} diff --git a/packages/destination-actions/src/destinations/altertable/identify/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/altertable/identify/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..a6e1c3eda3 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Altertable's identify destination action: all fields 1`] = ` +Object { + "device_id": "p*Ezt^", + "distinct_id": "p*Ezt^", + "environment": "p*Ezt^", + "timestamp": "2024-06-01T12:00:00.000Z", + "traits": Object { + "$ip": "p*Ezt^", + "$lib": "p*Ezt^", + "$lib_version": "p*Ezt^", + "$os": "p*Ezt^", + "$referer": "http://ok.gh/evo", + "$url": "http://ok.gh/evo", + "$user_agent": "p*Ezt^", + "$utm_campaign": "p*Ezt^", + "$utm_content": "p*Ezt^", + "$utm_medium": "p*Ezt^", + "$utm_source": "p*Ezt^", + "$utm_term": "p*Ezt^", + "$viewport": "-69090444679577.6x-69090444679577.6", + "testType": "p*Ezt^", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/altertable/identify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/altertable/identify/__tests__/index.test.ts new file mode 100644 index 0000000000..d1f4837666 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/__tests__/index.test.ts @@ -0,0 +1,183 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('Altertable.identify', () => { + const endpoint = 'https://api.altertable.ai' + const apiKey = 'test-api-key' + const environment = 'test-environment' + + beforeEach(() => nock.cleanAll()) + + it('should send identify event to Altertable', async () => { + const event = createTestEvent({ + userId: 'test-user-id', + traits: { + name: 'Test User', + email: 'test@example.com', + plan: 'premium' + }, + context: { + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0', + campaign: { name: 'spring-sale', source: 'newsletter', medium: 'email', term: 'shoes', content: 'top-banner' }, + page: { url: 'https://example.com/page', referrer: 'https://example.com' } + }, + timestamp: '2024-01-01T00:00:00.000Z' + }) + + nock(endpoint).post('/identify').reply(200, {}) + + const responses = await testDestination.testAction('identify', { + event, + useDefaultMappings: true, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const payload = JSON.parse(responses[0].options.body as string) + expect(payload).toMatchObject({ + anonymous_id: "anonId1234", + distinct_id: "test-user-id", + environment: "test-environment", + timestamp: "2024-01-01T00:00:00.000Z", + traits: { + $ip: "192.168.1.1", + $lib: "altertable-segment", + $referer: "https://example.com", + $url: "https://example.com/page", + $user_agent: "Mozilla/5.0", + $utm_campaign: "spring-sale", + $utm_content: "top-banner", + $utm_medium: "email", + $utm_source: "newsletter", + $utm_term: "shoes", + email: "test@example.com", + name: "Test User", + plan: "premium", + } + }) + }) + + it('should throw error if required fields are missing', async () => { + const event = createTestEvent({ + traits: { + name: 'Test User' + } + }) + + await expect( + testDestination.testAction('identify', { + event, + mapping: { + // Only provide traits, missing required userId and timestamp + traits: { + '@path': '$.traits' + } + }, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + ).rejects.toThrow() + }) + + it('should correctly map Segment context properties to Altertable format', async () => { + const event: SegmentEvent = { + type: 'identify', + userId: 'test-user-id', + traits: { + name: 'Test User', + email: 'test@example.com', + customTrait: 'custom-value' + }, + context: { + ip: '192.168.1.1', + page: { + url: 'https://example.com/page', + referrer: 'https://google.com' + }, + os: { + name: 'iOS' + }, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)', + screen: { + width: 375, + height: 667 + }, + campaign: { + name: 'summer-sale', + source: 'google', + medium: 'cpc', + term: 'shoes', + content: 'ad-variant-a' + }, + library: { + name: 'analytics.js', + version: '3.0.0' + }, + device: { + id: 'device-123' + } + } + } + + nock(endpoint).post('/identify').reply(200, {}) + + const responses = await testDestination.testAction('identify', { + event, + useDefaultMappings: true, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const payload = JSON.parse(responses[0].options.body as string) + + // Verify context mappings are merged into traits + expect(payload.traits).toMatchObject({ + // Direct context mappings + $ip: '192.168.1.1', + $url: 'https://example.com/page', + $referer: 'https://google.com', + $os: 'iOS', + $user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)', + + // Screen dimensions -> viewport + $viewport: '375x667', + + // Campaign mappings + $utm_campaign: 'summer-sale', + $utm_source: 'google', + $utm_medium: 'cpc', + $utm_term: 'shoes', + $utm_content: 'ad-variant-a', + + // Library mappings + $lib: 'analytics.js', + $lib_version: '3.0.0', + + // User traits should still be present + name: 'Test User', + email: 'test@example.com', + customTrait: 'custom-value' + }) + + // Verify device_id is extracted correctly + expect(payload.device_id).toBe('device-123') + expect(payload.distinct_id).toBe('test-user-id') + }) +}) diff --git a/packages/destination-actions/src/destinations/altertable/identify/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/altertable/identify/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..2032947630 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/__tests__/snapshot.test.ts @@ -0,0 +1,45 @@ +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 = 'identify' +const destinationSlug = 'Altertable' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + 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, + timestamp: "2024-06-01T12:00:00.000Z" + }, + 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/altertable/identify/generated-types.ts b/packages/destination-actions/src/destinations/altertable/identify/generated-types.ts new file mode 100644 index 0000000000..75abd92e9c --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/generated-types.ts @@ -0,0 +1,91 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The Segment event type + */ + type: string + /** + * The traits of the user + */ + traits: { + [k: string]: unknown + } + /** + * The ID of the user + */ + userId: string + /** + * The anonymous ID of the user + */ + anonymousId?: string + /** + * The context properties to send with the event + */ + context?: { + /** + * The IP address of the user + */ + ip?: string + /** + * The URL of the page + */ + url?: string + /** + * The referrer URL + */ + referrer?: string + /** + * The name of the operating system + */ + os?: string + /** + * The user agent string + */ + user_agent?: string + /** + * The UTM campaign name + */ + utm_campaign?: string + /** + * The UTM source + */ + utm_source?: string + /** + * The UTM medium + */ + utm_medium?: string + /** + * The UTM term + */ + utm_term?: string + /** + * The UTM content + */ + utm_content?: string + /** + * The width of the screen + */ + screen_width?: number + /** + * The height of the screen + */ + screen_height?: number + /** + * The name of the library sending the event + */ + library_name?: string + /** + * The version of the library sending the event + */ + library_version?: string + /** + * The device ID of the user + */ + device_id?: string + } + /** + * The timestamp of the event + */ + timestamp: string | number +} diff --git a/packages/destination-actions/src/destinations/altertable/identify/index.ts b/packages/destination-actions/src/destinations/altertable/identify/index.ts new file mode 100644 index 0000000000..006d8f2db5 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/index.ts @@ -0,0 +1,37 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../fields' +import { send } from '../utils' + +const action: ActionDefinition = { + title: 'Identify', + description: 'Identify a user in Altertable.', + fields: { + type: { + label: 'Segment Event Type', + description: 'The Segment event type', + type: 'string', + choices: [ + { label: 'identify', value: 'identify' } + ], + required: true, + default: 'identify' + }, + traits: { + label: 'Traits', + description: 'The traits of the user', + type: 'object', + required: true, + default: { + '@path': '$.traits' + } + }, + ...commonFields + }, + perform: (request, { payload, settings }) => { + return send(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/altertable/index.ts b/packages/destination-actions/src/destinations/altertable/index.ts new file mode 100644 index 0000000000..c87344ecbc --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/index.ts @@ -0,0 +1,94 @@ +import { DestinationDefinition, defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' +import event from './event' +import identify from './identify' + +const destination: DestinationDefinition = { + name: 'Altertable', + slug: 'actions-altertable', + mode: 'cloud', + description: + 'Send events server-side to the [Altertable REST API](https://altertable.ai/docs/product-analytics/reference/api).', + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: 'Your Altertable API key', + type: 'password', + required: true + }, + environment: { + label: 'Environment', + description: 'The environment to send events to', + type: 'string', + required: true, + default: 'production' + }, + endpoint: { + label: 'Endpoint', + description: 'The endpoint to send events to', + type: 'string', + format: 'uri', + required: true, + default: 'https://api.altertable.ai' + } + } + }, + extendRequest: ({ settings }) => { + return { + headers: { + 'X-API-Key': settings.apiKey, + 'Content-Type': 'application/json' + } + } + }, + actions: { + event, + identify + }, + presets: [ + { + name: 'Track', + subscribe: 'type = "track"', + partnerAction: 'event', + mapping: { + ...defaultValues(event.fields), + type: 'track' + }, + type: 'automatic' + }, + { + name: 'Page View', + subscribe: 'type = "page"', + partnerAction: 'event', + mapping: { + ...defaultValues(event.fields), + type: 'page' + }, + type: 'automatic' + }, + { + name: 'Screen View', + subscribe: 'type = "screen"', + partnerAction: 'event', + mapping: { + ...defaultValues(event.fields), + type: 'screen', + }, + type: 'automatic' + }, + { + name: 'Identify', + subscribe: 'type = "identify"', + partnerAction: 'identify', + mapping: { + ...defaultValues(identify.fields), + type: 'identify' + }, + type: 'automatic' + } + ] +} + +export default destination diff --git a/packages/destination-actions/src/destinations/altertable/types.ts b/packages/destination-actions/src/destinations/altertable/types.ts new file mode 100644 index 0000000000..099ac5dff3 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/types.ts @@ -0,0 +1,32 @@ +export type BaseJSON = { + environment: string + timestamp: string | number + distinct_id?: string + anonymous_id?: string + device_id?: string +} + +export type EventJSON = BaseJSON & { + event: string + properties: Context & Record +} + +export type IdentifyJSON = BaseJSON & { + traits: Context & Record +} + +export type Context = { + $ip?: string + $url?: string + $referer?: string + $os?: string + $user_agent?: string + $utm_campaign?: string + $utm_source?: string + $utm_medium?: string + $utm_term?: string + $utm_content?: string + $viewport?: string + $lib?: string + $lib_version?: string +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/altertable/utils.ts b/packages/destination-actions/src/destinations/altertable/utils.ts new file mode 100644 index 0000000000..f159c6da43 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/utils.ts @@ -0,0 +1,96 @@ +import { RequestClient } from '@segment/actions-core' +import { Settings } from './generated-types' +import { Payload as EventPayload } from './event/generated-types' +import { Payload as IdentifyPayload } from './identify/generated-types' +import { BaseJSON, EventJSON, IdentifyJSON } from './types' + +export function send(request: RequestClient, settings: Settings, payload: EventPayload | IdentifyPayload) { + const { + userId, + anonymousId, + type, + timestamp, + context: { + ip, + url, + referrer, + os, + user_agent, + utm_campaign, + utm_source, + utm_medium, + utm_term, + utm_content, + screen_width, + screen_height, + library_name, + library_version, + device_id + } = {}, + } = payload + + const contextProps = { + ...(ip ? { $ip: ip } : {}), + ...(url ? { $url: url } : {}), + ...(referrer ? { $referer: referrer } : {}), + ...(os ? { $os: os } : {}), + ...(user_agent ? { $user_agent: user_agent } : {}), + ...(utm_campaign ? { $utm_campaign: utm_campaign } : {}), + ...(utm_source ? { $utm_source: utm_source } : {}), + ...(utm_medium ? { $utm_medium: utm_medium } : {}), + ...(utm_term ? { $utm_term: utm_term } : {}), + ...(utm_content ? { $utm_content: utm_content } : {}), + ...(screen_width && screen_height ? { $viewport: `${screen_width}x${screen_height}` } : {}), + ...(library_name ? { $lib: library_name } : { $lib: 'altertable-segment' }), + ...(library_version ? { $lib_version: library_version } : {}) + } + + const baseJSON: BaseJSON = { + environment: settings.environment, + timestamp, + distinct_id: userId || anonymousId, + anonymous_id: userId && userId !== anonymousId ? anonymousId : undefined, + device_id + } + + if (type === 'identify') { + const { traits } = payload as IdentifyPayload + + const json: IdentifyJSON = { + ...baseJSON, + traits: { + ...traits, + ...contextProps, + } + } + + return request(`${settings.endpoint}/identify`, { + method: 'post', + json + }) + } + else { + const { properties } = payload as EventPayload + + const event = + type === 'page' + ? '$pageview' + : type === 'screen' + ? '$screenview' + : (payload as EventPayload).event as string + + const json: EventJSON = { + ... baseJSON, + event, + properties: { + ...properties, + ...contextProps + } + } + + return request(`${settings.endpoint}/track`, { + method: 'post', + json + }) + } +}