diff --git a/packages/destination-actions/src/destinations/lark/__tests__/index.test.ts b/packages/destination-actions/src/destinations/lark/__tests__/index.test.ts new file mode 100644 index 0000000000..b507437d83 --- /dev/null +++ b/packages/destination-actions/src/destinations/lark/__tests__/index.test.ts @@ -0,0 +1,65 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Lark', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock('https://api.uselark.ai').get('/subjects').reply(200, { data: [] }) + + const authData = { apiKey: 'test-api-key' } + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + + it('should fail with invalid API key', async () => { + nock('https://api.uselark.ai').get('/subjects').reply(401, { error: 'Unauthorized' }) + + const authData = { apiKey: 'invalid-api-key' } + + await expect(testDestination.testAuthentication(authData)).rejects.toThrowError() + }) + }) + + describe('createUsageEvent', () => { + it('should send a usage event to Lark', async () => { + nock('https://api.uselark.ai').post('/usage-events').reply(200, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Job Completed', + userId: 'user-123', + messageId: 'msg-123', + timestamp: '2024-01-15T10:30:00.000Z', + properties: { + compute_hours: 100.5, + instance_type: 't2.micro', + region: 'us-east-1' + } + }) + + const responses = await testDestination.testAction('createUsageEvent', { + event, + settings: { apiKey: 'test-api-key' }, + mapping: { + event_name: { '@path': '$.event' }, + subject_id: { '@path': '$.userId' }, + idempotency_key: { '@path': '$.messageId' }, + timestamp: { '@path': '$.timestamp' }, + data: { '@path': '$.properties' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + + const requestBody = JSON.parse(responses[0].options.body as string) + expect(requestBody.event_name).toBe('Job Completed') + expect(requestBody.subject_id).toBe('user-123') + expect(requestBody.idempotency_key).toBe('msg-123') + expect(requestBody.data.compute_hours).toBe(100.5) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/lark/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/lark/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..06115665f9 --- /dev/null +++ b/packages/destination-actions/src/destinations/lark/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +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-lark' + +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, + 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, + 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/lark/createUsageEvent/generated-types.ts b/packages/destination-actions/src/destinations/lark/createUsageEvent/generated-types.ts new file mode 100644 index 0000000000..bd0b70ae7f --- /dev/null +++ b/packages/destination-actions/src/destinations/lark/createUsageEvent/generated-types.ts @@ -0,0 +1,26 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the event. This is used by pricing metrics to aggregate usage events. + */ + event_name: string + /** + * The ID or external ID of the subject that the usage event is for. + */ + subject_id: string + /** + * The idempotency key for the usage event. This ensures that the same event is not processed multiple times. + */ + idempotency_key: string + /** + * The timestamp of the usage event (ISO 8601 format). If not provided, the current timestamp will be used. + */ + timestamp?: string | number + /** + * The data of the usage event. This should contain any data that is needed to aggregate the usage event. + */ + data?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/lark/createUsageEvent/index.ts b/packages/destination-actions/src/destinations/lark/createUsageEvent/index.ts new file mode 100644 index 0000000000..0aefe694e2 --- /dev/null +++ b/packages/destination-actions/src/destinations/lark/createUsageEvent/index.ts @@ -0,0 +1,77 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Create Usage Event', + description: 'Send a usage event to Lark for billing and metering purposes.', + defaultSubscription: 'type = "track"', + fields: { + event_name: { + label: 'Event Name', + description: 'The name of the event. This is used by pricing metrics to aggregate usage events.', + type: 'string', + required: true, + default: { + '@path': '$.event' + } + }, + subject_id: { + label: 'Subject ID', + description: 'The ID or external ID of the subject that the usage event is for.', + type: 'string', + required: true, + default: { + '@path': '$.userId' + } + }, + idempotency_key: { + label: 'Idempotency Key', + description: + 'The idempotency key for the usage event. This ensures that the same event is not processed multiple times.', + type: 'string', + required: true, + default: { + '@path': '$.messageId' + } + }, + timestamp: { + label: 'Timestamp', + description: + 'The timestamp of the usage event (ISO 8601 format). If not provided, the current timestamp will be used.', + type: 'datetime', + required: false, + default: { + '@path': '$.timestamp' + } + }, + data: { + label: 'Data', + description: + 'The data of the usage event. This should contain any data that is needed to aggregate the usage event.', + type: 'object', + required: false, + default: { + '@path': '$.properties' + } + } + }, + perform: (request, { settings, payload }) => { + return request('https://api.uselark.ai/usage-events', { + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': settings.apiKey + }, + json: { + event_name: payload.event_name, + subject_id: payload.subject_id, + idempotency_key: payload.idempotency_key, + timestamp: payload.timestamp, + data: payload.data + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/lark/generated-types.ts b/packages/destination-actions/src/destinations/lark/generated-types.ts new file mode 100644 index 0000000000..b987f88264 --- /dev/null +++ b/packages/destination-actions/src/destinations/lark/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Lark API key. You can find this in your Lark dashboard. + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/lark/index.ts b/packages/destination-actions/src/destinations/lark/index.ts new file mode 100644 index 0000000000..b5fda711f6 --- /dev/null +++ b/packages/destination-actions/src/destinations/lark/index.ts @@ -0,0 +1,59 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import createUsageEvent from './createUsageEvent' + +const destination: DestinationDefinition = { + name: 'Lark', + slug: 'actions-lark', + mode: 'cloud', + description: 'Send usage events to Lark for billing and metering purposes.', + + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: 'Your Lark API key. You can find this in your Lark dashboard.', + type: 'password', + required: true + } + }, + testAuthentication: async (request, { settings }) => { + // Test the API key by making a request to list subjects (a lightweight endpoint) + // If the API key is invalid, this will return an error + return request('https://api.uselark.ai/subjects', { + method: 'get', + headers: { + 'X-API-Key': settings.apiKey + } + }) + } + }, + + extendRequest({ settings }) { + return { + headers: { + 'X-API-Key': settings.apiKey, + 'Content-Type': 'application/json' + } + } + }, + + actions: { + createUsageEvent + }, + + presets: [ + { + name: 'Send Usage Event to Lark', + subscribe: 'type = "track"', + partnerAction: 'createUsageEvent', + mapping: defaultValues(createUsageEvent.fields), + type: 'automatic' + } + ] +} + +export default destination