|
2 | 2 | using System.Text.Json; |
3 | 3 | using Microsoft.Extensions.Logging.Abstractions; |
4 | 4 | using Microsoft.Extensions.Time.Testing; |
| 5 | +using PostHog; |
5 | 6 | using PostHog.Api; |
6 | 7 | using PostHog.Features; |
7 | 8 | using PostHog.Json; |
@@ -748,6 +749,204 @@ public void ThrowsInconclusiveMatchExceptionWhenUnknownOperator() |
748 | 749 | } |
749 | 750 | } |
750 | 751 |
|
| 752 | +public class TheMixedTargetingEvaluation |
| 753 | +{ |
| 754 | + static LocalEvaluationApiResult CreateMixedFlag() |
| 755 | + { |
| 756 | + return new LocalEvaluationApiResult |
| 757 | + { |
| 758 | + Flags = [ |
| 759 | + new LocalFeatureFlag |
| 760 | + { |
| 761 | + Id = 1, |
| 762 | + TeamId = 1, |
| 763 | + Name = "Mixed Flag", |
| 764 | + Key = "mixed-flag", |
| 765 | + Active = true, |
| 766 | + Filters = new FeatureFlagFilters |
| 767 | + { |
| 768 | + AggregationGroupTypeIndex = null, |
| 769 | + Groups = [ |
| 770 | + new FeatureFlagGroup |
| 771 | + { |
| 772 | + AggregationGroupTypeIndex = 0, |
| 773 | + Properties = [ |
| 774 | + new PropertyFilter |
| 775 | + { |
| 776 | + Type = FilterType.Group, |
| 777 | + Key = "plan", |
| 778 | + Value = new PropertyFilterValue("enterprise"), |
| 779 | + Operator = ComparisonOperator.Exact, |
| 780 | + GroupTypeIndex = 0 |
| 781 | + } |
| 782 | + ], |
| 783 | + RolloutPercentage = 100 |
| 784 | + }, |
| 785 | + new FeatureFlagGroup |
| 786 | + { |
| 787 | + AggregationGroupTypeIndex = null, |
| 788 | + Properties = [ |
| 789 | + new PropertyFilter |
| 790 | + { |
| 791 | + Type = FilterType.Person, |
| 792 | + Key = "email", |
| 793 | + Value = new PropertyFilterValue("test@example.com"), |
| 794 | + Operator = ComparisonOperator.Exact |
| 795 | + } |
| 796 | + ], |
| 797 | + RolloutPercentage = 100 |
| 798 | + } |
| 799 | + ] |
| 800 | + } |
| 801 | + } |
| 802 | + ], |
| 803 | + GroupTypeMapping = new Dictionary<string, string> { { "0", "company" } } |
| 804 | + }; |
| 805 | + } |
| 806 | + |
| 807 | + static LocalEvaluationApiResult CreateOnlyGroupFlag() |
| 808 | + { |
| 809 | + return new LocalEvaluationApiResult |
| 810 | + { |
| 811 | + Flags = [ |
| 812 | + new LocalFeatureFlag |
| 813 | + { |
| 814 | + Id = 2, |
| 815 | + TeamId = 1, |
| 816 | + Name = "Only Group Flag", |
| 817 | + Key = "only-group-flag", |
| 818 | + Active = true, |
| 819 | + Filters = new FeatureFlagFilters |
| 820 | + { |
| 821 | + AggregationGroupTypeIndex = null, |
| 822 | + Groups = [ |
| 823 | + new FeatureFlagGroup |
| 824 | + { |
| 825 | + AggregationGroupTypeIndex = 0, |
| 826 | + Properties = [ |
| 827 | + new PropertyFilter |
| 828 | + { |
| 829 | + Type = FilterType.Group, |
| 830 | + Key = "plan", |
| 831 | + Value = new PropertyFilterValue("enterprise"), |
| 832 | + Operator = ComparisonOperator.Exact, |
| 833 | + GroupTypeIndex = 0 |
| 834 | + } |
| 835 | + ], |
| 836 | + RolloutPercentage = 100 |
| 837 | + } |
| 838 | + ] |
| 839 | + } |
| 840 | + } |
| 841 | + ], |
| 842 | + GroupTypeMapping = new Dictionary<string, string> { { "0", "company" } } |
| 843 | + }; |
| 844 | + } |
| 845 | + |
| 846 | + public static IEnumerable<object?[]> MixedFlagCases => |
| 847 | + [ |
| 848 | + // person condition matches when no groups passed |
| 849 | + ["user-1", null, new Dictionary<string, object?> { ["email"] = "test@example.com" }, true], |
| 850 | + // group condition matches when group props match |
| 851 | + [ |
| 852 | + "user-2", |
| 853 | + new GroupCollection { new Group("company", "acme", new Dictionary<string, object?> { ["plan"] = "enterprise" }) }, |
| 854 | + new Dictionary<string, object?> { ["email"] = "nope@example.com" }, |
| 855 | + true |
| 856 | + ], |
| 857 | + // no match when both person and group fail |
| 858 | + [ |
| 859 | + "user-3", |
| 860 | + new GroupCollection { new Group("company", "acme", new Dictionary<string, object?> { ["plan"] = "free" }) }, |
| 861 | + new Dictionary<string, object?> { ["email"] = "nope@example.com" }, |
| 862 | + false |
| 863 | + ], |
| 864 | + ]; |
| 865 | + |
| 866 | + [Theory] |
| 867 | + [MemberData(nameof(MixedFlagCases))] |
| 868 | + public void EvaluatesMixedFlagAcrossPersonAndGroupConditions( |
| 869 | + string distinctId, |
| 870 | + GroupCollection? groups, |
| 871 | + Dictionary<string, object?>? personProperties, |
| 872 | + bool expected) |
| 873 | + { |
| 874 | + var localEvaluator = new LocalEvaluator(CreateMixedFlag()); |
| 875 | + |
| 876 | + var result = localEvaluator.EvaluateFeatureFlag( |
| 877 | + key: "mixed-flag", |
| 878 | + distinctId: distinctId, |
| 879 | + groups: groups, |
| 880 | + personProperties: personProperties); |
| 881 | + |
| 882 | + Assert.Equal(expected, result); |
| 883 | + } |
| 884 | + |
| 885 | + [Fact] |
| 886 | + public void OnlyGroupConditionWithNoGroupsPassedReturnsFalseWithoutThrowing() |
| 887 | + { |
| 888 | + var localEvaluator = new LocalEvaluator(CreateOnlyGroupFlag()); |
| 889 | + |
| 890 | + var result = localEvaluator.EvaluateFeatureFlag( |
| 891 | + key: "only-group-flag", |
| 892 | + distinctId: "user-1"); |
| 893 | + |
| 894 | + // Group condition skips (no groups passed); no inconclusive raised. |
| 895 | + Assert.Equal(false, result); |
| 896 | + } |
| 897 | + |
| 898 | + // Group keys whose hash against `Hash("rollout-flag", <key>)` straddles the 50% bucket, |
| 899 | + // and a distinct_id whose hash is also outside the bucket. If the matcher regressed to |
| 900 | + // bucketing on distinct_id, both assertions below would yield false and the in-bucket |
| 901 | + // assertion would fail. |
| 902 | + [Theory] |
| 903 | + [InlineData("company-7", true)] // hash ~0.118 → in bucket at 50% |
| 904 | + [InlineData("company-2", false)] // hash ~0.803 → out of bucket at 50% |
| 905 | + public void RolloutUsesGroupKeyForGroupConditionsUnderMixedFlags(string groupKey, bool expected) |
| 906 | + { |
| 907 | + const string flagKey = "rollout-flag"; |
| 908 | + const string distinctId = "user-0"; // Hash("rollout-flag", "user-0") ~0.788 (out at 50%) |
| 909 | + var flags = new LocalEvaluationApiResult |
| 910 | + { |
| 911 | + Flags = [ |
| 912 | + new LocalFeatureFlag |
| 913 | + { |
| 914 | + Id = 3, |
| 915 | + TeamId = 1, |
| 916 | + Name = "Rollout Flag", |
| 917 | + Key = flagKey, |
| 918 | + Active = true, |
| 919 | + Filters = new FeatureFlagFilters |
| 920 | + { |
| 921 | + AggregationGroupTypeIndex = null, |
| 922 | + Groups = [ |
| 923 | + new FeatureFlagGroup |
| 924 | + { |
| 925 | + AggregationGroupTypeIndex = 0, |
| 926 | + Properties = [], |
| 927 | + RolloutPercentage = 50 |
| 928 | + } |
| 929 | + ] |
| 930 | + } |
| 931 | + } |
| 932 | + ], |
| 933 | + GroupTypeMapping = new Dictionary<string, string> { { "0", "company" } } |
| 934 | + }; |
| 935 | + var localEvaluator = new LocalEvaluator(flags); |
| 936 | + var groups = new GroupCollection |
| 937 | + { |
| 938 | + new Group("company", groupKey) |
| 939 | + }; |
| 940 | + |
| 941 | + var result = localEvaluator.EvaluateFeatureFlag( |
| 942 | + key: flagKey, |
| 943 | + distinctId: distinctId, |
| 944 | + groups: groups); |
| 945 | + |
| 946 | + Assert.Equal(expected, result); |
| 947 | + } |
| 948 | +} |
| 949 | + |
751 | 950 | public class TheFlagDependencyEvaluationMethod |
752 | 951 | { |
753 | 952 | static LocalEvaluationApiResult CreateFlagsWithDependencies( |
|
0 commit comments