From 46783e31acebf99201ea5c6d173e67cfc447cbf9 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 22 May 2026 15:12:34 +0100 Subject: [PATCH 1/5] MNTN new audience destinations --- .../__tests__/destination.test.ts | 158 ++++++ .../__tests__/syncAudience.test.ts | 529 ++++++++++++++++++ .../destinations/mntn-audiences/constants.ts | 1 + .../destinations/mntn-audiences/functions.ts | 75 +++ .../mntn-audiences/generated-types.ts | 20 + .../src/destinations/mntn-audiences/index.ts | 77 +++ .../mntn-audiences/syncAudience/functions.ts | 146 +++++ .../syncAudience/generated-types.ts | 32 ++ .../mntn-audiences/syncAudience/index.ts | 100 ++++ .../mntn-audiences/syncAudience/types.ts | 12 + .../src/destinations/mntn-audiences/types.ts | 20 + 11 files changed, 1170 insertions(+) create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/constants.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/functions.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/index.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/syncAudience/types.ts create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/types.ts diff --git a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts new file mode 100644 index 00000000000..02b57b72945 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts @@ -0,0 +1,158 @@ +import nock from 'nock' +import { createTestIntegration, IntegrationError, PayloadValidationError } from '@segment/actions-core' +import Destination from '../index' + +const testDestination = createTestIntegration(Destination as any) + +const MNTN_BASE = 'https://integrations.ex.mountain.com' +const TEST_SETTINGS = { + advertiser_id: 'adv-001', + api_key: 'test-api-key-secret' +} + +beforeEach(() => { + nock.cleanAll() +}) + +afterAll(() => { + nock.cleanAll() + nock.restore() +}) + +// ─── testAuthentication ─────────────────────────────────────────────────────── + +describe('testAuthentication', () => { + it('succeeds when the API responds 200', async () => { + nock(MNTN_BASE, { reqheaders: { authorization: `Bearer ${TEST_SETTINGS.api_key}` } }) + .get('/v2026/audience/segments') + .query({ limit: '1' }) + .reply(200, { segments: [] }) + + await expect(testDestination.testAuthentication(TEST_SETTINGS)).resolves.not.toThrow() + }) + + it('throws when the API responds 401', async () => { + nock(MNTN_BASE) + .get('/v2026/audience/segments') + .query({ limit: '1' }) + .reply(401, { error: { code: 'Unauthenticated' } }) + + await expect(testDestination.testAuthentication(TEST_SETTINGS)).rejects.toThrow(/Credentials are invalid: 401/) + }) +}) + +// ─── createAudience ─────────────────────────────────────────────────────────── + +describe('createAudience', () => { + it('POSTs to create a new segment and returns its ID as externalId', async () => { + nock(MNTN_BASE) + .post('/v2026/audience/segments', { segment: { name: 'High Value Users' } }) + .reply(200, { segment: { id: 'mntn-new-id', name: 'High Value Users' } }) + + const result = await testDestination.createAudience({ + audienceName: 'High Value Users', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + + expect(result).toEqual({ externalId: 'mntn-new-id' }) + }) + + it('uses pre-configured segment_id without calling the API', async () => { + const result = await testDestination.createAudience({ + audienceName: 'My Audience', + settings: TEST_SETTINGS, + audienceSettings: { segment_id: 'pre-existing-001' } + }) + + expect(result).toEqual({ externalId: 'pre-existing-001' }) + }) + + it('throws PayloadValidationError when audienceName is missing and no segment_id configured', async () => { + await expect( + testDestination.createAudience({ + audienceName: '', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow(/missing audience name/i) + }) + + it('throws IntegrationError if the API response has no segment.id', async () => { + nock(MNTN_BASE).post('/v2026/audience/segments').reply(200, { segment: {} }) + + await expect( + testDestination.createAudience({ + audienceName: 'Broken Audience', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow(/unexpected response/i) + }) +}) + +// ─── getAudience ────────────────────────────────────────────────────────────── + +describe('getAudience', () => { + it('GETs the segment by externalId and returns it', async () => { + const segmentId = 'existing-seg-abc' + + nock(MNTN_BASE).get(`/v2026/audience/segments/${segmentId}`).reply(200, { segment: { id: segmentId } }) + + const result = await testDestination.getAudience({ + externalId: segmentId, + settings: TEST_SETTINGS, + audienceSettings: {} + }) + + expect(result).toEqual({ externalId: segmentId }) + }) + + it('prefers audienceSettings.segment_id over externalId', async () => { + const overrideId = 'override-seg-999' + + nock(MNTN_BASE).get(`/v2026/audience/segments/${overrideId}`).reply(200, { segment: { id: overrideId } }) + + const result = await testDestination.getAudience({ + externalId: 'stale-id', + settings: TEST_SETTINGS, + audienceSettings: { segment_id: overrideId } + }) + + expect(result).toEqual({ externalId: overrideId }) + }) + + it('throws when neither externalId nor segment_id is provided', async () => { + await expect( + testDestination.getAudience({ + externalId: '', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow(/no mntn segment id found/i) + }) + + it('throws when the API responds 404', async () => { + nock(MNTN_BASE).get('/v2026/audience/segments/does-not-exist').reply(404, { error: { code: 'NotFound' } }) + + await expect( + testDestination.getAudience({ + externalId: 'does-not-exist', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow() + }) + + it('throws IntegrationError if the API response has no segment.id', async () => { + nock(MNTN_BASE).get('/v2026/audience/segments/seg-abc').reply(200, { segment: {} }) + + await expect( + testDestination.getAudience({ + externalId: 'seg-abc', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow(/unexpected response/i) + }) +}) diff --git a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts new file mode 100644 index 00000000000..93984cc24e0 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts @@ -0,0 +1,529 @@ +/** + * Tests for the syncAudience action. + * + * Covers: + * - Add path (perform): audienceMembership=true → POST with { identity: ... } + * - Remove path (perform): audienceMembership=false → DELETE with identity_id in path + * - Phone normalization: non-numeric chars including + are stripped before sending/hashing + * - Email normalization: lowercased before sending/hashing + * - Batch adds (performBatch): POST with { identities: [...] } + * - Batch removes (performBatch): DELETE with comma-separated URL-encoded IDs + * - Duplicate identity_id detection + * - MultiStatusResponse shape on success and error + */ + +import nock from 'nock' +import { createHash } from 'crypto' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../index' + +const testDestination = createTestIntegration(Destination as any) + +const MNTN_BASE = 'https://integrations.ex.mountain.com' +const SEGMENT_ID = 'seg-abc-123' +const USER_ID = 'user-123' +const ANON_ID = 'anon-456' +const AUDIENCE_KEY = 'my_audience' +const EMAIL = 'user@example.com' +const PHONE_E164 = '+15556004638' +const PHONE_NORMALIZED = '15556004638' // + and non-numeric stripped +const IP = '9.165.155.19' +const TIMESTAMP = '2026-03-25T10:00:00.000Z' + +const TEST_SETTINGS = { + advertiser_id: 'adv-001', + api_key: 'test-api-key-secret' +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +// Pull the action directly for testing performBatch without the test harness +const syncAudienceAction = (Destination as any).actions.syncAudience + +beforeEach(() => { + nock.cleanAll() +}) + +afterAll(() => { + nock.cleanAll() + nock.restore() +}) + +// ─── perform: add path ──────────────────────────────────────────────────────── + +describe('syncAudience — perform: add', () => { + it('sends POST when audienceMembership is true', async () => { + const scope = nock(MNTN_BASE) + .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { email: EMAIL, [AUDIENCE_KEY]: true }, + context: { + ip: IP, + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(scope.isDone()).toBe(true) + }) + + it('sends identity.id, source, and identifiers in POST body', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { email: EMAIL, [AUDIENCE_KEY]: true }, + context: { + ip: IP, + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(capturedBody.identity.id).toBe(USER_ID) + expect(capturedBody.identity.source).toBe('segment') + expect(capturedBody.identity.identifiers).toBeDefined() + }) + + it('wraps single-event body in { identity } not { identities }', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { email: EMAIL, [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(capturedBody.identity).toBeDefined() + expect(capturedBody.identities).toBeUndefined() + }) + + it('includes source_time when timestamp is present', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + timestamp: TIMESTAMP, + traits: { [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + mapping: { + segment_id: SEGMENT_ID, + identity_id: USER_ID, + timestamp: TIMESTAMP + } + }) + + expect(capturedBody.identity.source_time).toEqual({ rfc3339: TIMESTAMP }) + }) +}) + +// ─── perform: remove path ───────────────────────────────────────────────────── + +describe('syncAudience — perform: remove', () => { + it('sends DELETE when audienceMembership is false', async () => { + const scope = nock(MNTN_BASE) + .delete(`/v2026/audience/segments/${SEGMENT_ID}/identities/${USER_ID}`) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { [AUDIENCE_KEY]: false }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(scope.isDone()).toBe(true) + }) + + it('falls back to anonymousId when userId is absent', async () => { + const scope = nock(MNTN_BASE) + .delete(`/v2026/audience/segments/${SEGMENT_ID}/identities/${ANON_ID}`) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: undefined, + anonymousId: ANON_ID, + traits: { [AUDIENCE_KEY]: false }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(scope.isDone()).toBe(true) + }) +}) + +// ─── Phone normalization ────────────────────────────────────────────────────── + +describe('syncAudience — phone normalization', () => { + it('strips + and non-numeric chars from phone before sending and hashing', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + mapping: { + segment_id: SEGMENT_ID, + identity_id: USER_ID, + phone: PHONE_E164 + } + }) + + const phoneEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'phone') + const hashEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'phone_sha256') + + expect(phoneEntry.value).toBe(PHONE_NORMALIZED) + expect(hashEntry.value).toBe(sha256(PHONE_NORMALIZED)) + }) + + it('strips spaces and dashes from phone numbers', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + mapping: { + segment_id: SEGMENT_ID, + identity_id: USER_ID, + phone: '1 (555) 600-4638' + } + }) + + const phoneEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'phone') + expect(phoneEntry.value).toBe('15556004638') + }) +}) + +// ─── Email normalization ────────────────────────────────────────────────────── + +describe('syncAudience — email normalization', () => { + it('lowercases email and hashes the normalized value', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + mapping: { + segment_id: SEGMENT_ID, + identity_id: USER_ID, + email: 'User@Example.COM' + } + }) + + const emailEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'email') + const hashEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'email_sha256') + + expect(emailEntry.value).toBe('user@example.com') + expect(hashEntry.value).toBe(sha256('user@example.com')) + }) +}) + +// ─── performBatch: adds ─────────────────────────────────────────────────────── + +describe('syncAudience — performBatch: adds', () => { + it('sends POST with { identities: [...] } array for batch adds', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-1', + email: 'user1@example.com' + }, + { + segment_id: SEGMENT_ID, + identity_id: 'user-2', + email: 'user2@example.com' + } + ], + audienceMembership: [true, true], + settings: TEST_SETTINGS + }) + + expect(mockRequest).toHaveBeenCalledTimes(1) + const [url, options] = mockRequest.mock.calls[0] + expect(url).toContain(`/v2026/audience/segments/${SEGMENT_ID}/identities`) + expect(options.method).toBe('POST') + expect(options.json.identities).toHaveLength(2) + expect(options.json.identity).toBeUndefined() + }) + + it('returns MultiStatusResponse with 202 for each added identity', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-1' + }, + { + segment_id: SEGMENT_ID, + identity_id: 'user-2' + } + ], + audienceMembership: [true, true], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(202) + expect(result.getResponseAtIndex(1).value().status).toBe(202) + }) +}) + +// ─── performBatch: removes ──────────────────────────────────────────────────── + +describe('syncAudience — performBatch: removes', () => { + it('sends DELETE with comma-separated URL-encoded IDs', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-1' + }, + { + segment_id: SEGMENT_ID, + identity_id: 'user-2' + } + ], + audienceMembership: [false, false], + settings: TEST_SETTINGS + }) + + expect(mockRequest).toHaveBeenCalledTimes(1) + const [url, options] = mockRequest.mock.calls[0] + expect(options.method).toBe('DELETE') + expect(url).toContain('user-1%2Cuser-2') + }) + + it('URL-encodes special characters in identity IDs', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user@example.com' + } + ], + audienceMembership: [false], + settings: TEST_SETTINGS + }) + + const [url] = mockRequest.mock.calls[0] + expect(url).toContain(encodeURIComponent('user@example.com')) + }) +}) + +// ─── performBatch: mixed ───────────────────────────────────────────────────── + +describe('syncAudience — performBatch: mixed', () => { + it('sends separate POST and DELETE for mixed add/remove in same segment', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-add' + }, + { + segment_id: SEGMENT_ID, + identity_id: 'user-remove' + } + ], + audienceMembership: [true, false], + settings: TEST_SETTINGS + }) + + expect(mockRequest).toHaveBeenCalledTimes(2) + const methods = mockRequest.mock.calls.map(([, opts]) => opts.method) + expect(methods).toContain('POST') + expect(methods).toContain('DELETE') + }) + + it('returns error responses in MultiStatusResponse when API fails', async () => { + const mockRequest = jest.fn().mockRejectedValue( + Object.assign(new Error('Service Unavailable'), { response: { status: 503 } }) + ) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-1' + } + ], + audienceMembership: [true], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(503) + }) +}) + +// ─── performBatch: duplicate detection ─────────────────────────────────────── + +describe('syncAudience — performBatch: duplicate detection', () => { + it('marks duplicate identity_id entries as errors and only sends unique identities', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { segment_id: SEGMENT_ID, identity_id: 'user-1', email: 'a@example.com' }, + { segment_id: SEGMENT_ID, identity_id: 'user-1', email: 'b@example.com' }, + { segment_id: SEGMENT_ID, identity_id: 'user-2', email: 'c@example.com' } + ], + audienceMembership: [true, true, true], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(202) + expect(result.getResponseAtIndex(1).value().status).toBe(400) + expect(result.getResponseAtIndex(1).value().errormessage).toMatch(/duplicate identity_id/i) + expect(result.getResponseAtIndex(2).value().status).toBe(202) + + expect(mockRequest).toHaveBeenCalledTimes(1) + const [, options] = mockRequest.mock.calls[0] + expect(options.json.identities).toHaveLength(2) + }) +}) diff --git a/packages/destination-actions/src/destinations/mntn-audiences/constants.ts b/packages/destination-actions/src/destinations/mntn-audiences/constants.ts new file mode 100644 index 00000000000..fa93e7c7e3b --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/constants.ts @@ -0,0 +1 @@ +export const MNTN_API_BASE = 'https://integrations.ex.mountain.com' diff --git a/packages/destination-actions/src/destinations/mntn-audiences/functions.ts b/packages/destination-actions/src/destinations/mntn-audiences/functions.ts new file mode 100644 index 00000000000..1d1f1e7385f --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/functions.ts @@ -0,0 +1,75 @@ +import { IntegrationError, PayloadValidationError, RequestClient } from '@segment/actions-core' +import { MNTN_API_BASE } from './constants' +import type { CreateAudienceInput, GetAudienceInput, SegmentResponse } from './types' + +export async function testAuthentication(request: RequestClient) { + return request(`${MNTN_API_BASE}/v2026/audience/segments?limit=1`, { + method: 'GET' + }) +} + +export async function createAudience(request: RequestClient, createAudienceInput: CreateAudienceInput) { + const { audienceName, audienceSettings: { segment_id } = {} } = createAudienceInput + + if (segment_id) { + return { externalId: segment_id } + } + + if (!audienceName) { + throw new PayloadValidationError( + 'Missing audience name. Provide an audience name or supply a pre-existing MNTN Segment ID in the audience settings.' + ) + } + + const response = await request(`${MNTN_API_BASE}/v2026/audience/segments`, { + method: 'POST', + json: { + segment: { + name: audienceName + } + } + }) + + const id = response.data?.segment?.id + + if (!id) { + throw new IntegrationError( + 'MNTN returned an unexpected response when creating the audience segment. Please try again or contact MNTN support.', + 'INVALID_RESPONSE', + 500 + ) + } + + return { externalId: id } +} + +export async function getAudience(request: RequestClient, getAudienceInput: GetAudienceInput) { + const { externalId, audienceSettings: { segment_id } = {} } = getAudienceInput + + const segmentId = segment_id || externalId + + if (!segmentId) { + throw new IntegrationError( + 'No MNTN Segment ID found. Ensure the destination was properly initialized, or provide a Segment ID in the audience settings.', + 'MISSING_SEGMENT_ID', + 400 + ) + } + + const response = await request( + `${MNTN_API_BASE}/v2026/audience/segments/${encodeURIComponent(segmentId)}`, + { method: 'GET' } + ) + + const id = response.data?.segment?.id + + if (!id) { + throw new IntegrationError( + 'MNTN returned an unexpected response when verifying the audience segment. Please try again or contact MNTN support.', + 'INVALID_RESPONSE', + 500 + ) + } + + return { externalId: id } +} diff --git a/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts b/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts new file mode 100644 index 00000000000..64201aa6620 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts @@ -0,0 +1,20 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your MNTN Advertiser ID, provided when you register as an MNTN advertiser. Contact your MNTN account manager if you need help locating this. + */ + advertiser_id: string + /** + * Your MNTN Audience API key, issued via the MNTN Integrations Marketplace. Treat this value as a secret — do not share it or commit it to source control. + */ + api_key: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface AudienceSettings { + /** + * The ID of a pre-existing MNTN audience segment to sync to. If left blank, a new MNTN segment will be created automatically when this destination is enabled for an audience. + */ + segment_id?: string +} diff --git a/packages/destination-actions/src/destinations/mntn-audiences/index.ts b/packages/destination-actions/src/destinations/mntn-audiences/index.ts new file mode 100644 index 00000000000..9dfac1d5134 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/index.ts @@ -0,0 +1,77 @@ +import type { AudienceDestinationDefinition } from '@segment/actions-core' +import type { Settings, AudienceSettings } from './generated-types' +import syncAudience from './syncAudience' +import { testAuthentication, createAudience, getAudience } from './functions' + +const destination: AudienceDestinationDefinition = { + name: 'MNTN Audiences', + slug: 'actions-mntn-audiences', + mode: 'cloud', + + description: + 'Send Segment Engage audience membership data to MNTN. Syncs users into and out of MNTN audience segments using identity signals including email, phone, IP address, and Mobile Advertising ID (MAID).', + + authentication: { + scheme: 'custom', + fields: { + advertiser_id: { + label: 'Advertiser ID', + description: + 'Your MNTN Advertiser ID, provided when you register as an MNTN advertiser. Contact your MNTN account manager if you need help locating this.', + type: 'string', + required: true + }, + api_key: { + label: 'API Key', + description: + 'Your MNTN Audience API key, issued via the MNTN Integrations Marketplace. Treat this value as a secret — do not share it or commit it to source control.', + type: 'password', + required: true + } + }, + + testAuthentication: (request) => { + return testAuthentication(request) + } + }, + + extendRequest({ settings: { api_key } }) { + return { + headers: { + Authorization: `Bearer ${api_key}`, + 'Content-Type': 'application/json', + Accept: 'application/json' + } + } + }, + + audienceFields: { + segment_id: { + label: 'MNTN Segment ID', + description: + 'The ID of a pre-existing MNTN audience segment to sync to. If left blank, a new MNTN segment will be created automatically when this destination is enabled for an audience.', + type: 'string' + } + }, + + audienceConfig: { + mode: { + type: 'synced', + full_audience_sync: false + }, + + async createAudience(request, createAudienceInput) { + return createAudience(request, createAudienceInput) + }, + + async getAudience(request, getAudienceInput) { + return getAudience(request, getAudienceInput) + } + }, + + actions: { + syncAudience + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts new file mode 100644 index 00000000000..91675c11bdf --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts @@ -0,0 +1,146 @@ +import { + RequestClient, + MultiStatusResponse, + JSONLikeObject, + AudienceMembership, + HTTPError +} from '@segment/actions-core' +import { processHashing } from '../../../lib/hashing-utils' +import type { Payload } from './generated-types' +import type { IdentifierKind, IdentityPayload, PayloadWithIndex } from './types' +import { MNTN_API_BASE } from '../constants' + +function sha256(value: string): string { + return processHashing(value, 'sha256', 'hex') +} + +export function buildIdentity(payload: Payload): IdentityPayload { + const { email, phone, ip, maid, identity_id, timestamp } = payload + const identifiers: Array<{ kind: IdentifierKind; value: string }> = [ + ...(email + ? [ + { kind: 'email' as const, value: email.toLowerCase().trim() }, + { kind: 'email_sha256' as const, value: sha256(email.toLowerCase().trim()) } + ] + : []), + ...(phone + ? [ + { kind: 'phone' as const, value: phone.replace(/\D/g, '') }, + { kind: 'phone_sha256' as const, value: sha256(phone.replace(/\D/g, '')) } + ] + : []), + ...(ip ? [{ kind: 'ipv4' as const, value: ip.trim() }] : []), + ...(maid ? [{ kind: 'maid' as const, value: maid.trim() }] : []) + ] + + return { + id: identity_id, + source: 'segment', + ...(timestamp ? { source_time: { rfc3339: timestamp } } : {}), + identifiers + } +} + +function markSuccess(msResponse: MultiStatusResponse | undefined, entries: PayloadWithIndex[]) { + entries.forEach(({ index, p, identity }) => { + msResponse?.setSuccessResponseAtIndex(index, { + status: 202, + sent: p as unknown as JSONLikeObject, + body: identity as unknown as JSONLikeObject + }) + }) +} + +function markError(msResponse: MultiStatusResponse | undefined, entries: PayloadWithIndex[], error: unknown) { + const status = (error as HTTPError)?.response?.status ?? 500 + const errormessage = error instanceof Error ? error.message : 'Request to MNTN failed.' + entries.forEach(({ index, p, identity }) => { + msResponse?.setErrorResponseAtIndex(index, { + status, + errormessage, + sent: p as unknown as JSONLikeObject, + body: identity as unknown as JSONLikeObject + }) + }) +} + +export async function syncAudience( + request: RequestClient, + payloads: Payload[], + audienceMemberships: AudienceMembership[], + isBatch: boolean +) { + const msResponse = isBatch ? new MultiStatusResponse() : undefined + const adds: PayloadWithIndex[] = [] + const removes: PayloadWithIndex[] = [] + const seen = new Set() + const segment_id = payloads[0].segment_id // batch_keys ensure this is static for a batch of events + + payloads.forEach((p, index) => { + const { identity_id } = p + + if (seen.has(identity_id)) { + msResponse?.setErrorResponseAtIndex(index, { + status: 400, + errormessage: `Duplicate identity_id "${identity_id}".`, + sent: p as unknown as JSONLikeObject, + body: {} + }) + return + } + seen.add(identity_id) + + const identity = buildIdentity(p) + if (audienceMemberships[index]) { + adds.push({ index, p, identity }) + } else { + removes.push({ index, p, identity }) + } + }) + + const encodedSegmentId = encodeURIComponent(segment_id) + + if (adds.length > 0) { + const json = adds.length === 1 + ? { identity: adds[0].identity } + : { identities: adds.map(({ identity }) => identity) } + + try { + const response = await request(`${MNTN_API_BASE}/v2026/audience/segments/${encodedSegmentId}/identities`, { + method: 'POST', + json + }) + if (!isBatch) { + return response + } + markSuccess(msResponse, adds) + } catch (error) { + if (!isBatch) { + throw error + } + markError(msResponse, adds, error) + } + } + + if (removes.length > 0) { + const encodedIds = encodeURIComponent(removes.map(({ identity: { id } }) => id).join(',')) + + try { + const response = await request( + `${MNTN_API_BASE}/v2026/audience/segments/${encodedSegmentId}/identities/${encodedIds}`, + { method: 'DELETE' } + ) + if (!isBatch) { + return response + } + markSuccess(msResponse, removes) + } catch (error) { + if (!isBatch) { + throw error + } + markError(msResponse, removes, error) + } + } + + return msResponse +} diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts new file mode 100644 index 00000000000..38dba0ed48a --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts @@ -0,0 +1,32 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the MNTN audience segment to sync this user into or out of. Automatically populated from the audience setup — do not change unless overriding. + */ + segment_id: string + /** + * A stable identifier for this user in MNTN. Must be consistent between add and remove operations for the same user. Defaults to userId, falling back to anonymousId. + */ + identity_id: string + /** + * The user's email address. Sent to MNTN in plaintext and as a SHA-256 hash for audience matching. + */ + email?: string + /** + * The user's phone number. All non-numeric characters (including the + prefix) are stripped before sending and hashing per MNTN API spec. Sent in normalized form and as a SHA-256 hash. + */ + phone?: string + /** + * The user's IPv4 address. Used for probabilistic audience matching in MNTN campaigns. + */ + ip?: string + /** + * The user's Mobile Advertising ID — IDFA on iOS or GAID on Android. + */ + maid?: string + /** + * ISO 8601 timestamp of when this audience membership event occurred. Sent to MNTN as source_time. + */ + timestamp?: string +} diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts new file mode 100644 index 00000000000..ce82225fafa --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts @@ -0,0 +1,100 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { syncAudience } from './functions' + +const action: ActionDefinition = { + title: 'Sync Audience', + description: 'Sync a Segment Engage audience to an MNTN audience segment.', + defaultSubscription: 'type = "identify" or type = "track"', + fields: { + segment_id: { + label: 'MNTN Segment ID', + description: 'The ID of the MNTN audience segment to sync to. Customers should not need to edit this field.', + type: 'string', + required: true, + default: { + '@path': '$.context.personas.external_audience_id' + } + }, + identity_id: { + label: 'Identity ID', + description: 'A stable identifier for this user in MNTN. Defaults to userId, falling back to anonymousId.', + type: 'string', + required: true, + default: { + '@if': { + exists: { '@path': '$.userId' }, + then: { '@path': '$.userId' }, + else: { '@path': '$.anonymousId' } + } + } + }, + email: { + label: 'Email Address', + description: "The user's email address. Sent to MNTN in plaintext and as a SHA-256 hash for audience matching.", + type: 'string', + format: 'email', + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.context.traits.email' } + } + } + }, + phone: { + label: 'Phone Number', + description: + "The user's phone number. Non-numeric characters (including the + prefix) are removed, and the number is hashed before sending to MNTN.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.phone' }, + then: { '@path': '$.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + } + }, + ip: { + label: 'IP Address', + description: "The user's IPv4 address. Used for probabilistic audience matching in MNTN campaigns.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.ip' }, + then: { '@path': '$.traits.ip' }, + else: { '@path': '$.properties.ip' } + } + } + }, + maid: { + label: 'Mobile Advertising ID (MAID)', + description: "The user's Mobile Advertising ID — IDFA on iOS or GAID on Android.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.advertisingId' }, + then: { '@path': '$.traits.advertisingId' }, + else: { '@path': '$.properties.advertisingId' } + } + } + }, + timestamp: { + label: 'Event Timestamp', + description: 'ISO 8601 timestamp of when this audience membership event occurred. Sent to MNTN as source_time.', + type: 'string', + default: { + '@path': '$.timestamp' + } + } + }, + perform: (request, { payload, audienceMembership }) => { + return syncAudience(request, [payload], [audienceMembership], false) + }, + performBatch: (request, { payload, audienceMembership }) => { + return syncAudience(request, payload, audienceMembership || [], true) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/types.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/types.ts new file mode 100644 index 00000000000..1e541d0e848 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/types.ts @@ -0,0 +1,12 @@ +import type { Payload } from './generated-types' + +export type IdentifierKind = 'email' | 'email_sha256' | 'phone' | 'phone_sha256' | 'ipv4' | 'maid' + +export interface IdentityPayload { + id: string + source: string + source_time?: { rfc3339: string } + identifiers: Array<{ kind: IdentifierKind; value: string }> +} + +export type PayloadWithIndex = { index: number; p: Payload; identity: IdentityPayload } diff --git a/packages/destination-actions/src/destinations/mntn-audiences/types.ts b/packages/destination-actions/src/destinations/mntn-audiences/types.ts new file mode 100644 index 00000000000..f9c3179a06f --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/types.ts @@ -0,0 +1,20 @@ +import type { Settings, AudienceSettings } from './generated-types' + +export interface CreateAudienceInput { + audienceName?: string + settings: Settings + audienceSettings?: AudienceSettings +} + +export interface GetAudienceInput { + externalId: string + settings: Settings + audienceSettings?: AudienceSettings +} + +export interface SegmentResponse { + segment?: { + id?: string + name?: string + } +} From 0a435fcbcff02afa8437875f13611b606ae54cb0 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 25 May 2026 09:02:45 +0100 Subject: [PATCH 2/5] versioning --- .../__tests__/destination.test.ts | 17 ++++++++------- .../__tests__/syncAudience.test.ts | 21 ++++++++++--------- .../destinations/mntn-audiences/functions.ts | 7 ++++--- .../mntn-audiences/syncAudience/functions.ts | 5 +++-- .../mntn-audiences/versioning-info.ts | 1 + 5 files changed, 28 insertions(+), 23 deletions(-) create mode 100644 packages/destination-actions/src/destinations/mntn-audiences/versioning-info.ts diff --git a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts index 02b57b72945..ad70af939a3 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts @@ -1,6 +1,7 @@ import nock from 'nock' import { createTestIntegration, IntegrationError, PayloadValidationError } from '@segment/actions-core' import Destination from '../index' +import { MNTN_API_VERSION } from '../versioning-info' const testDestination = createTestIntegration(Destination as any) @@ -24,7 +25,7 @@ afterAll(() => { describe('testAuthentication', () => { it('succeeds when the API responds 200', async () => { nock(MNTN_BASE, { reqheaders: { authorization: `Bearer ${TEST_SETTINGS.api_key}` } }) - .get('/v2026/audience/segments') + .get(`/${MNTN_API_VERSION}/audience/segments`) .query({ limit: '1' }) .reply(200, { segments: [] }) @@ -33,7 +34,7 @@ describe('testAuthentication', () => { it('throws when the API responds 401', async () => { nock(MNTN_BASE) - .get('/v2026/audience/segments') + .get(`/${MNTN_API_VERSION}/audience/segments`) .query({ limit: '1' }) .reply(401, { error: { code: 'Unauthenticated' } }) @@ -46,7 +47,7 @@ describe('testAuthentication', () => { describe('createAudience', () => { it('POSTs to create a new segment and returns its ID as externalId', async () => { nock(MNTN_BASE) - .post('/v2026/audience/segments', { segment: { name: 'High Value Users' } }) + .post(`/${MNTN_API_VERSION}/audience/segments`, { segment: { name: 'High Value Users' } }) .reply(200, { segment: { id: 'mntn-new-id', name: 'High Value Users' } }) const result = await testDestination.createAudience({ @@ -79,7 +80,7 @@ describe('createAudience', () => { }) it('throws IntegrationError if the API response has no segment.id', async () => { - nock(MNTN_BASE).post('/v2026/audience/segments').reply(200, { segment: {} }) + nock(MNTN_BASE).post(`/${MNTN_API_VERSION}/audience/segments`).reply(200, { segment: {} }) await expect( testDestination.createAudience({ @@ -97,7 +98,7 @@ describe('getAudience', () => { it('GETs the segment by externalId and returns it', async () => { const segmentId = 'existing-seg-abc' - nock(MNTN_BASE).get(`/v2026/audience/segments/${segmentId}`).reply(200, { segment: { id: segmentId } }) + nock(MNTN_BASE).get(`/${MNTN_API_VERSION}/audience/segments/${segmentId}`).reply(200, { segment: { id: segmentId } }) const result = await testDestination.getAudience({ externalId: segmentId, @@ -111,7 +112,7 @@ describe('getAudience', () => { it('prefers audienceSettings.segment_id over externalId', async () => { const overrideId = 'override-seg-999' - nock(MNTN_BASE).get(`/v2026/audience/segments/${overrideId}`).reply(200, { segment: { id: overrideId } }) + nock(MNTN_BASE).get(`/${MNTN_API_VERSION}/audience/segments/${overrideId}`).reply(200, { segment: { id: overrideId } }) const result = await testDestination.getAudience({ externalId: 'stale-id', @@ -133,7 +134,7 @@ describe('getAudience', () => { }) it('throws when the API responds 404', async () => { - nock(MNTN_BASE).get('/v2026/audience/segments/does-not-exist').reply(404, { error: { code: 'NotFound' } }) + nock(MNTN_BASE).get(`/${MNTN_API_VERSION}/audience/segments/does-not-exist`).reply(404, { error: { code: 'NotFound' } }) await expect( testDestination.getAudience({ @@ -145,7 +146,7 @@ describe('getAudience', () => { }) it('throws IntegrationError if the API response has no segment.id', async () => { - nock(MNTN_BASE).get('/v2026/audience/segments/seg-abc').reply(200, { segment: {} }) + nock(MNTN_BASE).get(`/${MNTN_API_VERSION}/audience/segments/seg-abc`).reply(200, { segment: {} }) await expect( testDestination.getAudience({ diff --git a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts index 93984cc24e0..29428e96d90 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts @@ -16,6 +16,7 @@ import nock from 'nock' import { createHash } from 'crypto' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../index' +import { MNTN_API_VERSION } from '../versioning-info' const testDestination = createTestIntegration(Destination as any) @@ -56,7 +57,7 @@ afterAll(() => { describe('syncAudience — perform: add', () => { it('sends POST when audienceMembership is true', async () => { const scope = nock(MNTN_BASE) - .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`) + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`) .reply(202, {}) await testDestination.testAction('syncAudience', { @@ -84,7 +85,7 @@ describe('syncAudience — perform: add', () => { let capturedBody: any nock(MNTN_BASE) - .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { capturedBody = body return true }) @@ -117,7 +118,7 @@ describe('syncAudience — perform: add', () => { let capturedBody: any nock(MNTN_BASE) - .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { capturedBody = body return true }) @@ -148,7 +149,7 @@ describe('syncAudience — perform: add', () => { let capturedBody: any nock(MNTN_BASE) - .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { capturedBody = body return true }) @@ -185,7 +186,7 @@ describe('syncAudience — perform: add', () => { describe('syncAudience — perform: remove', () => { it('sends DELETE when audienceMembership is false', async () => { const scope = nock(MNTN_BASE) - .delete(`/v2026/audience/segments/${SEGMENT_ID}/identities/${USER_ID}`) + .delete(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities/${USER_ID}`) .reply(202, {}) await testDestination.testAction('syncAudience', { @@ -210,7 +211,7 @@ describe('syncAudience — perform: remove', () => { it('falls back to anonymousId when userId is absent', async () => { const scope = nock(MNTN_BASE) - .delete(`/v2026/audience/segments/${SEGMENT_ID}/identities/${ANON_ID}`) + .delete(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities/${ANON_ID}`) .reply(202, {}) await testDestination.testAction('syncAudience', { @@ -242,7 +243,7 @@ describe('syncAudience — phone normalization', () => { let capturedBody: any nock(MNTN_BASE) - .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { capturedBody = body return true }) @@ -280,7 +281,7 @@ describe('syncAudience — phone normalization', () => { let capturedBody: any nock(MNTN_BASE) - .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { capturedBody = body return true }) @@ -319,7 +320,7 @@ describe('syncAudience — email normalization', () => { let capturedBody: any nock(MNTN_BASE) - .post(`/v2026/audience/segments/${SEGMENT_ID}/identities`, (body) => { + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { capturedBody = body return true }) @@ -379,7 +380,7 @@ describe('syncAudience — performBatch: adds', () => { expect(mockRequest).toHaveBeenCalledTimes(1) const [url, options] = mockRequest.mock.calls[0] - expect(url).toContain(`/v2026/audience/segments/${SEGMENT_ID}/identities`) + expect(url).toContain(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`) expect(options.method).toBe('POST') expect(options.json.identities).toHaveLength(2) expect(options.json.identity).toBeUndefined() diff --git a/packages/destination-actions/src/destinations/mntn-audiences/functions.ts b/packages/destination-actions/src/destinations/mntn-audiences/functions.ts index 1d1f1e7385f..92c89e11e4c 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/functions.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/functions.ts @@ -1,9 +1,10 @@ import { IntegrationError, PayloadValidationError, RequestClient } from '@segment/actions-core' import { MNTN_API_BASE } from './constants' +import { MNTN_API_VERSION } from './versioning-info' import type { CreateAudienceInput, GetAudienceInput, SegmentResponse } from './types' export async function testAuthentication(request: RequestClient) { - return request(`${MNTN_API_BASE}/v2026/audience/segments?limit=1`, { + return request(`${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments?limit=1`, { method: 'GET' }) } @@ -21,7 +22,7 @@ export async function createAudience(request: RequestClient, createAudienceInput ) } - const response = await request(`${MNTN_API_BASE}/v2026/audience/segments`, { + const response = await request(`${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments`, { method: 'POST', json: { segment: { @@ -57,7 +58,7 @@ export async function getAudience(request: RequestClient, getAudienceInput: GetA } const response = await request( - `${MNTN_API_BASE}/v2026/audience/segments/${encodeURIComponent(segmentId)}`, + `${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments/${encodeURIComponent(segmentId)}`, { method: 'GET' } ) diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts index 91675c11bdf..6e7c6d00438 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts @@ -9,6 +9,7 @@ import { processHashing } from '../../../lib/hashing-utils' import type { Payload } from './generated-types' import type { IdentifierKind, IdentityPayload, PayloadWithIndex } from './types' import { MNTN_API_BASE } from '../constants' +import { MNTN_API_VERSION } from '../versioning-info' function sha256(value: string): string { return processHashing(value, 'sha256', 'hex') @@ -106,7 +107,7 @@ export async function syncAudience( : { identities: adds.map(({ identity }) => identity) } try { - const response = await request(`${MNTN_API_BASE}/v2026/audience/segments/${encodedSegmentId}/identities`, { + const response = await request(`${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments/${encodedSegmentId}/identities`, { method: 'POST', json }) @@ -127,7 +128,7 @@ export async function syncAudience( try { const response = await request( - `${MNTN_API_BASE}/v2026/audience/segments/${encodedSegmentId}/identities/${encodedIds}`, + `${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments/${encodedSegmentId}/identities/${encodedIds}`, { method: 'DELETE' } ) if (!isBatch) { diff --git a/packages/destination-actions/src/destinations/mntn-audiences/versioning-info.ts b/packages/destination-actions/src/destinations/mntn-audiences/versioning-info.ts new file mode 100644 index 00000000000..162ac85429c --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/versioning-info.ts @@ -0,0 +1 @@ +export const MNTN_API_VERSION = 'v2026' From 4beca4cf9bc47baf28c2349a94c3691ced7f8a9b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 25 May 2026 09:06:59 +0100 Subject: [PATCH 3/5] trimming before adding Co-Authored-By: Claude Opus 4.6 --- .../mntn-audiences/syncAudience/functions.ts | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts index 6e7c6d00438..d137cb4ea49 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts @@ -17,22 +17,37 @@ function sha256(value: string): string { export function buildIdentity(payload: Payload): IdentityPayload { const { email, phone, ip, maid, identity_id, timestamp } = payload - const identifiers: Array<{ kind: IdentifierKind; value: string }> = [ - ...(email - ? [ - { kind: 'email' as const, value: email.toLowerCase().trim() }, - { kind: 'email_sha256' as const, value: sha256(email.toLowerCase().trim()) } - ] - : []), - ...(phone - ? [ - { kind: 'phone' as const, value: phone.replace(/\D/g, '') }, - { kind: 'phone_sha256' as const, value: sha256(phone.replace(/\D/g, '')) } - ] - : []), - ...(ip ? [{ kind: 'ipv4' as const, value: ip.trim() }] : []), - ...(maid ? [{ kind: 'maid' as const, value: maid.trim() }] : []) - ] + const identifiers: Array<{ kind: IdentifierKind; value: string }> = [] + + if (email) { + const normalized = email.toLowerCase().trim() + if (normalized) { + identifiers.push({ kind: 'email', value: normalized }) + identifiers.push({ kind: 'email_sha256', value: sha256(normalized) }) + } + } + + if (phone) { + const normalized = phone.replace(/\D/g, '') + if (normalized) { + identifiers.push({ kind: 'phone', value: normalized }) + identifiers.push({ kind: 'phone_sha256', value: sha256(normalized) }) + } + } + + if (ip) { + const normalized = ip.trim() + if (normalized) { + identifiers.push({ kind: 'ipv4', value: normalized }) + } + } + + if (maid) { + const normalized = maid.trim() + if (normalized) { + identifiers.push({ kind: 'maid', value: normalized }) + } + } return { id: identity_id, From b545cc1618ee5838c8680d71bb785bb93edc6c35 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Mon, 25 May 2026 09:17:10 +0100 Subject: [PATCH 4/5] more edits from Copilot feedback --- .../mntn-audiences/__tests__/destination.test.ts | 3 +-- .../mntn-audiences/__tests__/syncAudience.test.ts | 1 - .../src/destinations/mntn-audiences/generated-types.ts | 4 ---- .../src/destinations/mntn-audiences/index.ts | 7 ------- .../mntn-audiences/syncAudience/generated-types.ts | 6 +++--- .../src/destinations/mntn-audiences/syncAudience/index.ts | 2 +- 6 files changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts index ad70af939a3..e58c92e967a 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts @@ -1,5 +1,5 @@ import nock from 'nock' -import { createTestIntegration, IntegrationError, PayloadValidationError } from '@segment/actions-core' +import { createTestIntegration } from '@segment/actions-core' import Destination from '../index' import { MNTN_API_VERSION } from '../versioning-info' @@ -7,7 +7,6 @@ const testDestination = createTestIntegration(Destination as any) const MNTN_BASE = 'https://integrations.ex.mountain.com' const TEST_SETTINGS = { - advertiser_id: 'adv-001', api_key: 'test-api-key-secret' } diff --git a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts index 29428e96d90..4b246ed0313 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts @@ -32,7 +32,6 @@ const IP = '9.165.155.19' const TIMESTAMP = '2026-03-25T10:00:00.000Z' const TEST_SETTINGS = { - advertiser_id: 'adv-001', api_key: 'test-api-key-secret' } diff --git a/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts b/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts index 64201aa6620..415f3d12e21 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts @@ -1,10 +1,6 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Settings { - /** - * Your MNTN Advertiser ID, provided when you register as an MNTN advertiser. Contact your MNTN account manager if you need help locating this. - */ - advertiser_id: string /** * Your MNTN Audience API key, issued via the MNTN Integrations Marketplace. Treat this value as a secret — do not share it or commit it to source control. */ diff --git a/packages/destination-actions/src/destinations/mntn-audiences/index.ts b/packages/destination-actions/src/destinations/mntn-audiences/index.ts index 9dfac1d5134..f1377ab5610 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/index.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/index.ts @@ -14,13 +14,6 @@ const destination: AudienceDestinationDefinition = { authentication: { scheme: 'custom', fields: { - advertiser_id: { - label: 'Advertiser ID', - description: - 'Your MNTN Advertiser ID, provided when you register as an MNTN advertiser. Contact your MNTN account manager if you need help locating this.', - type: 'string', - required: true - }, api_key: { label: 'API Key', description: diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts index 38dba0ed48a..247787c48f6 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts @@ -2,11 +2,11 @@ export interface Payload { /** - * The ID of the MNTN audience segment to sync this user into or out of. Automatically populated from the audience setup — do not change unless overriding. + * The ID of the MNTN audience segment to sync to. Customers should not need to edit this field. */ segment_id: string /** - * A stable identifier for this user in MNTN. Must be consistent between add and remove operations for the same user. Defaults to userId, falling back to anonymousId. + * A stable identifier for this user in MNTN. Defaults to userId, falling back to anonymousId. */ identity_id: string /** @@ -14,7 +14,7 @@ export interface Payload { */ email?: string /** - * The user's phone number. All non-numeric characters (including the + prefix) are stripped before sending and hashing per MNTN API spec. Sent in normalized form and as a SHA-256 hash. + * The user's phone number. Non-numeric characters (including the + prefix) are removed. Sent to MNTN in normalized plaintext and as a SHA-256 hash for audience matching. */ phone?: string /** diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts index ce82225fafa..87857c9649d 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts @@ -46,7 +46,7 @@ const action: ActionDefinition = { phone: { label: 'Phone Number', description: - "The user's phone number. Non-numeric characters (including the + prefix) are removed, and the number is hashed before sending to MNTN.", + "The user's phone number. Non-numeric characters (including the + prefix) are removed. Sent to MNTN in normalized plaintext and as a SHA-256 hash for audience matching.", type: 'string', default: { '@if': { From fe561a47a669956f8bf73663e6fe906502134117 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 7 Jul 2026 15:56:13 +0100 Subject: [PATCH 5/5] [MNTN] Fail fast on missing audience membership in syncAudience Defaulting audienceMembership to `[]` (and treating an undefined entry as falsey) meant a missing membership classified an identity as a removal, risking unintended DELETEs. syncAudience now requires an explicit boolean per entry: `perform` throws a PayloadValidationError, and `performBatch` marks the entry as a 400 in the MultiStatusResponse instead of deleting it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/syncAudience.test.ts | 53 +++++++++++++++++++ .../mntn-audiences/syncAudience/functions.ts | 20 ++++++- .../mntn-audiences/syncAudience/index.ts | 2 +- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts index 4b246ed0313..9d45cf4734a 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts @@ -501,6 +501,59 @@ describe('syncAudience — performBatch: mixed', () => { }) }) +// ─── missing audience membership: fail fast ───────────────────────────────── + +describe('syncAudience — missing audience membership', () => { + it('perform throws instead of issuing a DELETE when audienceMembership is undefined', async () => { + const mockRequest = jest.fn() + + await expect( + syncAudienceAction.perform(mockRequest, { + payload: { segment_id: SEGMENT_ID, identity_id: 'user-1' }, + settings: TEST_SETTINGS + }) + ).rejects.toThrow(/audience membership/i) + + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('performBatch marks entries with missing membership as errors and does not DELETE them', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { segment_id: SEGMENT_ID, identity_id: 'user-1' }, + { segment_id: SEGMENT_ID, identity_id: 'user-2' } + ], + audienceMembership: [true], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(202) + expect(result.getResponseAtIndex(1).value().status).toBe(400) + expect(result.getResponseAtIndex(1).value().errormessage).toMatch(/audience membership/i) + + expect(mockRequest).toHaveBeenCalledTimes(1) + expect(mockRequest.mock.calls[0][1].method).toBe('POST') + }) + + it('performBatch issues no DELETE when audienceMembership is entirely missing', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { segment_id: SEGMENT_ID, identity_id: 'user-1' }, + { segment_id: SEGMENT_ID, identity_id: 'user-2' } + ], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(400) + expect(result.getResponseAtIndex(1).value().status).toBe(400) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) + // ─── performBatch: duplicate detection ─────────────────────────────────────── describe('syncAudience — performBatch: duplicate detection', () => { diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts index d137cb4ea49..369be8c9b97 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts @@ -3,7 +3,8 @@ import { MultiStatusResponse, JSONLikeObject, AudienceMembership, - HTTPError + HTTPError, + PayloadValidationError } from '@segment/actions-core' import { processHashing } from '../../../lib/hashing-utils' import type { Payload } from './generated-types' @@ -106,8 +107,23 @@ export async function syncAudience( } seen.add(identity_id) + const membership = audienceMemberships[index] + if (typeof membership !== 'boolean') { + const errormessage = `Missing audience membership for identity_id "${identity_id}". Expected a boolean indicating whether the user was added to or removed from the audience.` + if (!isBatch) { + throw new PayloadValidationError(errormessage) + } + msResponse?.setErrorResponseAtIndex(index, { + status: 400, + errormessage, + sent: p as unknown as JSONLikeObject, + body: {} + }) + return + } + const identity = buildIdentity(p) - if (audienceMemberships[index]) { + if (membership) { adds.push({ index, p, identity }) } else { removes.push({ index, p, identity }) diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts index 87857c9649d..2b67c71bb7d 100644 --- a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts @@ -93,7 +93,7 @@ const action: ActionDefinition = { return syncAudience(request, [payload], [audienceMembership], false) }, performBatch: (request, { payload, audienceMembership }) => { - return syncAudience(request, payload, audienceMembership || [], true) + return syncAudience(request, payload, Array.isArray(audienceMembership) ? audienceMembership : [], true) } }