Skip to content

Commit a8ebbab

Browse files
STRATCONN-6562 - [Facebook Custom Audiences] - rewrite (#3671)
1 parent c3b39f9 commit a8ebbab

26 files changed

Lines changed: 3959 additions & 1663 deletions
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import nock from 'nock'
2+
import { createTestIntegration, IntegrationError } from '@segment/actions-core'
3+
import Destination from '../index'
4+
import { API_VERSION, BASE_URL } from '../constants'
5+
6+
const adAccountId = '1500000000000000'
7+
const audienceId = '1506489116128966'
8+
const testDestination = createTestIntegration(Destination)
9+
10+
const getAudienceUrl = `${BASE_URL}/${API_VERSION}/`
11+
12+
const getAudienceInput = {
13+
externalId: audienceId,
14+
settings: {
15+
retlAdAccountId: '123'
16+
}
17+
}
18+
19+
const baseCreateAudienceInput = () => ({
20+
settings: {
21+
retlAdAccountId: '123'
22+
},
23+
audienceName: '',
24+
audienceSettings: {
25+
engageAdAccountId: adAccountId,
26+
audienceDescription: 'We are the Mario Brothers and plumbing is our game.'
27+
},
28+
features: {}
29+
})
30+
31+
describe('Facebook Custom Audiences', () => {
32+
describe('createAudience', () => {
33+
it('should fail if no audience name is set', async () => {
34+
await expect(testDestination.createAudience(baseCreateAudienceInput())).rejects.toThrowError(IntegrationError)
35+
})
36+
37+
it('should fail if no ad account ID is set', async () => {
38+
const input = baseCreateAudienceInput()
39+
input.audienceName = 'The Void'
40+
input.audienceSettings.engageAdAccountId = ''
41+
await expect(testDestination.createAudience(input)).rejects.toThrowError(IntegrationError)
42+
})
43+
44+
it('should create a new Facebook Audience', async () => {
45+
nock(`${BASE_URL}/${API_VERSION}/act_${adAccountId}`)
46+
.post('/customaudiences')
47+
.reply(200, { id: '88888888888888888' })
48+
49+
const input = baseCreateAudienceInput()
50+
input.audienceName = 'The Super Mario Brothers Fans'
51+
52+
const r = await testDestination.createAudience(input)
53+
expect(r).toEqual({ externalId: '88888888888888888' })
54+
})
55+
56+
it('should use error_user_title and error_user_msg when both are present', async () => {
57+
nock(`${BASE_URL}/${API_VERSION}/act_${adAccountId}`)
58+
.post('/customaudiences')
59+
.reply(400, {
60+
error: {
61+
message: 'Invalid parameter',
62+
type: 'OAuthException',
63+
code: 100,
64+
error_user_title: 'Update Restricted Fields and Rule',
65+
error_user_msg: 'This custom audience has integrity restrictions.'
66+
}
67+
})
68+
69+
const input = baseCreateAudienceInput()
70+
input.audienceName = 'Restricted Audience'
71+
72+
await expect(testDestination.createAudience(input)).rejects.toThrow(
73+
"error_user_title: \"Update Restricted Fields and Rule\". error_user_msg: \"This custom audience has integrity restrictions.\". fbmessage: \"Invalid parameter\". message: \"Bad Request\". code: \"100\""
74+
)
75+
})
76+
77+
it('should use error_user_msg alone when error_user_title is absent', async () => {
78+
nock(`${BASE_URL}/${API_VERSION}/act_${adAccountId}`)
79+
.post('/customaudiences')
80+
.reply(400, {
81+
error: {
82+
message: 'Invalid parameter',
83+
type: 'OAuthException',
84+
code: 100,
85+
error_user_msg: 'This custom audience has integrity restrictions.'
86+
}
87+
})
88+
89+
const input = baseCreateAudienceInput()
90+
input.audienceName = 'Restricted Audience'
91+
92+
await expect(testDestination.createAudience(input)).rejects.toThrow(
93+
'This custom audience has integrity restrictions.'
94+
)
95+
})
96+
97+
it('should fall back to the raw message when no user-facing fields are present', async () => {
98+
nock(`${BASE_URL}/${API_VERSION}/act_${adAccountId}`)
99+
.post('/customaudiences')
100+
.reply(400, {
101+
error: {
102+
message: 'Invalid parameter',
103+
type: 'OAuthException',
104+
code: 100
105+
}
106+
})
107+
108+
const input = baseCreateAudienceInput()
109+
input.audienceName = 'Restricted Audience'
110+
111+
await expect(testDestination.createAudience(input)).rejects.toThrow('Invalid parameter')
112+
})
113+
})
114+
115+
describe('getAudience', () => {
116+
it('should fail if FB replies with an error ID', async () => {
117+
nock(getAudienceUrl).get(`/${audienceId}`).query({ fields: 'id,name' }).reply(400, {})
118+
await expect(testDestination.getAudience(getAudienceInput)).rejects.toThrowError()
119+
})
120+
121+
it("should fail if Segment Audience ID doesn't match FB Audience ID", async () => {
122+
nock(getAudienceUrl).get(`/${audienceId}`).query({ fields: 'id,name' }).reply(200, { id: '42' })
123+
await expect(testDestination.getAudience(getAudienceInput)).rejects.toThrowError()
124+
})
125+
126+
it('should succeed when Segment Audience ID matches FB audience ID', async () => {
127+
nock(getAudienceUrl)
128+
.get(`/${audienceId}`)
129+
.query({ fields: 'id,name' })
130+
.reply(200, { id: `${audienceId}` })
131+
const r = await testDestination.getAudience(getAudienceInput)
132+
expect(r).toEqual({ externalId: audienceId })
133+
})
134+
})
135+
})
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import nock from 'nock'
2+
import { createTestEvent, createTestIntegration } from '@segment/actions-core'
3+
import { BASE_URL, FACEBOOK_CUSTOM_AUDIENCE_FLAGON } from '../constants'
4+
import { SCHEMA_PROPERTIES } from '../sync/constants'
5+
import Destination from '../index'
6+
7+
8+
// Override CANARY_API_VERSION with a distinct test value so we can verify
9+
// that requests are routed to the correct URL. The real constant stays at
10+
// its production value; only this test module sees the override.
11+
// The literal must be inlined in the factory because jest.mock is hoisted
12+
// before any variable declarations.
13+
jest.mock('../constants', () => ({
14+
...jest.requireActual('../constants'),
15+
CANARY_API_VERSION: 'v25.0'
16+
}))
17+
18+
const TEST_CANARY_API_VERSION = 'v25.0'
19+
const TEST_API_VERSION = 'v24.0'
20+
const testDestination = createTestIntegration(Destination)
21+
const auth = { accessToken: '123', refreshToken: '321' }
22+
const settings = { retlAdAccountId: '123' }
23+
const adAccountId = '1500000000000000'
24+
const audienceId = '900'
25+
26+
// 13 empty strings for unmapped PII fields
27+
const EMPTY_TAIL = ['', '', '', '', '', '', '', '', '', '', '', '', '']
28+
29+
const baseMapping = {
30+
__segment_internal_sync_mode: 'upsert',
31+
externalId: { '@path': '$.userId' },
32+
email: { '@path': '$.properties.email' },
33+
retlOnMappingSave: {
34+
inputs: {},
35+
outputs: {
36+
audienceName: 'test-audience',
37+
audienceId
38+
}
39+
},
40+
enable_batching: true,
41+
batch_size: 10000
42+
}
43+
44+
describe('Facebook Custom Audiences - canary API version', () => {
45+
beforeEach(() => {
46+
nock.cleanAll()
47+
})
48+
49+
describe('sync action (upsert)', () => {
50+
const events = [createTestEvent({ type: 'track', event: 'new', userId: 'user-1', properties: { email: '[email protected]' } })]
51+
52+
const expectedBody = {
53+
payload: {
54+
schema: SCHEMA_PROPERTIES,
55+
data: [['user-1', 'b36a83701f1c3191e19722d6f90274bc1b5501fe69ebf33313e440fe4b0fe210', ...EMPTY_TAIL]]
56+
}
57+
}
58+
59+
const facebookResponse = { audience_id: audienceId, num_received: 1, num_invalid_entries: 0, invalid_entry_samples: {} }
60+
61+
it('sends payload to the standard API_VERSION URL when the canary flag is off', async () => {
62+
nock(`${BASE_URL}/${TEST_API_VERSION}`)
63+
.post(`/${audienceId}/users`, expectedBody)
64+
.reply(200, facebookResponse)
65+
66+
const responses = await testDestination.executeBatch('sync', {
67+
events,
68+
settings,
69+
auth,
70+
mapping: baseMapping,
71+
features: { [FACEBOOK_CUSTOM_AUDIENCE_FLAGON]: false, 'actions-core-audience-membership': true }
72+
})
73+
74+
expect(responses[0].status).toBe(200)
75+
})
76+
77+
it('sends payload to the CANARY_API_VERSION URL when the canary flag is on', async () => {
78+
nock(`${BASE_URL}/${TEST_CANARY_API_VERSION}`)
79+
.post(`/${audienceId}/users`, expectedBody)
80+
.reply(200, facebookResponse)
81+
82+
const responses = await testDestination.executeBatch('sync', {
83+
events,
84+
settings,
85+
auth,
86+
mapping: baseMapping,
87+
features: { [FACEBOOK_CUSTOM_AUDIENCE_FLAGON]: true, 'actions-core-audience-membership': true }
88+
})
89+
90+
expect(responses[0].status).toBe(200)
91+
})
92+
93+
it('does NOT send to the canary URL when the flag is off', async () => {
94+
// Only mock the standard version - if the code incorrectly hits the
95+
// canary URL, nock will throw a connection error and the test will fail.
96+
nock(`${BASE_URL}/${TEST_API_VERSION}`)
97+
.post(`/${audienceId}/users`, expectedBody)
98+
.reply(200, facebookResponse)
99+
100+
const responses = await testDestination.executeBatch('sync', {
101+
events,
102+
settings,
103+
auth,
104+
mapping: baseMapping,
105+
features: { 'actions-core-audience-membership': true }
106+
})
107+
108+
expect(responses[0].status).toBe(200)
109+
})
110+
})
111+
112+
describe('createAudience', () => {
113+
it('sends the create request to the standard API_VERSION URL when the canary flag is off', async () => {
114+
nock(`${BASE_URL}/${TEST_API_VERSION}/act_${adAccountId}`)
115+
.post('/customaudiences')
116+
.reply(200, { id: '88888888888888888' })
117+
118+
const result = await testDestination.createAudience({
119+
settings,
120+
audienceName: 'Test Audience',
121+
audienceSettings: { engageAdAccountId: adAccountId, audienceDescription: 'Test' },
122+
features: { [FACEBOOK_CUSTOM_AUDIENCE_FLAGON]: false }
123+
})
124+
125+
expect(result).toEqual({ externalId: '88888888888888888' })
126+
})
127+
128+
it('sends the create request to the CANARY_API_VERSION URL when the canary flag is on', async () => {
129+
nock(`${BASE_URL}/${TEST_CANARY_API_VERSION}/act_${adAccountId}`)
130+
.post('/customaudiences')
131+
.reply(200, { id: '88888888888888888' })
132+
133+
const result = await testDestination.createAudience({
134+
settings,
135+
audienceName: 'Test Audience',
136+
audienceSettings: { engageAdAccountId: adAccountId, audienceDescription: 'Test' },
137+
features: { [FACEBOOK_CUSTOM_AUDIENCE_FLAGON]: true }
138+
})
139+
140+
expect(result).toEqual({ externalId: '88888888888888888' })
141+
})
142+
})
143+
144+
describe('getAudience', () => {
145+
it('sends the get request to the standard API_VERSION URL when the canary flag is off', async () => {
146+
nock(`${BASE_URL}/${TEST_API_VERSION}`)
147+
.get(`/${audienceId}`)
148+
.query({ fields: 'id,name' })
149+
.reply(200, { id: audienceId, name: 'Test Audience' })
150+
151+
const result = await testDestination.getAudience({
152+
externalId: audienceId,
153+
settings,
154+
features: { [FACEBOOK_CUSTOM_AUDIENCE_FLAGON]: false }
155+
})
156+
157+
expect(result).toEqual({ externalId: audienceId })
158+
})
159+
160+
it('sends the get request to the CANARY_API_VERSION URL when the canary flag is on', async () => {
161+
nock(`${BASE_URL}/${TEST_CANARY_API_VERSION}`)
162+
.get(`/${audienceId}`)
163+
.query({ fields: 'id,name' })
164+
.reply(200, { id: audienceId, name: 'Test Audience' })
165+
166+
const result = await testDestination.getAudience({
167+
externalId: audienceId,
168+
settings,
169+
features: { [FACEBOOK_CUSTOM_AUDIENCE_FLAGON]: true }
170+
})
171+
172+
expect(result).toEqual({ externalId: audienceId })
173+
})
174+
})
175+
})

0 commit comments

Comments
 (0)