@mj-biz-apps/marketing-server 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/adConversionParity.test.js +649 -3
- package/dist/__tests__/adConversionParity.test.js.map +1 -1
- package/dist/agents/MarketingStrategyOnboardingAgent.d.ts.map +1 -1
- package/dist/agents/MarketingStrategyOnboardingAgent.js +5 -1
- package/dist/agents/MarketingStrategyOnboardingAgent.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts +7 -0
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js +29 -0
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/programs/ContentPostProgramDriver.d.ts.map +1 -1
- package/dist/programs/ContentPostProgramDriver.js +12 -1
- package/dist/programs/ContentPostProgramDriver.js.map +1 -1
- package/dist/programs/google/GoogleAdsClient.d.ts +46 -0
- package/dist/programs/google/GoogleAdsClient.d.ts.map +1 -1
- package/dist/programs/google/GoogleAdsClient.js +193 -4
- package/dist/programs/google/GoogleAdsClient.js.map +1 -1
- package/dist/programs/google/GoogleAdsProgramDriver.d.ts +37 -2
- package/dist/programs/google/GoogleAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/google/GoogleAdsProgramDriver.js +221 -21
- package/dist/programs/google/GoogleAdsProgramDriver.js.map +1 -1
- package/dist/programs/google/googleAdsExecutionContext.d.ts +6 -0
- package/dist/programs/google/googleAdsExecutionContext.d.ts.map +1 -1
- package/dist/programs/google/googleAdsExecutionContext.js +9 -2
- package/dist/programs/google/googleAdsExecutionContext.js.map +1 -1
- package/dist/programs/index.d.ts +1 -1
- package/dist/programs/index.d.ts.map +1 -1
- package/dist/programs/index.js +1 -1
- package/dist/programs/index.js.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsClient.d.ts +38 -4
- package/dist/programs/linkedin/LinkedInAdsClient.d.ts.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsClient.js +110 -7
- package/dist/programs/linkedin/LinkedInAdsClient.js.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts +13 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.js +270 -33
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.js.map +1 -1
- package/dist/programs/linkedin/linkedInExecutionContext.d.ts +24 -1
- package/dist/programs/linkedin/linkedInExecutionContext.d.ts.map +1 -1
- package/dist/programs/linkedin/linkedInExecutionContext.js +41 -15
- package/dist/programs/linkedin/linkedInExecutionContext.js.map +1 -1
- package/dist/programs/meta/MetaAdsClient.d.ts +25 -0
- package/dist/programs/meta/MetaAdsClient.d.ts.map +1 -1
- package/dist/programs/meta/MetaAdsClient.js +60 -3
- package/dist/programs/meta/MetaAdsClient.js.map +1 -1
- package/dist/programs/meta/MetaAdsProgramDriver.d.ts +12 -3
- package/dist/programs/meta/MetaAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/meta/MetaAdsProgramDriver.js +152 -18
- package/dist/programs/meta/MetaAdsProgramDriver.js.map +1 -1
- package/dist/programs/meta/metaAdsExecutionContext.d.ts +2 -0
- package/dist/programs/meta/metaAdsExecutionContext.d.ts.map +1 -1
- package/dist/programs/meta/metaAdsExecutionContext.js +6 -3
- package/dist/programs/meta/metaAdsExecutionContext.js.map +1 -1
- package/package.json +6 -6
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { GoogleAdsClient } from '../programs/google/GoogleAdsClient.js';
|
|
3
|
+
import { buildGoogleCampaignTargetingState, GoogleAdsProgramDriver } from '../programs/google/GoogleAdsProgramDriver.js';
|
|
4
|
+
import { LinkedInAdsClient, linkedInConversionTypeFor } from '../programs/linkedin/LinkedInAdsClient.js';
|
|
5
|
+
import { LinkedInAdsProgramDriver } from '../programs/linkedin/LinkedInAdsProgramDriver.js';
|
|
3
6
|
import { MetaAdsClient, customConversionActionType, metaCallToActionFor, metaObjectiveFor } from '../programs/meta/MetaAdsClient.js';
|
|
4
7
|
import { MetaAdsProgramDriver } from '../programs/meta/MetaAdsProgramDriver.js';
|
|
5
8
|
function googleClient(answer) {
|
|
@@ -37,11 +40,17 @@ describe('GoogleAdsClient — conversion actions (D-34 parity)', () => {
|
|
|
37
40
|
const create = calls.find(c => c.url.includes('customConversionGoals:mutate'));
|
|
38
41
|
expect(JSON.parse(String(create.init?.body))).toEqual({ operations: [{ create: { name: 'Marketing: Ascend Book Download - Google - Form Submission', conversionActions: ['customers/4684433735/conversionActions/7845520214'], status: 'ENABLED' } }] });
|
|
39
42
|
await client.setCampaignConversionGoal('customers/4684433735/campaigns/24313683017', goal);
|
|
40
|
-
const
|
|
41
|
-
expect(JSON.parse(String(
|
|
43
|
+
const goalConfigCalls = calls.filter(c => c.url.includes('conversionGoalCampaignConfigs:mutate'));
|
|
44
|
+
expect(JSON.parse(String(goalConfigCalls[0].init?.body))).toEqual({ operations: [{
|
|
42
45
|
update: { resourceName: 'customers/4684433735/conversionGoalCampaignConfigs/24313683017', goalConfigLevel: 'CAMPAIGN', customConversionGoal: goal },
|
|
43
46
|
updateMask: 'goal_config_level,custom_conversion_goal',
|
|
44
47
|
}] });
|
|
48
|
+
await client.useCustomerConversionGoals('customers/4684433735/campaigns/24313683017');
|
|
49
|
+
const customerGoalCalls = calls.filter(c => c.url.includes('conversionGoalCampaignConfigs:mutate'));
|
|
50
|
+
expect(JSON.parse(String(customerGoalCalls[1].init?.body))).toEqual({ operations: [{
|
|
51
|
+
update: { resourceName: 'customers/4684433735/conversionGoalCampaignConfigs/24313683017', goalConfigLevel: 'CUSTOMER' },
|
|
52
|
+
updateMask: 'goal_config_level',
|
|
53
|
+
}] });
|
|
45
54
|
// Google allows ONE goal per action list on the customer: an existing goal holding exactly this action is reused
|
|
46
55
|
// as is (whoever made it, whatever its name), a goal holding more actions is not a match.
|
|
47
56
|
const { client: adoptingClient, calls: adoptCalls } = googleClient(() => [{ results: [
|
|
@@ -63,7 +72,7 @@ describe('GoogleAdsClient — conversion actions (D-34 parity)', () => {
|
|
|
63
72
|
const queries = calls.filter(c => c.url.includes('searchStream')).map(queryOf);
|
|
64
73
|
expect(queries).toHaveLength(2);
|
|
65
74
|
expect(queries[0]).not.toContain('segments.conversion_action');
|
|
66
|
-
expect(queries[1]).toContain("SELECT metrics.all_conversions FROM campaign WHERE campaign.resource_name IN ('customers/4684433735/campaigns/1') AND segments.date BETWEEN '2026-10-01' AND '2026-10-14' AND segments.conversion_action = 'customers/4684433735/conversionActions/7845520214'");
|
|
75
|
+
expect(queries[1]).toContain("SELECT segments.conversion_action, metrics.all_conversions FROM campaign WHERE campaign.resource_name IN ('customers/4684433735/campaigns/1') AND segments.date BETWEEN '2026-10-01' AND '2026-10-14' AND segments.conversion_action = 'customers/4684433735/conversionActions/7845520214'");
|
|
67
76
|
const plain = await client.campaignMetrics(['customers/4684433735/campaigns/1'], new Date('2026-10-01T00:00:00Z'), new Date('2026-10-14T00:00:00Z'));
|
|
68
77
|
expect(plain.conversions).toBe(0);
|
|
69
78
|
expect(calls.filter(c => c.url.includes('searchStream'))).toHaveLength(3);
|
|
@@ -161,5 +170,642 @@ describe('MetaAdsClient — custom conversions (D-34 parity)', () => {
|
|
|
161
170
|
const short = await driver.validateProposal(makeProgram('Your board expects an AI plan. Ascend gives you the roadmap.'));
|
|
162
171
|
expect(short.find(i => i.code === 'MA_PRIMARY_TEXT_LONG')).toBeUndefined();
|
|
163
172
|
});
|
|
173
|
+
it('creates a URL-scoped Lead custom conversion on the brand\'s Pixel and adopts an enabled namesake (KI-152)', async () => {
|
|
174
|
+
const { client, calls } = metaClient((url) => url.pathname.endsWith('/customconversions') && url.searchParams.has('fields')
|
|
175
|
+
? { data: [{ id: '9001', name: 'CX-10 — lead conversion', custom_event_type: 'LEAD', is_archived: false, pixel: { id: '415938279713144' } }] }
|
|
176
|
+
: { id: '9002' });
|
|
177
|
+
const adopted = await client.findCustomConversionByName('CX-10 — lead conversion');
|
|
178
|
+
expect(adopted).toMatchObject({ id: '9001', name: 'CX-10 — lead conversion', customEventType: 'LEAD', pixelId: '415938279713144' });
|
|
179
|
+
const created = await client.createUrlCustomConversion({ name: 'CX-11 — lead conversion', pixelId: '415938279713144', urlContains: 'sidecar.ai/ai' });
|
|
180
|
+
expect(created).toEqual({ id: '9002', name: 'CX-11 — lead conversion', enabled: true, customEventType: 'LEAD', pixelId: '415938279713144' });
|
|
181
|
+
const post = calls[1].body;
|
|
182
|
+
expect(post.get('event_source_id')).toBe('415938279713144');
|
|
183
|
+
expect(post.get('custom_event_type')).toBe('LEAD');
|
|
184
|
+
expect(JSON.parse(post.get('rule'))).toEqual({ and: [{ event: { eq: 'Lead' } }, { URL: { i_contains: 'sidecar.ai/ai' } }] });
|
|
185
|
+
});
|
|
186
|
+
it('lists Meta saved audiences and merges their targeting spec onto the ad set (KI-144)', async () => {
|
|
187
|
+
const { client, calls } = metaClient((url) => url.pathname.endsWith('/saved_audiences')
|
|
188
|
+
? { data: [{ id: '6002002', name: 'SC | Association Leaders', approximate_count_64bit: 420_000, targeting: { flexible_spec: [{ interests: [{ id: '6003', name: 'Association' }] }] } }] }
|
|
189
|
+
: { id: '3300' });
|
|
190
|
+
const audiences = await client.savedAudiences();
|
|
191
|
+
expect(audiences).toEqual([{
|
|
192
|
+
id: '6002002', name: 'SC | Association Leaders', approximateMemberCount: 420_000,
|
|
193
|
+
targeting: { flexible_spec: [{ interests: [{ id: '6003', name: 'Association' }] }] },
|
|
194
|
+
}]);
|
|
195
|
+
await client.createAdSet({
|
|
196
|
+
name: 'CX-12 — ad set', campaignId: '120', dailyBudgetUsd: 25, status: 'PAUSED',
|
|
197
|
+
runSchedule: { start: 1_792_000_000_000 }, countries: ['US'],
|
|
198
|
+
savedAudienceTargeting: audiences[0].targeting,
|
|
199
|
+
});
|
|
200
|
+
const targeting = JSON.parse(calls[1].body.get('targeting'));
|
|
201
|
+
expect(targeting).toEqual({
|
|
202
|
+
flexible_spec: [{ interests: [{ id: '6003', name: 'Association' }] }],
|
|
203
|
+
geo_locations: { countries: ['US'] },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
it('skips ARCHIVED or DELETED campaigns and ad sets in Meta findByName (KI-158)', async () => {
|
|
207
|
+
const { client } = metaClient(() => ({ data: [
|
|
208
|
+
{ id: '1', name: 'CX-01', status: 'ARCHIVED' },
|
|
209
|
+
{ id: '2', name: 'CX-01', status: 'PAUSED' },
|
|
210
|
+
] }));
|
|
211
|
+
expect(await client.findByName('campaign', 'CX-01')).toBe('2');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe('GoogleAdsClient — user lists, geo targeting, and schedule dates (KI-158, KI-159, KI-160)', () => {
|
|
215
|
+
it('filters user_list findByName to OPEN lists and reopens a CLOSED namesake (KI-158)', async () => {
|
|
216
|
+
const { client, calls } = googleClient((url, body) => {
|
|
217
|
+
if (url.includes('userLists:mutate'))
|
|
218
|
+
return { results: [{ resourceName: 'customers/4684433735/userLists/77' }] };
|
|
219
|
+
const q = body.query;
|
|
220
|
+
return q.includes("membership_status = 'CLOSED'")
|
|
221
|
+
? [{ results: [{ userList: { resourceName: 'customers/4684433735/userLists/77' } }] }]
|
|
222
|
+
: [];
|
|
223
|
+
});
|
|
224
|
+
expect(await client.findByName('user_list', 'CG-01 site visitors — 90d')).toBeNull();
|
|
225
|
+
expect(queryOf(calls[1])).toContain("AND user_list.membership_status = 'OPEN'");
|
|
226
|
+
const closed = await client.findClosedUserListByName('CG-01 site visitors — 90d');
|
|
227
|
+
expect(closed).toBe('customers/4684433735/userLists/77');
|
|
228
|
+
await client.reopenUserList(closed);
|
|
229
|
+
const reopen = calls.find(c => c.url.includes('userLists:mutate'));
|
|
230
|
+
expect(JSON.parse(String(reopen.init?.body))).toEqual({
|
|
231
|
+
operations: [{ update: { resourceName: 'customers/4684433735/userLists/77', membershipStatus: 'OPEN' }, updateMask: 'membership_status' }],
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
it('lists OPEN user lists and attaches the US geo criterion idempotently (KI-159)', async () => {
|
|
235
|
+
const { client, calls } = googleClient((url, body) => {
|
|
236
|
+
if (url.includes('campaignCriteria:mutate'))
|
|
237
|
+
return { results: [{ resourceName: 'customers/4684433735/campaignCriteria/1~2840' }] };
|
|
238
|
+
const q = body.query;
|
|
239
|
+
if (q.includes('FROM user_list')) {
|
|
240
|
+
return [{ results: [{ userList: { id: '8001001', resourceName: 'customers/4684433735/userLists/8001001', name: 'All Users of sidecar.ai', membershipStatus: 'OPEN', sizeForDisplay: 18500 } }] }];
|
|
241
|
+
}
|
|
242
|
+
return [];
|
|
243
|
+
});
|
|
244
|
+
const lists = await client.userLists();
|
|
245
|
+
expect(lists).toEqual([{ id: '8001001', resourceName: 'customers/4684433735/userLists/8001001', name: 'All Users of sidecar.ai', approximateMemberCount: 18500 }]);
|
|
246
|
+
await client.attachCampaignLocation('customers/4684433735/campaigns/99');
|
|
247
|
+
const create = calls.find(c => c.url.includes('campaignCriteria:mutate'));
|
|
248
|
+
expect(JSON.parse(String(create.init?.body))).toEqual({
|
|
249
|
+
operations: [{ create: { campaign: 'customers/4684433735/campaigns/99', location: { geoTargetConstant: 'geoTargetConstants/2840' } } }],
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
it('stamps startDate and endDate on Google campaigns at creation and updates them on a window move (KI-160)', async () => {
|
|
253
|
+
const { client, calls } = googleClient(() => ({ results: [{ resourceName: 'customers/4684433735/campaigns/99' }] }));
|
|
254
|
+
const start = new Date('2026-11-01T00:00:00Z').getTime();
|
|
255
|
+
const end = new Date('2026-11-15T00:00:00Z').getTime();
|
|
256
|
+
await client.createCampaign({ name: 'CG-10', budgetResourceName: 'customers/4684433735/campaignBudgets/1', status: 'PAUSED', runSchedule: { start, end } });
|
|
257
|
+
const createBody = JSON.parse(String(calls[1].init?.body));
|
|
258
|
+
expect(createBody.operations[0].create['startDate']).toBe('2026-11-01');
|
|
259
|
+
expect(createBody.operations[0].create['endDate']).toBe('2026-11-15');
|
|
260
|
+
expect(createBody.operations[0].create['targetSpend']).toEqual({ cpcBidCeilingMicros: '2000000' });
|
|
261
|
+
expect(createBody.operations[0].create['geoTargetTypeSetting']).toEqual({ positiveGeoTargetType: 'PRESENCE' });
|
|
262
|
+
await client.setCampaignSchedule('customers/4684433735/campaigns/99', { start, end: new Date('2026-11-22T00:00:00Z').getTime() });
|
|
263
|
+
const updateBody = JSON.parse(String(calls[2].init?.body));
|
|
264
|
+
expect(updateBody.operations[0].update['endDate']).toBe('2026-11-22');
|
|
265
|
+
expect(updateBody.operations[0].updateMask).toContain('end_date');
|
|
266
|
+
});
|
|
267
|
+
it('attaches multi-location, language, and negative mobile-app criteria idempotently and updates delivery settings (D-37, KI-164)', async () => {
|
|
268
|
+
const { client, calls } = googleClient((url, body) => {
|
|
269
|
+
if (url.includes('campaignCriteria:mutate') || url.includes('campaigns:mutate')) {
|
|
270
|
+
return { results: [{ resourceName: 'customers/4684433735/campaignCriteria/99~1' }] };
|
|
271
|
+
}
|
|
272
|
+
const q = body.query;
|
|
273
|
+
if (q.includes('campaign_criterion.location.geo_target_constant')) {
|
|
274
|
+
return [{ results: [{ campaignCriterion: { location: { geoTargetConstant: 'geoTargetConstants/2840' } } }] }];
|
|
275
|
+
}
|
|
276
|
+
return [];
|
|
277
|
+
});
|
|
278
|
+
await client.attachCampaignLocations('customers/4684433735/campaigns/99', ['geoTargetConstants/2840', 'geoTargetConstants/2124']);
|
|
279
|
+
const locMutate = calls.find(c => c.url.includes('campaignCriteria:mutate'));
|
|
280
|
+
expect(JSON.parse(String(locMutate.init?.body))).toEqual({
|
|
281
|
+
operations: [{ create: { campaign: 'customers/4684433735/campaigns/99', location: { geoTargetConstant: 'geoTargetConstants/2124' } } }],
|
|
282
|
+
});
|
|
283
|
+
await client.attachCampaignLanguages('customers/4684433735/campaigns/99', ['languageConstants/1000']);
|
|
284
|
+
const criteriaMutates = calls.filter(c => c.url.includes('campaignCriteria:mutate'));
|
|
285
|
+
expect(JSON.parse(String(criteriaMutates[1].init?.body))).toEqual({
|
|
286
|
+
operations: [{ create: { campaign: 'customers/4684433735/campaigns/99', language: { languageConstant: 'languageConstants/1000' } } }],
|
|
287
|
+
});
|
|
288
|
+
await client.excludeCampaignMobileApps('customers/4684433735/campaigns/99');
|
|
289
|
+
const criteriaMutatesAfterExclude = calls.filter(c => c.url.includes('campaignCriteria:mutate'));
|
|
290
|
+
expect(JSON.parse(String(criteriaMutatesAfterExclude[2].init?.body))).toEqual({
|
|
291
|
+
operations: [{
|
|
292
|
+
create: {
|
|
293
|
+
campaign: 'customers/4684433735/campaigns/99',
|
|
294
|
+
negative: true,
|
|
295
|
+
mobileAppCategory: { mobileAppCategoryConstant: 'mobileAppCategoryConstants/69500' },
|
|
296
|
+
},
|
|
297
|
+
}],
|
|
298
|
+
});
|
|
299
|
+
await client.updateCampaignDeliverySettings('customers/4684433735/campaigns/99', { maxCpcUsd: 2 });
|
|
300
|
+
const campaignMutates = calls.filter(c => c.url.includes('campaigns:mutate'));
|
|
301
|
+
expect(JSON.parse(String(campaignMutates[0].init?.body))).toEqual({
|
|
302
|
+
operations: [{
|
|
303
|
+
update: {
|
|
304
|
+
resourceName: 'customers/4684433735/campaigns/99',
|
|
305
|
+
targetSpend: { cpcBidCeilingMicros: '2000000' },
|
|
306
|
+
geoTargetTypeSetting: { positiveGeoTargetType: 'PRESENCE' },
|
|
307
|
+
},
|
|
308
|
+
updateMask: 'target_spend.cpc_bid_ceiling_micros,geo_target_type_setting.positive_geo_target_type',
|
|
309
|
+
}],
|
|
310
|
+
});
|
|
311
|
+
await client.setCampaignMaximizeConversions('customers/4684433735/campaigns/99');
|
|
312
|
+
const campaignMutatesAfterMaxConv = calls.filter(c => c.url.includes('campaigns:mutate'));
|
|
313
|
+
expect(JSON.parse(String(campaignMutatesAfterMaxConv[1].init?.body))).toEqual({
|
|
314
|
+
operations: [{
|
|
315
|
+
update: {
|
|
316
|
+
resourceName: 'customers/4684433735/campaigns/99',
|
|
317
|
+
maximizeConversions: {},
|
|
318
|
+
},
|
|
319
|
+
updateMask: 'maximize_conversions',
|
|
320
|
+
}],
|
|
321
|
+
});
|
|
322
|
+
expect(buildGoogleCampaignTargetingState({
|
|
323
|
+
maxCpcUsd: 2,
|
|
324
|
+
locationsAttached: ['geoTargetConstants/2840', 'geoTargetConstants/2124'],
|
|
325
|
+
languagesAttached: ['languageConstants/1000'],
|
|
326
|
+
mobileAppsExcluded: true,
|
|
327
|
+
})).toEqual({
|
|
328
|
+
bidding: 'TARGET_SPEND',
|
|
329
|
+
maxCpcUsd: 2,
|
|
330
|
+
positiveGeoTargetType: 'PRESENCE',
|
|
331
|
+
locationsAttached: ['geoTargetConstants/2840', 'geoTargetConstants/2124'],
|
|
332
|
+
languagesAttached: ['languageConstants/1000'],
|
|
333
|
+
mobileAppsExcluded: true,
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
it('emits notice-level parity issues when Google-only targeting/bidding levers are passed to LinkedIn or Meta (D-37)', async () => {
|
|
337
|
+
const tacticConfigJSON = JSON.stringify({
|
|
338
|
+
audience: 'targeted',
|
|
339
|
+
dailyBudgetUsd: 25,
|
|
340
|
+
maxCpcUsd: 2,
|
|
341
|
+
locations: ['US', 'CA'],
|
|
342
|
+
languages: ['en'],
|
|
343
|
+
landingPageUrl: 'https://example.org/ai',
|
|
344
|
+
optimizeFor: 'clicks',
|
|
345
|
+
creativeHeadlines: ['Master AI Strategy', 'AI Roadmap for Leaders'],
|
|
346
|
+
creativeDescriptions: ['Download the free ebook.'],
|
|
347
|
+
});
|
|
348
|
+
const liDriver = new LinkedInAdsProgramDriver();
|
|
349
|
+
const liIssues = await liDriver.validateProposal({ id: 'P1', brandId: 'B1', params: { campaignCode: 'CL-01', tacticConfigJSON } });
|
|
350
|
+
expect(liIssues.map(i => [i.code, i.severity])).toEqual([
|
|
351
|
+
['LI_MAX_CPC_IGNORED', 'notice'],
|
|
352
|
+
['LI_LOCATIONS_IN_AUDIENCE', 'notice'],
|
|
353
|
+
['LI_LANGUAGES_IN_AUDIENCE', 'notice'],
|
|
354
|
+
]);
|
|
355
|
+
const metaDriver = new MetaAdsProgramDriver();
|
|
356
|
+
const metaIssues = await metaDriver.validateProposal({ id: 'P2', brandId: 'B1', params: { campaignCode: 'CM-01', tacticConfigJSON } });
|
|
357
|
+
expect(metaIssues.map(i => [i.code, i.severity])).toEqual([
|
|
358
|
+
['MA_MAX_CPC_IGNORED', 'notice'],
|
|
359
|
+
['MA_LOCATIONS_IGNORED', 'notice'],
|
|
360
|
+
['MA_LANGUAGES_IGNORED', 'notice'],
|
|
361
|
+
]);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
function linkedInClient(answer) {
|
|
365
|
+
const calls = [];
|
|
366
|
+
const fetchImpl = (async (input, init) => {
|
|
367
|
+
const url = String(input);
|
|
368
|
+
calls.push({ url, init });
|
|
369
|
+
if (url.endsWith('/accessToken')) {
|
|
370
|
+
return new Response(JSON.stringify({ access_token: 'li-tok', expires_in: 3600, scope: 'r_ads,rw_ads,r_ads_reporting,rw_dmp_segments,rw_conversions' }), { status: 200 });
|
|
371
|
+
}
|
|
372
|
+
const res = answer(url, init);
|
|
373
|
+
return new Response(res.body === null ? null : JSON.stringify(res.body), {
|
|
374
|
+
status: res.status ?? 200,
|
|
375
|
+
headers: res.headers,
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
return { client: new LinkedInAdsClient({ clientId: 'c', clientSecret: 's', refreshToken: 'r', adAccountId: '509410093', fetchImpl }), calls };
|
|
379
|
+
}
|
|
380
|
+
describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CONVERSION analytics (D-34, KI-148, KI-152)', () => {
|
|
381
|
+
it('lists conversions, adopts an enabled conversion by name, creates a URL conversion rule, and maps every CTA to LinkedIn ConversionType', async () => {
|
|
382
|
+
const { client, calls } = linkedInClient((url, init) => {
|
|
383
|
+
if (init?.method === 'POST' && url.endsWith('/rest/conversions')) {
|
|
384
|
+
return { status: 201, headers: { 'x-restli-id': '32001001' }, body: {} };
|
|
385
|
+
}
|
|
386
|
+
if (init?.method === 'PUT' && url.includes('/rest/campaignConversions/')) {
|
|
387
|
+
return { status: 204, body: null };
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
body: {
|
|
391
|
+
elements: [
|
|
392
|
+
{ id: 29519276, name: 'SC | Ascend Download', type: 'DOWNLOAD', enabled: true },
|
|
393
|
+
{ id: 29325716, name: 'CP-04 — lead conversion', type: 'LEAD', enabled: true },
|
|
394
|
+
{ id: 100, name: 'CP-05 — lead conversion', type: 'LEAD', enabled: false },
|
|
395
|
+
],
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
});
|
|
399
|
+
const list = await client.conversions();
|
|
400
|
+
expect(list).toEqual([
|
|
401
|
+
{ id: '29519276', urn: 'urn:lla:llaPartnerConversion:29519276', name: 'SC | Ascend Download', type: 'DOWNLOAD', enabled: true },
|
|
402
|
+
{ id: '29325716', urn: 'urn:lla:llaPartnerConversion:29325716', name: 'CP-04 — lead conversion', type: 'LEAD', enabled: true },
|
|
403
|
+
{ id: '100', urn: 'urn:lla:llaPartnerConversion:100', name: 'CP-05 — lead conversion', type: 'LEAD', enabled: false },
|
|
404
|
+
]);
|
|
405
|
+
expect(await client.findConversionById(' 29519276 ')).toMatchObject({ id: '29519276', name: 'SC | Ascend Download' });
|
|
406
|
+
expect(await client.findConversionByName('CP-04 — lead conversion')).toMatchObject({ id: '29325716', urn: 'urn:lla:llaPartnerConversion:29325716' });
|
|
407
|
+
// Disabled conversions are not adopted by name
|
|
408
|
+
expect(await client.findConversionByName('CP-05 — lead conversion')).toBeNull();
|
|
409
|
+
const created = await client.createUrlConversion({
|
|
410
|
+
name: 'CP-06 — lead conversion',
|
|
411
|
+
urlContains: '/ebook-thank-you',
|
|
412
|
+
type: linkedInConversionTypeFor('DOWNLOAD'),
|
|
413
|
+
});
|
|
414
|
+
expect(created).toEqual({
|
|
415
|
+
id: '32001001',
|
|
416
|
+
urn: 'urn:lla:llaPartnerConversion:32001001',
|
|
417
|
+
name: 'CP-06 — lead conversion',
|
|
418
|
+
type: 'DOWNLOAD',
|
|
419
|
+
enabled: true,
|
|
420
|
+
});
|
|
421
|
+
const postCall = calls.find(c => c.init?.method === 'POST' && c.url.endsWith('/rest/conversions'));
|
|
422
|
+
expect(JSON.parse(String(postCall.init?.body))).toEqual({
|
|
423
|
+
account: 'urn:li:sponsoredAccount:509410093',
|
|
424
|
+
name: 'CP-06 — lead conversion',
|
|
425
|
+
type: 'DOWNLOAD',
|
|
426
|
+
conversionMethod: 'INSIGHT_TAG_URL_MATCH_RULES',
|
|
427
|
+
postClickAttributionWindowSize: 30,
|
|
428
|
+
viewThroughAttributionWindowSize: 7,
|
|
429
|
+
attributionType: 'LAST_TOUCH_BY_CONVERSION',
|
|
430
|
+
urlMatchRuleExpression: [[{ matchType: 'CONTAINS', matchValue: '/ebook-thank-you' }]],
|
|
431
|
+
});
|
|
432
|
+
await client.associateConversion('urn:li:sponsoredCampaign:919676896', created.urn);
|
|
433
|
+
const putCall = calls.find(c => c.init?.method === 'PUT' && c.url.includes('/rest/campaignConversions/'));
|
|
434
|
+
expect(putCall.url).toContain('(campaign:urn%3Ali%3AsponsoredCampaign%3A919676896,conversion:urn%3Alla%3AllaPartnerConversion%3A32001001)');
|
|
435
|
+
let putAttempts = 0;
|
|
436
|
+
const { client: retryClient } = linkedInClient((url) => {
|
|
437
|
+
if (url.includes('/rest/campaignConversions/')) {
|
|
438
|
+
putAttempts++;
|
|
439
|
+
if (putAttempts === 1)
|
|
440
|
+
return { status: 404, body: { message: 'Requester does not have permission to UPDATE the resource.', status: 404 } };
|
|
441
|
+
return { status: 204, body: null };
|
|
442
|
+
}
|
|
443
|
+
return { body: { elements: [] } };
|
|
444
|
+
});
|
|
445
|
+
await retryClient.associateConversion('urn:li:sponsoredCampaign:898478323', created.urn, [1]);
|
|
446
|
+
expect(putAttempts).toBe(2);
|
|
447
|
+
expect(linkedInConversionTypeFor('DOWNLOAD')).toBe('DOWNLOAD');
|
|
448
|
+
expect(linkedInConversionTypeFor('REGISTER')).toBe('REGISTER_EVENT');
|
|
449
|
+
expect(linkedInConversionTypeFor('ATTEND')).toBe('REGISTER_EVENT');
|
|
450
|
+
expect(linkedInConversionTypeFor('SUBSCRIBE')).toBe('SUBSCRIBE');
|
|
451
|
+
expect(linkedInConversionTypeFor('SIGN_UP')).toBe('SIGN_UP');
|
|
452
|
+
expect(linkedInConversionTypeFor('JOIN')).toBe('SIGN_UP');
|
|
453
|
+
expect(linkedInConversionTypeFor('VIEW_QUOTE')).toBe('REQUEST_QUOTE');
|
|
454
|
+
expect(linkedInConversionTypeFor('REQUEST_DEMO')).toBe('LEAD');
|
|
455
|
+
expect(linkedInConversionTypeFor('LEARN_MORE')).toBe('LEAD');
|
|
456
|
+
expect(linkedInConversionTypeFor(null)).toBe('LEAD');
|
|
457
|
+
});
|
|
458
|
+
it('scopes externalWebsiteConversions via pivot=CONVERSION when conversionId is provided, and uses pivot=CAMPAIGN alone for clicks programs (D-34 addendum)', async () => {
|
|
459
|
+
const { client, calls } = linkedInClient((url) => {
|
|
460
|
+
if (url.includes('pivot=CONVERSION')) {
|
|
461
|
+
return {
|
|
462
|
+
body: {
|
|
463
|
+
elements: [
|
|
464
|
+
{ externalWebsiteConversions: 5, pivotValues: ['urn:lla:llaPartnerConversion:25692636'] },
|
|
465
|
+
{ externalWebsiteConversions: 2, pivotValues: ['urn:lla:llaPartnerConversion:29519276'] },
|
|
466
|
+
{ externalWebsiteConversions: 1, pivotValues: ['urn:lla:llaPartnerConversion:18994420'] },
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
body: {
|
|
473
|
+
elements: [
|
|
474
|
+
{ impressions: 1400, clicks: 35, landingPageClicks: 28, externalWebsiteConversions: 8, costInLocalCurrency: '42.75' },
|
|
475
|
+
],
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
const scoped = await client.campaignAnalytics(['urn:li:sponsoredCampaign:919676896'], new Date('2026-11-01T00:00:00Z'), new Date('2026-11-15T00:00:00Z'), '29519276');
|
|
480
|
+
expect(scoped).toEqual({
|
|
481
|
+
impressions: 1400,
|
|
482
|
+
clicks: 35,
|
|
483
|
+
landingPageClicks: 28,
|
|
484
|
+
externalWebsiteConversions: 2,
|
|
485
|
+
spendUsd: 42.75,
|
|
486
|
+
});
|
|
487
|
+
const analyticsCalls = calls.filter(c => c.url.includes('/adAnalytics'));
|
|
488
|
+
expect(analyticsCalls).toHaveLength(2);
|
|
489
|
+
expect(analyticsCalls[0].url).toContain('pivot=CAMPAIGN');
|
|
490
|
+
expect(analyticsCalls[1].url).toContain('pivot=CONVERSION');
|
|
491
|
+
expect(analyticsCalls[1].url).toContain('fields=externalWebsiteConversions,pivotValues');
|
|
492
|
+
const unsegmented = await client.campaignAnalytics(['urn:li:sponsoredCampaign:919676896'], new Date('2026-11-01T00:00:00Z'), new Date('2026-11-15T00:00:00Z'));
|
|
493
|
+
expect(unsegmented.externalWebsiteConversions).toBe(8);
|
|
494
|
+
expect(calls.filter(c => c.url.includes('/adAnalytics'))).toHaveLength(3);
|
|
495
|
+
});
|
|
496
|
+
it('refuses conversionRule on Google Ads with GA_CONVERSION_RULE_UNSUPPORTED (driver parity)', async () => {
|
|
497
|
+
const gDriver = new GoogleAdsProgramDriver();
|
|
498
|
+
const tacticConfigJSON = JSON.stringify({
|
|
499
|
+
audience: 'targeted',
|
|
500
|
+
dailyBudgetUsd: 25,
|
|
501
|
+
landingPageUrl: 'https://example.org/ai',
|
|
502
|
+
conversionRule: '/ebook-thank-you',
|
|
503
|
+
creativeHeadlines: ['Master AI Strategy'],
|
|
504
|
+
creativeDescriptions: ['Download the free ebook.'],
|
|
505
|
+
});
|
|
506
|
+
const issues = await gDriver.validateProposal({ id: 'P3', brandId: 'B1', params: { campaignCode: 'CG-05', tacticConfigJSON } });
|
|
507
|
+
expect(issues.find(i => i.code === 'GA_CONVERSION_RULE_UNSUPPORTED')).toMatchObject({
|
|
508
|
+
severity: 'error',
|
|
509
|
+
code: 'GA_CONVERSION_RULE_UNSUPPORTED',
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
it('detaches a conversion via DELETE /campaignConversions and treats 404 as already detached', async () => {
|
|
513
|
+
let count = 0;
|
|
514
|
+
const { client, calls } = linkedInClient((url, init) => {
|
|
515
|
+
if (init?.method === 'DELETE' && url.includes('/rest/campaignConversions/')) {
|
|
516
|
+
count += 1;
|
|
517
|
+
return count === 1 ? { status: 204, body: null } : { status: 404, body: { message: 'Not found' } };
|
|
518
|
+
}
|
|
519
|
+
return { body: {} };
|
|
520
|
+
});
|
|
521
|
+
await client.disassociateConversion('urn:li:sponsoredCampaign:910066056', 'urn:lla:llaPartnerConversion:18994420');
|
|
522
|
+
await client.disassociateConversion('urn:li:sponsoredCampaign:910066056', 'urn:lla:llaPartnerConversion:18994420');
|
|
523
|
+
const deletes = calls.filter(c => c.init?.method === 'DELETE');
|
|
524
|
+
expect(deletes).toHaveLength(2);
|
|
525
|
+
expect(deletes[0].url).toContain('/rest/campaignConversions/(campaign:urn%3Ali%3AsponsoredCampaign%3A910066056,conversion:urn%3Alla%3AllaPartnerConversion%3A18994420)');
|
|
526
|
+
});
|
|
527
|
+
it('attaches connection trackingConversions on LinkedIn lock without changing state.conversion or optimizeFor, and detaches removed standing conversions on re-lock', async () => {
|
|
528
|
+
const associated = [];
|
|
529
|
+
const disassociated = [];
|
|
530
|
+
const savedRefs = [];
|
|
531
|
+
const fakeUser = { ID: 'u1' };
|
|
532
|
+
let currentTracking = [
|
|
533
|
+
{ ID: '30316052', Name: 'Self-Serve Checkout' },
|
|
534
|
+
{ ID: '29325716', Name: 'Demo Form Submit' },
|
|
535
|
+
];
|
|
536
|
+
const driver = new LinkedInAdsProgramDriver(fakeUser, async () => ({
|
|
537
|
+
ok: true,
|
|
538
|
+
brandConnectionId: 'bc-li',
|
|
539
|
+
adAccountId: '509410093',
|
|
540
|
+
launchState: 'Paused',
|
|
541
|
+
organizationUrn: 'urn:li:organization:900001',
|
|
542
|
+
defaultAudienceTemplateId: '70217227',
|
|
543
|
+
trackingConversions: currentTracking,
|
|
544
|
+
client: {
|
|
545
|
+
accountReference: async () => 'urn:li:organization:900001',
|
|
546
|
+
tokenScopes: async () => ['r_ads', 'rw_ads', 'rw_conversions'],
|
|
547
|
+
conversions: async () => [
|
|
548
|
+
{ id: '30316052', urn: 'urn:lla:llaPartnerConversion:30316052', name: 'Self-Serve Checkout', type: 'PURCHASE', enabled: true },
|
|
549
|
+
{ id: '29325716', urn: 'urn:lla:llaPartnerConversion:29325716', name: 'Demo Form Submit', type: 'LEAD', enabled: true },
|
|
550
|
+
],
|
|
551
|
+
findAudienceTemplateById: async () => ({ id: '70217227', name: 'Execs', approximateMemberCount: 50_000, targetingCriteria: {} }),
|
|
552
|
+
createCampaignGroup: async () => 'urn:li:sponsoredCampaignGroup:1',
|
|
553
|
+
createCampaign: async () => 'urn:li:sponsoredCampaign:2',
|
|
554
|
+
associateConversion: async (camp, conv) => { associated.push([camp, conv]); },
|
|
555
|
+
disassociateConversion: async (camp, conv) => { disassociated.push([camp, conv]); },
|
|
556
|
+
},
|
|
557
|
+
}));
|
|
558
|
+
driver['loadRefs'] = async () => [];
|
|
559
|
+
driver['runScheduleFor'] = async () => ({ start: 1_800_000_000_000 });
|
|
560
|
+
driver['saveRef'] = async (_pid, _bc, kind, urn, state) => {
|
|
561
|
+
savedRefs.push({ kind, urn, state });
|
|
562
|
+
};
|
|
563
|
+
const program = {
|
|
564
|
+
id: 'prog-li-1',
|
|
565
|
+
brandId: 'brand-1',
|
|
566
|
+
hypothesis: 'h',
|
|
567
|
+
metricName: 'Clicks',
|
|
568
|
+
params: {
|
|
569
|
+
campaignCode: 'CP-09',
|
|
570
|
+
tacticConfigJSON: JSON.stringify({
|
|
571
|
+
audience: 'targeted',
|
|
572
|
+
dailyBudgetUsd: 25,
|
|
573
|
+
landingPageUrl: 'https://example.org/ebook',
|
|
574
|
+
optimizeFor: 'clicks',
|
|
575
|
+
}),
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
await driver.onLock(program);
|
|
579
|
+
expect(associated).toEqual([
|
|
580
|
+
['urn:li:sponsoredCampaign:2', 'urn:lla:llaPartnerConversion:30316052'],
|
|
581
|
+
['urn:li:sponsoredCampaign:2', 'urn:lla:llaPartnerConversion:29325716'],
|
|
582
|
+
]);
|
|
583
|
+
const campaignRef = savedRefs.find(r => r.kind === 'Campaign');
|
|
584
|
+
expect(campaignRef?.state).toMatchObject({
|
|
585
|
+
conversion: null,
|
|
586
|
+
optimizeFor: 'clicks',
|
|
587
|
+
objective: 'WEBSITE_VISIT',
|
|
588
|
+
trackingConversions: [
|
|
589
|
+
{ id: '30316052', name: 'Self-Serve Checkout' },
|
|
590
|
+
{ id: '29325716', name: 'Demo Form Submit' },
|
|
591
|
+
],
|
|
592
|
+
});
|
|
593
|
+
// Re-lock after removing 29325716 from the connection: detaches 29325716 and updates StateJSON.trackingConversions.
|
|
594
|
+
associated.length = 0;
|
|
595
|
+
currentTracking = [{ ID: '30316052', Name: 'Self-Serve Checkout' }];
|
|
596
|
+
const existingCampaignRow = {
|
|
597
|
+
ExternalKind: 'Campaign',
|
|
598
|
+
ExternalURN: 'urn:li:sponsoredCampaign:2',
|
|
599
|
+
StateJSON: JSON.stringify(campaignRef?.state ?? {}),
|
|
600
|
+
Save: async () => true,
|
|
601
|
+
};
|
|
602
|
+
driver['loadRefs'] = async () => [
|
|
603
|
+
{ ExternalKind: 'CampaignGroup', ExternalURN: 'urn:li:sponsoredCampaignGroup:1', StateJSON: null, Save: async () => true },
|
|
604
|
+
existingCampaignRow,
|
|
605
|
+
];
|
|
606
|
+
await driver.onLock(program);
|
|
607
|
+
expect(disassociated).toEqual([
|
|
608
|
+
['urn:li:sponsoredCampaign:2', 'urn:lla:llaPartnerConversion:29325716'],
|
|
609
|
+
]);
|
|
610
|
+
expect(associated).toEqual([
|
|
611
|
+
['urn:li:sponsoredCampaign:2', 'urn:lla:llaPartnerConversion:30316052'],
|
|
612
|
+
]);
|
|
613
|
+
expect(JSON.parse(existingCampaignRow.StateJSON)).toMatchObject({
|
|
614
|
+
conversion: null,
|
|
615
|
+
optimizeFor: 'clicks',
|
|
616
|
+
trackingConversions: [{ id: '30316052', name: 'Self-Serve Checkout' }],
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
it('validates standing conversions at propose, connection save, and BEFORE onLock creates any group or campaign', async () => {
|
|
620
|
+
const fakeUser = { ID: 'u1' };
|
|
621
|
+
let scopes = ['r_ads', 'rw_ads', 'rw_conversions'];
|
|
622
|
+
let groupCreated = false;
|
|
623
|
+
let campaignCreated = false;
|
|
624
|
+
const driver = new LinkedInAdsProgramDriver(fakeUser, async () => ({
|
|
625
|
+
ok: true,
|
|
626
|
+
brandConnectionId: 'bc-li',
|
|
627
|
+
adAccountId: '509410093',
|
|
628
|
+
launchState: 'Paused',
|
|
629
|
+
organizationUrn: 'urn:li:organization:900001',
|
|
630
|
+
defaultAudienceTemplateId: '70217227',
|
|
631
|
+
trackingConversions: [{ ID: '99999999', Name: 'Deleted Conversion' }],
|
|
632
|
+
client: {
|
|
633
|
+
accountReference: async () => 'urn:li:organization:900001',
|
|
634
|
+
tokenScopes: async () => scopes,
|
|
635
|
+
conversions: async () => [
|
|
636
|
+
{ id: '30316052', urn: 'urn:lla:llaPartnerConversion:30316052', name: 'Self-Serve Checkout', type: 'PURCHASE', enabled: true },
|
|
637
|
+
],
|
|
638
|
+
findAudienceTemplateById: async () => ({ id: '70217227', name: 'Execs', approximateMemberCount: 50_000, targetingCriteria: {} }),
|
|
639
|
+
createCampaignGroup: async () => { groupCreated = true; return 'urn:li:sponsoredCampaignGroup:1'; },
|
|
640
|
+
createCampaign: async () => { campaignCreated = true; return 'urn:li:sponsoredCampaign:2'; },
|
|
641
|
+
},
|
|
642
|
+
}));
|
|
643
|
+
const program = {
|
|
644
|
+
id: 'prog-li-2',
|
|
645
|
+
brandId: 'brand-1',
|
|
646
|
+
hypothesis: 'h',
|
|
647
|
+
metricName: 'Clicks',
|
|
648
|
+
params: {
|
|
649
|
+
campaignCode: 'CP-10',
|
|
650
|
+
tacticConfigJSON: JSON.stringify({
|
|
651
|
+
audience: 'targeted',
|
|
652
|
+
dailyBudgetUsd: 25,
|
|
653
|
+
landingPageUrl: 'https://example.org/ebook',
|
|
654
|
+
optimizeFor: 'clicks',
|
|
655
|
+
}),
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
// 1. Propose emits LI_CONVERSION_UNKNOWN for unknown standing conversion
|
|
659
|
+
const unknownIssues = await driver.validateProposal(program);
|
|
660
|
+
expect(unknownIssues.some(i => i.code === 'LI_CONVERSION_UNKNOWN' && i.severity === 'error')).toBe(true);
|
|
661
|
+
// 2. onLock throws BEFORE creating CampaignGroup or Campaign (no KI-148 orphan)
|
|
662
|
+
driver['loadRefs'] = async () => [];
|
|
663
|
+
await expect(driver.onLock(program)).rejects.toThrow(/no standing conversion with id 99999999/);
|
|
664
|
+
expect(groupCreated).toBe(false);
|
|
665
|
+
expect(campaignCreated).toBe(false);
|
|
666
|
+
// 3. Connection-save helper refuses unknown ID, refuses missing rw_conversions scope, and resolves canonical names when valid
|
|
667
|
+
const missingRes = await driver.resolveStandingConversions({
|
|
668
|
+
brandId: 'brand-1',
|
|
669
|
+
credentialId: 'cred-1',
|
|
670
|
+
credentialName: 'LinkedIn Cred',
|
|
671
|
+
externalAccountId: '509410093',
|
|
672
|
+
requested: [{ ID: '99999999', Name: 'Stale' }],
|
|
673
|
+
});
|
|
674
|
+
expect(missingRes).toEqual({
|
|
675
|
+
ok: false,
|
|
676
|
+
message: expect.stringContaining('No conversion with id 99999999 on LinkedIn ad account 509410093'),
|
|
677
|
+
});
|
|
678
|
+
const validRes = await driver.resolveStandingConversions({
|
|
679
|
+
brandId: 'brand-1',
|
|
680
|
+
credentialId: 'cred-1',
|
|
681
|
+
credentialName: 'LinkedIn Cred',
|
|
682
|
+
externalAccountId: '509410093',
|
|
683
|
+
requested: [{ ID: '30316052', Name: 'Caller Typed Label' }],
|
|
684
|
+
});
|
|
685
|
+
expect(validRes).toEqual({
|
|
686
|
+
ok: true,
|
|
687
|
+
conversions: [{ ID: '30316052', Name: 'Self-Serve Checkout' }],
|
|
688
|
+
});
|
|
689
|
+
scopes = ['r_ads', 'rw_ads'];
|
|
690
|
+
const noScopeRes = await driver.resolveStandingConversions({
|
|
691
|
+
brandId: 'brand-1',
|
|
692
|
+
credentialId: 'cred-1',
|
|
693
|
+
credentialName: 'LinkedIn Cred',
|
|
694
|
+
externalAccountId: '509410093',
|
|
695
|
+
requested: [{ ID: '30316052', Name: null }],
|
|
696
|
+
});
|
|
697
|
+
expect(noScopeRes).toEqual({
|
|
698
|
+
ok: false,
|
|
699
|
+
message: expect.stringContaining('rw_conversions'),
|
|
700
|
+
});
|
|
701
|
+
const noScopeIssues = await driver.validateProposal(program);
|
|
702
|
+
expect(noScopeIssues.some(i => i.code === 'LI_CONVERSION_SCOPE' && i.severity === 'error')).toBe(true);
|
|
703
|
+
});
|
|
704
|
+
it('keeps Google campaigns on account-default CUSTOMER goals under Maximize clicks (G1) and updates adopted campaigns only after platform writes succeed', async () => {
|
|
705
|
+
const fakeUser = { ID: 'u1' };
|
|
706
|
+
const deliveryCalls = [];
|
|
707
|
+
const customerGoalCalls = [];
|
|
708
|
+
let customGoalCalled = false;
|
|
709
|
+
let shouldFailDeliveryUpdate = true;
|
|
710
|
+
const storedRefs = [];
|
|
711
|
+
const driver = new GoogleAdsProgramDriver(fakeUser, async () => ({
|
|
712
|
+
ok: true,
|
|
713
|
+
brandConnectionId: 'bc-ga',
|
|
714
|
+
customerId: '4684433735',
|
|
715
|
+
brandAssets: null,
|
|
716
|
+
launchState: 'Paused',
|
|
717
|
+
defaultLocations: ['US', 'CA'],
|
|
718
|
+
defaultLanguages: ['en'],
|
|
719
|
+
client: {
|
|
720
|
+
findConversionActionById: async () => ({
|
|
721
|
+
id: '7845520214',
|
|
722
|
+
name: 'Ebook Form Submit',
|
|
723
|
+
resourceName: 'customers/4684433735/conversionActions/7845520214',
|
|
724
|
+
enabled: true,
|
|
725
|
+
countsByDefault: false,
|
|
726
|
+
}),
|
|
727
|
+
findByName: async (resource) => resource === 'campaign' ? 'customers/4684433735/campaigns/777' : null,
|
|
728
|
+
createCampaignBudget: async () => 'customers/4684433735/campaignBudgets/11',
|
|
729
|
+
updateCampaignDeliverySettings: async (camp, opts) => {
|
|
730
|
+
deliveryCalls.push([camp, opts]);
|
|
731
|
+
if (shouldFailDeliveryUpdate)
|
|
732
|
+
throw new Error('Simulated delivery update failure');
|
|
733
|
+
},
|
|
734
|
+
useCustomerConversionGoals: async (camp) => {
|
|
735
|
+
customerGoalCalls.push(camp);
|
|
736
|
+
},
|
|
737
|
+
attachCampaignLocations: async () => { },
|
|
738
|
+
attachCampaignLanguages: async () => { },
|
|
739
|
+
excludeCampaignMobileApps: async () => { },
|
|
740
|
+
ensureCustomConversionGoal: async () => { customGoalCalled = true; return 'customers/4684433735/customConversionGoals/6459709271'; },
|
|
741
|
+
setCampaignConversionGoal: async () => { customGoalCalled = true; },
|
|
742
|
+
createAdGroup: async () => 'customers/4684433735/adGroups/22',
|
|
743
|
+
},
|
|
744
|
+
}));
|
|
745
|
+
const proposalIssues = await driver.validateProposal({
|
|
746
|
+
id: 'prog-ga-1',
|
|
747
|
+
brandId: 'brand-1',
|
|
748
|
+
hypothesis: 'h',
|
|
749
|
+
metricName: 'Downloads',
|
|
750
|
+
params: {
|
|
751
|
+
campaignCode: 'CG-09',
|
|
752
|
+
tacticConfigJSON: JSON.stringify({
|
|
753
|
+
audience: 'targeted',
|
|
754
|
+
dailyBudgetUsd: 25,
|
|
755
|
+
landingPageUrl: 'https://example.org/ebook',
|
|
756
|
+
conversionId: '7845520214',
|
|
757
|
+
}),
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
expect(proposalIssues).toEqual([]);
|
|
761
|
+
driver['loadRefs'] = async () => storedRefs;
|
|
762
|
+
driver['runScheduleFor'] = async () => ({ start: 1_800_000_000_000 });
|
|
763
|
+
driver['saveRef'] = async (_pid, _bc, kind, urn, state) => {
|
|
764
|
+
const row = {
|
|
765
|
+
ExternalKind: kind,
|
|
766
|
+
ExternalURN: urn,
|
|
767
|
+
StateJSON: state ? JSON.stringify(state) : null,
|
|
768
|
+
Save: async () => true,
|
|
769
|
+
};
|
|
770
|
+
storedRefs.push(row);
|
|
771
|
+
return row;
|
|
772
|
+
};
|
|
773
|
+
const programArg = {
|
|
774
|
+
id: 'prog-ga-1',
|
|
775
|
+
brandId: 'brand-1',
|
|
776
|
+
hypothesis: 'h',
|
|
777
|
+
metricName: 'Downloads',
|
|
778
|
+
params: {
|
|
779
|
+
campaignCode: 'CG-09',
|
|
780
|
+
tacticConfigJSON: JSON.stringify({
|
|
781
|
+
audience: 'targeted',
|
|
782
|
+
dailyBudgetUsd: 25,
|
|
783
|
+
maxCpcUsd: 2,
|
|
784
|
+
landingPageUrl: 'https://example.org/ebook',
|
|
785
|
+
conversionId: '7845520214',
|
|
786
|
+
}),
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
await expect(driver.onLock(programArg)).rejects.toThrow('Simulated delivery update failure');
|
|
790
|
+
const campaignRowAfterFail = storedRefs.find(r => r.ExternalKind === 'Campaign');
|
|
791
|
+
expect(JSON.parse(campaignRowAfterFail.StateJSON).bidding).toBeUndefined();
|
|
792
|
+
expect(JSON.parse(campaignRowAfterFail.StateJSON).goalConfigLevel).toBeUndefined();
|
|
793
|
+
shouldFailDeliveryUpdate = false;
|
|
794
|
+
await driver.onLock(programArg);
|
|
795
|
+
expect(deliveryCalls).toHaveLength(2);
|
|
796
|
+
expect(customerGoalCalls).toEqual(['customers/4684433735/campaigns/777']);
|
|
797
|
+
expect(customGoalCalled).toBe(false);
|
|
798
|
+
expect(JSON.parse(campaignRowAfterFail.StateJSON)).toMatchObject({
|
|
799
|
+
bidding: 'TARGET_SPEND',
|
|
800
|
+
maxCpcUsd: 2,
|
|
801
|
+
positiveGeoTargetType: 'PRESENCE',
|
|
802
|
+
goalConfigLevel: 'CUSTOMER',
|
|
803
|
+
optimizeFor: 'conversions',
|
|
804
|
+
locationsAttached: ['geoTargetConstants/2840', 'geoTargetConstants/2124'],
|
|
805
|
+
languagesAttached: ['languageConstants/1000'],
|
|
806
|
+
mobileAppsExcluded: true,
|
|
807
|
+
});
|
|
808
|
+
expect(JSON.parse(campaignRowAfterFail.StateJSON).conversionGoal).toBeUndefined();
|
|
809
|
+
});
|
|
164
810
|
});
|
|
165
811
|
//# sourceMappingURL=adConversionParity.test.js.map
|