@mj-biz-apps/marketing-server 0.6.0 → 0.8.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.
Files changed (53) hide show
  1. package/dist/__tests__/adConversionParity.test.js +1168 -8
  2. package/dist/__tests__/adConversionParity.test.js.map +1 -1
  3. package/dist/__tests__/googleCreativeImage.test.js +6 -5
  4. package/dist/__tests__/googleCreativeImage.test.js.map +1 -1
  5. package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts +221 -0
  6. package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts.map +1 -1
  7. package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js +1306 -106
  8. package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js.map +1 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/programs/brandConversionBindings.d.ts +58 -0
  14. package/dist/programs/brandConversionBindings.d.ts.map +1 -0
  15. package/dist/programs/brandConversionBindings.js +145 -0
  16. package/dist/programs/brandConversionBindings.js.map +1 -0
  17. package/dist/programs/google/GoogleAdsClient.d.ts +28 -6
  18. package/dist/programs/google/GoogleAdsClient.d.ts.map +1 -1
  19. package/dist/programs/google/GoogleAdsClient.js +123 -11
  20. package/dist/programs/google/GoogleAdsClient.js.map +1 -1
  21. package/dist/programs/google/GoogleAdsProgramDriver.d.ts +32 -4
  22. package/dist/programs/google/GoogleAdsProgramDriver.d.ts.map +1 -1
  23. package/dist/programs/google/GoogleAdsProgramDriver.js +264 -35
  24. package/dist/programs/google/GoogleAdsProgramDriver.js.map +1 -1
  25. package/dist/programs/google/googleAdsExecutionContext.d.ts +4 -0
  26. package/dist/programs/google/googleAdsExecutionContext.d.ts.map +1 -1
  27. package/dist/programs/google/googleAdsExecutionContext.js +3 -1
  28. package/dist/programs/google/googleAdsExecutionContext.js.map +1 -1
  29. package/dist/programs/index.d.ts +3 -1
  30. package/dist/programs/index.d.ts.map +1 -1
  31. package/dist/programs/index.js +2 -1
  32. package/dist/programs/index.js.map +1 -1
  33. package/dist/programs/linkedin/LinkedInAdsClient.d.ts +39 -4
  34. package/dist/programs/linkedin/LinkedInAdsClient.d.ts.map +1 -1
  35. package/dist/programs/linkedin/LinkedInAdsClient.js +114 -6
  36. package/dist/programs/linkedin/LinkedInAdsClient.js.map +1 -1
  37. package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts +11 -2
  38. package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts.map +1 -1
  39. package/dist/programs/linkedin/LinkedInAdsProgramDriver.js +297 -36
  40. package/dist/programs/linkedin/LinkedInAdsProgramDriver.js.map +1 -1
  41. package/dist/programs/linkedin/linkedInExecutionContext.d.ts +18 -0
  42. package/dist/programs/linkedin/linkedInExecutionContext.d.ts.map +1 -1
  43. package/dist/programs/linkedin/linkedInExecutionContext.js +36 -13
  44. package/dist/programs/linkedin/linkedInExecutionContext.js.map +1 -1
  45. package/dist/programs/meta/MetaAdsClient.d.ts +8 -5
  46. package/dist/programs/meta/MetaAdsClient.d.ts.map +1 -1
  47. package/dist/programs/meta/MetaAdsClient.js +39 -10
  48. package/dist/programs/meta/MetaAdsClient.js.map +1 -1
  49. package/dist/programs/meta/MetaAdsProgramDriver.d.ts +12 -4
  50. package/dist/programs/meta/MetaAdsProgramDriver.d.ts.map +1 -1
  51. package/dist/programs/meta/MetaAdsProgramDriver.js +312 -32
  52. package/dist/programs/meta/MetaAdsProgramDriver.js.map +1 -1
  53. package/package.json +16 -16
