@mj-biz-apps/marketing-server 0.7.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 (49) hide show
  1. package/dist/__tests__/adConversionParity.test.js +677 -71
  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 +5 -5
  18. package/dist/programs/google/GoogleAdsClient.d.ts.map +1 -1
  19. package/dist/programs/google/GoogleAdsClient.js +16 -6
  20. package/dist/programs/google/GoogleAdsClient.js.map +1 -1
  21. package/dist/programs/google/GoogleAdsProgramDriver.d.ts +8 -4
  22. package/dist/programs/google/GoogleAdsProgramDriver.d.ts.map +1 -1
  23. package/dist/programs/google/GoogleAdsProgramDriver.js +205 -23
  24. package/dist/programs/google/GoogleAdsProgramDriver.js.map +1 -1
  25. package/dist/programs/index.d.ts +2 -0
  26. package/dist/programs/index.d.ts.map +1 -1
  27. package/dist/programs/index.js +1 -0
  28. package/dist/programs/index.js.map +1 -1
  29. package/dist/programs/linkedin/LinkedInAdsClient.d.ts +6 -5
  30. package/dist/programs/linkedin/LinkedInAdsClient.d.ts.map +1 -1
  31. package/dist/programs/linkedin/LinkedInAdsClient.js +15 -10
  32. package/dist/programs/linkedin/LinkedInAdsClient.js.map +1 -1
  33. package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts +12 -13
  34. package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts.map +1 -1
  35. package/dist/programs/linkedin/LinkedInAdsProgramDriver.js +177 -128
  36. package/dist/programs/linkedin/LinkedInAdsProgramDriver.js.map +1 -1
  37. package/dist/programs/linkedin/linkedInExecutionContext.d.ts +1 -6
  38. package/dist/programs/linkedin/linkedInExecutionContext.d.ts.map +1 -1
  39. package/dist/programs/linkedin/linkedInExecutionContext.js +1 -2
  40. package/dist/programs/linkedin/linkedInExecutionContext.js.map +1 -1
  41. package/dist/programs/meta/MetaAdsClient.d.ts +8 -5
  42. package/dist/programs/meta/MetaAdsClient.d.ts.map +1 -1
  43. package/dist/programs/meta/MetaAdsClient.js +39 -10
  44. package/dist/programs/meta/MetaAdsClient.js.map +1 -1
  45. package/dist/programs/meta/MetaAdsProgramDriver.d.ts +12 -4
  46. package/dist/programs/meta/MetaAdsProgramDriver.d.ts.map +1 -1
  47. package/dist/programs/meta/MetaAdsProgramDriver.js +294 -32
  48. package/dist/programs/meta/MetaAdsProgramDriver.js.map +1 -1
  49. package/package.json +16 -16
@@ -5,6 +5,7 @@ import { LinkedInAdsClient, linkedInConversionTypeFor } from '../programs/linked
5
5
  import { LinkedInAdsProgramDriver } from '../programs/linkedin/LinkedInAdsProgramDriver.js';
6
6
  import { MetaAdsClient, customConversionActionType, metaCallToActionFor, metaObjectiveFor } from '../programs/meta/MetaAdsClient.js';
7
7
  import { MetaAdsProgramDriver } from '../programs/meta/MetaAdsProgramDriver.js';
