Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { LDLogger, TypeValidators } from '@launchdarkly/js-sdk-common';

import { recordOf, validatorOf } from '../../src/configuration/validateOptions';

let logger: LDLogger;

beforeEach(() => {
logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
});

describe('recordOf with built-in defaults', () => {
const valueValidator = validatorOf({
name: TypeValidators.String,
count: TypeValidators.numberWithMin(1),
});

const keyValidator = TypeValidators.oneOf('a', 'b', 'c');

const builtInDefaults = {
a: { name: 'alpha', count: 10 },
b: { name: 'beta', count: 20 },
c: { name: 'gamma', count: 30 },
};

const validator = recordOf(keyValidator, valueValidator, { defaults: builtInDefaults });

it('uses built-in defaults for keys not provided by the caller', () => {
const result = validator.validate({ a: { name: 'custom' } }, 'test', logger);

expect(result?.value).toEqual({
a: { name: 'custom', count: 10 },
b: { name: 'beta', count: 20 },
c: { name: 'gamma', count: 30 },
});
expect(logger.warn).not.toHaveBeenCalled();
});

it('fills in missing fields from the per-key default', () => {
const result = validator.validate({ b: { count: 99 } }, 'test', logger);

// name comes from built-in default for key 'b'
expect((result?.value as any).b).toEqual({ name: 'beta', count: 99 });
});

it('returns built-in defaults when input is empty object', () => {
const result = validator.validate({}, 'test', logger);

expect(result?.value).toEqual(builtInDefaults);
});

it('returns undefined when input is undefined', () => {
const result = validator.validate(undefined, 'test', logger);

expect(result).toBeUndefined();
});

it('returns undefined when input is null', () => {
const result = validator.validate(null, 'test', logger);

expect(result).toBeUndefined();
});

it('prefers built-in defaults over caller-provided defaults', () => {
const callerDefaults = {
a: { name: 'caller-alpha', count: 1 },
};

const result = validator.validate({}, 'test', logger, callerDefaults);

// Built-in defaults take priority over caller defaults.
expect(result?.value).toEqual(builtInDefaults);
});
});

