Skip to content

Commit 112bddb

Browse files
committed
[AI-FSSDK] [FSSDK-12760] add localHoldouts to datafile for backward compatibility
1 parent 80e4c84 commit 112bddb

4 files changed

Lines changed: 281 additions & 93 deletions

File tree

lib/core/decision_service/index.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2882,9 +2882,12 @@ describe('DecisionService', () => {
28822882

28832883
describe('local holdouts', () => {
28842884
// Helper: build a datafile that has a local holdout targeting a specific experiment or delivery rule.
2885+
// Per FSSDK-12760, local holdouts live in the top-level `localHoldouts` section
2886+
// (separate from `holdouts`, which now only carries global holdouts).
28852887
const makeLocalHoldoutDatafile = (targetRuleId: string, ruleIds: string[] = [targetRuleId]) => {
28862888
const datafile = getDecisionTestDatafile();
2887-
(datafile as any).holdouts = [
2889+
(datafile as any).holdouts = [];
2890+
(datafile as any).localHoldouts = [
28882891
{
28892892
id: 'local_holdout_id',
28902893
key: 'local_holdout',

lib/project_config/project_config.spec.ts

Lines changed: 178 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -416,12 +416,12 @@ describe('createProjectConfig - holdouts', () => {
416416
});
417417
});
418418

419-
// Level 1 tests for local holdouts (FSSDK-12369): isGlobal, getGlobalHoldouts, getHoldoutsForRule
420-
describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
421-
const makeLocalHoldoutsDatafile = () => {
419+
// Level 1 tests for local holdouts (FSSDK-12369, FSSDK-12760).
420+
// Section membership (`holdouts` vs `localHoldouts`) is the sole signal for scope.
421+
describe('createProjectConfig - local holdouts (FSSDK-12369, FSSDK-12760)', () => {
422+
const makeHoldoutsDatafile = () => {
422423
const datafile = testDatafile.getTestDecideProjectConfig();
423-
// Holdout with no includedRules → global
424-
// Holdout with includedRules array → local
424+
// Entries in `holdouts` are global by section membership.
425425
(datafile as any).holdouts = [
426426
{
427427
id: 'global_holdout_id',
@@ -433,8 +433,10 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
433433
audienceConditions: [],
434434
variations: [{ id: 'global_var_id', key: 'global_var', variables: [] }],
435435
trafficAllocation: [{ entityId: 'global_var_id', endOfRange: 5000 }],
436-
// no includedRules → isGlobal should be true
437436
},
437+
];
438+
// Entries in `localHoldouts` are local; scoped via includedRules.
439+
(datafile as any).localHoldouts = [
438440
{
439441
id: 'local_holdout_rule_a_id',
440442
key: 'local_holdout_rule_a',
@@ -447,70 +449,32 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
447449
variations: [{ id: 'local_var_id', key: 'local_var', variables: [] }],
448450
trafficAllocation: [{ entityId: 'local_var_id', endOfRange: 5000 }],
449451
},
450-
{
451-
id: 'empty_local_holdout_id',
452-
key: 'empty_local_holdout',
453-
status: 'Running',
454-
includedFlags: [],
455-
excludedFlags: [],
456-
audienceIds: [],
457-
audienceConditions: [],
458-
includedRules: [], // empty array → local holdout (NOT global)
459-
variations: [{ id: 'empty_var_id', key: 'empty_var', variables: [] }],
460-
trafficAllocation: [{ entityId: 'empty_var_id', endOfRange: 5000 }],
461-
},
462-
{
463-
id: 'null_included_rules_id',
464-
key: 'null_included_rules',
465-
status: 'Running',
466-
includedFlags: [],
467-
excludedFlags: [],
468-
audienceIds: [],
469-
audienceConditions: [],
470-
includedRules: null, // explicit null → global holdout
471-
variations: [{ id: 'null_var_id', key: 'null_var', variables: [] }],
472-
trafficAllocation: [{ entityId: 'null_var_id', endOfRange: 5000 }],
473-
},
474452
];
475453
return datafile;
476454
};
477455

478-
it('should set isGlobal=true for holdout with no includedRules field (backward compat with old datafiles)', () => {
479-
const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any);
456+
it('should set isGlobal=true for entries in the holdouts section (backward compat with old datafiles)', () => {
457+
const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any);
480458
const holdout = config.holdoutIdMap!['global_holdout_id'];
481459
expect(holdout.isGlobal).toBe(true);
482460
});
483461

484-
it('should set isGlobal=false for holdout with includedRules array', () => {
485-
const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any);
462+
it('should set isGlobal=false for entries in the localHoldouts section', () => {
463+
const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any);
486464
const holdout = config.holdoutIdMap!['local_holdout_rule_a_id'];
487465
expect(holdout.isGlobal).toBe(false);
488466
});
489467

490-
it('should set isGlobal=false for holdout with empty includedRules array (empty array is still local)', () => {
491-
const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any);
492-
const holdout = config.holdoutIdMap!['empty_local_holdout_id'];
493-
expect(holdout.isGlobal).toBe(false);
494-
});
495-
496-
it('should set isGlobal=true for holdout with explicit null includedRules', () => {
497-
const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any);
498-
const holdout = config.holdoutIdMap!['null_included_rules_id'];
499-
expect(holdout.isGlobal).toBe(true);
500-
});
501-
502-
it('getGlobalHoldouts should return only holdouts with isGlobal=true', () => {
503-
const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any);
468+
it('getGlobalHoldouts should return only entries from the holdouts section', () => {
469+
const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any);
504470
const globals = getGlobalHoldouts(config);
505471
const globalIds = globals.map(h => h.id);
506472
expect(globalIds).toContain('global_holdout_id');
507-
expect(globalIds).toContain('null_included_rules_id');
508473
expect(globalIds).not.toContain('local_holdout_rule_a_id');
509-
expect(globalIds).not.toContain('empty_local_holdout_id');
510474
});
511475

512476
it('getHoldoutsForRule should return local holdouts targeting the given rule ID', () => {
513-
const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any);
477+
const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any);
514478
const forRuleA = getHoldoutsForRule(config, 'rule_a');
515479
expect(forRuleA).toHaveLength(1);
516480
expect(forRuleA[0].id).toBe('local_holdout_rule_a_id');
@@ -521,14 +485,15 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
521485
});
522486

523487
it('getHoldoutsForRule should return empty array for an unknown rule ID', () => {
524-
const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any);
488+
const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any);
525489
const forUnknown = getHoldoutsForRule(config, 'nonexistent_rule');
526490
expect(forUnknown).toHaveLength(0);
527491
});
528492

529-
it('getGlobalHoldouts should return empty array when all holdouts are local', () => {
530-
const datafile = cloneDeep(makeLocalHoldoutsDatafile());
531-
(datafile as any).holdouts = [
493+
it('getGlobalHoldouts should return empty array when only the localHoldouts section is populated', () => {
494+
const datafile = cloneDeep(makeHoldoutsDatafile());
495+
(datafile as any).holdouts = [];
496+
(datafile as any).localHoldouts = [
532497
{
533498
id: 'only_local_id',
534499
key: 'only_local',
@@ -554,7 +519,7 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
554519
});
555520

556521
it('a single local holdout targeting multiple rules should appear for each targeted rule', () => {
557-
const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any);
522+
const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any);
558523
const forRuleA = getHoldoutsForRule(config, 'rule_a');
559524
const forRuleB = getHoldoutsForRule(config, 'rule_b');
560525
// Both rule_a and rule_b point to the same holdout
@@ -563,6 +528,163 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
563528
});
564529
});
565530

531+
// Level 1 tests for the FSSDK-12760 backward-compatible localHoldouts section design.
532+
// Older SDKs (Gen 1/Gen 2) ignore the unknown `localHoldouts` top-level key entirely.
533+
// Gen 3 SDKs (this one) treat section membership as the sole signal for scope.
534+
describe('createProjectConfig - localHoldouts section (FSSDK-12760)', () => {
535+
const makeBaseDatafile = () => testDatafile.getTestDecideProjectConfig();
536+
537+
const makeLocal = (id: string, key: string, includedRules: any) => ({
538+
id,
539+
key,
540+
status: 'Running',
541+
includedFlags: [],
542+
excludedFlags: [],
543+
audienceIds: [],
544+
audienceConditions: [],
545+
includedRules,
546+
variations: [{ id: `${id}_var`, key: 'holdout', variables: [] }],
547+
trafficAllocation: [{ entityId: `${id}_var`, endOfRange: 10000 }],
548+
});
549+
550+
const makeGlobal = (id: string, key: string, extra: Record<string, any> = {}) => ({
551+
id,
552+
key,
553+
status: 'Running',
554+
includedFlags: [],
555+
excludedFlags: [],
556+
audienceIds: [],
557+
audienceConditions: [],
558+
variations: [{ id: `${id}_var`, key: 'holdout', variables: [] }],
559+
trafficAllocation: [{ entityId: `${id}_var`, endOfRange: 10000 }],
560+
...extra,
561+
});
562+
563+
it('exposes localHoldouts as a top-level array on the project config', () => {
564+
const datafile = cloneDeep(makeBaseDatafile()) as any;
565+
datafile.holdouts = [];
566+
datafile.localHoldouts = [makeLocal('l1', 'local_h', ['rule_x'])];
567+
const config = projectConfig.createProjectConfig(datafile);
568+
expect(Array.isArray(config.localHoldouts)).toBe(true);
569+
expect(config.localHoldouts).toHaveLength(1);
570+
expect(config.localHoldouts[0].id).toBe('l1');
571+
});
572+
573+
it('defaults localHoldouts to an empty array when the section is absent (backward compat)', () => {
574+
const datafile = cloneDeep(makeBaseDatafile()) as any;
575+
datafile.holdouts = [makeGlobal('g1', 'global_h')];
576+
// No localHoldouts key at all
577+
const config = projectConfig.createProjectConfig(datafile);
578+
expect(config.localHoldouts).toEqual([]);
579+
expect(getGlobalHoldouts(config).map(h => h.id)).toContain('g1');
580+
expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]);
581+
});
582+
583+
it('ignores includedRules on entries in the global holdouts section', () => {
584+
// An includedRules field on a `holdouts` entry must NOT narrow its scope —
585+
// section membership is the sole signal for scope.
586+
const datafile = cloneDeep(makeBaseDatafile()) as any;
587+
datafile.holdouts = [
588+
makeGlobal('stray', 'stray_global', { includedRules: ['rule_should_be_ignored'] }),
589+
];
590+
datafile.localHoldouts = [];
591+
const config = projectConfig.createProjectConfig(datafile);
592+
593+
const stray = config.holdoutIdMap!['stray'];
594+
// includedRules must be stripped at parse time
595+
expect(stray.includedRules).toBeUndefined();
596+
// Entity is global
597+
expect(stray.isGlobal).toBe(true);
598+
// It appears in global list and NOT in the rule map
599+
expect(getGlobalHoldouts(config).map(h => h.id)).toContain('stray');
600+
expect(getHoldoutsForRule(config, 'rule_should_be_ignored')).toEqual([]);
601+
});
602+
603+
it('does not mutate the caller-provided datafile when stripping includedRules', () => {
604+
// Regression guard: parsing a datafile must not bleed mutations into the
605+
// caller's reference (some hosts re-use the same datafile object).
606+
const datafile = cloneDeep(makeBaseDatafile()) as any;
607+
datafile.holdouts = [
608+
makeGlobal('stray', 'stray_global', { includedRules: ['rule_x'] }),
609+
];
610+
projectConfig.createProjectConfig(datafile);
611+
// Original datafile still has includedRules on the entry
612+
expect(datafile.holdouts[0].includedRules).toEqual(['rule_x']);
613+
});
614+
615+
it('partitions both sections correctly when both are present', () => {
616+
const datafile = cloneDeep(makeBaseDatafile()) as any;
617+
datafile.holdouts = [makeGlobal('g1', 'g'), makeGlobal('g2', 'g2')];
618+
datafile.localHoldouts = [
619+
makeLocal('l1', 'l1', ['rule_a']),
620+
makeLocal('l2', 'l2', ['rule_b']),
621+
];
622+
const config = projectConfig.createProjectConfig(datafile);
623+
624+
expect(getGlobalHoldouts(config).map(h => h.id).sort()).toEqual(['g1', 'g2']);
625+
expect(getHoldoutsForRule(config, 'rule_a').map(h => h.id)).toEqual(['l1']);
626+
expect(getHoldoutsForRule(config, 'rule_b').map(h => h.id)).toEqual(['l2']);
627+
// ID map covers both sections
628+
expect(config.holdoutIdMap!['g1']).toBeDefined();
629+
expect(config.holdoutIdMap!['l1']).toBeDefined();
630+
});
631+
632+
it('logs an error and excludes localHoldouts entries with no includedRules', () => {
633+
const datafile = cloneDeep(makeBaseDatafile()) as any;
634+
datafile.holdouts = [];
635+
// Invalid: no includedRules at all
636+
const invalid: any = makeLocal('bad', 'invalid_local', undefined);
637+
delete invalid.includedRules;
638+
datafile.localHoldouts = [invalid];
639+
640+
const config = projectConfig.createProjectConfig(datafile, null, logger);
641+
642+
expect(getGlobalHoldouts(config)).toEqual([]);
643+
expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]);
644+
expect(config.holdoutIdMap!['bad']).toBeUndefined();
645+
expect(logger.error).toHaveBeenCalledWith(
646+
expect.stringMatching(/invalid_local.*includedRules/i)
647+
);
648+
});
649+
650+
it('logs an error and excludes localHoldouts entries with null includedRules', () => {
651+
const datafile = cloneDeep(makeBaseDatafile()) as any;
652+
datafile.holdouts = [];
653+
datafile.localHoldouts = [makeLocal('bad_null', 'null_local', null)];
654+
655+
const config = projectConfig.createProjectConfig(datafile, null, logger);
656+
657+
expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]);
658+
expect(config.holdoutIdMap!['bad_null']).toBeUndefined();
659+
expect(logger.error).toHaveBeenCalledWith(
660+
expect.stringMatching(/null_local.*includedRules/i)
661+
);
662+
});
663+
664+
it('logs an error and excludes localHoldouts entries with empty includedRules', () => {
665+
const datafile = cloneDeep(makeBaseDatafile()) as any;
666+
datafile.holdouts = [];
667+
datafile.localHoldouts = [makeLocal('bad_empty', 'empty_local', [])];
668+
669+
const config = projectConfig.createProjectConfig(datafile, null, logger);
670+
671+
expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]);
672+
expect(config.holdoutIdMap!['bad_empty']).toBeUndefined();
673+
expect(logger.error).toHaveBeenCalledWith(
674+
expect.stringMatching(/empty_local.*includedRules/i)
675+
);
676+
});
677+
678+
it('keeps holdout variations in the variationIdMap from both sections', () => {
679+
const datafile = cloneDeep(makeBaseDatafile()) as any;
680+
datafile.holdouts = [makeGlobal('g1', 'g')];
681+
datafile.localHoldouts = [makeLocal('l1', 'l', ['rule_x'])];
682+
const config = projectConfig.createProjectConfig(datafile);
683+
expect(config.variationIdMap['g1_var']).toBeDefined();
684+
expect(config.variationIdMap['l1_var']).toBeDefined();
685+
});
686+
});
687+
566688
describe('getExperimentId', () => {
567689
let testData: Record<string, any>;
568690
let configObj: ProjectConfig;

0 commit comments

Comments
 (0)