8
+ import { BrandConversionLookupError } from '../programs/brandConversionBindings.js';
8
9
  function googleClient(answer) {
9
10
  const calls = [];
10
11
  const fetchImpl = (async (input, init) => {
@@ -183,15 +184,45 @@ describe('MetaAdsClient — custom conversions (D-34 parity)', () => {
183
184
  expect(post.get('custom_event_type')).toBe('LEAD');
184
185
  expect(JSON.parse(post.get('rule'))).toEqual({ and: [{ event: { eq: 'Lead' } }, { URL: { i_contains: 'sidecar.ai/ai' } }] });
185
186
  });
186
- 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 () => {
187
188
  const { client, calls } = metaClient((url) => url.pathname.endsWith('/saved_audiences')
188
- ? { data: [{ id: '6002002', name: 'SC | Association Leaders', approximate_count_64bit: 420_000, targeting: { flexible_spec: [{ interests: [{ id: '6003', name: 'Association' }] }] } }] }
189
+ ? {
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
+ }
189
207
  : { id: '3300' });
190
208
  const audiences = await client.savedAudiences();
191
- expect(audiences).toEqual([{
192
- 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,
193
217
  targeting: { flexible_spec: [{ interests: [{ id: '6003', name: 'Association' }] }] },
194
- }]);
218
+ },
219
+ {
220
+ id: '6002003',
221
+ name: 'SC | Pending Estimate',
222
+ approximateMemberCount: null,
223
+ targeting: { geo_locations: { countries: ['US'] } },
224
+ },
225
+ ]);
195
226
  await client.createAdSet({
196
227
  name: 'CX-12 — ad set', campaignId: '120', dailyBudgetUsd: 25, status: 'PAUSED',
197
228
  runSchedule: { start: 1_792_000_000_000 }, countries: ['US'],
@@ -203,6 +234,44 @@ describe('MetaAdsClient — custom conversions (D-34 parity)', () => {
203
234
  geo_locations: { countries: ['US'] },
204
235
  });
205
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
+ });
206
275
  it('skips ARCHIVED or DELETED campaigns and ad sets in Meta findByName (KI-158)', async () => {
207
276
  const { client } = metaClient(() => ({ data: [
208
277
  { id: '1', name: 'CX-01', status: 'ARCHIVED' },
@@ -524,14 +593,29 @@ describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CON
524
593
  expect(deletes).toHaveLength(2);
525
594
  expect(deletes[0].url).toContain('/rest/campaignConversions/(campaign:urn%3Ali%3AsponsoredCampaign%3A910066056,conversion:urn%3Alla%3AllaPartnerConversion%3A18994420)');
526
595
  });
527
- it('attaches connection trackingConversions on LinkedIn lock without changing state.conversion or optimizeFor, and detaches removed standing conversions on re-lock', async () => {
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 () => {
528
597
  const associated = [];
529
598
  const disassociated = [];
599
+ const analyticsArgs = [];
530
600
  const savedRefs = [];
531
601
  const fakeUser = { ID: 'u1' };
532
- let currentTracking = [
533
- { ID: '30316052', Name: 'Self-Serve Checkout' },
534
- { ID: '29325716', Name: 'Demo Form Submit' },
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
+ },
535
619
  ];
536
620
  const driver = new LinkedInAdsProgramDriver(fakeUser, async () => ({
537
621
  ok: true,
@@ -540,7 +624,6 @@ describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CON
540
624
  launchState: 'Paused',
541
625
  organizationUrn: 'urn:li:organization:900001',
542
626
  defaultAudienceTemplateId: '70217227',
543
- trackingConversions: currentTracking,
544
627
  client: {
545
628
  accountReference: async () => 'urn:li:organization:900001',
546
629
  tokenScopes: async () => ['r_ads', 'rw_ads', 'rw_conversions'],
@@ -553,8 +636,12 @@ describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CON
553
636
  createCampaign: async () => 'urn:li:sponsoredCampaign:2',
554
637
  associateConversion: async (camp, conv) => { associated.push([camp, conv]); },
555
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
+ },
556
643
  },
557
- }));
644
+ }), async () => currentBrandConversions);
558
645
  driver['loadRefs'] = async () => [];
559
646
  driver['runScheduleFor'] = async () => ({ start: 1_800_000_000_000 });
560
647
  driver['saveRef'] = async (_pid, _bc, kind, urn, state) => {
@@ -585,14 +672,29 @@ describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CON
585
672
  conversion: null,
586
673
  optimizeFor: 'clicks',
587
674
  objective: 'WEBSITE_VISIT',
588
- trackingConversions: [
589
- { id: '30316052', name: 'Self-Serve Checkout' },
590
- { id: '29325716', name: 'Demo Form Submit' },
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' },
591
678
  ],
592
679
  });
593
- // Re-lock after removing 29325716 from the connection: detaches 29325716 and updates StateJSON.trackingConversions.
680
+ // Re-lock after turning off AttachToEveryCampaign on Book Demo: detaches 29325716 and updates StateJSON.standingConversions.
594
681
  associated.length = 0;
595
- currentTracking = [{ ID: '30316052', Name: 'Self-Serve Checkout' }];
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
+ ];
596
698
  const existingCampaignRow = {
597
699
  ExternalKind: 'Campaign',
598
700
  ExternalURN: 'urn:li:sponsoredCampaign:2',
@@ -613,40 +715,260 @@ describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CON
613
715
  expect(JSON.parse(existingCampaignRow.StateJSON)).toMatchObject({
614
716
  conversion: null,
615
717
  optimizeFor: 'clicks',
616
- trackingConversions: [{ id: '30316052', name: 'Self-Serve Checkout' }],
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,
617
734
  });
618
735
  });
