diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index bac768c..bac8a02 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -764,6 +764,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -878,6 +879,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], attributes: [ @@ -1411,6 +1413,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -1424,6 +1427,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 3, @@ -1437,6 +1441,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 4, @@ -1450,6 +1455,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 5, @@ -1463,6 +1469,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -1531,6 +1538,7 @@ describe("Context", () => { fullOn: experiment.name === "exp_test_fullon", custom: false, audienceMismatch: false, + ruleOverride: false, }); } @@ -1574,6 +1582,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -1623,6 +1632,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -1664,6 +1674,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + ruleOverride: false, }, ], }, @@ -1705,6 +1716,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + ruleOverride: false, }, ], }, @@ -1767,6 +1779,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 0, @@ -1780,6 +1793,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -1906,6 +1920,7 @@ describe("Context", () => { name: "exp_test_ab", variant: 0, audienceMismatch: true, + ruleOverride: false, assigned: false, }), ], @@ -1931,6 +1946,7 @@ describe("Context", () => { name: "exp_test_ab", variant: 1, audienceMismatch: false, + ruleOverride: false, assigned: true, }), ], @@ -2038,6 +2054,577 @@ describe("Context", () => { }); }); + describe("rules evaluation", () => { + const buildRulesResponse = (overrides = {}, responseOverrides = {}) => ({ + ...getContextResponse, + ...responseOverrides, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { ...x, ...overrides }; + } + return x; + }), + }); + + const rulesContextResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "US Internal Users", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 1, + }, + ], + }), + }); + + const envScopedRulesContextResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "Production Only", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [10], + variant: 1, + }, + ], + }), + }, { environment_id: 10 }); + + const rulesStrictContextResponse = buildRulesResponse({ + audienceStrict: true, + audience: JSON.stringify({ + filter: [{ gte: [{ var: "age" }, { value: 20 }] }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "US Users", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 1, + }, + ], + }), + }); + + it("should return rule variant when rule matches", () => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + // Normal assignment would return 2, rules force variant 1 + expect(context.treatment("exp_test_abc")).toEqual(1); + }); + + it("should return normal assignment when no rules match", () => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "GB"); + // No rule matches, should get normal assignment (2) + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should skip rule when environment id does not match", () => { + const stagingResponse = { + ...envScopedRulesContextResponse, + environment_id: 20, + }; + const context = new Context(sdk, contextOptions, contextParams, stagingResponse); + context.attribute("country", "US"); + // Rule scoped to env 10, context has env 20 — should get normal assignment (2) + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should skip environment-scoped rules when API response has no environment_id", () => { + const noEnvIdResponse = { ...envScopedRulesContextResponse }; + delete noEnvIdResponse.environment_id; + const context = new Context(sdk, contextOptions, contextParams, noEnvIdResponse); + context.attribute("country", "US"); + // Rule requires env 10, but no environment_id in response — should get normal assignment + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should match rule when environment id matches", () => { + const context = new Context(sdk, contextOptions, contextParams, envScopedRulesContextResponse); + context.attribute("country", "US"); + // Rule scoped to env 10, context has env 10 — should get rule variant (1) + expect(context.treatment("exp_test_abc")).toEqual(1); + }); + + it("override should take priority over rules", () => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + context.override("exp_test_abc", 0); + expect(context.treatment("exp_test_abc")).toEqual(0); + }); + + it("should set correct flags in exposure when rule matches", (done) => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); + expect(exposure).toMatchObject({ + id: 2, + name: "exp_test_abc", + unit: "session_id", + variant: 1, + assigned: false, + eligible: true, + overridden: false, + fullOn: false, + custom: false, + ruleOverride: true, + }); + done(); + }); + }); + + it("should set correct flags in exposure when no rule matches (normal assignment)", (done) => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "GB"); + expect(context.treatment("exp_test_abc")).toEqual(2); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); + expect(exposure).toMatchObject({ + id: 2, + name: "exp_test_abc", + unit: "session_id", + variant: 2, + assigned: true, + eligible: true, + overridden: false, + fullOn: false, + custom: false, + ruleOverride: false, + }); + done(); + }); + }); + + it("should set correct flags when rule matches with audienceMismatch", (done) => { + const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); + expect(exposure).toMatchObject({ + variant: 1, + assigned: false, + eligible: true, + overridden: false, + fullOn: false, + custom: false, + audienceMismatch: true, + ruleOverride: true, + }); + done(); + }); + }); + + it("should set correct flags when override takes priority over rule", (done) => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + context.override("exp_test_abc", 0); + expect(context.treatment("exp_test_abc")).toEqual(0); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); + expect(exposure).toMatchObject({ + variant: 0, + assigned: false, + overridden: true, + fullOn: false, + custom: false, + ruleOverride: false, + }); + done(); + }); + }); + + it("should set correct override flags even when override variant matches rule variant", (done) => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + + expect(context.treatment("exp_test_abc")).toEqual(1); + + context.override("exp_test_abc", 1); + + expect(context.treatment("exp_test_abc")).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposures = publishCall.exposures.filter((e) => e.name === "exp_test_abc"); + const lastExposure = exposures[exposures.length - 1]; + expect(lastExposure).toMatchObject({ + variant: 1, + assigned: false, + overridden: true, + ruleOverride: false, + }); + done(); + }); + }); + + it("should invalidate cached assignment when rule result changes due to attribute change", () => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + + // First call: no country set, rule doesn't match → normal assignment (2) + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + + // Change attribute so rule now matches + context.attribute("country", "US"); + + // Second call: rule matches → should return rule variant (1), not cached (2) + expect(context.treatment("exp_test_abc")).toEqual(1); + }); + + it("should invalidate cached assignment when rule stops matching due to attribute change", () => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + + // First call: country=US, rule matches → variant 1 + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + // Change attribute so rule no longer matches + context.attribute("country", "GB"); + + // Second call: rule no longer matches → should return normal assignment (2) + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("rule should take priority over audienceStrict when rule matches", () => { + const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); + context.attribute("country", "US"); + // audienceStrict is on, user doesn't match audience filter (no age set), + // but rule matches — should still get rule variant (1) + expect(context.treatment("exp_test_abc")).toEqual(1); + }); + + it("should fall back to audienceStrict behavior when no rule matches", () => { + const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); + context.attribute("country", "GB"); + // No rule matches, audienceStrict on, audience filter mismatch — variant 0 + expect(context.treatment("exp_test_abc")).toEqual(0); + }); + + it("should return correct variableValue when rule forces a different variant", () => { + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + // Rule forces variant 1 (B) which has config {"button.color":"blue"} + // Normal assignment would be variant 2 (C) with {"button.color":"red"} + expect(context.treatment("exp_test_abc")).toEqual(1); + expect(context.variableValue("button.color", "default")).toEqual("blue"); + }); + + it("should invalidate cache when rule switches to a different matching variant", () => { + const twoRulesContextResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "US Users", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 1, + }, + { + name: "GB Users", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "GB" }] }] }, + environments: [], + variant: 2, + }, + ], + }), + }); + const context = new Context(sdk, contextOptions, contextParams, twoRulesContextResponse); + + // First: US → rule variant 1 + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + // Switch to GB → rule variant 2 + context.attribute("country", "GB"); + expect(context.treatment("exp_test_abc")).toEqual(2); + }); + + it("should match rule with multiple and conditions", () => { + const multiAndResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "US Internal", + type: "assign", + conditions: { and: [ + { eq: [{ var: "country" }, { value: "US" }] }, + { eq: [{ var: "user_type" }, { value: "internal" }] }, + ] }, + environments: [], + variant: 1, + }, + ], + }), + }); + const context = new Context(sdk, contextOptions, contextParams, multiAndResponse); + + context.attribute("country", "US"); + context.attribute("user_type", "internal"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + context.attribute("user_type", "external"); + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should match rule scoped to multiple environment ids", () => { + const multiEnvResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "Prod and Staging", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [10, 20], + variant: 1, + }, + ], + }), + }, { environment_id: 20 }); + const context = new Context(sdk, contextOptions, contextParams, multiEnvResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + }); + + it("should evaluate multiple or rules and match the first", () => { + const multiOrResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "US Users", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 1, + }, + { + name: "GB Users", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "GB" }] }] }, + environments: [], + variant: 2, + }, + { + name: "FR Users", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "FR" }] }] }, + environments: [], + variant: 0, + }, + ], + }), + }); + const context = new Context(sdk, contextOptions, contextParams, multiOrResponse); + + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + context.attribute("country", "GB"); + expect(context.treatment("exp_test_abc")).toEqual(2); + + context.attribute("country", "FR"); + expect(context.treatment("exp_test_abc")).toEqual(0); + + context.attribute("country", "DE"); + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should fall back to normal assignment when rule variant is out of bounds", () => { + const oobResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "OOB Rule", + type: "assign", + conditions: null, + environments: [], + variant: 99, + }, + ], + }), + }); + const context = new Context(sdk, contextOptions, contextParams, oobResponse); + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should fall back to normal assignment when rule variant is negative", () => { + const negResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "Negative Rule", + type: "assign", + conditions: null, + environments: [], + variant: -1, + }, + ], + }), + }); + const context = new Context(sdk, contextOptions, contextParams, negResponse); + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should set ruleOverride flag when rule forces control variant (0)", (done) => { + const controlRuleResponse = buildRulesResponse({ + assignmentRules: JSON.stringify({ + rules: [ + { + name: "Force Control", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 0, + }, + ], + }), + }); + const context = new Context(sdk, contextOptions, contextParams, controlRuleResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(0); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); + expect(exposure).toMatchObject({ + variant: 0, + assigned: false, + eligible: true, + overridden: false, + ruleOverride: true, + }); + done(); + }); + }); + + it("should fall back to normal assignment when assignmentRules is invalid JSON", () => { + const badJsonResponse = buildRulesResponse({ + assignmentRules: "not-valid-json{{{", + }); + const context = new Context(sdk, contextOptions, contextParams, badJsonResponse); + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should not invalidate cache when out-of-range rule variant changes to a different out-of-range value", () => { + const oobRulesResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "OOB Rule US", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 99, + }, + { + name: "OOB Rule GB", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "GB" }] }] }, + environments: [], + variant: 100, + }, + ], + }), + }); + const context = new Context(sdk, contextOptions, contextParams, oobRulesResponse); + + context.attribute("country", "US"); + const first = context.treatment("exp_test_abc"); + + context.attribute("country", "GB"); + const second = context.treatment("exp_test_abc"); + + expect(first).toEqual(second); + }); + + it("should normalise out-of-range ruleVariant to null in cached assignment", (done) => { + const oobResponse = buildRulesResponse({ + audience: JSON.stringify({ + filter: [{ value: true }], + }), + assignmentRules: JSON.stringify({ + rules: [ + { + name: "OOB Rule", + type: "assign", + conditions: null, + environments: [], + variant: 99, + }, + ], + }), + }); + const context = new Context(sdk, contextOptions, contextParams, oobResponse); + context.treatment("exp_test_abc"); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); + expect(exposure.ruleOverride).toBe(false); + done(); + }); + }); + }); + describe("variableValue()", () => { it("should not return variable values when unassigned", (done) => { const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); @@ -2119,6 +2706,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -2132,6 +2720,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 3, @@ -2145,6 +2734,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 4, @@ -2158,6 +2748,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 5, @@ -2171,6 +2762,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -2284,6 +2876,7 @@ describe("Context", () => { fullOn: experiment.name === "exp_test_fullon", custom: false, audienceMismatch: false, + ruleOverride: false, }); } else { expect(SDK.defaultEventLogger).not.toHaveBeenCalled(); @@ -2350,6 +2943,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -2391,6 +2985,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + ruleOverride: false, }, ], }, @@ -2432,6 +3027,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + ruleOverride: false, }, ], }, @@ -2872,6 +3468,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 3, @@ -2885,6 +3482,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], goals: [ @@ -3109,6 +3707,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 0, @@ -3122,6 +3721,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -3135,6 +3735,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + ruleOverride: false, }, ], goals: [ @@ -3370,6 +3971,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3421,6 +4023,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3621,6 +4224,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -3634,6 +4238,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3683,6 +4288,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3731,6 +4337,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 4, @@ -3744,6 +4351,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3801,6 +4409,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -3814,6 +4423,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + ruleOverride: false, }, ], }, diff --git a/src/__tests__/matcher.test.js b/src/__tests__/matcher.test.js index 05c345c..f85b333 100644 --- a/src/__tests__/matcher.test.js +++ b/src/__tests__/matcher.test.js @@ -26,6 +26,429 @@ describe("AudienceMatcher", () => { expect(matcher.evaluate('{"filter":[{"not":{"var":"returning"}}]}', { returning: true })).toBe(false); expect(matcher.evaluate('{"filter":[{"not":{"var":"returning"}}]}', { returning: false })).toBe(true); }); + describe("evaluateRules", () => { + it("should return null when no rules in audience", () => { + expect(matcher.evaluateRules("{}", 1, {})).toBe(null); + expect(matcher.evaluateRules('{"filter":[]}', 1, {})).toBe(null); + }); + + it("should return null when rules is empty array", () => { + expect(matcher.evaluateRules('{"rules":[]}', 1, {})).toBe(null); + }); + + it("should return variant when conditions match", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, { country: "US" })).toBe(1); + }); + + it("should return null when conditions do not match", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, { country: "GB" })).toBe(null); + }); + + it("should skip rules with non-matching environment ids", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [2], + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, { country: "US" })).toBe(null); + }); + + it("should match when environment id is in the environments list", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [1, 2], + variant: 2, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, { country: "US" })).toBe(2); + expect(matcher.evaluateRules(audience, 2, { country: "US" })).toBe(2); + }); + + it("should match all environments when environments is empty", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { value: true }, + environments: [], + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(1); + expect(matcher.evaluateRules(audience, 2, {})).toBe(1); + expect(matcher.evaluateRules(audience, null, {})).toBe(1); + }); + + it("should skip rules when environments is non-empty and environmentId is null", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { value: true }, + environments: [1], + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, null, {})).toBe(null); + }); + + it("should return first matching rule (first match wins)", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 1, + }, + { + name: "rule2", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 2, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, { country: "US" })).toBe(1); + }); + + it("should return variant when conditions is null (matches all)", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: null, + environments: [], + variant: 3, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(3); + }); + + it("should return variant when conditions field is absent (matches all)", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + environments: [], + variant: 3, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(3); + }); + + it("should handle malformed audience JSON gracefully", () => { + expect(matcher.evaluateRules("not json", 1, {})).toBe(null); + expect(matcher.evaluateRules("", 1, {})).toBe(null); + }); + + it("should return null when rule has no variant property", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + environments: [], + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(null); + }); + + it("should return null when variant is not a number", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + environments: [], + variant: "bad", + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(null); + }); + + it("should skip rule with invalid variant and continue to next valid rule", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "bad rule", + type: "assign", + environments: [], + variant: "not a number", + }, + { + name: "good rule", + type: "assign", + environments: [], + variant: 2, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(2); + }); + + it("should skip rule with missing variant and continue to next valid rule", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "no variant", + type: "assign", + environments: [], + }, + { + name: "good rule", + type: "assign", + environments: [], + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(1); + }); + + it("should handle malformed rules gracefully", () => { + expect(matcher.evaluateRules('{"rules":"not an array"}', 1, {})).toBe(null); + expect(matcher.evaluateRules('{"rules":[null]}', 1, {})).toBe(null); + }); + + it("should skip rules with non-assign type", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "other", + environments: [], + variant: 1, + }, + { + name: "rule2", + type: "assign", + environments: [], + variant: 2, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(2); + }); + + it("should skip rules with missing type", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + environments: [], + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(null); + }); + + it("should skip to second rule when first does not match", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "GB" }] }] }, + environments: [], + variant: 1, + }, + { + name: "rule2", + type: "assign", + conditions: { and: [{ eq: [{ var: "country" }, { value: "US" }] }] }, + environments: [], + variant: 2, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, { country: "US" })).toBe(2); + }); + + it("should support variant 0", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { value: true }, + environments: [], + variant: 0, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(0); + }); + + it("should skip rule with fractional variant", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + environments: [], + variant: 1.5, + }, + { + name: "rule2", + type: "assign", + environments: [], + variant: 2, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(2); + }); + + it("should skip rule with non-object conditions", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: "invalid", + environments: [], + variant: 1, + }, + { + name: "rule2", + type: "assign", + environments: [], + variant: 2, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(2); + }); + + it("should skip rule when environments is not an array", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { value: true }, + environments: "not-an-array", + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(null); + }); + + it("should not match integer environmentIds against fractional entries in environments list", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { value: true }, + environments: [1.5], + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(null); + expect(matcher.evaluateRules(audience, 2, {})).toBe(null); + }); + + it("should skip environment-scoped rule when environmentId is 0 and not in list", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: { value: true }, + environments: [1, 2], + variant: 1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 0, {})).toBe(null); + }); + + it("should skip rule when conditions evaluation throws and continue to next rule", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "throws", + type: "assign", + conditions: { badOperator: [1, 2] }, + environments: [], + variant: 1, + }, + { + name: "fallback", + type: "assign", + environments: [], + variant: 2, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(2); + }); + + it("should return negative variant (bounds checking is caller responsibility)", () => { + const audience = JSON.stringify({ + rules: [ + { + name: "rule1", + type: "assign", + conditions: null, + environments: [], + variant: -1, + }, + ], + }); + expect(matcher.evaluateRules(audience, 1, {})).toBe(-1); + }); + }); }); /* diff --git a/src/client.ts b/src/client.ts index 3f934bb..e7d4e12 100644 --- a/src/client.ts +++ b/src/client.ts @@ -85,6 +85,10 @@ export default class Client { this._delay = 50; } + getEnvironment(): string { + return this._opts.environment; + } + getContext(options?: Partial) { return this.getUnauthed({ ...options, @@ -298,10 +302,6 @@ export default class Client { return this._opts.application; } - getEnvironment(): string { - return this._opts.environment; - } - getUnauthed(options: ClientRequestOptions) { return this.request({ ...options, diff --git a/src/context.ts b/src/context.ts index 2ca4f42..404e2e3 100644 --- a/src/context.ts +++ b/src/context.ts @@ -31,6 +31,7 @@ export type ExperimentData = { trafficSeedHi: number; trafficSeedLo: number; audience: string; + assignmentRules?: string; audienceStrict: boolean; split: number[]; seedHi: number; @@ -61,6 +62,9 @@ type Assignment = { fullOn: boolean; custom: boolean; audienceMismatch: boolean; + ruleOverride: boolean; + ruleVariant?: number | null; + ruleKey?: string; trafficSplit?: number[]; variables?: Record; attrsSeq?: number; @@ -88,6 +92,7 @@ export type Exposure = { fullOn: boolean; custom: boolean; audienceMismatch: boolean; + ruleOverride: boolean; }; export type Attribute = { @@ -121,6 +126,7 @@ export type ContextOptions = { export type ContextData = { experiments?: ExperimentData[]; + environment_id?: number; }; export default class Context { @@ -129,6 +135,7 @@ export default class Context { private readonly _audienceMatcher: AudienceMatcher; private readonly _cassignments: Record; private readonly _dataProvider: ContextDataProvider; + private _environmentId: number | null; private readonly _eventLogger: EventLogger; private readonly _opts: ContextOptions; private readonly _publisher: ContextPublisher; @@ -168,6 +175,7 @@ export default class Context { this._units = {}; this._assigners = {}; this._audienceMatcher = new AudienceMatcher(); + this._environmentId = null; this._attrsSeq = 0; if (params.units) { @@ -432,6 +440,13 @@ export default class Context { } } + private _computeRuleVariant(assignmentRules: string, variantCount: number, attrs: Record): number | null { + const rawRuleVariant = this._audienceMatcher.evaluateRules(assignmentRules, this._environmentId, attrs); + return rawRuleVariant !== null && rawRuleVariant >= 0 && rawRuleVariant < variantCount + ? rawRuleVariant + : null; + } + private _checkReady(expectNotFinalized?: boolean) { if (!this.isReady()) { throw new Error("ABSmartly Context is not yet ready."); @@ -462,17 +477,43 @@ export default class Context { }; const audienceMatches = (experiment: ExperimentData, assignment: Assignment) => { - if (experiment.audience && experiment.audience.length > 0) { - if (this._attrsSeq > (assignment.attrsSeq ?? 0)) { - const result = this._audienceMatcher.evaluate(experiment.audience, this._getAttributesMap()); + const ruleKey = experiment.assignmentRules + ? `${experiment.assignmentRules}:${this._environmentId}` + : ""; + const ruleKeyChanged = ruleKey !== (assignment.ruleKey ?? ""); + + if (ruleKeyChanged) { + if (!ruleKey && (assignment.ruleVariant != null || assignment.ruleOverride)) { + assignment.ruleVariant = undefined; + assignment.ruleOverride = false; + assignment.ruleKey = undefined; + return false; + } + } + + if (this._attrsSeq > (assignment.attrsSeq ?? 0) || ruleKeyChanged) { + const attrs = this._getAttributesMap(); + + if (experiment.audience && experiment.audience.length > 0) { + const result = this._audienceMatcher.evaluate(experiment.audience, attrs); const newAudienceMismatch = typeof result === "boolean" ? !result : false; if (newAudienceMismatch !== assignment.audienceMismatch) { return false; } + } + + if (experiment.assignmentRules && experiment.assignmentRules.length > 0) { + const ruleVariant = this._computeRuleVariant(experiment.assignmentRules, experiment.variants.length, attrs); + if (ruleVariant !== (assignment.ruleVariant ?? null)) { + return false; + } - assignment.attrsSeq = this._attrsSeq; + assignment.ruleVariant = ruleVariant; } + + assignment.ruleKey = ruleKey; + assignment.attrsSeq = this._attrsSeq; } return true; }; @@ -514,6 +555,7 @@ export default class Context { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }; this._assignments[experimentName] = assignment; @@ -530,15 +572,30 @@ export default class Context { if (experiment != null) { const unitType = experiment.data.unitType; + let ruleVariant: number | null = null; + const attrs = this._getAttributesMap(); + if (experiment.data.audience && experiment.data.audience.length > 0) { - const result = this._audienceMatcher.evaluate(experiment.data.audience, this._getAttributesMap()); + const result = this._audienceMatcher.evaluate(experiment.data.audience, attrs); if (typeof result === "boolean") { assignment.audienceMismatch = !result; } } - if (experiment.data.audienceStrict && assignment.audienceMismatch) { + if (experiment.data.assignmentRules && experiment.data.assignmentRules.length > 0) { + ruleVariant = this._computeRuleVariant(experiment.data.assignmentRules, experiment.data.variants.length, attrs); + } + + assignment.ruleVariant = ruleVariant; + assignment.ruleKey = experiment.data.assignmentRules + ? `${experiment.data.assignmentRules}:${this._environmentId}` + : ""; + + if (ruleVariant !== null && ruleVariant >= 0 && ruleVariant < experiment.data.variants.length) { + assignment.variant = ruleVariant; + assignment.ruleOverride = true; + } else if (experiment.data.audienceStrict && assignment.audienceMismatch) { assignment.variant = 0; } else if (experiment.data.fullOnVariant === 0) { if (unitType !== null) { @@ -629,6 +686,7 @@ export default class Context { fullOn: assignment.fullOn, custom: assignment.custom, audienceMismatch: assignment.audienceMismatch, + ruleOverride: assignment.ruleOverride, }; this._logEvent("exposure", exposureEvent); @@ -731,7 +789,7 @@ export default class Context { this._queueExposure(experimentName, assignment); } - if (key in assignment.variables && (assignment.assigned || assignment.overridden)) { + if (key in assignment.variables && (assignment.assigned || assignment.overridden || assignment.ruleOverride)) { return assignment.variables[key] as string; } } @@ -745,7 +803,7 @@ export default class Context { const experimentName = this._indexVariables[key][i].data.name; const assignment = this._assign(experimentName); if (assignment.variables !== undefined) { - if (key in assignment.variables && (assignment.assigned || assignment.overridden)) { + if (key in assignment.variables && (assignment.assigned || assignment.overridden || assignment.ruleOverride)) { return assignment.variables[key] as string; } } @@ -856,6 +914,7 @@ export default class Context { fullOn: x.fullOn, custom: x.custom, audienceMismatch: x.audienceMismatch, + ruleOverride: x.ruleOverride, })); } @@ -954,6 +1013,7 @@ export default class Context { private _init(data: ContextData, assignments: Record = {}) { this._data = data; + this._environmentId = data.environment_id ?? null; const index: Record = {}; const indexVariables: Record = {}; diff --git a/src/matcher.ts b/src/matcher.ts index 424988e..3b1a993 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -17,5 +17,55 @@ export class AudienceMatcher { return null; } + evaluateRules(assignmentRulesString: string, environmentId: number | null, vars: Record): number | null { + let assignmentRules; + try { + assignmentRules = JSON.parse(assignmentRulesString); + } catch (error) { + console.error(error); + return null; + } + + if (!assignmentRules || !Array.isArray(assignmentRules.rules)) return null; + + for (const rule of assignmentRules.rules) { + if (!rule) continue; + + if (rule.type !== "assign") continue; + + if (rule.environments != null) { + if (!Array.isArray(rule.environments)) continue; + + if (rule.environments.length > 0) { + if (environmentId == null || !rule.environments.includes(environmentId)) { + continue; + } + } + } + + if (typeof rule.variant !== "number") continue; + if (rule.variant !== Math.floor(rule.variant)) continue; + + const conditions = rule.conditions; + + if (conditions == null) { + return rule.variant; + } + + if (!isObject(conditions)) continue; + + try { + const result = this._jsonExpr.evaluateBooleanExpr(conditions, vars); + if (result === true) { + return rule.variant; + } + } catch (e) { + console.warn(`Failed to evaluate assignment rule conditions for variant ${rule.variant}: ${e}`); + } + } + + return null; + } + _jsonExpr = new JsonExpr(); }