diff --git a/.gitignore b/.gitignore index 09b71286c9..9ed1b857cb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist/ node_modules/ **/*.tsbuildinfo coverage/ +tmp/ docs/ diff --git a/platform-node/__tests__/LDClientNode.bigSegments.test.ts b/platform-node/__tests__/LDClientNode.bigSegments.test.ts new file mode 100644 index 0000000000..109820b96e --- /dev/null +++ b/platform-node/__tests__/LDClientNode.bigSegments.test.ts @@ -0,0 +1,153 @@ +import { + integrations, interfaces, LDBigSegmentsOptions, LDLogger, +} from '@launchdarkly/js-server-sdk-common'; +import { basicLogger, LDClientImpl } from '../src'; +import { LDClient } from '../src/api/LDClient'; + +describe('given test data with big segments', () => { + // To use the public interfaces to create a client which doesn't use the + // network. (Versus being offline, or a null update processor.) + let td: integrations.TestData; + let logger: LDLogger; + + beforeEach(() => { + td = new integrations.TestData(); + logger = basicLogger({ + destination: () => {}, + }); + }); + + describe('given a healthy big segment store', () => { + let client: LDClient; + const bigSegmentsConfig: LDBigSegmentsOptions = { + statusPollInterval: 0.1, + store(): interfaces.BigSegmentStore { + return { + getMetadata: async () => ({ lastUpToDate: Date.now() }), + getUserMembership: async () => undefined, + close: () => { }, + }; + }, + }; + + beforeEach(() => { + client = new LDClientImpl('sdk-key', { + updateProcessor: td.getFactory(), + sendEvents: false, + bigSegments: bigSegmentsConfig, + }); + }); + + it('can get status', () => { + const status = client.bigSegmentStoreStatusProvider.getStatus(); + expect(status).toBeUndefined(); + }); + + it('can require status', async () => { + const status = await client.bigSegmentStoreStatusProvider.requireStatus(); + expect(status.available).toEqual(true); + expect(status.stale).toEqual(false); + }); + + it('Can listen to the event emitter for the status', (done) => { + client.bigSegmentStoreStatusProvider.on('change', (status: interfaces.BigSegmentStoreStatus) => { + expect(status.stale).toEqual(false); + expect(status.available).toEqual(true); + + const status2 = client.bigSegmentStoreStatusProvider.getStatus(); + expect(status2!.stale).toEqual(false); + expect(status2!.available).toEqual(true); + done(); + }); + }); + + afterEach(() => { + client.close(); + }); + }); + + describe('given a stale store', () => { + let client: LDClient; + const bigSegmentsConfig: LDBigSegmentsOptions = { + store(): interfaces.BigSegmentStore { + return { + getMetadata: async () => ({ lastUpToDate: 1000 }), + getUserMembership: async () => undefined, + close: () => { }, + }; + }, + }; + + beforeEach(async () => { + client = new LDClientImpl('sdk-key', { + updateProcessor: td.getFactory(), + sendEvents: false, + bigSegments: bigSegmentsConfig, + }); + + await client.waitForInitialization(); + }); + + it('can require status', async () => { + const status = await client.bigSegmentStoreStatusProvider.requireStatus(); + expect(status.available).toEqual(true); + expect(status.stale).toEqual(true); + }); + + afterEach(() => { + client.close(); + }); + }); + + describe('given a store that can produce an error', () => { + let client: LDClient; + let error: boolean; + const bigSegmentsConfig: LDBigSegmentsOptions = { + statusPollInterval: 0.1, + store(): interfaces.BigSegmentStore { + return { + getMetadata: async () => { + if (error) { + throw new Error('sorry'); + } + return { lastUpToDate: Date.now() }; + }, + getUserMembership: async () => undefined, + close: () => { }, + }; + }, + }; + + beforeEach(async () => { + error = false; + client = new LDClientImpl('sdk-key', { + updateProcessor: td.getFactory(), + sendEvents: false, + bigSegments: bigSegmentsConfig, + logger, + }); + + await client.waitForInitialization(); + }); + + it('Can observe the status change', (done) => { + let message = 0; + client.bigSegmentStoreStatusProvider.on('change', (status: interfaces.BigSegmentStoreStatus) => { + if (message === 0) { + expect(status.stale).toEqual(false); + expect(status.available).toEqual(true); + error = true; + message += 1; + } else { + expect(status.stale).toEqual(false); + expect(status.available).toEqual(false); + done(); + } + }); + }); + + afterEach(() => { + client.close(); + }); + }); +}); diff --git a/platform-node/__tests__/LDClientNode.fileDataSource.test.ts b/platform-node/__tests__/LDClientNode.fileDataSource.test.ts new file mode 100644 index 0000000000..7418ddfb32 --- /dev/null +++ b/platform-node/__tests__/LDClientNode.fileDataSource.test.ts @@ -0,0 +1,164 @@ +import { integrations } from '@launchdarkly/js-server-sdk-common'; +import * as fs from 'node:fs'; +import LDClientNode from '../src/LDClientNode'; + +const flag1Key = 'flag1'; +const flag2Key = 'flag2'; +const flag2Value = 'value2'; +const segment1Key = 'seg1'; + +const flag1 = { + key: flag1Key, + on: true, + rules: [ + { clauses: [{ op: 'segmentMatch', values: [segment1Key] }], variation: 1 }, + ], + fallthrough: { + variation: 2, + }, + variations: ['fall', 'off', 'on'], +}; + +const segment1 = { + key: segment1Key, + included: ['user1'], +}; + +const flagOnlyJson = ` +{ + "flags": { + "${flag1Key}": ${JSON.stringify(flag1)} + } +}`; + +const segmentOnlyJson = ` +{ + "segments": { + "${segment1Key}": ${JSON.stringify(segment1)} + } +}`; + +const allPropertiesJson = ` +{ + "flags": { + "${flag1Key}": ${JSON.stringify(flag1)} + }, + "flagValues": { + "${flag2Key}": "${flag2Value}" + }, + "segments": { + "${segment1Key}": ${JSON.stringify(segment1)} + } +}`; + +const tmpFiles: string[] = []; + +function makeTempFile(content: string): string { + const fileName = (Math.random() + 1).toString(36).substring(7); + if (!fs.existsSync('./tmp')) { + fs.mkdirSync('./tmp'); + } + const fullPath = `./tmp/${fileName}`; + fs.writeFileSync(fullPath, content); + tmpFiles.push(fullPath); + return fullPath; +} + +function replaceFileContent(filePath: string, content: string) { + fs.writeFileSync(filePath, content); +} + +describe('When using a file data source', () => { + afterAll(() => { + tmpFiles.forEach((filePath) => { + fs.unlinkSync(filePath); + }); + }); + + it('loads flags on start from JSON', async () => { + const path = makeTempFile(allPropertiesJson); + const fds = new integrations.FileDataSourceFactory({ + paths: [path], + }); + + const client = new LDClientNode('sdk-key', { + updateProcessor: fds.getFactory(), + sendEvents: false, + }); + + await client.waitForInitialization(); + + const f1Var = await client.variation(flag1Key, { key: 'user1' }, 'default'); + expect(f1Var).toEqual('off'); + const f1VarNoSeg = await client.variation(flag1Key, { key: 'user2' }, 'default'); + expect(f1VarNoSeg).toEqual('on'); + const f2Var = await client.variation(flag2Key, { key: 'user1' }, 'default'); + expect(f2Var).toEqual('value2'); + + client.close(); + }); + + it('it can load multiple files', async () => { + const path1 = makeTempFile(flagOnlyJson); + const path2 = makeTempFile(segmentOnlyJson); + const fds = new integrations.FileDataSourceFactory({ + paths: [path1, path2], + }); + + const client = new LDClientNode('sdk-key', { + updateProcessor: fds.getFactory(), + sendEvents: false, + }); + + await client.waitForInitialization(); + + const f1Var = await client.variation(flag1Key, { key: 'user1' }, 'default'); + expect(f1Var).toEqual('off'); + const f1VarNoSeg = await client.variation(flag1Key, { key: 'user2' }, 'default'); + expect(f1VarNoSeg).toEqual('on'); + + client.close(); + }); + + it('reloads the file if the content changes', async () => { + const path = makeTempFile(allPropertiesJson); + const fds = new integrations.FileDataSourceFactory({ + paths: [path], + autoUpdate: true, + }); + + const client = new LDClientNode('sdk-key', { + updateProcessor: fds.getFactory(), + sendEvents: false, + }); + + await client.waitForInitialization(); + + const f1Var = await client.variation(flag1Key, { key: 'user1' }, 'default'); + expect(f1Var).toEqual('off'); + const f1VarNoSeg = await client.variation(flag1Key, { key: 'user2' }, 'default'); + expect(f1VarNoSeg).toEqual('on'); + const f2Var = await client.variation(flag2Key, { key: 'user1' }, 'default'); + expect(f2Var).toEqual('value2'); + + replaceFileContent(path, flagOnlyJson); + + await new Promise((resolve) => { + client.once('update', () => { + // After the file reloads we get changes, so we know we can move onto + // evaluation. + resolve(); + }); + replaceFileContent(path, flagOnlyJson); + }); + + const f1VarB = await client.variation(flag1Key, { key: 'user1' }, 'default'); + expect(f1VarB).toEqual('on'); // Segment doesn't exist anymore. + const f1VarNoSegB = await client.variation(flag1Key, { key: 'user2' }, 'default'); + expect(f1VarNoSegB).toEqual('on'); + const f2VarB = await client.variation(flag2Key, { key: 'user1' }, 'default'); + expect(f2VarB).toEqual('default'); + + client.close(); + }); +}); diff --git a/platform-node/__tests__/LDClientNode.listeners.test.ts b/platform-node/__tests__/LDClientNode.listeners.test.ts new file mode 100644 index 0000000000..4707ff6155 --- /dev/null +++ b/platform-node/__tests__/LDClientNode.listeners.test.ts @@ -0,0 +1,93 @@ +import { integrations } from '@launchdarkly/js-server-sdk-common'; +import { LDClient } from '../src'; +import LDClientNode from '../src/LDClientNode'; + +describe('given an LDClient with test data', () => { + let client: LDClient; + let td: integrations.TestData; + + beforeEach(() => { + td = new integrations.TestData(); + client = new LDClientNode( + 'sdk-key', + { + updateProcessor: td.getFactory(), + sendEvents: false, + }, + ); + }); + + afterEach(() => { + client.close(); + }); + + it('sends an "update" event when a flag is added', (done) => { + client.on('update', (params) => { + expect(params.key).toEqual('new-flag'); + done(); + }); + td.update(td.flag('new-flag')); + }); + + it('sends an "update:new-flag" event when a flag is added', (done) => { + client.on('update:new-flag', (params) => { + expect(params.key).toEqual('new-flag'); + done(); + }); + td.update(td.flag('new-flag')); + }); + + it('sends an "update" when a flag is updated', (done) => { + const expectedUpdates = [ + 'flag1', + 'flag2', + 'flag1', + 'flag2', + ]; + + client.on('update', (params) => { + expect(expectedUpdates.includes(params.key)).toBeTruthy(); + expectedUpdates.splice(expectedUpdates.indexOf(params.key), 1); + if (expectedUpdates.length === 0) { + done(); + } + }); + + td.update(td.flag('flag1').on(true)); + td.update(td.flag('flag2').on(true)); + + td.update(td.flag('flag1').on(false)); + td.update(td.flag('flag2').on(false)); + }); + + it('sends an "update:" when a flag is updated', (done) => { + const expectedUpdates = [ + 'flag1', + 'flag2', + 'flag1', + 'flag2', + ]; + + client.on('update:flag1', (params) => { + expect(expectedUpdates.includes(params.key)).toBeTruthy(); + expectedUpdates.splice(expectedUpdates.indexOf(params.key), 1); + if (expectedUpdates.length === 0) { + done(); + } + }); + + client.on('update:flag2', (params) => { + expect(expectedUpdates.includes(params.key)).toBeTruthy(); + expectedUpdates.splice(expectedUpdates.indexOf(params.key), 1); + if (expectedUpdates.length === 0) { + done(); + } + }); + + td.update(td.flag('flag1').on(true)); + td.update(td.flag('flag2').on(true)); + + td.update(td.flag('flag1').on(false)); + td.update(td.flag('flag2').on(false)); + }); +}); diff --git a/platform-node/__tests__/LDClientNode.test.ts b/platform-node/__tests__/LDClientNode.test.ts new file mode 100644 index 0000000000..49d339e255 --- /dev/null +++ b/platform-node/__tests__/LDClientNode.test.ts @@ -0,0 +1,51 @@ +import { LDContext } from '@launchdarkly/js-server-sdk-common'; +import { init } from '../src'; + +it('fires ready event in offline mode', (done) => { + const client = init('sdk_key', { offline: true }); + client.on('ready', () => { + client.close(); + done(); + }); +}); + +it('fires the failed event if initialization fails', (done) => { + const client = init('sdk_key', { + updateProcessor: { + start: (fn: (err: any) => void) => { + setTimeout(() => { + fn(new Error('BAD THINGS')); + }, 0); + }, + stop: () => { }, + close: () => { }, + }, + }); + client.on('failed', () => { + client.close(); + done(); + }); +}); + +// These tests are done in the node implementation because common doesn't have a crypto +// implementation. +describe('when using secure mode hash', () => { + it('correctly computes hash for a known message and secret', () => { + const client = init('secret', { offline: true }); + const hash = client.secureModeHash({ key: 'Message' }); + expect(hash).toEqual('aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597'); + }); + + it.each<[LDContext, string]>([ + [{ key: 'Message' }, 'aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597'], + [{ kind: 'user', key: 'Message' }, 'aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597'], + [{ kind: 'org', key: 'orgtest' }, '40bc9b2e66a842e269ab98dad813e4e15203bbbfd91e8c96b92f3ae6f3f5e223'], + [{ kind: 'multi', user: { key: 'user:test' }, org: { key: 'org:test' } }, '607cc91526c615823e320dabca7967ce544fbe83bcb2b7287163f2d1c7aa210f'], + ])('it uses the canonical key %p', (context, expectedHash) => { + const client = init('secret', { offline: true }); + const hash = client.secureModeHash(context); + + expect(hash).toEqual(expectedHash); + client.close(); + }); +}); diff --git a/platform-node/__tests__/LDClientNode.tls.test.ts b/platform-node/__tests__/LDClientNode.tls.test.ts new file mode 100644 index 0000000000..635a62dee9 --- /dev/null +++ b/platform-node/__tests__/LDClientNode.tls.test.ts @@ -0,0 +1,116 @@ +import { + AsyncQueue, + sleepAsync, + SSEItem, + TestHttpHandlers, + TestHttpServer, +} from 'launchdarkly-js-test-helpers'; +import { basicLogger, LDClient, LDLogger } from '../src'; + +import LDClientNode from '../src/LDClientNode'; + +describe('When using a TLS connection', () => { + let client: LDClient; + let server: TestHttpServer; + let logger: LDLogger; + + beforeEach(() => { + logger = basicLogger({ + destination: () => {}, + }); + }); + + it( + 'can connect via HTTPS to a server with a self-signed certificate, if CA is specified', + async () => { + server = await TestHttpServer.startSecure(); + server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson({})); + + client = new LDClientNode('sdk-key', { + baseUri: server.url, + sendEvents: false, + stream: false, + logger, + tlsParams: { ca: server.certificate }, + diagnosticOptOut: true, + }); + await client.waitForInitialization(); + }, + ); + + it( + 'cannot connect via HTTPS to a server with a self-signed certificate, using default config', + async () => { + server = await TestHttpServer.startSecure(); + server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson({})); + + client = new LDClientNode('sdk-key', { + baseUri: server.url, + sendEvents: false, + stream: false, + logger, + diagnosticOptOut: true, + }); + + const spy = jest.spyOn(logger, 'warn'); + + // the client won't signal an unrecoverable error, but it should log a message + await sleepAsync(300); + + expect(spy).toHaveBeenCalledWith(expect.stringMatching(/self.signed/)); + }, + ); + + it('can use custom TLS options for streaming as well as polling', async () => { + const eventData = { data: { flags: { flag: { version: 1 } }, segments: {} } }; + const events = new AsyncQueue(); + events.add({ type: 'put', data: JSON.stringify(eventData) }); + server = await TestHttpServer.startSecure(); + server.forMethodAndPath('get', '/stream/all', TestHttpHandlers.sseStream(events)); + + client = new LDClientNode('sdk-key', { + baseUri: server.url, + streamUri: `${server.url}/stream`, + sendEvents: false, + logger, + tlsParams: { ca: server.certificate }, + diagnosticOptOut: true, + }); + + // this won't return until the stream receives the "put" event + await client.waitForInitialization(); + events.close(); + }); + + it('can use custom TLS options for posting events', async () => { + server = await TestHttpServer.startSecure(); + server.forMethodAndPath('post', '/events/bulk', TestHttpHandlers.respond(200)); + server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson({})); + + client = new LDClientNode('sdk-key', { + baseUri: server.url, + eventsUri: `${server.url}/events`, + stream: false, + tlsParams: { ca: server.certificate }, + diagnosticOptOut: true, + }); + + await client.waitForInitialization(); + client.identify({ key: 'user' }); + await client.flush(); + + const flagsRequest = await server.nextRequest(); + expect(flagsRequest.path).toEqual('/sdk/latest-all'); + + const eventsRequest = await server.nextRequest(); + expect(eventsRequest.path).toEqual('/events/bulk'); + const eventData = JSON.parse(eventsRequest.body!); + expect(eventData.length).toEqual(1); + expect(eventData[0].kind).toEqual('identify'); + }); + + afterEach(() => { + client.close(); + server.close(); + }); +}); diff --git a/platform-node/__tests__/platform/HeaderWrapper.test.ts b/platform-node/__tests__/platform/HeaderWrapper.test.ts new file mode 100644 index 0000000000..c0d6a71ec9 --- /dev/null +++ b/platform-node/__tests__/platform/HeaderWrapper.test.ts @@ -0,0 +1,62 @@ +/* eslint-disable no-restricted-syntax */ +// The header interface uses generators, so we are using restricted-syntax. +import * as http from 'http'; +import HeaderWrapper from '../../src/platform/HeaderWrapper'; + +describe('given header values', () => { + const headers: http.IncomingHttpHeaders = { + accept: 'anything', + 'some-header': 'some-value', + 'some-array': ['a', 'b'], + }; + const wrapper = new HeaderWrapper(headers); + + it('can get a single value header', () => { + expect(wrapper.get('accept')).toEqual('anything'); + }); + + it('can get an array value header', () => { + expect(wrapper.get('some-array')).toEqual('a, b'); + }); + + it('can get the entries', () => { + const flat = []; + for (const entry of wrapper.entries()) { + flat.push(entry); + } + expect(flat).toEqual([ + ['accept', 'anything'], + ['some-header', 'some-value'], + ['some-array', 'a, b'], + ]); + }); + + it('can check if a value is present', () => { + expect(wrapper.has('accept')).toBeTruthy(); + expect(wrapper.has('potato')).toBeFalsy(); + }); + + it('can key the keys', () => { + const keys = []; + for (const key of wrapper.keys()) { + keys.push(key); + } + expect(keys).toEqual([ + 'accept', + 'some-header', + 'some-array', + ]); + }); + + it('can key the values', () => { + const values = []; + for (const value of wrapper.values()) { + values.push(value); + } + expect(values).toEqual([ + 'anything', + 'some-value', + 'a, b', + ]); + }); +}); diff --git a/platform-node/__tests__/NodeInfo.test.ts b/platform-node/__tests__/platform/NodeInfo.test.ts similarity index 97% rename from platform-node/__tests__/NodeInfo.test.ts rename to platform-node/__tests__/platform/NodeInfo.test.ts index 4ba4f94597..bc5babeb6c 100644 --- a/platform-node/__tests__/NodeInfo.test.ts +++ b/platform-node/__tests__/platform/NodeInfo.test.ts @@ -1,5 +1,5 @@ import * as os from 'os'; -import NodeInfo from '../src/platform/NodeInfo'; +import NodeInfo from '../../src/platform/NodeInfo'; describe('given an information instance', () => { const info = new NodeInfo(); diff --git a/platform-node/__tests__/NodeRequests.test.ts b/platform-node/__tests__/platform/NodeRequests.test.ts similarity index 98% rename from platform-node/__tests__/NodeRequests.test.ts rename to platform-node/__tests__/platform/NodeRequests.test.ts index 0bdd79308e..2976451fcc 100644 --- a/platform-node/__tests__/NodeRequests.test.ts +++ b/platform-node/__tests__/platform/NodeRequests.test.ts @@ -1,6 +1,6 @@ import * as http from 'http'; -import NodeRequests from '../src/platform/NodeRequests'; +import NodeRequests from '../../src/platform/NodeRequests'; const PORT = '3333'; const TEXT_RESPONSE = 'Test Text'; diff --git a/platform-node/package.json b/platform-node/package.json index ac32ded7c1..7a97d0f4ba 100644 --- a/platform-node/package.json +++ b/platform-node/package.json @@ -24,6 +24,7 @@ "@types/jest": "^27.4.1", "jest": "^27.5.1", "ts-jest": "^27.1.4", - "typescript": "^4.6.3" + "typescript": "^4.6.3", + "launchdarkly-js-test-helpers": "^2.2.0" } } diff --git a/platform-node/src/LDClientNode.ts b/platform-node/src/LDClientNode.ts index 48f14b8e16..064016aa2f 100644 --- a/platform-node/src/LDClientNode.ts +++ b/platform-node/src/LDClientNode.ts @@ -11,7 +11,7 @@ import { Emits } from './Emits'; import BigSegmentStoreStatusProviderNode from './BigSegmentsStoreStatusProviderNode'; import { BigSegmentStoreStatusProvider } from './api'; -class ClientEmitter extends EventEmitter {} +class ClientEmitter extends EventEmitter { } class LDClientNode extends LDClientImpl { emitter: EventEmitter; @@ -33,22 +33,24 @@ class LDClientNode extends LDClientImpl { sdkKey, new NodePlatform({ ...options, logger }), { ...options, logger }, - (err: Error) => { - if (emitter.listenerCount('error')) { - emitter.emit('error', err); - } + { + onError: (err: Error) => { + if (emitter.listenerCount('error')) { + emitter.emit('error', err); + } + }, + onFailed: (err: Error) => { + emitter.emit('failed', err); + }, + onReady: () => { + emitter.emit('ready'); + }, + onUpdate: (key: string) => { + emitter.emit('update', { key }); + emitter.emit(`update:${key}`, { key }); + }, + hasEventListeners: () => emitter.eventNames().some((name) => name === 'update' || (typeof name === 'string' && name.startsWith('update:'))), }, - (err: Error) => { - emitter.emit('failed', err); - }, - () => { - emitter.emit('ready'); - }, - (key: string) => { - emitter.emit('update', { key }); - emitter.emit(`update:${key}`, { key }); - }, - () => emitter.eventNames().some((name) => name === 'update' || (typeof name === 'string' && name.startsWith('update:'))), ); this.emitter = emitter; diff --git a/server-sdk-common/__tests__/LDClient.allFlags.test.ts b/server-sdk-common/__tests__/LDClient.allFlags.test.ts new file mode 100644 index 0000000000..92cdf651b2 --- /dev/null +++ b/server-sdk-common/__tests__/LDClient.allFlags.test.ts @@ -0,0 +1,284 @@ +import { LDClientImpl } from '../src'; +import TestData from '../src/integrations/test_data/TestData'; +import basicPlatform from './evaluation/mocks/platform'; +import TestLogger, { LogLevel } from './Logger'; +import makeCallbacks from './makeCallbacks'; + +const defaultUser = { key: 'user' }; + +describe('given an LDClient with test data', () => { + let client: LDClientImpl; + let td: TestData; + let logger: TestLogger; + + beforeEach(async () => { + logger = new TestLogger(); + td = new TestData(); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + sendEvents: false, + logger, + }, + makeCallbacks(true), + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + it('captures flag state', async () => { + const value1 = 'value1'; const value2 = 'value2'; const + value3 = 'value3'; + const flag1 = { + key: 'key1', + version: 100, + on: false, + offVariation: 0, + variations: [value1], + }; + const flag2 = { + key: 'key2', + version: 200, + on: false, + offVariation: 1, + variations: ['x', value2], + trackEvents: true, + debugEventsUntilDate: 1000, + }; + // flag3 has an experiment (evaluation is a fallthrough and TrackEventsFallthrough is on) + const flag3 = { + key: 'key3', + version: 300, + on: true, + fallthrough: { variation: 1 }, + variations: ['x', value3], + trackEvents: false, + trackEventsFallthrough: true, + }; + td.usePreconfiguredFlag(flag1); + td.usePreconfiguredFlag(flag2); + td.usePreconfiguredFlag(flag3); + + const state = await client.allFlagsState(defaultUser); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual( + { [flag1.key]: value1, [flag2.key]: value2, [flag3.key]: value3 }, + ); + expect(state.getFlagValue(flag1.key)).toEqual(value1); + expect(state.getFlagReason('feature')).toEqual(null); + expect(state.toJSON()).toEqual({ + [flag1.key]: value1, + [flag2.key]: value2, + [flag3.key]: value3, + $flagsState: { + [flag1.key]: { + version: flag1.version, + variation: 0, + }, + [flag2.key]: { + version: flag2.version, + variation: 1, + trackEvents: true, + debugEventsUntilDate: 1000, + }, + [flag3.key]: { + version: flag3.version, + variation: 1, + reason: { kind: 'FALLTHROUGH' }, + trackEvents: true, + trackReason: true, + }, + }, + $valid: true, + }); + }); + + it('can filter for only client-side flags', async () => { + td.usePreconfiguredFlag({ + key: 'server-side-1', on: false, offVariation: 0, variations: ['a'], clientSide: false, + }); + td.usePreconfiguredFlag({ + key: 'server-side-2', on: false, offVariation: 0, variations: ['b'], clientSide: false, + }); + td.usePreconfiguredFlag({ + key: 'client-side-1', on: false, offVariation: 0, variations: ['value1'], clientSide: true, + }); + td.usePreconfiguredFlag({ + key: 'client-side-2', on: false, offVariation: 0, variations: ['value2'], clientSide: true, + }); + const state = await client.allFlagsState(defaultUser, { clientSideOnly: true }); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({ 'client-side-1': 'value1', 'client-side-2': 'value2' }); + }); + + it('can include reasons', async () => { + td.usePreconfiguredFlag({ + key: 'feature', + version: 100, + offVariation: 1, + variations: ['a', 'b'], + trackEvents: true, + debugEventsUntilDate: 1000, + }); + const state = await client.allFlagsState(defaultUser, { withReasons: true }); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({ feature: 'b' }); + expect(state.getFlagValue('feature')).toEqual('b'); + expect(state.getFlagReason('feature')).toEqual({ kind: 'OFF' }); + expect(state.toJSON()).toEqual({ + feature: 'b', + $flagsState: { + feature: { + version: 100, + variation: 1, + reason: { kind: 'OFF' }, + trackEvents: true, + debugEventsUntilDate: 1000, + }, + }, + $valid: true, + }); + }); + + it('can omit details for untracked flags', async () => { + const flag1 = { + key: 'flag1', + version: 100, + offVariation: 0, + variations: ['value1'], + }; + const flag2 = { + key: 'flag2', + version: 200, + offVariation: 0, + variations: ['value2'], + trackEvents: true, + }; + const flag3 = { + key: 'flag3', + version: 300, + offVariation: 0, + variations: ['value3'], + debugEventsUntilDate: 1000, + }; + td.usePreconfiguredFlag(flag1); + td.usePreconfiguredFlag(flag2); + td.usePreconfiguredFlag(flag3); + + const state = await client.allFlagsState( + defaultUser, + { withReasons: true, detailsOnlyForTrackedFlags: true }, + ); + 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, + }); + }); + + it('does not overflow the call stack when evaluating a huge number of flags', async () => { + const flagCount = 5000; + for (let i = 0; i < flagCount; i += 1) { + td.usePreconfiguredFlag({ + key: `feature${i}`, + version: 1, + on: false, + }); + } + const state = await client.allFlagsState(defaultUser); + expect(Object.keys(state.allValues()).length).toEqual(flagCount); + }); + + it('can use callback instead of promise', (done) => { + td.usePreconfiguredFlag({ + key: 'server-side-1', on: false, offVariation: 0, variations: ['a'], clientSide: false, + }); + td.usePreconfiguredFlag({ + key: 'server-side-2', on: false, offVariation: 0, variations: ['b'], clientSide: false, + }); + td.usePreconfiguredFlag({ + key: 'client-side-1', on: false, offVariation: 0, variations: ['value1'], clientSide: true, + }); + td.usePreconfiguredFlag({ + key: 'client-side-2', on: false, offVariation: 0, variations: ['value2'], clientSide: true, + }); + client.allFlagsState(defaultUser, { clientSideOnly: true }, (err, state) => { + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({ 'client-side-1': 'value1', 'client-side-2': 'value2' }); + done(); + }); + }); +}); + +describe('given an offline client', () => { + let logger: TestLogger; + let client: LDClientImpl; + let td: TestData; + + beforeEach(() => { + logger = new TestLogger(); + td = new TestData(); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + offline: true, + updateProcessor: td.getFactory(), + sendEvents: false, + logger, + }, + makeCallbacks(true), + ); + }); + + afterEach(() => { + client.close(); + }); + + it('returns empty state in offline mode and logs a message', async () => { + const flag = { + key: 'flagkey', + on: false, + offVariation: null, + }; + td.usePreconfiguredFlag(flag); + const state = await client.allFlagsState(defaultUser); + expect(state.valid).toEqual(false); + expect(state.allValues()).toEqual({}); + expect(logger.getCount(LogLevel.Info)).toEqual(1); + }); + + it('can use a callback instead of a Promise', (done) => { + client.allFlagsState(defaultUser, {}, (err, state) => { + expect(state.valid).toEqual(false); + done(); + }); + }); +}); diff --git a/server-sdk-common/__tests__/LDClient.evaluation.test.ts b/server-sdk-common/__tests__/LDClient.evaluation.test.ts new file mode 100644 index 0000000000..8f77ddfc0d --- /dev/null +++ b/server-sdk-common/__tests__/LDClient.evaluation.test.ts @@ -0,0 +1,251 @@ +import { LDClientImpl } from '../src'; +import { LDFeatureStore } from '../src/api/subsystems'; +import NullUpdateProcessor from '../src/data_sources/NullUpdateProcessor'; +import TestData from '../src/integrations/test_data/TestData'; +import AsyncStoreFacade from '../src/store/AsyncStoreFacade'; +import InMemoryFeatureStore from '../src/store/InMemoryFeatureStore'; +import VersionedDataKinds from '../src/store/VersionedDataKinds'; +import basicPlatform from './evaluation/mocks/platform'; +import TestLogger, { LogLevel } from './Logger'; +import makeCallbacks from './makeCallbacks'; + +const defaultUser = { key: 'user' }; + +describe('given an LDClient with test data', () => { + let client: LDClientImpl; + let td: TestData; + + beforeEach(async () => { + td = new TestData(); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + sendEvents: false, + }, + makeCallbacks(true), + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + it('evaluates an existing flag', async () => { + td.update(td.flag('flagkey').on(true).variations('a', 'b').fallthroughVariation(1)); + expect(await client.variation('flagkey', defaultUser, 'c')).toBe('b'); + }); + + it('returns default for an unknown flag', async () => { + expect(await client.variation('flagkey', defaultUser, 'c')).toBe('c'); + }); + + it('returns default if a flag key is not specified', async () => { + // @ts-ignore + expect(await client.variation(null, defaultUser, 'c')).toBe('c'); + }); + + it('returns the default for a flag which evaluates to null', async () => { + td.usePreconfiguredFlag({ // TestData normally won't construct a flag with offVariation: null + key: 'flagIsNull', + on: false, + offVariation: null, + }); + + expect(await client.variation('flagIsNull', defaultUser, 'default')).toEqual('default'); + }); + + it('returns the default for a flag which evaluates to null using variationDetail', async () => { + td.usePreconfiguredFlag({ // TestData normally won't construct a flag with offVariation: null + key: 'flagIsNull', + on: false, + offVariation: null, + }); + + expect(await client.variationDetail('flagIsNull', defaultUser, 'default')).toMatchObject( + { value: 'default', variationIndex: null, reason: { kind: 'OFF' } }, + ); + }); + + it('can use a callback instead of a promise', (done) => { + client.variation('nonsense', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual('default'); + done(); + }); + }); + + it('can use a callback instead of a promise for variationDetail', (done) => { + client.variationDetail('nonsense', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toMatchObject( + { + value: 'default', + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + }, + ); + done(); + }); + }); + + it('can evaluate an existing flag with detail', async () => { + td.update(td.flag('flagkey').on(true).variations('a', 'b').fallthroughVariation(1)); + expect(await client.variationDetail('flagkey', defaultUser, 'c')).toMatchObject( + { value: 'b', variationIndex: 1, reason: { kind: 'FALLTHROUGH' } }, + ); + }); + + it('returns default for an unknown flag with detail', async () => { + expect(await client.variationDetail('flagkey', defaultUser, 'default')).toMatchObject( + { + value: 'default', + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + }, + ); + }); +}); + +describe('given an offline client', () => { + let logger: TestLogger; + let client: LDClientImpl; + let td: TestData; + + beforeEach(() => { + logger = new TestLogger(); + td = new TestData(); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + offline: true, + updateProcessor: td.getFactory(), + sendEvents: false, + logger, + }, + makeCallbacks(true), + ); + }); + + afterEach(() => { + client.close(); + }); + + it('returns the default value for variation', async () => { + await client.waitForInitialization(); + td.update(td.flag('flagkey').variations('value').variationForAll(0)); + const result = await client.variation('flagkey', defaultUser, 'default'); + expect(result).toEqual('default'); + expect(logger.getCount(LogLevel.Info)).toEqual(1); + }); + + it('returns the default value for variationDetail', async () => { + await client.waitForInitialization(); + td.update(td.flag('flagkey').variations('value').variationForAll(0)); + const result = await client.variationDetail('flagkey', defaultUser, 'default'); + expect(result).toMatchObject({ + value: 'default', + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' }, + }); + expect(logger.getCount(LogLevel.Info)).toEqual(1); + }); +}); + +describe('given a client and store that are uninitialized', () => { + let store: LDFeatureStore; + let client: LDClientImpl; + + beforeEach(async () => { + store = new InMemoryFeatureStore(); + const asyncStore = new AsyncStoreFacade(store); + // Put something in the store, but don't initialize it. + await asyncStore.upsert(VersionedDataKinds.Features, { + key: 'flagkey', + version: 1, + on: false, + offVariation: 0, + variations: ['value'], + }); + + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: new NullUpdateProcessor(), + sendEvents: false, + featureStore: store, + }, + makeCallbacks(true), + ); + }); + + afterEach(() => { + client.close(); + }); + + it('returns the default value for variation', async () => { + expect(await client.variation('flagkey', defaultUser, 'default')).toEqual('default'); + }); + + it('returns the default value for variationDetail', async () => { + expect(await client.variationDetail('flagkey', defaultUser, 'default')).toMatchObject( + { + value: 'default', + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' }, + }, + ); + }); +}); + +describe('given a client that is un-initialized and store that is initialized', () => { + let store: LDFeatureStore; + let client: LDClientImpl; + + beforeEach(async () => { + store = new InMemoryFeatureStore(); + const asyncStore = new AsyncStoreFacade(store); + // Put something in the store, but don't initialize it. + await asyncStore.init({ + features: { + flagkey: { + version: 1, + on: false, + offVariation: 0, + variations: ['value'], + }, + }, + segments: {}, + }); + + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: new NullUpdateProcessor(), + sendEvents: false, + featureStore: store, + }, + makeCallbacks(true), + ); + }); + + afterEach(() => { + client.close(); + }); + + it('returns the value for variation', async () => { + expect(await client.variation('flagkey', defaultUser, 'default')).toEqual('value'); + }); + + it('returns the value for variationDetail', async () => { + expect(await client.variationDetail('flagkey', defaultUser, 'default')).toMatchObject( + { value: 'value', variationIndex: 0, reason: { kind: 'OFF' } }, + ); + }); +}); diff --git a/server-sdk-common/__tests__/LDClient.events.test.ts b/server-sdk-common/__tests__/LDClient.events.test.ts new file mode 100644 index 0000000000..8663b59c7e --- /dev/null +++ b/server-sdk-common/__tests__/LDClient.events.test.ts @@ -0,0 +1,308 @@ +import { Context } from '@launchdarkly/js-sdk-common'; +import { LDClientImpl } from '../src'; +import EventProcessor from '../src/events/EventProcessor'; +import InputEvent from '../src/events/InputEvent'; +import TestData from '../src/integrations/test_data/TestData'; +import basicPlatform from './evaluation/mocks/platform'; +import makeCallbacks from './makeCallbacks'; + +const defaultUser = { key: 'user' }; +const anonymousUser = { key: 'anon-user', anonymous: true }; + +describe('given a client with mock event processor', () => { + let client: LDClientImpl; + let events: InputEvent[]; + let td: TestData; + + beforeEach(async () => { + events = []; + jest.spyOn(EventProcessor.prototype, 'sendEvent').mockImplementation((evt) => events.push(evt)); + jest.spyOn(EventProcessor.prototype, 'flush').mockImplementation(() => Promise.resolve()); + + td = new TestData(); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + }, + makeCallbacks(false), + ); + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + it('generates event for existing feature', async () => { + td.update(td.flag('flagkey').on(true).variations('a', 'b').fallthroughVariation(1)); + + await client.variation('flagkey', defaultUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + context: Context.fromLDContext(defaultUser), + variation: 1, + value: 'b', + default: 'c', + }); + }); + + it('generates event for existing feature when user is anonymous', async () => { + td.update(td.flag('flagkey').on(true).variations('a', 'b').fallthroughVariation(1)); + await client.variation('flagkey', anonymousUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + context: Context.fromLDContext(anonymousUser), + variation: 1, + value: 'b', + default: 'c', + }); + }); + + it('generates event for existing feature with reason', async () => { + td.update(td.flag('flagkey').on(true).variations('a', 'b').fallthroughVariation(1)); + await client.variationDetail('flagkey', defaultUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + context: Context.fromLDContext(defaultUser), + variation: 1, + value: 'b', + default: 'c', + reason: { kind: 'FALLTHROUGH' }, + }); + }); + + it('forces tracking when a matched rule has trackEvents set', async () => { + td.usePreconfiguredFlag({ // TestData doesn't normally set trackEvents + key: 'flagkey', + version: 1, + on: true, + targets: [], + rules: [ + { + clauses: [{ attribute: 'key', op: 'in', values: [defaultUser.key] }], + variation: 0, + id: 'rule-id', + trackEvents: true, + }, + ], + fallthrough: { variation: 1 }, + variations: ['a', 'b'], + }); + await client.variation('flagkey', defaultUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + creationDate: e.creationDate, + key: 'flagkey', + version: 1, + context: Context.fromLDContext(defaultUser), + variation: 0, + value: 'a', + default: 'c', + trackEvents: true, + reason: { kind: 'RULE_MATCH', ruleIndex: 0, ruleId: 'rule-id' }, + }); + }); + + it('does not force tracking when a matched rule does not have trackEvents set', async () => { + td.usePreconfiguredFlag({ + key: 'flagkey', + version: 1, + on: true, + targets: [], + rules: [ + { + clauses: [{ attribute: 'key', op: 'in', values: [defaultUser.key] }], + variation: 0, + id: 'rule-id', + }, + ], + fallthrough: { variation: 1 }, + variations: ['a', 'b'], + }); + await client.variation('flagkey', defaultUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + creationDate: e.creationDate, + key: 'flagkey', + version: 1, + context: Context.fromLDContext(defaultUser), + variation: 0, + value: 'a', + default: 'c', + }); + }); + + it('forces tracking for fallthrough result when trackEventsFallthrough is set', async () => { + td.usePreconfiguredFlag({ + key: 'flagkey', + version: 1, + on: true, + targets: [], + rules: [], + fallthrough: { variation: 1 }, + variations: ['a', 'b'], + trackEventsFallthrough: true, + }); + await client.variation('flagkey', defaultUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + creationDate: e.creationDate, + key: 'flagkey', + version: 1, + context: Context.fromLDContext(defaultUser), + variation: 1, + value: 'b', + default: 'c', + trackEvents: true, + reason: { kind: 'FALLTHROUGH' }, + }); + }); + + it('forces tracking when an evaluation is in the tracked portion of an experiment rollout', async () => { + td.usePreconfiguredFlag({ + key: 'flagkey', + version: 1, + on: true, + targets: [], + rules: [], + fallthrough: { + rollout: { + kind: 'experiment', + variations: [ + { + weight: 100000, + variation: 1, + }, + ], + }, + }, + variations: ['a', 'b'], + }); + await client.variation('flagkey', defaultUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + creationDate: e.creationDate, + key: 'flagkey', + version: 1, + context: Context.fromLDContext(defaultUser), + variation: 1, + value: 'b', + default: 'c', + trackEvents: true, + reason: { kind: 'FALLTHROUGH', inExperiment: true }, + }); + }); + + it('does not force tracking when an evaluation is in the untracked portion of an experiment rollout', async () => { + td.usePreconfiguredFlag({ + key: 'flagkey', + version: 1, + on: true, + targets: [], + rules: [], + fallthrough: { + rollout: { + kind: 'experiment', + variations: [ + { + weight: 100000, + variation: 1, + untracked: true, + }, + ], + }, + }, + variations: ['a', 'b'], + }); + + await client.variation('flagkey', defaultUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + creationDate: e.creationDate, + key: 'flagkey', + version: 1, + context: Context.fromLDContext(defaultUser), + variation: 1, + value: 'b', + default: 'c', + }); + }); + + it('does not force tracking for fallthrough result when trackEventsFallthrough is not set', async () => { + td.update(td.flag('flagkey').on(true).variations('a', 'b').fallthroughVariation(1)); + await client.variation('flagkey', defaultUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + creationDate: e.creationDate, + key: 'flagkey', + version: 1, + context: Context.fromLDContext(defaultUser), + variation: 1, + value: 'b', + default: 'c', + }); + }); + + it('generates event for unknown feature', async () => { + await client.variation('flagkey', defaultUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + context: Context.fromLDContext(defaultUser), + value: 'c', + default: 'c', + }); + }); + + it('generates event for unknown feature when user is anonymous', async () => { + await client.variation('flagkey', anonymousUser, 'c'); + + expect(events).toHaveLength(1); + const e = events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + context: Context.fromLDContext(anonymousUser), + value: 'c', + default: 'c', + }); + }); +}); diff --git a/server-sdk-common/__tests__/LDClientImpl.bigSegments.test.ts b/server-sdk-common/__tests__/LDClientImpl.bigSegments.test.ts new file mode 100644 index 0000000000..1125ecbfd3 --- /dev/null +++ b/server-sdk-common/__tests__/LDClientImpl.bigSegments.test.ts @@ -0,0 +1,198 @@ +import { LDBigSegmentsOptions, LDClientImpl } from '../src'; +import { BigSegmentStore } from '../src/api/interfaces'; +import makeBigSegmentRef from '../src/evaluation/makeBigSegmentRef'; +import TestData from '../src/integrations/test_data/TestData'; +import { Hasher, Crypto, Hmac } from '../src/platform'; +import { makeSegmentMatchClause } from './evaluation/flags'; +import basicPlatform from './evaluation/mocks/platform'; +import makeCallbacks from './makeCallbacks'; + +const user = { key: 'userkey' }; +const bigSegment = { + key: 'segmentkey', + version: 1, + unbounded: true, + generation: 2, +}; +const flag = { + key: 'flagkey', + on: true, + variations: [false, true], + fallthrough: { variation: 0 }, + rules: [ + { variation: 1, clauses: [makeSegmentMatchClause(bigSegment)] }, + ], +}; + +class TestHasher implements Hasher { + private value: string = 'is_hashed:'; + + update(toAdd: string): Hasher { + this.value += toAdd; + return this; + } + + digest() { + return this.value; + } +} + +const crypto: Crypto = { + createHash(algorithm: string): Hasher { + expect(algorithm).toEqual('sha256'); + return new TestHasher(); + }, + createHmac(algorithm: string, key: string): Hmac { + // Not used for this test. + throw new Error(`Function not implemented.${algorithm}${key}`); + }, +}; + +describe('given test data with big segments', () => { + let client: LDClientImpl; + let td: TestData; + + beforeEach(async () => { + td = new TestData(); + td.usePreconfiguredFlag(flag); + td.usePreconfiguredSegment(bigSegment); + }); + + describe('given a big segment store without the user', () => { + beforeEach(async () => { + const bigSegmentsConfig: LDBigSegmentsOptions = { + store(): BigSegmentStore { + return { + getMetadata: async () => ({ lastUpToDate: Date.now() }), + getUserMembership: async () => undefined, + close: () => { }, + }; + }, + }; + + client = new LDClientImpl( + 'sdk-key', + { ...basicPlatform, crypto }, + { + updateProcessor: td.getFactory(), + sendEvents: false, + bigSegments: bigSegmentsConfig, + }, + makeCallbacks(true), + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + it('user not found in big segment store', async () => { + const result = await client.variationDetail(flag.key, user, false); + expect(result.value).toBe(false); + expect(result.reason.bigSegmentsStatus).toEqual('HEALTHY'); + }); + }); + + describe('given a big segment store with the user', () => { + beforeEach(async () => { + const membership = { [makeBigSegmentRef(bigSegment)]: true }; + const bigSegmentsConfig: LDBigSegmentsOptions = { + store(): BigSegmentStore { + return { + getMetadata: async () => ({ lastUpToDate: Date.now() }), + getUserMembership: async (hash) => (hash === `is_hashed:${user.key}` ? membership : undefined), + close: () => { }, + }; + }, + }; + + client = new LDClientImpl( + 'sdk-key', + { ...basicPlatform, crypto }, + { + updateProcessor: td.getFactory(), + sendEvents: false, + bigSegments: bigSegmentsConfig, + }, + makeCallbacks(true), + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + it('user found in big segment store', async () => { + const result = await client.variationDetail(flag.key, user, false); + expect(result.value).toBe(true); + expect(result.reason.bigSegmentsStatus).toEqual('HEALTHY'); + }); + }); + + describe('given a big segment store which experiences an error', () => { + beforeEach(async () => { + const bigSegmentsConfig: LDBigSegmentsOptions = { + store(): BigSegmentStore { + return { + getMetadata: async () => ({ lastUpToDate: Date.now() }), + getUserMembership: async () => { throw new Error('sorry'); }, + close: () => { }, + }; + }, + }; + + client = new LDClientImpl( + 'sdk-key', + { ...basicPlatform, crypto }, + { + updateProcessor: td.getFactory(), + sendEvents: false, + bigSegments: bigSegmentsConfig, + }, + makeCallbacks(true), + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + it('produces a store error', async () => { + const result = await client.variationDetail(flag.key, user, false); + expect(result.value).toBe(false); + expect(result.reason.bigSegmentsStatus).toEqual('STORE_ERROR'); + }); + }); + + describe('given a client without big segment support.', () => { + beforeEach(async () => { + client = new LDClientImpl( + 'sdk-key', + { ...basicPlatform, crypto }, + { + updateProcessor: td.getFactory(), + sendEvents: false, + }, + makeCallbacks(true), + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + it('produces a not configured error', async () => { + const result = await client.variationDetail(flag.key, user, false); + expect(result.value).toBe(false); + expect(result.reason.bigSegmentsStatus).toEqual('NOT_CONFIGURED'); + }); + }); +}); diff --git a/server-sdk-common/__tests__/LDClientImpl.listeners.test.ts b/server-sdk-common/__tests__/LDClientImpl.listeners.test.ts new file mode 100644 index 0000000000..875c603c65 --- /dev/null +++ b/server-sdk-common/__tests__/LDClientImpl.listeners.test.ts @@ -0,0 +1,58 @@ +import { LDClientImpl } from '../src'; +import TestData from '../src/integrations/test_data/TestData'; +import AsyncQueue from './AsyncQueue'; +import basicPlatform from './evaluation/mocks/platform'; +import TestLogger from './Logger'; +import makeCallbacks from './makeCallbacks'; + +describe('given an LDClient with test data', () => { + let client: LDClientImpl; + let td: TestData; + let queue: AsyncQueue; + + beforeEach(() => { + queue = new AsyncQueue(); + td = new TestData(); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + sendEvents: false, + logger: new TestLogger(), + }, + { ...makeCallbacks(true), onUpdate: (key: string) => queue.push(key) }, + // () => { }, + // () => { }, + // () => { }, + // (key) => { + // queue.push(key); + // }, + // // Always listen to events. + // () => true, + ); + }); + + afterEach(() => { + client.close(); + }); + + it('sends an event when a flag is added', async () => { + td.update(td.flag('new-flag')); + expect(await queue.take()).toEqual('new-flag'); + }); + + it('sends an event when a flag is updated', async () => { + td.update(td.flag('flag1').on(true)); + td.update(td.flag('flag2').on(true)); + + expect(await queue.take()).toEqual('flag1'); + expect(await queue.take()).toEqual('flag2'); + + td.update(td.flag('flag1').on(false)); + td.update(td.flag('flag2').on(false)); + + expect(await queue.take()).toEqual('flag1'); + expect(await queue.take()).toEqual('flag2'); + }); +}); diff --git a/server-sdk-common/__tests__/LDClientImpl.test.ts b/server-sdk-common/__tests__/LDClientImpl.test.ts new file mode 100644 index 0000000000..8a78258df2 --- /dev/null +++ b/server-sdk-common/__tests__/LDClientImpl.test.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { LDClientImpl } from '../src'; +import basicPlatform from './evaluation/mocks/platform'; +import TestLogger from './Logger'; +import makeCallbacks from './makeCallbacks'; + +it('fires ready event in offline mode', (done) => { + const client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { offline: true }, + { ...makeCallbacks(false), onReady: () => done() }, + ); + client.close(); +}); + +it('fires the failed event if initialization fails', (done) => { + const client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: { + start: (fn: (err: any) => void) => { + setTimeout(() => { + fn(new Error('BAD THINGS')); + }, 0); + }, + stop: () => { }, + close: () => { }, + }, + }, + { ...makeCallbacks(false), onFailed: () => done() }, + ); + + client.close(); +}); + +it('isOffline returns true in offline mode', (done) => { + const client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { offline: true }, + { + ...makeCallbacks(false), + onReady: () => { + expect(client.isOffline()).toEqual(true); + done(); + }, + }, + // (_err) => { }, + // (_err) => { }, + // () => { + // expect(client.isOffline()).toEqual(true); + // done(); + // }, + // (_key) => { }, + // () => false, + ); + + client.close(); +}); + +describe('when waiting for initialization', () => { + let client: LDClientImpl; + + beforeEach(() => { + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: { + start: (fn: (err?: any) => void) => { + setTimeout(() => { + fn(); + }, 0); + }, + stop: () => { }, + close: () => { }, + }, + sendEvents: false, + logger: new TestLogger(), + }, + makeCallbacks(false), + ); + }); + + afterEach(() => { + client.close(); + }); + + it('resolves when ready', async () => { + await client.waitForInitialization(); + }); + + it('resolves immediately if the client is already ready', async () => { + await client.waitForInitialization(); + await client.waitForInitialization(); + }); + + it('creates only one Promise', async () => { + const p1 = client.waitForInitialization(); + const p2 = client.waitForInitialization(); + expect(p2).toBe(p1); + }); +}); + +it('does not crash when closing an offline client', () => { + const client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { offline: true }, + makeCallbacks(false), + ); + + expect(() => client.close()).not.toThrow(); + client.close(); +}); + +it('the wait for initialization promise is rejected if initialization fails', (done) => { + const client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: { + start: (fn: (err: any) => void) => { + setTimeout(() => { + fn(new Error('BAD THINGS')); + }, 0); + }, + stop: () => { }, + close: () => { }, + }, + }, + makeCallbacks(false), + ); + + client.waitForInitialization().catch(() => done()); + client.close(); +}); diff --git a/server-sdk-common/__tests__/makeCallbacks.ts b/server-sdk-common/__tests__/makeCallbacks.ts new file mode 100644 index 0000000000..f17655a95d --- /dev/null +++ b/server-sdk-common/__tests__/makeCallbacks.ts @@ -0,0 +1,11 @@ +import { LDClientCallbacks } from '../src/LDClientImpl'; + +export default function makeCallbacks(listenEvents: boolean): LDClientCallbacks { + return { + onError: () => { }, + onFailed: () => { }, + onReady: () => { }, + onUpdate: () => { }, + hasEventListeners: () => listenEvents, + }; +} diff --git a/server-sdk-common/src/LDClientImpl.ts b/server-sdk-common/src/LDClientImpl.ts index 686847f470..bf1830b333 100644 --- a/server-sdk-common/src/LDClientImpl.ts +++ b/server-sdk-common/src/LDClientImpl.ts @@ -40,6 +40,17 @@ enum InitState { Failed, } +export interface LDClientCallbacks { + onError: (err: Error) => void; + onFailed: (err: Error) => void; + onReady: () => void; + // Called whenever flags change, if there are listeners. + onUpdate: (key: string) => void, + // Method to check if event listeners have been registered. + // If none are registered, then onUpdate will never be called. + hasEventListeners: () => boolean, +} + export default class LDClientImpl implements LDClient { private initState: InitState = InitState.Initializing; @@ -67,6 +78,12 @@ export default class LDClientImpl implements LDClient { private bigSegmentsManager: BigSegmentsManager; + private onError: (err: Error) => void; + + private onFailed: (err: Error) => void; + + private onReady: () => void; + private diagnosticsManager?: DiagnosticsManager; /** @@ -82,15 +99,13 @@ export default class LDClientImpl implements LDClient { private sdkKey: string, private platform: Platform, options: LDOptions, - private onError: (err: Error) => void, - private onFailed: (err: Error) => void, - private onReady: () => void, - // Called whenever flags change, if there are listeners. - onUpdate: (key: string) => void, - // Method to check if event listeners have been registered. - // If none are registered, then onUpdate will never be called. - hasEventListeners: () => boolean, + callbacks: LDClientCallbacks, ) { + this.onError = callbacks.onError; + this.onFailed = callbacks.onFailed; + this.onReady = callbacks.onReady; + + const { onUpdate, hasEventListeners } = callbacks; const config = new Configuration(options); if (!sdkKey && !config.offline) { throw new Error('You must configure the client with an SDK key');