@mj-biz-apps/marketing-server 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/adConversionParity.test.js +677 -71
- package/dist/__tests__/adConversionParity.test.js.map +1 -1
- package/dist/__tests__/googleCreativeImage.test.js +6 -5
- package/dist/__tests__/googleCreativeImage.test.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts +221 -0
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js +1306 -106
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/programs/brandConversionBindings.d.ts +58 -0
- package/dist/programs/brandConversionBindings.d.ts.map +1 -0
- package/dist/programs/brandConversionBindings.js +145 -0
- package/dist/programs/brandConversionBindings.js.map +1 -0
- package/dist/programs/google/GoogleAdsClient.d.ts +5 -5
- package/dist/programs/google/GoogleAdsClient.d.ts.map +1 -1
- package/dist/programs/google/GoogleAdsClient.js +16 -6
- package/dist/programs/google/GoogleAdsClient.js.map +1 -1
- package/dist/programs/google/GoogleAdsProgramDriver.d.ts +8 -4
- package/dist/programs/google/GoogleAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/google/GoogleAdsProgramDriver.js +205 -23
- package/dist/programs/google/GoogleAdsProgramDriver.js.map +1 -1
- package/dist/programs/index.d.ts +2 -0
- package/dist/programs/index.d.ts.map +1 -1
- package/dist/programs/index.js +1 -0
- package/dist/programs/index.js.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsClient.d.ts +6 -5
- package/dist/programs/linkedin/LinkedInAdsClient.d.ts.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsClient.js +15 -10
- package/dist/programs/linkedin/LinkedInAdsClient.js.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts +12 -13
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.js +177 -128
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.js.map +1 -1
- package/dist/programs/linkedin/linkedInExecutionContext.d.ts +1 -6
- package/dist/programs/linkedin/linkedInExecutionContext.d.ts.map +1 -1
- package/dist/programs/linkedin/linkedInExecutionContext.js +1 -2
- package/dist/programs/linkedin/linkedInExecutionContext.js.map +1 -1
- package/dist/programs/meta/MetaAdsClient.d.ts +8 -5
- package/dist/programs/meta/MetaAdsClient.d.ts.map +1 -1
- package/dist/programs/meta/MetaAdsClient.js +39 -10
- package/dist/programs/meta/MetaAdsClient.js.map +1 -1
- package/dist/programs/meta/MetaAdsProgramDriver.d.ts +12 -4
- package/dist/programs/meta/MetaAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/meta/MetaAdsProgramDriver.js +294 -32
- package/dist/programs/meta/MetaAdsProgramDriver.js.map +1 -1
- 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
|
-
? {
|
|
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(
|
|
192
|
-
|
|
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
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
|
680
|
+
// Re-lock after turning off AttachToEveryCampaign on Book Demo: detaches 29325716 and updates StateJSON.standingConversions.
|
|
594
681
|
associated.length = 0;
|
|
595
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const
|
|
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 () =>
|
|
752
|
+
tokenScopes: async () => ['r_ads', 'rw_ads', 'rw_conversions'],
|
|
635
753
|
conversions: async () => [
|
|
636
|
-
{ id: '
|
|
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 () => {
|
|
640
|
-
|
|
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
|
|
644
|
-
|
|
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-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
702
|
-
expect(
|
|
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
|