diff --git a/.mocharc.yml b/.mocharc.yml new file mode 100644 index 0000000..dae5074 --- /dev/null +++ b/.mocharc.yml @@ -0,0 +1 @@ +exit: true diff --git a/eslint.config.js b/eslint.config.js index 29ac66d..4fad667 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,24 +1,15 @@ -import js from '@eslint/js'; -import globals from 'globals'; +import { defineConfig } from "eslint/config"; -export default [ - { - ...js.configs.recommended, - files: ['src/**/*.js'] +export default defineConfig({ + files: ['src/**/*.js'], + rules: { + quotes: ['error', 'single'], + semi: ['error', 'always'], + curly: ['error', 'multi-line'], + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }] }, - { - files: ['src/**/*.js'], - rules: { - quotes: ['error', 'single'], - semi: ['error', 'always'], - curly: ['error', 'multi-line'], - }, - languageOptions: { - ecmaVersion: 2022, - sourceType: 'module', - globals: { - ...globals.node - } - } + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module' } -]; \ No newline at end of file +}); \ No newline at end of file diff --git a/package.json b/package.json index 6fda0fb..1f046b8 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,15 @@ "src/" ], "devDependencies": { - "@babel/eslint-parser": "^7.28.5", - "@typescript-eslint/eslint-plugin": "^8.52.0", - "@typescript-eslint/parser": "^8.52.0", - "c8": "^10.1.3", + "@typescript-eslint/eslint-plugin": "^8.57.1", + "@typescript-eslint/parser": "^8.57.1", + "c8": "^11.0.0", "chai": "^6.2.2", "env-cmd": "^11.0.0", - "eslint": "^9.39.2", + "eslint": "^10.1.0", "mocha": "^11.7.5", "mocha-sonarqube-reporter": "^1.0.2", - "sinon": "^21.0.1" + "sinon": "^21.0.3" }, "repository": { "type": "git", diff --git a/tests/helper/dummy-cert.pem b/tests/helper/dummy-cert.pem index 7966930..953eda7 100644 --- a/tests/helper/dummy-cert.pem +++ b/tests/helper/dummy-cert.pem @@ -1,3 +1,29 @@ -----BEGIN CERTIFICATE----- -== +MIIFBTCCAu2gAwIBAgIQWgDyEtjUtIDzkkFX6imDBTANBgkqhkiG9w0BAQsFADBP +MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy +Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa +Fw0yNzAzMTIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF +bmNyeXB0MQwwCgYDVQQDEwNSMTMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQClZ3CN0FaBZBUXYc25BtStGZCMJlA3mBZjklTb2cyEBZPs0+wIG6BgUUNI +fSvHSJaetC3ancgnO1ehn6vw1g7UDjDKb5ux0daknTI+WE41b0VYaHEX/D7YXYKg +L7JRbLAaXbhZzjVlyIuhrxA3/+OcXcJJFzT/jCuLjfC8cSyTDB0FxLrHzarJXnzR +yQH3nAP2/Apd9Np75tt2QnDr9E0i2gB3b9bJXxf92nUupVcM9upctuBzpWjPoXTi +dYJ+EJ/B9aLrAek4sQpEzNPCifVJNYIKNLMc6YjCR06CDgo28EdPivEpBHXazeGa +XP9enZiVuppD0EqiFwUBBDDTMrOPAgMBAAGjgfgwgfUwDgYDVR0PAQH/BAQDAgGG +MB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBTnq58PLDOgU9NeT3jIsoQOO9aSMzAfBgNVHSMEGDAWgBR5 +tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKG +Fmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0gBAwwCjAIBgZngQwBAgEwJwYD +VR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVuY3Iub3JnLzANBgkqhkiG9w0B +AQsFAAOCAgEAUTdYUqEimzW7TbrOypLqCfL7VOwYf/Q79OH5cHLCZeggfQhDconl +k7Kgh8b0vi+/XuWu7CN8n/UPeg1vo3G+taXirrytthQinAHGwc/UdbOygJa9zuBc +VyqoH3CXTXDInT+8a+c3aEVMJ2St+pSn4ed+WkDp8ijsijvEyFwE47hulW0Ltzjg +9fOV5Pmrg/zxWbRuL+k0DBDHEJennCsAen7c35Pmx7jpmJ/HtgRhcnz0yjSBvyIw +6L1QIupkCv2SBODT/xDD3gfQQyKv6roV4G2EhfEyAsWpmojxjCUCGiyg97FvDtm/ +NK2LSc9lybKxB73I2+P2G3CaWpvvpAiHCVu30jW8GCxKdfhsXtnIy2imskQqVZ2m +0Pmxobb28Tucr7xBK7CtwvPrb79os7u2XP3O5f9b/H66GNyRrglRXlrYjI1oGYL/ +f4I1n/Sgusda6WvA6C190kxjU15Y12mHU4+BxyR9cx2hhGS9fAjMZKJss28qxvz6 +Axu4CaDmRNZpK/pQrXF17yXCXkmEWgvSOEZy6Z9pcbLIVEGckV/iVeq0AOo2pkg9 +p4QRIy0tK2diRENLSF2KysFwbY6B26BFeFs3v1sYVRhFW9nLkOrQVporCS0KyZmf +wVD89qSTlnctLcZnIavjKsKUu1nA1iU0yYMdYepKR7lWbnwhdx3ewok= -----END CERTIFICATE----- \ No newline at end of file diff --git a/tests/switcher-context.test.js b/tests/switcher-context.test.js new file mode 100644 index 0000000..14a42b3 --- /dev/null +++ b/tests/switcher-context.test.js @@ -0,0 +1,118 @@ +import { assert } from 'chai'; +import { stub } from 'sinon'; + +import FetchFacade from '../src/lib/utils/fetchFacade.js'; +import { Client } from '../switcher-client.js'; +import { given, generateAuth, generateResult, assertReject } from './helper/utils.js'; +import { removeAgent } from '../src/lib/remote.js'; + +describe('Switcher Remote:', function () { + + let contextSettings; + let fetchStub; + + beforeEach(function() { + fetchStub = stub(FetchFacade, 'fetch'); + + contextSettings = { + apiKey: '[api_key]', + domain: 'Business', + component: 'business-service', + environment: 'default', + url: 'http://localhost:3000' + }; + }); + + afterEach(function() { + fetchStub.restore(); + }); + + it('should throw when certPath is invalid', function() { + assert.throws(() => Client.buildContext(contextSettings, { certPath: 'invalid' }), + 'Invalid certificate path \'invalid\''); + }); + + it('should NOT throw when certPath is valid', function() { + assert.doesNotThrow(() => Client.buildContext(contextSettings, { certPath: './tests/helper/dummy-cert.pem' })); + removeAgent(); + }); + + it('should be invalid - Missing API url field', async function () { + // given + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + + // test + Client.buildContext({ url: undefined, apiKey: 'apiKey', domain: 'domain', component: 'component', environment: 'default' }); + let switcher = Client.getSwitcher(); + + await switcher + .checkValue('User 1') + .checkNetwork('192.168.0.1') + .prepare('MY_FLAG'); + + await assertReject(assert, switcher.isItOn(), 'Something went wrong: URL is required'); + }); + + it('should be invalid - Missing API Key field', async function () { + // given + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + + // test + Client.buildContext({ url: 'url', apiKey: undefined, domain: 'domain', component: 'component', environment: 'default' }); + let switcher = Client.getSwitcher(); + + await switcher + .checkValue('User 1') + .checkNetwork('192.168.0.1') + .prepare('MY_FLAG'); + + await assertReject(assert, switcher.isItOn(), 'Something went wrong: API Key is required'); + }); + + it('should be invalid - Missing key field', async function () { + // given + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(undefined) }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + await switcher + .checkValue('User 1') + .checkNetwork('192.168.0.1') + .prepare(undefined); + + await assertReject(assert, switcher.isItOn(), 'Something went wrong: Missing key field'); + }); + + it('should be invalid - Missing component field', async function () { + // given + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(undefined) }); + + // test + Client.buildContext({ url: 'url', apiKey: 'apiKey', domain: 'domain', component: undefined, environment: 'default' }); + let switcher = Client.getSwitcher(); + + await assertReject(assert, switcher + .checkValue('User 1') + .checkNetwork('192.168.0.1') + .isItOn('MY_FLAG'), 'Something went wrong: Component is required'); + }); + + it('should be invalid - Missing token field', async function () { + // given + given(fetchStub, 0, { json: () => generateAuth(undefined, 1), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(undefined) }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + + await assertReject(assert, switcher + .checkValue('User 1') + .checkNetwork('192.168.0.1') + .isItOn('MY_FLAG'), 'Something went wrong: Missing token field'); + }); + +}); \ No newline at end of file diff --git a/tests/switcher-functional.test.js b/tests/switcher-functional.test.js deleted file mode 100644 index 73ff0e9..0000000 --- a/tests/switcher-functional.test.js +++ /dev/null @@ -1,704 +0,0 @@ -import { assert } from 'chai'; -import { stub, spy } from 'sinon'; -import { unwatchFile } from 'node:fs'; - -import FetchFacade from '../src/lib/utils/fetchFacade.js'; -import ExecutionLogger from '../src/lib/utils/executionLogger.js'; -import { Client } from '../switcher-client.js'; -import { given, givenError, throws, generateAuth, generateResult, assertReject, - assertResolve, generateDetailedResult, sleep, assertUntilResolve } from './helper/utils.js'; - -describe('Integrated test - Switcher:', function () { - - let contextSettings; - - this.afterAll(function() { - unwatchFile('./tests/snapshot/default.json'); - }); - - this.beforeEach(function() { - Client.testMode(); - - contextSettings = { - apiKey: '[api_key]', - domain: 'Business', - component: 'business-service', - environment: 'default', - url: 'http://localhost:3000' - }; - }); - - describe('check criteria (e2e):', function () { - - let fetchStub; - - beforeEach(function() { - fetchStub = stub(FetchFacade, 'fetch'); - ExecutionLogger.clearLogger(); - }); - - afterEach(function() { - fetchStub.restore(); - }); - - it('should be valid', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - - await switcher.prepare('FLAG_1'); - assert.isTrue(await switcher.isItOn()); - }); - - it('should be valid - using persisted switcher key', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); - - // test - Client.buildContext(contextSettings); - - // Get switcher multiple times with the same key - const switcher1 = Client.getSwitcher('MY_PERSISTED_SWITCHER_KEY'); - const switcher2 = Client.getSwitcher('MY_PERSISTED_SWITCHER_KEY'); - const differentSwitcher = Client.getSwitcher('DIFFERENT_KEY'); - - // Verify they are the same instance (persisted) - assert.strictEqual(switcher1, switcher2, 'Switcher instances should be the same (persisted)'); - assert.notStrictEqual(switcher1, differentSwitcher, 'Different keys should create different instances'); - assert.isTrue(await switcher1.isItOn()); - }); - - it('should NOT throw error when default result is provided using remote', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { error: 'ERROR', status: 404 }); - - // test - let asyncErrorMessage = null; - Client.buildContext(contextSettings); - Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); - let switcher = Client.getSwitcher().defaultResult(true); - - assert.isTrue(await switcher.isItOn('UNKNOWN_FEATURE')); - assert.equal(asyncErrorMessage, 'Something went wrong: [checkCriteria] failed with status 404'); - }); - - it('should NOT be valid - API returned 429 (too many requests)', async function () { - // given API responses - given(fetchStub, 0, { error: 'Too many requests', status: 429 }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - - await assertReject(assert, switcher.isItOn('FLAG_1'), 'Something went wrong: [auth] failed with status 429'); - }); - - it('should be valid - throttle', async function () { - this.timeout(2000); - - // given API responses - // first API call - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // sync - given(fetchStub, 2, { json: () => generateResult(true), status: 200 }); // async - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - switcher.throttle(1000); - - const spyExecutionLogger = spy(ExecutionLogger, 'add'); - - assert.isTrue(await switcher.isItOn('FLAG_1')); // sync - assert.isTrue(await switcher.isItOn('FLAG_1')); // async - await sleep(500); // wait resolve async Promise - - assert.equal(spyExecutionLogger.callCount, 1); - }); - - it('should be valid - throttle - with details', async function () { - this.timeout(3000); - - // given API responses - // first API call - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // sync - given(fetchStub, 2, { json: () => generateResult(true), status: 200 }); // async - - // test - Client.buildContext(contextSettings); - - let switcher = Client.getSwitcher(); - switcher.throttle(1000); - - // first API call - stores result in cache - await switcher.isItOn('FLAG_2'); - - // first async API call - const response = await switcher.detail().isItOn('FLAG_2'); - assert.isTrue(response.result); - }); - - it('should renew token when using throttle', async function () { - this.timeout(3000); - - // given API responses - // first API call - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 1), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // sync - - // test - Client.buildContext(contextSettings); - - const switcher = Client.getSwitcher() - .throttle(500) - .detail(); - - const spyPrepare = spy(switcher, 'prepare'); - - // 1st - calls remote API and stores result in cache - let isItOn = await switcher.isItOn('FLAG_3'); - assert.isTrue(isItOn.result); - assert.isUndefined(isItOn.metadata); - assert.equal(spyPrepare.callCount, 1); - - // 2nd - uses cached result - isItOn = await switcher.isItOn('FLAG_3'); - assert.isTrue(isItOn.result); - assert.isTrue(isItOn.metadata.cached); - assert.equal(spyPrepare.callCount, 1); - - // should call the remote API - token has expired - await sleep(2000); - - // given - given(fetchStub, 2, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 3, { json: () => generateResult(false), status: 200 }); // after token expires - - // 3rd - use cached result, asynchronous renew token and stores new result in cache - isItOn = await switcher.isItOn('FLAG_3'); - assert.isTrue(isItOn.result); - assert.equal(spyPrepare.callCount, 2); - - // 4th - uses cached result - await sleep(50); - isItOn = await switcher.isItOn('FLAG_3'); - assert.isFalse(isItOn.result); - assert.equal(spyPrepare.callCount, 2); - }); - - it('should flush executions from a specific switcher key', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 1), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // sync - - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher('FLAG_1').throttle(1000); - - // when - assert.isTrue(await switcher.isItOn()); - let switcherExecutions = ExecutionLogger.getByKey('FLAG_1'); - assert.equal(switcherExecutions.length, 1); - - // test - switcher.flushExecutions(); - switcherExecutions = ExecutionLogger.getByKey('FLAG_1'); - assert.equal(switcherExecutions.length, 0); - }); - - it('should not crash when async checkCriteria fails', async function () { - this.timeout(5000); - - // given API responses - // first API call - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // before token expires - - // test - let asyncErrorMessage = null; - Client.buildContext(contextSettings); - Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); - - const switcher = Client.getSwitcher(); - switcher.throttle(1000); - - assert.isTrue(await switcher.isItOn('FLAG_1')); // sync - assert.isTrue(await switcher.isItOn('FLAG_1')); // async - - // Next call should call the API again - valid token but crashes on checkCriteria - await sleep(1000); - assert.isNull(asyncErrorMessage); - - // given - given(fetchStub, 2, { status: 500 }); - - // test - assert.isTrue(await switcher.isItOn('FLAG_1')); // async - await assertUntilResolve(assert, () => asyncErrorMessage, - 'Something went wrong: [checkCriteria] failed with status 500'); - }); - - }); - - describe('force remote (hybrid):', function () { - - let fetchStub; - - const forceRemoteOptions = { - local: true, - snapshotLocation: './tests/snapshot/', - regexSafe: false - }; - - beforeEach(function() { - fetchStub = stub(FetchFacade, 'fetch'); - }); - - afterEach(function() { - fetchStub.restore(); - }); - - it('should return true - snapshot switcher is true', async function () { - Client.buildContext(contextSettings, forceRemoteOptions); - - const switcher = Client.getSwitcher(); - await Client.loadSnapshot(); - assert.isTrue(await switcher.isItOn('FF2FOR2030')); - }); - - it('should return false - same switcher return false when remote', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(false), status: 200 }); - - // test - Client.buildContext(contextSettings, forceRemoteOptions); - - const switcher = Client.getSwitcher(); - const executeRemoteCriteria = spy(switcher, 'executeRemoteCriteria'); - - await Client.loadSnapshot(); - assert.isFalse(await switcher.remote().isItOn('FF2FOR2030')); - assert.equal(executeRemoteCriteria.callCount, 1); - }); - - it('should return true - including reason and metadata', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateDetailedResult({ - result: true, - reason: 'Success', - metadata: { - user: 'user1', - } - }), status: 200 }); - - // test - Client.buildContext(contextSettings); - - const switcher = Client.getSwitcher(); - const detailedResult = await switcher.detail().isItOn('FF2FOR2030'); - assert.isTrue(detailedResult.result); - assert.equal(detailedResult.reason, 'Success'); - assert.equal(detailedResult.metadata.user, 'user1'); - }); - - it('should return error when local is not enabled', async function () { - Client.buildContext(contextSettings, { regexSafe: false, local: false }); - - const switcher = Client.getSwitcher(); - - assert.throws(() => switcher.remote().isItOn('FF2FOR2030'), - 'Local mode is not enabled'); - }); - - }); - - describe('check fail response (e2e):', function () { - - let fetchStub; - - beforeEach(function() { - fetchStub = stub(FetchFacade, 'fetch'); - }); - - afterEach(function() { - fetchStub.restore(); - }); - - it('should NOT be valid - API returned 429 (too many requests) at auth', async function () { - // given API responses - given(fetchStub, 0, { status: 429 }); - given(fetchStub, 1, { error: 'Too many requests', status: 429 }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - - await assertReject(assert, switcher.isItOn('FLAG_1'), 'Something went wrong: [auth] failed with status 429'); - }); - - it('should NOT be valid - API returned 429 (too many requests) at checkCriteria', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { error: 'Too many requests', status: 429 }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - - await assertReject(assert, switcher.isItOn('FLAG_1'), 'Something went wrong: [checkCriteria] failed with status 429'); - }); - - it('should use silent mode when fail to check switchers', async function() { - // given - given(fetchStub, 0, { status: 429 }); - - // test - Client.buildContext(contextSettings, { silentMode: '5m', regexSafe: false, snapshotLocation: './tests/snapshot/' }); - await Client.checkSwitchers(['FEATURE01', 'FEATURE02']).catch(e => { - assert.equal(e.message, 'Something went wrong: [FEATURE01,FEATURE02] not found'); - }); - - await assertResolve(assert, Client.checkSwitchers(['FF2FOR2021', 'FF2FOR2021'])); - }); - - it('should use silent mode when fail to check criteria', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { status: 429 }); // [POST@/criteria] - givenError(fetchStub, 2, { errno: 'ECONNREFUSED' }); // [GET@/check] used in the 2nd isItOn call - - // test - let asyncErrorMessage = null; - Client.buildContext(contextSettings, { silentMode: '1s', regexSafe: false, snapshotLocation: './tests/snapshot/' }); - Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); - - const switcher = Client.getSwitcher(); - - // assert silent mode being used while registering the error - await assertResolve(assert, switcher.isItOn('FF2FOR2021')); - await assertUntilResolve(assert, () => asyncErrorMessage, - 'Something went wrong: [checkCriteria] failed with status 429'); - - // assert silent mode being used in the next call - await sleep(1500); - await assertResolve(assert, switcher.isItOn('FF2FOR2021')); - }); - - }); - - describe('check criteria:', function () { - - let fetchStub; - - beforeEach(function() { - fetchStub = stub(FetchFacade, 'fetch'); - }); - - afterEach(function() { - fetchStub.restore(); - }); - - it('should be valid', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - - await switcher - .checkValue('User 1') - .checkNumeric('1') - .checkNetwork('192.168.0.1') - .checkDate('2019-12-01T08:30') - .checkTime('08:00') - .checkRegex(String.raw`\bUSER_[0-9]{1,2}\b`) - .checkPayload(JSON.stringify({ name: 'User 1' })) - .prepare('SWITCHER_MULTIPLE_INPUT'); - - assert.sameDeepMembers(switcher.input, [ - [ 'VALUE_VALIDATION', 'User 1' ], - [ 'NUMERIC_VALIDATION', '1' ], - [ 'NETWORK_VALIDATION', '192.168.0.1' ], - [ 'DATE_VALIDATION', '2019-12-01T08:30' ], - [ 'TIME_VALIDATION', '08:00' ], - [ 'REGEX_VALIDATION', String.raw`\bUSER_[0-9]{1,2}\b` ], - [ 'PAYLOAD_VALIDATION', '{"name":"User 1"}' ] - ]); - }); - - it('should NOT throw when switcher keys provided were configured properly', async function() { - // given - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - const response = { not_found: [] }; - given(fetchStub, 1, { json: () => response, status: 200 }); - - // test - Client.buildContext(contextSettings); - await assertResolve(assert, Client.checkSwitchers(['FEATURE01', 'FEATURE02'])); - }); - - it('should throw when switcher keys provided were not configured properly', async function() { - // given - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - const response = { not_found: ['FEATURE02'] }; - given(fetchStub, 1, { json: () => response, status: 200 }); - - // test - Client.buildContext(contextSettings); - await assertReject(assert, Client.checkSwitchers(['FEATURE01', 'FEATURE02']), - 'Something went wrong: [FEATURE02] not found'); - }); - - it('should throw when no switcher keys were provided', async function() { - // given - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { status: 422 }); - - // test - Client.buildContext(contextSettings); - await assertReject(assert, Client.checkSwitchers([]), - 'Something went wrong: [checkSwitchers] failed with status 422'); - }); - - it('should throw when switcher keys provided were invalid', async function() { - // given - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { errno: 'ERROR' }); - - // test - Client.buildContext(contextSettings); - await assertReject(assert, Client.checkSwitchers('FEATURE02'), - 'Something went wrong: [checkSwitchers] failed with status undefined'); - }); - - it('should throw when certPath is invalid', function() { - assert.throws(() => Client.buildContext(contextSettings, { certPath: 'invalid' }), - 'Invalid certificate path \'invalid\''); - }); - - it('should NOT throw when certPath is valid', function() { - assert.doesNotThrow(() => Client.buildContext(contextSettings, { certPath: './tests/helper/dummy-cert.pem' })); - }); - - it('should renew the token after expiration', async function () { - this.timeout(3000); - - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 1), status: 200 }); - - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - const spyPrepare = spy(switcher, 'prepare'); - - // Prepare the call generating the token - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); - await switcher.prepare('MY_FLAG'); - assert.equal(await switcher.isItOn(), true); - - // The program delay 2 secs later for the next call - await sleep(2000); - - // Prepare the stub to provide the new token - given(fetchStub, 2, { json: () => generateAuth('asdad12d2232d2323f', 1), status: 200 }); - - // In this time period the expiration time has reached, it should call prepare once again to renew the token - given(fetchStub, 3, { json: () => generateResult(false), status: 200 }); - assert.equal(await switcher.isItOn(), false); - assert.equal(spyPrepare.callCount, 2); - - // In the meantime another call is made by the time the token is still not expired, so there is no need to call prepare again - given(fetchStub, 4, { json: () => generateResult(false), status: 200 }); - assert.equal(await switcher.isItOn(), false); - assert.equal(spyPrepare.callCount, 2); - }); - - it('should be valid - when sending key without calling prepare', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - assert.isTrue(await switcher - .checkValue('User 1') - .checkNetwork('192.168.0.1') - .isItOn('MY_FLAG')); - }); - - it('should be valid - when preparing key and sending input strategy afterwards', async function () { - // given API responses - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - - await switcher.prepare('MY_FLAG'); - assert.isTrue(await switcher - .checkValue('User 1') - .checkNetwork('192.168.0.1') - .isItOn(undefined)); - }); - - it('should be invalid - Missing API url field', async function () { - // given - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - - // test - Client.buildContext({ url: undefined, apiKey: 'apiKey', domain: 'domain', component: 'component', environment: 'default' }); - let switcher = Client.getSwitcher(); - - await switcher - .checkValue('User 1') - .checkNetwork('192.168.0.1') - .prepare('MY_FLAG'); - - await assertReject(assert, switcher.isItOn(), 'Something went wrong: URL is required'); - }); - - it('should be invalid - Missing API Key field', async function () { - // given - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - - // test - Client.buildContext({ url: 'url', apiKey: undefined, domain: 'domain', component: 'component', environment: 'default' }); - let switcher = Client.getSwitcher(); - - await switcher - .checkValue('User 1') - .checkNetwork('192.168.0.1') - .prepare('MY_FLAG'); - - await assertReject(assert, switcher.isItOn(), 'Something went wrong: API Key is required'); - }); - - it('should be invalid - Missing key field', async function () { - // given - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(undefined) }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - await switcher - .checkValue('User 1') - .checkNetwork('192.168.0.1') - .prepare(undefined); - - await assertReject(assert, switcher.isItOn(), 'Something went wrong: Missing key field'); - }); - - it('should be invalid - Missing component field', async function () { - // given - given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(undefined) }); - - // test - Client.buildContext({ url: 'url', apiKey: 'apiKey', domain: 'domain', component: undefined, environment: 'default' }); - let switcher = Client.getSwitcher(); - - await assertReject(assert, switcher - .checkValue('User 1') - .checkNetwork('192.168.0.1') - .isItOn('MY_FLAG'), 'Something went wrong: Component is required'); - }); - - it('should be invalid - Missing token field', async function () { - // given - given(fetchStub, 0, { json: () => generateAuth(undefined, 1), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(undefined) }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - - await assertReject(assert, switcher - .checkValue('User 1') - .checkNetwork('192.168.0.1') - .isItOn('MY_FLAG'), 'Something went wrong: Missing token field'); - }); - - it('should run in silent mode', async function () { - this.timeout(6000); - - // setup context to read the snapshot in case the API does not respond - Client.buildContext(contextSettings, { - snapshotLocation: './tests/snapshot/', - regexSafe: false, - silentMode: '2s' - }); - - let switcher = Client.getSwitcher(); - const spyRemote = spy(switcher, 'executeRemoteCriteria'); - - // First attempt to reach the API - Since it's configured to use silent mode, it should return true (according to the snapshot) - givenError(fetchStub, 0, { errno: 'ECONNREFUSED' }); - assert.isTrue(await switcher.isItOn('FF2FOR2030')); - - // The call below is in silent mode. It is getting the configuration from the local snapshot again - await sleep(500); - assert.isTrue(await switcher.isItOn()); - - // As the silent mode was configured to retry after 2 seconds, it's still in time, - // therefore, remote call was not yet invoked - assert.equal(spyRemote.callCount, 0); - await sleep(3000); - - // Setup the remote mocked response and made it to return false just to make sure it's not fetching from the snapshot - given(fetchStub, 1, { status: 200 }); - given(fetchStub, 2, { json: () => generateAuth('[auth_token]', 10), status: 200 }); - given(fetchStub, 3, { json: () => generateResult(false), status: 200 }); - - // Auth is async when silent mode is enabled to prevent blocking the execution while the API is not available - assert.isTrue(await switcher.isItOn()); - - // Now the remote call was invoked, so it should return false - await sleep(500); - assert.isFalse(await switcher.isItOn()); - assert.equal(spyRemote.callCount, 1); - }); - - it('should throw error if not in silent mode', async function () { - // given - fetchStub.restore(); - fetchStub = stub(FetchFacade, 'fetch'); - throws(fetchStub, { errno: 'ECONNREFUSED' }); - - // test - Client.buildContext(contextSettings); - let switcher = Client.getSwitcher(); - - await assertReject(assert, switcher.isItOn('FF2FOR2030'), - 'Something went wrong: Connection has been refused - ECONNREFUSED'); - }); - - it('should run in silent mode when API is unavailable', async function () { - // given: API unavailable - given(fetchStub, 0, { status: 503 }); - - // test - Client.buildContext(contextSettings, { - snapshotLocation: './tests/snapshot/', - regexSafe: false, - silentMode: '5m' - }); - - let switcher = Client.getSwitcher(); - assert.isTrue(await switcher.isItOn('FF2FOR2030')); - }); - - }); - -}); \ No newline at end of file diff --git a/tests/switcher-integrated.test.js b/tests/switcher-integrated.test.js index 210399a..58a5e6a 100644 --- a/tests/switcher-integrated.test.js +++ b/tests/switcher-integrated.test.js @@ -4,6 +4,13 @@ import { Client } from '../switcher-client.js'; describe('Switcher integrated test', () => { + const contextSettings = { + url: 'https://api.switcherapi.com', + apiKey: process.env.SWITCHER_API_KEY, + domain: 'Switcher API', + component: 'switcher-client-js' + }; + it('should hit remote API and return Switcher response', async function () { this.timeout(3000); @@ -12,18 +19,49 @@ describe('Switcher integrated test', () => { } // given context build - Client.buildContext({ - url: 'https://api.switcherapi.com', - apiKey: process.env.SWITCHER_API_KEY, - domain: 'Switcher API', - component: 'switcher-client-js' - }); + Client.buildContext(contextSettings); + + // test + const switcher = Client.getSwitcher().detail(); + const result = await switcher.isItOn('CLIENT_JS_FEATURE'); + + assert.isNotNull(result); + }); + + it('should load snapshot from remote API', async function () { + this.timeout(3000); + + if (!process.env.SWITCHER_API_KEY) { + this.skip(); + } + + // given context build + Client.buildContext(contextSettings, { local: true }); // test + await Client.loadSnapshot({ fetchRemote: true }); + const switcher = Client.getSwitcher().detail(); const result = await switcher.isItOn('CLIENT_JS_FEATURE'); assert.isNotNull(result); + assert.isAbove(Client.snapshotVersion, 0); + }); + + it('should check Switcher availability', async function () { + this.timeout(3000); + + if (!process.env.SWITCHER_API_KEY) { + this.skip(); + } + + // given context build + Client.buildContext(contextSettings); + + // test + await Client.checkSwitchers(['CLIENT_JS_FEATURE']); + + assert.isTrue(true); }); }); \ No newline at end of file diff --git a/tests/switcher-client.test.js b/tests/switcher-local.test.js similarity index 74% rename from tests/switcher-client.test.js rename to tests/switcher-local.test.js index 6a41251..e88c755 100644 --- a/tests/switcher-client.test.js +++ b/tests/switcher-local.test.js @@ -2,9 +2,9 @@ import { rmdir } from 'node:fs'; import { assert } from 'chai'; import { spy } from 'sinon'; -import { Client, SwitcherContext, SwitcherOptions } from '../switcher-client.js'; +import { Client } from '../switcher-client.js'; import { StrategiesType } from '../src/lib/snapshot.js'; -import { assertReject, assertResolve, deleteGeneratedSnapshot } from './helper/utils.js'; +import { assertReject, assertResolve } from './helper/utils.js'; import TimedMatch from '../src/lib/utils/timed-match/index.js'; let switcher; @@ -24,7 +24,7 @@ const options = { regexMaxTimeLimit: 500 }; -describe('E2E test - Client local #1:', function () { +describe('E2E test - Client local:', function () { this.beforeAll(async function () { Client.buildContext(contextSettings, options); @@ -250,7 +250,7 @@ describe('E2E test - Client local #1:', function () { }); -describe('E2E test - Client local #2:', function () { +describe('E2E test - Client local - domain disabled:', function () { this.beforeAll(async function () { Client.buildContext({ domain: contextSettings.domain, environment: 'default_disabled' }, options); @@ -393,127 +393,6 @@ describe('E2E test - Client local from cache:', function () { }); }); -describe('E2E test - Client testing (assume) feature:', function () { - this.beforeAll(async function () { - Client.buildContext(contextSettings, options); - - await Client.loadSnapshot(); - switcher = Client.getSwitcher(); - }); - - this.afterAll(function () { - Client.unloadSnapshot(); - TimedMatch.terminateWorker(); - }); - - this.beforeEach(function () { - Client.clearLogger(); - Client.forget('FF2FOR2020'); - switcher = Client.getSwitcher(); - }); - - it('should replace the result of isItOn with Client.assume', async function () { - await switcher.prepare('DUMMY'); - - Client.assume('DUMMY').true(); - assert.isTrue(await switcher.isItOn()); - - Client.assume('DUMMY').false(); - assert.isFalse(await switcher.isItOn()); - }); - - it('should be valid assuming key to be false and then forgetting it', async function () { - await switcher - .checkValue('Japan') - .checkNetwork('10.0.0.3') - .prepare('FF2FOR2020'); - - assert.isTrue(await switcher.isItOn()); - Client.assume('FF2FOR2020').false(); - assert.isFalse(await switcher.isItOn()); - - Client.forget('FF2FOR2020'); - assert.isTrue(await switcher.isItOn()); - }); - - it('should be valid assuming key to be false - with details', async function () { - Client.assume('FF2FOR2020').false(); - const { result, reason } = await switcher.detail().isItOn('FF2FOR2020'); - - assert.isFalse(result); - assert.equal(reason, 'Forced to false'); - }); - - it('should be valid assuming key to be false - with metadata', async function () { - Client.assume('FF2FOR2020').false().withMetadata({ value: 'something' }); - const { result, reason, metadata } = await switcher.detail().isItOn('FF2FOR2020'); - - assert.isFalse(result); - assert.equal(reason, 'Forced to false'); - assert.deepEqual(metadata, { value: 'something' }); - }); - - it('should be valid assuming unknown key to be true and throw error when forgetting', async function () { - await switcher - .checkValue('Japan') - .checkNetwork('10.0.0.3') - .prepare('UNKNOWN'); - - Client.assume('UNKNOWN').true(); - assert.isTrue(await switcher.isItOn()); - - Client.forget('UNKNOWN'); - assert.throws(() => switcher.isItOn(), Error, 'Something went wrong: {"error":"Unable to load a key UNKNOWN"}'); - }); - - it('should return true using Client.assume only when Strategy input values match', async function () { - await switcher - .checkValue('Canada') // result to be false - .checkNetwork('10.0.0.3') - .prepare('FF2FOR2020'); - - assert.isFalse(await switcher.isItOn()); - Client.assume('FF2FOR2020').true() - .when(StrategiesType.VALUE, 'Canada') // manipulate the condition to result to true - .and(StrategiesType.NETWORK, '10.0.0.3'); - - assert.isTrue(await switcher.isItOn()); - }); - - it('should NOT return true using Client.assume when Strategy input values does not match', async function () { - await switcher - .checkValue('Japan') - .checkNetwork('10.0.0.3') - .prepare('FF2FOR2020'); - - assert.isTrue(await switcher.isItOn()); - Client.assume('FF2FOR2020').true() - .when(StrategiesType.VALUE, ['Brazil', 'Japan']) - .and(StrategiesType.NETWORK, ['10.0.0.4', '192.168.0.1']); - - assert.isFalse(await switcher.isItOn()); - }); - -}); - -describe('Type placeholders:', function () { - - this.afterAll(function () { - deleteGeneratedSnapshot('./generated-snapshots'); - }); - - it('should check exported types', function () { - const switcherContext = SwitcherContext.build(); - const switcherOptions = SwitcherOptions.build(); - - assert.isTrue(switcherContext instanceof SwitcherContext); - assert.isTrue(switcherOptions instanceof SwitcherOptions); - - assert.isNotNull(switcherContext); - assert.isNotNull(switcherOptions); - }); -}); - describe('E2E test - Restrict Relay:', function () { this.beforeAll(async function () { Client.buildContext(contextSettings, options); diff --git a/tests/switcher-remote.test.js b/tests/switcher-remote.test.js new file mode 100644 index 0000000..011d504 --- /dev/null +++ b/tests/switcher-remote.test.js @@ -0,0 +1,346 @@ +import { assert } from 'chai'; +import { stub, spy } from 'sinon'; +import { unwatchFile } from 'node:fs'; + +import FetchFacade from '../src/lib/utils/fetchFacade.js'; +import ExecutionLogger from '../src/lib/utils/executionLogger.js'; +import { Client } from '../switcher-client.js'; +import { given, generateAuth, generateResult, assertReject, + assertResolve, generateDetailedResult, sleep } from './helper/utils.js'; + +describe('Switcher Remote:', function () { + + let contextSettings; + + this.afterAll(function() { + unwatchFile('./tests/snapshot/default.json'); + }); + + this.beforeEach(function() { + Client.testMode(); + + contextSettings = { + apiKey: '[api_key]', + domain: 'Business', + component: 'business-service', + environment: 'default', + url: 'http://localhost:3000' + }; + }); + + describe('check criteria:', function () { + + let fetchStub; + + beforeEach(function() { + fetchStub = stub(FetchFacade, 'fetch'); + ExecutionLogger.clearLogger(); + }); + + afterEach(function() { + fetchStub.restore(); + }); + + it('should be valid (simple)', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + + await switcher.prepare('FLAG_1'); + assert.isTrue(await switcher.isItOn()); + }); + + it('should be valid - using persisted switcher key', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); + + // test + Client.buildContext(contextSettings); + + // Get switcher multiple times with the same key + const switcher1 = Client.getSwitcher('MY_PERSISTED_SWITCHER_KEY'); + const switcher2 = Client.getSwitcher('MY_PERSISTED_SWITCHER_KEY'); + const differentSwitcher = Client.getSwitcher('DIFFERENT_KEY'); + + // Verify they are the same instance (persisted) + assert.strictEqual(switcher1, switcher2, 'Switcher instances should be the same (persisted)'); + assert.notStrictEqual(switcher1, differentSwitcher, 'Different keys should create different instances'); + assert.isTrue(await switcher1.isItOn()); + }); + + it('should NOT throw error when default result is provided using remote', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { error: 'ERROR', status: 404 }); + + // test + let asyncErrorMessage = null; + Client.buildContext(contextSettings); + Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); + let switcher = Client.getSwitcher().defaultResult(true); + + assert.isTrue(await switcher.isItOn('UNKNOWN_FEATURE')); + assert.equal(asyncErrorMessage, 'Something went wrong: [checkCriteria] failed with status 404'); + }); + + it('should NOT be valid - API returned 429 (too many requests)', async function () { + // given API responses + given(fetchStub, 0, { error: 'Too many requests', status: 429 }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + + await assertReject(assert, switcher.isItOn('FLAG_1'), 'Something went wrong: [auth] failed with status 429'); + }); + + it('should return true - including reason and metadata', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateDetailedResult({ + result: true, + reason: 'Success', + metadata: { + user: 'user1', + } + }), status: 200 }); + + // test + Client.buildContext(contextSettings); + + const switcher = Client.getSwitcher(); + const detailedResult = await switcher.detail().isItOn('FF2FOR2030'); + assert.isTrue(detailedResult.result); + assert.equal(detailedResult.reason, 'Success'); + assert.equal(detailedResult.metadata.user, 'user1'); + }); + + it('should be valid (with all strategies)', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + + await switcher + .checkValue('User 1') + .checkNumeric('1') + .checkNetwork('192.168.0.1') + .checkDate('2019-12-01T08:30') + .checkTime('08:00') + .checkRegex(String.raw`\bUSER_[0-9]{1,2}\b`) + .checkPayload(JSON.stringify({ name: 'User 1' })) + .prepare('SWITCHER_MULTIPLE_INPUT'); + + assert.sameDeepMembers(switcher.input, [ + [ 'VALUE_VALIDATION', 'User 1' ], + [ 'NUMERIC_VALIDATION', '1' ], + [ 'NETWORK_VALIDATION', '192.168.0.1' ], + [ 'DATE_VALIDATION', '2019-12-01T08:30' ], + [ 'TIME_VALIDATION', '08:00' ], + [ 'REGEX_VALIDATION', String.raw`\bUSER_[0-9]{1,2}\b` ], + [ 'PAYLOAD_VALIDATION', '{"name":"User 1"}' ] + ]); + }); + + it('should NOT throw when switcher keys provided were configured properly', async function() { + // given + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + const response = { not_found: [] }; + given(fetchStub, 1, { json: () => response, status: 200 }); + + // test + Client.buildContext(contextSettings); + await assertResolve(assert, Client.checkSwitchers(['FEATURE01', 'FEATURE02'])); + }); + + it('should throw when switcher keys provided were not configured properly', async function() { + // given + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + const response = { not_found: ['FEATURE02'] }; + given(fetchStub, 1, { json: () => response, status: 200 }); + + // test + Client.buildContext(contextSettings); + await assertReject(assert, Client.checkSwitchers(['FEATURE01', 'FEATURE02']), + 'Something went wrong: [FEATURE02] not found'); + }); + + it('should throw when no switcher keys were provided', async function() { + // given + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { status: 422 }); + + // test + Client.buildContext(contextSettings); + await assertReject(assert, Client.checkSwitchers([]), + 'Something went wrong: [checkSwitchers] failed with status 422'); + }); + + it('should throw when switcher keys provided were invalid', async function() { + // given + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { errno: 'ERROR' }); + + // test + Client.buildContext(contextSettings); + await assertReject(assert, Client.checkSwitchers('FEATURE02'), + 'Something went wrong: [checkSwitchers] failed with status undefined'); + }); + + it('should renew the token after expiration', async function () { + this.timeout(3000); + + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 1), status: 200 }); + + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + const spyPrepare = spy(switcher, 'prepare'); + + // Prepare the call generating the token + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); + await switcher.prepare('MY_FLAG'); + assert.equal(await switcher.isItOn(), true); + + // The program delay 2 secs later for the next call + await sleep(2000); + + // Prepare the stub to provide the new token + given(fetchStub, 2, { json: () => generateAuth('asdad12d2232d2323f', 1), status: 200 }); + + // In this time period the expiration time has reached, it should call prepare once again to renew the token + given(fetchStub, 3, { json: () => generateResult(false), status: 200 }); + assert.equal(await switcher.isItOn(), false); + assert.equal(spyPrepare.callCount, 2); + + // In the meantime another call is made by the time the token is still not expired, so there is no need to call prepare again + given(fetchStub, 4, { json: () => generateResult(false), status: 200 }); + assert.equal(await switcher.isItOn(), false); + assert.equal(spyPrepare.callCount, 2); + }); + + it('should be valid - when sending key without calling prepare', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + assert.isTrue(await switcher + .checkValue('User 1') + .checkNetwork('192.168.0.1') + .isItOn('MY_FLAG')); + }); + + it('should be valid - when preparing key and sending input strategy afterwards', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + + await switcher.prepare('MY_FLAG'); + assert.isTrue(await switcher + .checkValue('User 1') + .checkNetwork('192.168.0.1') + .isItOn(undefined)); + }); + + }); + + describe('force remote (hybrid):', function () { + + let fetchStub; + + const forceRemoteOptions = { + local: true, + snapshotLocation: './tests/snapshot/', + regexSafe: false + }; + + beforeEach(function() { + fetchStub = stub(FetchFacade, 'fetch'); + }); + + afterEach(function() { + fetchStub.restore(); + }); + + it('should return false - same switcher return false when remote', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(false), status: 200 }); + + // test + Client.buildContext(contextSettings, forceRemoteOptions); + + const switcher = Client.getSwitcher(); + const executeRemoteCriteria = spy(switcher, 'executeRemoteCriteria'); + + await Client.loadSnapshot(); + assert.isTrue(await switcher.isItOn('FF2FOR2030')); // snapshot value is true + assert.isFalse(await switcher.remote().isItOn('FF2FOR2030')); // remote value is false + assert.equal(executeRemoteCriteria.callCount, 1); + }); + + it('should return error when local is not enabled', async function () { + Client.buildContext(contextSettings, { regexSafe: false, local: false }); + + const switcher = Client.getSwitcher(); + + assert.throws(() => switcher.remote().isItOn('FF2FOR2030'), + 'Local mode is not enabled'); + }); + + }); + + describe('check fail response:', function () { + + let fetchStub; + + beforeEach(function() { + fetchStub = stub(FetchFacade, 'fetch'); + }); + + afterEach(function() { + fetchStub.restore(); + }); + + it('should NOT be valid - API returned 429 (too many requests) at auth', async function () { + // given API responses + given(fetchStub, 0, { status: 429 }); + given(fetchStub, 1, { error: 'Too many requests', status: 429 }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + + await assertReject(assert, switcher.isItOn('FLAG_1'), 'Something went wrong: [auth] failed with status 429'); + }); + + it('should NOT be valid - API returned 429 (too many requests) at checkCriteria', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { error: 'Too many requests', status: 429 }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + + await assertReject(assert, switcher.isItOn('FLAG_1'), 'Something went wrong: [checkCriteria] failed with status 429'); + }); + + }); + +}); \ No newline at end of file diff --git a/tests/switcher-silent-mode.test.js b/tests/switcher-silent-mode.test.js new file mode 100644 index 0000000..da1a191 --- /dev/null +++ b/tests/switcher-silent-mode.test.js @@ -0,0 +1,143 @@ +import { assert } from 'chai'; +import { stub, spy } from 'sinon'; +import { unwatchFile } from 'node:fs'; + +import FetchFacade from '../src/lib/utils/fetchFacade.js'; +import ExecutionLogger from '../src/lib/utils/executionLogger.js'; +import { Client } from '../switcher-client.js'; +import { given, givenError, throws, generateAuth, generateResult, assertReject, + assertResolve, sleep, assertUntilResolve } from './helper/utils.js'; + +describe('Switcher Silent Mode:', function () { + + let contextSettings; + let fetchStub; + + this.afterAll(function() { + unwatchFile('./tests/snapshot/default.json'); + }); + + beforeEach(function() { + fetchStub = stub(FetchFacade, 'fetch'); + ExecutionLogger.clearLogger(); + Client.testMode(); + + contextSettings = { + apiKey: '[api_key]', + domain: 'Business', + component: 'business-service', + environment: 'default', + url: 'http://localhost:3000' + }; + }); + + afterEach(function() { + fetchStub.restore(); + }); + + it('should run in silent mode', async function () { + this.timeout(6000); + + // setup context to read the snapshot in case the API does not respond + Client.buildContext(contextSettings, { + snapshotLocation: './tests/snapshot/', + regexSafe: false, + silentMode: '2s' + }); + + let switcher = Client.getSwitcher(); + const spyRemote = spy(switcher, 'executeRemoteCriteria'); + + // First attempt to reach the API - Since it's configured to use silent mode, it should return true (according to the snapshot) + givenError(fetchStub, 0, { errno: 'ECONNREFUSED' }); + assert.isTrue(await switcher.isItOn('FF2FOR2030')); + + // The call below is in silent mode. It is getting the configuration from the local snapshot again + await sleep(500); + assert.isTrue(await switcher.isItOn()); + + // As the silent mode was configured to retry after 2 seconds, it's still in time, + // therefore, remote call was not yet invoked + assert.equal(spyRemote.callCount, 0); + await sleep(3000); + + // Setup the remote mocked response and made it to return false just to make sure it's not fetching from the snapshot + given(fetchStub, 1, { status: 200 }); + given(fetchStub, 2, { json: () => generateAuth('[auth_token]', 10), status: 200 }); + given(fetchStub, 3, { json: () => generateResult(false), status: 200 }); + + // Auth is async when silent mode is enabled to prevent blocking the execution while the API is not available + assert.isTrue(await switcher.isItOn()); + + // Now the remote call was invoked, so it should return false + await sleep(500); + assert.isFalse(await switcher.isItOn()); + assert.equal(spyRemote.callCount, 1); + }); + + it('should throw error if not in silent mode', async function () { + // given + fetchStub.restore(); + fetchStub = stub(FetchFacade, 'fetch'); + throws(fetchStub, { errno: 'ECONNREFUSED' }); + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + + await assertReject(assert, switcher.isItOn('FF2FOR2030'), + 'Something went wrong: Connection has been refused - ECONNREFUSED'); + }); + + it('should run in silent mode when API is unavailable', async function () { + // given: API unavailable + given(fetchStub, 0, { status: 503 }); + + // test + Client.buildContext(contextSettings, { + snapshotLocation: './tests/snapshot/', + regexSafe: false, + silentMode: '5m' + }); + + let switcher = Client.getSwitcher(); + assert.isTrue(await switcher.isItOn('FF2FOR2030')); + }); + + it('should use silent mode when fail to check switchers', async function() { + // given + given(fetchStub, 0, { status: 429 }); + + // test + Client.buildContext(contextSettings, { silentMode: '5m', regexSafe: false, snapshotLocation: './tests/snapshot/' }); + await Client.checkSwitchers(['FEATURE01', 'FEATURE02']).catch(e => { + assert.equal(e.message, 'Something went wrong: [FEATURE01,FEATURE02] not found'); + }); + + await assertResolve(assert, Client.checkSwitchers(['FF2FOR2021', 'FF2FOR2021'])); + }); + + it('should use silent mode when fail to check criteria', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { status: 429 }); // [POST@/criteria] + givenError(fetchStub, 2, { errno: 'ECONNREFUSED' }); // [GET@/check] used in the 2nd isItOn call + + // test + let asyncErrorMessage = null; + Client.buildContext(contextSettings, { silentMode: '1s', regexSafe: false, snapshotLocation: './tests/snapshot/' }); + Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); + + const switcher = Client.getSwitcher(); + + // assert silent mode being used while registering the error + await assertResolve(assert, switcher.isItOn('FF2FOR2021')); + await assertUntilResolve(assert, () => asyncErrorMessage, + 'Something went wrong: [checkCriteria] failed with status 429'); + + // assert silent mode being used in the next call + await sleep(1500); + await assertResolve(assert, switcher.isItOn('FF2FOR2021')); + }); + +}); \ No newline at end of file diff --git a/tests/switcher-stub.test.js b/tests/switcher-stub.test.js new file mode 100644 index 0000000..74fa9c0 --- /dev/null +++ b/tests/switcher-stub.test.js @@ -0,0 +1,125 @@ +import { assert } from 'chai'; + +import { Client } from '../switcher-client.js'; +import { StrategiesType } from '../src/lib/snapshot.js'; +import TimedMatch from '../src/lib/utils/timed-match/index.js'; + +let switcher; +const contextSettings = { + apiKey: '[api_key]', + domain: 'Business', + component: 'business-service', + environment: 'default', + url: 'http://localhost:3000' +}; + +const options = { + snapshotLocation: './tests/snapshot/', + local: true, + logger: true, + regexMaxBlackList: 1, + regexMaxTimeLimit: 500 +}; + +describe('E2E test - Client testing (stub) feature:', function () { + this.beforeAll(async function () { + Client.buildContext(contextSettings, options); + + await Client.loadSnapshot(); + switcher = Client.getSwitcher(); + }); + + this.afterAll(function () { + Client.unloadSnapshot(); + TimedMatch.terminateWorker(); + }); + + this.beforeEach(function () { + Client.clearLogger(); + Client.forget('FF2FOR2020'); + switcher = Client.getSwitcher(); + }); + + it('should replace the result of isItOn with Client.assume', async function () { + await switcher.prepare('DUMMY'); + + Client.assume('DUMMY').true(); + assert.isTrue(await switcher.isItOn()); + + Client.assume('DUMMY').false(); + assert.isFalse(await switcher.isItOn()); + }); + + it('should be valid assuming key to be false and then forgetting it', async function () { + await switcher + .checkValue('Japan') + .checkNetwork('10.0.0.3') + .prepare('FF2FOR2020'); + + assert.isTrue(await switcher.isItOn()); + Client.assume('FF2FOR2020').false(); + assert.isFalse(await switcher.isItOn()); + + Client.forget('FF2FOR2020'); + assert.isTrue(await switcher.isItOn()); + }); + + it('should be valid assuming key to be false - with details', async function () { + Client.assume('FF2FOR2020').false(); + const { result, reason } = await switcher.detail().isItOn('FF2FOR2020'); + + assert.isFalse(result); + assert.equal(reason, 'Forced to false'); + }); + + it('should be valid assuming key to be false - with metadata', async function () { + Client.assume('FF2FOR2020').false().withMetadata({ value: 'something' }); + const { result, reason, metadata } = await switcher.detail().isItOn('FF2FOR2020'); + + assert.isFalse(result); + assert.equal(reason, 'Forced to false'); + assert.deepEqual(metadata, { value: 'something' }); + }); + + it('should be valid assuming unknown key to be true and throw error when forgetting', async function () { + await switcher + .checkValue('Japan') + .checkNetwork('10.0.0.3') + .prepare('UNKNOWN'); + + Client.assume('UNKNOWN').true(); + assert.isTrue(await switcher.isItOn()); + + Client.forget('UNKNOWN'); + assert.throws(() => switcher.isItOn(), Error, 'Something went wrong: {"error":"Unable to load a key UNKNOWN"}'); + }); + + it('should return true using Client.assume only when Strategy input values match', async function () { + await switcher + .checkValue('Canada') // result to be false + .checkNetwork('10.0.0.3') + .prepare('FF2FOR2020'); + + assert.isFalse(await switcher.isItOn()); + Client.assume('FF2FOR2020').true() + .when(StrategiesType.VALUE, 'Canada') // manipulate the condition to result to true + .and(StrategiesType.NETWORK, '10.0.0.3'); + + assert.isTrue(await switcher.isItOn()); + }); + + it('should NOT return true using Client.assume when Strategy input values does not match', async function () { + await switcher + .checkValue('Japan') + .checkNetwork('10.0.0.3') + .prepare('FF2FOR2020'); + + assert.isTrue(await switcher.isItOn()); + Client.assume('FF2FOR2020').true() + .when(StrategiesType.VALUE, ['Brazil', 'Japan']) + .and(StrategiesType.NETWORK, ['10.0.0.4', '192.168.0.1']); + + assert.isFalse(await switcher.isItOn()); + }); + +}); diff --git a/tests/switcher-throttle.test.js b/tests/switcher-throttle.test.js new file mode 100644 index 0000000..8cdcda1 --- /dev/null +++ b/tests/switcher-throttle.test.js @@ -0,0 +1,183 @@ +import { assert } from 'chai'; +import { stub, spy } from 'sinon'; +import { unwatchFile } from 'node:fs'; + +import FetchFacade from '../src/lib/utils/fetchFacade.js'; +import ExecutionLogger from '../src/lib/utils/executionLogger.js'; +import { Client } from '../switcher-client.js'; +import { given, generateAuth, generateResult, + sleep, assertUntilResolve } from './helper/utils.js'; + +describe('Switcher Throttle:', function () { + + let fetchStub; + let contextSettings; + + this.afterAll(function() { + unwatchFile('./tests/snapshot/default.json'); + }); + + afterEach(function() { + fetchStub.restore(); + }); + + beforeEach(function() { + fetchStub = stub(FetchFacade, 'fetch'); + ExecutionLogger.clearLogger(); + Client.testMode(); + + contextSettings = { + apiKey: '[api_key]', + domain: 'Business', + component: 'business-service', + environment: 'default', + url: 'http://localhost:3000' + }; + }); + + it('should be valid - throttle', async function () { + this.timeout(2000); + + // given API responses + // first API call + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // sync + given(fetchStub, 2, { json: () => generateResult(true), status: 200 }); // async + + // test + Client.buildContext(contextSettings); + let switcher = Client.getSwitcher(); + switcher.throttle(1000); + + const spyExecutionLogger = spy(ExecutionLogger, 'add'); + + assert.isTrue(await switcher.isItOn('FLAG_1')); // sync + assert.isTrue(await switcher.isItOn('FLAG_1')); // async + await sleep(500); // wait resolve async Promise + + assert.equal(spyExecutionLogger.callCount, 1); + }); + + it('should be valid - throttle - with details', async function () { + this.timeout(3000); + + // given API responses + // first API call + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // sync + given(fetchStub, 2, { json: () => generateResult(true), status: 200 }); // async + + // test + Client.buildContext(contextSettings); + + let switcher = Client.getSwitcher(); + switcher.throttle(1000); + + // first API call - stores result in cache + await switcher.isItOn('FLAG_2'); + + // first async API call + const response = await switcher.detail().isItOn('FLAG_2'); + assert.isTrue(response.result); + }); + + it('should renew token when using throttle', async function () { + this.timeout(3000); + + // given API responses + // first API call + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 1), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // sync + + // test + Client.buildContext(contextSettings); + + const switcher = Client.getSwitcher() + .throttle(500) + .detail(); + + const spyPrepare = spy(switcher, 'prepare'); + + // 1st - calls remote API and stores result in cache + let isItOn = await switcher.isItOn('FLAG_3'); + assert.isTrue(isItOn.result); + assert.isUndefined(isItOn.metadata); + assert.equal(spyPrepare.callCount, 1); + + // 2nd - uses cached result + isItOn = await switcher.isItOn('FLAG_3'); + assert.isTrue(isItOn.result); + assert.isTrue(isItOn.metadata.cached); + assert.equal(spyPrepare.callCount, 1); + + // should call the remote API - token has expired + await sleep(2000); + + // given + given(fetchStub, 2, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 3, { json: () => generateResult(false), status: 200 }); // after token expires + + // 3rd - use cached result, asynchronous renew token and stores new result in cache + isItOn = await switcher.isItOn('FLAG_3'); + assert.isTrue(isItOn.result); + assert.equal(spyPrepare.callCount, 2); + + // 4th - uses cached result + await sleep(50); + isItOn = await switcher.isItOn('FLAG_3'); + assert.isFalse(isItOn.result); + assert.equal(spyPrepare.callCount, 2); + }); + + it('should flush executions from a specific switcher key', async function () { + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 1), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // sync + + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher('FLAG_1').throttle(1000); + + // when + assert.isTrue(await switcher.isItOn()); + let switcherExecutions = ExecutionLogger.getByKey('FLAG_1'); + assert.equal(switcherExecutions.length, 1); + + // test + switcher.flushExecutions(); + switcherExecutions = ExecutionLogger.getByKey('FLAG_1'); + assert.equal(switcherExecutions.length, 0); + }); + + it('should not crash when async checkCriteria fails', async function () { + this.timeout(5000); + + // given API responses + // first API call + given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // before token expires + + // test + let asyncErrorMessage = null; + Client.buildContext(contextSettings); + Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); + + const switcher = Client.getSwitcher(); + switcher.throttle(1000); + + assert.isTrue(await switcher.isItOn('FLAG_1')); // sync + assert.isTrue(await switcher.isItOn('FLAG_1')); // async + + // Next call should call the API again - valid token but crashes on checkCriteria + await sleep(1000); + assert.isNull(asyncErrorMessage); + + // given + given(fetchStub, 2, { status: 500 }); + + // test + assert.isTrue(await switcher.isItOn('FLAG_1')); // async + await assertUntilResolve(assert, () => asyncErrorMessage, + 'Something went wrong: [checkCriteria] failed with status 500'); + }); + +}); \ No newline at end of file diff --git a/tests/switcher-type.test.js b/tests/switcher-type.test.js new file mode 100644 index 0000000..7590b1a --- /dev/null +++ b/tests/switcher-type.test.js @@ -0,0 +1,22 @@ +import { assert } from 'chai'; + +import { SwitcherContext, SwitcherOptions } from '../switcher-client.js'; +import { deleteGeneratedSnapshot } from './helper/utils.js'; + +describe('Type placeholders:', function () { + + this.afterAll(function () { + deleteGeneratedSnapshot('./generated-snapshots'); + }); + + it('should check exported types', function () { + const switcherContext = SwitcherContext.build(); + const switcherOptions = SwitcherOptions.build(); + + assert.isTrue(switcherContext instanceof SwitcherContext); + assert.isTrue(switcherOptions instanceof SwitcherOptions); + + assert.isNotNull(switcherContext); + assert.isNotNull(switcherOptions); + }); +});