diff --git a/feature_store.js b/feature_store.js index c65a3d6..ec21e22 100644 --- a/feature_store.js +++ b/feature_store.js @@ -73,7 +73,7 @@ function InMemoryFeatureStore() { var items = this.allData[kind.namespace]; if (!items) { items = {}; - this.allData[kind] = items; + this.allData[kind.namespace] = items; } if (Object.hasOwnProperty.call(items, key)) { diff --git a/flags_state.js b/flags_state.js index 5a7a2eb..ba8b620 100644 --- a/flags_state.js +++ b/flags_state.js @@ -4,21 +4,24 @@ function FlagsStateBuilder(valid) { var flagValues = {}; var flagMetadata = {}; - builder.addFlag = function(flag, value, variation, reason) { + builder.addFlag = function(flag, value, variation, reason, detailsOnlyIfTracked) { flagValues[flag.key] = value; - var meta = { - version: flag.version, - trackEvents: flag.trackEvents - }; + var meta = {}; + if (!detailsOnlyIfTracked || flag.trackEvents || flag.debugEventsUntilDate) { + meta.version = flag.version; + if (reason) { + meta.reason = reason; + } + } if (variation !== undefined && variation !== null) { meta.variation = variation; } + if (flag.trackEvents) { + meta.trackEvents = true; + } if (flag.debugEventsUntilDate !== undefined && flag.debugEventsUntilDate !== null) { meta.debugEventsUntilDate = flag.debugEventsUntilDate; } - if (reason) { - meta.reason = reason; - } flagMetadata[flag.key] = meta; }; diff --git a/index.d.ts b/index.d.ts index 6636e1c..2b526ed 100644 --- a/index.d.ts +++ b/index.d.ts @@ -538,6 +538,17 @@ declare module 'ldclient-node' { * client-side SDK. By default, all flags are included. */ clientSideOnly?: boolean; + /** + * True if evaluation reason data should be captured in the state object (see LDClient.variationDetail). + * By default, it is not. + */ + withReasons?: boolean; + /** + * True if any flag metadata that is normally only used for event generation - such as flag versions and + * evaluation reasons - should be omitted for any flag that does not have event tracking or debugging turned on. + * This reduces the size of the JSON data if you are passing the flag state to the front end. + */ + detailsOnlyForTrackedFlags?: boolean; }; /** diff --git a/index.js b/index.js index 66d56d4..0a2980e 100644 --- a/index.js +++ b/index.js @@ -289,6 +289,7 @@ var newClient = function(sdkKey, config) { var builder = FlagsStateBuilder(true); var clientOnly = options.clientSideOnly; var withReasons = options.withReasons; + var detailsOnlyIfTracked = options.detailsOnlyForTrackedFlags; config.featureStore.all(dataKind.features, function(flags) { async.forEachOf(flags, function(flag, key, iterateeCb) { if (clientOnly && !flag.clientSide) { @@ -299,7 +300,7 @@ var newClient = function(sdkKey, config) { if (err != null) { maybeReportError(new Error('Error for feature flag "' + flag.key + '" while evaluating all flags: ' + err)); } - builder.addFlag(flag, detail.value, detail.variationIndex, withReasons ? detail.reason : null); + builder.addFlag(flag, detail.value, detail.variationIndex, withReasons ? detail.reason : null, detailsOnlyIfTracked); setImmediate(iterateeCb); }); } diff --git a/streaming.js b/streaming.js index 16b5498..4f53488 100644 --- a/streaming.js +++ b/streaming.js @@ -3,18 +3,20 @@ var errors = require('./errors'); var EventSource = require('./eventsource'); var dataKind = require('./versioned_data_kind'); -function StreamProcessor(sdkKey, config, requestor) { +function StreamProcessor(sdkKey, config, requestor, eventSourceFactory) { var processor = {}, featureStore = config.featureStore, es; + eventSourceFactory = eventSourceFactory || EventSource; + function getKeyFromPath(kind, path) { return path.startsWith(kind.streamApiPath) ? path.substring(kind.streamApiPath.length) : null; } processor.start = function(fn) { var cb = fn || function(){}; - es = new EventSource(config.streamUri + "/all", + es = new eventSourceFactory(config.streamUri + "/all", { agent: config.proxyAgent, headers: {'Authorization': sdkKey,'User-Agent': config.userAgent} @@ -24,10 +26,22 @@ function StreamProcessor(sdkKey, config, requestor) { cb(new errors.LDStreamingError(err.message, err.code)); }; + function reportJsonError(type, data) { + config.logger.error('Stream received invalid data in "' + type + '" message'); + config.logger.debug('Invalid JSON follows: ' + data); + cb(new errors.LDStreamingError('Malformed JSON data in event stream')); + } + es.addEventListener('put', function(e) { config.logger.debug('Received put event'); if (e && e.data) { - var all = JSON.parse(e.data); + var all; + try { + all = JSON.parse(e.data); + } catch (err) { + reportJsonError('put', e.data); + return; + } var initData = {}; initData[dataKind.features.namespace] = all.data.flags; initData[dataKind.segments.namespace] = all.data.segments; @@ -42,7 +56,13 @@ function StreamProcessor(sdkKey, config, requestor) { es.addEventListener('patch', function(e) { config.logger.debug('Received patch event'); if (e && e.data) { - var patch = JSON.parse(e.data); + var patch; + try { + patch = JSON.parse(e.data); + } catch (err) { + reportJsonError('patch', e.data); + return; + } for (var k in dataKind) { var kind = dataKind[k]; var key = getKeyFromPath(kind, patch.path); @@ -60,8 +80,14 @@ function StreamProcessor(sdkKey, config, requestor) { es.addEventListener('delete', function(e) { config.logger.debug('Received delete event'); if (e && e.data) { - var data = JSON.parse(e.data), - version = data.version; + var data, version; + try { + data = JSON.parse(e.data); + } catch (err) { + reportJsonError('delete', e.data); + return; + } + version = data.version; for (var k in dataKind) { var kind = dataKind[k]; var key = getKeyFromPath(kind, data.path); @@ -78,7 +104,7 @@ function StreamProcessor(sdkKey, config, requestor) { es.addEventListener('indirect/put', function(e) { config.logger.debug('Received indirect put event') - requestor.requestAllFlags(function (err, resp) { + requestor.requestAllData(function (err, resp) { if (err) { cb(err); } else { diff --git a/test/LDClient-evaluation-test.js b/test/LDClient-evaluation-test.js new file mode 100644 index 0000000..1794e4e --- /dev/null +++ b/test/LDClient-evaluation-test.js @@ -0,0 +1,516 @@ +var InMemoryFeatureStore = require('../feature_store'); +var LDClient = require('../index.js'); +var dataKind = require('../versioned_data_kind'); +var messages = require('../messages'); +var stubs = require('./stubs'); + +describe('LDClient', () => { + + var defaultUser = { key: 'user' }; + + function createClientWithFlagsInUninitializedStore(flagsMap) { + var store = InMemoryFeatureStore(); + for (var key in flagsMap) { + store.upsert(dataKind.features, flagsMap[key]); + } + return stubs.createClient({ featureStore: store }, {}); + } + + describe('variation()', () => { + it('evaluates an existing flag', done => { + var flag = { + key: 'flagkey', + version: 1, + on: true, + targets: [], + fallthrough: { variation: 1 }, + variations: ['a', 'b'], + trackEvents: true + }; + var client = stubs.createClient({}, { flagkey: flag }); + client.on('ready', () => { + client.variation(flag.key, defaultUser, 'c', (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual('b'); + done(); + }); + }); + }); + + it('returns default for unknown flag', done => { + var client = stubs.createClient({}, {}); + client.on('ready', () => { + client.variation('flagkey', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual('default'); + done(); + }); + }); + }); + + it('returns default if client is offline', done => { + var flag = { + key: 'flagkey', + version: 1, + on: false, + offVariation: 0, + variations: ['value'] + }; + var logger = stubs.stubLogger(); + client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); + client.variation('flagkey', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual('default'); + expect(logger.info).toHaveBeenCalled(); + done(); + }); + }); + + it('returns default if client and store are not initialized', done => { + var flag = { + key: 'flagkey', + version: 1, + on: false, + offVariation: 0, + variations: ['value'] + }; + var client = createClientWithFlagsInUninitializedStore({ flagkey: flag }); + client.variation('flagkey', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual('default'); + done(); + }); + }); + + it('returns value from store if store is initialized but client is not', done => { + var flag = { + key: 'flagkey', + version: 1, + on: false, + offVariation: 0, + variations: ['value'] + }; + var logger = stubs.stubLogger(); + var updateProcessor = stubs.stubUpdateProcessor(); + updateProcessor.shouldInitialize = false; + client = stubs.createClient({ updateProcessor: updateProcessor, logger: logger }, { flagkey: flag }); + client.variation('flagkey', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual('value'); + expect(logger.warn).toHaveBeenCalled(); + done(); + }); + }); + + it('returns default if flag key is not specified', done => { + var client = stubs.createClient({}, {}); + client.on('ready', () => { + client.variation(null, defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual('default'); + done(); + }); + }); + }); + + it('returns default for flag that evaluates to null', done => { + var flag = { + key: 'flagkey', + on: false, + offVariation: null + }; + var client = stubs.createClient({}, { flagkey: flag }); + client.on('ready', () => { + client.variation(flag.key, defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual('default'); + done(); + }); + }); + }); + + it('allows deprecated method toggle()', done => { + var flag = { + key: 'flagkey', + on: false, + offVariation: 0, + variations: [true] + }; + var logger = stubs.stubLogger(); + var client = stubs.createClient({ logger: logger }, { flagkey: flag }); + client.on('ready', () => { + client.toggle(flag.key, defaultUser, false, (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual(true); + expect(logger.warn).toHaveBeenCalled(); + done(); + }); + }); + }); + }); + + describe('variationDetail()', () => { + it('evaluates an existing flag', done => { + var flag = { + key: 'flagkey', + version: 1, + on: true, + targets: [], + fallthrough: { variation: 1 }, + variations: ['a', 'b'], + trackEvents: true + }; + var client = stubs.createClient({}, { flagkey: flag }); + client.on('ready', () => { + client.variationDetail(flag.key, defaultUser, 'c', (err, result) => { + expect(err).toBeNull(); + expect(result).toMatchObject({ value: 'b', variationIndex: 1, reason: { kind: 'FALLTHROUGH' } }); + done(); + }); + }); + }); + + it('returns default for unknown flag', done => { + var client = stubs.createClient({}, { }); + client.on('ready', () => { + client.variationDetail('flagkey', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toMatchObject({ value: 'default', variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }); + done(); + }); + }); + }); + + it('returns default if client is offline', done => { + var flag = { + key: 'flagkey', + version: 1, + on: false, + offVariation: 0, + variations: ['value'] + }; + var logger = stubs.stubLogger(); + client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); + client.variationDetail('flagkey', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toMatchObject({ value: 'default', variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' }}); + expect(logger.info).toHaveBeenCalled(); + done(); + }); + }); + + it('returns default if client and store are not initialized', done => { + var flag = { + key: 'flagkey', + version: 1, + on: false, + offVariation: 0, + variations: ['value'] + }; + client = createClientWithFlagsInUninitializedStore({ flagkey: flag }); + client.variationDetail('flagkey', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toMatchObject({ value: 'default', variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' } }); + done(); + }); + }); + + it('returns value from store if store is initialized but client is not', done => { + var flag = { + key: 'flagkey', + version: 1, + on: false, + offVariation: 0, + variations: ['value'] + }; + var logger = stubs.stubLogger(); + var updateProcessor = stubs.stubUpdateProcessor(); + updateProcessor.shouldInitialize = false; + client = stubs.createClient({ updateProcessor: updateProcessor, logger: logger }, { flagkey: flag }); + client.variationDetail('flagkey', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toMatchObject({ value: 'value', variationIndex: 0, reason: { kind: 'OFF' }}) + expect(logger.warn).toHaveBeenCalled(); + done(); + }); + }); + + it('returns default if flag key is not specified', done => { + var client = stubs.createClient({}, { }); + client.on('ready', () => { + client.variationDetail(null, defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toMatchObject({ value: 'default', variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }); + done(); + }); + }); + }); + + it('returns default for flag that evaluates to null', done => { + var flag = { + key: 'flagkey', + on: false, + offVariation: null + }; + var client = stubs.createClient({}, { flagkey: flag }); + client.on('ready', () => { + client.variationDetail(flag.key, defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toMatchObject({ value: 'default', variationIndex: null, reason: { kind: 'OFF' } }); + done(); + }); + }); + }); + }); + + describe('allFlags()', () => { + it('evaluates flags', done => { + var flag = { + key: 'feature', + version: 1, + offVariation: 1, + variations: ['a', 'b'] + }; + var logger = stubs.stubLogger(); + var client = stubs.createClient({ logger: logger }, { feature: flag }); + client.on('ready', () => { + client.allFlags(defaultUser, (err, results) => { + expect(err).toBeNull(); + expect(results).toEqual({feature: 'b'}); + expect(logger.warn).toHaveBeenCalledTimes(1); // deprecation warning + done(); + }); + }); + }); + + it('returns empty map in offline mode and logs a message', done => { + var flag = { + key: 'flagkey', + on: false, + offVariation: null + }; + var logger = stubs.stubLogger(); + var client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); + client.on('ready', () => { + client.allFlags(defaultUser, (err, result) => { + expect(result).toEqual({}); + expect(logger.info).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + + it('allows deprecated method all_flags', done => { + var logger = stubs.stubLogger(); + var client = stubs.createClient({ logger: logger }, {}); + client.on('ready', () => { + client.all_flags(defaultUser, (err, result) => { + expect(result).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith(messages.deprecated('all_flags', 'allFlags')); + done(); + }); + }); + }); + + it('does not overflow the call stack when evaluating a huge number of flags', done => { + var flagCount = 5000; + var flags = {}; + for (var i = 0; i < flagCount; i++) { + var key = 'feature' + i; + var flag = { + key: key, + version: 1, + on: false + }; + flags[key] = flag; + } + var client = stubs.createClient({}, flags); + client.on('ready', () => { + client.allFlags(defaultUser, (err, result) => { + expect(err).toEqual(null); + expect(Object.keys(result).length).toEqual(flagCount); + done(); + }); + }); + }); + }); + + describe('allFlagsState()', () => { + it('captures flag state', done => { + var flag = { + key: 'feature', + version: 100, + offVariation: 1, + variations: ['a', 'b'], + trackEvents: true, + debugEventsUntilDate: 1000 + }; + var client = stubs.createClient({}, { feature: flag }); + client.on('ready', () => { + client.allFlagsState(defaultUser, {}, (err, state) => { + expect(err).toBeNull(); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({feature: 'b'}); + expect(state.getFlagValue('feature')).toEqual('b'); + expect(state.toJSON()).toEqual({ + feature: 'b', + $flagsState: { + feature: { + version: 100, + variation: 1, + trackEvents: true, + debugEventsUntilDate: 1000 + } + }, + $valid: true + }); + done(); + }); + }); + }); + + it('can filter for only client-side flags', done => { + var flag1 = { key: 'server-side-1', on: false, offVariation: 0, variations: ['a'], clientSide: false }; + var flag2 = { key: 'server-side-2', on: false, offVariation: 0, variations: ['b'], clientSide: false }; + var flag3 = { key: 'client-side-1', on: false, offVariation: 0, variations: ['value1'], clientSide: true }; + var flag4 = { key: 'client-side-2', on: false, offVariation: 0, variations: ['value2'], clientSide: true }; + var client = stubs.createClient({}, { + 'server-side-1': flag1, 'server-side-2': flag2, 'client-side-1': flag3, 'client-side-2': flag4 + }); + client.on('ready', () => { + client.allFlagsState(defaultUser, { clientSideOnly: true }, (err, state) => { + expect(err).toBeNull(); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({ 'client-side-1': 'value1', 'client-side-2': 'value2' }); + done(); + }); + }); + }); + + it('can omit options parameter', done => { + var flag = { key: 'key', on: false, offVariation: 0, variations: ['value'] }; + var client = stubs.createClient({}, { 'key': flag }); + client.on('ready', () => { + client.allFlagsState(defaultUser, (err, state) => { + expect(err).toBeNull(); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({ 'key': 'value' }); + done(); + }); + }); + }); + + it('can include reasons', done => { + var flag = { + key: 'feature', + version: 100, + offVariation: 1, + variations: ['a', 'b'], + trackEvents: true, + debugEventsUntilDate: 1000 + }; + var client = stubs.createClient({}, { feature: flag }); + client.on('ready', () => { + client.allFlagsState(defaultUser, { withReasons: true }, (err, state) => { + expect(err).toBeNull(); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({feature: 'b'}); + expect(state.getFlagValue('feature')).toEqual('b'); + expect(state.toJSON()).toEqual({ + feature: 'b', + $flagsState: { + feature: { + version: 100, + variation: 1, + reason: { kind: 'OFF' }, + trackEvents: true, + debugEventsUntilDate: 1000 + } + }, + $valid: true + }); + done(); + }); + }); + }); + + it('can omit details for untracked flags', done => { + var flag1 = { + key: 'flag1', + version: 100, + offVariation: 0, + variations: ['value1'] + }; + var flag2 = { + key: 'flag2', + version: 200, + offVariation: 0, + variations: ['value2'], + trackEvents: true + }; + var flag3 = { + key: 'flag3', + version: 300, + offVariation: 0, + variations: ['value3'], + debugEventsUntilDate: 1000 + }; + var client = stubs.createClient({}, { flag1: flag1, flag2: flag2, flag3: flag3 }); + var user = { key: 'user' }; + client.on('ready', function() { + client.allFlagsState(user, { withReasons: true, detailsOnlyForTrackedFlags: true }, function(err, state) { + expect(err).toBeNull(); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({flag1: 'value1', flag2: 'value2', flag3: 'value3'}); + expect(state.getFlagValue('flag1')).toEqual('value1'); + expect(state.toJSON()).toEqual({ + flag1: 'value1', + flag2: 'value2', + flag3: 'value3', + $flagsState: { + flag1: { + variation: 0 + }, + flag2: { + version: 200, + variation: 0, + reason: { kind: 'OFF' }, + trackEvents: true + }, + flag3: { + version: 300, + variation: 0, + reason: { kind: 'OFF' }, + debugEventsUntilDate: 1000 + } + }, + $valid: true + }); + done(); + }); + }); + }); + + it('returns empty state in offline mode and logs a message', done => { + var flag = { + key: 'flagkey', + on: false, + offVariation: null + }; + var logger = stubs.stubLogger(); + var client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); + client.on('ready', () => { + client.allFlagsState(defaultUser, {}, (err, state) => { + expect(state.valid).toEqual(false); + expect(state.allValues()).toEqual({}); + expect(logger.info).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + }); +}); diff --git a/test/LDClient-events-test.js b/test/LDClient-events-test.js new file mode 100644 index 0000000..2df637a --- /dev/null +++ b/test/LDClient-events-test.js @@ -0,0 +1,188 @@ +var stubs = require('./stubs'); + +describe('LDClient - analytics events', () => { + + var eventProcessor; + var defaultUser = { key: 'user' }; + + beforeEach(() => { + eventProcessor = stubs.stubEventProcessor(); + }); + + describe('feature event', () => { + it('generates event for existing feature', done => { + var flag = { + key: 'flagkey', + version: 1, + on: true, + targets: [], + fallthrough: { variation: 1 }, + variations: ['a', 'b'], + trackEvents: true + }; + var client = stubs.createClient({ eventProcessor: eventProcessor }, { flagkey: flag }); + client.on('ready', () => { + client.variation(flag.key, defaultUser, 'c', (err, result) => { + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + user: defaultUser, + variation: 1, + value: 'b', + default: 'c', + trackEvents: true + }); + done(); + }); + }); + }); + + it('generates event for existing feature with reason', done => { + var flag = { + key: 'flagkey', + version: 1, + on: true, + targets: [], + fallthrough: { variation: 1 }, + variations: ['a', 'b'], + trackEvents: true + }; + var client = stubs.createClient({ eventProcessor: eventProcessor }, { flagkey: flag }); + client.on('ready', () => { + client.variationDetail(flag.key, defaultUser, 'c', (err, result) => { + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + user: defaultUser, + variation: 1, + value: 'b', + default: 'c', + reason: { kind: 'FALLTHROUGH' }, + trackEvents: true + }); + done(); + }); + }); + }); + + it('generates event for unknown feature', done => { + var client = stubs.createClient({ eventProcessor: eventProcessor }, {}); + client.on('ready', () => { + client.variation('flagkey', defaultUser, 'c', (err, result) => { + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: null, + user: defaultUser, + variation: null, + value: 'c', + default: 'c', + trackEvents: null + }); + done(); + }); + }); + }); + + it('generates event for existing feature when user key is missing', done => { + var flag = { + key: 'flagkey', + version: 1, + on: true, + targets: [], + fallthrough: { variation: 1 }, + variations: ['a', 'b'], + trackEvents: true + }; + var client = stubs.createClient({ eventProcessor: eventProcessor }, { flagkey: flag }); + var badUser = { name: 'Bob' }; + client.on('ready', () => { + client.variation(flag.key, badUser, 'c', (err, result) => { + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + user: badUser, + variation: null, + value: 'c', + default: 'c', + trackEvents: true + }); + done(); + }); + }); + }); + + it('generates event for existing feature when user is null', done => { + var flag = { + key: 'flagkey', + version: 1, + on: true, + targets: [], + fallthrough: { variation: 1 }, + variations: ['a', 'b'], + trackEvents: true + }; + var client = stubs.createClient({ eventProcessor: eventProcessor }, { flagkey: flag }); + client.on('ready', () => { + client.variation(flag.key, null, 'c', (err, result) => { + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + user: null, + variation: null, + value: 'c', + default: 'c', + trackEvents: true + }); + done(); + }); + }); + }); + }); + + it('generates an event for identify()', done => { + var client = stubs.createClient({ eventProcessor: eventProcessor }, {}); + client.on('ready', () => { + client.identify(defaultUser); + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'identify', + key: defaultUser.key, + user: defaultUser + }); + done(); + }); + }); + + it('generates an event for track()', done => { + var data = { thing: 'stuff' }; + var client = stubs.createClient({ eventProcessor: eventProcessor }, {}); + client.on('ready', () => { + client.track('eventkey', defaultUser, data); + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'custom', + key: 'eventkey', + user: defaultUser, + data: data + }); + done(); + }); + }); +}); diff --git a/test/LDClient-test.js b/test/LDClient-test.js index a18023f..1c3bb05 100644 --- a/test/LDClient-test.js +++ b/test/LDClient-test.js @@ -1,560 +1,87 @@ -var InMemoryFeatureStore = require('../feature_store'); var LDClient = require('../index.js'); -var dataKind = require('../versioned_data_kind'); var messages = require('../messages'); +var stubs = require('./stubs'); -describe('LDClient', function() { +describe('LDClient', () => { - var logger = {}; - - var eventProcessor = { - events: [], - sendEvent: function(event) { - eventProcessor.events.push(event); - }, - flush: function(callback) { - if (callback) { - setImmediate(callback); - } else { - return Promise.resolve(null); - } - }, - close: function() {} - }; - - var updateProcessor = { - start: function(callback) { - setImmediate(callback, updateProcessor.error); - } - }; - - beforeEach(function() { - logger.debug = jest.fn(); - logger.info = jest.fn(); - logger.warn = jest.fn(); - logger.error = jest.fn(); - eventProcessor.events = []; - updateProcessor.error = null; - }); - - it('should trigger the ready event in offline mode', function(done) { - var client = LDClient.init('sdk_key', {offline: true}); - client.on('ready', function() { - done(); - }); - }); - - it('returns true for isOffline in offline mode', function(done) { - var client = LDClient.init('sdk_key', {offline: true}); - client.on('ready', function() { - expect(client.isOffline()).toEqual(true); - done(); - }); - }); - - it('allows deprecated method is_offline', function(done) { - var client = LDClient.init('sdk_key', {offline: true, logger: logger}); - client.on('ready', function() { - expect(client.is_offline()).toEqual(true); - expect(logger.warn).toHaveBeenCalledWith(messages.deprecated('is_offline', 'isOffline')); - done(); - }); - }); - - it('should correctly compute the secure mode hash for a known message and secret', function() { - var client = LDClient.init('secret', {offline: true}); - var hash = client.secureModeHash({"key": "Message"}); - expect(hash).toEqual("aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597"); - }); - - it('allows deprecated method secure_mode_hash', function() { - var client = LDClient.init('secret', {offline: true, logger: logger}); - var hash = client.secure_mode_hash({"key": "Message"}); - expect(hash).toEqual("aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597"); - expect(logger.warn).toHaveBeenCalledWith(messages.deprecated('secure_mode_hash', 'secureModeHash')); - }); - - it('returns empty map for allFlags in offline mode and logs a message', function(done) { - var client = LDClient.init('secret', {offline: true, logger: logger}); - client.on('ready', function() { - client.allFlags({key: 'user'}, function(err, result) { - expect(result).toEqual({}); - expect(logger.info).toHaveBeenCalledTimes(1); - done(); - }); - }); - }); - - it('returns empty state for allFlagsState in offline mode and logs a message', function(done) { - var client = LDClient.init('secret', {offline: true, logger: logger}); - client.on('ready', function() { - client.allFlagsState({key: 'user'}, {}, function(err, state) { - expect(state.valid).toEqual(false); - expect(state.allValues()).toEqual({}); - expect(logger.info).toHaveBeenCalledTimes(1); - done(); - }); - }); - }); - - it('allows deprecated method all_flags', function(done) { - var client = LDClient.init('secret', {offline: true, logger: logger}); - client.on('ready', function() { - client.all_flags({key: 'user'}, function(err, result) { - expect(result).toEqual({}); - expect(logger.warn).toHaveBeenCalledWith(messages.deprecated('all_flags', 'allFlags')); - done(); - }); - }); - }); - - function createOnlineClientWithFlags(flagsMap) { - var store = InMemoryFeatureStore(); - var allData = {}; - var dummyUri = 'bad'; - allData[dataKind.features.namespace] = flagsMap; - store.init(allData); - return LDClient.init('secret', { - featureStore: store, - eventProcessor: eventProcessor, - updateProcessor: updateProcessor, - logger: logger - }); - } - - it('evaluates a flag with variation()', function(done) { - var flag = { - key: 'flagkey', - version: 1, - on: true, - targets: [], - fallthrough: { variation: 1 }, - variations: ['a', 'b'], - trackEvents: true - }; - var client = createOnlineClientWithFlags({ flagkey: flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.variation(flag.key, user, 'c', function(err, result) { - expect(err).toBeNull(); - expect(result).toEqual('b'); - done(); - }); - }); - }); - - it('returns default from variation() for unknown flag', function(done) { - var client = createOnlineClientWithFlags({ }); - var user = { key: 'user' }; - client.on('ready', function() { - client.variation('flagkey', user, 'default', function(err, result) { - expect(err).toBeNull(); - expect(result).toEqual('default'); - done(); - }); - }); - }); - - it('returns default from variation() for flag that evaluates to null', function(done) { - var flag = { - key: 'flagkey', - on: false, - offVariation: null - }; - var client = createOnlineClientWithFlags({ flagkey: flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.variation(flag.key, user, 'default', function(err, result) { - expect(err).toBeNull(); - expect(result).toEqual('default'); - done(); - }); - }); - }); - - it('evaluates a flag with variationDetail()', function(done) { - var flag = { - key: 'flagkey', - version: 1, - on: true, - targets: [], - fallthrough: { variation: 1 }, - variations: ['a', 'b'], - trackEvents: true - }; - var client = createOnlineClientWithFlags({ flagkey: flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.variationDetail(flag.key, user, 'c', function(err, result) { - expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'b', variationIndex: 1, reason: { kind: 'FALLTHROUGH' } }); - done(); - }); - }); - }); - - it('returns default from variationDetail() for unknown flag', function(done) { - var client = createOnlineClientWithFlags({ }); - var user = { key: 'user' }; - client.on('ready', function() { - client.variationDetail('flagkey', user, 'default', function(err, result) { - expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'default', variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }); - done(); - }); - }); - }); - - it('returns default from variationDetail() for flag that evaluates to null', function(done) { - var flag = { - key: 'flagkey', - on: false, - offVariation: null - }; - var client = createOnlineClientWithFlags({ flagkey: flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.variationDetail(flag.key, user, 'default', function(err, result) { - expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'default', variationIndex: null, reason: { kind: 'OFF' } }); - done(); - }); - }); - }); - - it('generates an event for an existing feature', function(done) { - var flag = { - key: 'flagkey', - version: 1, - on: true, - targets: [], - fallthrough: { variation: 1 }, - variations: ['a', 'b'], - trackEvents: true - }; - var client = createOnlineClientWithFlags({ flagkey: flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.variation(flag.key, user, 'c', function(err, result) { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: 1, - user: user, - variation: 1, - value: 'b', - default: 'c', - trackEvents: true - }); - done(); - }); - }); - }); - - it('generates an event for an existing feature with reason', function(done) { - var flag = { - key: 'flagkey', - version: 1, - on: true, - targets: [], - fallthrough: { variation: 1 }, - variations: ['a', 'b'], - trackEvents: true - }; - var client = createOnlineClientWithFlags({ flagkey: flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.variationDetail(flag.key, user, 'c', function(err, result) { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: 1, - user: user, - variation: 1, - value: 'b', - default: 'c', - reason: { kind: 'FALLTHROUGH' }, - trackEvents: true - }); - done(); - }); - }); - }); - - it('generates an event for an unknown feature', function(done) { - var client = createOnlineClientWithFlags({}); - var user = { key: 'user' }; - client.on('ready', function() { - client.variation('flagkey', user, 'c', function(err, result) { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: null, - user: user, - variation: null, - value: 'c', - default: 'c', - trackEvents: null - }); - done(); - }); - }); - }); - - it('generates an event for an existing feature even if user key is missing', function(done) { - var flag = { - key: 'flagkey', - version: 1, - on: true, - targets: [], - fallthrough: { variation: 1 }, - variations: ['a', 'b'], - trackEvents: true - }; - var client = createOnlineClientWithFlags({ flagkey: flag }); - var user = { name: 'Bob' }; - client.on('ready', function() { - client.variation(flag.key, user, 'c', function(err, result) { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: 1, - user: user, - variation: null, - value: 'c', - default: 'c', - trackEvents: true - }); + describe('ready event', () => { + it('is fired in offline mode', done => { + var client = LDClient.init('sdk_key', { offline: true }); + client.on('ready', () => { done(); }); }); }); - it('generates an event for an existing feature even if user is null', function(done) { - var flag = { - key: 'flagkey', - version: 1, - on: true, - targets: [], - fallthrough: { variation: 1 }, - variations: ['a', 'b'], - trackEvents: true - }; - var client = createOnlineClientWithFlags({ flagkey: flag }); - client.on('ready', function() { - client.variation(flag.key, null, 'c', function(err, result) { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: 1, - user: null, - variation: null, - value: 'c', - default: 'c', - trackEvents: true - }); - done(); - }); - }); - }); - - it('evaluates a flag with allFlags()', function(done) { - var flag = { - key: 'feature', - version: 1, - on: true, - targets: [], - fallthrough: { variation: 1 }, - variations: ['a', 'b'] - }; - var client = createOnlineClientWithFlags({ feature: flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.allFlags(user, function(err, results) { - expect(err).toBeNull(); - expect(results).toEqual({feature: 'b'}); - expect(logger.warn).toHaveBeenCalledTimes(1); // deprecation warning - done(); - }); - }); - }); + describe('failed event', () => { + it('is fired if initialization fails', done => { + var updateProcessor = stubs.stubUpdateProcessor(); + updateProcessor.error = { status: 403 }; + var client = stubs.createClient({ updateProcessor: updateProcessor }, {}); - it('captures flag state with allFlagsState()', function(done) { - var flag = { - key: 'feature', - version: 100, - on: true, - targets: [], - fallthrough: { variation: 1 }, - variations: ['a', 'b'], - trackEvents: true, - debugEventsUntilDate: 1000 - }; - var client = createOnlineClientWithFlags({ feature: flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.allFlagsState(user, {}, function(err, state) { - expect(err).toBeNull(); - expect(state.valid).toEqual(true); - expect(state.allValues()).toEqual({feature: 'b'}); - expect(state.getFlagValue('feature')).toEqual('b'); - expect(state.toJSON()).toEqual({ - feature: 'b', - $flagsState: { - feature: { - version: 100, - variation: 1, - trackEvents: true, - debugEventsUntilDate: 1000 - } - }, - $valid: true - }); + client.on('failed', err => { + expect(err).toEqual(updateProcessor.error); done(); }); }); }); - it('can filter for only client-side flags with allFlagsState()', function(done) { - var flag1 = { key: 'server-side-1', on: false, offVariation: 0, variations: ['a'], clientSide: false }; - var flag2 = { key: 'server-side-2', on: false, offVariation: 0, variations: ['b'], clientSide: false }; - var flag3 = { key: 'client-side-1', on: false, offVariation: 0, variations: ['value1'], clientSide: true }; - var flag4 = { key: 'client-side-2', on: false, offVariation: 0, variations: ['value2'], clientSide: true }; - var client = createOnlineClientWithFlags({ - 'server-side-1': flag1, 'server-side-2': flag2, 'client-side-1': flag3, 'client-side-2': flag4 - }); - var user = { key: 'user' }; - client.on('ready', function() { - client.allFlagsState(user, { clientSideOnly: true }, function(err, state) { - expect(err).toBeNull(); - expect(state.valid).toEqual(true); - expect(state.allValues()).toEqual({ 'client-side-1': 'value1', 'client-side-2': 'value2' }); + describe('isOffline()', () => { + it('returns true in offline mode', done => { + var client = LDClient.init('sdk_key', {offline: true}); + client.on('ready', () => { + expect(client.isOffline()).toEqual(true); done(); }); }); - }); - it('can omit options parameter for allFlagsState()', function(done) { - var flag = { key: 'key', on: false, offVariation: 0, variations: ['value'] }; - var client = createOnlineClientWithFlags({ 'key': flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.allFlagsState(user, function(err, state) { - expect(err).toBeNull(); - expect(state.valid).toEqual(true); - expect(state.allValues()).toEqual({ 'key': 'value' }); + it('allows deprecated method is_offline', done => { + var logger = stubs.stubLogger(); + var client = LDClient.init('sdk_key', {offline: true, logger: logger}); + client.on('ready', () => { + expect(client.is_offline()).toEqual(true); + expect(logger.warn).toHaveBeenCalledWith(messages.deprecated('is_offline', 'isOffline')); done(); }); }); }); - it('can include reasons in allFlagsState()', function(done) { - var flag = { - key: 'feature', - version: 100, - on: true, - targets: [], - fallthrough: { variation: 1 }, - variations: ['a', 'b'], - trackEvents: true, - debugEventsUntilDate: 1000 - }; - var client = createOnlineClientWithFlags({ feature: flag }); - var user = { key: 'user' }; - client.on('ready', function() { - client.allFlagsState(user, { withReasons: true }, function(err, state) { - expect(err).toBeNull(); - expect(state.valid).toEqual(true); - expect(state.allValues()).toEqual({feature: 'b'}); - expect(state.getFlagValue('feature')).toEqual('b'); - expect(state.toJSON()).toEqual({ - feature: 'b', - $flagsState: { - feature: { - version: 100, - variation: 1, - reason: { kind: 'FALLTHROUGH' }, - trackEvents: true, - debugEventsUntilDate: 1000 - } - }, - $valid: true - }); - done(); - }); + describe('secureModeHash()', () => { + it('correctly computes hash for a known message and secret', () => { + var client = LDClient.init('secret', {offline: true}); + var hash = client.secureModeHash({"key": "Message"}); + expect(hash).toEqual("aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597"); }); - }); - it('should not overflow the call stack when evaluating a huge number of flags', function(done) { - var flagCount = 5000; - var flags = {}; - for (var i = 0; i < flagCount; i++) { - var key = 'feature' + i; - var flag = { - key: key, - version: 1, - on: false - }; - flags[key] = flag; - } - var client = createOnlineClientWithFlags(flags); - client.on('ready', function() { - client.allFlags({key: 'user'}, function(err, result) { - expect(err).toEqual(null); - expect(Object.keys(result).length).toEqual(flagCount); - done(); - }); + it('allows deprecated method secure_mode_hash', () => { + var logger = stubs.stubLogger(); + var client = LDClient.init('secret', {offline: true, logger: logger}); + var hash = client.secure_mode_hash({"key": "Message"}); + expect(hash).toEqual("aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597"); + expect(logger.warn).toHaveBeenCalledWith(messages.deprecated('secure_mode_hash', 'secureModeHash')); }); }); - it('should not crash when closing an offline client', function(done) { - var client = LDClient.init('sdk_key', {offline: true}); - expect(() => client.close()).not.toThrow(); - done(); - }); - - describe('waitUntilReady()', function () { - it('should resolve waitUntilReady() when ready', function(done) { - var client = LDClient.init('secret', {offline: true}); - var callback = jest.fn(); - - client.waitUntilReady().then(callback) - .then(() => { - expect(callback).toHaveBeenCalled(); - done(); - }).catch(done.error) + describe('waitUntilReady()', () => { + it('resolves when ready', done => { + var client = stubs.createClient({}, {}); + client.waitUntilReady().then(done) + .catch(done.error) }); - it('should resolve waitUntilReady() even if the client is already ready', function(done) { - var client = LDClient.init('secret', {offline: true}); - var callback = jest.fn(); - - client.waitUntilReady() - .then(() => { - client.waitUntilReady().then(callback) - .then(() => { - expect(callback).toHaveBeenCalled(); - done(); - }).catch(done.error) - }).catch(done.error) + it('resolves immediately if the client is already ready', done => { + var client = stubs.createClient({}, {}); + client.waitUntilReady().then(() => { + client.waitUntilReady().then(done) + .catch(done.error) + }).catch(done.error); }); }); - describe('waitForInitialization()', function () { - it('should resolve when ready', function(done) { + describe('waitForInitialization()', () => { + it('resolves when ready', done => { var callback = jest.fn(); - var client = createOnlineClientWithFlags({}); + var client = stubs.createClient({}, {}); client.waitForInitialization().then(callback) .then(() => { @@ -564,9 +91,9 @@ describe('LDClient', function() { }).catch(done.error) }); - it('should resolve even if the client is already ready', function(done) { + it('resolves immediately if the client is already ready', done => { var callback = jest.fn(); - var client = createOnlineClientWithFlags({}); + var client = stubs.createClient({}, {}); client.waitForInitialization() .then(() => { @@ -579,9 +106,10 @@ describe('LDClient', function() { }).catch(done.error) }); - it('should be rejected if initialization fails', function(done) { + it('is rejected if initialization fails', done => { + var updateProcessor = stubs.stubUpdateProcessor(); updateProcessor.error = { status: 403 }; - var client = createOnlineClientWithFlags({}); + var client = stubs.createClient({ updateProcessor: updateProcessor }, {}); client.waitForInitialization() .catch(err => { @@ -591,15 +119,11 @@ describe('LDClient', function() { }); }); - describe('failed event', function() { - it('should be fired if initialization fails', function(done) { - updateProcessor.error = { status: 403 }; - var client = createOnlineClientWithFlags({}); - - client.on('failed', err => { - expect(err).toEqual(updateProcessor.error); - done(); - }); + describe('close()', () => { + it('does not crash when closing an offline client', done => { + var client = LDClient.init('sdk_key', {offline: true}); + expect(() => client.close()).not.toThrow(); + done(); }); - }) + }); }); diff --git a/test/streaming-test.js b/test/streaming-test.js new file mode 100644 index 0000000..10996d1 --- /dev/null +++ b/test/streaming-test.js @@ -0,0 +1,320 @@ +var InMemoryFeatureStore = require('../feature_store'); +var StreamProcessor = require('../streaming'); +var dataKind = require('../versioned_data_kind'); + +describe('StreamProcessor', function() { + var sdkKey = 'SDK_KEY'; + + function fakeEventSource() { + var es = { handlers: {} }; + es.constructor = function(url, options) { + es.url = url; + es.options = options; + this.addEventListener = function(type, handler) { + es.handlers[type] = handler; + }; + }; + return es; + } + + function fakeLogger() { + return { + debug: jest.fn(), + error: jest.fn() + }; + } + + function expectJsonError(config, done) { + return function(err) { + expect(err).not.toBe(undefined); + expect(err.message).toEqual('Malformed JSON data in event stream'); + expect(config.logger.error).toHaveBeenCalled(); + done(); + } + } + + it('uses expected URL', function() { + var config = { streamUri: 'http://test' }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + sp.start(); + expect(es.url).toEqual(config.streamUri + '/all'); + }); + + it('sets expected headers', function() { + var config = { streamUri: 'http://test', userAgent: 'agent' }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + sp.start(); + expect(es.options.headers['Authorization']).toEqual(sdkKey); + expect(es.options.headers['User-Agent']).toEqual(config.userAgent); + }); + + describe('put message', function() { + var putData = { + data: { + flags: { + flagkey: { key: 'flagkey', version: 1 } + }, + segments: { + segkey: { key: 'segkey', version: 2 } + } + } + }; + + it('causes flags and segments to be stored', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + sp.start(); + + es.handlers.put({ data: JSON.stringify(putData) }); + + featureStore.initialized(function(flag) { + expect(flag).toEqual(true); + }); + + featureStore.get(dataKind.features, 'flagkey', function(f) { + expect(f.version).toEqual(1); + featureStore.get(dataKind.segments, 'segkey', function(s) { + expect(s.version).toEqual(2); + done(); + }); + }); + }); + + it('calls initialization callback', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + + var cb = function(err) { + expect(err).toBe(undefined); + done(); + } + + sp.start(cb); + es.handlers.put({ data: JSON.stringify(putData) }); + }); + + it('passes error to callback if data is invalid', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + + sp.start(expectJsonError(config, done)); + es.handlers.put({ data: '{not-good' }); + }); + }); + + describe('patch message', function() { + it('updates flag', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + + var patchData = { + path: '/flags/flagkey', + data: { key: 'flagkey', version: 1 } + }; + + sp.start(); + es.handlers.patch({ data: JSON.stringify(patchData) }); + + featureStore.get(dataKind.features, 'flagkey', function(f) { + expect(f.version).toEqual(1); + done(); + }); + }); + + it('updates segment', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + + var patchData = { + path: '/segments/segkey', + data: { key: 'segkey', version: 1 } + }; + + sp.start(); + es.handlers.patch({ data: JSON.stringify(patchData) }); + + featureStore.get(dataKind.segments, 'segkey', function(s) { + expect(s.version).toEqual(1); + done(); + }); + }); + + it('passes error to callback if data is invalid', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + + sp.start(expectJsonError(config, done)); + es.handlers.patch({ data: '{not-good' }); + }); + }); + + describe('delete message', function() { + it('deletes flag', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + + sp.start(); + + var flag = { key: 'flagkey', version: 1 } + featureStore.upsert(dataKind.features, flag, function() { + featureStore.get(dataKind.features, flag.key, function(f) { + expect(f).toEqual(flag); + + var deleteData = { path: '/flags/' + flag.key, version: 2 }; + es.handlers.delete({ data: JSON.stringify(deleteData) }); + + featureStore.get(dataKind.features, flag.key, function(f) { + expect(f).toBe(null); + done(); + }) + }); + }); + }); + + it('deletes segment', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + + sp.start(); + + var segment = { key: 'segkey', version: 1 } + featureStore.upsert(dataKind.segments, segment, function() { + featureStore.get(dataKind.segments, segment.key, function(s) { + expect(s).toEqual(segment); + + var deleteData = { path: '/segments/' + segment.key, version: 2 }; + es.handlers.delete({ data: JSON.stringify(deleteData) }); + + featureStore.get(dataKind.segments, segment.key, function(s) { + expect(s).toBe(null); + done(); + }) + }); + }); + }); + + it('passes error to callback if data is invalid', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, null, es.constructor); + + sp.start(expectJsonError(config, done)); + es.handlers.delete({ data: '{not-good' }); + }); + }); + + describe('indirect put message', function() { + var allData = { + flags: { + flagkey: { key: 'flagkey', version: 1 } + }, + segments: { + segkey: { key: 'segkey', version: 2 } + } + }; + var fakeRequestor = { + requestAllData: function(cb) { + cb(null, JSON.stringify(allData)); + } + }; + + it('requests and stores flags and segments', function(done) { + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, fakeRequestor, es.constructor); + + sp.start(); + + es.handlers['indirect/put']({}); + + setImmediate(function() { + featureStore.get(dataKind.features, 'flagkey', function(f) { + expect(f.version).toEqual(1); + featureStore.get(dataKind.segments, 'segkey', function(s) { + expect(s.version).toEqual(2); + featureStore.initialized(function(flag) { + expect(flag).toBe(true); + }); + done(); + }); + }); + }); + }); + }); + + describe('indirect patch message', function() { + it('requests and updates flag', function(done) { + var flag = { key: 'flagkey', version: 1 }; + var fakeRequestor = { + requestObject: function(kind, key, cb) { + expect(kind).toBe(dataKind.features); + expect(key).toEqual(flag.key); + cb(null, JSON.stringify(flag)); + } + }; + + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, fakeRequestor, es.constructor); + + sp.start(); + + es.handlers['indirect/patch']({ data: '/flags/flagkey' }); + + setImmediate(function() { + featureStore.get(dataKind.features, 'flagkey', function(f) { + expect(f.version).toEqual(1); + done(); + }); + }); + }); + + it('requests and updates segment', function(done) { + var segment = { key: 'segkey', version: 1 }; + var fakeRequestor = { + requestObject: function(kind, key, cb) { + expect(kind).toBe(dataKind.segments); + expect(key).toEqual(segment.key); + cb(null, JSON.stringify(segment)); + } + }; + + var featureStore = InMemoryFeatureStore(); + var config = { featureStore: featureStore, logger: fakeLogger() }; + var es = fakeEventSource(); + var sp = StreamProcessor(sdkKey, config, fakeRequestor, es.constructor); + + sp.start(); + + es.handlers['indirect/patch']({ data: '/segments/segkey' }); + + setImmediate(function() { + featureStore.get(dataKind.segments, 'segkey', function(s) { + expect(s.version).toEqual(1); + done(); + }); + }); + }); + }); +}); diff --git a/test/stubs.js b/test/stubs.js new file mode 100644 index 0000000..b455b3f --- /dev/null +++ b/test/stubs.js @@ -0,0 +1,65 @@ +var InMemoryFeatureStore = require('../feature_store'); +var LDClient = require('../index.js'); +var dataKind = require('../versioned_data_kind'); + +function stubEventProcessor() { + var eventProcessor = { + events: [], + sendEvent: function(event) { + eventProcessor.events.push(event); + }, + flush: function(callback) { + if (callback) { + setImmediate(callback); + } else { + return Promise.resolve(null); + } + }, + close: function() {} + }; + return eventProcessor; +} + +function stubLogger() { + return { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + }; +} + +function stubUpdateProcessor() { + var updateProcessor = { + start: function(callback) { + if (updateProcessor.shouldInitialize) { + setImmediate(callback, updateProcessor.error); + } + }, + shouldInitialize: true + }; + return updateProcessor; +} + +function createClient(overrideOptions, flagsMap) { + var store = InMemoryFeatureStore(); + if (flagsMap !== undefined) { + var allData = {}; + allData[dataKind.features.namespace] = flagsMap; + store.init(allData); + } + var defaults = { + featureStore: store, + eventProcessor: stubEventProcessor(), + updateProcessor: stubUpdateProcessor(), + logger: stubLogger() + }; + return LDClient.init('secret', Object.assign({}, defaults, overrideOptions)); +} + +module.exports = { + createClient: createClient, + stubEventProcessor: stubEventProcessor, + stubLogger: stubLogger, + stubUpdateProcessor: stubUpdateProcessor +};