diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index be7d10b..2c88889 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM denoland/deno:2.6.4 +FROM denoland/deno:2.7.7 # Install tools RUN apt-get update && \ diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 1a1dc99..962f1a0 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -17,10 +17,10 @@ jobs: with: fetch-depth: 0 - - name: Setup Deno v2.6.4 + - name: Setup Deno v2.7.7 uses: denoland/setup-deno@v2 with: - deno-version: v2.6.4 + deno-version: v2.7.7 - name: Setup LCOV run: sudo apt install -y lcov @@ -45,7 +45,7 @@ jobs: strategy: fail-fast: false matrix: - deno-version: [v1.46.3, v2.6.4] + deno-version: [v1.46.3, v2.7.7] os: [ ubuntu-latest, windows-latest ] runs-on: ${{ matrix.os }} diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 0614086..4a8bd67 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -33,10 +33,10 @@ jobs: ref: ${{ steps.pr.outputs.head_sha }} fetch-depth: 0 - - name: Setup Deno v2.6.4 + - name: Setup Deno v2.7.7 uses: denoland/setup-deno@v2 with: - deno-version: v2.6.4 + deno-version: v2.7.7 - name: Setup LCOV run: sudo apt install -y lcov diff --git a/README.md b/README.md index e48e7e8..2023f4d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ A Deno SDK for Switcher API ```bash --allow-read --allow-write --allow-net ``` + (*) add `--unstable-http` when using Deno v1.4x with custom certificates ### Installation diff --git a/deno.jsonc b/deno.jsonc index de987dd..ed3ccf3 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -6,8 +6,8 @@ "cache-reload": "deno cache --reload --lock=deno.lock mod.ts", "fmt": "deno fmt mod.ts src/ --options-single-quote --options-line-width=120 --check", "fmt:fix": "deno fmt mod.ts src/ --options-single-quote --options-line-width=120", - "test": "deno test --allow-read --allow-net --allow-write --allow-import --coverage=coverage", - "test-v1": "deno test --allow-read --allow-net --allow-write --coverage=coverage", + "test": "deno test --allow-read --allow-net --allow-write --allow-env --allow-import --coverage=coverage", + "test-v1": "deno test --allow-read --allow-net --allow-write --allow-env --unstable-http --coverage=coverage", "clean": "rm -rf ./npm ./coverage ./generated-snapshots", "lcov": "deno coverage coverage --lcov --output=coverage/report.lcov", "cover": "deno task clean && deno task test && deno task lcov && genhtml -o coverage/html coverage/report.lcov", diff --git a/deno.lock b/deno.lock index 3abce09..d5d79e8 100644 --- a/deno.lock +++ b/deno.lock @@ -1,13 +1,13 @@ { "version": "5", "specifiers": { - "jsr:@std/fs@1.0.21": "1.0.21", + "jsr:@std/fs@1.0.23": "1.0.23", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/path@^1.1.4": "1.1.4" }, "jsr": { - "@std/fs@1.0.21": { - "integrity": "d720fe1056d78d43065a4d6e0eeb2b19f34adb8a0bc7caf3a4dbf1d4178252cd", + "@std/fs@1.0.23": { + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "dependencies": [ "jsr:@std/internal", "jsr:@std/path" diff --git a/src/deps.ts b/src/deps.ts index 7849fe0..13ef146 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -1 +1 @@ -export { existsSync } from 'jsr:@std/fs@1.0.21'; +export { existsSync } from 'jsr:@std/fs@1.0.23'; diff --git a/src/lib/remote.ts b/src/lib/remote.ts index bb2b18d..fe919f2 100644 --- a/src/lib/remote.ts +++ b/src/lib/remote.ts @@ -20,6 +20,12 @@ export const setCerts = (certPath: string) => { }); }; +export const destroyHttpClient = () => { + if (httpClient) { + httpClient.close(); + } +}; + export const getEntry = (input?: string[][]) => { if (!input) { return undefined; diff --git a/tests/deps.ts b/tests/deps.ts index d681351..e25ad54 100644 --- a/tests/deps.ts +++ b/tests/deps.ts @@ -13,8 +13,8 @@ export { assertArrayIncludes, assertStrictEquals, assertNotStrictEquals -} from 'jsr:@std/assert@1.0.16'; -export { assertSpyCalls, spy } from 'jsr:@std/testing@1.0.16/mock'; +} from 'jsr:@std/assert@1.0.19'; +export { assertSpyCalls, spy } from 'jsr:@std/testing@1.0.17/mock'; export { describe, it, @@ -22,7 +22,7 @@ export { beforeEach, beforeAll, afterEach -} from 'jsr:@std/testing@1.0.16/bdd'; -export { delay } from 'jsr:@std/async@1.0.16/delay'; +} from 'jsr:@std/testing@1.0.17/bdd'; +export { delay } from 'jsr:@std/async@1.2.0/delay'; export { load } from 'jsr:@std/dotenv@0.225.6'; export * as mf from 'https://deno.land/x/mock_fetch@0.3.0/mod.ts'; \ No newline at end of file diff --git a/tests/helper/dummy-cert.pem b/tests/helper/dummy-cert.pem new file mode 100644 index 0000000..87b6a58 --- /dev/null +++ b/tests/helper/dummy-cert.pem @@ -0,0 +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.ts b/tests/switcher-context.test.ts new file mode 100644 index 0000000..5d38768 --- /dev/null +++ b/tests/switcher-context.test.ts @@ -0,0 +1,123 @@ +import { describe, it, beforeEach, assertRejects, assertThrows } from './deps.ts'; +import { given, tearDown, generateAuth, assertTrue } from './helper/utils.ts' + +import { Client, type SwitcherContext } from '../mod.ts'; +import { destroyHttpClient } from "../src/lib/remote.ts"; + +describe('Switcher Context:', function () { + let contextSettings: SwitcherContext; + + beforeEach(function() { + tearDown(); + + contextSettings = { + url: 'http://localhost:3000', + apiKey: '[apiKey]', + domain: '[domain]', + component: '[component]', + environment: 'default' + }; + }); + + it('should throw when certPath is invalid', function() { + assertThrows(() => Client.buildContext(contextSettings, { certPath: 'invalid' })); + }); + + it('should NOT throw when certPath is valid', function() { + Client.buildContext(contextSettings, { certPath: './tests/helper/dummy-cert.pem' }); + destroyHttpClient(); + assertTrue(true); + }); + + it('should be invalid - Missing API url field', async function () { + // given + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + + // test + Client.buildContext({ + url: undefined, + apiKey: '[apiKey]', + domain: '[domain]', + component: '[component]', + environment: 'default' + }); + + const switcher = Client.getSwitcher(); + + await assertRejects(async () => + await switcher.isItOn(), + Error, 'Something went wrong: URL is required'); + }); + + it('should be invalid - Missing API Key field', async function () { + // given + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + + // test + Client.buildContext({ + url: 'http://localhost:3000', + apiKey: undefined, + domain: '[domain]', + component: '[component]', + environment: 'default' + }); + + const switcher = Client.getSwitcher(); + + await switcher + .checkValue('User 1') + .checkNetwork('192.168.0.1') + .prepare('MY_FLAG'); + + await assertRejects(async () => + await switcher.isItOn(), + Error, 'Something went wrong: API Key is required'); + }); + + it('should be invalid - Missing key field', async function () { + // given + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + + await assertRejects(async () => + await switcher.isItOn(), + Error, 'Something went wrong: Missing key field'); + }); + + it('should be invalid - Missing component field', async function () { + // given + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + + // test + Client.buildContext({ + url: 'http://localhost:3000', + apiKey: '[apiKey]', + domain: '[domain]', + component: undefined, + environment: 'default' + }); + + const switcher = Client.getSwitcher(); + + await assertRejects(async () => + await switcher.isItOn('MY_FLAG'), + Error, 'Something went wrong: Component is required'); + }); + + it('should be invalid - Missing token field', async function () { + // given + given('POST@/criteria/auth', generateAuth(undefined, 5)); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + + await assertRejects(async () => + await switcher.isItOn('MY_FLAG'), + Error, 'Something went wrong: Missing token field'); + }); + +}); \ No newline at end of file diff --git a/tests/switcher-functional.test.ts b/tests/switcher-functional.test.ts deleted file mode 100644 index cc827a4..0000000 --- a/tests/switcher-functional.test.ts +++ /dev/null @@ -1,676 +0,0 @@ -import { describe, it, afterAll, afterEach, beforeEach, - assertEquals, assertRejects, assertThrows, assertFalse, - assertSpyCalls, assertStrictEquals, assertNotStrictEquals, spy } from './deps.ts'; -import { given, givenError, tearDown, assertTrue, generateAuth, generateResult, generateDetailedResult, sleep } from './helper/utils.ts' - -import { Client, type SwitcherResult, type SwitcherContext } from '../mod.ts'; -import TimedMatch from '../src/lib/utils/timed-match/index.ts'; -import ExecutionLogger from '../src/lib/utils/executionLogger.ts'; - -describe('Integrated test - Client:', function () { - - let contextSettings: SwitcherContext; - - afterAll(function() { - Client.unloadSnapshot(); - TimedMatch.terminateWorker(); - }); - - beforeEach(function() { - tearDown(); - Client.testMode(); - - contextSettings = { - url: 'http://localhost:3000', - apiKey: '[apiKey]', - domain: '[domain]', - component: '[component]', - environment: 'default' - }; - }); - - describe('check criteria (e2e):', function () { - - afterEach(function() { - tearDown(); - }); - - beforeEach(function() { - ExecutionLogger.clearLogger(); - }); - - it('should be valid', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(true)); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - - await switcher.prepare('FLAG_1'); - assertTrue(await switcher.isItOn()); - }); - - it('should be valid - using persisted switcher key', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(true)); - - // 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) - assertStrictEquals(switcher1, switcher2, 'Switcher instances should be the same (persisted)'); - assertNotStrictEquals(switcher1, differentSwitcher, 'Different keys should create different instances'); - assertTrue(await switcher1.isItOn()); - }); - - it('should NOT throw error when default result is provided using remote', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', { message: 'ERROR' }, 404); - - // test - let asyncErrorMessage = null; - Client.buildContext(contextSettings); - Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); - const switcher = Client.getSwitcher().defaultResult(true); - - assertTrue(await switcher.isItOn('UNKNOWN_FEATURE')); - assertEquals(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('POST@/criteria/auth', undefined, 429); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - - await assertRejects(async () => - await switcher.isItOn('FLAG_1'), - Error, 'Something went wrong: [auth] failed with status 429'); - }); - - it('should be valid - throttle', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(true)); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - switcher.throttle(1000); - - const spyExecutionLogger = spy(ExecutionLogger, 'add'); - - assertTrue(await switcher.isItOn('FLAG_1')); // sync - assertTrue(await switcher.isItOn('FLAG_1')); // async - await sleep(100); // wait resolve async Promise - - assertSpyCalls(spyExecutionLogger, 1); - }); - - it('should be valid - throttle - with details', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(true)); - - // test - Client.buildContext(contextSettings); - const 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') as SwitcherResult; - assertTrue(response.result); - }); - - it('should renew token when using throttle', async function () { - // given API responses - // first API call - given('POST@/criteria/auth', generateAuth('[auth_token]', 1)); - given('POST@/criteria', generateResult(true)); // before token expires - - // 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') as SwitcherResult; - assertTrue(isItOn.result); - assertEquals(isItOn.metadata, undefined); - assertSpyCalls(spyPrepare, 1); - - // 2nd - uses cached result - isItOn = await switcher.isItOn('FLAG_3') as SwitcherResult; - assertTrue(isItOn.result); - assertTrue((isItOn.metadata as { cached: boolean }).cached); - assertSpyCalls(spyPrepare, 1); - - // should call the remote API - token has expired - await sleep(2000); // wait resolve async Promise - - // given - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(false)); // after token expires - - // 3rd - use cached result, asynchronous renew token and stores new result in cache - isItOn = await switcher.isItOn('FLAG_3') as SwitcherResult; - assertTrue(isItOn.result); - assertSpyCalls(spyPrepare, 2); - - // 4th - uses cached result - await sleep(50); - isItOn = await switcher.isItOn('FLAG_3') as SwitcherResult; - assertFalse(isItOn.result); - assertSpyCalls(spyPrepare, 2); - }); - - it('should flush executions from a specific switcher key', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(true)); - - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher('FLAG_1').throttle(1000); - - // when - assertTrue(await switcher.isItOn()); - let switcherExecutions = ExecutionLogger.getByKey('FLAG_1'); - assertEquals(switcherExecutions.length, 1); - - // test - switcher.flushExecutions(); - switcherExecutions = ExecutionLogger.getByKey('FLAG_1'); - assertEquals(switcherExecutions.length, 0); - }); - - it('should not crash when async checkCriteria fails', async function () { - // given API responses - // first API call - given('POST@/criteria/auth', generateAuth('[auth_token]', 1)); - given('POST@/criteria', generateResult(true)); // before token expires - - // test - let asyncErrorMessage = null; - Client.buildContext(contextSettings); - Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); - - const switcher = Client.getSwitcher(); - switcher.throttle(1000); - - assertTrue(await switcher.isItOn('FLAG_1')); // sync - assertTrue(await switcher.isItOn('FLAG_1')); // async - - // Next call should call the API again - valid token but crashes on checkCriteria - await sleep(1000); - assertEquals(asyncErrorMessage, null); - - // given - given('POST@/criteria', { message: 'error' }, 500); - assertTrue(await switcher.isItOn('FLAG_1')); // async - - await sleep(1000); - assertEquals(asyncErrorMessage, 'Something went wrong: [checkCriteria] failed with status 500'); - }); - }); - - describe('force remote (hybrid):', function () { - - const forceRemoteOptions = { - local: true, - snapshotLocation: './tests/snapshot/', - regexSafe: false - }; - - afterEach(function() { - tearDown(); - }); - - it('should return true - snapshot switcher is true', async function () { - Client.buildContext(contextSettings, forceRemoteOptions); - - const switcher = Client.getSwitcher(); - await Client.loadSnapshot(); - assertTrue(await switcher.isItOn('FF2FOR2030')); - }); - - it('should return false - same switcher return false when remote', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(false)); - - // test - Client.buildContext(contextSettings, forceRemoteOptions); - - const switcher = Client.getSwitcher(); - const executeRemoteCriteria = spy(switcher, 'executeRemoteCriteria'); - - await Client.loadSnapshot(); - assertFalse(await switcher.remote().isItOn('FF2FOR2030')); - assertSpyCalls(executeRemoteCriteria, 1); - }); - - it('should return true - including reason and metadata', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateDetailedResult({ - result: true, - reason: 'Success', - metadata: { - user: 'user1', - } - })); - - // test - Client.buildContext(contextSettings); - - const switcher = Client.getSwitcher(); - const detailedResult = await switcher.detail().isItOn('FF2FOR2030') as SwitcherResult; - assertTrue(detailedResult.result); - assertEquals(detailedResult.reason, 'Success'); - assertEquals(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(); - - await assertRejects(async () => - await switcher.remote().isItOn('FF2FOR2030'), - Error, 'Local mode is not enabled'); - }); - - }); - - describe('check fail response (e2e):', function () { - - let contextSettings: SwitcherContext; - - afterAll(function() { - Client.unloadSnapshot(); - }); - - beforeEach(function() { - tearDown(); - Client.testMode(); - - contextSettings = { - url: 'http://localhost:3000', - apiKey: '[apiKey]', - domain: '[domain]', - component: '[component]', - environment: 'default' - }; - }); - - it('should NOT be valid - API returned 429 (too many requests) at auth', async function () { - // given API responses - given('POST@/criteria/auth', { error: 'Too many requests' }, 429); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - - await assertRejects(async () => - await switcher.isItOn('FLAG_1'), - Error, '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('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', { error: 'Too many requests' }, 429); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - - await assertRejects(async () => - await switcher.isItOn('FLAG_1'), - Error, 'Something went wrong: [checkCriteria] failed with status 429'); - }); - - it('should use silent mode when fail to check switchers', async function() { - Client.buildContext(contextSettings, { silentMode: '5m', regexSafe: false, snapshotLocation: './tests/snapshot/' }); - await assertRejects(async () => - await Client.checkSwitchers(['FEATURE01', 'FEATURE02']), - Error, 'Something went wrong: [FEATURE01,FEATURE02] not found'); - - await Client.checkSwitchers(['FF2FOR2021', 'FF2FOR2021']); - }); - - it('should use silent mode when fail to check criteria', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', { error: 'Too many requests' }, 429); - givenError('GET@/check', 'ECONNREFUSED'); // 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 - assertTrue(await switcher.isItOn('FF2FOR2022')); - assertEquals(asyncErrorMessage, 'Something went wrong: [checkCriteria] failed with status 429'); - - // assert silent mode being used in the next call - await sleep(1500); - assertTrue(await switcher.isItOn('FF2FOR2022')); - }); - - }); - - describe('check criteria:', function () { - - beforeEach(function() { - tearDown(); - }); - - it('should be valid', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(true)); - - // test - Client.buildContext(contextSettings); - const 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'); - - assertEquals(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"}' ] - ]); - - assertTrue(await switcher.isItOn()); - }); - - it('should NOT throw when switcher keys provided were configured properly', async function() { - // given - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria/switchers_check', { not_found: [] }); - - // test - Client.buildContext(contextSettings); - - let error: Error | undefined; - await Client.checkSwitchers(['FEATURE01', 'FEATURE02']).catch(err => error = err); - assertEquals(error, undefined); - }); - - it('should throw when switcher keys provided were not configured properly', async function() { - // given - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria/switchers_check', { not_found: ['FEATURE02'] }); - - // test - Client.buildContext(contextSettings); - await assertRejects(async () => - await Client.checkSwitchers(['FEATURE01', 'FEATURE02']), - Error, 'Something went wrong: [FEATURE02] not found'); - }); - - it('should throw when no switcher keys were provided', async function() { - // given - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria/switchers_check', undefined, 422); - - // test - Client.buildContext(contextSettings); - await assertRejects(async () => - await Client.checkSwitchers([]), - Error, 'Something went wrong: [checkSwitchers] failed with status 422'); - }); - - it('should throw when certPath is invalid', function() { - assertThrows(() => Client.buildContext(contextSettings, { certPath: 'invalid' })); - }); - - it('should renew the token after expiration', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 1)); - - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - const spyPrepare = spy(switcher, 'prepare'); - - // Prepare the call generating the token - given('POST@/criteria', generateResult(true)); - await switcher.prepare('MY_FLAG'); - assertTrue(await switcher.isItOn()); - - // The program delay 2 secs later for the next call - await sleep(2000); - - // Prepare the stub to provide the new token - given('POST@/criteria/auth', generateAuth('asdad12d2232d2323f', 1)); - - // In this time period the expiration time has reached, it should call prepare once again to renew the token - given('POST@/criteria', generateResult(false)); - assertFalse(await switcher.isItOn()); - assertSpyCalls(spyPrepare, 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('POST@/criteria', generateResult(false)); - assertFalse(await switcher.isItOn()); - assertSpyCalls(spyPrepare, 2); - }); - - it('should be valid - when sending key without calling prepare', async function () { - // given API responses - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(true)); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - assertTrue(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('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - given('POST@/criteria', generateResult(true)); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - - await switcher.prepare('MY_FLAG'); - assertTrue(await switcher - .checkValue('User 1') - .checkNetwork('192.168.0.1') - .isItOn()); - }); - - it('should be invalid - Missing API url field', async function () { - // given - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - - // test - Client.buildContext({ - url: undefined, - apiKey: '[apiKey]', - domain: '[domain]', - component: '[component]', - environment: 'default' - }); - - const switcher = Client.getSwitcher(); - - await assertRejects(async () => - await switcher.isItOn(), - Error, 'Something went wrong: URL is required'); - }); - - it('should be invalid - Missing API Key field', async function () { - // given - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - - // test - Client.buildContext({ - url: 'http://localhost:3000', - apiKey: undefined, - domain: '[domain]', - component: '[component]', - environment: 'default' - }); - - const switcher = Client.getSwitcher(); - - await switcher - .checkValue('User 1') - .checkNetwork('192.168.0.1') - .prepare('MY_FLAG'); - - await assertRejects(async () => - await switcher.isItOn(), - Error, 'Something went wrong: API Key is required'); - }); - - it('should be invalid - Missing key field', async function () { - // given - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - - await assertRejects(async () => - await switcher.isItOn(), - Error, 'Something went wrong: Missing key field'); - }); - - it('should be invalid - Missing component field', async function () { - // given - given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); - - // test - Client.buildContext({ - url: 'http://localhost:3000', - apiKey: '[apiKey]', - domain: '[domain]', - component: undefined, - environment: 'default' - }); - - const switcher = Client.getSwitcher(); - - await assertRejects(async () => - await switcher.isItOn('MY_FLAG'), - Error, 'Something went wrong: Component is required'); - }); - - it('should be invalid - Missing token field', async function () { - // given - given('POST@/criteria/auth', generateAuth(undefined, 5)); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - - await assertRejects(async () => - await switcher.isItOn('MY_FLAG'), - Error, 'Something went wrong: Missing token field'); - }); - - it('should run in silent mode', async function () { - // setup context to read the snapshot in case the API does not respond - Client.buildContext(contextSettings, { - snapshotLocation: './tests/snapshot/', - regexSafe: false, - silentMode: '2s', - }); - - const 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('POST@/criteria/auth', 'ECONNREFUSED'); - assertTrue(await switcher.isItOn('FF2FOR2030')); - - // The call below is in silent mode. It is getting the configuration from the local snapshot again - await sleep(500); - assertTrue(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 - assertSpyCalls(spyRemote, 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('GET@/check', undefined, 200); - given('POST@/criteria/auth', generateAuth('[auth_token]', 10)); - given('POST@/criteria', generateResult(false)); - - // Auth is async when silent mode is enabled to prevent blocking the execution while the API is not available - assertTrue(await switcher.isItOn()); - - // Now the remote call was invoked, so it should return false - await sleep(500); - assertFalse(await switcher.isItOn()); - assertSpyCalls(spyRemote, 1); - }); - - it('should throw error if not in silent mode', async function () { - givenError('POST@/criteria/auth', 'ECONNREFUSED'); - - // test - Client.buildContext(contextSettings); - const switcher = Client.getSwitcher(); - - await assertRejects(async () => - await switcher.isItOn('FF2FOR2030'), - Error, 'Something went wrong: Connection has been refused - ECONNREFUSED'); - }); - - it('should run in silent mode when API is unavailable', async function () { - Client.buildContext(contextSettings, { - snapshotLocation: './tests/snapshot/', - regexSafe: false, - silentMode: '5m' - }); - - const switcher = Client.getSwitcher(); - assertTrue(await switcher.isItOn('FF2FOR2030')); - }); - - }); -}); \ No newline at end of file diff --git a/tests/switcher-integrated.test.ts b/tests/switcher-integrated.test.ts new file mode 100644 index 0000000..dfdf807 --- /dev/null +++ b/tests/switcher-integrated.test.ts @@ -0,0 +1,64 @@ +import { describe, it, assertExists, assertGreater, load } from './deps.ts'; +import { assertTrue } from "./helper/utils.ts"; + +import { Client } from '../mod.ts'; + +await load({ export: true, envPath: '.env' }); + +describe('Switcher integrated test', () => { + + const contextSettings = { + url: 'https://api.switcherapi.com', + apiKey: Deno.env.get('SWITCHER_API_KEY') ?? '', + domain: 'Switcher API', + component: 'switcher4deno' + }; + + it('should hit remote API and return Switcher response', async function () { + if (!Deno.env.get('SWITCHER_API_KEY')) { + return; + } + + // given context build + Client.buildContext(contextSettings); + + // test + const switcher = Client.getSwitcher().detail(); + const result = await switcher.isItOn('CLIENT_DENO_FEATURE'); + + assertExists(result); + }); + + it('should load snapshot from remote API', async function () { + if (!Deno.env.get('SWITCHER_API_KEY')) { + return; + } + + // 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_DENO_FEATURE'); + + assertExists(result); + assertGreater(Client.snapshotVersion, 0); + }); + + it('should check Switcher availability', async function () { + if (!Deno.env.get('SWITCHER_API_KEY')) { + return; + } + + // given context build + Client.buildContext(contextSettings); + + // test + await Client.checkSwitchers(['CLIENT_DENO_FEATURE']); + + assertTrue(true); + }); + +}); \ No newline at end of file diff --git a/tests/switcher-client.test.ts b/tests/switcher-local.test.ts similarity index 78% rename from tests/switcher-client.test.ts rename to tests/switcher-local.test.ts index 5c4b247..6639ac4 100644 --- a/tests/switcher-client.test.ts +++ b/tests/switcher-local.test.ts @@ -19,7 +19,7 @@ const environment = 'default'; const url = 'http://localhost:3000'; const snapshotLocation = './tests/snapshot/'; -describe('E2E test - Client local #1:', function () { +describe('E2E test - Client local:', function () { beforeAll(async function() { Client.buildContext({ url, apiKey, domain, component, environment }, { snapshotLocation, local: true, logger: true, regexMaxBlackList: 1, regexMaxTimeLimit: 500 @@ -246,7 +246,7 @@ describe('E2E test - Client local #1:', function () { }); -describe('E2E test - Client local #2:', function () { +describe('E2E test - Client local - domain disabled:', function () { beforeAll(async function() { Client.buildContext({ url, apiKey, domain, component, environment: 'default_disabled' }, { snapshotLocation, local: true, logger: true, regexMaxBlackList: 1, regexMaxTimeLimit: 500 @@ -391,113 +391,6 @@ describe('E2E test - Client local from cache:', function () { }); }); -describe('E2E test - Client testing (assume) feature:', function () { - beforeAll(async function() { - Client.buildContext({ url, apiKey, domain, component, environment }, { - snapshotLocation, local: true, logger: true, regexMaxBlackList: 1, regexMaxTimeLimit: 500 - }); - - await Client.loadSnapshot(); - switcher = Client.getSwitcher(); - }); - - afterAll(function() { - Client.unloadSnapshot(); - TimedMatch.terminateWorker(); - }); - - beforeEach(function() { - Client.clearLogger(); - Client.forget('FF2FOR2020'); - switcher = Client.getSwitcher(); - }); - - it('should replace the result of isItOn with Client.assume', testSettings, async function () { - await switcher.prepare('DUMMY'); - - Client.assume('DUMMY').true(); - assertTrue(await switcher.isItOn()); - - Client.assume('DUMMY').false(); - assertFalse(await switcher.isItOn()); - }); - - it('should be valid assuming key to be false and then forgetting it', testSettings, async function () { - await switcher - .checkValue('Japan') - .checkNetwork('10.0.0.3') - .prepare('FF2FOR2020'); - - assertTrue(await switcher.isItOn()); - Client.assume('FF2FOR2020').false(); - assertFalse(await switcher.isItOn()); - - Client.forget('FF2FOR2020'); - assertTrue(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') as SwitcherResult; - - assertFalse(result); - assertEquals(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(true).isItOn('FF2FOR2020') as SwitcherResult; - - assertFalse(result); - assertEquals(reason, 'Forced to false'); - assertEquals(metadata, { value: 'something' }); - }); - - it('should be valid assuming unknown key to be true and throw error when forgetting', testSettings, async function () { - await switcher - .checkValue('Japan') - .checkNetwork('10.0.0.3') - .prepare('UNKNOWN'); - - Client.assume('UNKNOWN').true(); - assertTrue(await switcher.isItOn()); - - Client.forget('UNKNOWN'); - await assertRejects(async () => - await 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', testSettings, async function () { - await switcher - .checkValue('Canada') // result to be false - .checkNetwork('10.0.0.3') - .prepare('FF2FOR2020'); - - assertFalse(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'); - - assertTrue(await switcher.isItOn()); - }); - - it('should NOT return true using Client.assume when Strategy input values does not match', testSettings, async function () { - await switcher - .checkValue('Japan') - .checkNetwork('10.0.0.3') - .prepare('FF2FOR2020'); - - assertTrue(await switcher.isItOn()); - Client.assume('FF2FOR2020').true() - .when(StrategiesType.VALUE, ['Brazil', 'Japan']) - .and(StrategiesType.NETWORK, ['10.0.0.4', '192.168.0.1']); - - assertFalse(await switcher.isItOn()); - }); - -}); - describe('E2E test - Restrict Relay:', function () { beforeAll(async function() { Client.buildContext({ url, apiKey, domain, component, environment }, { diff --git a/tests/switcher-remote.test.ts b/tests/switcher-remote.test.ts new file mode 100644 index 0000000..8c6cd75 --- /dev/null +++ b/tests/switcher-remote.test.ts @@ -0,0 +1,342 @@ +import { describe, it, afterAll, afterEach, beforeEach, + assertEquals, assertRejects, assertFalse, + assertSpyCalls, assertStrictEquals, assertNotStrictEquals, spy } from './deps.ts'; +import { given, tearDown, assertTrue, generateAuth, generateResult, generateDetailedResult, sleep } from './helper/utils.ts' + +import { Client, type SwitcherResult, type SwitcherContext } from '../mod.ts'; +import TimedMatch from '../src/lib/utils/timed-match/index.ts'; +import ExecutionLogger from '../src/lib/utils/executionLogger.ts'; + +describe('Switcher Remote:', function () { + + let contextSettings: SwitcherContext; + + afterAll(function() { + Client.unloadSnapshot(); + TimedMatch.terminateWorker(); + }); + + beforeEach(function() { + tearDown(); + Client.testMode(); + + contextSettings = { + url: 'http://localhost:3000', + apiKey: '[apiKey]', + domain: '[domain]', + component: '[component]', + environment: 'default' + }; + }); + + describe('check criteria:', function () { + + beforeEach(function() { + ExecutionLogger.clearLogger(); + tearDown(); + }); + + it('should be valid (simple)', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(true)); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + + await switcher.prepare('FLAG_1'); + assertTrue(await switcher.isItOn()); + }); + + it('should be valid - using persisted switcher key', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(true)); + + // 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) + assertStrictEquals(switcher1, switcher2, 'Switcher instances should be the same (persisted)'); + assertNotStrictEquals(switcher1, differentSwitcher, 'Different keys should create different instances'); + assertTrue(await switcher1.isItOn()); + }); + + it('should NOT throw error when default result is provided using remote', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', { message: 'ERROR' }, 404); + + // test + let asyncErrorMessage = null; + Client.buildContext(contextSettings); + Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); + const switcher = Client.getSwitcher().defaultResult(true); + + assertTrue(await switcher.isItOn('UNKNOWN_FEATURE')); + assertEquals(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('POST@/criteria/auth', undefined, 429); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + + await assertRejects(async () => + await switcher.isItOn('FLAG_1'), + Error, 'Something went wrong: [auth] failed with status 429'); + }); + + it('should return true - including reason and metadata', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateDetailedResult({ + result: true, + reason: 'Success', + metadata: { + user: 'user1', + } + })); + + // test + Client.buildContext(contextSettings); + + const switcher = Client.getSwitcher(); + const detailedResult = await switcher.detail().isItOn('FF2FOR2030') as SwitcherResult; + assertTrue(detailedResult.result); + assertEquals(detailedResult.reason, 'Success'); + assertEquals(detailedResult.metadata, { user: 'user1' }); + }); + + it('should be valid (with all strategies)', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(true)); + + // test + Client.buildContext(contextSettings); + const 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'); + + assertEquals(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"}' ] + ]); + + assertTrue(await switcher.isItOn()); + }); + + it('should NOT throw when switcher keys provided were configured properly', async function() { + // given + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria/switchers_check', { not_found: [] }); + + // test + Client.buildContext(contextSettings); + + let error: Error | undefined; + await Client.checkSwitchers(['FEATURE01', 'FEATURE02']).catch(err => error = err); + assertEquals(error, undefined); + }); + + it('should throw when switcher keys provided were not configured properly', async function() { + // given + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria/switchers_check', { not_found: ['FEATURE02'] }); + + // test + Client.buildContext(contextSettings); + await assertRejects(async () => + await Client.checkSwitchers(['FEATURE01', 'FEATURE02']), + Error, 'Something went wrong: [FEATURE02] not found'); + }); + + it('should throw when no switcher keys were provided', async function() { + // given + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria/switchers_check', undefined, 422); + + // test + Client.buildContext(contextSettings); + await assertRejects(async () => + await Client.checkSwitchers([]), + Error, 'Something went wrong: [checkSwitchers] failed with status 422'); + }); + + it('should renew the token after expiration', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 1)); + + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + const spyPrepare = spy(switcher, 'prepare'); + + // Prepare the call generating the token + given('POST@/criteria', generateResult(true)); + await switcher.prepare('MY_FLAG'); + assertTrue(await switcher.isItOn()); + + // The program delay 2 secs later for the next call + await sleep(2000); + + // Prepare the stub to provide the new token + given('POST@/criteria/auth', generateAuth('asdad12d2232d2323f', 1)); + + // In this time period the expiration time has reached, it should call prepare once again to renew the token + given('POST@/criteria', generateResult(false)); + assertFalse(await switcher.isItOn()); + assertSpyCalls(spyPrepare, 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('POST@/criteria', generateResult(false)); + assertFalse(await switcher.isItOn()); + assertSpyCalls(spyPrepare, 2); + }); + + it('should be valid - when sending key without calling prepare', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(true)); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + assertTrue(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('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(true)); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + + await switcher.prepare('MY_FLAG'); + assertTrue(await switcher + .checkValue('User 1') + .checkNetwork('192.168.0.1') + .isItOn()); + }); + + }); + + describe('force remote (hybrid):', function () { + + const forceRemoteOptions = { + local: true, + snapshotLocation: './tests/snapshot/', + regexSafe: false + }; + + afterEach(function() { + tearDown(); + }); + + it('should return false - same switcher return false when remote', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(false)); + + // test + Client.buildContext(contextSettings, forceRemoteOptions); + + const switcher = Client.getSwitcher(); + const executeRemoteCriteria = spy(switcher, 'executeRemoteCriteria'); + + await Client.loadSnapshot(); + assertTrue(await switcher.isItOn('FF2FOR2030')); // snapshot value is true + assertFalse(await switcher.remote().isItOn('FF2FOR2030')); // remote value is false + assertSpyCalls(executeRemoteCriteria, 1); + }); + + it('should return error when local is not enabled', async function () { + Client.buildContext(contextSettings, { regexSafe: false, local: false }); + + const switcher = Client.getSwitcher(); + + await assertRejects(async () => + await switcher.remote().isItOn('FF2FOR2030'), + Error, 'Local mode is not enabled'); + }); + + }); + + describe('check fail response:', function () { + + let contextSettings: SwitcherContext; + + afterAll(function() { + Client.unloadSnapshot(); + }); + + beforeEach(function() { + tearDown(); + Client.testMode(); + + contextSettings = { + url: 'http://localhost:3000', + apiKey: '[apiKey]', + domain: '[domain]', + component: '[component]', + environment: 'default' + }; + }); + + it('should NOT be valid - API returned 429 (too many requests) at auth', async function () { + // given API responses + given('POST@/criteria/auth', { error: 'Too many requests' }, 429); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + + await assertRejects(async () => + await switcher.isItOn('FLAG_1'), + Error, '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('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', { error: 'Too many requests' }, 429); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + + await assertRejects(async () => + await switcher.isItOn('FLAG_1'), + Error, 'Something went wrong: [checkCriteria] failed with status 429'); + }); + + }); + +}); \ No newline at end of file diff --git a/tests/switcher-slient-mode.test.ts b/tests/switcher-slient-mode.test.ts new file mode 100644 index 0000000..2d51519 --- /dev/null +++ b/tests/switcher-slient-mode.test.ts @@ -0,0 +1,122 @@ +import { describe, it, afterAll, beforeEach, + assertEquals, assertRejects, assertFalse, + assertSpyCalls, spy } from './deps.ts'; +import { given, givenError, tearDown, assertTrue, generateAuth, generateResult, sleep } from './helper/utils.ts' + +import { Client, type SwitcherContext } from '../mod.ts'; +import ExecutionLogger from '../src/lib/utils/executionLogger.ts'; + +describe('Switcher Silent Mode:', function () { + let contextSettings: SwitcherContext; + + afterAll(function() { + Client.unloadSnapshot(); + }); + + beforeEach(function() { + tearDown(); + ExecutionLogger.clearLogger(); + Client.testMode(); + + contextSettings = { + url: 'http://localhost:3000', + apiKey: '[apiKey]', + domain: '[domain]', + component: '[component]', + environment: 'default' + }; + }); + + it('should run in silent mode', async function () { + // setup context to read the snapshot in case the API does not respond + Client.buildContext(contextSettings, { + snapshotLocation: './tests/snapshot/', + regexSafe: false, + silentMode: '2s', + }); + + const 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('POST@/criteria/auth', 'ECONNREFUSED'); + assertTrue(await switcher.isItOn('FF2FOR2030')); + + // The call below is in silent mode. It is getting the configuration from the local snapshot again + await sleep(500); + assertTrue(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 + assertSpyCalls(spyRemote, 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('GET@/check', undefined, 200); + given('POST@/criteria/auth', generateAuth('[auth_token]', 10)); + given('POST@/criteria', generateResult(false)); + + // Auth is async when silent mode is enabled to prevent blocking the execution while the API is not available + assertTrue(await switcher.isItOn()); + + // Now the remote call was invoked, so it should return false + await sleep(500); + assertFalse(await switcher.isItOn()); + assertSpyCalls(spyRemote, 1); + }); + + it('should throw error if not in silent mode', async function () { + givenError('POST@/criteria/auth', 'ECONNREFUSED'); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + + await assertRejects(async () => + await switcher.isItOn('FF2FOR2030'), + Error, 'Something went wrong: Connection has been refused - ECONNREFUSED'); + }); + + it('should run in silent mode when API is unavailable', async function () { + Client.buildContext(contextSettings, { + snapshotLocation: './tests/snapshot/', + regexSafe: false, + silentMode: '5m' + }); + + const switcher = Client.getSwitcher(); + assertTrue(await switcher.isItOn('FF2FOR2030')); + }); + + it('should use silent mode when fail to check switchers', async function() { + Client.buildContext(contextSettings, { silentMode: '5m', regexSafe: false, snapshotLocation: './tests/snapshot/' }); + await assertRejects(async () => + await Client.checkSwitchers(['FEATURE01', 'FEATURE02']), + Error, 'Something went wrong: [FEATURE01,FEATURE02] not found'); + + await Client.checkSwitchers(['FF2FOR2021', 'FF2FOR2021']); + }); + + it('should use silent mode when fail to check criteria', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', { error: 'Too many requests' }, 429); + givenError('GET@/check', 'ECONNREFUSED'); // 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 + assertTrue(await switcher.isItOn('FF2FOR2022')); + assertEquals(asyncErrorMessage, 'Something went wrong: [checkCriteria] failed with status 429'); + + // assert silent mode being used in the next call + await sleep(1500); + assertTrue(await switcher.isItOn('FF2FOR2022')); + }); + +}); \ No newline at end of file diff --git a/tests/switcher-stub.test.ts b/tests/switcher-stub.test.ts new file mode 100644 index 0000000..c2f0643 --- /dev/null +++ b/tests/switcher-stub.test.ts @@ -0,0 +1,124 @@ +import { describe, it, afterAll, beforeEach, beforeAll, + assertEquals, assertRejects, assertFalse } from './deps.ts'; +import { assertTrue } from './helper/utils.ts' + +import { Client, type Switcher, type SwitcherResult } from '../mod.ts'; +import TimedMatch from '../src/lib/utils/timed-match/index.ts'; +import { StrategiesType } from '../src/lib/snapshot.ts'; + +const testSettings = { sanitizeOps: false, sanitizeResources: false, sanitizeExit: false }; + +let switcher: Switcher; +const apiKey = '[api_key]'; +const domain = 'Business'; +const component = 'business-service'; +const environment = 'default'; +const url = 'http://localhost:3000'; +const snapshotLocation = './tests/snapshot/'; + +describe('E2E test - Client testing (stub) feature:', function () { + beforeAll(async function() { + Client.buildContext({ url, apiKey, domain, component, environment }, { + snapshotLocation, local: true, logger: true, regexMaxBlackList: 1, regexMaxTimeLimit: 500 + }); + + await Client.loadSnapshot(); + switcher = Client.getSwitcher(); + }); + + afterAll(function() { + Client.unloadSnapshot(); + TimedMatch.terminateWorker(); + }); + + beforeEach(function() { + Client.clearLogger(); + Client.forget('FF2FOR2020'); + switcher = Client.getSwitcher(); + }); + + it('should replace the result of isItOn with Client.assume', testSettings, async function () { + await switcher.prepare('DUMMY'); + + Client.assume('DUMMY').true(); + assertTrue(await switcher.isItOn()); + + Client.assume('DUMMY').false(); + assertFalse(await switcher.isItOn()); + }); + + it('should be valid assuming key to be false and then forgetting it', testSettings, async function () { + await switcher + .checkValue('Japan') + .checkNetwork('10.0.0.3') + .prepare('FF2FOR2020'); + + assertTrue(await switcher.isItOn()); + Client.assume('FF2FOR2020').false(); + assertFalse(await switcher.isItOn()); + + Client.forget('FF2FOR2020'); + assertTrue(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') as SwitcherResult; + + assertFalse(result); + assertEquals(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(true).isItOn('FF2FOR2020') as SwitcherResult; + + assertFalse(result); + assertEquals(reason, 'Forced to false'); + assertEquals(metadata, { value: 'something' }); + }); + + it('should be valid assuming unknown key to be true and throw error when forgetting', testSettings, async function () { + await switcher + .checkValue('Japan') + .checkNetwork('10.0.0.3') + .prepare('UNKNOWN'); + + Client.assume('UNKNOWN').true(); + assertTrue(await switcher.isItOn()); + + Client.forget('UNKNOWN'); + await assertRejects(async () => + await 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', testSettings, async function () { + await switcher + .checkValue('Canada') // result to be false + .checkNetwork('10.0.0.3') + .prepare('FF2FOR2020'); + + assertFalse(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'); + + assertTrue(await switcher.isItOn()); + }); + + it('should NOT return true using Client.assume when Strategy input values does not match', testSettings, async function () { + await switcher + .checkValue('Japan') + .checkNetwork('10.0.0.3') + .prepare('FF2FOR2020'); + + assertTrue(await switcher.isItOn()); + Client.assume('FF2FOR2020').true() + .when(StrategiesType.VALUE, ['Brazil', 'Japan']) + .and(StrategiesType.NETWORK, ['10.0.0.4', '192.168.0.1']); + + assertFalse(await switcher.isItOn()); + }); + +}); diff --git a/tests/switcher-throttle.test.ts b/tests/switcher-throttle.test.ts new file mode 100644 index 0000000..d1f0cb3 --- /dev/null +++ b/tests/switcher-throttle.test.ts @@ -0,0 +1,164 @@ +import { describe, it, afterAll, afterEach, beforeEach, + assertEquals, assertFalse, + assertSpyCalls, spy } from './deps.ts'; +import { given, tearDown, assertTrue, generateAuth, generateResult, sleep } from './helper/utils.ts' + +import { Client, type SwitcherResult, type SwitcherContext } from '../mod.ts'; +import ExecutionLogger from '../src/lib/utils/executionLogger.ts'; + +describe('Switcher Throttle:', function () { + let contextSettings: SwitcherContext; + + afterAll(function() { + Client.unloadSnapshot(); + }); + + afterEach(function() { + tearDown(); + }); + + beforeEach(function() { + tearDown(); + ExecutionLogger.clearLogger(); + Client.testMode(); + + contextSettings = { + url: 'http://localhost:3000', + apiKey: '[apiKey]', + domain: '[domain]', + component: '[component]', + environment: 'default' + }; + }); + + it('should be valid - throttle', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(true)); + + // test + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher(); + switcher.throttle(1000); + + const spyExecutionLogger = spy(ExecutionLogger, 'add'); + + assertTrue(await switcher.isItOn('FLAG_1')); // sync + assertTrue(await switcher.isItOn('FLAG_1')); // async + await sleep(100); // wait resolve async Promise + + assertSpyCalls(spyExecutionLogger, 1); + }); + + it('should be valid - throttle - with details', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(true)); + + // test + Client.buildContext(contextSettings); + const 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') as SwitcherResult; + assertTrue(response.result); + }); + + it('should renew token when using throttle', async function () { + // given API responses + // first API call + given('POST@/criteria/auth', generateAuth('[auth_token]', 1)); + given('POST@/criteria', generateResult(true)); // before token expires + + // 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') as SwitcherResult; + assertTrue(isItOn.result); + assertEquals(isItOn.metadata, undefined); + assertSpyCalls(spyPrepare, 1); + + // 2nd - uses cached result + isItOn = await switcher.isItOn('FLAG_3') as SwitcherResult; + assertTrue(isItOn.result); + assertTrue((isItOn.metadata as { cached: boolean }).cached); + assertSpyCalls(spyPrepare, 1); + + // should call the remote API - token has expired + await sleep(2000); // wait resolve async Promise + + // given + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(false)); // after token expires + + // 3rd - use cached result, asynchronous renew token and stores new result in cache + isItOn = await switcher.isItOn('FLAG_3') as SwitcherResult; + assertTrue(isItOn.result); + assertSpyCalls(spyPrepare, 2); + + // 4th - uses cached result + await sleep(50); + isItOn = await switcher.isItOn('FLAG_3') as SwitcherResult; + assertFalse(isItOn.result); + assertSpyCalls(spyPrepare, 2); + }); + + it('should flush executions from a specific switcher key', async function () { + // given API responses + given('POST@/criteria/auth', generateAuth('[auth_token]', 5)); + given('POST@/criteria', generateResult(true)); + + Client.buildContext(contextSettings); + const switcher = Client.getSwitcher('FLAG_1').throttle(1000); + + // when + assertTrue(await switcher.isItOn()); + let switcherExecutions = ExecutionLogger.getByKey('FLAG_1'); + assertEquals(switcherExecutions.length, 1); + + // test + switcher.flushExecutions(); + switcherExecutions = ExecutionLogger.getByKey('FLAG_1'); + assertEquals(switcherExecutions.length, 0); + }); + + it('should not crash when async checkCriteria fails', async function () { + // given API responses + // first API call + given('POST@/criteria/auth', generateAuth('[auth_token]', 1)); + given('POST@/criteria', generateResult(true)); // before token expires + + // test + let asyncErrorMessage = null; + Client.buildContext(contextSettings); + Client.subscribeNotifyError((error) => asyncErrorMessage = error.message); + + const switcher = Client.getSwitcher(); + switcher.throttle(1000); + + assertTrue(await switcher.isItOn('FLAG_1')); // sync + assertTrue(await switcher.isItOn('FLAG_1')); // async + + // Next call should call the API again - valid token but crashes on checkCriteria + await sleep(1000); + assertEquals(asyncErrorMessage, null); + + // given + given('POST@/criteria', { message: 'error' }, 500); + assertTrue(await switcher.isItOn('FLAG_1')); // async + + await sleep(1000); + assertEquals(asyncErrorMessage, 'Something went wrong: [checkCriteria] failed with status 500'); + }); +}); \ No newline at end of file