diff --git a/src/__tests__/node.spec.js b/src/__tests__/node.spec.js index dab4e8f..a8fad03 100644 --- a/src/__tests__/node.spec.js +++ b/src/__tests__/node.spec.js @@ -1,7 +1,13 @@ import tape from 'tape-catch'; import clientSuite from './nodeSuites/client.spec.js'; +import providerSuite from './nodeSuites/provider.spec.js'; -tape('## OpenFeature JavaScript Split Provider - tests', async function (assert) { +tape('## OpenFeature JavaScript Split Client - tests', async function (assert) { assert.test('Client Tests', clientSuite); -}); \ No newline at end of file +}); + + +tape('## OpenFeature JavaScript Split Provider - tests', async function (assert) { + assert.test('Provider Tests', providerSuite); +}); diff --git a/src/__tests__/nodeSuites/client.spec.js b/src/__tests__/nodeSuites/client.spec.js index 2cdc813..01fac01 100644 --- a/src/__tests__/nodeSuites/client.spec.js +++ b/src/__tests__/nodeSuites/client.spec.js @@ -49,11 +49,11 @@ export default async function(assert) { const getBooleanSplitWithKeyTest = async (client) => { let result = await client.getBooleanDetails('my_feature', false); assert.equals(result.value, true); - assert.looseEquals(result.flagMetadata, { desc: 'this applies only to ON treatment' }); + assert.looseEquals(result.flagMetadata, { config: '{"desc" : "this applies only to ON treatment"}' }); result = await client.getBooleanDetails('my_feature', true, { targetingKey: 'randomKey' }); assert.equals(result.value, false); - assert.looseEquals(result.flagMetadata, {}); + assert.looseEquals(result.flagMetadata, { config: ''}); }; const getStringSplitTest = async (client) => { diff --git a/src/__tests__/nodeSuites/provider.spec.js b/src/__tests__/nodeSuites/provider.spec.js index f032947..a0cc7a1 100644 --- a/src/__tests__/nodeSuites/provider.spec.js +++ b/src/__tests__/nodeSuites/provider.spec.js @@ -1,3 +1,6 @@ +import { ParseError } from "@openfeature/server-sdk"; +import { makeProviderWithSpy } from "../testUtils"; + export default async function(assert) { const shouldFailWithBadApiKeyTest = () => { @@ -80,6 +83,63 @@ export default async function(assert) { assert.equal(1, 1); }; + const trackingSuite = (t) => { + + t.test("track: throws when missing eventName", async (t) => { + const { provider } = makeProviderWithSpy(); + try { + await provider.track("", { targetingKey: "u1", trafficType: "user" }, {}); + t.fail("expected ParseError for eventName"); + } catch (e) { + t.ok(e instanceof ParseError, "got ParseError"); + } + t.end(); + }); + + t.test("track: throws when missing trafficType", async (t) => { + const { provider } = makeProviderWithSpy(); + try { + await provider.track("evt", { targetingKey: "u1" }, {}); + t.fail("expected ParseError for trafficType"); + } catch (e) { + t.ok(e instanceof ParseError, "got ParseError"); + } + t.end(); + }); + + t.test("track: ok without details", async (t) => { + const { provider, calls } = makeProviderWithSpy(); + await provider.track("view", { targetingKey: "u1", trafficType: "user" }, null); + + t.equal(calls.count, 1, "Split track called once"); + t.deepEqual( + calls.args, + ["u1", "user", "view", undefined, {}], + "called with key, trafficType, eventName, 0, {}" + ); + t.end(); + }); + + t.test("track: ok with details", async (t) => { + const { provider, calls } = makeProviderWithSpy(); + await provider.track( + "purchase", + { targetingKey: "u1", trafficType: "user" }, + { value: 9.99, properties: { plan: "pro", beta: true } } + ); + + t.equal(calls.count, 1, "Split track called once"); + t.equal(calls.args[0], "u1"); + t.equal(calls.args[1], "user"); + t.equal(calls.args[2], "purchase"); + t.equal(calls.args[3], 9.99); + t.deepEqual(calls.args[4], { plan: "pro", beta: true }); + t.end(); + }); +} + + trackingSuite(assert); + shouldFailWithBadApiKeyTest(); evalBooleanNullEmptyTest(); diff --git a/src/__tests__/testUtils/index.js b/src/__tests__/testUtils/index.js index 885bcd1..70eda26 100644 --- a/src/__tests__/testUtils/index.js +++ b/src/__tests__/testUtils/index.js @@ -1,3 +1,5 @@ +import { OpenFeatureSplitProvider } from "../.."; + const DEFAULT_ERROR_MARGIN = 50; // 0.05 secs /** @@ -67,3 +69,23 @@ export function url(settings, target) { } return `${settings.urls.sdk}${target}`; } + + +/** + * Create a spy for the OpenFeatureSplitProvider. + * @returns {provider: OpenFeatureSplitProvider, calls: {count: number, args: any[]}} + */ +export function makeProviderWithSpy() { + const calls = { count: 0, args: null }; + const track = (...args) => { calls.count++; calls.args = args; return true; }; + + const splitClient = { + __getStatus: () => ({ isReady: true }), + on: () => {}, + Event: { SDK_READY: "SDK_READY" }, + track, + getTreatmentWithConfig: () => ({ treatment: "on", config: "" }), + }; + + return { provider: new OpenFeatureSplitProvider({ splitClient }), calls }; +} \ No newline at end of file diff --git a/src/lib/js-split-provider.ts b/src/lib/js-split-provider.ts index 6bcfc1b..ff2d90b 100644 --- a/src/lib/js-split-provider.ts +++ b/src/lib/js-split-provider.ts @@ -7,6 +7,7 @@ import { JsonValue, TargetingKeyMissingError, StandardResolutionReasons, + TrackingEventDetails, } from "@openfeature/server-sdk"; import type SplitIO from "@splitsoftware/splitio/types/splitio"; @@ -53,16 +54,17 @@ export class OpenFeatureSplitProvider implements Provider { flagKey, this.transformContext(context) ); + const treatment = details.value.toLowerCase(); - if ( details.value === "on" || details.value === "true" ) { + if ( treatment === "on" || treatment === "true" ) { return { ...details, value: true }; } - if ( details.value === "off" || details.value === "false" ) { + if ( treatment === "off" || treatment === "false" ) { return { ...details, value: false }; } - throw new ParseError(`Invalid boolean value for ${details.value}`); + throw new ParseError(`Invalid boolean value for ${treatment}`); } async resolveStringEvaluation( @@ -119,7 +121,7 @@ export class OpenFeatureSplitProvider implements Provider { if (value === CONTROL_TREATMENT) { throw new FlagNotFoundError(CONTROL_VALUE_ERROR_MESSAGE); } - const flagMetadata = config ? JSON.parse(config) : undefined; + const flagMetadata = { config: config ? config : '' }; const details: ResolutionDetails = { value: value, variant: value, @@ -130,6 +132,44 @@ export class OpenFeatureSplitProvider implements Provider { } } + async track( + trackingEventName: string, + context: EvaluationContext, + details: TrackingEventDetails + ): Promise { + + // targetingKey is always required + const { targetingKey } = context; + if (targetingKey == null || targetingKey === "") + throw new TargetingKeyMissingError(); + + // eventName is always required + if (trackingEventName == null || trackingEventName === "") + throw new ParseError("Missing eventName, required to track"); + + // trafficType is always required + const ttVal = context["trafficType"]; + const trafficType = + ttVal != null && typeof ttVal === "string" && ttVal.trim() !== "" + ? ttVal + : null; + if (trafficType == null || trafficType === "") + throw new ParseError("Missing trafficType variable, required to track"); + + let value; + let properties: SplitIO.Properties = {}; + if (details != null) { + if (details.value != null) { + value = details.value; + } + if (details.properties != null) { + properties = details.properties as SplitIO.Properties; + } + } + + this.client.track(targetingKey, trafficType, trackingEventName, value, properties); + } + //Transform the context into an object useful for the Split API, an key string with arbitrary Split "Attributes". private transformContext(context: EvaluationContext): Consumer { const { targetingKey, ...attributes } = context;