619
- it('validates standing conversions at propose, connection save, and BEFORE onLock creates any group or campaign', async () => {
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 () => {
620
737
  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 () => ({
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 () => ({
625
744
  ok: true,
626
745
  brandConnectionId: 'bc-li',
627
746
  adAccountId: '509410093',
628
747
  launchState: 'Paused',
629
748
  organizationUrn: 'urn:li:organization:900001',
630
749
  defaultAudienceTemplateId: '70217227',
631
- trackingConversions: [{ ID: '99999999', Name: 'Deleted Conversion' }],
632
750
  client: {
633
751
  accountReference: async () => 'urn:li:organization:900001',
634
- tokenScopes: async () => scopes,
752
+ tokenScopes: async () => ['r_ads', 'rw_ads', 'rw_conversions'],
635
753
  conversions: async () => [
636
- { id: '30316052', urn: 'urn:lla:llaPartnerConversion:30316052', name: 'Self-Serve Checkout', type: 'PURCHASE', enabled: true },
754
+ { id: '29325716', urn: 'urn:lla:llaPartnerConversion:29325716', name: 'Demo Form Submit', type: 'LEAD', enabled: true },
637
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,
638
759
  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'; },
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' },
641
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
+ }],
642
838
  }));
643
- const program = {
644
- id: 'prog-li-2',
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',
645
967
  brandId: 'brand-1',
646
968
  hypothesis: 'h',
647
969
  metricName: 'Clicks',
648
970
  params: {
649
- campaignCode: 'CP-10',
971
+ campaignCode: 'CP-FC',
650
972
  tacticConfigJSON: JSON.stringify({
651
973
  audience: 'targeted',
652
974
  dailyBudgetUsd: 25,
@@ -655,51 +977,194 @@ describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CON
655
977
  }),
656
978
  },
657
979
  };
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({
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',
668
1054
  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({
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',
679
1088
  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({
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 () => ({
686
1122
  ok: true,
687
- conversions: [{ ID: '30316052', Name: 'Self-Serve Checkout' }],
688
- });
689
- scopes = ['r_ads', 'rw_ads'];
690
- const noScopeRes = await driver.resolveStandingConversions({
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',
691
1145
  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'),
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',
700
1165
  });
701
- const noScopeIssues = await driver.validateProposal(program);
702
- expect(noScopeIssues.some(i => i.code === 'LI_CONVERSION_SCOPE' && i.severity === 'error')).toBe(true);
1166
+ saveOk = false;
1167
+ await expect(maDriver.onLock(prog)).rejects.toThrow(/simulated DB error/);
703
1168
  });
704
1169
  it('keeps Google campaigns on account-default CUSTOMER goals under Maximize clicks (G1) and updates adopted campaigns only after platform writes succeed', async () => {
705
1170
  const fakeUser = { ID: 'u1' };
@@ -741,7 +1206,7 @@ describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CON
741
1206
  setCampaignConversionGoal: async () => { customGoalCalled = true; },
742
1207
  createAdGroup: async () => 'customers/4684433735/adGroups/22',
743
1208
  },
744
- }));
1209
+ }), async () => []);
745
1210
  const proposalIssues = await driver.validateProposal({
746
1211
  id: 'prog-ga-1',
747
1212
  brandId: 'brand-1',
@@ -807,5 +1272,146 @@ describe('LinkedInAdsClient — conversions, URL conversion rules, and pivot=CON
807
1272
  });
808
1273
  expect(JSON.parse(campaignRowAfterFail.StateJSON).conversionGoal).toBeUndefined();
809
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
+ });
810
1416
  });
811
1417
  //# sourceMappingURL=adConversionParity.test.js.map