describe('recordOf without built-in defaults', () => {
const valueValidator = validatorOf({
name: TypeValidators.String,
count: TypeValidators.numberWithMin(1),
});

const keyValidator = TypeValidators.oneOf('a', 'b');

const validator = recordOf(keyValidator, valueValidator);

it('uses caller-provided defaults when no built-in defaults', () => {
const callerDefaults = {
a: { name: 'alpha', count: 10 },
b: { name: 'beta', count: 20 },
};

const result = validator.validate({ a: { name: 'custom' } }, 'test', logger, callerDefaults);

expect((result?.value as any).a).toEqual({ name: 'custom', count: 10 });
expect((result?.value as any).b).toEqual({ name: 'beta', count: 20 });
});

it('uses empty defaults when neither built-in nor caller defaults are provided', () => {
const result = validator.validate({ a: { name: 'custom' } }, 'test', logger);

// No defaults to fill in count, so only name is present.
expect((result?.value as any).a).toEqual({ name: 'custom' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { LDLogger } from '@launchdarkly/js-sdk-common';

import validateOptions from '../../src/configuration/validateOptions';
import {
BACKGROUND_POLL_INTERVAL_SECONDS,
connectionModesValidator,
DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS,
MODE_DEFINITION_DEFAULTS,
MODE_TABLE,
modeDefinitionValidators,
Expand Down Expand Up @@ -497,6 +499,7 @@ describe('given a partial override', () => {
expect(result.streaming).toEqual({
initializers: [{ type: 'polling' }],
synchronizers: [{ type: 'streaming' }],
fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS },
});
// Other modes retain their defaults.
expect(result.polling).toEqual(MODE_TABLE.polling);
Expand Down Expand Up @@ -555,6 +558,7 @@ describe('given unknown mode names', () => {
expect(result.polling).toEqual({
initializers: [],
synchronizers: [{ type: 'polling', pollInterval: 60 }],
fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS },
});
expect(result.streaming).toEqual(MODE_TABLE.streaming);
expect(logger.warn).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -610,6 +614,7 @@ describe('given a custom defaults table', () => {
expect(result.polling).toEqual({
initializers: [],
synchronizers: [{ type: 'polling', pollInterval: 120 }],
fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS },
});
// Custom default retained for streaming (not the built-in MODE_TABLE default).
expect(result.streaming).toEqual(customDefaults.streaming);
Expand Down Expand Up @@ -640,3 +645,186 @@ describe('given no logger for validateModeTable', () => {
expect(result.polling).toEqual(MODE_TABLE.polling);
});
});

// ----------------------------- fdv1Fallback validation --------------------------------

describe('given MODE_TABLE fdv1Fallback defaults', () => {
it('has the default poll interval for streaming mode', () => {
expect(MODE_TABLE.streaming.fdv1Fallback).toEqual({
pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS,
});
});

it('has the default poll interval for polling mode', () => {
expect(MODE_TABLE.polling.fdv1Fallback).toEqual({
pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS,
});
});

it('has the background poll interval for background mode', () => {
expect(MODE_TABLE.background.fdv1Fallback).toEqual({
pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS,
});
});

it('does not have fdv1Fallback for offline mode', () => {
expect(MODE_TABLE.offline.fdv1Fallback).toBeUndefined();
});

it('does not have fdv1Fallback for one-shot mode', () => {
expect(MODE_TABLE['one-shot'].fdv1Fallback).toBeUndefined();
});
});

describe('given a valid fdv1Fallback in a mode definition', () => {
it('passes through a valid fdv1Fallback with pollInterval', () => {
const input = {
initializers: [{ type: 'cache' }],
synchronizers: [{ type: 'polling' }],
fdv1Fallback: { pollInterval: 600 },
};

const result = validateModeDefinition(input, 'testMode', logger);

expect(result.fdv1Fallback).toEqual({ pollInterval: 600 });
expect(logger.warn).not.toHaveBeenCalled();
});

it('passes through fdv1Fallback with endpoint overrides', () => {
const input = {
initializers: [],
synchronizers: [{ type: 'polling' }],
fdv1Fallback: {
pollInterval: 120,
endpoints: { pollingBaseUri: 'https://relay.example.com' },
},
};

const result = validateModeDefinition(input, 'testMode', logger);

expect(result.fdv1Fallback).toEqual({
pollInterval: 120,
endpoints: { pollingBaseUri: 'https://relay.example.com' },
});
expect(logger.warn).not.toHaveBeenCalled();
});

it('passes through fdv1Fallback with only endpoints', () => {
const input = {
initializers: [],
synchronizers: [{ type: 'polling' }],
fdv1Fallback: {
endpoints: { pollingBaseUri: 'https://relay.example.com' },
},
};

const result = validateModeDefinition(input, 'testMode', logger);

expect(result.fdv1Fallback).toEqual({
endpoints: { pollingBaseUri: 'https://relay.example.com' },
});
expect(logger.warn).not.toHaveBeenCalled();
});
});

describe('given invalid fdv1Fallback in a mode definition', () => {
it('clamps pollInterval to minimum when below 30', () => {
const input = {
initializers: [],
synchronizers: [{ type: 'polling' }],
fdv1Fallback: { pollInterval: 5 },
};

const result = validateModeDefinition(input, 'testMode', logger);

expect(result.fdv1Fallback).toEqual({ pollInterval: 30 });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pollInterval'));
});

it('drops pollInterval when it is a string and warns', () => {
const input = {
initializers: [],
synchronizers: [{ type: 'polling' }],
fdv1Fallback: { pollInterval: 'fast' },
};

const result = validateModeDefinition(input, 'testMode', logger);

// validatorOf returns undefined when all nested fields are invalid/dropped.
expect(result.fdv1Fallback).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pollInterval'));
});

it('drops fdv1Fallback when it is not an object and warns', () => {
const input = {
initializers: [],
synchronizers: [{ type: 'polling' }],
fdv1Fallback: 'invalid',
};

const result = validateModeDefinition(input, 'testMode', logger);

expect(result.fdv1Fallback).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('fdv1Fallback'));
});

it('drops invalid endpoint fields within fdv1Fallback', () => {
const input = {
initializers: [],
synchronizers: [{ type: 'polling' }],
fdv1Fallback: {
pollInterval: 60,
endpoints: { pollingBaseUri: 123 },
},
};

const result = validateModeDefinition(input, 'testMode', logger);

expect(result.fdv1Fallback).toEqual({ pollInterval: 60 });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pollingBaseUri'));
});
});

describe('given mode table overrides with fdv1Fallback', () => {
it('preserves default fdv1Fallback when user override does not specify it', () => {
const result = validateModeTable(
{
streaming: {
initializers: [{ type: 'polling' }],
synchronizers: [{ type: 'streaming' }],
},
},
MODE_TABLE,
logger,
);

// The validatorOf merges defaults from MODE_TABLE, so fdv1Fallback
// is carried through even when the user doesn't specify it.
expect((result.streaming as any).fdv1Fallback).toEqual({
pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS,
});
expect((result.streaming as any).initializers).toEqual([{ type: 'polling' }]);
expect((result.streaming as any).synchronizers).toEqual([{ type: 'streaming' }]);
// Non-overridden modes retain their defaults including fdv1Fallback.
expect((result.background as any).fdv1Fallback).toEqual({
pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS,
});
});

it('uses user-specified fdv1Fallback when provided', () => {
const result = validateModeTable(
{
background: {
initializers: [{ type: 'cache' }],
synchronizers: [{ type: 'polling', pollInterval: 7200 }],
fdv1Fallback: { pollInterval: 7200 },
},
},
MODE_TABLE,
logger,
);

expect((result.background as any).fdv1Fallback).toEqual({ pollInterval: 7200 });
expect(logger.warn).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,50 @@ it('appends a blocked FDv1 fallback synchronizer when fdv1Endpoints are configur
manager.close();
});

it('uses per-mode fdv1Fallback pollInterval from MODE_TABLE for background mode', async () => {
const sourceFactoryProvider = makeSourceFactoryProvider();
const fdv1Endpoints = {
polling: jest.fn(() => ({
pathGet: jest.fn(),
pathReport: jest.fn(),
pathPost: jest.fn(),
pathPing: jest.fn(),
})),
streaming: jest.fn(() => ({
pathGet: jest.fn(),
pathReport: jest.fn(),
pathPost: jest.fn(),
pathPing: jest.fn(),
})),
};

(makeRequestor as jest.Mock).mockReturnValue({});
(createFDv1PollingSynchronizer as jest.Mock).mockReturnValue({ close: jest.fn() });

const manager = createFDv2DataManagerBase(
makeBaseConfig({
sourceFactoryProvider,
fdv1Endpoints,
foregroundMode: 'background',
}),
);
await identifyManager(manager);

const dsConfig = capturedDataSourceConfigs[0];
const fdv1Slot = dsConfig.synchronizerSlots[dsConfig.synchronizerSlots.length - 1];
// Invoke the factory to trigger createFDv1PollingSynchronizer.
fdv1Slot.factory(() => undefined);

// The FDv1 fallback synchronizer should use background's default (3600s = 3600000ms).
expect(createFDv1PollingSynchronizer).toHaveBeenCalledWith(
expect.anything(),
3600 * 1000,
expect.anything(),
);

manager.close();
});

it('resolves identify immediately when initial mode has no sources', async () => {
// Use a custom mode table where the initial mode has empty initializers and synchronizers.
const sourceFactoryProvider = makeSourceFactoryProvider();
Expand Down
Loading
Loading