diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index 0dc3f89..1af6d9c 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -758,6 +758,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -871,6 +872,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], attributes: [ @@ -1403,6 +1405,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -1416,6 +1419,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 3, @@ -1429,6 +1433,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 4, @@ -1442,6 +1447,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 5, @@ -1455,6 +1461,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -1523,6 +1530,7 @@ describe("Context", () => { fullOn: experiment.name === "exp_test_fullon", custom: false, audienceMismatch: false, + ruleOverride: false, }); } @@ -1565,6 +1573,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -1613,6 +1622,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -1653,6 +1663,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + ruleOverride: false, }, ], }, @@ -1693,6 +1704,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + ruleOverride: false, }, ], }, @@ -1754,6 +1766,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 0, @@ -1767,6 +1780,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -1893,6 +1907,7 @@ describe("Context", () => { name: "exp_test_ab", variant: 0, audienceMismatch: true, + ruleOverride: false, assigned: false, }), ], @@ -1918,6 +1933,7 @@ describe("Context", () => { name: "exp_test_ab", variant: 1, audienceMismatch: false, + ruleOverride: false, assigned: true, }), ], @@ -2025,6 +2041,490 @@ describe("Context", () => { }); }); + describe("rules evaluation", () => { + const rulesContextResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "US Internal Users", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + + const envScopedRulesContextResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "Production Only", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: ["production"], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + + const rulesStrictContextResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audienceStrict: true, + audience: JSON.stringify({ + filter: [{ gte: [{ var: "age" }, { value: 20 }] }], + rules: [ + { + or: [ + { + name: "US Users", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + + it("should return rule variant when rule matches", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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 does not match", () => { + client.getEnvironment = jest.fn().mockReturnValue("staging"); + const context = new Context(sdk, contextOptions, contextParams, envScopedRulesContextResponse); + context.attribute("country", "US"); + // Rule scoped to production, SDK is staging — should get normal assignment (2) + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should match rule when environment matches", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, envScopedRulesContextResponse); + context.attribute("country", "US"); + // Rule scoped to production, SDK is production — should get rule variant (1) + expect(context.treatment("exp_test_abc")).toEqual(1); + }); + + it("should override takes priority over rules", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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: true, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + ruleOverride: true, + }); + done(); + }); + }); + + it("should set correct flags in exposure when no rule matches (normal assignment)", (done) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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: true, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + audienceMismatch: true, + ruleOverride: true, + }); + done(); + }); + }); + + it("should set correct flags when override takes priority over rule", (done) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + 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", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const twoRulesContextResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "US Users", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + { + name: "GB Users", + and: [{ eq: [{ var: "country" }, { value: "GB" }] }], + environments: [], + variant: 2, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + 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", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const multiAndResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "US Internal", + and: [ + { eq: [{ var: "country" }, { value: "US" }] }, + { eq: [{ var: "user_type" }, { value: "internal" }] }, + ], + environments: [], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + 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 environments", () => { + client.getEnvironment = jest.fn().mockReturnValue("staging"); + const multiEnvResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "Prod and Staging", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: ["production", "staging"], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + 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", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const multiOrResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "US Users", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + { + name: "GB Users", + and: [{ eq: [{ var: "country" }, { value: "GB" }] }], + environments: [], + variant: 2, + }, + { + name: "FR Users", + and: [{ eq: [{ var: "country" }, { value: "FR" }] }], + environments: [], + variant: 0, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + 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"]); + }); + }); + describe("variableValue()", () => { it("should not return variable values when unassigned", (done) => { const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); @@ -2105,6 +2605,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -2118,6 +2619,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 3, @@ -2131,6 +2633,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 4, @@ -2144,6 +2647,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 5, @@ -2157,6 +2661,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -2270,6 +2775,7 @@ describe("Context", () => { fullOn: experiment.name === "exp_test_fullon", custom: false, audienceMismatch: false, + ruleOverride: false, }); } else { expect(SDK.defaultEventLogger).not.toHaveBeenCalled(); @@ -2335,6 +2841,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -2375,6 +2882,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + ruleOverride: false, }, ], }, @@ -2415,6 +2923,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + ruleOverride: false, }, ], }, @@ -2852,6 +3361,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 3, @@ -2865,6 +3375,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], goals: [ @@ -3086,6 +3597,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 0, @@ -3099,6 +3611,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -3112,6 +3625,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + ruleOverride: false, }, ], goals: [ @@ -3345,6 +3859,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3395,6 +3910,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3594,6 +4110,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -3607,6 +4124,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3655,6 +4173,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3702,6 +4221,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }, { id: 4, @@ -3715,6 +4235,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, + ruleOverride: false, }, ], }, @@ -3771,6 +4292,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + ruleOverride: false, }, { id: 2, @@ -3784,6 +4306,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..f18cff5 100644 --- a/src/__tests__/matcher.test.js +++ b/src/__tests__/matcher.test.js @@ -26,6 +26,349 @@ 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("{}", "production", {})).toBe(null); + expect(matcher.evaluateRules('{"filter":[]}', "production", {})).toBe(null); + }); + + it("should return null when rules is empty array", () => { + expect(matcher.evaluateRules('{"rules":[]}', "production", {})).toBe(null); + }); + + it("should return variant when conditions match", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(1); + }); + + it("should return null when conditions do not match", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "GB" })).toBe(null); + }); + + it("should skip rules with non-matching environments", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: ["staging"], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(null); + }); + + it("should match when environment is in the environments list", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: ["production", "staging"], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(2); + expect(matcher.evaluateRules(audience, "staging", { country: "US" })).toBe(2); + }); + + it("should match all environments when environments is empty", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ value: true }], + environments: [], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(1); + expect(matcher.evaluateRules(audience, "staging", {})).toBe(1); + expect(matcher.evaluateRules(audience, null, {})).toBe(1); + }); + + it("should skip rules when environments is non-empty and environmentName is null", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ value: true }], + environments: ["production"], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, null, {})).toBe(null); + }); + + it("should return first matching rule (first match wins)", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + { + name: "rule2", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(1); + }); + + it("should return variant when conditions are empty (matches all)", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [], + environments: [], + variant: 3, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(3); + }); + + it("should return variant when and field is absent (matches all)", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + environments: [], + variant: 3, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(3); + }); + + it("should handle malformed audience JSON gracefully", () => { + expect(matcher.evaluateRules("not json", "production", {})).toBe(null); + expect(matcher.evaluateRules("", "production", {})).toBe(null); + }); + + it("should return null when rule has no variant property", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [], + environments: [], + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(null); + }); + + it("should return null when variant is not a number", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [], + environments: [], + variant: "bad", + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(null); + }); + + it("should skip rule with invalid variant and continue to next valid rule", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "bad rule", + and: [], + environments: [], + variant: "not a number", + }, + { + name: "good rule", + and: [], + environments: [], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(2); + }); + + it("should skip rule with missing variant and continue to next valid rule", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "no variant", + and: [], + environments: [], + }, + { + name: "good rule", + and: [], + environments: [], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(1); + }); + + it("should handle malformed rules gracefully", () => { + expect(matcher.evaluateRules('{"rules":"not an array"}', "production", {})).toBe(null); + expect(matcher.evaluateRules('{"rules":[{"or":"not an array"}]}', "production", {})).toBe(null); + expect(matcher.evaluateRules('{"rules":[null]}', "production", {})).toBe(null); + }); + + it("should skip to second rule when first does not match", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "GB" }] }], + environments: [], + variant: 1, + }, + { + name: "rule2", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(2); + }); + + it("should evaluate second rule group when first has no match", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "GB" }] }], + environments: [], + variant: 1, + }, + ], + }, + { + or: [ + { + name: "rule2", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(2); + }); + + it("should support variant 0", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ value: true }], + environments: [], + variant: 0, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(0); + }); + }); }); /* diff --git a/src/client.ts b/src/client.ts index 6afdf66..1d4cd3e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -83,6 +83,10 @@ export default class Client { this._delay = 50; } + getEnvironment(): string { + return this._opts.environment; + } + getContext(options?: Partial) { return this.getUnauthed({ ...options, diff --git a/src/context.ts b/src/context.ts index 424a0b4..6d77ebd 100644 --- a/src/context.ts +++ b/src/context.ts @@ -60,6 +60,8 @@ type Assignment = { fullOn: boolean; custom: boolean; audienceMismatch: boolean; + ruleOverride: boolean; + ruleVariant?: number | null; trafficSplit?: number[]; variables?: Record; attrsSeq?: number; @@ -87,6 +89,7 @@ export type Exposure = { fullOn: boolean; custom: boolean; audienceMismatch: boolean; + ruleOverride: boolean; }; export type Attribute = { @@ -127,6 +130,7 @@ export default class Context { private readonly _audienceMatcher: AudienceMatcher; private readonly _cassignments: Record; private readonly _dataProvider: ContextDataProvider; + private readonly _environmentName: string | null; private readonly _eventLogger: EventLogger; private readonly _opts: ContextOptions; private readonly _publisher: ContextPublisher; @@ -166,6 +170,7 @@ export default class Context { this._units = {}; this._assigners = {}; this._audienceMatcher = new AudienceMatcher(); + this._environmentName = sdk.getClient().getEnvironment(); this._attrsSeq = 0; if (params.units) { @@ -462,13 +467,20 @@ 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 attrs = this._getAttributesMap(); + const result = this._audienceMatcher.evaluate(experiment.audience, attrs); const newAudienceMismatch = typeof result === "boolean" ? !result : false; if (newAudienceMismatch !== assignment.audienceMismatch) { return false; } + const ruleVariant = this._audienceMatcher.evaluateRules(experiment.audience, this._environmentName, attrs); + if (ruleVariant !== (assignment.ruleVariant ?? null)) { + return false; + } + + assignment.ruleVariant = ruleVariant; assignment.attrsSeq = this._attrsSeq; } } @@ -482,7 +494,7 @@ export default class Context { if (experimentName in this._assignments) { const assignment = this._assignments[experimentName]; if (hasOverride) { - if (assignment.overridden && assignment.variant === this._overrides[experimentName]) { + if (assignment.overridden && !assignment.assigned && assignment.variant === this._overrides[experimentName]) { // override up-to-date return assignment; } @@ -512,6 +524,7 @@ export default class Context { fullOn: false, custom: false, audienceMismatch: false, + ruleOverride: false, }; this._assignments[experimentName] = assignment; @@ -528,15 +541,28 @@ export default class Context { if (experiment != null) { const unitType = experiment.data.unitType; + let ruleVariant: number | null = null; + if (experiment.data.audience && experiment.data.audience.length > 0) { - const result = this._audienceMatcher.evaluate(experiment.data.audience, this._getAttributesMap()); + const attrs = this._getAttributesMap(); + const result = this._audienceMatcher.evaluate(experiment.data.audience, attrs); if (typeof result === "boolean") { assignment.audienceMismatch = !result; } + + ruleVariant = this._audienceMatcher.evaluateRules(experiment.data.audience, this._environmentName, attrs); } - if (experiment.data.audienceStrict && assignment.audienceMismatch) { + assignment.ruleVariant = ruleVariant; + + if (ruleVariant !== null) { + assignment.assigned = true; + assignment.eligible = true; + assignment.variant = ruleVariant; + assignment.overridden = true; + assignment.ruleOverride = true; + } else if (experiment.data.audienceStrict && assignment.audienceMismatch) { assignment.variant = 0; } else if (experiment.data.fullOnVariant === 0) { if (unitType !== null) { @@ -627,6 +653,7 @@ export default class Context { fullOn: assignment.fullOn, custom: assignment.custom, audienceMismatch: assignment.audienceMismatch, + ruleOverride: assignment.ruleOverride, }; this._logEvent("exposure", exposureEvent); @@ -827,6 +854,7 @@ export default class Context { fullOn: x.fullOn, custom: x.custom, audienceMismatch: x.audienceMismatch, + ruleOverride: x.ruleOverride, })); } diff --git a/src/matcher.ts b/src/matcher.ts index 424988e..8474ca1 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -17,5 +17,45 @@ export class AudienceMatcher { return null; } + evaluateRules(audienceString: string, environmentName: string | null, vars: Record): number | null { + let audience; + try { + audience = JSON.parse(audienceString); + } catch (error) { + console.error(error); + return null; + } + + if (!audience || !Array.isArray(audience.rules)) return null; + + for (const ruleGroup of audience.rules) { + if (!ruleGroup || !Array.isArray(ruleGroup.or)) continue; + for (const rule of ruleGroup.or) { + if (Array.isArray(rule.environments) && rule.environments.length > 0) { + if (environmentName == null || !rule.environments.includes(environmentName)) { + continue; + } + } + + if (typeof rule.variant !== "number") continue; + + const conditions = rule.and; + + if (!conditions || (Array.isArray(conditions) && conditions.length === 0)) { + return rule.variant; + } + + if (!Array.isArray(conditions)) continue; + + const result = this._jsonExpr.evaluateBooleanExpr({ and: conditions }, vars); + if (result === true) { + return rule.variant; + } + } + } + + return null; + } + _jsonExpr = new JsonExpr(); }