@@ -1,7 +1,11 @@
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';
8
+ import { BrandConversionLookupError } from '../programs/brandConversionBindings.js';
5
9
  function googleClient(answer) {
6
10
  const calls = [];
7
11
  const fetchImpl = (async (input, init) => {
@@ -37,11 +41,17 @@ describe('GoogleAdsClient — conversion actions (D-34 parity)', () => {
37
41
  const create = calls.find(c => c.url.includes('customConversionGoals:mutate'));
38
42
  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
43
  await client.setCampaignConversionGoal('customers/4684433735/campaigns/24313683017', goal);
40
- const point = calls.find(c => c.url.includes('conversionGoalCampaignConfigs:mutate'));
41
- expect(JSON.parse(String(point.init?.body))).toEqual({ operations: [{
44
+ const goalConfigCalls = calls.filter(c => c.url.includes('conversionGoalCampaignConfigs:mutate'));
45
+ expect(JSON.parse(String(goalConfigCalls[0].init?.body))).toEqual({ operations: [{
42
46
  update: { resourceName: 'customers/4684433735/conversionGoalCampaignConfigs/24313683017', goalConfigLevel: 'CAMPAIGN', customConversionGoal: goal },
43
47
  updateMask: 'goal_config_level,custom_conversion_goal',
44
48
  }] });
49
+ await client.useCustomerConversionGoals('customers/4684433735/campaigns/24313683017');
50
+ const customerGoalCalls = calls.filter(c => c.url.includes('conversionGoalCampaignConfigs:mutate'));
51
+ expect(JSON.parse(String(customerGoalCalls[1].init?.body))).toEqual({ operations: [{
52
+ update: { resourceName: 'customers/4684433735/conversionGoalCampaignConfigs/24313683017', goalConfigLevel: 'CUSTOMER' },
53
+ updateMask: 'goal_config_level',
54
+ }] });
45
55
  // Google allows ONE goal per action list on the customer: an existing goal holding exactly this action is reused
46
56
  // as is (whoever made it, whatever its name), a goal holding more actions is not a match.
47
57
  const { client: adoptingClient, calls: adoptCalls } = googleClient(() => [{ results: [
@@ -63,7 +73,7 @@ describe('GoogleAdsClient — conversion actions (D-34 parity)', () => {
63
73
  const queries = calls.filter(c => c.url.includes('searchStream')).map(queryOf);
64
74
  expect(queries).toHaveLength(2);
65
75
  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'");
76
+ 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
77
  const plain = await client.campaignMetrics(['customers/4684433735/campaigns/1'], new Date('2026-10-01T00:00:00Z'), new Date('2026-10-14T00:00:00Z'));
68
78
  expect(plain.conversions).toBe(0);
69
79
  expect(calls.filter(c => c.url.includes('searchStream'))).toHaveLength(3);
@@ -174,15 +184,45 @@ describe('MetaAdsClient — custom conversions (D-34 parity)', () => {
174
184
  expect(post.get('custom_event_type')).toBe('LEAD');
175
185
  expect(JSON.parse(post.get('rule'))).toEqual({ and: [{ event: { eq: 'Lead' } }, { URL: { i_contains: 'sidecar.ai/ai' } }] });
176
186
  });
177
- it('lists Meta saved audiences and merges their targeting spec onto the ad set (KI-144)', async () => {
187
+ it('lists Meta saved audiences using lower/upper count bounds (pinning fields) and merges their targeting spec onto the ad set (KI-144)', async () => {
178
188
  const { client, calls } = metaClient((url) => url.pathname.endsWith('/saved_audiences')
179
- ? { data: [{ id: '6002002', name: 'SC | Association Leaders', approximate_count_64bit: 420_000, targeting: { flexible_spec: [{ interests: [{ id: '6003', name: 'Association' }] }] } }] }
189
+ ? {
190
+ data: [
191
+ {
192
+ id: '6002002',
193
+ name: 'SC | Association Leaders',
194
+ approximate_count_lower_bound: 380_000,
195
+ approximate_count_upper_bound: 460_000,
196
+ targeting: { flexible_spec: [{ interests: [{ id: '6003', name: 'Association' }] }] },
197
+ },
198
+ {
199
+ id: '6002003',
200
+ name: 'SC | Pending Estimate',
201
+ approximate_count_lower_bound: -1,
202
+ approximate_count_upper_bound: -1,
203
+ targeting: { geo_locations: { countries: ['US'] } },
204
+ },
205
+ ],
206
+ }
180
207
  : { id: '3300' });
181
208
  const audiences = await client.savedAudiences();
182
- expect(audiences).toEqual([{
183
- id: '6002002', name: 'SC | Association Leaders', approximateMemberCount: 420_000,
209
+ expect(calls[0].url.pathname).toBe('/v21.0/act_2312673322359408/saved_audiences');
210
+ expect(calls[0].url.searchParams.get('fields')).toBe('id,name,approximate_count_lower_bound,approximate_count_upper_bound,targeting');
211
+ expect(calls[0].url.searchParams.get('fields')).not.toContain('approximate_count_64bit');
212
+ expect(audiences).toEqual([
213
+ {
214
+ id: '6002002',
215
+ name: 'SC | Association Leaders',
216
+ approximateMemberCount: 420_000,
184
217
  targeting: { flexible_spec: [{ interests: [{ id: '6003', name: 'Association' }] }] },
185
- }]);
218
+ },
219
+ {
220
+ id: '6002003',
221
+ name: 'SC | Pending Estimate',
222
+ approximateMemberCount: null,
223
+ targeting: { geo_locations: { countries: ['US'] } },
224
+ },
225
+ ]);
186
226
  await client.createAdSet({
187
227
  name: 'CX-12 — ad set', campaignId: '120', dailyBudgetUsd: 25, status: 'PAUSED',
188
228
  runSchedule: { start: 1_792_000_000_000 }, countries: ['US'],
@@ -194,6 +234,44 @@ describe('MetaAdsClient — custom conversions (D-34 parity)', () => {
194
234
  geo_locations: { countries: ['US'] },
195
235
  });
196
236
  });
237
+ it('survives partial Graph API failure in MetaAdsProgramDriver.listAudienceOptions so one failing endpoint never takes down the other picker', async () => {
238
+ const user = { ID: 'u-1', Email: 'ops@example.org' };
239
+ const makeDriver = (client) => new MetaAdsProgramDriver(user, async () => ({
240
+ ok: true,
241
+ client: client,
242
+ brandConnectionId: 'conn-meta-1',
243
+ adAccountId: 'act_2312673322359408',
244
+ pageId: '12345',
245
+ pixelId: '415938279713144',
246
+ launchState: 'Paused',
247
+ }));
248
+ // 1. savedAudiences fails -> conversions still returned + audiencesError surfaced
249
+ const d1 = makeDriver({
250
+ savedAudiences: async () => { throw new Error('(#100) Tried accessing nonexisting field'); },
251
+ customConversions: async () => [{ id: '1650799', name: 'Demo Submit', enabled: true, customEventType: 'LEAD', pixelId: '415938' }],
252
+ });
253
+ expect(await d1.listAudienceOptions('brand-1')).toEqual({
254
+ audiences: [],
255
+ conversions: [{ id: '1650799', name: 'Demo Submit', enabled: true }],
256
+ audiencesError: '(#100) Tried accessing nonexisting field',
257
+ });
258
+ // 2. customConversions fails -> audiences still returned + conversionsError surfaced
259
+ const d2 = makeDriver({
260
+ savedAudiences: async () => [{ id: '1202433', name: 'Association Leaders', approximateMemberCount: 7500, targeting: {} }],
261
+ customConversions: async () => { throw new Error('customconversions permission denied'); },
262
+ });
263
+ expect(await d2.listAudienceOptions('brand-1')).toEqual({
264
+ audiences: [{ id: '1202433', name: 'Association Leaders', approximateMemberCount: 7500 }],
265
+ conversions: [],
266
+ conversionsError: 'customconversions permission denied',
267
+ });
268
+ // 3. Both fail -> throws so callers treat the connection read as failed
269
+ const d3 = makeDriver({
270
+ savedAudiences: async () => { throw new Error('Token expired'); },
271
+ customConversions: async () => { throw new Error('Token expired'); },
272
+ });
273
+ await expect(d3.listAudienceOptions('brand-1')).rejects.toThrow('Token expired');
274
+ });
197
275
  it('skips ARCHIVED or DELETED campaigns and ad sets in Meta findByName (KI-158)', async () => {
198
276
  const { client } = metaClient(() => ({ data: [
199
277
  { id: '1', name: 'CX-01', status: 'ARCHIVED' },
@@ -248,10 +326,1092 @@ describe('GoogleAdsClient — user lists, geo targeting, and schedule dates (KI-
248
326
  const createBody = JSON.parse(String(calls[1].init?.body));
249
327
  expect(createBody.operations[0].create['startDate']).toBe('2026-11-01');
250
328
  expect(createBody.operations[0].create['endDate']).toBe('2026-11-15');
329
+ expect(createBody.operations[0].create['targetSpend']).toEqual({ cpcBidCeilingMicros: '2000000' });
330
+ expect(createBody.operations[0].create['geoTargetTypeSetting']).toEqual({ positiveGeoTargetType: 'PRESENCE' });
251
331
  await client.setCampaignSchedule('customers/4684433735/campaigns/99', { start, end: new Date('2026-11-22T00:00:00Z').getTime() });
252
332
  const updateBody = JSON.parse(String(calls[2].init?.body));
253
333
  expect(updateBody.operations[0].update['endDate']).toBe('2026-11-22');
254
334
  expect(updateBody.operations[0].updateMask).toContain('end_date');
255
335
  });
336
+ it('attaches multi-location, language, and negative mobile-app criteria idempotently and updates delivery settings (D-37, KI-164)', async () => {
337
+ const { client, calls } = googleClient((url, body) => {
338
+ if (url.includes('campaignCriteria:mutate') || url.includes('campaigns:mutate')) {
339
+ return { results: [{ resourceName: 'customers/4684433735/campaignCriteria/99~1' }] };
340
+ }
341
+ const q = body.query;
342
+ if (q.includes('campaign_criterion.location.geo_target_constant')) {
343
+ return [{ results: [{ campaignCriterion: { location: { geoTargetConstant: 'geoTargetConstants/2840' } } }] }];
344
+ }
345
+ return [];
346
+ });
347
+ await client.attachCampaignLocations('customers/4684433735/campaigns/99', ['geoTargetConstants/2840', 'geoTargetConstants/2124']);
348
+ const locMutate = calls.find(c => c.url.includes('campaignCriteria:mutate'));
349
+ expect(JSON.parse(String(locMutate.init?.body))).toEqual({
350
+ operations: [{ create: { campaign: 'customers/4684433735/campaigns/99', location: { geoTargetConstant: 'geoTargetConstants/2124' } } }],
351
+ });
352
+ await client.attachCampaignLanguages('customers/4684433735/campaigns/99', ['languageConstants/1000']);
353
+ const criteriaMutates = calls.filter(c => c.url.includes('campaignCriteria:mutate'));
354
+ expect(JSON.parse(String(criteriaMutates[1].init?.body))).toEqual({
355
+ operations: [{ create: { campaign: 'customers/4684433735/campaigns/99', language: { languageConstant: 'languageConstants/1000' } } }],
356
+ });
357
+ await client.excludeCampaignMobileApps('customers/4684433735/campaigns/99');
358
+ const criteriaMutatesAfterExclude = calls.filter(c => c.url.includes('campaignCriteria:mutate'));
359
+ expect(JSON.parse(String(criteriaMutatesAfterExclude[2].init?.body))).toEqual({
360
+ operations: [{
361
+ create: {
362
+ campaign: 'customers/4684433735/campaigns/99',
363
+ negative: true,
364
+ mobileAppCategory: { mobileAppCategoryConstant: 'mobileAppCategoryConstants/69500' },
365
+ },
366
+ }],
367
+ });
368
+ await client.updateCampaignDeliverySettings('customers/4684433735/campaigns/99', { maxCpcUsd: 2 });
369
+ const campaignMutates = calls.filter(c => c.url.includes('campaigns:mutate'));
370
+ expect(JSON.parse(String(campaignMutates[0].init?.body))).toEqual({
371
+ operations: [{
372
+ update: {
373
+ resourceName: 'customers/4684433735/campaigns/99',
374
+ targetSpend: { cpcBidCeilingMicros: '2000000' },
375
+ geoTargetTypeSetting: { positiveGeoTargetType: 'PRESENCE' },
376
+ },
377
+ updateMask: 'target_spend.cpc_bid_ceiling_micros,geo_target_type_setting.positive_geo_target_type',
378
+ }],
379
+ });
380
+ await client.setCampaignMaximizeConversions('customers/4684433735/campaigns/99');
381
+ const campaignMutatesAfterMaxConv = calls.filter(c => c.url.includes('campaigns:mutate'));
382
+ expect(JSON.parse(String(campaignMutatesAfterMaxConv[1].init?.body))).toEqual({
383
+ operations: [{
384
+ update: {
385
+ resourceName: 'customers/4684433735/campaigns/99',
386
+ maximizeConversions: {},
387
+ },
388
+ updateMask: 'maximize_conversions',
389
+ }],
390
+ });
391
+ expect(buildGoogleCampaignTargetingState({
392
+ maxCpcUsd: 2,
393
+ locationsAttached: ['geoTargetConstants/2840', 'geoTargetConstants/2124'],
394
+ languagesAttached: ['languageConstants/1000'],
395
+ mobileAppsExcluded: true,
396
+ })).toEqual({
397
+ bidding: 'TARGET_SPEND',
398
+ maxCpcUsd: 2,
399
+ positiveGeoTargetType: 'PRESENCE',
400
+ locationsAttached: ['geoTargetConstants/2840', 'geoTargetConstants/2124'],
401
+ languagesAttached: ['languageConstants/1000'],
402
+ mobileAppsExcluded: true,
403
+ });
404
+ });
405
+ it('emits notice-level parity issues when Google-only targeting/bidding levers are passed to LinkedIn or Meta (D-37)', async () => {
406
+ const tacticConfigJSON = JSON.stringify({
407
+ audience: 'targeted',
408
+ dailyBudgetUsd: 25,
409
+ maxCpcUsd: 2,
410
+ locations: ['US', 'CA'],
411
+ languages: ['en'],
412
+ landingPageUrl: 'https://example.org/ai',
413
+ optimizeFor: 'clicks',
414
+ creativeHeadlines: ['Master AI Strategy', 'AI Roadmap for Leaders'],
415
+ creativeDescriptions: ['Download the free ebook.'],
416
+ });
417
+ const liDriver = new LinkedInAdsProgramDriver();
418
+ const liIssues = await liDriver.validateProposal({ id: 'P1', brandId: 'B1', params: { campaignCode: 'CL-01', tacticConfigJSON } });
419
+ expect(liIssues.map(i => [i.code, i.severity])).toEqual([
420
+ ['LI_MAX_CPC_IGNORED', 'notice'],
421
+ ['LI_LOCATIONS_IN_AUDIENCE', 'notice'],
422
+ ['LI_LANGUAGES_IN_AUDIENCE', 'notice'],
423
+ ]);
424
+ const metaDriver = new MetaAdsProgramDriver();
425
+ const metaIssues = await metaDriver.validateProposal({ id: 'P2', brandId: 'B1', params: { campaignCode: 'CM-01', tacticConfigJSON } });
426
+ expect(metaIssues.map(i => [i.code, i.severity])).toEqual([
427
+ ['MA_MAX_CPC_IGNORED', 'notice'],
428
+ ['MA_LOCATIONS_IGNORED', 'notice'],
429
+ ['MA_LANGUAGES_IGNORED', 'notice'],
430
+ ]);
431
+ });
432
+ });
433
+ function linkedInClient(answer) {
434
+ const calls = [];
435
+ const fetchImpl = (async (input, init) => {
436
+ const url = String(input);
437
+ calls.push({ url, init });
438
+ if (url.endsWith('/accessToken')) {
439
+ 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 });
440
+ }
441
+ const res = answer(url, init);
442
+ return new Response(res.body === null ? null : JSON.stringify(res.body), {
443
+ status: res.status ?? 200,
444
+ headers: res.headers,
445
+ });
446
+ });
447
+ return { client: new LinkedInAdsClient({ clientId: 'c', clientSecret: 's', refreshToken: 'r', adAccountId: '509410093', fetchImpl }), calls };
448
+ }
449
+ describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CONVERSION analytics (D-34, KI-148, KI-152)', () => {
450
+ it('lists conversions, adopts an enabled conversion by name, creates a URL conversion rule, and maps every CTA to LinkedIn ConversionType', async () => {
451
+ const { client, calls } = linkedInClient((url, init) => {
452
+ if (init?.method === 'POST' && url.endsWith('/rest/conversions')) {
453
+ return { status: 201, headers: { 'x-restli-id': '32001001' }, body: {} };
454
+ }
455
+ if (init?.method === 'PUT' && url.includes('/rest/campaignConversions/')) {
456
+ return { status: 204, body: null };
457
+ }
458
+ return {
459
+ body: {
460
+ elements: [
461
+ { id: 29519276, name: 'SC | Ascend Download', type: 'DOWNLOAD', enabled: true },
462
+ { id: 29325716, name: 'CP-04 — lead conversion', type: 'LEAD', enabled: true },
463
+ { id: 100, name: 'CP-05 — lead conversion', type: 'LEAD', enabled: false },
464
+ ],
465
+ },
466
+ };
467
+ });
468
+ const list = await client.conversions();
469
+ expect(list).toEqual([
470
+ { id: '29519276', urn: 'urn:lla:llaPartnerConversion:29519276', name: 'SC | Ascend Download', type: 'DOWNLOAD', enabled: true },
471
+ { id: '29325716', urn: 'urn:lla:llaPartnerConversion:29325716', name: 'CP-04 — lead conversion', type: 'LEAD', enabled: true },
472
+ { id: '100', urn: 'urn:lla:llaPartnerConversion:100', name: 'CP-05 — lead conversion', type: 'LEAD', enabled: false },
473
+ ]);
474
+ expect(await client.findConversionById(' 29519276 ')).toMatchObject({ id: '29519276', name: 'SC | Ascend Download' });
475
+ expect(await client.findConversionByName('CP-04 — lead conversion')).toMatchObject({ id: '29325716', urn: 'urn:lla:llaPartnerConversion:29325716' });
476
+ // Disabled conversions are not adopted by name
477
+ expect(await client.findConversionByName('CP-05 — lead conversion')).toBeNull();
478
+ const created = await client.createUrlConversion({
479
+ name: 'CP-06 — lead conversion',
480
+ urlContains: '/ebook-thank-you',
481
+ type: linkedInConversionTypeFor('DOWNLOAD'),
482
+ });
483
+ expect(created).toEqual({
484
+ id: '32001001',
485
+ urn: 'urn:lla:llaPartnerConversion:32001001',
486
+ name: 'CP-06 — lead conversion',
487
+ type: 'DOWNLOAD',
488
+ enabled: true,
489
+ });
490
+ const postCall = calls.find(c => c.init?.method === 'POST' && c.url.endsWith('/rest/conversions'));
491
+ expect(JSON.parse(String(postCall.init?.body))).toEqual({
492
+ account: 'urn:li:sponsoredAccount:509410093',
493
+ name: 'CP-06 — lead conversion',
494
+ type: 'DOWNLOAD',
495
+ conversionMethod: 'INSIGHT_TAG_URL_MATCH_RULES',
496
+ postClickAttributionWindowSize: 30,
497
+ viewThroughAttributionWindowSize: 7,
498
+ attributionType: 'LAST_TOUCH_BY_CONVERSION',
499
+ urlMatchRuleExpression: [[{ matchType: 'CONTAINS', matchValue: '/ebook-thank-you' }]],
500
+ });
501
+ await client.associateConversion('urn:li:sponsoredCampaign:919676896', created.urn);
502
+ const putCall = calls.find(c => c.init?.method === 'PUT' && c.url.includes('/rest/campaignConversions/'));
503
+ expect(putCall.url).toContain('(campaign:urn%3Ali%3AsponsoredCampaign%3A919676896,conversion:urn%3Alla%3AllaPartnerConversion%3A32001001)');
504
+ let putAttempts = 0;
505
+ const { client: retryClient } = linkedInClient((url) => {
506
+ if (url.includes('/rest/campaignConversions/')) {
507
+ putAttempts++;
508
+ if (putAttempts === 1)
509
+ return { status: 404, body: { message: 'Requester does not have permission to UPDATE the resource.', status: 404 } };
510
+ return { status: 204, body: null };
511
+ }
512
+ return { body: { elements: [] } };
513
+ });
514
+ await retryClient.associateConversion('urn:li:sponsoredCampaign:898478323', created.urn, [1]);
515
+ expect(putAttempts).toBe(2);
516
+ expect(linkedInConversionTypeFor('DOWNLOAD')).toBe('DOWNLOAD');
517
+ expect(linkedInConversionTypeFor('REGISTER')).toBe('REGISTER_EVENT');
518
+ expect(linkedInConversionTypeFor('ATTEND')).toBe('REGISTER_EVENT');
519
+ expect(linkedInConversionTypeFor('SUBSCRIBE')).toBe('SUBSCRIBE');
520
+ expect(linkedInConversionTypeFor('SIGN_UP')).toBe('SIGN_UP');
521
+ expect(linkedInConversionTypeFor('JOIN')).toBe('SIGN_UP');
522
+ expect(linkedInConversionTypeFor('VIEW_QUOTE')).toBe('REQUEST_QUOTE');
523
+ expect(linkedInConversionTypeFor('REQUEST_DEMO')).toBe('LEAD');
524
+ expect(linkedInConversionTypeFor('LEARN_MORE')).toBe('LEAD');
525
+ expect(linkedInConversionTypeFor(null)).toBe('LEAD');
526
+ });
527
+ it('scopes externalWebsiteConversions via pivot=CONVERSION when conversionId is provided, and uses pivot=CAMPAIGN alone for clicks programs (D-34 addendum)', async () => {
528
+ const { client, calls } = linkedInClient((url) => {
529
+ if (url.includes('pivot=CONVERSION')) {
530
+ return {
531
+ body: {
532
+ elements: [
533
+ { externalWebsiteConversions: 5, pivotValues: ['urn:lla:llaPartnerConversion:25692636'] },
534
+ { externalWebsiteConversions: 2, pivotValues: ['urn:lla:llaPartnerConversion:29519276'] },
535
+ { externalWebsiteConversions: 1, pivotValues: ['urn:lla:llaPartnerConversion:18994420'] },
536
+ ],
537
+ },
538
+ };
539
+ }
540
+ return {
541
+ body: {
542
+ elements: [
543
+ { impressions: 1400, clicks: 35, landingPageClicks: 28, externalWebsiteConversions: 8, costInLocalCurrency: '42.75' },
544
+ ],
545
+ },
546
+ };
547
+ });
548
+ const scoped = await client.campaignAnalytics(['urn:li:sponsoredCampaign:919676896'], new Date('2026-11-01T00:00:00Z'), new Date('2026-11-15T00:00:00Z'), '29519276');
549
+ expect(scoped).toEqual({
550
+ impressions: 1400,
551
+ clicks: 35,
552
+ landingPageClicks: 28,
553
+ externalWebsiteConversions: 2,
554
+ spendUsd: 42.75,
555
+ });
556
+ const analyticsCalls = calls.filter(c => c.url.includes('/adAnalytics'));
557
+ expect(analyticsCalls).toHaveLength(2);
558
+ expect(analyticsCalls[0].url).toContain('pivot=CAMPAIGN');
559
+ expect(analyticsCalls[1].url).toContain('pivot=CONVERSION');
560
+ expect(analyticsCalls[1].url).toContain('fields=externalWebsiteConversions,pivotValues');
561
+ const unsegmented = await client.campaignAnalytics(['urn:li:sponsoredCampaign:919676896'], new Date('2026-11-01T00:00:00Z'), new Date('2026-11-15T00:00:00Z'));
562
+ expect(unsegmented.externalWebsiteConversions).toBe(8);
563
+ expect(calls.filter(c => c.url.includes('/adAnalytics'))).toHaveLength(3);
564
+ });
565
+ it('refuses conversionRule on Google Ads with GA_CONVERSION_RULE_UNSUPPORTED (driver parity)', async () => {
566
+ const gDriver = new GoogleAdsProgramDriver();
567
+ const tacticConfigJSON = JSON.stringify({
568
+ audience: 'targeted',
569
+ dailyBudgetUsd: 25,
570
+ landingPageUrl: 'https://example.org/ai',
571
+ conversionRule: '/ebook-thank-you',
572
+ creativeHeadlines: ['Master AI Strategy'],
573
+ creativeDescriptions: ['Download the free ebook.'],
574
+ });
575
+ const issues = await gDriver.validateProposal({ id: 'P3', brandId: 'B1', params: { campaignCode: 'CG-05', tacticConfigJSON } });
576
+ expect(issues.find(i => i.code === 'GA_CONVERSION_RULE_UNSUPPORTED')).toMatchObject({
577
+ severity: 'error',
578
+ code: 'GA_CONVERSION_RULE_UNSUPPORTED',
579
+ });
580
+ });
581
+ it('detaches a conversion via DELETE /campaignConversions and treats 404 as already detached', async () => {
582
+ let count = 0;
583
+ const { client, calls } = linkedInClient((url, init) => {
584
+ if (init?.method === 'DELETE' && url.includes('/rest/campaignConversions/')) {
585
+ count += 1;
586
+ return count === 1 ? { status: 204, body: null } : { status: 404, body: { message: 'Not found' } };
587
+ }
588
+ return { body: {} };
589
+ });
590
+ await client.disassociateConversion('urn:li:sponsoredCampaign:910066056', 'urn:lla:llaPartnerConversion:18994420');
591
+ await client.disassociateConversion('urn:li:sponsoredCampaign:910066056', 'urn:lla:llaPartnerConversion:18994420');
592
+ const deletes = calls.filter(c => c.init?.method === 'DELETE');
593
+ expect(deletes).toHaveLength(2);
594
+ expect(deletes[0].url).toContain('/rest/campaignConversions/(campaign:urn%3Ali%3AsponsoredCampaign%3A910066056,conversion:urn%3Alla%3AllaPartnerConversion%3A18994420)');
595
+ });
596
+ it('attaches AttachToEveryCampaign brandConversions on LinkedIn lock without changing state.conversion or optimizeFor, detaches removed ones on re-lock, and scopes clicks analytics to standing IDs (D-42)', async () => {
597
+ const associated = [];
598
+ const disassociated = [];
599
+ const analyticsArgs = [];
600
+ const savedRefs = [];
601
+ const fakeUser = { ID: 'u1' };
602
+ const bcConv1Id = '00000000-0000-4000-8000-000000000101';
603
+ const bcConv2Id = '00000000-0000-4000-8000-000000000102';
604
+ let currentBrandConversions = [
605
+ {
606
+ id: bcConv1Id,
607
+ name: 'Self-Serve Checkout',
608
+ isActive: true,
609
+ attachToEveryCampaign: true,
610
+ binding: { externalId: '30316052', cachedName: 'Self-Serve Checkout' },
611
+ },
612
+ {
613
+ id: bcConv2Id,
614
+ name: 'Book Demo',
615
+ isActive: true,
616
+ attachToEveryCampaign: true,
617
+ binding: { externalId: '29325716', cachedName: 'Demo Form Submit' },
618
+ },
619
+ ];
620
+ const driver = new LinkedInAdsProgramDriver(fakeUser, async () => ({
621
+ ok: true,
622
+ brandConnectionId: 'bc-li',
623
+ adAccountId: '509410093',
624
+ launchState: 'Paused',
625
+ organizationUrn: 'urn:li:organization:900001',
626
+ defaultAudienceTemplateId: '70217227',
627
+ client: {
628
+ accountReference: async () => 'urn:li:organization:900001',
629
+ tokenScopes: async () => ['r_ads', 'rw_ads', 'rw_conversions'],
630
+ conversions: async () => [
631
+ { id: '30316052', urn: 'urn:lla:llaPartnerConversion:30316052', name: 'Self-Serve Checkout', type: 'PURCHASE', enabled: true },
632
+ { id: '29325716', urn: 'urn:lla:llaPartnerConversion:29325716', name: 'Demo Form Submit', type: 'LEAD', enabled: true },
633
+ ],
634
+ findAudienceTemplateById: async () => ({ id: '70217227', name: 'Execs', approximateMemberCount: 50_000, targetingCriteria: {} }),
635
+ createCampaignGroup: async () => 'urn:li:sponsoredCampaignGroup:1',
636
+ createCampaign: async () => 'urn:li:sponsoredCampaign:2',
637
+ associateConversion: async (camp, conv) => { associated.push([camp, conv]); },
638
+ disassociateConversion: async (camp, conv) => { disassociated.push([camp, conv]); },
639
+ campaignAnalytics: async (_urns, _from, _to, convId) => {
640
+ analyticsArgs.push(convId);
641
+ return { impressions: 1200, clicks: 44, landingPageClicks: 31, externalWebsiteConversions: 3, spendUsd: 19.5 };
642
+ },
643
+ },
644
+ }), async () => currentBrandConversions);
645
+ driver['loadRefs'] = async () => [];
646
+ driver['runScheduleFor'] = async () => ({ start: 1_800_000_000_000 });
647
+ driver['saveRef'] = async (_pid, _bc, kind, urn, state) => {
648
+ savedRefs.push({ kind, urn, state });
649
+ };
650
+ const program = {
651
+ id: 'prog-li-1',
652
+ brandId: 'brand-1',
653
+ hypothesis: 'h',
654
+ metricName: 'Clicks',
655
+ params: {
656
+ campaignCode: 'CP-09',
657
+ tacticConfigJSON: JSON.stringify({
658
+ audience: 'targeted',
659
+ dailyBudgetUsd: 25,
660
+ landingPageUrl: 'https://example.org/ebook',
661
+ optimizeFor: 'clicks',
662
+ }),
663
+ },
664
+ };
665
+ await driver.onLock(program);
666
+ expect(associated).toEqual([
667
+ ['urn:li:sponsoredCampaign:2', 'urn:lla:llaPartnerConversion:30316052'],
668
+ ['urn:li:sponsoredCampaign:2', 'urn:lla:llaPartnerConversion:29325716'],
669
+ ]);
670
+ const campaignRef = savedRefs.find(r => r.kind === 'Campaign');
671
+ expect(campaignRef?.state).toMatchObject({
672
+ conversion: null,
673
+ optimizeFor: 'clicks',
674
+ objective: 'WEBSITE_VISIT',
675
+ standingConversions: [
676
+ { brandConversionId: bcConv1Id, brandConversionName: 'Self-Serve Checkout', id: '30316052', name: 'Self-Serve Checkout' },
677
+ { brandConversionId: bcConv2Id, brandConversionName: 'Book Demo', id: '29325716', name: 'Demo Form Submit' },
678
+ ],
679
+ });
680
+ // Re-lock after turning off AttachToEveryCampaign on Book Demo: detaches 29325716 and updates StateJSON.standingConversions.
681
+ associated.length = 0;
682
+ currentBrandConversions = [
683
+ {
684
+ id: bcConv1Id,
685
+ name: 'Self-Serve Checkout',
686
+ isActive: true,
687
+ attachToEveryCampaign: true,
688
+ binding: { externalId: '30316052', cachedName: 'Self-Serve Checkout' },
689
+ },
690
+ {
691
+ id: bcConv2Id,
692
+ name: 'Book Demo',
693
+ isActive: true,
694
+ attachToEveryCampaign: false,
695
+ binding: { externalId: '29325716', cachedName: 'Demo Form Submit' },
696
+ },
697
+ ];
698
+ const existingCampaignRow = {
699
+ ExternalKind: 'Campaign',
700
+ ExternalURN: 'urn:li:sponsoredCampaign:2',
701
+ StateJSON: JSON.stringify(campaignRef?.state ?? {}),
702
+ Save: async () => true,
703
+ };
704
+ driver['loadRefs'] = async () => [
705
+ { ExternalKind: 'CampaignGroup', ExternalURN: 'urn:li:sponsoredCampaignGroup:1', StateJSON: null, Save: async () => true },
706
+ existingCampaignRow,
707
+ ];
708
+ await driver.onLock(program);
709
+ expect(disassociated).toEqual([
710
+ ['urn:li:sponsoredCampaign:2', 'urn:lla:llaPartnerConversion:29325716'],
711
+ ]);
712
+ expect(associated).toEqual([
713
+ ['urn:li:sponsoredCampaign:2', 'urn:lla:llaPartnerConversion:30316052'],
714
+ ]);
715
+ expect(JSON.parse(existingCampaignRow.StateJSON)).toMatchObject({
716
+ conversion: null,
717
+ optimizeFor: 'clicks',
718
+ standingConversions: [
719
+ { brandConversionId: bcConv1Id, brandConversionName: 'Self-Serve Checkout', id: '30316052', name: 'Self-Serve Checkout' },
720
+ ],
721
+ });
722
+ // collectMetrics on clicks program scopes conversionsCount to standingIds while keeping observedMetric = landingPageClicks.
723
+ const obs = await driver.collectMetrics(program, {
724
+ from: new Date('2026-10-01T00:00:00Z'),
725
+ to: new Date('2026-10-14T00:00:00Z'),
726
+ sprintId: null,
727
+ });
728
+ expect(analyticsArgs).toEqual([['30316052']]);
729
+ expect(obs?.[0]).toMatchObject({
730
+ observedMetric: 31,
731
+ measures: 'landing-page clicks',
732
+ conversionsCount: 3,
733
+ clicksCount: 31,
734
+ });
735
+ });
736
+ it('enforces brandConversionId lever rules (unknown=error, unbound=notice at propose + refusal at lock, bound=optimise) and clicks-only Google/Meta standing notices + scoped measurement (D-42)', async () => {
737
+ const fakeUser = { ID: 'u1' };
738
+ const unknownId = '00000000-0000-4000-8000-000000000999';
739
+ const unboundId = '00000000-0000-4000-8000-000000000201';
740
+ const boundId = '00000000-0000-4000-8000-000000000202';
741
+ const standingId = '00000000-0000-4000-8000-000000000203';
742
+ let liGroupCreated = false;
743
+ const liDriver = new LinkedInAdsProgramDriver(fakeUser, async () => ({
744
+ ok: true,
745
+ brandConnectionId: 'bc-li',
746
+ adAccountId: '509410093',
747
+ launchState: 'Paused',
748
+ organizationUrn: 'urn:li:organization:900001',
749
+ defaultAudienceTemplateId: '70217227',
750
+ client: {
751
+ accountReference: async () => 'urn:li:organization:900001',
752
+ tokenScopes: async () => ['r_ads', 'rw_ads', 'rw_conversions'],
753
+ conversions: async () => [
754
+ { id: '29325716', urn: 'urn:lla:llaPartnerConversion:29325716', name: 'Demo Form Submit', type: 'LEAD', enabled: true },
755
+ ],
756
+ findConversionById: async (id) => id === '29325716'
757
+ ? { id: '29325716', urn: 'urn:lla:llaPartnerConversion:29325716', name: 'Demo Form Submit', type: 'LEAD', enabled: true }
758
+ : null,
759
+ findAudienceTemplateById: async () => ({ id: '70217227', name: 'Execs', approximateMemberCount: 50_000, targetingCriteria: {} }),
760
+ createCampaignGroup: async () => { liGroupCreated = true; return 'urn:li:sponsoredCampaignGroup:1'; },
761
+ },
762
+ }), async () => [
763
+ { id: unboundId, name: 'Unbound Goal', isActive: true, attachToEveryCampaign: false, binding: null },
764
+ {
765
+ id: boundId,
766
+ name: 'Book Demo',
767
+ isActive: true,
768
+ attachToEveryCampaign: false,
769
+ binding: { externalId: '29325716', cachedName: 'Demo Form Submit' },
770
+ },
771
+ ]);
772
+ liDriver['loadRefs'] = async () => [];
773
+ const makeProg = (levers) => ({
774
+ id: 'prog-bc-1',
775
+ brandId: 'brand-1',
776
+ hypothesis: 'h',
777
+ metricName: 'Demos',
778
+ params: {
779
+ campaignCode: 'CP-11',
780
+ tacticConfigJSON: JSON.stringify({
781
+ audience: 'targeted',
782
+ dailyBudgetUsd: 25,
783
+ landingPageUrl: 'https://example.org/demo',
784
+ ...levers,
785
+ }),
786
+ },
787
+ });
788
+ // 0. brandConversion name without brandConversionId -> AD_BRAND_CONVERSION_ID_REQUIRED (zero by-name fallback)
789
+ const nameOnlyIssues = await liDriver.validateProposal(makeProg({ brandConversion: 'Book Demo' }));
790
+ expect(nameOnlyIssues.find(i => i.code === 'AD_BRAND_CONVERSION_ID_REQUIRED')).toMatchObject({ severity: 'error' });
791
+ // 1. Unknown brandConversionId -> error at propose & refusal at lock
792
+ const unknownIssues = await liDriver.validateProposal(makeProg({ brandConversionId: unknownId, brandConversion: 'Nonexistent' }));
793
+ expect(unknownIssues.find(i => i.code === 'AD_BRAND_CONVERSION_UNKNOWN')).toMatchObject({ severity: 'error' });
794
+ await expect(liDriver.onLock(makeProg({ brandConversionId: unknownId, brandConversion: 'Nonexistent' }))).rejects.toThrow(/no active Brand Conversion with id/);
795
+ expect(liGroupCreated).toBe(false);
796
+ // 2. Unbound brandConversionId -> NOTICE at propose, REFUSAL at lock before any object is created
797
+ const unboundIssues = await liDriver.validateProposal(makeProg({ brandConversionId: unboundId, brandConversion: 'Unbound Goal' }));
798
+ expect(unboundIssues.filter(i => i.severity === 'error')).toEqual([]);
799
+ expect(unboundIssues.find(i => i.code === 'AD_BRAND_CONVERSION_UNBOUND')).toMatchObject({ severity: 'notice' });
800
+ await expect(liDriver.onLock(makeProg({ brandConversionId: unboundId, brandConversion: 'Unbound Goal' }))).rejects.toThrow(/has no LinkedIn Ads binding/);
801
+ expect(liGroupCreated).toBe(false);
802
+ // 3. Multi-action standing measurement on LinkedInAdsClient, GoogleAdsClient, and MetaAdsClient
803
+ const { client: liMultiClient } = linkedInClient((url) => url.includes('pivot=CONVERSION')
804
+ ? {
805
+ body: {
806
+ elements: [
807
+ { externalWebsiteConversions: 4, pivotValues: ['urn:lla:llaPartnerConversion:30316052'] },
808
+ { externalWebsiteConversions: 2, pivotValues: ['urn:lla:llaPartnerConversion:29325716'] },
809
+ { externalWebsiteConversions: 9, pivotValues: ['urn:lla:llaPartnerConversion:99999999'] },
810
+ ],
811
+ },
812
+ }
813
+ : { body: { elements: [{ impressions: 500, clicks: 20, landingPageClicks: 15, externalWebsiteConversions: 15, costInLocalCurrency: '10.00' }] } });
814
+ const liMulti = await liMultiClient.campaignAnalytics(['urn:li:sponsoredCampaign:2'], new Date('2026-10-01T00:00:00Z'), new Date('2026-10-14T00:00:00Z'), ['30316052', '29325716']);
815
+ expect(liMulti.externalWebsiteConversions).toBe(6);
816
+ const { client: gaMultiClient, calls: gaCalls } = googleClient((_url, body) => {
817
+ const q = body.query;
818
+ return q.includes('segments.conversion_action')
819
+ ? [{ results: [{ metrics: { allConversions: 2 } }, { metrics: { allConversions: 5 } }] }]
820
+ : [{ results: [{ metrics: { impressions: 800, clicks: 30, conversions: 0, costMicros: 15_000_000 } }] }];
821
+ });
822
+ const gaMulti = await gaMultiClient.campaignMetrics(['customers/4684433735/campaigns/1'], new Date('2026-10-01T00:00:00Z'), new Date('2026-10-14T00:00:00Z'), ['customers/4684433735/conversionActions/11', 'customers/4684433735/conversionActions/22']);
823
+ expect(gaMulti.conversions).toBe(7);
824
+ const gaSegmentedQuery = gaCalls.filter(c => c.url.includes('searchStream')).map(queryOf)[1];
825
+ expect(gaSegmentedQuery).toContain('SELECT segments.conversion_action, metrics.all_conversions');
826
+ expect(gaSegmentedQuery).toContain("AND segments.conversion_action IN ('customers/4684433735/conversionActions/11', 'customers/4684433735/conversionActions/22')");
827
+ const { client: maMultiClient } = metaClient(() => ({
828
+ data: [{
829
+ impressions: '600',
830
+ clicks: '25',
831
+ spend: '14.0',
832
+ actions: [
833
+ { action_type: 'offsite_conversion.custom.111', value: '3' },
834
+ { action_type: 'offsite_conversion.custom.222', value: '4' },
835
+ { action_type: 'offsite_conversion.fb_pixel_lead', value: '19' },
836
+ ],
837
+ }],
838
+ }));
839
+ const maMulti = await maMultiClient.adSetMetrics(['1'], new Date('2026-10-01T00:00:00Z'), new Date('2026-10-14T00:00:00Z'), ['111', '222']);
840
+ expect(maMulti.conversions).toBe(7);
841
+ // 4. Google & Meta drivers emit GA_STANDING_MEASURED_ONLY / MA_STANDING_MEASURED_ONLY ONLY on clicks-only programs (skipped when primary conversion is set)
842
+ const gaDriver = new GoogleAdsProgramDriver(fakeUser, async () => ({
843
+ ok: true,
844
+ brandConnectionId: 'bc-ga',
845
+ customerId: '4684433735',
846
+ brandAssets: null,
847
+ launchState: 'Paused',
848
+ client: {
849
+ conversionActions: async () => [{
850
+ id: '7217103107',
851
+ resourceName: 'customers/4684433735/conversionActions/7217103107',
852
+ name: 'Purchase',
853
+ enabled: true,
854
+ category: 'PURCHASE',
855
+ origin: 'WEBSITE',
856
+ countsByDefault: true,
857
+ }],
858
+ findConversionActionById: async (id) => id === '7845520214'
859
+ ? {
860
+ id: '7845520214',
861
+ resourceName: 'customers/4684433735/conversionActions/7845520214',
862
+ name: 'Book Demo Action',
863
+ enabled: true,
864
+ category: 'SUBMIT_LEAD_FORM',
865
+ origin: 'WEBSITE',
866
+ countsByDefault: false,
867
+ }
868
+ : null,
869
+ },
870
+ }), async () => [
871
+ {
872
+ id: standingId,
873
+ name: 'Self-Serve Checkout',
874
+ isActive: true,
875
+ attachToEveryCampaign: true,
876
+ binding: { externalId: '7217103107', cachedName: 'Purchase' },
877
+ },
878
+ {
879
+ id: boundId,
880
+ name: 'Book Demo',
881
+ isActive: true,
882
+ attachToEveryCampaign: false,
883
+ binding: { externalId: '7845520214', cachedName: 'Book Demo Action' },
884
+ },
885
+ ]);
886
+ const gaStandingIssues = await gaDriver.validateProposal(makeProg({ optimizeFor: 'clicks' }));
887
+ expect(gaStandingIssues.find(i => i.code === 'GA_STANDING_MEASURED_ONLY')).toMatchObject({ severity: 'notice' });
888
+ const gaPrimaryIssues = await gaDriver.validateProposal(makeProg({ brandConversionId: boundId, brandConversion: 'Book Demo' }));
889
+ expect(gaPrimaryIssues.find(i => i.code === 'GA_STANDING_MEASURED_ONLY')).toBeUndefined();
890
+ const maDriver = new MetaAdsProgramDriver(fakeUser, async () => ({
891
+ ok: true,
892
+ brandConnectionId: 'bc-ma',
893
+ adAccountId: 'act_2312673322359408',
894
+ pageId: '519210931614315',
895
+ pixelId: '415938279713144',
896
+ launchState: 'Paused',
897
+ client: {
898
+ customConversions: async () => [{
899
+ id: '795683087750734',
900
+ name: 'Purchase',
901
+ enabled: true,
902
+ customEventType: 'PURCHASE',
903
+ pixelId: '415938279713144',
904
+ }],
905
+ findCustomConversionById: async (id) => id === '888111222'
906
+ ? { id: '888111222', name: 'Demo Lead', enabled: true, customEventType: 'LEAD', pixelId: '415938279713144' }
907
+ : null,
908
+ findByName: async () => null,
909
+ createCampaign: async () => 'camp-1',
910
+ createAdSet: async () => 'adset-1',
911
+ },
912
+ }), async () => [
913
+ {
914
+ id: standingId,
915
+ name: 'Self-Serve Checkout',
916
+ isActive: true,
917
+ attachToEveryCampaign: true,
918
+ binding: { externalId: '795683087750734', cachedName: 'Purchase' },
919
+ },
920
+ {
921
+ id: boundId,
922
+ name: 'Book Demo',
923
+ isActive: true,
924
+ attachToEveryCampaign: false,
925
+ binding: { externalId: '888111222', cachedName: 'Demo Lead' },
926
+ },
927
+ ]);
928
+ const maStandingIssues = await maDriver.validateProposal(makeProg({ optimizeFor: 'clicks' }));
929
+ expect(maStandingIssues.find(i => i.code === 'MA_STANDING_MEASURED_ONLY')).toMatchObject({ severity: 'notice' });
930
+ const maPrimaryIssues = await maDriver.validateProposal(makeProg({ brandConversionId: boundId, brandConversion: 'Book Demo' }));
931
+ expect(maPrimaryIssues.find(i => i.code === 'MA_STANDING_MEASURED_ONLY')).toBeUndefined();
932
+ const maRefs = [];
933
+ maDriver['loadRefs'] = async () => [];
934
+ maDriver['runScheduleFor'] = async () => ({ start: 1_800_000_000_000 });
935
+ maDriver['saveRef'] = async (_pid, _bc, kind, _urn, state) => {
936
+ maRefs.push({ kind, state });
937
+ };
938
+ await maDriver.onLock(makeProg({ optimizeFor: 'clicks' }));
939
+ expect(maRefs.find(r => r.kind === 'Campaign')?.state).toMatchObject({
940
+ conversion: null,
941
+ optimizeFor: 'clicks',
942
+ standingConversions: [{
943
+ brandConversionId: standingId,
944
+ brandConversionName: 'Self-Serve Checkout',
945
+ id: '795683087750734',
946
+ name: 'Purchase',
947
+ }],
948
+ });
949
+ maRefs.length = 0;
950
+ await maDriver.onLock(makeProg({ brandConversionId: boundId, brandConversion: 'Book Demo' }));
951
+ const maPrimaryState = maRefs.find(r => r.kind === 'Campaign')?.state ?? {};
952
+ expect(maPrimaryState).toMatchObject({
953
+ brandConversionId: boundId,
954
+ brandConversion: { id: boundId, name: 'Book Demo' },
955
+ conversion: { id: '888111222', name: 'Demo Lead' },
956
+ optimizeFor: 'conversions',
957
+ });
958
+ expect(maPrimaryState['standingConversions']).toBeUndefined();
959
+ });
960
+ it('warns at propose (AD_BRAND_CONVERSION_LOOKUP_FAILED) and fails closed at lock when loadBrandConversions throws BrandConversionLookupError', async () => {
961
+ const fakeUser = { ID: 'u1' };
962
+ const failingLoader = async () => {
963
+ throw new BrandConversionLookupError('BrandConversions query failed: timeout');
964
+ };
965
+ const prog = {
966
+ id: 'prog-fail-closed',
967
+ brandId: 'brand-1',
968
+ hypothesis: 'h',
969
+ metricName: 'Clicks',
970
+ params: {
971
+ campaignCode: 'CP-FC',
972
+ tacticConfigJSON: JSON.stringify({
973
+ audience: 'targeted',
974
+ dailyBudgetUsd: 25,
975
+ landingPageUrl: 'https://example.org/ebook',
976
+ optimizeFor: 'clicks',
977
+ }),
978
+ },
979
+ };
980
+ const liDriver = new LinkedInAdsProgramDriver(fakeUser, async () => ({
981
+ ok: true,
982
+ brandConnectionId: 'bc-li',
983
+ adAccountId: '509410093',
984
+ launchState: 'Paused',
985
+ organizationUrn: 'urn:li:organization:900001',
986
+ defaultAudienceTemplateId: '70217227',
987
+ client: {
988
+ accountReference: async () => 'urn:li:organization:900001',
989
+ findAudienceTemplateById: async () => ({ id: '70217227', name: 'Execs', approximateMemberCount: 50_000, targetingCriteria: {} }),
990
+ },
991
+ }), failingLoader);
992
+ liDriver['loadRefs'] = async () => [];
993
+ const liIssues = await liDriver.validateProposal(prog);
994
+ expect(liIssues.find(i => i.code === 'AD_BRAND_CONVERSION_LOOKUP_FAILED')).toMatchObject({ severity: 'warning' });
995
+ await expect(liDriver.onLock(prog)).rejects.toThrow(BrandConversionLookupError);
996
+ const gaDriver = new GoogleAdsProgramDriver(fakeUser, async () => ({
997
+ ok: true,
998
+ brandConnectionId: 'bc-ga',
999
+ customerId: '4684433735',
1000
+ brandAssets: null,
1001
+ launchState: 'Paused',
1002
+ client: { conversionActions: async () => [] },
1003
+ }), failingLoader);
1004
+ gaDriver['loadRefs'] = async () => [];
1005
+ const gaIssues = await gaDriver.validateProposal(prog);
1006
+ expect(gaIssues.find(i => i.code === 'AD_BRAND_CONVERSION_LOOKUP_FAILED')).toMatchObject({ severity: 'warning' });
1007
+ await expect(gaDriver.onLock(prog)).rejects.toThrow(BrandConversionLookupError);
1008
+ const maDriver = new MetaAdsProgramDriver(fakeUser, async () => ({
1009
+ ok: true,
1010
+ brandConnectionId: 'bc-ma',
1011
+ adAccountId: 'act_2312673322359408',
1012
+ pageId: '519210931614315',
1013
+ pixelId: '415938279713144',
1014
+ launchState: 'Paused',
1015
+ client: { customConversions: async () => [] },
1016
+ }), failingLoader);
1017
+ maDriver['loadRefs'] = async () => [];
1018
+ const maIssues = await maDriver.validateProposal(prog);
1019
+ expect(maIssues.find(i => i.code === 'AD_BRAND_CONVERSION_LOOKUP_FAILED')).toMatchObject({ severity: 'warning' });
1020
+ await expect(maDriver.onLock(prog)).rejects.toThrow(BrandConversionLookupError);
1021
+ });
1022
+ it('creates zero conversionRule or audience orphans on LinkedIn or Meta when a downstream preflight check refuses lock', async () => {
1023
+ const fakeUser = { ID: 'u1' };
1024
+ const standingId = '00000000-0000-4000-8000-000000000301';
1025
+ let liUrlConversionCreated = false;
1026
+ const liDriver = new LinkedInAdsProgramDriver(fakeUser, async () => ({
1027
+ ok: true,
1028
+ brandConnectionId: 'bc-li',
1029
+ adAccountId: '509410093',
1030
+ launchState: 'Paused',
1031
+ organizationUrn: 'urn:li:organization:900001',
1032
+ defaultAudienceTemplateId: '70217227',
1033
+ client: {
1034
+ accountReference: async () => 'urn:li:organization:900001',
1035
+ tokenScopes: async () => ['r_ads', 'rw_ads', 'rw_conversions'],
1036
+ conversions: async () => [],
1037
+ findConversionByName: async () => null,
1038
+ findAudienceTemplateById: async () => ({ id: '70217227', name: 'Execs', approximateMemberCount: 50_000, targetingCriteria: {} }),
1039
+ createUrlConversion: async () => {
1040
+ liUrlConversionCreated = true;
1041
+ return { id: '999', urn: 'urn:lla:llaPartnerConversion:999', name: 'Created', type: 'LEAD', enabled: true };
1042
+ },
1043
+ },
1044
+ }), async () => [{
1045
+ id: standingId,
1046
+ name: 'Broken Standing',
1047
+ isActive: true,
1048
+ attachToEveryCampaign: true,
1049
+ binding: { externalId: 'missing-on-li', cachedName: 'Missing' },
1050
+ }]);
1051
+ liDriver['loadRefs'] = async () => [];
1052
+ await expect(liDriver.onLock({
1053
+ id: 'prog-li-orphan',
1054
+ brandId: 'brand-1',
1055
+ hypothesis: 'h',
1056
+ metricName: 'Leads',
1057
+ params: {
1058
+ campaignCode: 'CP-ORPHAN',
1059
+ tacticConfigJSON: JSON.stringify({
1060
+ audience: 'targeted',
1061
+ dailyBudgetUsd: 25,
1062
+ landingPageUrl: 'https://example.org/ebook',
1063
+ conversionRule: '/thank-you',
1064
+ }),
1065
+ },
1066
+ })).rejects.toThrow(/standing Brand Conversion "Broken Standing"/);
1067
+ expect(liUrlConversionCreated).toBe(false);
1068
+ let metaUrlConversionCreated = false;
1069
+ const maDriver = new MetaAdsProgramDriver(fakeUser, async () => ({
1070
+ ok: true,
1071
+ brandConnectionId: 'bc-ma',
1072
+ adAccountId: 'act_2312673322359408',
1073
+ pageId: '519210931614315',
1074
+ pixelId: '415938279713144',
1075
+ launchState: 'Paused',
1076
+ client: {
1077
+ findSavedAudienceById: async () => null,
1078
+ findCustomConversionByName: async () => null,
1079
+ createUrlCustomConversion: async () => {
1080
+ metaUrlConversionCreated = true;
1081
+ return { id: '888', name: 'Created', enabled: true, customEventType: 'LEAD', pixelId: '415938279713144' };
1082
+ },
1083
+ },
1084
+ }), async () => []);
1085
+ maDriver['loadRefs'] = async () => [];
1086
+ await expect(maDriver.onLock({
1087
+ id: 'prog-ma-orphan',
1088
+ brandId: 'brand-1',
1089
+ hypothesis: 'h',
1090
+ metricName: 'Leads',
1091
+ params: {
1092
+ campaignCode: 'CM-ORPHAN',
1093
+ tacticConfigJSON: JSON.stringify({
1094
+ audience: 'targeted',
1095
+ audienceTemplateId: 'missing-saved-aud',
1096
+ dailyBudgetUsd: 25,
1097
+ landingPageUrl: 'https://example.org/ebook',
1098
+ conversionRule: '/thank-you',
1099
+ }),
1100
+ },
1101
+ })).rejects.toThrow(/no saved audience with id missing-saved-aud/);
1102
+ expect(metaUrlConversionCreated).toBe(false);
1103
+ });
1104
+ it('freezes conversion, brandConversionId, brandConversion, and optimizeFor on Meta re-lock and throws when updateRefState Save() returns false', async () => {
1105
+ const fakeUser = { ID: 'u1' };
1106
+ const origBrandConvId = '00000000-0000-4000-8000-000000000401';
1107
+ const newBrandConvId = '00000000-0000-4000-8000-000000000402';
1108
+ let saveOk = true;
1109
+ const existingAdSetRow = {
1110
+ ExternalKind: 'Campaign',
1111
+ ExternalURN: 'adset-existing',
1112
+ StateJSON: JSON.stringify({
1113
+ brandConversionId: origBrandConvId,
1114
+ brandConversion: { id: origBrandConvId, name: 'Original Goal' },
1115
+ conversion: { id: '111', name: 'Original Pixel Event' },
1116
+ optimizeFor: 'conversions',
1117
+ }),
1118
+ LatestResult: { CompleteMessage: 'simulated DB error' },
1119
+ Save: async () => saveOk,
1120
+ };
1121
+ const maDriver = new MetaAdsProgramDriver(fakeUser, async () => ({
1122
+ ok: true,
1123
+ brandConnectionId: 'bc-ma',
1124
+ adAccountId: 'act_2312673322359408',
1125
+ pageId: '519210931614315',
1126
+ pixelId: '415938279713144',
1127
+ launchState: 'Paused',
1128
+ client: {
1129
+ findCustomConversionById: async () => ({ id: '222', name: 'Renamed Event', enabled: true, customEventType: 'LEAD', pixelId: '415938279713144' }),
1130
+ findByName: async () => null,
1131
+ },
1132
+ }), async () => [{
1133
+ id: newBrandConvId,
1134
+ name: 'Mutated Goal',
1135
+ isActive: true,
1136
+ attachToEveryCampaign: false,
1137
+ binding: { externalId: '222', cachedName: 'Renamed Event' },
1138
+ }]);
1139
+ maDriver['loadRefs'] = async () => [
1140
+ { ExternalKind: 'CampaignGroup', ExternalURN: 'camp-existing', StateJSON: null, Save: async () => true },
1141
+ existingAdSetRow,
1142
+ ];
1143
+ const prog = {
1144
+ id: 'prog-ma-freeze',
1145
+ brandId: 'brand-1',
1146
+ hypothesis: 'h',
1147
+ metricName: 'Leads',
1148
+ params: {
1149
+ campaignCode: 'CM-FRZ',
1150
+ tacticConfigJSON: JSON.stringify({
1151
+ audience: 'targeted',
1152
+ dailyBudgetUsd: 25,
1153
+ landingPageUrl: 'https://example.org/ebook',
1154
+ brandConversionId: newBrandConvId,
1155
+ brandConversion: 'Mutated Goal',
1156
+ }),
1157
+ },
1158
+ };
1159
+ await maDriver.onLock(prog);
1160
+ expect(JSON.parse(existingAdSetRow.StateJSON)).toMatchObject({
1161
+ brandConversionId: origBrandConvId,
1162
+ brandConversion: { id: origBrandConvId, name: 'Original Goal' },
1163
+ conversion: { id: '111', name: 'Original Pixel Event' },
1164
+ optimizeFor: 'conversions',
1165
+ });
1166
+ saveOk = false;
1167
+ await expect(maDriver.onLock(prog)).rejects.toThrow(/simulated DB error/);
1168
+ });
1169
+ it('keeps Google campaigns on account-default CUSTOMER goals under Maximize clicks (G1) and updates adopted campaigns only after platform writes succeed', async () => {
1170
+ const fakeUser = { ID: 'u1' };
1171
+ const deliveryCalls = [];
1172
+ const customerGoalCalls = [];
1173
+ let customGoalCalled = false;
1174
+ let shouldFailDeliveryUpdate = true;
1175
+ const storedRefs = [];
1176
+ const driver = new GoogleAdsProgramDriver(fakeUser, async () => ({
1177
+ ok: true,
1178
+ brandConnectionId: 'bc-ga',
1179
+ customerId: '4684433735',
1180
+ brandAssets: null,
1181
+ launchState: 'Paused',
1182
+ defaultLocations: ['US', 'CA'],
1183
+ defaultLanguages: ['en'],
1184
+ client: {
1185
+ findConversionActionById: async () => ({
1186
+ id: '7845520214',
1187
+ name: 'Ebook Form Submit',
1188
+ resourceName: 'customers/4684433735/conversionActions/7845520214',
1189
+ enabled: true,
1190
+ countsByDefault: false,
1191
+ }),
1192
+ findByName: async (resource) => resource === 'campaign' ? 'customers/4684433735/campaigns/777' : null,
1193
+ createCampaignBudget: async () => 'customers/4684433735/campaignBudgets/11',
1194
+ updateCampaignDeliverySettings: async (camp, opts) => {
1195
+ deliveryCalls.push([camp, opts]);
1196
+ if (shouldFailDeliveryUpdate)
1197
+ throw new Error('Simulated delivery update failure');
1198
+ },
1199
+ useCustomerConversionGoals: async (camp) => {
1200
+ customerGoalCalls.push(camp);
1201
+ },
1202
+ attachCampaignLocations: async () => { },
1203
+ attachCampaignLanguages: async () => { },
1204
+ excludeCampaignMobileApps: async () => { },
1205
+ ensureCustomConversionGoal: async () => { customGoalCalled = true; return 'customers/4684433735/customConversionGoals/6459709271'; },
1206
+ setCampaignConversionGoal: async () => { customGoalCalled = true; },
1207
+ createAdGroup: async () => 'customers/4684433735/adGroups/22',
1208
+ },
1209
+ }), async () => []);
1210
+ const proposalIssues = await driver.validateProposal({
1211
+ id: 'prog-ga-1',
1212
+ brandId: 'brand-1',
1213
+ hypothesis: 'h',
1214
+ metricName: 'Downloads',
1215
+ params: {
1216
+ campaignCode: 'CG-09',
1217
+ tacticConfigJSON: JSON.stringify({
1218
+ audience: 'targeted',
1219
+ dailyBudgetUsd: 25,
1220
+ landingPageUrl: 'https://example.org/ebook',
1221
+ conversionId: '7845520214',
1222
+ }),
1223
+ },
1224
+ });
1225
+ expect(proposalIssues).toEqual([]);
1226
+ driver['loadRefs'] = async () => storedRefs;
1227
+ driver['runScheduleFor'] = async () => ({ start: 1_800_000_000_000 });
1228
+ driver['saveRef'] = async (_pid, _bc, kind, urn, state) => {
1229
+ const row = {
1230
+ ExternalKind: kind,
1231
+ ExternalURN: urn,
1232
+ StateJSON: state ? JSON.stringify(state) : null,
1233
+ Save: async () => true,
1234
+ };
1235
+ storedRefs.push(row);
1236
+ return row;
1237
+ };
1238
+ const programArg = {
1239
+ id: 'prog-ga-1',
1240
+ brandId: 'brand-1',
1241
+ hypothesis: 'h',
1242
+ metricName: 'Downloads',
1243
+ params: {
1244
+ campaignCode: 'CG-09',
1245
+ tacticConfigJSON: JSON.stringify({
1246
+ audience: 'targeted',
1247
+ dailyBudgetUsd: 25,
1248
+ maxCpcUsd: 2,
1249
+ landingPageUrl: 'https://example.org/ebook',
1250
+ conversionId: '7845520214',
1251
+ }),
1252
+ },
1253
+ };
1254
+ await expect(driver.onLock(programArg)).rejects.toThrow('Simulated delivery update failure');
1255
+ const campaignRowAfterFail = storedRefs.find(r => r.ExternalKind === 'Campaign');
1256
+ expect(JSON.parse(campaignRowAfterFail.StateJSON).bidding).toBeUndefined();
1257
+ expect(JSON.parse(campaignRowAfterFail.StateJSON).goalConfigLevel).toBeUndefined();
1258
+ shouldFailDeliveryUpdate = false;
1259
+ await driver.onLock(programArg);
1260
+ expect(deliveryCalls).toHaveLength(2);
1261
+ expect(customerGoalCalls).toEqual(['customers/4684433735/campaigns/777']);
1262
+ expect(customGoalCalled).toBe(false);
1263
+ expect(JSON.parse(campaignRowAfterFail.StateJSON)).toMatchObject({
1264
+ bidding: 'TARGET_SPEND',
1265
+ maxCpcUsd: 2,
1266
+ positiveGeoTargetType: 'PRESENCE',
1267
+ goalConfigLevel: 'CUSTOMER',
1268
+ optimizeFor: 'conversions',
1269
+ locationsAttached: ['geoTargetConstants/2840', 'geoTargetConstants/2124'],
1270
+ languagesAttached: ['languageConstants/1000'],
1271
+ mobileAppsExcluded: true,
1272
+ });
1273
+ expect(JSON.parse(campaignRowAfterFail.StateJSON).conversionGoal).toBeUndefined();
1274
+ });
1275
+ it('attaches offsite_conversion tracking_specs on newly created Meta clicks ads (2B-all), omits them on primary-conversion ads, and refuses standing conversions without a Pixel (MA_CONVERSION_NO_PIXEL)', async () => {
1276
+ // 1. MetaAdsClient.createAd serializes deduped tracking_specs when trackingPixelIds is non-empty and omits when empty
1277
+ const { client, calls } = metaClient(() => ({ id: 'ad-901' }));
1278
+ await client.createAd({
1279
+ name: 'CM-01 — ad',
1280
+ adSetId: 'adset-1',
1281
+ creativeId: 'creative-1',
1282
+ status: 'PAUSED',
1283
+ trackingPixelIds: [' 415938279713144 ', '415938279713144', '998877665544332'],
1284
+ });
1285
+ expect(JSON.parse(calls[0].body.get('tracking_specs'))).toEqual([
1286
+ { 'action.type': ['offsite_conversion'], fb_pixel: ['415938279713144', '998877665544332'] },
1287
+ ]);
1288
+ await client.createAd({
1289
+ name: 'CM-02 — ad',
1290
+ adSetId: 'adset-2',
1291
+ creativeId: 'creative-2',
1292
+ status: 'PAUSED',
1293
+ });
1294
+ expect(calls[1].body.has('tracking_specs')).toBe(false);
1295
+ // 2. MetaAdsProgramDriver.onLock passes trackingPixelIds on clicks programs (both with standing conversions and with credential pixelId alone) and omits on primary-conversion programs
1296
+ const fakeUser = { ID: 'u1' };
1297
+ const standingId = '00000000-0000-4000-8000-000000000501';
1298
+ const primaryId = '00000000-0000-4000-8000-000000000502';
1299
+ const createAdPayloads = [];
1300
+ const savedRefs = [];
1301
+ let configuredConversions = [
1302
+ {
1303
+ id: standingId,
1304
+ name: 'Standing Checkout',
1305
+ isActive: true,
1306
+ attachToEveryCampaign: true,
1307
+ binding: { externalId: '777001', cachedName: 'Checkout Event' },
1308
+ },
1309
+ {
1310
+ id: primaryId,
1311
+ name: 'Primary Demo',
1312
+ isActive: true,
1313
+ attachToEveryCampaign: false,
1314
+ binding: { externalId: '777002', cachedName: 'Demo Event' },
1315
+ },
1316
+ ];
1317
+ const makeDriver = (pixelId, customConversionPixelId) => {
1318
+ const d = new MetaAdsProgramDriver(fakeUser, async () => ({
1319
+ ok: true,
1320
+ brandConnectionId: 'bc-ma',
1321
+ adAccountId: 'act_100200300',
1322
+ pageId: 'page-1',
1323
+ pixelId,
1324
+ launchState: 'Paused',
1325
+ client: {
1326
+ customConversions: async () => [
1327
+ { id: '777001', name: 'Checkout Event', enabled: true, customEventType: 'PURCHASE', pixelId: customConversionPixelId },
1328
+ ],
1329
+ findCustomConversionById: async (id) => id === '777002'
1330
+ ? { id: '777002', name: 'Demo Event', enabled: true, customEventType: 'LEAD', pixelId: customConversionPixelId ?? '111222333' }
1331
+ : null,
1332
+ findByName: async () => null,
1333
+ createCampaign: async () => 'camp-1',
1334
+ createAdSet: async () => 'adset-1',
1335
+ createCreative: async () => 'creative-1',
1336
+ createAd: async (opts) => {
1337
+ createAdPayloads.push(opts);
1338
+ return 'ad-1';
1339
+ },
1340
+ },
1341
+ }), async () => configuredConversions);
1342
+ d['loadRefs'] = async () => [];
1343
+ d['runScheduleFor'] = async () => ({ start: 1_800_000_000_000 });
1344
+ d['resolveImage'] = async () => ({ imageHash: null, imageNote: 'link ad without an image' });
1345
+ d['saveRef'] = async (_pid, _bc, kind, urn, state) => {
1346
+ savedRefs.push({ kind, urn, state });
1347
+ };
1348
+ return d;
1349
+ };
1350
+ const makeClicksOrConvProgram = (extra) => ({
1351
+ id: 'prog-ma-2b',
1352
+ brandId: 'brand-1',
1353
+ hypothesis: 'h',
1354
+ metricName: 'Clicks',
1355
+ params: {
1356
+ campaignCode: 'CM-2B',
1357
+ tacticConfigJSON: JSON.stringify({
1358
+ audience: 'targeted',
1359
+ dailyBudgetUsd: 25,
1360
+ landingPageUrl: 'https://example.org/guide',
1361
+ creativeHeadlines: ['Practical Guide'],
1362
+ creativeDescriptions: ['Read the guide'],
1363
+ ...extra,
1364
+ }),
1365
+ },
1366
+ });
1367
+ // 2a. Clicks + standing conversion with a second pixel -> deduped [ctx.pixelId, conversion.pixelId]
1368
+ const driverWithStanding = makeDriver('111222333', '444555666');
1369
+ const standingNoticeIssues = await driverWithStanding.validateProposal(makeClicksOrConvProgram({ optimizeFor: 'clicks' }));
1370
+ expect(standingNoticeIssues.find(i => i.code === 'MA_STANDING_MEASURED_ONLY')?.message).toContain('counts appear once ads serve');
1371
+ await driverWithStanding.onLock(makeClicksOrConvProgram({ optimizeFor: 'clicks' }));
1372
+ expect(createAdPayloads[0]).toMatchObject({ trackingPixelIds: ['111222333', '444555666'] });
1373
+ expect(savedRefs.find(r => r.kind === 'Other')?.state).toMatchObject({
1374
+ role: 'ad',
1375
+ trackingPixelIds: ['111222333', '444555666'],
1376
+ });
1377
+ // 2b. Clicks + 0 standing conversions + ctx.pixelId (2B-all) -> [ctx.pixelId] still attached
1378
+ createAdPayloads.length = 0;
1379
+ savedRefs.length = 0;
1380
+ configuredConversions = [];
1381
+ const driverClicksOnly = makeDriver('111222333', null);
1382
+ await driverClicksOnly.onLock(makeClicksOrConvProgram({ optimizeFor: 'clicks' }));
1383
+ expect(createAdPayloads[0]).toMatchObject({ trackingPixelIds: ['111222333'] });
1384
+ expect(savedRefs.find(r => r.kind === 'Other')?.state).toMatchObject({
1385
+ role: 'ad',
1386
+ trackingPixelIds: ['111222333'],
1387
+ });
1388
+ // 2c. Primary conversion program -> omits trackingPixelIds on createAd (ad set carries promoted_object)
1389
+ createAdPayloads.length = 0;
1390
+ savedRefs.length = 0;
1391
+ configuredConversions = [{
1392
+ id: primaryId,
1393
+ name: 'Primary Demo',
1394
+ isActive: true,
1395
+ attachToEveryCampaign: false,
1396
+ binding: { externalId: '777002', cachedName: 'Demo Event' },
1397
+ }];
1398
+ const driverPrimary = makeDriver('111222333', '444555666');
1399
+ await driverPrimary.onLock(makeClicksOrConvProgram({ brandConversionId: primaryId, brandConversion: 'Primary Demo' }));
1400
+ expect(createAdPayloads[0]['trackingPixelIds']).toBeUndefined();
1401
+ expect(savedRefs.find(r => r.kind === 'Other')?.state?.['trackingPixelIds']).toBeUndefined();
1402
+ // 3. Standing conversion with neither conversion.pixelId nor ctx.pixelId -> MA_CONVERSION_NO_PIXEL at propose and refusal at lock
1403
+ configuredConversions = [{
1404
+ id: standingId,
1405
+ name: 'Pixel-less Standing',
1406
+ isActive: true,
1407
+ attachToEveryCampaign: true,
1408
+ binding: { externalId: '777001', cachedName: 'Checkout Event' },
1409
+ }];
1410
+ const driverNoPixel = makeDriver(null, null);
1411
+ const noPixelIssues = await driverNoPixel.validateProposal(makeClicksOrConvProgram({ optimizeFor: 'clicks' }));
1412
+ expect(noPixelIssues.find(i => i.code === 'MA_CONVERSION_NO_PIXEL')).toMatchObject({ severity: 'error' });
1413
+ expect(noPixelIssues.find(i => i.code === 'MA_STANDING_MEASURED_ONLY')).toBeUndefined();
1414
+ await expect(driverNoPixel.onLock(makeClicksOrConvProgram({ optimizeFor: 'clicks' }))).rejects.toThrow(/names no Pixel and the Meta Ads credential carries none/);
1415
+ });
256
1416
  });
257
1417
  //# sourceMappingURL=adConversionParity.test.js.map