@mj-biz-apps/marketing-server 0.6.0 → 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 +557 -3
- package/dist/__tests__/adConversionParity.test.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/google/GoogleAdsClient.d.ts +23 -1
- package/dist/programs/google/GoogleAdsClient.d.ts.map +1 -1
- package/dist/programs/google/GoogleAdsClient.js +108 -6
- package/dist/programs/google/GoogleAdsClient.js.map +1 -1
- package/dist/programs/google/GoogleAdsProgramDriver.d.ts +24 -0
- package/dist/programs/google/GoogleAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/google/GoogleAdsProgramDriver.js +64 -17
- package/dist/programs/google/GoogleAdsProgramDriver.js.map +1 -1
- package/dist/programs/google/googleAdsExecutionContext.d.ts +4 -0
- package/dist/programs/google/googleAdsExecutionContext.d.ts.map +1 -1
- package/dist/programs/google/googleAdsExecutionContext.js +3 -1
- 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 +109 -6
- package/dist/programs/linkedin/LinkedInAdsClient.js.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts +11 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.js +241 -29
- 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 +38 -14
- package/dist/programs/linkedin/linkedInExecutionContext.js.map +1 -1
- package/dist/programs/meta/MetaAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/meta/MetaAdsProgramDriver.js +18 -0
- package/dist/programs/meta/MetaAdsProgramDriver.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);
|
|
@@ -248,10 +257,555 @@ describe('GoogleAdsClient — user lists, geo targeting, and schedule dates (KI-
|
|
|
248
257
|
const createBody = JSON.parse(String(calls[1].init?.body));
|
|
249
258
|
expect(createBody.operations[0].create['startDate']).toBe('2026-11-01');
|
|
250
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' });
|
|
251
262
|
await client.setCampaignSchedule('customers/4684433735/campaigns/99', { start, end: new Date('2026-11-22T00:00:00Z').getTime() });
|
|
252
263
|
const updateBody = JSON.parse(String(calls[2].init?.body));
|
|
253
264
|
expect(updateBody.operations[0].update['endDate']).toBe('2026-11-22');
|
|
254
265
|
expect(updateBody.operations[0].updateMask).toContain('end_date');
|
|
255
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
|
+
});
|
|
256
810
|
});
|
|
257
811
|
//# sourceMappingURL=adConversionParity.test.js.map
|