From b6361d0c714f1198e42b914ef8633c144b4786ea Mon Sep 17 00:00:00 2001 From: adams85 <31276480+adams85@users.noreply.github.com> Date: Tue, 21 Nov 2023 08:43:06 +0100 Subject: [PATCH] Config v6 (#75) * Update config JSON model to v6 * Refactor evaluator and evaluation logging to prepare it for the new features * Implement segment condition evaluation * Implement prerequisite flag condition evaluation * Implement new comparison operators * Implement SDK key format validation + fix broken tests * Add benchmarks for flag evaluation * Refactor matrix and model tests to load configs directly from CDN instead of local snapshots --- .editorconfig | 5 + .gitignore | 1 + benchmarks/ConfigCat.Client.Benchmarks.sln | 60 + .../ConfigCat.Client.Benchmarks.csproj | 35 + .../FlagEvaluationBenchmark.cs | 68 ++ .../JsonDeserializationBenchmark.cs | 24 +- .../MatrixTestBenchmark.cs | 45 + .../ConfigCat.Client.Benchmarks/Program.cs | 11 + benchmarks/ConfigCatClient.Benchmarks.csproj | 35 - benchmarks/ConfigCatClient.Benchmarks.sln | 39 - .../NewVersionLib/BenchmarkHelper.Shared.cs | 57 + benchmarks/NewVersionLib/BenchmarkHelper.cs | 148 +++ benchmarks/NewVersionLib/ConfigHelper.cs | 10 + benchmarks/NewVersionLib/NewVersionLib.csproj | 33 + benchmarks/NewVersionLib/NullLogger.cs | 14 + benchmarks/OldVersionLib/BenchmarkHelper.cs | 104 ++ benchmarks/OldVersionLib/OldVersionLib.csproj | 36 + .../OldVersionLib/data/sample_v5_old.json | 334 ++++++ benchmarks/Program.cs | 14 - .../BasicConfigCatClientIntegrationTests.cs | 14 +- .../ConfigCacheTests.cs | 8 +- .../ConfigCat.Client.Tests.csproj | 82 +- .../ConfigCatClientTests.cs | 120 +- .../ConfigEvaluatorTestsBase.cs | 114 -- .../ConfigV1EvaluationTests.cs | 130 +++ .../ConfigV2EvaluationTests.cs | 589 ++++++++++ .../DataGovernanceTests.cs | 28 +- .../DeserializerTests.cs | 2 +- .../EvaluationLogTests.cs | 330 ++++++ ...aluatorTests.cs => EvaluationTestsBase.cs} | 38 +- .../Helpers/ConfigHelper.cs | 14 +- .../Helpers/ConfigLocation.Cdn.cs | 50 + .../Helpers/ConfigLocation.LocalFile.cs | 28 + .../Helpers/ConfigLocation.cs | 14 + .../Helpers/LoggerExtensions.cs | 9 - .../Helpers/LoggingHelper.cs | 38 + .../MatrixTestRunner.cs | 23 + .../MatrixTestRunnerBase.cs | 158 +++ src/ConfigCat.Client.Tests/ModelTests.cs | 175 +++ .../NumericConfigEvaluatorTests.cs | 11 - src/ConfigCat.Client.Tests/OverrideTests.cs | 27 +- .../SemanticVersion2ConfigEvaluatorTests.cs | 11 - .../SemanticVersionConfigEvaluatorTests.cs | 11 - .../SensitiveConfigEvaluatorTests.cs | 11 - src/ConfigCat.Client.Tests/UserTests.cs | 15 +- src/ConfigCat.Client.Tests/UtilsTest.cs | 119 +- .../data/evaluationlog/1_targeting_rule.json | 41 + .../1_rule_matching_targeted_attribute.txt | 4 + .../1_rule_no_targeted_attribute.txt | 6 + .../1_targeting_rule/1_rule_no_user.txt | 6 + ...1_rule_not_matching_targeted_attribute.txt | 4 + .../data/evaluationlog/2_targeting_rules.json | 41 + .../2_rules_matching_targeted_attribute.txt | 7 + .../2_rules_no_targeted_attribute.txt | 9 + .../2_targeting_rules/2_rules_no_user.txt | 8 + ..._rules_not_matching_targeted_attribute.txt | 7 + .../_overrides/test_list_truncation.json | 83 ++ .../data/evaluationlog/and_rules.json | 22 + .../and_rules/and_rules_no_user.txt | 7 + .../and_rules/and_rules_user.txt | 7 + .../data/evaluationlog/comparators.json | 20 + .../evaluationlog/comparators/allinone.txt | 57 + .../evaluationlog/epoch_date_validation.json | 16 + .../epoch_date_validation/date_error.txt | 7 + .../data/evaluationlog/list_truncation.json | 14 + .../list_truncation/list_truncation.txt | 7 + .../data/evaluationlog/number_validation.json | 16 + .../number_validation/number_error.txt | 6 + .../options_after_targeting_rule.json | 41 + ...eting_rule_matching_targeted_attribute.txt | 4 + ...r_targeting_rule_no_targeted_attribute.txt | 9 + .../options_after_targeting_rule_no_user.txt | 7 + ...g_rule_not_matching_targeted_attribute.txt | 7 + .../options_based_on_custom_attr.json | 31 + .../matching_options_custom_attribute.txt | 5 + .../no_options_custom_attribute.txt | 4 + .../options_custom_attribute_no_user.txt | 4 + .../options_based_on_user_id.json | 21 + .../options_user_attribute_no_user.txt | 4 + .../options_user_attribute_user.txt | 5 + .../options_within_targeting_rule.json | 52 + ...argeted_attribute_no_options_attribute.txt | 7 + ...g_targeted_attribute_options_attribute.txt | 7 + ...n_targeting_rule_no_targeted_attribute.txt | 6 + .../options_within_targeting_rule_no_user.txt | 6 + ...g_rule_not_matching_targeted_attribute.txt | 4 + .../data/evaluationlog/prerequisite_flag.json | 35 + .../prerequisite_flag/prerequisite_flag.txt | 32 + ...erequisite_flag_no_user_needed_by_both.txt | 38 + ...rerequisite_flag_no_user_needed_by_dep.txt | 15 + ...equisite_flag_no_user_needed_by_prereq.txt | 18 + .../data/evaluationlog/segment.json | 41 + .../segment/segment_matching.txt | 11 + .../segment/segment_no_matching.txt | 11 + .../segment/segment_no_targeted_attribute.txt | 13 + .../evaluationlog/segment/segment_no_user.txt | 6 + .../data/evaluationlog/semver_validation.json | 26 + .../semver_validation/semver_error.txt | 9 + .../semver_relations_error.txt | 18 + .../data/evaluationlog/simple_value.json | 37 + .../simple_value/double_setting.txt | 2 + .../simple_value/int_setting.txt | 2 + .../evaluationlog/simple_value/off_flag.txt | 2 + .../evaluationlog/simple_value/on_flag.txt | 2 + .../simple_value/text_setting.txt | 2 + .../data/sample_number_v5.json | 85 -- .../data/sample_semantic_2_v5.json | 579 --------- .../data/sample_semantic_v5.json | 219 ---- .../data/sample_sensitive_v5.json | 63 - .../data/sample_v5.json | 600 ++++++---- .../data/sample_variationid_v5.json | 276 +++-- .../data/test_circulardependency_v6.json | 80 ++ .../data/test_json_complex.json | 32 +- .../data/test_json_simple.json | 4 +- .../data/test_override_flagdependency_v6.json | 44 + .../data/test_override_segments_v6.json | 66 ++ .../data/testmatrix_and_or.csv | 15 + .../data/testmatrix_comparators_v6.csv | 24 + .../data/testmatrix_prerequisite_flag.csv | 5 + .../data/testmatrix_segments.csv | 6 + .../data/testmatrix_segments_old.csv | 6 + .../data/testmatrix_semantic_2.csv | 2 +- .../data/testmatrix_unicode.csv | 14 + .../data/testmatrix_variationid.csv | 4 +- .../strongname.snk => ConfigCatClient.snk} | Bin src/ConfigCatClient/ConfigCatClient.cs | 46 +- src/ConfigCatClient/ConfigCatClient.csproj | 5 +- .../ConfigService/ConfigServiceBase.cs | 2 +- .../Configuration/ConfigCatClientOptions.cs | 2 +- .../Evaluation/EvaluateContext.cs | 49 + .../Evaluation/EvaluateLogHelper.cs | 349 ++++++ .../Evaluation/EvaluateLogger.cs | 36 - .../Evaluation/EvaluateResult.cs | 25 +- .../Evaluation/EvaluationDetails.cs | 91 +- .../Evaluation/IRolloutEvaluator.cs | 4 +- .../Evaluation/RolloutEvaluator.cs | 1038 +++++++++++++---- .../Evaluation/RolloutEvaluatorExtensions.cs | 51 +- .../Extensions/ObjectExtensions.cs | 213 ++-- .../Extensions/SerializationExtensions.cs | 8 +- .../Extensions/StringExtensions.cs | 37 +- .../Extensions/TypeExtensions.cs | 4 +- src/ConfigCatClient/HttpConfigFetcher.cs | 8 +- .../Logging/FormattableLogMessage.cs | 14 +- src/ConfigCatClient/Logging/LogMessages.cs | 25 +- src/ConfigCatClient/Logging/LoggerWrapper.cs | 6 +- src/ConfigCatClient/Models/Comparator.cs | 97 -- src/ConfigCatClient/Models/Condition.cs | 17 + .../Models/ConditionContainer.cs | 54 + src/ConfigCatClient/Models/Config.cs | 104 ++ .../Models/PercentageOption.cs | 38 + src/ConfigCatClient/Models/Preferences.cs | 19 +- .../Models/PrerequisiteFlagComparator.cs | 17 + .../Models/PrerequisiteFlagCondition.cs | 76 ++ .../Models/RolloutPercentageItem.cs | 77 -- src/ConfigCatClient/Models/RolloutRule.cs | 127 -- src/ConfigCatClient/Models/Segment.cs | 68 ++ .../Models/SegmentComparator.cs | 17 + .../Models/SegmentCondition.cs | 73 ++ src/ConfigCatClient/Models/Setting.cs | 108 +- src/ConfigCatClient/Models/SettingType.cs | 4 - src/ConfigCatClient/Models/SettingValue.cs | 131 +++ .../Models/SettingValueContainer.cs | 49 + .../Models/SettingsWithPreferences.cs | 47 - src/ConfigCatClient/Models/TargetingRule.cs | 107 ++ src/ConfigCatClient/Models/UserComparator.cs | 187 +++ src/ConfigCatClient/Models/UserCondition.cs | 117 ++ src/ConfigCatClient/NullableAttributes.cs | 5 + .../Override/LocalFileDataSource.cs | 2 +- src/ConfigCatClient/ProjectConfig.cs | 9 +- src/ConfigCatClient/User.cs | 86 +- src/ConfigCatClient/Utils/ArrayUtils.cs | 33 + src/ConfigCatClient/Utils/DateTimeUtils.cs | 58 +- .../Utils/IndentedTextBuilder.cs | 99 ++ src/ConfigCatClient/Utils/ModelHelper.cs | 38 + .../Utils/StringListFormatter.cs | 79 ++ src/ConfigCatClient/strongname.snk | Bin 596 -> 0 bytes 176 files changed, 7647 insertions(+), 2614 deletions(-) create mode 100644 benchmarks/ConfigCat.Client.Benchmarks.sln create mode 100644 benchmarks/ConfigCat.Client.Benchmarks/ConfigCat.Client.Benchmarks.csproj create mode 100644 benchmarks/ConfigCat.Client.Benchmarks/FlagEvaluationBenchmark.cs rename benchmarks/{ => ConfigCat.Client.Benchmarks}/JsonDeserializationBenchmark.cs (57%) create mode 100644 benchmarks/ConfigCat.Client.Benchmarks/MatrixTestBenchmark.cs create mode 100644 benchmarks/ConfigCat.Client.Benchmarks/Program.cs delete mode 100644 benchmarks/ConfigCatClient.Benchmarks.csproj delete mode 100644 benchmarks/ConfigCatClient.Benchmarks.sln create mode 100644 benchmarks/NewVersionLib/BenchmarkHelper.Shared.cs create mode 100644 benchmarks/NewVersionLib/BenchmarkHelper.cs create mode 100644 benchmarks/NewVersionLib/ConfigHelper.cs create mode 100644 benchmarks/NewVersionLib/NewVersionLib.csproj create mode 100644 benchmarks/NewVersionLib/NullLogger.cs create mode 100644 benchmarks/OldVersionLib/BenchmarkHelper.cs create mode 100644 benchmarks/OldVersionLib/OldVersionLib.csproj create mode 100644 benchmarks/OldVersionLib/data/sample_v5_old.json delete mode 100644 benchmarks/Program.cs delete mode 100644 src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs create mode 100644 src/ConfigCat.Client.Tests/ConfigV1EvaluationTests.cs create mode 100644 src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs create mode 100644 src/ConfigCat.Client.Tests/EvaluationLogTests.cs rename src/ConfigCat.Client.Tests/{BasicConfigEvaluatorTests.cs => EvaluationTestsBase.cs} (76%) create mode 100644 src/ConfigCat.Client.Tests/Helpers/ConfigLocation.Cdn.cs create mode 100644 src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs create mode 100644 src/ConfigCat.Client.Tests/Helpers/ConfigLocation.cs delete mode 100644 src/ConfigCat.Client.Tests/Helpers/LoggerExtensions.cs create mode 100644 src/ConfigCat.Client.Tests/Helpers/LoggingHelper.cs create mode 100644 src/ConfigCat.Client.Tests/MatrixTestRunner.cs create mode 100644 src/ConfigCat.Client.Tests/MatrixTestRunnerBase.cs create mode 100644 src/ConfigCat.Client.Tests/ModelTests.cs delete mode 100644 src/ConfigCat.Client.Tests/NumericConfigEvaluatorTests.cs delete mode 100644 src/ConfigCat.Client.Tests/SemanticVersion2ConfigEvaluatorTests.cs delete mode 100644 src/ConfigCat.Client.Tests/SemanticVersionConfigEvaluatorTests.cs delete mode 100644 src/ConfigCat.Client.Tests/SensitiveConfigEvaluatorTests.cs create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_matching_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_no_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_no_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_matching_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_no_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_no_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/_overrides/test_list_truncation.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/and_rules.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/and_rules/and_rules_no_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/and_rules/and_rules_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/comparators.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/comparators/allinone.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/epoch_date_validation.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/epoch_date_validation/date_error.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/list_truncation.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/list_truncation/list_truncation.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/number_validation.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/number_validation/number_error.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_no_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/matching_options_custom_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/no_options_custom_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/options_custom_attribute_no_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id/options_user_attribute_no_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id/options_user_attribute_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_no_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/segment.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_matching.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_matching.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_targeted_attribute.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_user.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation/semver_error.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation/semver_relations_error.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/simple_value.json create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/double_setting.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/int_setting.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/off_flag.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/on_flag.txt create mode 100644 src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/text_setting.txt delete mode 100644 src/ConfigCat.Client.Tests/data/sample_number_v5.json delete mode 100644 src/ConfigCat.Client.Tests/data/sample_semantic_2_v5.json delete mode 100644 src/ConfigCat.Client.Tests/data/sample_semantic_v5.json delete mode 100644 src/ConfigCat.Client.Tests/data/sample_sensitive_v5.json create mode 100644 src/ConfigCat.Client.Tests/data/test_circulardependency_v6.json create mode 100644 src/ConfigCat.Client.Tests/data/test_override_flagdependency_v6.json create mode 100644 src/ConfigCat.Client.Tests/data/test_override_segments_v6.json create mode 100644 src/ConfigCat.Client.Tests/data/testmatrix_and_or.csv create mode 100644 src/ConfigCat.Client.Tests/data/testmatrix_comparators_v6.csv create mode 100644 src/ConfigCat.Client.Tests/data/testmatrix_prerequisite_flag.csv create mode 100644 src/ConfigCat.Client.Tests/data/testmatrix_segments.csv create mode 100644 src/ConfigCat.Client.Tests/data/testmatrix_segments_old.csv create mode 100644 src/ConfigCat.Client.Tests/data/testmatrix_unicode.csv rename src/{ConfigCat.Client.Tests/strongname.snk => ConfigCatClient.snk} (100%) create mode 100644 src/ConfigCatClient/Evaluation/EvaluateContext.cs create mode 100644 src/ConfigCatClient/Evaluation/EvaluateLogHelper.cs delete mode 100644 src/ConfigCatClient/Evaluation/EvaluateLogger.cs delete mode 100644 src/ConfigCatClient/Models/Comparator.cs create mode 100644 src/ConfigCatClient/Models/Condition.cs create mode 100644 src/ConfigCatClient/Models/ConditionContainer.cs create mode 100644 src/ConfigCatClient/Models/Config.cs create mode 100644 src/ConfigCatClient/Models/PercentageOption.cs create mode 100644 src/ConfigCatClient/Models/PrerequisiteFlagComparator.cs create mode 100644 src/ConfigCatClient/Models/PrerequisiteFlagCondition.cs delete mode 100644 src/ConfigCatClient/Models/RolloutPercentageItem.cs delete mode 100644 src/ConfigCatClient/Models/RolloutRule.cs create mode 100644 src/ConfigCatClient/Models/Segment.cs create mode 100644 src/ConfigCatClient/Models/SegmentComparator.cs create mode 100644 src/ConfigCatClient/Models/SegmentCondition.cs create mode 100644 src/ConfigCatClient/Models/SettingValue.cs create mode 100644 src/ConfigCatClient/Models/SettingValueContainer.cs delete mode 100644 src/ConfigCatClient/Models/SettingsWithPreferences.cs create mode 100644 src/ConfigCatClient/Models/TargetingRule.cs create mode 100644 src/ConfigCatClient/Models/UserComparator.cs create mode 100644 src/ConfigCatClient/Models/UserCondition.cs create mode 100644 src/ConfigCatClient/Utils/IndentedTextBuilder.cs create mode 100644 src/ConfigCatClient/Utils/ModelHelper.cs create mode 100644 src/ConfigCatClient/Utils/StringListFormatter.cs delete mode 100644 src/ConfigCatClient/strongname.snk diff --git a/.editorconfig b/.editorconfig index 5d928536..a7a7ca38 100644 --- a/.editorconfig +++ b/.editorconfig @@ -305,6 +305,11 @@ indent_size = 2 charset = utf-8 indent_size = 2 +# Json files +[*.json] +charset = utf-8 +indent_size = 2 + # Shell scripts [*.sh] end_of_line = lf diff --git a/.gitignore b/.gitignore index 5369e4c1..b79da535 100644 --- a/.gitignore +++ b/.gitignore @@ -288,6 +288,7 @@ __pycache__/ *.xsd.cs # Custom +BenchmarkDotNet.Artifacts*/ coverage.xml .DS_Store .vscode diff --git a/benchmarks/ConfigCat.Client.Benchmarks.sln b/benchmarks/ConfigCat.Client.Benchmarks.sln new file mode 100644 index 00000000..a1d17bf8 --- /dev/null +++ b/benchmarks/ConfigCat.Client.Benchmarks.sln @@ -0,0 +1,60 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.6.33815.320 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigCat.Client.Benchmarks", "ConfigCat.Client.Benchmarks\ConfigCat.Client.Benchmarks.csproj", "{B7381881-0709-4F72-AE6C-3778979CD8C1}" + ProjectSection(ProjectDependencies) = postProject + {B51439A6-F230-46E5-9BC3-7E4E9FA841FC} = {B51439A6-F230-46E5-9BC3-7E4E9FA841FC} + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigCatClient", "..\src\ConfigCatClient\ConfigCatClient.csproj", "{B51439A6-F230-46E5-9BC3-7E4E9FA841FC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OldVersionLib", "OldVersionLib\OldVersionLib.csproj", "{53015044-8ED1-4F77-BB02-357313F7952A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NewVersionLib", "NewVersionLib\NewVersionLib.csproj", "{30B22E19-6701-4C36-B1F4-72AE24E93CEA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProjectReferences", "ProjectReferences", "{3B9B9CF8-8D20-423D-A327-60D2D2C77976}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Benchmark|Any CPU = Benchmark|Any CPU + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B7381881-0709-4F72-AE6C-3778979CD8C1}.Benchmark|Any CPU.ActiveCfg = Release|Any CPU + {B7381881-0709-4F72-AE6C-3778979CD8C1}.Benchmark|Any CPU.Build.0 = Release|Any CPU + {B7381881-0709-4F72-AE6C-3778979CD8C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7381881-0709-4F72-AE6C-3778979CD8C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7381881-0709-4F72-AE6C-3778979CD8C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7381881-0709-4F72-AE6C-3778979CD8C1}.Release|Any CPU.Build.0 = Release|Any CPU + {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Benchmark|Any CPU.ActiveCfg = Benchmark|Any CPU + {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Benchmark|Any CPU.Build.0 = Benchmark|Any CPU + {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Release|Any CPU.Build.0 = Release|Any CPU + {53015044-8ED1-4F77-BB02-357313F7952A}.Benchmark|Any CPU.ActiveCfg = Release|Any CPU + {53015044-8ED1-4F77-BB02-357313F7952A}.Benchmark|Any CPU.Build.0 = Release|Any CPU + {53015044-8ED1-4F77-BB02-357313F7952A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53015044-8ED1-4F77-BB02-357313F7952A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53015044-8ED1-4F77-BB02-357313F7952A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53015044-8ED1-4F77-BB02-357313F7952A}.Release|Any CPU.Build.0 = Release|Any CPU + {30B22E19-6701-4C36-B1F4-72AE24E93CEA}.Benchmark|Any CPU.ActiveCfg = Release|Any CPU + {30B22E19-6701-4C36-B1F4-72AE24E93CEA}.Benchmark|Any CPU.Build.0 = Release|Any CPU + {30B22E19-6701-4C36-B1F4-72AE24E93CEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30B22E19-6701-4C36-B1F4-72AE24E93CEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30B22E19-6701-4C36-B1F4-72AE24E93CEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30B22E19-6701-4C36-B1F4-72AE24E93CEA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B51439A6-F230-46E5-9BC3-7E4E9FA841FC} = {3B9B9CF8-8D20-423D-A327-60D2D2C77976} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {71FC06CD-80AD-4090-863E-1965313C9027} + EndGlobalSection +EndGlobal diff --git a/benchmarks/ConfigCat.Client.Benchmarks/ConfigCat.Client.Benchmarks.csproj b/benchmarks/ConfigCat.Client.Benchmarks/ConfigCat.Client.Benchmarks.csproj new file mode 100644 index 00000000..d6560026 --- /dev/null +++ b/benchmarks/ConfigCat.Client.Benchmarks/ConfigCat.Client.Benchmarks.csproj @@ -0,0 +1,35 @@ + + + + Exe + net48;net6.0 + 10.0 + enable + nullable + true + ..\..\src\ConfigCatClient.snk + + + + + + + + + + Configuration=Benchmark + from_project + + + + + + + + + from_nuget + + + + + diff --git a/benchmarks/ConfigCat.Client.Benchmarks/FlagEvaluationBenchmark.cs b/benchmarks/ConfigCat.Client.Benchmarks/FlagEvaluationBenchmark.cs new file mode 100644 index 00000000..91fd1802 --- /dev/null +++ b/benchmarks/ConfigCat.Client.Benchmarks/FlagEvaluationBenchmark.cs @@ -0,0 +1,68 @@ +extern alias from_nuget; +extern alias from_project; + +using System; +using BenchmarkDotNet.Attributes; + +namespace ConfigCat.Client.Benchmarks; + +[MemoryDiagnoser] +public class FlagEvaluationBenchmark +{ + private object evaluationServicesOld = null!; + private from_nuget::ConfigCat.Client.User userOld = null!; + + private object evaluationServicesNew = null!; + private from_project::ConfigCat.Client.User userNew = null!; + + [GlobalSetup] + public void Setup() + { + Environment.CurrentDirectory = AppContext.BaseDirectory; + + this.evaluationServicesOld = Old.BenchmarkHelper.CreateEvaluationServices(LogInfo); + this.userOld = new("Cat") { Email = "cat@configcat.com", Custom = { ["Version"] = "1.1.1", ["Number"] = "1" } }; + + this.evaluationServicesNew = New.BenchmarkHelper.CreateEvaluationServices(LogInfo); + this.userNew = new("Cat") { Email = "cat@configcat.com", Custom = { ["Version"] = "1.1.1", ["Number"] = "1" } }; + } + + [Params(false, true)] + public bool LogInfo { get; set; } + + [Benchmark] + public object Basic_ConfigV5() + { + return Old.BenchmarkHelper.Evaluate(this.evaluationServicesOld, "basicFlag", false); + } + + [Benchmark] + public object Basic_ConfigV6() + { + return New.BenchmarkHelper.Evaluate(this.evaluationServicesNew, "basicFlag", false); + } + + [Benchmark] + public object Complex_ConfigV5() + { + return Old.BenchmarkHelper.Evaluate(this.evaluationServicesOld, "complexFlag", "", this.userOld); + } + + [Benchmark] + public object Complex_ConfigV6() + { + return New.BenchmarkHelper.Evaluate(this.evaluationServicesNew, "complexFlag", "", this.userNew); + } + + [Benchmark] + public object All_ConfigV5() + { + return Old.BenchmarkHelper.EvaluateAll(this.evaluationServicesOld, this.userOld); + } + + [Benchmark] + public object All_ConfigV6() + { + return New.BenchmarkHelper.EvaluateAll(this.evaluationServicesNew, this.userNew); + } +} diff --git a/benchmarks/JsonDeserializationBenchmark.cs b/benchmarks/ConfigCat.Client.Benchmarks/JsonDeserializationBenchmark.cs similarity index 57% rename from benchmarks/JsonDeserializationBenchmark.cs rename to benchmarks/ConfigCat.Client.Benchmarks/JsonDeserializationBenchmark.cs index 0010593a..36900bbf 100644 --- a/benchmarks/JsonDeserializationBenchmark.cs +++ b/benchmarks/ConfigCat.Client.Benchmarks/JsonDeserializationBenchmark.cs @@ -1,24 +1,24 @@ -extern alias from_nuget; +extern alias from_nuget; extern alias from_project; using BenchmarkDotNet.Attributes; using System; -namespace ConfigCatClient.Benchmarks; +namespace ConfigCat.Client.Benchmarks; [MemoryDiagnoser] public class JsonDeserializationBenchmark { - private readonly from_project::ConfigCat.Client.IConfigCatClient newClient = from_project::ConfigCat.Client.ConfigCatClientBuilder - .Initialize("rv3YCMKenkaM7xkOCVQfeg/-I_w49WSQUWdZypPPM4Yyg") - .WithManualPoll() - .WithBaseUrl(new Uri("https://test-cdn-global.configcat.com")) - .Create(); + private readonly from_project::ConfigCat.Client.IConfigCatClient newClient = from_project::ConfigCat.Client.ConfigCatClient.Get("rv3YCMKenkaM7xkOCVQfeg/-I_w49WSQUWdZypPPM4Yyg", o => + { + o.PollingMode = from_project::ConfigCat.Client.PollingModes.ManualPoll; + o.BaseUrl = new Uri("https://test-cdn-global.configcat.com"); + }); - private readonly from_nuget::ConfigCat.Client.IConfigCatClient oldClient = from_nuget::ConfigCat.Client.ConfigCatClientBuilder - .Initialize("rv3YCMKenkaM7xkOCVQfeg/-I_w49WSQUWdZypPPM4Yyg") - .WithManualPoll() - .WithBaseUrl(new Uri("https://test-cdn-global.configcat.com")) - .Create(); + private readonly from_nuget::ConfigCat.Client.IConfigCatClient oldClient = from_nuget::ConfigCat.Client.ConfigCatClient.Get("rv3YCMKenkaM7xkOCVQfeg/-I_w49WSQUWdZypPPM4Yyg", o => + { + o.PollingMode = from_nuget::ConfigCat.Client.PollingModes.ManualPoll; + o.BaseUrl = new Uri("https://test-cdn-global.configcat.com"); + }); private readonly from_project::ConfigCat.Client.User newUser = new("test@test.com"); private readonly from_nuget::ConfigCat.Client.User oldUser = new("test@test.com"); diff --git a/benchmarks/ConfigCat.Client.Benchmarks/MatrixTestBenchmark.cs b/benchmarks/ConfigCat.Client.Benchmarks/MatrixTestBenchmark.cs new file mode 100644 index 00000000..6d8fa245 --- /dev/null +++ b/benchmarks/ConfigCat.Client.Benchmarks/MatrixTestBenchmark.cs @@ -0,0 +1,45 @@ +using System; +using BenchmarkDotNet.Attributes; + +namespace ConfigCat.Client.Benchmarks; + +[MemoryDiagnoser] +public class MatrixTestBenchmark +{ + private Old.MatrixTestRunnerBase testRunnerOld = null!; + private object evaluationServicesOld = null!; + + private New.MatrixTestRunnerBase testRunnerNew = null!; + private object evaluationServicesNew = null!; + + private object?[][] tests = null!; + + [GlobalSetup] + public void Setup() + { + Environment.CurrentDirectory = AppContext.BaseDirectory; + + this.testRunnerOld = new(); + this.evaluationServicesOld = Old.BenchmarkHelper.CreateEvaluationServices(LogInfo); + + this.testRunnerNew = new(); + this.evaluationServicesNew = New.BenchmarkHelper.CreateEvaluationServices(LogInfo); + + this.tests = New.BenchmarkHelper.GetMatrixTests(); + } + + [Params(false, true)] + public bool LogInfo { get; set; } + + [Benchmark] + public int MatrixTests_ConfigV5() + { + return Old.BenchmarkHelper.RunAllMatrixTests(this.testRunnerOld, this.evaluationServicesOld, this.tests); + } + + [Benchmark] + public int MatrixTests_ConfigV6() + { + return New.BenchmarkHelper.RunAllMatrixTests(this.testRunnerNew, this.evaluationServicesNew, this.tests); + } +} diff --git a/benchmarks/ConfigCat.Client.Benchmarks/Program.cs b/benchmarks/ConfigCat.Client.Benchmarks/Program.cs new file mode 100644 index 00000000..a84eebc9 --- /dev/null +++ b/benchmarks/ConfigCat.Client.Benchmarks/Program.cs @@ -0,0 +1,11 @@ +using BenchmarkDotNet.Running; + +namespace ConfigCatClient.Benchmarks; + +internal class Program +{ + private static void Main(string[] args) + { + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + } +} diff --git a/benchmarks/ConfigCatClient.Benchmarks.csproj b/benchmarks/ConfigCatClient.Benchmarks.csproj deleted file mode 100644 index 60965e3e..00000000 --- a/benchmarks/ConfigCatClient.Benchmarks.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - - Exe - net6.0 - Debug;Release;Benchmark - - - - true - - - - - - - - - - - ..\src\ConfigCatClient\bin\Benchmark\netstandard2.1\ConfigCat.Client.Benchmark.dll - from_project - true - - - - - - - from_nuget - - - - - diff --git a/benchmarks/ConfigCatClient.Benchmarks.sln b/benchmarks/ConfigCatClient.Benchmarks.sln deleted file mode 100644 index 07a8d758..00000000 --- a/benchmarks/ConfigCatClient.Benchmarks.sln +++ /dev/null @@ -1,39 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30907.101 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigCatClient.Benchmarks", "ConfigCatClient.Benchmarks.csproj", "{B7381881-0709-4F72-AE6C-3778979CD8C1}" - ProjectSection(ProjectDependencies) = postProject - {B51439A6-F230-46E5-9BC3-7E4E9FA841FC} = {B51439A6-F230-46E5-9BC3-7E4E9FA841FC} - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigCatClient", "..\src\ConfigCatClient\ConfigCatClient.csproj", "{B51439A6-F230-46E5-9BC3-7E4E9FA841FC}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Benchmark|Any CPU = Benchmark|Any CPU - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {B7381881-0709-4F72-AE6C-3778979CD8C1}.Benchmark|Any CPU.ActiveCfg = Benchmark|Any CPU - {B7381881-0709-4F72-AE6C-3778979CD8C1}.Benchmark|Any CPU.Build.0 = Benchmark|Any CPU - {B7381881-0709-4F72-AE6C-3778979CD8C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B7381881-0709-4F72-AE6C-3778979CD8C1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B7381881-0709-4F72-AE6C-3778979CD8C1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B7381881-0709-4F72-AE6C-3778979CD8C1}.Release|Any CPU.Build.0 = Release|Any CPU - {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Benchmark|Any CPU.ActiveCfg = Benchmark|Any CPU - {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Benchmark|Any CPU.Build.0 = Benchmark|Any CPU - {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B51439A6-F230-46E5-9BC3-7E4E9FA841FC}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {71FC06CD-80AD-4090-863E-1965313C9027} - EndGlobalSection -EndGlobal diff --git a/benchmarks/NewVersionLib/BenchmarkHelper.Shared.cs b/benchmarks/NewVersionLib/BenchmarkHelper.Shared.cs new file mode 100644 index 00000000..c9dad514 --- /dev/null +++ b/benchmarks/NewVersionLib/BenchmarkHelper.Shared.cs @@ -0,0 +1,57 @@ +using System.Linq; +using ConfigCat.Client.Evaluation; + +#if BENCHMARK_OLD +namespace ConfigCat.Client.Benchmarks.Old; +#else +namespace ConfigCat.Client.Benchmarks.New; +#endif + +internal class EvaluationServices +{ + public EvaluationServices(bool logInfo) + { + Logger = new LoggerWrapper(new NullLogger { LogLevel = logInfo ? LogLevel.Info : LogLevel.Warning }); + Evaluator = new RolloutEvaluator(Logger); + } + + public LoggerWrapper Logger { get; } + public RolloutEvaluator Evaluator { get; } +} + +public static partial class BenchmarkHelper +{ + public static object CreateEvaluationServices(bool logInfo) => new EvaluationServices(logInfo); + + public static object?[][] GetMatrixTests() + where TDescriptor : IMatrixTestDescriptor, new() + { + return MatrixTestRunnerBase.GetTests().ToArray(); + } + + public static bool RunMatrixTest(this MatrixTestRunnerBase runner, object evaluationServices, string settingKey, string expectedReturnValue, User? user = null) + where TDescriptor : IMatrixTestDescriptor, new() + { + var services = (EvaluationServices)evaluationServices; + return runner.RunTest(services.Evaluator, services.Logger, settingKey, expectedReturnValue, user); + } + + public static int RunAllMatrixTests(this MatrixTestRunnerBase runner, object evaluationServices, object?[][] tests) + where TDescriptor : IMatrixTestDescriptor, new() + { + var services = (EvaluationServices)evaluationServices; + return runner.RunAllTests(services.Evaluator, services.Logger, tests); + } + + public static EvaluationDetails Evaluate(object evaluationServices, string key, T defaultValue, User? user = null) + { + var services = (EvaluationServices)evaluationServices; + return services.Evaluator.Evaluate(Config.Settings, key, defaultValue, user, remoteConfig: null, services.Logger); + } + + public static EvaluationDetails[] EvaluateAll(object evaluationServices, User? user = null) + { + var services = (EvaluationServices)evaluationServices; + return services.Evaluator.EvaluateAll(Config.Settings, user, remoteConfig: null, services.Logger, "empty array", out _); + } +} diff --git a/benchmarks/NewVersionLib/BenchmarkHelper.cs b/benchmarks/NewVersionLib/BenchmarkHelper.cs new file mode 100644 index 00000000..be1d3127 --- /dev/null +++ b/benchmarks/NewVersionLib/BenchmarkHelper.cs @@ -0,0 +1,148 @@ +using System; +using ConfigCat.Client.Tests.Helpers; + +namespace ConfigCat.Client.Benchmarks.New; + +public static partial class BenchmarkHelper +{ + public class BasicMatrixTestsDescriptor : IMatrixTestDescriptor + { + public ConfigLocation ConfigLocation => new ConfigLocation.LocalFile("data", "sample_v5.json"); + public string MatrixResultFileName => "testmatrix.csv"; + } + + private static readonly Config Config = new Func(() => + { + var config = new Config + { + Preferences = new Preferences + { + Salt = "LKQu1a62agfNnWuGwA8cZglf4x0yZSbY2En7WQn5dWw" + }, + Settings = + { + ["basicFlag"] = new Setting + { + SettingType = SettingType.Boolean, + Value = new SettingValue { BoolValue = true }, + }, + ["complexFlag"] = new Setting + { + SettingType = SettingType.String, + TargetingRules = new[] + { + new TargetingRule + { + Conditions = new[] + { + new ConditionContainer + { + UserCondition = new UserCondition() + { + ComparisonAttribute = nameof(User.Identifier), + Comparator = UserComparator.SensitiveIsOneOf, + StringListValue = new[] + { + "61418c941ecda8031d08ab86ec821e676fde7b6a59cd16b1e7191503c2f8297d", + "2ebea0310612c4c40d183b0c123d9bd425cf54f1e101f42858e701b5077cba01" + } + } + }, + }, + SimpleValue = new SimpleSettingValue { Value = new SettingValue { StringValue = "a" } }, + }, + new TargetingRule + { + Conditions = new[] + { + new ConditionContainer + { + UserCondition = new UserCondition() + { + ComparisonAttribute = nameof(User.Email), + Comparator = UserComparator.ContainsAnyOf, + StringListValue = new[] { "@example.com" } + } + }, + }, + SimpleValue = new SimpleSettingValue { Value = new SettingValue { StringValue = "b" } }, + }, + new TargetingRule + { + Conditions = new[] + { + new ConditionContainer + { + UserCondition = new UserCondition() + { + ComparisonAttribute = "Version", + Comparator = UserComparator.SemVerIsOneOf, + StringListValue = new[] { "1.0.0", "2.0.0" } + } + }, + }, + SimpleValue = new SimpleSettingValue { Value = new SettingValue { StringValue = "c" } }, + }, + new TargetingRule + { + Conditions = new[] + { + new ConditionContainer + { + UserCondition = new UserCondition() + { + ComparisonAttribute = "Version", + Comparator = UserComparator.SemVerGreater, + StringValue = "3.0.0" + } + }, + }, + SimpleValue = new SimpleSettingValue { Value = new SettingValue { StringValue = "d" } }, + }, + new TargetingRule + { + Conditions = new[] + { + new ConditionContainer + { + UserCondition = new UserCondition() + { + ComparisonAttribute = "Number", + Comparator = UserComparator.NumberGreater, + DoubleValue = 3.14 + } + }, + }, + SimpleValue = new SimpleSettingValue { Value = new SettingValue { StringValue = "e" } }, + }, + new TargetingRule + { + PercentageOptions = new[] + { + new PercentageOption + { + Percentage = 20, + Value = new SettingValue { StringValue = "p20" } + }, + new PercentageOption + { + Percentage = 30, + Value = new SettingValue { StringValue = "p30" } + }, + new PercentageOption + { + Percentage = 50, + Value = new SettingValue { StringValue = "p50" } + }, + } + }, + }, + Value = new SettingValue { StringValue = "fallback" } + } + } + }; + + config.OnDeserialized(); + return config; + })(); +} diff --git a/benchmarks/NewVersionLib/ConfigHelper.cs b/benchmarks/NewVersionLib/ConfigHelper.cs new file mode 100644 index 00000000..32fa5dd2 --- /dev/null +++ b/benchmarks/NewVersionLib/ConfigHelper.cs @@ -0,0 +1,10 @@ +#if BENCHMARK_OLD +using Config = ConfigCat.Client.SettingsWithPreferences; +#endif + +namespace ConfigCat.Client.Tests.Helpers; + +internal static class ConfigHelper +{ + public static Config FetchConfigCached(this ConfigLocation location) => location.FetchConfig(); +} diff --git a/benchmarks/NewVersionLib/NewVersionLib.csproj b/benchmarks/NewVersionLib/NewVersionLib.csproj new file mode 100644 index 00000000..d5434022 --- /dev/null +++ b/benchmarks/NewVersionLib/NewVersionLib.csproj @@ -0,0 +1,33 @@ + + + + ConfigCatClientBenchmarks + ConfigCat.Client.Benchmarks.New + net48;net6.0 + 10.0 + enable + nullable + true + ..\..\src\ConfigCatClient.snk + BENCHMARK_NEW;$(DefineConstants) + + + + + Configuration=Benchmark + + + + + + + + + + + + PreserveNewest + + + + diff --git a/benchmarks/NewVersionLib/NullLogger.cs b/benchmarks/NewVersionLib/NullLogger.cs new file mode 100644 index 00000000..f75a4162 --- /dev/null +++ b/benchmarks/NewVersionLib/NullLogger.cs @@ -0,0 +1,14 @@ +using System; + +#if BENCHMARK_OLD +namespace ConfigCat.Client.Benchmarks.Old; +#else +namespace ConfigCat.Client.Benchmarks.New; +#endif + +public class NullLogger : IConfigCatLogger +{ + public LogLevel LogLevel { get; set; } + + public void Log(LogLevel level, LogEventId eventId, ref FormattableLogMessage message, Exception? exception = null) { } +} diff --git a/benchmarks/OldVersionLib/BenchmarkHelper.cs b/benchmarks/OldVersionLib/BenchmarkHelper.cs new file mode 100644 index 00000000..bfff8c45 --- /dev/null +++ b/benchmarks/OldVersionLib/BenchmarkHelper.cs @@ -0,0 +1,104 @@ +using System.Reflection; +using System.Text.Json; +using ConfigCat.Client.Tests.Helpers; + +namespace ConfigCat.Client.Benchmarks.Old; + +public static partial class BenchmarkHelper +{ + public class BasicMatrixTestsDescriptor : IMatrixTestDescriptor + { + public ConfigLocation ConfigLocation => new ConfigLocation.LocalFile("data", "sample_v5_old.json"); + public string MatrixResultFileName => "testmatrix.csv"; + } + + private static readonly SettingsWithPreferences Config = new SettingsWithPreferences + { + Settings = + { + ["basicFlag"] = CreateSetting(SettingType.Boolean, JsonDocument.Parse("true").RootElement), + ["complexFlag"] = CreateSetting(SettingType.String, JsonDocument.Parse("\"fallback\"").RootElement, + new[] + { + new RolloutRule + { + Order = 0, + ComparisonAttribute = nameof(User.Identifier), + Comparator = Comparator.SensitiveOneOf, + ComparisonValue = "68d93aa74a0aa1664f65ad6c0515f24769b15c84,8409e4e5d27a1465165012b03b2606f0e5b08250", + Value = JsonDocument.Parse("\"a\"").RootElement, + }, + new RolloutRule + { + Order = 1, + ComparisonAttribute = nameof(User.Email), + Comparator = Comparator.Contains, + ComparisonValue = "@example.com", + Value = JsonDocument.Parse("\"b\"").RootElement, + }, + new RolloutRule + { + Order = 2, + ComparisonAttribute = "Version", + Comparator = Comparator.SemVerIn, + ComparisonValue = "1.0.0, 2.0.0", + Value = JsonDocument.Parse("\"c\"").RootElement, + }, + new RolloutRule + { + Order = 3, + ComparisonAttribute = "Version", + Comparator = Comparator.SemVerGreaterThan, + ComparisonValue = "3.0.0", + Value = JsonDocument.Parse("\"d\"").RootElement, + }, + new RolloutRule + { + Order = 4, + ComparisonAttribute = "Number", + Comparator = Comparator.NumberGreaterThan, + ComparisonValue = "3.14", + Value = JsonDocument.Parse("\"e\"").RootElement, + }, + }, + new[] + { + new RolloutPercentageItem + { + Percentage = 20, + Value = JsonDocument.Parse("\"p20\"").RootElement + }, + new RolloutPercentageItem + { + Percentage = 30, + Value = JsonDocument.Parse("\"p30\"").RootElement + }, + new RolloutPercentageItem + { + Percentage = 50, + Value = JsonDocument.Parse("\"p50\"").RootElement + }, + }) + } + }; + + private static Setting CreateSetting(SettingType settingType, JsonElement value, RolloutRule[]? targetingRules = null, RolloutPercentageItem[]? percentageOptions = null) + { + var setting = new Setting + { + SettingType = settingType, + Value = value, + }; + + SetPrivatePropertyValue(setting, nameof(setting.RolloutRules), targetingRules); + SetPrivatePropertyValue(setting, nameof(setting.RolloutPercentageItems), percentageOptions); + + return setting; + } + + private static void SetPrivatePropertyValue(object obj, string propertyName, object? value) + { + var type = obj.GetType(); + type.InvokeMember(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.SetProperty, null, obj, new[] { value }); + } +} diff --git a/benchmarks/OldVersionLib/OldVersionLib.csproj b/benchmarks/OldVersionLib/OldVersionLib.csproj new file mode 100644 index 00000000..001c5e43 --- /dev/null +++ b/benchmarks/OldVersionLib/OldVersionLib.csproj @@ -0,0 +1,36 @@ + + + + + ConfigCatClientTests + ConfigCat.Client.Benchmarks.New + net48;net6.0 + 10.0 + enable + nullable + true + ..\..\src\ConfigCatClient.snk + BENCHMARK_OLD;$(DefineConstants) + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/benchmarks/OldVersionLib/data/sample_v5_old.json b/benchmarks/OldVersionLib/data/sample_v5_old.json new file mode 100644 index 00000000..253e5320 --- /dev/null +++ b/benchmarks/OldVersionLib/data/sample_v5_old.json @@ -0,0 +1,334 @@ +{ + "f": { + "stringDefaultCat": { + "v": "Cat", + "t": 1, + "p": [], + "r": [] + }, + "stringIsInDogDefaultCat": { + "v": "Cat", + "t": 1, + "p": [], + "r": [ + { + "o": 0, + "a": "Email", + "t": 0, + "c": "a@configcat.com, b@configcat.com", + "v": "Dog" + }, + { + "o": 1, + "a": "Custom1", + "t": 0, + "c": "admin", + "v": "Dog" + } + ] + }, + "stringIsNotInDogDefaultCat": { + "v": "Cat", + "t": 1, + "p": [], + "r": [ + { + "o": 0, + "a": "Email", + "t": 1, + "c": "a@configcat.com,b@configcat.com", + "v": "Dog" + } + ] + }, + "stringContainsDogDefaultCat": { + "v": "Cat", + "t": 1, + "p": [], + "r": [ + { + "o": 0, + "a": "Email", + "t": 2, + "c": "@configcat.com", + "v": "Dog" + } + ] + }, + "stringNotContainsDogDefaultCat": { + "v": "Cat", + "t": 1, + "p": [], + "r": [ + { + "o": 0, + "a": "Email", + "t": 3, + "c": "@configcat.com", + "v": "Dog" + } + ] + }, + "string25Cat25Dog25Falcon25Horse": { + "v": "Chicken", + "t": 1, + "p": [ + { + "o": 0, + "v": "Cat", + "p": 25 + }, + { + "o": 1, + "v": "Dog", + "p": 25 + }, + { + "o": 2, + "v": "Falcon", + "p": 25 + }, + { + "o": 3, + "v": "Horse", + "p": 25 + } + ], + "r": [] + }, + "string75Cat0Dog25Falcon0Horse": { + "v": "Chicken", + "t": 1, + "p": [ + { + "o": 0, + "v": "Cat", + "p": 75 + }, + { + "o": 1, + "v": "Dog", + "p": 0 + }, + { + "o": 2, + "v": "Falcon", + "p": 25 + }, + { + "o": 3, + "v": "Horse", + "p": 0 + } + ], + "r": [] + }, + "string25Cat25Dog25Falcon25HorseAdvancedRules": { + "v": "Chicken", + "t": 1, + "p": [ + { + "o": 0, + "v": "Cat", + "p": 25 + }, + { + "o": 1, + "v": "Dog", + "p": 25 + }, + { + "o": 2, + "v": "Falcon", + "p": 25 + }, + { + "o": 3, + "v": "Horse", + "p": 25 + } + ], + "r": [ + { + "o": 0, + "a": "Country", + "t": 0, + "c": "Hungary, United Kingdom", + "v": "Dolphin" + }, + { + "o": 1, + "a": "Custom1", + "t": 2, + "c": "admi", + "v": "Lion" + }, + { + "o": 2, + "a": "Email", + "t": 2, + "c": "@configcat.com", + "v": "Kitten" + } + ] + }, + "boolDefaultTrue": { + "v": true, + "t": 0, + "p": [], + "r": [] + }, + "boolDefaultFalse": { + "v": false, + "t": 0, + "p": [], + "r": [] + }, + "bool30TrueAdvancedRules": { + "v": true, + "t": 0, + "p": [ + { + "o": 0, + "v": true, + "p": 30 + }, + { + "o": 1, + "v": false, + "p": 70 + } + ], + "r": [ + { + "o": 0, + "a": "Email", + "t": 0, + "c": "a@configcat.com, b@configcat.com", + "v": false + }, + { + "o": 1, + "a": "Country", + "t": 2, + "c": "United", + "v": false + } + ] + }, + "integer25One25Two25Three25FourAdvancedRules": { + "v": -1, + "t": 2, + "p": [ + { + "o": 0, + "v": 1, + "p": 25 + }, + { + "o": 1, + "v": 2, + "p": 25 + }, + { + "o": 2, + "v": 3, + "p": 25 + }, + { + "o": 3, + "v": 4, + "p": 25 + } + ], + "r": [ + { + "o": 0, + "a": "Email", + "t": 2, + "c": "@configcat.com", + "v": 5 + } + ] + }, + "integerDefaultOne": { + "v": 1, + "t": 2, + "p": [], + "r": [] + }, + "doubleDefaultPi": { + "v": 3.1415, + "t": 3, + "p": [], + "r": [] + }, + "double25Pi25E25Gr25Zero": { + "v": -1.0, + "t": 3, + "p": [ + { + "o": 0, + "v": 3.1415, + "p": 25 + }, + { + "o": 1, + "v": 2.7182, + "p": 25 + }, + { + "o": 2, + "v": 1.61803, + "p": 25 + }, + { + "o": 3, + "v": 0.0, + "p": 25 + } + ], + "r": [ + { + "o": 0, + "a": "Email", + "t": 2, + "c": "@configcat.com", + "v": 5.561 + } + ] + }, + "keySampleText": { + "v": "Cat", + "t": 1, + "p": [ + { + "o": 0, + "v": "Falcon", + "p": 50 + }, + { + "o": 1, + "v": "Horse", + "p": 50 + } + ], + "r": [ + { + "o": 0, + "a": "Country", + "t": 0, + "c": "Hungary,Bahamas", + "v": "Dog" + }, + { + "o": 1, + "a": "SubscriptionType", + "t": 0, + "c": "unlimited", + "v": "Lion" + } + ] + } + } +} \ No newline at end of file diff --git a/benchmarks/Program.cs b/benchmarks/Program.cs deleted file mode 100644 index 527bf545..00000000 --- a/benchmarks/Program.cs +++ /dev/null @@ -1,14 +0,0 @@ -using BenchmarkDotNet.Running; -using System; - -namespace ConfigCatClient.Benchmarks; - -internal class Program -{ - private static void Main(string[] args) - { - BenchmarkRunner.Run(); - - Console.ReadKey(); - } -} diff --git a/src/ConfigCat.Client.Tests/BasicConfigCatClientIntegrationTests.cs b/src/ConfigCat.Client.Tests/BasicConfigCatClientIntegrationTests.cs index b856926b..43e0bd44 100644 --- a/src/ConfigCat.Client.Tests/BasicConfigCatClientIntegrationTests.cs +++ b/src/ConfigCat.Client.Tests/BasicConfigCatClientIntegrationTests.cs @@ -340,7 +340,7 @@ static void Configure(ConfigCatClientOptions options) public async Task Http_Timeout_Test_Async() { var response = $"{{ \"f\": {{ \"fakeKey\": {{ \"v\": \"fakeValue\", \"p\": [] ,\"r\": [] }} }} }}"; - using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake", options => + using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake-67890123456789012/1234567890123456789012", options => { options.PollingMode = PollingModes.ManualPoll; options.Logger = ConsoleLogger; @@ -357,7 +357,7 @@ public async Task Http_Timeout_Test_Async() public void Http_Timeout_Test_Sync() { var response = $"{{ \"f\": {{ \"fakeKey\": {{ \"v\": \"fakeValue\", \"p\": [] ,\"r\": [] }} }} }}"; - using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake", options => + using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake-67890123456789012/1234567890123456789012", options => { options.PollingMode = PollingModes.ManualPoll; options.Logger = ConsoleLogger; @@ -374,7 +374,7 @@ public async Task Ensure_MaxInitWait_Overrides_Timeout() { var now = DateTimeOffset.UtcNow; var response = $"{{ \"f\": {{ \"fakeKey\": {{ \"v\": \"fakeValue\", \"p\": [] ,\"r\": [] }} }} }}"; - using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake", options => + using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake-67890123456789012/1234567890123456789012", options => { options.PollingMode = PollingModes.AutoPoll(maxInitWaitTime: TimeSpan.FromSeconds(1)); options.Logger = ConsoleLogger; @@ -390,7 +390,7 @@ public void Ensure_MaxInitWait_Overrides_Timeout_Sync() { var now = DateTimeOffset.UtcNow; var response = $"{{ \"f\": {{ \"fakeKey\": {{ \"v\": \"fakeValue\", \"p\": [] ,\"r\": [] }} }} }}"; - using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake", options => + using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake-67890123456789012/1234567890123456789012", options => { options.PollingMode = PollingModes.AutoPoll(maxInitWaitTime: TimeSpan.FromSeconds(1)); options.Logger = ConsoleLogger; @@ -407,7 +407,7 @@ public void Ensure_Client_Dispose_Kill_Hanging_Http_Call() var defer = new ManualResetEvent(false); var now = DateTimeOffset.UtcNow; var response = $"{{ \"f\": {{ \"fakeKey\": {{ \"v\": \"fakeValue\", \"p\": [] ,\"r\": [] }} }} }}"; - using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake", options => + using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake-67890123456789012/1234567890123456789012", options => { options.Logger = ConsoleLogger; options.HttpClientHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK, response, TimeSpan.FromSeconds(5)); @@ -426,7 +426,7 @@ public void Ensure_Client_Dispose_Kill_Hanging_Http_Call_Sync() var defer = new ManualResetEvent(false); var now = DateTimeOffset.UtcNow; var response = $"{{ \"f\": {{ \"fakeKey\": {{ \"v\": \"fakeValue\", \"p\": [] ,\"r\": [] }} }} }}"; - using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake", options => + using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake-67890123456789012/1234567890123456789012", options => { options.Logger = ConsoleLogger; options.HttpClientHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK, response, TimeSpan.FromSeconds(5)); @@ -447,7 +447,7 @@ public void Ensure_Client_Dispose_Kill_Hanging_Http_Call_Sync() public void Ensure_Multiple_Requests_Doesnt_Interfere_In_ValueTasks() { var response = $"{{ \"f\": {{ \"fakeKey\": {{ \"v\": \"fakeValue\", \"p\": [] ,\"r\": [] }} }} }}"; - using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake", options => + using IConfigCatClient manualPollClient = ConfigCatClient.Get("fake-67890123456789012/1234567890123456789012", options => { options.Logger = ConsoleLogger; options.PollingMode = PollingModes.ManualPoll; diff --git a/src/ConfigCat.Client.Tests/ConfigCacheTests.cs b/src/ConfigCat.Client.Tests/ConfigCacheTests.cs index 8c46a2fc..1ce2065e 100644 --- a/src/ConfigCat.Client.Tests/ConfigCacheTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCacheTests.cs @@ -228,21 +228,21 @@ public async Task ConfigCache_ShouldHandleWhenExternalCacheFails(bool isAsync) loggerMock.Verify(l => l.Log(LogLevel.Error, 2200, ref It.Ref.IsAny, It.Is(ex => ex is ApplicationException)), Times.Once); } - [DataRow("test1", "147c5b4c2b2d7c77e1605b1a4309f0ea6684a0c6")] - [DataRow("test2", "c09513b1756de9e4bc48815ec7a142b2441ed4d5")] + [DataRow("test1", "7f845c43ecc95e202b91e271435935e6d1391e5d")] + [DataRow("test2", "a78b7e323ef543a272c74540387566a22415148a")] [DataTestMethod] public void CacheKeyGeneration_ShouldBePlatformIndependent(string sdkKey, string expectedCacheKey) { Assert.AreEqual(expectedCacheKey, ConfigCatClient.GetCacheKey(sdkKey)); } - private const string PayloadTestConfigJson = "{\"p\":{\"u\":\"https://cdn-global.configcat.com\",\"r\":0},\"f\":{\"testKey\":{\"v\":\"testValue\",\"t\":1,\"p\":[],\"r\":[]}}}"; + private const string PayloadTestConfigJson = "{\"p\":{\"u\":\"https://cdn-global.configcat.com\",\"r\":0,\"s\":\"FUkC6RADjzF0vXrDSfJn7BcEBag9afw1Y6jkqjMP9BA=\"},\"f\":{\"testKey\":{\"t\":1,\"v\":{\"s\":\"testValue\"}}}}"; [DataRow(PayloadTestConfigJson, "2023-06-14T15:27:15.8440000Z", "test-etag", "1686756435844\ntest-etag\n" + PayloadTestConfigJson)] [DataTestMethod] public void CachePayloadSerialization_ShouldBePlatformIndependent(string configJson, string timeStamp, string httpETag, string expectedPayload) { var timeStampDateTime = DateTimeOffset.ParseExact(timeStamp, "o", CultureInfo.InvariantCulture).UtcDateTime; - var pc = new ProjectConfig(configJson, configJson.Deserialize(), timeStampDateTime, httpETag); + var pc = new ProjectConfig(configJson, configJson.Deserialize(), timeStampDateTime, httpETag); Assert.AreEqual(expectedPayload, ProjectConfig.Serialize(pc)); } diff --git a/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj b/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj index 12c07e46..784def52 100644 --- a/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj +++ b/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj @@ -8,12 +8,41 @@ 10.0 enable nullable - strongname.snk + ..\ConfigCatClient.snk - - USE_NEWTONSOFT_JSON - + + + + USE_NEWTONSOFT_JSON + + + + + + + + + + + + + + + + + + + + + + + + @@ -22,8 +51,6 @@ - - @@ -35,47 +62,8 @@ - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always + + PreserveNewest diff --git a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs index b7ccb4b9..ce6c1fa6 100644 --- a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs @@ -57,6 +57,44 @@ public void CreateAnInstance_WhenSdkKeyIsNull_ShouldThrowArgumentNullException() using var _ = ConfigCatClient.Get(sdkKey!); } + [DataRow("sdk-key-90123456789012", false, false)] + [DataRow("sdk-key-9012345678901/1234567890123456789012", false, false)] + [DataRow("sdk-key-90123456789012/123456789012345678901", false, false)] + [DataRow("sdk-key-90123456789012/12345678901234567890123", false, false)] + [DataRow("sdk-key-901234567890123/1234567890123456789012", false, false)] + [DataRow("sdk-key-90123456789012/1234567890123456789012", false, true)] + [DataRow("configcat-sdk-1/sdk-key-90123456789012", false, false)] + [DataRow("configcat-sdk-1/sdk-key-9012345678901/1234567890123456789012", false, false)] + [DataRow("configcat-sdk-1/sdk-key-90123456789012/123456789012345678901", false, false)] + [DataRow("configcat-sdk-1/sdk-key-90123456789012/12345678901234567890123", false, false)] + [DataRow("configcat-sdk-1/sdk-key-901234567890123/1234567890123456789012", false, false)] + [DataRow("configcat-sdk-1/sdk-key-90123456789012/1234567890123456789012", false, true)] + [DataRow("configcat-sdk-2/sdk-key-90123456789012/1234567890123456789012", false, false)] + [DataRow("configcat-proxy/", false, false)] + [DataRow("configcat-proxy/", true, false)] + [DataRow("configcat-proxy/sdk-key-90123456789012", false, false)] + [DataRow("configcat-proxy/sdk-key-90123456789012", true, true)] + [DataTestMethod] + [DoNotParallelize] + public void SdkKeyFormat_ShouldBeValidated(string sdkKey, bool customBaseUrl, bool isValid) + { + Action? configureOptions = customBaseUrl + ? o => o.BaseUrl = new Uri("https://my-configcat-proxy") + : null; + + if (isValid) + { + using var _ = ConfigCatClient.Get(sdkKey, configureOptions); + } + else + { + Assert.ThrowsException(() => + { + using var _ = ConfigCatClient.Get(sdkKey, configureOptions); + }); + } + } + [ExpectedException(typeof(ArgumentOutOfRangeException))] [TestMethod] [DoNotParallelize] @@ -83,7 +121,7 @@ public void CreateAnInstance_WhenLazyLoadConfigurationTimeToLiveSecondsIsZero_Sh [DoNotParallelize] public void CreateAnInstance_WhenLoggerIsNull_ShouldCreateAnInstance() { - using var client = ConfigCatClient.Get("hsdrTr4sxbHdSgdhHRZds346hdgsS2vfsgf/GsdrTr4sxbHdSgdhHRZds346hdOPsSgvfsgf", options => + using var client = ConfigCatClient.Get("hsdrTr4sxbHdSgdhHRZds3/GsdrTr4sxbHdSgdhHRZds3", options => { options.Logger = null; }); @@ -95,7 +133,7 @@ public void CreateAnInstance_WhenLoggerIsNull_ShouldCreateAnInstance() [DoNotParallelize] public void CreateAnInstance_WithSdkKey_ShouldCreateAnInstance() { - using var _ = ConfigCatClient.Get("hsdrTr4sxbHdSgdhHRZds346hdgsS2vfsgf/GsdrTr4sxbHdSgdhHRZds346hdOPsSgvfsgf"); + using var _ = ConfigCatClient.Get("hsdrTr4sxbHdSgdhHRZds3/GsdrTr4sxbHdSgdhHRZds3"); } [TestMethod] @@ -149,8 +187,12 @@ public void GetValue_EvaluateServiceThrowException_ShouldReturnDefaultValue() const string defaultValue = "Victory for the Firstborn!"; + this.configServiceMock + .Setup(m => m.GetConfig()) + .Throws(); + this.evaluatorMock - .Setup(m => m.Evaluate(It.IsAny(), It.IsAny(), defaultValue, null)) + .Setup(m => m.Evaluate(It.IsAny(), ref It.Ref.IsAny, out It.Ref.IsAny)) .Throws(); var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); @@ -178,8 +220,12 @@ public async Task GetValueAsync_EvaluateServiceThrowException_ShouldReturnDefaul const string defaultValue = "Victory for the Firstborn!"; + this.configServiceMock + .Setup(m => m.GetConfigAsync(It.IsAny())) + .Throws(); + this.evaluatorMock - .Setup(m => m.Evaluate(It.IsAny(), It.IsAny(), defaultValue, null)) + .Setup(m => m.Evaluate(It.IsAny(), ref It.Ref.IsAny, out It.Ref.IsAny)) .Throws(); var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); @@ -240,8 +286,8 @@ public async Task GetValueDetails_ShouldReturnCorrectEvaluationDetails_SettingIs Assert.AreSame(user, actual.User); Assert.IsNotNull(actual.ErrorMessage); Assert.IsNull(actual.ErrorException); - Assert.IsNull(actual.MatchedEvaluationRule); - Assert.IsNull(actual.MatchedEvaluationPercentageRule); + Assert.IsNull(actual.MatchedTargetingRule); + Assert.IsNull(actual.MatchedPercentageOption); } [DataRow(false)] @@ -292,8 +338,8 @@ public async Task GetValueDetails_ShouldReturnCorrectEvaluationDetails_SettingIs Assert.IsNull(actual.User); Assert.IsNull(actual.ErrorMessage); Assert.IsNull(actual.ErrorException); - Assert.IsNull(actual.MatchedEvaluationRule); - Assert.IsNull(actual.MatchedEvaluationPercentageRule); + Assert.IsNull(actual.MatchedTargetingRule); + Assert.IsNull(actual.MatchedPercentageOption); } [DataRow(false)] @@ -346,8 +392,8 @@ public async Task GetValueDetails_ShouldReturnCorrectEvaluationDetails_SettingIs Assert.AreSame(user, actual.User); Assert.IsNull(actual.ErrorMessage); Assert.IsNull(actual.ErrorException); - Assert.IsNotNull(actual.MatchedEvaluationRule); - Assert.IsNull(actual.MatchedEvaluationPercentageRule); + Assert.IsNotNull(actual.MatchedTargetingRule); + Assert.IsNull(actual.MatchedPercentageOption); } [DataRow(false)] @@ -400,8 +446,8 @@ public async Task GetValueDetails_ShouldReturnCorrectEvaluationDetails_SettingIs Assert.AreSame(user, actual.User); Assert.IsNull(actual.ErrorMessage); Assert.IsNull(actual.ErrorException); - Assert.IsNull(actual.MatchedEvaluationRule); - Assert.IsNotNull(actual.MatchedEvaluationPercentageRule); + Assert.IsNull(actual.MatchedTargetingRule); + Assert.IsNotNull(actual.MatchedPercentageOption); } [DataRow(false)] @@ -443,8 +489,8 @@ public async Task GetValueDetails_ConfigServiceThrowException_ShouldReturnDefaul Assert.IsNull(actual.User); Assert.AreEqual(errorMessage, actual.ErrorMessage); Assert.IsInstanceOfType(actual.ErrorException, typeof(ApplicationException)); - Assert.IsNull(actual.MatchedEvaluationRule); - Assert.IsNull(actual.MatchedEvaluationPercentageRule); + Assert.IsNull(actual.MatchedTargetingRule); + Assert.IsNull(actual.MatchedPercentageOption); } [DataRow(false)] @@ -463,7 +509,7 @@ public async Task GetValueDetails_EvaluateServiceThrowException_ShouldReturnDefa var timeStamp = ProjectConfig.GenerateTimeStamp(); this.evaluatorMock - .Setup(m => m.Evaluate(It.IsAny(), It.IsAny(), defaultValue, It.IsAny())) + .Setup(m => m.Evaluate(It.IsAny(), ref It.Ref.IsAny, out It.Ref.IsAny)) .Throws(new ApplicationException(errorMessage)); var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, @@ -506,8 +552,8 @@ public async Task GetValueDetails_EvaluateServiceThrowException_ShouldReturnDefa Assert.AreSame(user, actual.User); Assert.AreEqual(errorMessage, actual.ErrorMessage); Assert.IsInstanceOfType(actual.ErrorException, typeof(ApplicationException)); - Assert.IsNull(actual.MatchedEvaluationRule); - Assert.IsNull(actual.MatchedEvaluationPercentageRule); + Assert.IsNull(actual.MatchedTargetingRule); + Assert.IsNull(actual.MatchedPercentageOption); Assert.AreEqual(1, flagEvaluatedEvents.Count); Assert.AreSame(actual, flagEvaluatedEvents[0].EvaluationDetails); @@ -575,8 +621,8 @@ public async Task GetAllValueDetails_ShouldReturnCorrectEvaluationDetails(bool i Assert.AreSame(user, actualDetails.User); Assert.IsNull(actualDetails.ErrorMessage); Assert.IsNull(actualDetails.ErrorException); - Assert.IsNotNull(actualDetails.MatchedEvaluationRule); - Assert.IsNull(actualDetails.MatchedEvaluationPercentageRule); + Assert.IsNotNull(actualDetails.MatchedTargetingRule); + Assert.IsNull(actualDetails.MatchedPercentageOption); var flagEvaluatedDetails = flagEvaluatedEvents.Select(e => e.EvaluationDetails).FirstOrDefault(details => details.Key == expectedItem.Key); @@ -594,7 +640,7 @@ public async Task GetAllValueDetails_DeserializeFailed_ShouldReturnWithEmptyArra this.configServiceMock.Setup(m => m.GetConfig()).Returns(ProjectConfig.Empty); this.configServiceMock.Setup(m => m.GetConfigAsync(It.IsAny())).ReturnsAsync(ProjectConfig.Empty); - var o = new SettingsWithPreferences(); + var o = new Config(); using IConfigCatClient client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); @@ -622,6 +668,10 @@ public async Task GetAllValueDetails_ConfigServiceThrowException_ShouldReturnEmp { // Arrange + this.configServiceMock + .Setup(m => m.GetConfig()) + .Throws(); + this.configServiceMock .Setup(m => m.GetConfigAsync(It.IsAny())) .Throws(); @@ -658,7 +708,7 @@ public async Task GetAllValueDetails_EvaluateServiceThrowException_ShouldReturnD var timeStamp = ProjectConfig.GenerateTimeStamp(); this.evaluatorMock - .Setup(m => m.Evaluate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(m => m.Evaluate(It.IsAny(), ref It.Ref.IsAny, out It.Ref.IsAny)) .Throws(new ApplicationException(errorMessage)); var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, @@ -707,8 +757,8 @@ public async Task GetAllValueDetails_EvaluateServiceThrowException_ShouldReturnD Assert.AreSame(user, actualDetails.User); Assert.AreEqual(errorMessage, actualDetails.ErrorMessage); Assert.IsInstanceOfType(actualDetails.ErrorException, typeof(ApplicationException)); - Assert.IsNull(actualDetails.MatchedEvaluationRule); - Assert.IsNull(actualDetails.MatchedEvaluationPercentageRule); + Assert.IsNull(actualDetails.MatchedTargetingRule); + Assert.IsNull(actualDetails.MatchedPercentageOption); var flagEvaluatedDetails = flagEvaluatedEvents.Select(e => e.EvaluationDetails).FirstOrDefault(details => details.Key == key); @@ -779,8 +829,8 @@ public void GetAllKeys_DeserializerThrowException_ShouldReturnsWithEmptyArray() { // Arrange - this.configServiceMock.Setup(m => m.GetConfigAsync(It.IsAny())).ReturnsAsync(ProjectConfig.Empty); - var o = new SettingsWithPreferences(); + this.configServiceMock.Setup(m => m.GetConfig()).Returns(ProjectConfig.Empty); + var o = new Config(); IConfigCatClient instance = new ConfigCatClient( this.configServiceMock.Object, @@ -1160,11 +1210,11 @@ void Configure(ConfigCatClientOptions options) // Act - using var client1 = ConfigCatClient.Get("test", Configure); + using var client1 = ConfigCatClient.Get("test-67890123456789012/1234567890123456789012", Configure); var warnings1 = warnings.ToArray(); warnings.Clear(); - using var client2 = ConfigCatClient.Get("test", passConfigureToSecondGet ? Configure : null); + using var client2 = ConfigCatClient.Get("test-67890123456789012/1234567890123456789012", passConfigureToSecondGet ? Configure : null); var warnings2 = warnings.ToArray(); // Assert @@ -1189,7 +1239,7 @@ public void Dispose_CachedInstanceRemoved() { // Arrange - var client1 = ConfigCatClient.Get("test", options => options.PollingMode = PollingModes.ManualPoll); + var client1 = ConfigCatClient.Get("test-67890123456789012/1234567890123456789012", options => options.PollingMode = PollingModes.ManualPoll); // Act @@ -1211,7 +1261,7 @@ public void Dispose_CanRemoveCurrentCachedInstanceOnly() { // Arrange - var client1 = ConfigCatClient.Get("test", options => options.PollingMode = PollingModes.ManualPoll); + var client1 = ConfigCatClient.Get("test-67890123456789012/1234567890123456789012", options => options.PollingMode = PollingModes.ManualPoll); // Act @@ -1221,7 +1271,7 @@ public void Dispose_CanRemoveCurrentCachedInstanceOnly() var instanceCount2 = ConfigCatClient.Instances.GetAliveCount(); - var client2 = ConfigCatClient.Get("test", options => options.PollingMode = PollingModes.ManualPoll); + var client2 = ConfigCatClient.Get("test-67890123456789012/1234567890123456789012", options => options.PollingMode = PollingModes.ManualPoll); var instanceCount3 = ConfigCatClient.Instances.GetAliveCount(); @@ -1248,8 +1298,8 @@ public void DisposeAll_CachedInstancesRemoved() { // Arrange - var client1 = ConfigCatClient.Get("test1", options => options.PollingMode = PollingModes.AutoPoll()); - var client2 = ConfigCatClient.Get("test2", options => options.PollingMode = PollingModes.ManualPoll); + var client1 = ConfigCatClient.Get("test1-7890123456789012/1234567890123456789012", options => options.PollingMode = PollingModes.AutoPoll()); + var client2 = ConfigCatClient.Get("test2-7890123456789012/1234567890123456789012", options => options.PollingMode = PollingModes.ManualPoll); // Act @@ -1283,8 +1333,8 @@ static void CreateClients(out int instanceCount) // because that could interfere with this test: when raising the event, the service acquires a strong reference to the client, // which would temporarily prevent the client from being GCd. This could break the test in the case of unlucky timing. // Setting maxInitWaitTime to zero prevents this because then the event is raised immediately at creation. - var client1 = ConfigCatClient.Get("test1", options => options.PollingMode = PollingModes.AutoPoll(maxInitWaitTime: TimeSpan.Zero)); - var client2 = ConfigCatClient.Get("test2", options => options.PollingMode = PollingModes.ManualPoll); + var client1 = ConfigCatClient.Get("test1-7890123456789012/1234567890123456789012", options => options.PollingMode = PollingModes.AutoPoll(maxInitWaitTime: TimeSpan.Zero)); + var client2 = ConfigCatClient.Get("test2-7890123456789012/1234567890123456789012", options => options.PollingMode = PollingModes.ManualPoll); instanceCount = ConfigCatClient.Instances.GetAliveCount(); @@ -1316,7 +1366,7 @@ public void CachedInstancesCanBeGCdWhenHookHandlerClosesOverClientInstance() [MethodImpl(MethodImplOptions.NoInlining)] static void CreateClients(out int instanceCount) { - var client = ConfigCatClient.Get("test1", options => options.PollingMode = PollingModes.AutoPoll(maxInitWaitTime: TimeSpan.Zero)); + var client = ConfigCatClient.Get("test1-7890123456789012/1234567890123456789012", options => options.PollingMode = PollingModes.AutoPoll(maxInitWaitTime: TimeSpan.Zero)); client.ConfigChanged += (_, e) => { diff --git a/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs b/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs deleted file mode 100644 index 728188b5..00000000 --- a/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using ConfigCat.Client.Evaluation; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace ConfigCat.Client.Tests; - -public abstract class ConfigEvaluatorTestsBase -{ -#pragma warning disable IDE1006 // Naming Styles - private protected readonly LoggerWrapper Logger = new ConsoleLogger(LogLevel.Debug).AsWrapper(); -#pragma warning restore IDE1006 // Naming Styles - - private protected readonly IReadOnlyDictionary config; - - internal readonly IRolloutEvaluator configEvaluator; - - protected abstract string SampleJsonFileName { get; } - - protected abstract string MatrixResultFileName { get; } - - public ConfigEvaluatorTestsBase() - { - this.configEvaluator = new RolloutEvaluator(this.Logger); - - this.config = GetSampleJson().Deserialize()!.Settings; - } - - protected virtual void AssertValue(string keyName, string expected, User? user) - { - var k = keyName.ToLowerInvariant(); - - if (k.StartsWith("bool")) - { - var actual = this.configEvaluator.Evaluate(this.config, keyName, false, user, null, this.Logger).Value; - - Assert.AreEqual(bool.Parse(expected), actual, $"keyName: {keyName} | userId: {user?.Identifier}"); - } - else if (k.StartsWith("double")) - { - var actual = this.configEvaluator.Evaluate(this.config, keyName, double.NaN, user, null, this.Logger).Value; - - Assert.AreEqual(double.Parse(expected, CultureInfo.InvariantCulture), actual, $"keyName: {keyName} | userId: {user?.Identifier}"); - } - else if (k.StartsWith("integer")) - { - var actual = this.configEvaluator.Evaluate(this.config, keyName, int.MinValue, user, null, this.Logger).Value; - - Assert.AreEqual(int.Parse(expected), actual, $"keyName: {keyName} | userId: {user?.Identifier}"); - } - else - { - var actual = this.configEvaluator.Evaluate(this.config, keyName, string.Empty, user, null, this.Logger).Value; - - Assert.AreEqual(expected, actual, $"keyName: {keyName} | userId: {user?.Identifier}"); - } - } - - protected string GetSampleJson() - { - using Stream stream = File.OpenRead(Path.Combine("data", SampleJsonFileName)); - using StreamReader reader = new(stream); - return reader.ReadToEnd(); - } - - public async Task MatrixTest(Action assertation) - { - using Stream stream = File.OpenRead(Path.Combine("data", MatrixResultFileName)); - using StreamReader reader = new(stream); - var header = (await reader.ReadLineAsync())!; - - var columns = header.Split(new[] { ';' }).ToList(); - - while (!reader.EndOfStream) - { - var rawline = await reader.ReadLineAsync(); - - if (string.IsNullOrEmpty(rawline)) - { - continue; - } - - var row = rawline.Split(new[] { ';' }); - - User? u = null; - - if (row[0] != "##null##") - { - u = new User(row[0]) - { - Email = row[1] == "##null##" ? null : row[1], - Country = row[2] == "##null##" ? null : row[2], - Custom = row[3] == "##null##" ? null! : new Dictionary { { columns[3], row[3] } } - }; - } - - for (var i = 4; i < columns.Count; i++) - { - assertation(columns[i], row[i], u); - } - } - } - - [TestCategory("MatrixTests")] - [TestMethod] - public async Task Run_MatrixTests() - { - await MatrixTest(AssertValue); - } -} diff --git a/src/ConfigCat.Client.Tests/ConfigV1EvaluationTests.cs b/src/ConfigCat.Client.Tests/ConfigV1EvaluationTests.cs new file mode 100644 index 00000000..6135fa9f --- /dev/null +++ b/src/ConfigCat.Client.Tests/ConfigV1EvaluationTests.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using ConfigCat.Client.Tests.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ConfigCat.Client.Tests; + +[TestClass] +public class ConfigV1EvaluationTests : EvaluationTestsBase +{ + public class BasicTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"); + public string MatrixResultFileName => "testmatrix.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class NumericTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d747f0-5986-c2ef-eef3-ec778e32e10a/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("PKDVCLf-Hq-h-kCzMp-L7Q/uGyK3q9_ckmdxRyI7vjwCw"); + public string MatrixResultFileName => "testmatrix_number.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class SegmentTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d9f207-6883-43e5-868c-cbf677af3fe6/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("PKDVCLf-Hq-h-kCzMp-L7Q/LcYz135LE0qbcacz2mgXnA"); + public string MatrixResultFileName => "testmatrix_segments_old.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class SemanticVersionTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d745f1-f315-7daf-d163-5541d3786e6f/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("PKDVCLf-Hq-h-kCzMp-L7Q/BAr3KgLTP0ObzKnBTo5nhA"); + public string MatrixResultFileName => "testmatrix_semantic.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class SemanticVersion2TestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d77fa1-a796-85f9-df0c-57c448eb9934/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("PKDVCLf-Hq-h-kCzMp-L7Q/q6jMCFIp-EmuAfnmZhPY7w"); + public string MatrixResultFileName => "testmatrix_semantic_2.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class SensitiveTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d7b724-9285-f4a7-9fcd-00f64f1e83d5/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("PKDVCLf-Hq-h-kCzMp-L7Q/qX3TP2dTj06ZpCCT1h_SPA"); + public string MatrixResultFileName => "testmatrix_sensitive.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class VariationIdTestsDescriptor : IMatrixTestDescriptor, IVariationIdMatrixText + { + // https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d774b9-3d05-0027-d5f4-3e76c3dba752/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("PKDVCLf-Hq-h-kCzMp-L7Q/nQ5qkhRAUEa6beEyyrVLBA"); + public string MatrixResultFileName => "testmatrix_variationid.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + private protected override Dictionary BasicConfig => MatrixTestRunner.Default.config; + + [DataTestMethod] + [DynamicData(nameof(BasicTestsDescriptor.GetTests), typeof(BasicTestsDescriptor), DynamicDataSourceType.Method)] + public void BasicTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(NumericTestsDescriptor.GetTests), typeof(NumericTestsDescriptor), DynamicDataSourceType.Method)] + public void NumericTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(SegmentTestsDescriptor.GetTests), typeof(SegmentTestsDescriptor), DynamicDataSourceType.Method)] + public void SegmentTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(SemanticVersionTestsDescriptor.GetTests), typeof(SemanticVersionTestsDescriptor), DynamicDataSourceType.Method)] + public void SemanticVersionTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(SemanticVersion2TestsDescriptor.GetTests), typeof(SemanticVersion2TestsDescriptor), DynamicDataSourceType.Method)] + public void SemanticVersion2Tests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(SensitiveTestsDescriptor.GetTests), typeof(SensitiveTestsDescriptor), DynamicDataSourceType.Method)] + public void SensitiveTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(VariationIdTestsDescriptor.GetTests), typeof(VariationIdTestsDescriptor), DynamicDataSourceType.Method)] + public void VariationIdTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } +} diff --git a/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs b/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs new file mode 100644 index 00000000..53af3d99 --- /dev/null +++ b/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs @@ -0,0 +1,589 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using ConfigCat.Client.Configuration; +using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Tests.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace ConfigCat.Client.Tests; + +[TestClass] +public class ConfigV2EvaluationTests : EvaluationTestsBase +{ + public class BasicTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-1927-4d6b-8fb9-b1472564e2d3/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ"); + public string MatrixResultFileName => "testmatrix.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class NumericTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-0fa3-48d0-8de8-9de55b67fb8b/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw"); + public string MatrixResultFileName => "testmatrix_number.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class SegmentTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbd6ca-a85f-4ed0-888a-2da18def92b5/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/y_ZB7o-Xb0Swxth-ZlMSeA"); + public string MatrixResultFileName => "testmatrix_segments_old.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class SemanticVersionTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-278c-4f83-8d36-db73ad6e2a3a/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg"); + public string MatrixResultFileName => "testmatrix_semantic.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class SemanticVersion2TestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-2b2b-451e-8359-abdef494c2a2/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/U8nt3zEhDEO5S2ulubCopA"); + public string MatrixResultFileName => "testmatrix_semantic_2.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class SensitiveTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-2d62-4e1b-884b-6aa237b34764/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/-0YmVOUNgEGKkgRF-rU65g"); + public string MatrixResultFileName => "testmatrix_sensitive.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class VariationIdTestsDescriptor : IMatrixTestDescriptor, IVariationIdMatrixText + { + //https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-30c6-4969-8e4c-03f6a8764199/244cf8b0-f604-11e8-b543-f23c917f9d8d + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/spQnkRTIPEWVivZkWM84lQ"); + public string MatrixResultFileName => "testmatrix_variationid.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class AndOrMatrixTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9d5e-4988-891c-fd4a45790bd1/08dbc325-9ebd-4587-8171-88f76a3004cb + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A"); + public string MatrixResultFileName => "testmatrix_and_or.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class ComparatorMatrixTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9a6b-4947-84e2-91529248278a/08dbc325-9ebd-4587-8171-88f76a3004cb + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ"); + public string MatrixResultFileName => "testmatrix_comparators_v6.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class FlagDependencyMatrixTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9b74-45cb-86d0-4d61c25af1aa/08dbc325-9ebd-4587-8171-88f76a3004cb + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/JoGwdqJZQ0K2xDy7LnbyOg"); + public string MatrixResultFileName => "testmatrix_prerequisite_flag.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class SegmentMatrixTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9cfb-486f-8906-72a57c693615/08dbc325-9ebd-4587-8171-88f76a3004cb + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/h99HYXWWNE2bH8eWyLAVMA"); + public string MatrixResultFileName => "testmatrix_segments.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + public class UnicodeMatrixTestsDescriptor : IMatrixTestDescriptor + { + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbd63c-9774-49d6-8187-5f2aab7bd606/08dbc325-9ebd-4587-8171-88f76a3004cb + public ConfigLocation ConfigLocation => new ConfigLocation.Cdn("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/Da6w8dBbmUeMUBhh0iEeQQ"); + public string MatrixResultFileName => "testmatrix_unicode.csv"; + public static IEnumerable GetTests() => MatrixTestRunner.GetTests(); + } + + private protected override Dictionary BasicConfig => MatrixTestRunner.Default.config; + + [DataTestMethod] + [DynamicData(nameof(BasicTestsDescriptor.GetTests), typeof(BasicTestsDescriptor), DynamicDataSourceType.Method)] + public void BasicTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(NumericTestsDescriptor.GetTests), typeof(NumericTestsDescriptor), DynamicDataSourceType.Method)] + public void NumericTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(SegmentTestsDescriptor.GetTests), typeof(SegmentTestsDescriptor), DynamicDataSourceType.Method)] + public void SegmentTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(SemanticVersionTestsDescriptor.GetTests), typeof(SemanticVersionTestsDescriptor), DynamicDataSourceType.Method)] + public void SemanticVersionTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(SemanticVersion2TestsDescriptor.GetTests), typeof(SemanticVersion2TestsDescriptor), DynamicDataSourceType.Method)] + public void SemanticVersion2Tests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(SensitiveTestsDescriptor.GetTests), typeof(SensitiveTestsDescriptor), DynamicDataSourceType.Method)] + public void SensitiveTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(VariationIdTestsDescriptor.GetTests), typeof(VariationIdTestsDescriptor), DynamicDataSourceType.Method)] + public void VariationIdTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(AndOrMatrixTestsDescriptor.GetTests), typeof(AndOrMatrixTestsDescriptor), DynamicDataSourceType.Method)] + public void AndOrMatrixTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(ComparatorMatrixTestsDescriptor.GetTests), typeof(ComparatorMatrixTestsDescriptor), DynamicDataSourceType.Method)] + public void ComparatorMatrixTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(FlagDependencyMatrixTestsDescriptor.GetTests), typeof(FlagDependencyMatrixTestsDescriptor), DynamicDataSourceType.Method)] + public void FlagDependencyMatrixTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(SegmentMatrixTestsDescriptor.GetTests), typeof(SegmentMatrixTestsDescriptor), DynamicDataSourceType.Method)] + public void SegmentMatrixTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DynamicData(nameof(UnicodeMatrixTestsDescriptor.GetTests), typeof(UnicodeMatrixTestsDescriptor), DynamicDataSourceType.Method)] + public void UnicodeMatrixTests(string configLocation, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + MatrixTestRunner.Default.RunTest(this.configEvaluator, this.logger, settingKey, expectedReturnValue, + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); + } + + [DataTestMethod] + [DataRow("key1", "'key1' -> 'key1'")] + [DataRow("key2", "'key2' -> 'key3' -> 'key2'")] + [DataRow("key4", "'key4' -> 'key3' -> 'key2' -> 'key3'")] + public void PrerequisiteFlagCircularDependencyTest(string key, string dependencyCycle) + { + var config = new ConfigLocation.LocalFile("data", "test_circulardependency_v6.json").FetchConfig(); + + var logger = new Mock().Object.AsWrapper(); + var evaluator = new RolloutEvaluator(logger); + + var ex = Assert.ThrowsException(() => evaluator.Evaluate(config!.Settings, key, defaultValue: null, user: null, remoteConfig: null, logger)); + + StringAssert.Contains(ex.Message, "Circular dependency detected"); + StringAssert.Contains(ex.Message, dependencyCycle); + } + + [DataTestMethod] + [DataRow("stringDependsOnBool", "mainBoolFlag", true, "Dog")] + [DataRow("stringDependsOnBool", "mainBoolFlag", false, "Cat")] + [DataRow("stringDependsOnBool", "mainBoolFlag", "1", null)] + [DataRow("stringDependsOnBool", "mainBoolFlag", 1, null)] + [DataRow("stringDependsOnBool", "mainBoolFlag", 1.0, null)] + [DataRow("stringDependsOnBool", "mainBoolFlag", new[] { true }, null)] + [DataRow("stringDependsOnBool", "mainBoolFlag", null, null)] + [DataRow("stringDependsOnString", "mainStringFlag", "private", "Dog")] + [DataRow("stringDependsOnString", "mainStringFlag", "Private", "Cat")] + [DataRow("stringDependsOnString", "mainStringFlag", true, null)] + [DataRow("stringDependsOnString", "mainStringFlag", 1, null)] + [DataRow("stringDependsOnString", "mainStringFlag", 1.0, null)] + [DataRow("stringDependsOnString", "mainStringFlag", new[] { "private" }, null)] + [DataRow("stringDependsOnString", "mainStringFlag", null, null)] + [DataRow("stringDependsOnInt", "mainIntFlag", 2, "Dog")] + [DataRow("stringDependsOnInt", "mainIntFlag", 1, "Cat")] + [DataRow("stringDependsOnInt", "mainIntFlag", "2", null)] + [DataRow("stringDependsOnInt", "mainIntFlag", true, null)] + [DataRow("stringDependsOnInt", "mainIntFlag", 2.0, null)] + [DataRow("stringDependsOnInt", "mainIntFlag", new[] { 2 }, null)] + [DataRow("stringDependsOnInt", "mainIntFlag", null, null)] + [DataRow("stringDependsOnDouble", "mainDoubleFlag", 0.1, "Dog")] + [DataRow("stringDependsOnDouble", "mainDoubleFlag", 0.11, "Cat")] + [DataRow("stringDependsOnDouble", "mainDoubleFlag", "0.1", null)] + [DataRow("stringDependsOnDouble", "mainDoubleFlag", true, null)] + [DataRow("stringDependsOnDouble", "mainDoubleFlag", 1, null)] + [DataRow("stringDependsOnDouble", "mainDoubleFlag", new[] { 0.1 }, null)] + [DataRow("stringDependsOnDouble", "mainDoubleFlag", null, null)] + public async Task PrerequisiteFlagComparisonValueTypeMismatchTest(string key, string prerequisiteFlagKey, object? prerequisiteFlagValue, object? expectedValue) + { + var cdnLocation = (ConfigLocation.Cdn)new FlagDependencyMatrixTestsDescriptor().ConfigLocation; + + var logEvents = new List(); + var logger = LoggingHelper.CreateCapturingLogger(logEvents); + + var overrideDictionary = new Dictionary { [prerequisiteFlagKey] = prerequisiteFlagValue! }; + + var options = new ConfigCatClientOptions + { + FlagOverrides = FlagOverrides.LocalDictionary(overrideDictionary, OverrideBehaviour.LocalOverRemote), + PollingMode = PollingModes.ManualPoll, + Logger = logger + }; + cdnLocation.ConfigureBaseUrl(options); + + using var client = new ConfigCatClient(cdnLocation.SdkKey, options); + await client.ForceRefreshAsync(); + + var actualValue = await client.GetValueAsync(key, (object?)null); + Assert.AreEqual(expectedValue, actualValue); + + if (expectedValue is null) + { + var errors = logEvents.Where(evt => evt.Level == LogLevel.Error).ToArray(); + Assert.AreEqual(1, errors.Length); + Assert.AreEqual(1002, errors[0].EventId); + var ex = errors[0].Exception; + Assert.IsInstanceOfType(ex, typeof(InvalidOperationException)); + + if (prerequisiteFlagValue == null) + { + StringAssert.Contains(ex!.Message, "Setting value is null"); + } + else if (prerequisiteFlagValue.GetType().ToSettingType() == Setting.UnknownType) + { + StringAssert.Matches(ex!.Message, new Regex("Setting value '[^']+' is of an unsupported type")); + } + else + { + StringAssert.Matches(ex!.Message, new Regex("Type mismatch between comparison value '[^']+' and prerequisite flag '[^']+'")); + } + } + } + + [DataTestMethod] + [DataRow("stringDependsOnString", "1", "john@sensitivecompany.com", null, "Dog")] + [DataRow("stringDependsOnString", "1", "john@sensitivecompany.com", OverrideBehaviour.RemoteOverLocal, "Dog")] + [DataRow("stringDependsOnString", "1", "john@sensitivecompany.com", OverrideBehaviour.LocalOverRemote, "Dog")] + [DataRow("stringDependsOnString", "1", "john@sensitivecompany.com", OverrideBehaviour.LocalOnly, null)] + [DataRow("stringDependsOnString", "2", "john@notsensitivecompany.com", null, "Cat")] + [DataRow("stringDependsOnString", "2", "john@notsensitivecompany.com", OverrideBehaviour.RemoteOverLocal, "Cat")] + [DataRow("stringDependsOnString", "2", "john@notsensitivecompany.com", OverrideBehaviour.LocalOverRemote, "Dog")] + [DataRow("stringDependsOnString", "2", "john@notsensitivecompany.com", OverrideBehaviour.LocalOnly, null)] + [DataRow("stringDependsOnInt", "1", "john@sensitivecompany.com", null, "Dog")] + [DataRow("stringDependsOnInt", "1", "john@sensitivecompany.com", OverrideBehaviour.RemoteOverLocal, "Dog")] + [DataRow("stringDependsOnInt", "1", "john@sensitivecompany.com", OverrideBehaviour.LocalOverRemote, "Cat")] + [DataRow("stringDependsOnInt", "1", "john@sensitivecompany.com", OverrideBehaviour.LocalOnly, null)] + [DataRow("stringDependsOnInt", "2", "john@notsensitivecompany.com", null, "Cat")] + [DataRow("stringDependsOnInt", "2", "john@notsensitivecompany.com", OverrideBehaviour.RemoteOverLocal, "Cat")] + [DataRow("stringDependsOnInt", "2", "john@notsensitivecompany.com", OverrideBehaviour.LocalOverRemote, "Dog")] + [DataRow("stringDependsOnInt", "2", "john@notsensitivecompany.com", OverrideBehaviour.LocalOnly, null)] + public async Task PrerequisiteFlagOverrideTest(string key, string userId, string email, OverrideBehaviour? overrideBehaviour, object expectedValue) + { + var cdnLocation = (ConfigLocation.Cdn)new FlagDependencyMatrixTestsDescriptor().ConfigLocation; + + var options = new ConfigCatClientOptions + { + // The flag override alters the definition of the following flags: + // * 'mainStringFlag': to check the case where a prerequisite flag is overridden (dependent flag: 'stringDependsOnString') + // * 'stringDependsOnInt': to check the case where a dependent flag is overridden (prerequisite flag: 'mainIntFlag') + FlagOverrides = overrideBehaviour is not null + ? FlagOverrides.LocalFile(Path.Combine("data", "test_override_flagdependency_v6.json"), autoReload: false, overrideBehaviour.Value) + : null, + PollingMode = PollingModes.ManualPoll, + }; + cdnLocation.ConfigureBaseUrl(options); + + using var client = new ConfigCatClient(cdnLocation.SdkKey, options); + await client.ForceRefreshAsync(); + var actualValue = await client.GetValueAsync(key, (object?)null, new User(userId) { Email = email }); + + Assert.AreEqual(expectedValue, actualValue); + } + + [DataTestMethod] + [DataRow("developerAndBetaUserSegment", "1", "john@example.com", null, false)] + [DataRow("developerAndBetaUserSegment", "1", "john@example.com", OverrideBehaviour.RemoteOverLocal, false)] + [DataRow("developerAndBetaUserSegment", "1", "john@example.com", OverrideBehaviour.LocalOverRemote, true)] + [DataRow("developerAndBetaUserSegment", "1", "john@example.com", OverrideBehaviour.LocalOnly, true)] + [DataRow("notDeveloperAndNotBetaUserSegment", "2", "kate@example.com", null, true)] + [DataRow("notDeveloperAndNotBetaUserSegment", "2", "kate@example.com", OverrideBehaviour.RemoteOverLocal, true)] + [DataRow("notDeveloperAndNotBetaUserSegment", "2", "kate@example.com", OverrideBehaviour.LocalOverRemote, true)] + [DataRow("notDeveloperAndNotBetaUserSegment", "2", "kate@example.com", OverrideBehaviour.LocalOnly, null)] + public async Task ConfigSaltAndSegmentsOverrideTest(string key, string userId, string email, OverrideBehaviour? overrideBehaviour, object expectedValue) + { + var cdnLocation = (ConfigLocation.Cdn)new SegmentMatrixTestsDescriptor().ConfigLocation; + + var options = new ConfigCatClientOptions + { + // The flag override uses a different config json salt than the downloaded one and overrides the following segments: + // * 'Beta Users': User.Email IS ONE OF ['jane@example.com'] + // * 'Developers': User.Email IS ONE OF ['john@example.com'] + FlagOverrides = overrideBehaviour is not null + ? FlagOverrides.LocalFile(Path.Combine("data", "test_override_segments_v6.json"), autoReload: false, overrideBehaviour.Value) + : null, + PollingMode = PollingModes.ManualPoll, + }; + cdnLocation.ConfigureBaseUrl(options); + + using var client = new ConfigCatClient(cdnLocation.SdkKey, options); + await client.ForceRefreshAsync(); + var actualValue = await client.GetValueAsync(key, (object?)null, new User(userId) { Email = email }); + + Assert.AreEqual(expectedValue, actualValue); + } + + // https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9e4e-4f59-86b2-5da50924b6ca/08dbc325-9ebd-4587-8171-88f76a3004cb + [DataTestMethod] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", null, null, null, "Cat", false, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", null, null, "Cat", false, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "a@example.com", null, "Dog", true, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "a@configcat.com", null, "Cat", false, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "a@configcat.com", "", "Frog", true, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "a@configcat.com", "US", "Fish", true, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "b@configcat.com", null, "Cat", false, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "b@configcat.com", "", "Falcon", false, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "b@configcat.com", "US", "Spider", false, true)] + public void EvaluationDetails_MatchedEvaluationRuleAndPercantageOption_Test(string sdkKey, string key, string? userId, string? email, string? percentageBase, + string expectedReturnValue, bool expectedIsExpectedMatchedTargetingRuleSet, bool expectedIsExpectedMatchedPercentageOptionSet) + { + var config = new ConfigLocation.Cdn(sdkKey).FetchConfigCached(); + + var logger = new Mock().Object.AsWrapper(); + var evaluator = new RolloutEvaluator(logger); + + var user = userId is not null ? new User(userId) { Email = email, Custom = { ["PercentageBase"] = percentageBase! } } : null; + + var evaluationDetails = evaluator.Evaluate(config!.Settings, key, defaultValue: null, user, remoteConfig: null, logger); + + Assert.AreEqual(expectedReturnValue, evaluationDetails.Value); + Assert.AreEqual(expectedIsExpectedMatchedTargetingRuleSet, evaluationDetails.MatchedTargetingRule is not null); + Assert.AreEqual(expectedIsExpectedMatchedPercentageOptionSet, evaluationDetails.MatchedPercentageOption is not null); + } + + [TestMethod] + public void UserObjectAttributeValueConversion_TextComparisons_Test() + { + var config = new ConfigLocation.Cdn("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ").FetchConfigCached(); + + var logEvents = new List(); + var logger = LoggingHelper.CreateCapturingLogger(logEvents).AsWrapper(); + var evaluator = new RolloutEvaluator(logger); + + const string customAttributeName = "Custom1"; + const int customAttributeValue = 42; + var user = new User("12345") { Custom = { [customAttributeName] = customAttributeValue } }; + + const string key = "boolTextEqualsNumber"; + var evaluationDetails = evaluator.Evaluate(config!.Settings, key, defaultValue: null, user, remoteConfig: null, logger); + + Assert.AreEqual(true, evaluationDetails.Value); + + var warnings = logEvents.Where(evt => evt.Level == LogLevel.Warning).ToArray(); + Assert.AreEqual(1, warnings.Length); + Assert.AreEqual(3005, warnings[0].EventId); + + var message = warnings[0].Message.ToString(); + var expectedAttributeValueText = ((double)customAttributeValue).ToString(CultureInfo.InvariantCulture); + Assert.AreEqual($"Evaluation of condition (User.{customAttributeName} EQUALS '{expectedAttributeValueText}') for setting '{key}' may not produce the expected result (the User.{customAttributeName} attribute is not a string value, thus it was automatically converted to the string value '{expectedAttributeValueText}'). Please make sure that using a non-string value was intended.", message); + } + + [DataTestMethod] + // SemVer-based comparisons + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "0.0", "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "0.9.9", "< 1.0.0")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "1.0.0", "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "1.1", "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", 0, "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", 0.9, "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", 2, "20%")] + // Number-based comparisons + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (sbyte)-1, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (sbyte)2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (sbyte)3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (sbyte)5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (byte)2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (byte)3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (byte)5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (short)-1, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (short)2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (short)3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (short)5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (ushort)2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (ushort)3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (ushort)5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", -1, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2u, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3u, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5u, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", long.MinValue, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2L, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3L, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5L, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", long.MaxValue, ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2ul, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3ul, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5ul, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", ulong.MaxValue, ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", float.NegativeInfinity, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", -1f, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2f, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2.1f, "<2.1")] // 2.1f < 2.1d as (double)2.1f is 2.0999999046325684 !!! However, this is how IEEE 754 works, so we don't bother about it. + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3f, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5f, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", float.PositiveInfinity, ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", float.NaN, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", double.NegativeInfinity, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", -1d, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2d, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2.1d, "<=2,1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3d, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5d, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", double.PositiveInfinity, ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", double.NaN, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:-79228162514264337593543950335", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:2", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:2.1", "<=2,1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:3", "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:5", ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:79228162514264337593543950335", ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "-Infinity", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "-1", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "2", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "2.1", "<=2,1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "2,1", "<=2,1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "3", "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "5", ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "Infinity", ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "NaN", "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "NaNa", "80%")] + // Date time-based comparisons + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-03-31T23:59:59.9990000Z", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-04-01T01:59:59.9990000+02:00", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-04-01T00:00:00.0010000Z", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-04-01T02:00:00.0010000+02:00", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-04-30T23:59:59.9990000Z", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-05-01T01:59:59.9990000+02:00", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-05-01T00:00:00.0010000Z", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-05-01T02:00:00.0010000+02:00", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-03-31T23:59:59.9990000Z", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-04-01T01:59:59.9990000+02:00", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-04-01T00:00:00.0010000Z", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-04-01T02:00:00.0010000+02:00", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-04-30T23:59:59.9990000Z", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-05-01T01:59:59.9990000+02:00", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-05-01T00:00:00.0010000Z", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-05-01T02:00:00.0010000+02:00", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", double.NegativeInfinity, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307199.999, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307200.001, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899199.999, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899200.001, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", double.PositiveInfinity, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", double.NaN, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307199, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307201, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899199, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899201, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "-Infinity", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1680307199.999", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1680307200.001", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1682899199.999", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1682899200.001", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "+Infinity", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "NaN", false)] + // String array-based comparisons + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", new string[] { "x", "read" }, "Dog")] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", new string[] { "x", "Read" }, "Cat")] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", "[\"x\", \"read\"]", "Dog")] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", "[\"x\", \"Read\"]", "Cat")] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", "x, read", "Cat")] + public void UserObjectAttributeValueConversion_NonTextComparisons_Test(string sdkKey, string key, string? userId, string customAttributeName, object customAttributeValue, + object expectedReturnValue) + { + var config = new ConfigLocation.Cdn(sdkKey).FetchConfigCached(); + + var logger = new Mock().Object.AsWrapper(); + var evaluator = new RolloutEvaluator(logger); + + if (customAttributeValue is string s) + { + const string decimalPrefix = "decimal:", dateTimePrefix = "datetime:", dateTimeOffsetPrefix = "datetimeoffset:"; + if (s.StartsWith(decimalPrefix, StringComparison.Ordinal)) + { + customAttributeValue = decimal.Parse(s.Substring(decimalPrefix.Length)); + } + else if (s.StartsWith(dateTimePrefix, StringComparison.Ordinal)) + { + var dateTimeStyle = s.EndsWith("Z", StringComparison.Ordinal) ? DateTimeStyles.AdjustToUniversal : DateTimeStyles.None; + customAttributeValue = DateTime.ParseExact(s.Substring(dateTimePrefix.Length), "o", CultureInfo.InvariantCulture, dateTimeStyle); + } + else if (s.StartsWith(dateTimeOffsetPrefix, StringComparison.Ordinal)) + { + customAttributeValue = DateTimeOffset.ParseExact(s.Substring(dateTimeOffsetPrefix.Length), "o", CultureInfo.InvariantCulture); + } + } + + var user = userId is not null ? new User(userId) { Custom = { [customAttributeName] = customAttributeValue! } } : null; + + var evaluationDetails = evaluator.Evaluate(config!.Settings, key, defaultValue: null, user, remoteConfig: null, logger); + + Assert.AreEqual(expectedReturnValue, evaluationDetails.Value); + } +} diff --git a/src/ConfigCat.Client.Tests/DataGovernanceTests.cs b/src/ConfigCat.Client.Tests/DataGovernanceTests.cs index 4dbbd62f..0cb42e00 100644 --- a/src/ConfigCat.Client.Tests/DataGovernanceTests.cs +++ b/src/ConfigCat.Client.Tests/DataGovernanceTests.cs @@ -86,7 +86,7 @@ public async Task ClientIsGlobalAndOrgSettingIsGlobal_AllRequestsInvokeGlobalCdn DataGovernance = DataGovernance.Global }; - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { { GlobalCdnUri.Host, CreateResponse() } }; @@ -111,7 +111,7 @@ public async Task ClientIsEuOnlyAndOrgSettingIsGlobal_FirstRequestInvokesEuAfter DataGovernance = DataGovernance.EuOnly }; - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { {GlobalCdnUri.Host, CreateResponse()}, {EuOnlyCdnUri.Host, CreateResponse()} @@ -139,7 +139,7 @@ public async Task ClientIsGlobalAndOrgSettingIsEuOnly_FirstRequestInvokesGlobalA DataGovernance = DataGovernance.Global }; - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { {GlobalCdnUri.Host, CreateResponse(ConfigCatClientOptions.BaseUrlEu, RedirectMode.Should, false)}, {EuOnlyCdnUri.Host, CreateResponse(ConfigCatClientOptions.BaseUrlEu, RedirectMode.No, true)} @@ -168,7 +168,7 @@ public async Task ClientIsEuOnlyAndOrgSettingIsEuOnly_AllRequestsInvokeEu() DataGovernance = DataGovernance.EuOnly }; - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { {EuOnlyCdnUri.Host, CreateResponse(ConfigCatClientOptions.BaseUrlEu)} }; @@ -188,7 +188,7 @@ public async Task ClientIsGlobalAndHasCustomBaseUri_AllRequestInvokeCustomUri() { // Arrange - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { {CustomCdnUri.Host, CreateResponse()} }; @@ -215,7 +215,7 @@ public async Task ClientIsEuOnlyAndHasCustomBaseUri_AllRequestInvokeCustomUri() { // Arrange - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { {CustomCdnUri.Host, CreateResponse()} }; @@ -242,7 +242,7 @@ public async Task ClientIsGlobalAndOrgIsForced_AllRequestInvokeForcedUri() { // Arrange - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { {GlobalCdnUri.Host, CreateResponse(ForcedCdnUri, RedirectMode.Force, false)}, {ForcedCdnUri.Host, CreateResponse(ForcedCdnUri, RedirectMode.Force, true)} @@ -270,7 +270,7 @@ public async Task ClientIsEuOnlyAndOrgIsForced_AllRequestInvokeForcedUri() { // Arrange - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { {EuOnlyCdnUri.Host, CreateResponse(ForcedCdnUri, RedirectMode.Force, false)}, {ForcedCdnUri.Host, CreateResponse(ForcedCdnUri, RedirectMode.Force, true)} @@ -298,7 +298,7 @@ public async Task ClientIsGlobalAndHasCustomBaseUriAndOrgIsForced_FirstRequestIn { // Arrange - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { {CustomCdnUri.Host, CreateResponse(ForcedCdnUri, RedirectMode.Force, false)}, {ForcedCdnUri.Host, CreateResponse(ForcedCdnUri, RedirectMode.Force, true)} @@ -327,7 +327,7 @@ public async Task TestCircuitBreaker_WhenClientIsGlobalRedirectToEuAndRedirectTo { // Arrange - var responsesRegistry = new Dictionary + var responsesRegistry = new Dictionary { {GlobalCdnUri.Host, CreateResponse(EuOnlyCdnUri, RedirectMode.Should, false)}, {EuOnlyCdnUri.Host, CreateResponse(GlobalCdnUri, RedirectMode.Should, false)} @@ -351,7 +351,7 @@ public async Task TestCircuitBreaker_WhenClientIsGlobalRedirectToEuAndRedirectTo internal static async Task> Fetch( string sdkKey, ConfigCatClientOptions fetchConfig, - Dictionary responsesRegistry, + Dictionary responsesRegistry, byte fetchInvokeCount = 1) { // Arrange @@ -400,13 +400,13 @@ internal static async Task> Fetch( return requests; } - private static SettingsWithPreferences CreateResponse(Uri? url = null, RedirectMode redirectMode = RedirectMode.No, bool withSettings = true) + private static Config CreateResponse(Uri? url = null, RedirectMode redirectMode = RedirectMode.No, bool withSettings = true) { - var response = new SettingsWithPreferences + var response = new Config { Preferences = new Preferences { - Url = (url ?? ConfigCatClientOptions.BaseUrlGlobal).ToString(), + BaseUrl = (url ?? ConfigCatClientOptions.BaseUrlGlobal).ToString(), RedirectMode = redirectMode }, }; diff --git a/src/ConfigCat.Client.Tests/DeserializerTests.cs b/src/ConfigCat.Client.Tests/DeserializerTests.cs index ee35789c..3205f8ee 100644 --- a/src/ConfigCat.Client.Tests/DeserializerTests.cs +++ b/src/ConfigCat.Client.Tests/DeserializerTests.cs @@ -19,7 +19,7 @@ public void Ensure_Global_Settings_Doesnt_Interfere() return settings; }; - Assert.IsNotNull("{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}".DeserializeOrDefault()); + Assert.IsNotNull("{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}".DeserializeOrDefault()); } [DataRow(false)] diff --git a/src/ConfigCat.Client.Tests/EvaluationLogTests.cs b/src/ConfigCat.Client.Tests/EvaluationLogTests.cs new file mode 100644 index 00000000..87852101 --- /dev/null +++ b/src/ConfigCat.Client.Tests/EvaluationLogTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Tests.Helpers; +using ConfigCat.Client.Utils; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +#if NET45 +using Newtonsoft.Json; +using JsonObject = Newtonsoft.Json.Linq.JObject; +using JsonValue = Newtonsoft.Json.Linq.JValue; +#else +using System.Text.Json.Serialization; +using JsonObject = System.Text.Json.JsonElement; +using JsonValue = System.Text.Json.JsonElement; +#endif + +namespace ConfigCat.Client.Tests; + +[TestClass] +public class EvaluationLogTests +{ + private static readonly string TestDataRootPath = Path.Combine("data", "evaluationlog"); + + private static IEnumerable GetSimpleValueTests() => GetTests("simple_value"); + + [DataTestMethod] + [DynamicData(nameof(GetSimpleValueTests), DynamicDataSourceType.Method)] + public void SimpleValueTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetOneTargetingRuleTests() => GetTests("1_targeting_rule"); + + [DataTestMethod] + [DynamicData(nameof(GetOneTargetingRuleTests), DynamicDataSourceType.Method)] + public void OneTargetingRuleTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetTwoTargetingRulesTests() => GetTests("2_targeting_rules"); + + [DataTestMethod] + [DynamicData(nameof(GetTwoTargetingRulesTests), DynamicDataSourceType.Method)] + public void TwoTargetingRulesTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetPercentageOptionsBasedOnUserIdAttributeTests() => GetTests("options_based_on_user_id"); + + [DataTestMethod] + [DynamicData(nameof(GetPercentageOptionsBasedOnUserIdAttributeTests), DynamicDataSourceType.Method)] + public void PercentageOptionsBasedOnUserIdAttributeTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetPercentageOptionsBasedOnCustomAttributeTests() => GetTests("options_based_on_custom_attr"); + + [DataTestMethod] + [DynamicData(nameof(GetPercentageOptionsBasedOnCustomAttributeTests), DynamicDataSourceType.Method)] + public void PercentageOptionsBasedOnCustomAttributeTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetPercentageOptionsAfterTargetingRuleTests() => GetTests("options_after_targeting_rule"); + + [DataTestMethod] + [DynamicData(nameof(GetPercentageOptionsAfterTargetingRuleTests), DynamicDataSourceType.Method)] + public void PercentageOptionsAfterTargetingRuleTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetPercentageOptionsWithinTargetingRuleTests() => GetTests("options_within_targeting_rule"); + + [DataTestMethod] + [DynamicData(nameof(GetPercentageOptionsWithinTargetingRuleTests), DynamicDataSourceType.Method)] + public void PercentageOptionsWithinTargetingRuleTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetAndRulesTests() => GetTests("and_rules"); + + [DataTestMethod] + [DynamicData(nameof(GetAndRulesTests), DynamicDataSourceType.Method)] + public void AndRulesTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetSegmentConditionsTests() => GetTests("segment"); + + [DataTestMethod] + [DynamicData(nameof(GetSegmentConditionsTests), DynamicDataSourceType.Method)] + public void SegmentConditionsTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetPrerequisiteFlagConditionsTests() => GetTests("prerequisite_flag"); + + [DataTestMethod] + [DynamicData(nameof(GetPrerequisiteFlagConditionsTests), DynamicDataSourceType.Method)] + public void PrerequisiteFlagConditionsTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetComparatorsTests() => GetTests("comparators"); + + [DataTestMethod] + [DynamicData(nameof(GetComparatorsTests), DynamicDataSourceType.Method)] + public void ComparatorsTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetEpochDateValidationTests() => GetTests("epoch_date_validation"); + + [DataTestMethod] + [DynamicData(nameof(GetEpochDateValidationTests), DynamicDataSourceType.Method)] + public void EpochDateValidationTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetNumberValidationTests() => GetTests("number_validation"); + + [DataTestMethod] + [DynamicData(nameof(GetNumberValidationTests), DynamicDataSourceType.Method)] + public void NumberValidationTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetSemVerValidationTests() => GetTests("semver_validation"); + + [DataTestMethod] + [DynamicData(nameof(GetSemVerValidationTests), DynamicDataSourceType.Method)] + public void SemVerValidationTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetListTruncationTests() => GetTests("list_truncation"); + + [DataTestMethod] + [DynamicData(nameof(GetListTruncationTests), DynamicDataSourceType.Method)] + public void ListTruncationTests(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, + string key, string? defaultValue, string userObject, string? expectedReturnValue, string expectedLogFileName) + { + RunTest(testSetName, sdkKey, baseUrlOrOverrideFileName, key, defaultValue, userObject, expectedReturnValue, expectedLogFileName); + } + + private static IEnumerable GetTests(string testSetName) + { + var filePath = Path.Combine(TestDataRootPath, testSetName + ".json"); + var fileContent = File.ReadAllText(filePath); + var testSet = SerializationExtensions.Deserialize(fileContent); + + foreach (var testCase in testSet!.tests ?? ArrayUtils.EmptyArray()) + { + yield return new object?[] + { + testSetName, + testSet.sdkKey, + testSet.sdkKey is { Length: > 0 } ? testSet.baseUrl : testSet.jsonOverride, + testCase.key, + testCase.defaultValue.Serialize(), + testCase.user?.Serialize(), + testCase.returnValue.Serialize(), + testCase.expectedLog + }; + } + } + + private static void RunTest(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, string key, string? defaultValue, string? userObject, string? expectedReturnValue, string expectedLogFileName) + { + var defaultValueParsed = defaultValue?.Deserialize()!.ToSettingValue(out var settingType).GetValue(); + var expectedReturnValueParsed = expectedReturnValue?.Deserialize()!.ToSettingValue(out _).GetValue(); + + var userObjectParsed = userObject?.Deserialize?>(); + User? user; + if (userObjectParsed is not null) + { + user = new User(userObjectParsed[nameof(User.Identifier)]); + + if (userObjectParsed.TryGetValue(nameof(User.Email), out var email)) + { + user.Email = email; + } + + if (userObjectParsed.TryGetValue(nameof(User.Country), out var country)) + { + user.Country = country; + } + + foreach (var kvp in userObjectParsed) + { + if (kvp.Key is not (nameof(User.Identifier) or nameof(User.Email) or nameof(User.Country))) + { + user.Custom[kvp.Key] = kvp.Value; + } + } + } + else + { + user = null; + } + + var logEvents = new List(); + var logger = LoggingHelper.CreateCapturingLogger(logEvents).AsWrapper(); + + ConfigLocation configLocation = sdkKey is { Length: > 0 } + ? new ConfigLocation.Cdn(sdkKey, baseUrlOrOverrideFileName) + : new ConfigLocation.LocalFile(TestDataRootPath, "_overrides", baseUrlOrOverrideFileName!); + + var settings = configLocation.FetchConfigCached().Settings; + + var evaluator = new RolloutEvaluator(logger); + var evaluationDetails = evaluator.Evaluate(settings, key, defaultValueParsed, user, remoteConfig: null, logger); + var actualReturnValue = evaluationDetails.Value; + + Assert.AreEqual(expectedReturnValueParsed, actualReturnValue); + + var expectedLogFilePath = Path.Combine(TestDataRootPath, testSetName, expectedLogFileName); + var expectedLogText = string.Join(Environment.NewLine, File.ReadAllLines(expectedLogFilePath)); + + var actualLogText = string.Join(Environment.NewLine, logEvents.Select(evt => FormatLogEvent(ref evt))); + + Assert.AreEqual(expectedLogText, actualLogText); + } + + private static string FormatLogEvent(ref LogEvent evt) + { + var levelString = evt.Level switch + { + LogLevel.Debug => "DEBUG", + LogLevel.Info => "INFO", + LogLevel.Warning => "WARNING", + LogLevel.Error => "ERROR", + _ => evt.Level.ToString().ToUpperInvariant().PadRight(5) + }; + + var eventIdString = evt.EventId.Id.ToString(CultureInfo.InvariantCulture); + + var exceptionString = evt.Exception is null ? string.Empty : Environment.NewLine + evt.Exception; + + return $"{levelString} [{eventIdString}] {evt.Message.InvariantFormattedMessage}{exceptionString}"; + } + +#pragma warning disable IDE1006 // Naming Styles + public class TestSet + { + public string? sdkKey { get; set; } + public string? baseUrl { get; set; } + public string? jsonOverride { get; set; } + public TestCase[]? tests { get; set; } + } + + public class TestCase + { + public string key { get; set; } = null!; + public JsonValue defaultValue { get; set; } = default!; + public JsonObject? user { get; set; } = default!; + public JsonValue returnValue { get; set; } = default!; + public string expectedLog { get; set; } = null!; + } +#pragma warning restore IDE1006 // Naming Styles + + [DataTestMethod] + [DataRow(LogLevel.Off, false)] + [DataRow(LogLevel.Error, false)] + [DataRow(LogLevel.Warning, false)] + [DataRow(LogLevel.Info, true)] + [DataRow(LogLevel.Debug, true)] + public void EvaluationLogShouldBeBuiltOnlyWhenNecessary(LogLevel logLevel, bool expectedIsLogBuilt) + { + var settings = new ConfigLocation.Cdn("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ").FetchConfigCached().Settings; + + var logEvents = new List(); + var logger = LoggingHelper.CreateCapturingLogger(logEvents, logLevel).AsWrapper(); + + var evaluator = new RolloutEvaluator(logger); + + var actualIsLogBuilt = false; + var evaluatorMock = new Mock(); + evaluatorMock + .Setup(e => e.Evaluate(It.IsAny(), ref It.Ref.IsAny, out It.Ref.IsAny)) + .Returns((bool? defaultValue, ref EvaluateContext ctx, out bool? returnValue) => + { + var result = evaluator.Evaluate(defaultValue, ref ctx, out returnValue); + actualIsLogBuilt = ctx.LogBuilder is not null; + return result; + }); + + var evaluationResult = evaluatorMock.Object.Evaluate(settings, "bool30TrueAdvancedRules", defaultValue: null, user: null, remoteConfig: null, logger); + Assert.IsFalse(evaluationResult.IsDefaultValue); + Assert.IsTrue(evaluationResult.Value); + + Assert.AreEqual(actualIsLogBuilt, expectedIsLogBuilt); + + Assert.AreEqual(logLevel >= LogLevel.Warning, logEvents.Any(evt => evt is { Level: LogLevel.Warning, EventId.Id: 3001 })); + Assert.AreEqual(expectedIsLogBuilt, logEvents.Any(evt => evt is { Level: LogLevel.Info, EventId.Id: 5000 })); + } +} diff --git a/src/ConfigCat.Client.Tests/BasicConfigEvaluatorTests.cs b/src/ConfigCat.Client.Tests/EvaluationTestsBase.cs similarity index 76% rename from src/ConfigCat.Client.Tests/BasicConfigEvaluatorTests.cs rename to src/ConfigCat.Client.Tests/EvaluationTestsBase.cs index b1c94fe6..ee67108b 100644 --- a/src/ConfigCat.Client.Tests/BasicConfigEvaluatorTests.cs +++ b/src/ConfigCat.Client.Tests/EvaluationTestsBase.cs @@ -6,17 +6,23 @@ namespace ConfigCat.Client.Tests; -[TestClass] -public class BasicConfigEvaluatorTests : ConfigEvaluatorTestsBase +public abstract class EvaluationTestsBase { - protected override string SampleJsonFileName => "sample_v5.json"; + private protected readonly LoggerWrapper logger; + private protected readonly IRolloutEvaluator configEvaluator; - protected override string MatrixResultFileName => "testmatrix.csv"; + public EvaluationTestsBase() + { + this.logger = new ConsoleLogger(LogLevel.Debug).AsWrapper(); + this.configEvaluator = new RolloutEvaluator(this.logger); + } + + private protected abstract Dictionary BasicConfig { get; } [TestMethod] public void GetValue_WithSimpleKey_ShouldReturnCat() { - var actual = this.configEvaluator.Evaluate(this.config, "stringDefaultCat", string.Empty, user: null, null, this.Logger).Value; + var actual = this.configEvaluator.Evaluate(BasicConfig, "stringDefaultCat", string.Empty, user: null, null, this.logger).Value; Assert.AreNotEqual(string.Empty, actual); Assert.AreEqual("Cat", actual); @@ -25,7 +31,7 @@ public void GetValue_WithSimpleKey_ShouldReturnCat() [TestMethod] public void GetValue_WithNonExistingKey_ShouldReturnDefaultValue() { - var actual = this.configEvaluator.Evaluate(this.config, "NotExistsKey", "NotExistsValue", user: null, null, this.Logger).Value; + var actual = this.configEvaluator.Evaluate(BasicConfig, "NotExistsKey", "NotExistsValue", user: null, null, this.logger).Value; Assert.AreEqual("NotExistsValue", actual); } @@ -33,7 +39,7 @@ public void GetValue_WithNonExistingKey_ShouldReturnDefaultValue() [TestMethod] public void GetValue_WithEmptyProjectConfig_ShouldReturnDefaultValue() { - var actual = this.configEvaluator.Evaluate(new Dictionary(), "stringDefaultCat", "Default", user: null, null, this.Logger).Value; + var actual = this.configEvaluator.Evaluate(new Dictionary(), "stringDefaultCat", "Default", user: null, null, this.logger).Value; Assert.AreEqual("Default", actual); } @@ -41,17 +47,17 @@ public void GetValue_WithEmptyProjectConfig_ShouldReturnDefaultValue() [TestMethod] public void GetValue_WithUser_ShouldReturnEvaluatedValue() { - var actual = this.configEvaluator.Evaluate(this.config, "doubleDefaultPi", double.NaN, new User("c@configcat.com") + var actual = this.configEvaluator.Evaluate(BasicConfig, "doubleDefaultPi", double.NaN, new User("c@configcat.com") { Email = "c@configcat.com", Country = "United Kingdom", - Custom = new Dictionary { { "Custom1", "admin" } } - }, null, this.Logger).Value; + Custom = { { "Custom1", "admin" } } + }, null, this.logger).Value; Assert.AreEqual(3.1415, actual); } - private delegate EvaluationDetails EvaluateDelegate(IRolloutEvaluator evaluator, IReadOnlyDictionary settings, string key, object defaultValue, User user, + private delegate EvaluationDetails EvaluateDelegate(IRolloutEvaluator evaluator, Dictionary settings, string key, object defaultValue, User user, ProjectConfig remoteConfig, LoggerWrapper logger); private static readonly MethodInfo EvaluateMethodDefinition = new EvaluateDelegate(RolloutEvaluatorExtensions.Evaluate).Method.GetGenericMethodDefinition(); @@ -75,12 +81,12 @@ public void GetValue_WithCompatibleDefaultValue_ShouldSucceed(string key, object var args = new object?[] { this.configEvaluator, - this.config, + BasicConfig, key, defaultValue, null, null, - this.Logger, + this.logger, }; var evaluationDetails = (EvaluationDetails)EvaluateMethodDefinition.MakeGenericMethod(settingClrType).Invoke(null, args)!; @@ -98,12 +104,12 @@ public void GetValue_WithIncompatibleDefaultValueType_ShouldThrowWithImprovedErr var args = new object?[] { this.configEvaluator, - this.config, + BasicConfig, key, defaultValue, null, null, - this.Logger, + this.logger, }; var ex = Assert.ThrowsException(() => @@ -111,6 +117,6 @@ public void GetValue_WithIncompatibleDefaultValueType_ShouldThrowWithImprovedErr try { EvaluateMethodDefinition.MakeGenericMethod(settingClrType).Invoke(null, args); } catch (TargetInvocationException ex) { throw ex.InnerException!; } }); - StringAssert.Contains(ex.Message, $"Setting's type was {this.config[key].SettingType} but the default value's type was {settingClrType}."); + StringAssert.Contains(ex.Message, $"Setting's type was {BasicConfig[key].SettingType} but the default value's type was {settingClrType}."); } } diff --git a/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs b/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs index dea1fbff..d01c2df9 100644 --- a/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs +++ b/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.IO; namespace ConfigCat.Client.Tests.Helpers; @@ -7,11 +8,22 @@ internal static class ConfigHelper { public static ProjectConfig FromString(string configJson, string? httpETag, DateTime timeStamp) { - return new ProjectConfig(configJson, configJson.Deserialize(), timeStamp, httpETag); + return new ProjectConfig(configJson, configJson.Deserialize(), timeStamp, httpETag); } public static ProjectConfig FromFile(string configJsonFilePath, string? httpETag, DateTime timeStamp) { return FromString(File.ReadAllText(configJsonFilePath), httpETag, timeStamp); } + + private static readonly ConcurrentDictionary> ConfigCache = new(); + + public static Config FetchConfigCached(this ConfigLocation location) + { + // NOTE: ConfigLocation is a record type, that is, has value equality, + // which is exactly what we want here w.r.t. the cache key. + return ConfigCache + .GetOrAdd(location, _ => new Lazy(() => location.FetchConfig(), isThreadSafe: true)) + .Value; + } } diff --git a/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.Cdn.cs b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.Cdn.cs new file mode 100644 index 00000000..a1091748 --- /dev/null +++ b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.Cdn.cs @@ -0,0 +1,50 @@ +using System; +using ConfigCat.Client.Configuration; + +namespace ConfigCat.Client.Tests.Helpers; + +public partial record class ConfigLocation +{ + public sealed record class Cdn : ConfigLocation + { + public Cdn(string sdkKey, string? baseUrl = null) => (SdkKey, BaseUrl) = (sdkKey, baseUrl); + + public string SdkKey { get; } + public string? BaseUrl { get; } + + public override string GetRealLocation() + { + var options = new ConfigCatClientOptions(); + ConfigureBaseUrl(options); + return options.CreateUri(SdkKey).ToString(); + } + + internal override Config FetchConfig() + { + var options = new ConfigCatClientOptions() + { + PollingMode = PollingModes.ManualPoll, + Logger = new ConsoleLogger(), + }; + ConfigureBaseUrl(options); + + using var configFetcher = new HttpConfigFetcher( + options.CreateUri(SdkKey), + ConfigCatClient.GetProductVersion(options.PollingMode), + options.Logger!.AsWrapper(), + options.HttpClientHandler, + options.IsCustomBaseUrl, + options.HttpTimeout); + + var fetchResult = configFetcher.Fetch(ProjectConfig.Empty); + return fetchResult.IsSuccess + ? fetchResult.Config.Config! + : throw new InvalidOperationException("Could not fetch config from CDN: " + fetchResult.ErrorMessage); + } + + internal void ConfigureBaseUrl(ConfigCatClientOptions options) + { + options.BaseUrl = BaseUrl is not null ? new Uri(BaseUrl) : ConfigCatClientOptions.BaseUrlEu; + } + } +} diff --git a/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs new file mode 100644 index 00000000..ef6bd60f --- /dev/null +++ b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; + +#if BENCHMARK_OLD +using Config = ConfigCat.Client.SettingsWithPreferences; +#endif + +namespace ConfigCat.Client.Tests.Helpers; + +public partial record class ConfigLocation +{ + public sealed record class LocalFile : ConfigLocation + { + public LocalFile(params string[] paths) => FilePath = Path.Combine(paths); + + public string FilePath { get; } + + public override string GetRealLocation() => FilePath; + + internal override Config FetchConfig() + { + using Stream stream = File.OpenRead(FilePath); + using StreamReader reader = new(stream); + var configJson = reader.ReadToEnd(); + return configJson.Deserialize() ?? throw new InvalidOperationException("Invalid config JSON content: " + configJson); + } + } +} diff --git a/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.cs b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.cs new file mode 100644 index 00000000..1afc6237 --- /dev/null +++ b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.cs @@ -0,0 +1,14 @@ +#if BENCHMARK_OLD +using Config = ConfigCat.Client.SettingsWithPreferences; +#endif + +namespace ConfigCat.Client.Tests.Helpers; + +public abstract partial record class ConfigLocation +{ + private ConfigLocation() { } + + public abstract string GetRealLocation(); + + internal abstract Config FetchConfig(); +} diff --git a/src/ConfigCat.Client.Tests/Helpers/LoggerExtensions.cs b/src/ConfigCat.Client.Tests/Helpers/LoggerExtensions.cs deleted file mode 100644 index 23b100f3..00000000 --- a/src/ConfigCat.Client.Tests/Helpers/LoggerExtensions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ConfigCat.Client; - -internal static class LoggerExtensions -{ - public static LoggerWrapper AsWrapper(this IConfigCatLogger logger, Hooks? hooks = null) - { - return new LoggerWrapper(logger, hooks); - } -} diff --git a/src/ConfigCat.Client.Tests/Helpers/LoggingHelper.cs b/src/ConfigCat.Client.Tests/Helpers/LoggingHelper.cs new file mode 100644 index 00000000..1ed18ede --- /dev/null +++ b/src/ConfigCat.Client.Tests/Helpers/LoggingHelper.cs @@ -0,0 +1,38 @@ +using Moq; +using System.Collections.Generic; +using System; + +namespace ConfigCat.Client; + +internal struct LogEvent +{ + public LogEvent(LogLevel level, LogEventId eventId, ref FormattableLogMessage message, Exception? exception) + { + (this.Level, this.EventId, this.Message, this.Exception) = (level, eventId, message, exception); + } + + public readonly LogLevel Level; + public readonly LogEventId EventId; + public FormattableLogMessage Message; + public readonly Exception? Exception; +} + +internal static class LoggingHelper +{ + public static LoggerWrapper AsWrapper(this IConfigCatLogger logger, Hooks? hooks = null) + { + return new LoggerWrapper(logger, hooks); + } + + public static IConfigCatLogger CreateCapturingLogger(List logEvents, LogLevel logLevel = LogLevel.Info) + { + var loggerMock = new Mock(); + + loggerMock.SetupGet(logger => logger.LogLevel).Returns(logLevel); + + loggerMock.Setup(logger => logger.Log(It.IsAny(), It.IsAny(), ref It.Ref.IsAny, It.IsAny())) + .Callback(delegate (LogLevel level, LogEventId eventId, ref FormattableLogMessage msg, Exception ex) { logEvents.Add(new LogEvent(level, eventId, ref msg, ex)); }); + + return loggerMock.Object; + } +} diff --git a/src/ConfigCat.Client.Tests/MatrixTestRunner.cs b/src/ConfigCat.Client.Tests/MatrixTestRunner.cs new file mode 100644 index 00000000..708f7647 --- /dev/null +++ b/src/ConfigCat.Client.Tests/MatrixTestRunner.cs @@ -0,0 +1,23 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ConfigCat.Client.Tests; + +public class MatrixTestRunner : MatrixTestRunnerBase + where TDescriptor : IMatrixTestDescriptor, new() +{ + private static readonly Lazy> DefaultLazy = new(() => new MatrixTestRunner(), isThreadSafe: true); + public static MatrixTestRunnerBase Default => DefaultLazy.Value; + + protected override bool AssertValue(string expected, Func parse, T actual, string keyName, string? userId) + { + Assert.AreEqual(parse(expected), actual, $"config: {DescriptorInstance.ConfigLocation.GetRealLocation()} | keyName: {keyName} | userId: {userId}"); + return true; + } + + protected override bool AssertVariationId(string expected, string? actual, string keyName, string? userId) + { + Assert.AreEqual(expected, actual, $"config: {DescriptorInstance.ConfigLocation.GetRealLocation()} | keyName: {keyName} | userId: {userId}"); + return true; + } +} diff --git a/src/ConfigCat.Client.Tests/MatrixTestRunnerBase.cs b/src/ConfigCat.Client.Tests/MatrixTestRunnerBase.cs new file mode 100644 index 00000000..798831a9 --- /dev/null +++ b/src/ConfigCat.Client.Tests/MatrixTestRunnerBase.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Tests.Helpers; + +#if BENCHMARK_OLD +using Config = ConfigCat.Client.SettingsWithPreferences; + +namespace ConfigCat.Client.Benchmarks.Old; +#elif BENCHMARK_NEW +namespace ConfigCat.Client.Benchmarks.New; +#else +namespace ConfigCat.Client.Tests; +#endif + +// NOTE: These types are intentionally placed into a separate source file because it's also used in the benchmark project. + +public interface IMatrixTestDescriptor +{ + public ConfigLocation ConfigLocation { get; } + public string MatrixResultFileName { get; } +} + +public interface IVariationIdMatrixText { } + +public class MatrixTestRunnerBase where TDescriptor : IMatrixTestDescriptor, new() +{ + public static readonly TDescriptor DescriptorInstance = new(); + + private readonly bool isVariationIdMatrixTest; + internal readonly Dictionary config; + + public MatrixTestRunnerBase() + { + this.isVariationIdMatrixTest = DescriptorInstance is IVariationIdMatrixText; + this.config = DescriptorInstance.ConfigLocation.FetchConfigCached().Settings; + } + + public static IEnumerable GetTests() + { + var resultFilePath = Path.Combine("data", DescriptorInstance.MatrixResultFileName); + var configLocation = DescriptorInstance.ConfigLocation.ToString(); + + using var reader = new StreamReader(resultFilePath); + var header = reader.ReadLine()!; + + var columns = header.Split(new[] { ';' }); + + while (!reader.EndOfStream) + { + var rawline = reader.ReadLine(); + + if (string.IsNullOrEmpty(rawline)) + continue; + + var row = rawline.Split(new[] { ';' }); + + string? userId = null, userEmail = null, userCountry = null, userCustomAttributeName = null, userCustomAttributeValue = null; + if (row[0] != "##null##") + { + userId = row[0]; + userEmail = row[1] is "" or "##null##" ? null : row[1]; + userCountry = row[2] is "" or "##null##" ? null : row[2]; + if (row[3] is not ("" or "##null##")) + { + userCustomAttributeName = columns[3]; + userCustomAttributeValue = row[3]; + } + } + + for (var i = 4; i < columns.Length; i++) + { + yield return new[] + { + configLocation, columns[i], row[i], + userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue + }; + } + } + } + + protected virtual bool AssertValue(string expected, Func parse, T actual, string keyName, string? userId) => true; + + protected virtual bool AssertVariationId(string expected, string? actual, string keyName, string? userId) => true; + + internal bool RunTest(IRolloutEvaluator evaluator, LoggerWrapper logger, string settingKey, string expectedReturnValue, User? user = null) + { + if (this.isVariationIdMatrixTest) + { + var actual = evaluator.Evaluate(this.config, settingKey, (object?)null, user, null, logger).VariationId; + return AssertVariationId(expectedReturnValue, actual, settingKey, user?.Identifier); + } + else + { + if (settingKey.StartsWith("bool", StringComparison.OrdinalIgnoreCase)) + { + var actual = evaluator.Evaluate(this.config, settingKey, false, user, null, logger).Value; + + return AssertValue(expectedReturnValue, static e => bool.Parse(e), actual, settingKey, user?.Identifier); + } + else if (settingKey.StartsWith("double", StringComparison.OrdinalIgnoreCase)) + { + var actual = evaluator.Evaluate(this.config, settingKey, double.NaN, user, null, logger).Value; + + return AssertValue(expectedReturnValue, static e => double.Parse(e, CultureInfo.InvariantCulture), actual, settingKey, user?.Identifier); + } + else if (settingKey.StartsWith("integer", StringComparison.OrdinalIgnoreCase)) + { + var actual = evaluator.Evaluate(this.config, settingKey, int.MinValue, user, null, logger).Value; + + return AssertValue(expectedReturnValue, static e => int.Parse(e, CultureInfo.InvariantCulture), actual, settingKey, user?.Identifier); + } + else if (settingKey.StartsWith("string", StringComparison.OrdinalIgnoreCase)) + { + var actual = evaluator.Evaluate(this.config, settingKey, string.Empty, user, null, logger).Value; + + return AssertValue(expectedReturnValue, static e => e, actual, settingKey, user?.Identifier); + } + else + { + var actual = evaluator.Evaluate(this.config, settingKey, (object?)null, user, null, logger).Value; + + return AssertValue(expectedReturnValue, static e => e, Convert.ToString(actual, CultureInfo.InvariantCulture), settingKey, user?.Identifier); + } + } + } + + internal bool RunTest(IRolloutEvaluator evaluator, LoggerWrapper logger, string settingKey, string expectedReturnValue, + string? userId, string? userEmail, string? userCountry, string? userCustomAttributeName, string? userCustomAttributeValue) + { + User? user = null; + if (userId is not null) + { + user = new User(userId) { Email = userEmail, Country = userCountry }; + if (userCustomAttributeValue is not null) + { + user.Custom[userCustomAttributeName!] = userCustomAttributeValue; + } + } + + return RunTest(evaluator, logger, settingKey, expectedReturnValue, user); + } + + internal int RunAllTests(IRolloutEvaluator evaluator, LoggerWrapper logger, object?[][] tests) + { + int i; + for (i = 0; i < tests.Length; i++) + { + var args = tests[i]; + + RunTest(evaluator, logger, (string)args[1]!, (string)args[2]!, + (string?)args[3], (string?)args[4], (string?)args[5], (string?)args[6], (string?)args[7]); + } + return i; + } +} diff --git a/src/ConfigCat.Client.Tests/ModelTests.cs b/src/ConfigCat.Client.Tests/ModelTests.cs new file mode 100644 index 00000000..e91851a2 --- /dev/null +++ b/src/ConfigCat.Client.Tests/ModelTests.cs @@ -0,0 +1,175 @@ +using System; +using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Tests.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ConfigCat.Client.Tests; + +[TestClass] +public class ModelTests +{ + private const string BasicSampleSdkKey = "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"; + private const string AndOrV6SampleSdkKey = "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A"; + private const string ComparatorsV6SampleSdkKey = "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ"; + private const string FlagDependencyV6SampleSdkKey = "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/JoGwdqJZQ0K2xDy7LnbyOg"; + private const string SegmentsV6SampleSdkKey = "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/h99HYXWWNE2bH8eWyLAVMA"; + + private static ConfigLocation GetConfigLocation(string? sdkKey, string baseUrlOrFileName) + { + return sdkKey is { Length: > 0 } + ? new ConfigLocation.Cdn(sdkKey, baseUrlOrFileName) + : new ConfigLocation.LocalFile("data", baseUrlOrFileName + ".json"); + } + + [DataTestMethod] + [DataRow(false, "False")] + [DataRow(true, "True")] + [DataRow("Text", "Text")] + [DataRow(1, "1")] + [DataRow(1L, "1")] + [DataRow(1d, "1")] + [DataRow(3.14, "3.14")] + [DataRow(null, EvaluateLogHelper.InvalidValuePlaceholder)] + public void SettingValue_ToString(object? value, string expectedResult) + { + var settingValue = value.ToSettingValue(out _); + var actualResult = settingValue.ToString(); + Assert.AreEqual(expectedResult, actualResult); + } + + [DataTestMethod] + [DataRow(BasicSampleSdkKey, null, "stringIsNotInDogDefaultCat", 0, 0, new[] { "User.Email IS NOT ONE OF ['a@configcat.com', 'b@configcat.com']" })] + [DataRow(SegmentsV6SampleSdkKey, null, "countrySegment", 0, 0, new[] { "User IS IN SEGMENT 'United'" })] + [DataRow(FlagDependencyV6SampleSdkKey, null, "boolDependsOnBool", 0, 0, new[] { "Flag 'mainBoolFlag' EQUALS 'True'" })] + public void Condition_ToString(string? sdkKey, string baseUrlOrFileName, string settingKey, int targetingRuleIndex, int conditionIndex, string[] expectedResultLines) + { + IConfig config = GetConfigLocation(sdkKey, baseUrlOrFileName).FetchConfigCached(); + var setting = config.Settings[settingKey]; + var targetingRule = setting.TargetingRules[targetingRuleIndex]; + var condition = targetingRule.Conditions[conditionIndex]; + var actualResult = condition!.ToString(); + var expectedResult = string.Join(Environment.NewLine, expectedResultLines); + Assert.AreEqual(expectedResult, actualResult); + } + + [DataTestMethod] + [DataRow(BasicSampleSdkKey, null, "string25Cat25Dog25Falcon25Horse", -1, 0, new[] { "25%: 'Cat'" })] + [DataRow(ComparatorsV6SampleSdkKey, null, "missingPercentageAttribute", 0, 0, new[] { "50%: 'Falcon'" })] + public void PercentageOption_ToString(string? sdkKey, string baseUrlOrFileName, string settingKey, int targetingRuleIndex, int percentageOptionIndex, string[] expectedResultLines) + { + IConfig config = GetConfigLocation(sdkKey, baseUrlOrFileName).FetchConfigCached(); + var setting = config.Settings[settingKey]; + var percentageOptions = targetingRuleIndex >= 0 + ? setting.TargetingRules[targetingRuleIndex].PercentageOptions + : setting.PercentageOptions; + IPercentageOption percentageOption = percentageOptions![percentageOptionIndex]; + var actualResult = percentageOption!.ToString(); + var expectedResult = string.Join(Environment.NewLine, expectedResultLines); + Assert.AreEqual(expectedResult, actualResult); + } + + [DataTestMethod] + [DataRow(BasicSampleSdkKey, null, "stringIsNotInDogDefaultCat", 0, new[] + { + "IF User.Email IS NOT ONE OF ['a@configcat.com', 'b@configcat.com']", + "THEN 'Dog'", + })] + [DataRow(ComparatorsV6SampleSdkKey, null, "missingPercentageAttribute", 0, new[] + { + "IF User.Email ENDS WITH ANY OF [<1 hashed value>]", + "THEN", + " 50%: 'Falcon'", + " 50%: 'Horse'", + })] + [DataRow(AndOrV6SampleSdkKey, null, "emailAnd", 0, new[] + { + "IF User.Email STARTS WITH ANY OF [<1 hashed value>]", + " AND User.Email CONTAINS ANY OF ['@']", + " AND User.Email ENDS WITH ANY OF [<1 hashed value>]", + "THEN 'Dog'" + })] + public void TargetingRule_ToString(string? sdkKey, string baseUrlOrFileName, string settingKey, int targetingRuleIndex, string[] expectedResultLines) + { + IConfig config = GetConfigLocation(sdkKey, baseUrlOrFileName).FetchConfigCached(); + var setting = config.Settings[settingKey]; + var targetingRule = setting.TargetingRules[targetingRuleIndex]; + var actualResult = targetingRule.ToString(); + var expectedResult = string.Join(Environment.NewLine, expectedResultLines); + Assert.AreEqual(expectedResult, actualResult); + } + + [DataTestMethod] + [DataRow(null, "test_json_complex", "doubleSetting", new[] { "To all users: '3.14'" })] + [DataRow(BasicSampleSdkKey, null, "stringIsNotInDogDefaultCat", new[] + { + "IF User.Email IS NOT ONE OF ['a@configcat.com', 'b@configcat.com']", + "THEN 'Dog'", + "To all others: 'Cat'", + })] + [DataRow(BasicSampleSdkKey, null, "string25Cat25Dog25Falcon25Horse", new[] + { + "25% of users: 'Cat'", + "25% of users: 'Dog'", + "25% of users: 'Falcon'", + "25% of users: 'Horse'", + "To unidentified: 'Chicken'", + })] + [DataRow(ComparatorsV6SampleSdkKey, null, "countryPercentageAttribute", new[] + { + "50% of all Country attributes: 'Falcon'", + "50% of all Country attributes: 'Horse'", + "To unidentified: 'Chicken'", + })] + [DataRow(BasicSampleSdkKey, null, "string25Cat25Dog25Falcon25HorseAdvancedRules", new[] + { + "IF User.Country IS ONE OF ['Hungary', 'United Kingdom']", + "THEN 'Dolphin'", + "ELSE IF User.Custom1 CONTAINS ANY OF ['admi']", + "THEN 'Lion'", + "ELSE IF User.Email CONTAINS ANY OF ['@configcat.com']", + "THEN 'Kitten'", + "OTHERWISE", + " 25% of users: 'Cat'", + " 25% of users: 'Dog'", + " 25% of users: 'Falcon'", + " 25% of users: 'Horse'", + "To unidentified: 'Chicken'", + })] + [DataRow(ComparatorsV6SampleSdkKey, null, "missingPercentageAttribute", new[] + { + "IF User.Email ENDS WITH ANY OF [<1 hashed value>]", + "THEN", + " 50% of all NotFound attributes: 'Falcon'", + " 50% of all NotFound attributes: 'Horse'", + "ELSE IF User.Email ENDS WITH ANY OF [<1 hashed value>]", + "THEN 'NotFound'", + "To all others: 'Chicken'", + })] + [DataRow(AndOrV6SampleSdkKey, null, "emailAnd", new[] + { + "IF User.Email STARTS WITH ANY OF [<1 hashed value>]", + " AND User.Email CONTAINS ANY OF ['@']", + " AND User.Email ENDS WITH ANY OF [<1 hashed value>]", + "THEN 'Dog'", + "To all others: 'Cat'", + })] + public void Setting_ToString(string? sdkKey, string baseUrlOrFileName, string settingKey, string[] expectedResultLines) + { + IConfig config = GetConfigLocation(sdkKey, baseUrlOrFileName).FetchConfigCached(); + var setting = config.Settings[settingKey]; + var actualResult = setting.ToString(); + var expectedResult = string.Join(Environment.NewLine, expectedResultLines); + Assert.AreEqual(expectedResult, actualResult); + } + + [DataTestMethod] + [DataRow(SegmentsV6SampleSdkKey, null, 0, new[] { "User.Email IS ONE OF [<2 hashed values>]" })] + public void Segment_ToString(string? sdkKey, string baseUrlOrFileName, int segmentIndex, string[] expectedResultLines) + { + IConfig config = GetConfigLocation(sdkKey, baseUrlOrFileName).FetchConfigCached(); + var segment = config.Segments[segmentIndex]; + var actualResult = segment.ToString(); + var expectedResult = string.Join(Environment.NewLine, expectedResultLines); + Assert.AreEqual(expectedResult, actualResult); + } +} diff --git a/src/ConfigCat.Client.Tests/NumericConfigEvaluatorTests.cs b/src/ConfigCat.Client.Tests/NumericConfigEvaluatorTests.cs deleted file mode 100644 index 51131899..00000000 --- a/src/ConfigCat.Client.Tests/NumericConfigEvaluatorTests.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace ConfigCat.Client.Tests; - -[TestClass] -public class NumericConfigEvaluatorTests : ConfigEvaluatorTestsBase -{ - protected override string SampleJsonFileName => "sample_number_v5.json"; - - protected override string MatrixResultFileName => "testmatrix_number.csv"; -} diff --git a/src/ConfigCat.Client.Tests/OverrideTests.cs b/src/ConfigCat.Client.Tests/OverrideTests.cs index a6d5df4b..83be4c10 100644 --- a/src/ConfigCat.Client.Tests/OverrideTests.cs +++ b/src/ConfigCat.Client.Tests/OverrideTests.cs @@ -346,7 +346,7 @@ public void LocalOverRemote() var fakeHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK); - using var client = ConfigCatClient.Get("localhost", options => + using var client = ConfigCatClient.Get("localhost-123456789012/1234567890123456789012", options => { options.FlagOverrides = FlagOverrides.LocalDictionary(dict, OverrideBehaviour.LocalOverRemote); options.HttpClientHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK, GetJsonContent("false")); @@ -370,7 +370,7 @@ public async Task LocalOverRemote_Async() var fakeHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK); - using var client = ConfigCatClient.Get("localhost", options => + using var client = ConfigCatClient.Get("localhost-123456789012/1234567890123456789012", options => { options.FlagOverrides = FlagOverrides.LocalDictionary(dict, OverrideBehaviour.LocalOverRemote); options.HttpClientHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK, GetJsonContent("false")); @@ -394,7 +394,7 @@ public void RemoteOverLocal() var fakeHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK); - using var client = ConfigCatClient.Get("localhost", options => + using var client = ConfigCatClient.Get("localhost-123456789012/1234567890123456789012", options => { options.FlagOverrides = FlagOverrides.LocalDictionary(dict, OverrideBehaviour.RemoteOverLocal); options.HttpClientHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK, GetJsonContent("false")); @@ -418,7 +418,7 @@ public async Task RemoteOverLocal_Async() var fakeHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK); - using var client = ConfigCatClient.Get("localhost", options => + using var client = ConfigCatClient.Get("localhost-123456789012/1234567890123456789012", options => { options.FlagOverrides = FlagOverrides.LocalDictionary(dict, OverrideBehaviour.RemoteOverLocal); options.HttpClientHandler = new FakeHttpClientHandler(System.Net.HttpStatusCode.OK, GetJsonContent("false")); @@ -522,10 +522,10 @@ public void OverrideValueTypeMismatchShouldBeHandledCorrectly_Dictionary(object Assert.AreEqual(expectedEvaluatedValue, actualEvaluatedValue); - var overrideValueSettingType = overrideValue.DetermineSettingType(); + overrideValue.ToSettingValue(out var overrideValueSettingType); var expectedEvaluatedValues = new KeyValuePair[] { - new(key, overrideValueSettingType != SettingType.Unknown ? overrideValue : null) + new(key, overrideValueSettingType != Setting.UnknownType ? overrideValue : null) }; CollectionAssert.AreEquivalent(expectedEvaluatedValues, actualEvaluatedValues.ToArray()); } @@ -559,7 +559,7 @@ public void OverrideValueTypeMismatchShouldBeHandledCorrectly_SimplifiedConfig(s const string key = "flag"; var overrideValue = #if USE_NEWTONSOFT_JSON - overrideValueJson.Deserialize(); + overrideValueJson.Deserialize()!; #else overrideValueJson.Deserialize(); #endif @@ -583,13 +583,18 @@ public void OverrideValueTypeMismatchShouldBeHandledCorrectly_SimplifiedConfig(s var actualEvaluatedValues = client.GetAllValues(user: null); Assert.AreEqual(expectedEvaluatedValue, actualEvaluatedValue); - var overrideValueSettingType = overrideValue.DetermineSettingType(); + + var unwrappedOverrideValue = overrideValue is JsonValue jsonValue + ? jsonValue.ToSettingValue(out var overrideValueSettingType) + : overrideValue.ToSettingValue(out overrideValueSettingType); + var expectedEvaluatedValues = new KeyValuePair[] { - new(key, overrideValueSettingType != SettingType.Unknown - ? (overrideValue is JsonValue jsonValue ? jsonValue.ConvertToObject(overrideValueSettingType) : overrideValue) + new(key, overrideValueSettingType != Setting.UnknownType + ? unwrappedOverrideValue.GetValue(overrideValueSettingType) : null) }; + CollectionAssert.AreEquivalent(expectedEvaluatedValues, actualEvaluatedValues.ToArray()); } finally @@ -603,7 +608,7 @@ public void OverrideValueTypeMismatchShouldBeHandledCorrectly_SimplifiedConfig(s private static string GetJsonContent(string value) { - return $"{{ \"f\": {{ \"fakeKey\": {{ \"v\": \"{value}\", \"p\": [] ,\"r\": [] }} }} }}"; + return "{\"f\":{\"fakeKey\":{\"t\":1,\"v\":{\"s\":\"" + value + "\"}}}}"; } private static async Task CreateFileAndWriteContent(string path, string content) diff --git a/src/ConfigCat.Client.Tests/SemanticVersion2ConfigEvaluatorTests.cs b/src/ConfigCat.Client.Tests/SemanticVersion2ConfigEvaluatorTests.cs deleted file mode 100644 index 48eab00b..00000000 --- a/src/ConfigCat.Client.Tests/SemanticVersion2ConfigEvaluatorTests.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace ConfigCat.Client.Tests; - -[TestClass] -public class SemanticVersion2ConfigEvaluatorTests : ConfigEvaluatorTestsBase -{ - protected override string SampleJsonFileName => "sample_semantic_2_v5.json"; - - protected override string MatrixResultFileName => "testmatrix_semantic_2.csv"; -} diff --git a/src/ConfigCat.Client.Tests/SemanticVersionConfigEvaluatorTests.cs b/src/ConfigCat.Client.Tests/SemanticVersionConfigEvaluatorTests.cs deleted file mode 100644 index b013a0c7..00000000 --- a/src/ConfigCat.Client.Tests/SemanticVersionConfigEvaluatorTests.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace ConfigCat.Client.Tests; - -[TestClass] -public class SemanticVersionConfigEvaluatorTests : ConfigEvaluatorTestsBase -{ - protected override string SampleJsonFileName => "sample_semantic_v5.json"; - - protected override string MatrixResultFileName => "testmatrix_semantic.csv"; -} diff --git a/src/ConfigCat.Client.Tests/SensitiveConfigEvaluatorTests.cs b/src/ConfigCat.Client.Tests/SensitiveConfigEvaluatorTests.cs deleted file mode 100644 index 52a86065..00000000 --- a/src/ConfigCat.Client.Tests/SensitiveConfigEvaluatorTests.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace ConfigCat.Client.Tests; - -[TestClass] -public class SensitiveEvaluatorTests : ConfigEvaluatorTestsBase -{ - protected override string SampleJsonFileName => "sample_sensitive_v5.json"; - - protected override string MatrixResultFileName => "testmatrix_sensitive.csv"; -} diff --git a/src/ConfigCat.Client.Tests/UserTests.cs b/src/ConfigCat.Client.Tests/UserTests.cs index d54e4f04..c0b9f222 100644 --- a/src/ConfigCat.Client.Tests/UserTests.cs +++ b/src/ConfigCat.Client.Tests/UserTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Globalization; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace ConfigCat.Client.Tests; @@ -20,7 +21,7 @@ public void CreateUser_WithIdAndEmailAndCountry_AllAttributesShouldContainsPasse // Act - var actualAttributes = user.AllAttributes; + var actualAttributes = user.GetAllAttributes(); // Assert @@ -45,7 +46,7 @@ public void UseWellKnownAttributesAsCustomProperties_ShouldNotAppendAllAttribute Country = "US", - Custom = new Dictionary + Custom = { { "myCustomAttribute", "myCustomAttributeValue"}, { nameof(User.Identifier), "myIdentifier"}, @@ -56,7 +57,7 @@ public void UseWellKnownAttributesAsCustomProperties_ShouldNotAppendAllAttribute // Act - var actualAttributes = user.AllAttributes; + var actualAttributes = user.GetAllAttributes(); // Assert @@ -93,7 +94,7 @@ public void UseWellKnownAttributesAsCustomPropertiesWithDifferentNames_ShouldApp Country = "US", - Custom = new Dictionary + Custom = { { attributeName, attributeValue} } @@ -101,7 +102,7 @@ public void UseWellKnownAttributesAsCustomPropertiesWithDifferentNames_ShouldApp // Act - var actualAttributes = user.AllAttributes; + var actualAttributes = user.GetAllAttributes(); // Assert @@ -122,6 +123,6 @@ public void CreateUser_ShouldSetIdentifier(string identifier, string expectedVal var user = new User(identifier); Assert.AreEqual(expectedValue, user.Identifier); - Assert.AreEqual(expectedValue, user.AllAttributes[nameof(User.Identifier)]); + Assert.AreEqual(expectedValue, user.GetAllAttributes()[nameof(User.Identifier)]); } } diff --git a/src/ConfigCat.Client.Tests/UtilsTest.cs b/src/ConfigCat.Client.Tests/UtilsTest.cs index d1ce7945..05caa6c3 100644 --- a/src/ConfigCat.Client.Tests/UtilsTest.cs +++ b/src/ConfigCat.Client.Tests/UtilsTest.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; using ConfigCat.Client.Utils; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -12,9 +15,28 @@ public class UtilsTest [DataRow(new byte[] { 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef }, "0123456789abcdef")] [DataRow(new byte[] { 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10 }, "fedcba9876543210")] [DataTestMethod] - public void ArrayUtils_ToHexString_Works(byte[] bytes, string expected) + public void ArrayUtils_ToHexString_Works(byte[] bytes, string expectedResult) { - Assert.AreEqual(expected, bytes.ToHexString()); + Assert.AreEqual(expectedResult, bytes.ToHexString()); + } + + [DataRow(new byte[] { }, "", true)] + [DataRow(new byte[] { }, "00", false)] + [DataRow(new byte[] { }, " ", false)] + [DataRow(new byte[] { }, "0", false)] + [DataRow(new byte[] { 0 }, "00", true)] + [DataRow(new byte[] { 0 }, "0000", false)] + [DataRow(new byte[] { 0 }, "01", false)] + [DataRow(new byte[] { 0 }, "000", false)] + [DataRow(new byte[] { 0 }, " 00 ", false)] + [DataRow(new byte[] { 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef }, "0123456789abcdef", true)] + [DataRow(new byte[] { 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef }, "0123456789abcdee", false)] + [DataRow(new byte[] { 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef }, "0123456789abcdeg", false)] + [DataRow(new byte[] { 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef }, "0123456789a_bcde", false)] + [DataTestMethod] + public void ArrayUtils_EqualsToHexString_Works(byte[] bytes, string hexString, bool expectedResult) + { + Assert.AreEqual(expectedResult, bytes.Equals(hexString.AsSpan())); } [DataRow("-62135596800001", -1L)] @@ -42,4 +64,97 @@ public void DateTimeUtils_UnixTimeStampConversion_Works(string dateString, long Assert.IsFalse(success); } } + + [DataRow(-62135596800.001, -1L)] + [DataRow(-62135596800.000, 0L)] + [DataRow(0, 621355968000000000L)] + [DataRow(+253402300799.999, 3155378975999990000L)] + [DataRow(+253402300800.000, -1L)] + [DataTestMethod] + public void DateTimeUtils_UnixTimeSecondsConversion_Works(double seconds, long ticks) + { + var success = DateTimeUtils.TryConvertFromUnixTimeSeconds(seconds, out var dateTime); + if (ticks >= 0) + { + Assert.IsTrue(success); + Assert.AreEqual(ticks, dateTime.Ticks); + } + else + { + Assert.IsFalse(success); + } + } + + [DataRow(new string[] { }, 0, false, null, "")] + [DataRow(new string[] { }, 1, true, null, "")] + [DataRow(new string[] { "a" }, 0, false, null, "'a'")] + [DataRow(new string[] { "a" }, 1, true, null, "'a'")] + [DataRow(new string[] { "a" }, 1, true, "a", "'a'")] + [DataRow(new string[] { "a", "b", "c" }, 0, false, null, "'a', 'b', 'c'")] + [DataRow(new string[] { "a", "b", "c" }, 3, false, null, "'a', 'b', 'c'")] + [DataRow(new string[] { "a", "b", "c" }, 2, false, null, "'a', 'b'")] + [DataRow(new string[] { "a", "b", "c" }, 2, true, null, "'a', 'b', ...1 item(s) omitted")] + [DataRow(new string[] { "a", "b", "c" }, 0, true, "a", "'a' -> 'b' -> 'c'")] + [DataTestMethod] + public void StringListFormatter_ToString_Works(string[] items, int maxLength, bool addOmittedItemsText, string? format, string expectedResult) + { + var actualResult = new StringListFormatter(items, maxLength, addOmittedItemsText ? static (count) => $", ...{count} item(s) omitted" : null) + .ToString(format, CultureInfo.InvariantCulture); + + Assert.AreEqual(expectedResult, actualResult); + } + + [TestMethod] + public void ModelHelper_SetOneOf_Works() + { + object? field = null; + + Assert.IsFalse(ModelHelper.IsValidOneOf(field)); + + ModelHelper.SetOneOf(ref field, null); + Assert.IsNull(field); + Assert.IsFalse(ModelHelper.IsValidOneOf(field)); + + ModelHelper.SetOneOf(ref field, true); + Assert.AreEqual(true, field); + Assert.IsTrue(ModelHelper.IsValidOneOf(field)); + + ModelHelper.SetOneOf(ref field, null); + Assert.AreEqual(true, field); + Assert.IsTrue(ModelHelper.IsValidOneOf(field)); + + ModelHelper.SetOneOf(ref field, true); + Assert.IsNotNull(field); + Assert.AreNotEqual(true, field); + Assert.AreNotEqual(false, field); + Assert.IsFalse(ModelHelper.IsValidOneOf(field)); + + ModelHelper.SetOneOf(ref field, null); + Assert.IsNotNull(field); + Assert.AreNotEqual(true, field); + Assert.AreNotEqual(false, field); + Assert.IsFalse(ModelHelper.IsValidOneOf(field)); + } + + private static IEnumerable GetEnumValues() => Enum.GetValues(typeof(SettingType)) + .Cast() + .Concat(new[] { Setting.UnknownType }) + .Select(t => new object?[] { t }); + + [DataTestMethod] + [DynamicData(nameof(GetEnumValues), DynamicDataSourceType.Method)] + public void ModelHelper_SetEnum_Works(SettingType enumValue) + { + SettingType field = default; + + if (Enum.IsDefined(typeof(SettingType), enumValue)) + { + ModelHelper.SetEnum(ref field, enumValue); + Assert.AreEqual(enumValue, field); + } + else + { + Assert.ThrowsException(() => ModelHelper.SetEnum(ref field, enumValue)); + } + } } diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule.json b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule.json new file mode 100644 index 00000000..596bd2b4 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "1_rule_no_user.txt" + }, + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "1_rule_no_targeted_attribute.txt" + }, + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@example.com" + }, + "returnValue": "Cat", + "expectedLog": "1_rule_not_matching_targeted_attribute.txt" + }, + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com" + }, + "returnValue": "Dog", + "expectedLog": "1_rule_matching_targeted_attribute.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_matching_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_matching_targeted_attribute.txt new file mode 100644 index 00000000..f05c6f61 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +INFO [5000] Evaluating 'stringContainsDogDefaultCat' for User '{"Identifier":"12345","Email":"joe@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => MATCH, applying rule + Returning 'Dog'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_no_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_no_targeted_attribute.txt new file mode 100644 index 00000000..80702e92 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_no_targeted_attribute.txt @@ -0,0 +1,6 @@ +WARNING [3003] Cannot evaluate condition (User.Email CONTAINS ANY OF ['@configcat.com']) for setting 'stringContainsDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsDogDefaultCat' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_no_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_no_user.txt new file mode 100644 index 00000000..2f1f99bb --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_no_user.txt @@ -0,0 +1,6 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'stringContainsDogDefaultCat' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsDogDefaultCat' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt new file mode 100644 index 00000000..49d12525 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +INFO [5000] Evaluating 'stringContainsDogDefaultCat' for User '{"Identifier":"12345","Email":"joe@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => no match + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules.json b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules.json new file mode 100644 index 00000000..5cf8a3c8 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "2_rules_no_user.txt" + }, + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "2_rules_no_targeted_attribute.txt" + }, + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Custom1": "user" + }, + "returnValue": "Cat", + "expectedLog": "2_rules_not_matching_targeted_attribute.txt" + }, + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Custom1": "admin" + }, + "returnValue": "Dog", + "expectedLog": "2_rules_matching_targeted_attribute.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_matching_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_matching_targeted_attribute.txt new file mode 100644 index 00000000..d124a4f4 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_matching_targeted_attribute.txt @@ -0,0 +1,7 @@ +WARNING [3003] Cannot evaluate condition (User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com']) for setting 'stringIsInDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringIsInDogDefaultCat' for User '{"Identifier":"12345","Custom1":"admin"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => MATCH, applying rule + Returning 'Dog'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_no_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_no_targeted_attribute.txt new file mode 100644 index 00000000..0e020769 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_no_targeted_attribute.txt @@ -0,0 +1,9 @@ +WARNING [3003] Cannot evaluate condition (User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com']) for setting 'stringIsInDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +WARNING [3003] Cannot evaluate condition (User.Custom1 IS ONE OF ['admin']) for setting 'stringIsInDogDefaultCat' (the User.Custom1 attribute is missing). You should set the User.Custom1 attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringIsInDogDefaultCat' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => cannot evaluate, the User.Custom1 attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_no_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_no_user.txt new file mode 100644 index 00000000..da3e73a3 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_no_user.txt @@ -0,0 +1,8 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'stringIsInDogDefaultCat' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringIsInDogDefaultCat' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt new file mode 100644 index 00000000..72217b28 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt @@ -0,0 +1,7 @@ +WARNING [3003] Cannot evaluate condition (User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com']) for setting 'stringIsInDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringIsInDogDefaultCat' for User '{"Identifier":"12345","Custom1":"user"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => no match + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/_overrides/test_list_truncation.json b/src/ConfigCat.Client.Tests/data/evaluationlog/_overrides/test_list_truncation.json new file mode 100644 index 00000000..6fdde459 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/_overrides/test_list_truncation.json @@ -0,0 +1,83 @@ +{ + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0, + "s": "test-salt" + }, + "f": { + "booleanKey1": { + "t": 0, + "v": { + "b": false + }, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ] + } + }, + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11" + ] + } + }, + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12" + ] + } + } + ], + "s": { + "v": { + "b": true + } + } + } + ] + } + } +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/and_rules.json b/src/ConfigCat.Client.Tests/data/evaluationlog/and_rules.json new file mode 100644 index 00000000..c6ed879f --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/and_rules.json @@ -0,0 +1,22 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9d5e-4988-891c-fd4a45790bd1/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A", + "tests": [ + { + "key": "emailAnd", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "and_rules_no_user.txt" + }, + { + "key": "emailAnd", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "jane@configcat.com" + }, + "returnValue": "Cat", + "expectedLog": "and_rules_user.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/and_rules/and_rules_no_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/and_rules/and_rules_no_user.txt new file mode 100644 index 00000000..47a0cb58 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/and_rules/and_rules_no_user.txt @@ -0,0 +1,7 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'emailAnd' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'emailAnd' + Evaluating targeting rules and applying the first match if any: + - IF User.Email STARTS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/and_rules/and_rules_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/and_rules/and_rules_user.txt new file mode 100644 index 00000000..92c59ce7 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/and_rules/and_rules_user.txt @@ -0,0 +1,7 @@ +INFO [5000] Evaluating 'emailAnd' for User '{"Identifier":"12345","Email":"jane@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email STARTS WITH ANY OF [<1 hashed value>] => true + AND User.Email CONTAINS ANY OF ['@'] => true + AND User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'Dog' => no match + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/comparators.json b/src/ConfigCat.Client.Tests/data/evaluationlog/comparators.json new file mode 100644 index 00000000..5d5631e5 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/comparators.json @@ -0,0 +1,20 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9a6b-4947-84e2-91529248278a/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", + "tests": [ + { + "key": "allinone", + "defaultValue": "", + "user": { + "Identifier": "12345", + "Email": "joe@example.com", + "Country": "[\"USA\"]", + "Version": "1.0.0", + "Number": "1.0", + "Date": "1693497500" + }, + "returnValue": "default", + "expectedLog": "allinone.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/comparators/allinone.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/comparators/allinone.txt new file mode 100644 index 00000000..84e9b324 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/comparators/allinone.txt @@ -0,0 +1,57 @@ +INFO [5000] Evaluating 'allinone' for User '{"Identifier":"12345","Email":"joe@example.com","Country":"[\"USA\"]","Version":"1.0.0","Number":"1.0","Date":"1693497500"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email EQUALS '' => true + AND User.Email NOT EQUALS '' => false, skipping the remaining AND conditions + THEN '1h' => no match + - IF User.Email EQUALS 'joe@example.com' => true + AND User.Email NOT EQUALS 'joe@example.com' => false, skipping the remaining AND conditions + THEN '1c' => no match + - IF User.Email IS ONE OF [<1 hashed value>] => true + AND User.Email IS NOT ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '2h' => no match + - IF User.Email IS ONE OF ['joe@example.com'] => true + AND User.Email IS NOT ONE OF ['joe@example.com'] => false, skipping the remaining AND conditions + THEN '2c' => no match + - IF User.Email STARTS WITH ANY OF [<1 hashed value>] => true + AND User.Email NOT STARTS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '3h' => no match + - IF User.Email STARTS WITH ANY OF ['joe@'] => true + AND User.Email NOT STARTS WITH ANY OF ['joe@'] => false, skipping the remaining AND conditions + THEN '3c' => no match + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => true + AND User.Email NOT ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '4h' => no match + - IF User.Email ENDS WITH ANY OF ['@example.com'] => true + AND User.Email NOT ENDS WITH ANY OF ['@example.com'] => false, skipping the remaining AND conditions + THEN '4c' => no match + - IF User.Email CONTAINS ANY OF ['e@e'] => true + AND User.Email NOT CONTAINS ANY OF ['e@e'] => false, skipping the remaining AND conditions + THEN '5' => no match + - IF User.Version IS ONE OF ['1.0.0'] => true + AND User.Version IS NOT ONE OF ['1.0.0'] => false, skipping the remaining AND conditions + THEN '6' => no match + - IF User.Version < '1.0.1' => true + AND User.Version >= '1.0.1' => false, skipping the remaining AND conditions + THEN '7' => no match + - IF User.Version > '0.9.9' => true + AND User.Version <= '0.9.9' => false, skipping the remaining AND conditions + THEN '8' => no match + - IF User.Number = '1' => true + AND User.Number != '1' => false, skipping the remaining AND conditions + THEN '9' => no match + - IF User.Number < '1.1' => true + AND User.Number >= '1.1' => false, skipping the remaining AND conditions + THEN '10' => no match + - IF User.Number > '0.9' => true + AND User.Number <= '0.9' => false, skipping the remaining AND conditions + THEN '11' => no match + - IF User.Date BEFORE '1693497600' (2023-08-31T16:00:00.000Z UTC) => true + AND User.Date AFTER '1693497600' (2023-08-31T16:00:00.000Z UTC) => false, skipping the remaining AND conditions + THEN '12' => no match + - IF User.Country ARRAY CONTAINS ANY OF [<1 hashed value>] => true + AND User.Country ARRAY NOT CONTAINS ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '13h' => no match + - IF User.Country ARRAY CONTAINS ANY OF ['USA'] => true + AND User.Country ARRAY NOT CONTAINS ANY OF ['USA'] => false, skipping the remaining AND conditions + THEN '13c' => no match + Returning 'default'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/epoch_date_validation.json b/src/ConfigCat.Client.Tests/data/evaluationlog/epoch_date_validation.json new file mode 100644 index 00000000..e916d218 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/epoch_date_validation.json @@ -0,0 +1,16 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9a6b-4947-84e2-91529248278a/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", + "tests": [ + { + "key": "boolTrueIn202304", + "defaultValue": true, + "returnValue": false, + "expectedLog": "date_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "2023.04.10" + } + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/epoch_date_validation/date_error.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/epoch_date_validation/date_error.txt new file mode 100644 index 00000000..fbde23f9 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/epoch_date_validation/date_error.txt @@ -0,0 +1,7 @@ +WARNING [3004] Cannot evaluate condition (User.Custom1 AFTER '1680307200' (2023-04-01T00:00:00.000Z UTC)) for setting 'boolTrueIn202304' ('2023.04.10' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +INFO [5000] Evaluating 'boolTrueIn202304' for User '{"Identifier":"12345","Custom1":"2023.04.10"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 AFTER '1680307200' (2023-04-01T00:00:00.000Z UTC) => false, skipping the remaining AND conditions + THEN 'True' => cannot evaluate, the User.Custom1 attribute is invalid ('2023.04.10' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'False'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/list_truncation.json b/src/ConfigCat.Client.Tests/data/evaluationlog/list_truncation.json new file mode 100644 index 00000000..64e94262 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/list_truncation.json @@ -0,0 +1,14 @@ +{ + "jsonOverride": "test_list_truncation.json", + "tests": [ + { + "key": "booleanKey1", + "defaultValue": false, + "user": { + "Identifier": "12" + }, + "returnValue": true, + "expectedLog": "list_truncation.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/list_truncation/list_truncation.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/list_truncation/list_truncation.txt new file mode 100644 index 00000000..a07e52c4 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/list_truncation/list_truncation.txt @@ -0,0 +1,7 @@ +INFO [5000] Evaluating 'booleanKey1' for User '{"Identifier":"12"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Identifier CONTAINS ANY OF ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] => true + AND User.Identifier CONTAINS ANY OF ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', ... <1 more value>] => true + AND User.Identifier CONTAINS ANY OF ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', ... <2 more values>] => true + THEN 'True' => MATCH, applying rule + Returning 'True'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/number_validation.json b/src/ConfigCat.Client.Tests/data/evaluationlog/number_validation.json new file mode 100644 index 00000000..640cf3da --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/number_validation.json @@ -0,0 +1,16 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d747f0-5986-c2ef-eef3-ec778e32e10a/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/uGyK3q9_ckmdxRyI7vjwCw", + "tests": [ + { + "key": "number", + "defaultValue": "default", + "returnValue": "Default", + "expectedLog": "number_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "not_a_number" + } + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/number_validation/number_error.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/number_validation/number_error.txt new file mode 100644 index 00000000..f9368093 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/number_validation/number_error.txt @@ -0,0 +1,6 @@ +WARNING [3004] Cannot evaluate condition (User.Custom1 != '5') for setting 'number' ('not_a_number' is not a valid decimal number). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +INFO [5000] Evaluating 'number' for User '{"Identifier":"12345","Custom1":"not_a_number"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 != '5' THEN '<>5' => cannot evaluate, the User.Custom1 attribute is invalid ('not_a_number' is not a valid decimal number) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Default'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule.json b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule.json new file mode 100644 index 00000000..803840e2 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "returnValue": -1, + "expectedLog": "options_after_targeting_rule_no_user.txt" + }, + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "user": { + "Identifier": "12345" + }, + "returnValue": 2, + "expectedLog": "options_after_targeting_rule_no_targeted_attribute.txt" + }, + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "user": { + "Identifier": "12345", + "Email": "joe@example.com" + }, + "returnValue": 2, + "expectedLog": "options_after_targeting_rule_not_matching_targeted_attribute.txt" + }, + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com" + }, + "returnValue": 5, + "expectedLog": "options_after_targeting_rule_matching_targeted_attribute.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt new file mode 100644 index 00000000..6815fa39 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +INFO [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' for User '{"Identifier":"12345","Email":"joe@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => MATCH, applying rule + Returning '5'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt new file mode 100644 index 00000000..8e6facb8 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt @@ -0,0 +1,9 @@ +WARNING [3003] Cannot evaluate condition (User.Email CONTAINS ANY OF ['@configcat.com']) for setting 'integer25One25Two25Three25FourAdvancedRules' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 25 (this value is sticky and consistent across all SDKs) + - Hash value 25 selects % option 2 (25%), '2'. + Returning '2'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_no_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_no_user.txt new file mode 100644 index 00000000..b7384194 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_no_user.txt @@ -0,0 +1,7 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'integer25One25Two25Three25FourAdvancedRules' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Skipping % options because the User Object is missing. + Returning '-1'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt new file mode 100644 index 00000000..c412e5a5 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt @@ -0,0 +1,7 @@ +INFO [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' for User '{"Identifier":"12345","Email":"joe@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => no match + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 25 (this value is sticky and consistent across all SDKs) + - Hash value 25 selects % option 2 (25%), '2'. + Returning '2'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr.json b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr.json new file mode 100644 index 00000000..5f8d1c63 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr.json @@ -0,0 +1,31 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9e4e-4f59-86b2-5da50924b6ca/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", + "tests": [ + { + "key": "string75Cat0Dog25Falcon0HorseCustomAttr", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "options_custom_attribute_no_user.txt" + }, + { + "key": "string75Cat0Dog25Falcon0HorseCustomAttr", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Chicken", + "expectedLog": "no_options_custom_attribute.txt" + }, + { + "key": "string75Cat0Dog25Falcon0HorseCustomAttr", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Country": "US" + }, + "returnValue": "Cat", + "expectedLog": "matching_options_custom_attribute.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/matching_options_custom_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/matching_options_custom_attribute.txt new file mode 100644 index 00000000..2621086b --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/matching_options_custom_attribute.txt @@ -0,0 +1,5 @@ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0HorseCustomAttr' for User '{"Identifier":"12345","Country":"US"}' + Evaluating % options based on the User.Country attribute: + - Computing hash in the [0..99] range from User.Country => 70 (this value is sticky and consistent across all SDKs) + - Hash value 70 selects % option 1 (75%), 'Cat'. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/no_options_custom_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/no_options_custom_attribute.txt new file mode 100644 index 00000000..c92c5bcb --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/no_options_custom_attribute.txt @@ -0,0 +1,4 @@ +WARNING [3003] Cannot evaluate % options for setting 'string75Cat0Dog25Falcon0HorseCustomAttr' (the User.Country attribute is missing). You should set the User.Country attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0HorseCustomAttr' for User '{"Identifier":"12345"}' + Skipping % options because the User.Country attribute is missing. + Returning 'Chicken'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/options_custom_attribute_no_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/options_custom_attribute_no_user.txt new file mode 100644 index 00000000..50b92afc --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_custom_attr/options_custom_attribute_no_user.txt @@ -0,0 +1,4 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'string75Cat0Dog25Falcon0HorseCustomAttr' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0HorseCustomAttr' + Skipping % options because the User Object is missing. + Returning 'Chicken'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id.json b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id.json new file mode 100644 index 00000000..442f575c --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id.json @@ -0,0 +1,21 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "string75Cat0Dog25Falcon0Horse", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "options_user_attribute_no_user.txt" + }, + { + "key": "string75Cat0Dog25Falcon0Horse", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "options_user_attribute_user.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id/options_user_attribute_no_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id/options_user_attribute_no_user.txt new file mode 100644 index 00000000..2b1849ba --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id/options_user_attribute_no_user.txt @@ -0,0 +1,4 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'string75Cat0Dog25Falcon0Horse' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0Horse' + Skipping % options because the User Object is missing. + Returning 'Chicken'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id/options_user_attribute_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id/options_user_attribute_user.txt new file mode 100644 index 00000000..dac8dd6a --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_based_on_user_id/options_user_attribute_user.txt @@ -0,0 +1,5 @@ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0Horse' for User '{"Identifier":"12345"}' + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 21 (this value is sticky and consistent across all SDKs) + - Hash value 21 selects % option 1 (75%), 'Cat'. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule.json b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule.json new file mode 100644 index 00000000..4c6c533b --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule.json @@ -0,0 +1,52 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9e4e-4f59-86b2-5da50924b6ca/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", + "tests": [ + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_no_user.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_no_targeted_attribute.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@example.com" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_not_matching_targeted_attribute.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com", + "Country": "US" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt new file mode 100644 index 00000000..db721f51 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt @@ -0,0 +1,7 @@ +WARNING [3003] Cannot evaluate % options for setting 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' (the User.Country attribute is missing). You should set the User.Country attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345","Email":"joe@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => MATCH, applying rule + Skipping % options because the User.Country attribute is missing. + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt new file mode 100644 index 00000000..81295215 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt @@ -0,0 +1,7 @@ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345","Email":"joe@configcat.com","Country":"US"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => MATCH, applying rule + Evaluating % options based on the User.Country attribute: + - Computing hash in the [0..99] range from User.Country => 63 (this value is sticky and consistent across all SDKs) + - Hash value 63 selects % option 1 (75%), 'Cat'. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt new file mode 100644 index 00000000..74f812f4 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt @@ -0,0 +1,6 @@ +WARNING [3003] Cannot evaluate condition (User.Email CONTAINS ANY OF ['@configcat.com']) for setting 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_no_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_no_user.txt new file mode 100644 index 00000000..e886be82 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_no_user.txt @@ -0,0 +1,6 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt new file mode 100644 index 00000000..dd6032e5 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345","Email":"joe@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => no match + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag.json b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag.json new file mode 100644 index 00000000..674e2d33 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag.json @@ -0,0 +1,35 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9d5e-4988-891c-fd4a45790bd1/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A", + "tests": [ + { + "key": "dependentFeatureWithUserCondition", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "prerequisite_flag_no_user_needed_by_dep.txt" + }, + { + "key": "dependentFeature", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "prerequisite_flag_no_user_needed_by_prereq.txt" + }, + { + "key": "dependentFeatureWithUserCondition2", + "defaultValue": "default", + "returnValue": "Frog", + "expectedLog": "prerequisite_flag_no_user_needed_by_both.txt" + }, + { + "key": "dependentFeature", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "kate@configcat.com", + "Country": "USA" + }, + "returnValue": "Horse", + "expectedLog": "prerequisite_flag.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag.txt new file mode 100644 index 00000000..1d9022b0 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag.txt @@ -0,0 +1,32 @@ +INFO [5000] Evaluating 'dependentFeature' for User '{"Identifier":"12345","Email":"kate@configcat.com","Country":"USA"}' + Evaluating targeting rules and applying the first match if any: + - IF Flag 'mainFeature' EQUALS 'target' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => no match + - IF User.Country IS ONE OF [<1 hashed value>] => true + AND User IS NOT IN SEGMENT 'Beta Users' + ( + Evaluating segment 'Beta Users': + - IF User.Email IS ONE OF [<2 hashed values>] => false, skipping the remaining AND conditions + Segment evaluation result: User IS NOT IN SEGMENT. + Condition (User IS NOT IN SEGMENT 'Beta Users') evaluates to true. + ) => true + AND User IS NOT IN SEGMENT 'Developers' + ( + Evaluating segment 'Developers': + - IF User.Email IS ONE OF [<2 hashed values>] => false, skipping the remaining AND conditions + Segment evaluation result: User IS NOT IN SEGMENT. + Condition (User IS NOT IN SEGMENT 'Developers') evaluates to true. + ) => true + THEN 'target' => MATCH, applying rule + Prerequisite flag evaluation result: 'target'. + Condition (Flag 'mainFeature' EQUALS 'target') evaluates to true. + ) + THEN % options => MATCH, applying rule + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 78 (this value is sticky and consistent across all SDKs) + - Hash value 78 selects % option 4 (25%), 'Horse'. + Returning 'Horse'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt new file mode 100644 index 00000000..fef0f80d --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt @@ -0,0 +1,38 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'dependentFeatureWithUserCondition2' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'mainFeature' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'mainFeature' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'dependentFeatureWithUserCondition2' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF [<2 hashed values>] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF Flag 'mainFeature' EQUALS 'public' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Country IS ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'target' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Prerequisite flag evaluation result: 'public'. + Condition (Flag 'mainFeature' EQUALS 'public') evaluates to true. + ) + THEN % options => MATCH, applying rule + Skipping % options because the User Object is missing. + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF Flag 'mainFeature' EQUALS 'public' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Country IS ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'target' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Prerequisite flag evaluation result: 'public'. + Condition (Flag 'mainFeature' EQUALS 'public') evaluates to true. + ) + THEN 'Frog' => MATCH, applying rule + Returning 'Frog'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt new file mode 100644 index 00000000..b229f0c0 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt @@ -0,0 +1,15 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'dependentFeatureWithUserCondition' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'dependentFeatureWithUserCondition' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF [<2 hashed values>] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF Flag 'mainFeatureWithoutUserCondition' EQUALS 'True' + ( + Evaluating prerequisite flag 'mainFeatureWithoutUserCondition': + Prerequisite flag evaluation result: 'True'. + Condition (Flag 'mainFeatureWithoutUserCondition' EQUALS 'True') evaluates to true. + ) + THEN % options => MATCH, applying rule + Skipping % options because the User Object is missing. + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Chicken'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt new file mode 100644 index 00000000..1f6ba10e --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt @@ -0,0 +1,18 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'mainFeature' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'dependentFeature' + Evaluating targeting rules and applying the first match if any: + - IF Flag 'mainFeature' EQUALS 'target' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Country IS ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'target' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Prerequisite flag evaluation result: 'public'. + Condition (Flag 'mainFeature' EQUALS 'target') evaluates to false. + ) + THEN % options => no match + Returning 'Chicken'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/segment.json b/src/ConfigCat.Client.Tests/data/evaluationlog/segment.json new file mode 100644 index 00000000..41744c22 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/segment.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d9f207-6883-43e5-868c-cbf677af3fe6/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/LcYz135LE0qbcacz2mgXnA", + "tests": [ + { + "key": "featureWithSegmentTargeting", + "defaultValue": false, + "returnValue": false, + "expectedLog": "segment_no_user.txt" + }, + { + "key": "featureWithNegatedSegmentTargetingCleartext", + "defaultValue": false, + "user": { + "Identifier": "12345" + }, + "returnValue": false, + "expectedLog": "segment_no_targeted_attribute.txt" + }, + { + "key": "featureWithSegmentTargeting", + "defaultValue": false, + "user": { + "Identifier": "12345", + "Email": "jane@example.com" + }, + "returnValue": true, + "expectedLog": "segment_matching.txt" + }, + { + "key": "featureWithNegatedSegmentTargeting", + "defaultValue": false, + "user": { + "Identifier": "12345", + "Email": "jane@example.com" + }, + "returnValue": false, + "expectedLog": "segment_no_matching.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_matching.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_matching.txt new file mode 100644 index 00000000..ff46528a --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_matching.txt @@ -0,0 +1,11 @@ +INFO [5000] Evaluating 'featureWithSegmentTargeting' for User '{"Identifier":"12345","Email":"jane@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User IS IN SEGMENT 'Beta users' + ( + Evaluating segment 'Beta users': + - IF User.Email IS ONE OF [<2 hashed values>] => true + Segment evaluation result: User IS IN SEGMENT. + Condition (User IS IN SEGMENT 'Beta users') evaluates to true. + ) + THEN 'True' => MATCH, applying rule + Returning 'True'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_matching.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_matching.txt new file mode 100644 index 00000000..37235214 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_matching.txt @@ -0,0 +1,11 @@ +INFO [5000] Evaluating 'featureWithNegatedSegmentTargeting' for User '{"Identifier":"12345","Email":"jane@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User IS NOT IN SEGMENT 'Beta users' + ( + Evaluating segment 'Beta users': + - IF User.Email IS ONE OF [<2 hashed values>] => true + Segment evaluation result: User IS IN SEGMENT. + Condition (User IS NOT IN SEGMENT 'Beta users') evaluates to false. + ) + THEN 'True' => no match + Returning 'False'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_targeted_attribute.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_targeted_attribute.txt new file mode 100644 index 00000000..9f39d8c7 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_targeted_attribute.txt @@ -0,0 +1,13 @@ +WARNING [3003] Cannot evaluate condition (User.Email IS ONE OF ['jane@example.com', 'john@example.com']) for setting 'featureWithNegatedSegmentTargetingCleartext' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'featureWithNegatedSegmentTargetingCleartext' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User IS NOT IN SEGMENT 'Beta users (cleartext)' + ( + Evaluating segment 'Beta users (cleartext)': + - IF User.Email IS ONE OF ['jane@example.com', 'john@example.com'] => false, skipping the remaining AND conditions + Segment evaluation result: cannot evaluate, the User.Email attribute is missing. + Condition (User IS NOT IN SEGMENT 'Beta users (cleartext)') failed to evaluate. + ) + THEN 'True' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'False'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_user.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_user.txt new file mode 100644 index 00000000..d61e3f9e --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/segment/segment_no_user.txt @@ -0,0 +1,6 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'featureWithSegmentTargeting' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'featureWithSegmentTargeting' + Evaluating targeting rules and applying the first match if any: + - IF User IS IN SEGMENT 'Beta users' THEN 'True' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'False'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation.json b/src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation.json new file mode 100644 index 00000000..3a14fc67 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation.json @@ -0,0 +1,26 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d745f1-f315-7daf-d163-5541d3786e6f/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/BAr3KgLTP0ObzKnBTo5nhA", + "tests": [ + { + "key": "isNotOneOf", + "defaultValue": "default", + "returnValue": "Default", + "expectedLog": "semver_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "wrong_semver" + } + }, + { + "key": "relations", + "defaultValue": "default", + "returnValue": "Default", + "expectedLog": "semver_relations_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "wrong_semver" + } + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation/semver_error.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation/semver_error.txt new file mode 100644 index 00000000..e14cc952 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation/semver_error.txt @@ -0,0 +1,9 @@ +WARNING [3004] Cannot evaluate condition (User.Custom1 IS NOT ONE OF ['1.0.0', '1.0.1', '2.0.0', '2.0.1', '2.0.2', '']) for setting 'isNotOneOf' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 IS NOT ONE OF ['1.0.0', '3.0.1']) for setting 'isNotOneOf' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +INFO [5000] Evaluating 'isNotOneOf' for User '{"Identifier":"12345","Custom1":"wrong_semver"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 IS NOT ONE OF ['1.0.0', '1.0.1', '2.0.0', '2.0.1', '2.0.2', ''] THEN 'Is not one of (1.0.0, 1.0.1, 2.0.0 , 2.0.1, 2.0.2, )' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS NOT ONE OF ['1.0.0', '3.0.1'] THEN 'Is not one of (1.0.0, 3.0.1)' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Default'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation/semver_relations_error.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation/semver_relations_error.txt new file mode 100644 index 00000000..8198c854 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/semver_validation/semver_relations_error.txt @@ -0,0 +1,18 @@ +WARNING [3004] Cannot evaluate condition (User.Custom1 < '1.0.0,') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 < '1.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 <= '1.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 > '2.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 >= '2.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +INFO [5000] Evaluating 'relations' for User '{"Identifier":"12345","Custom1":"wrong_semver"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 < '1.0.0,' THEN '<1.0.0,' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 < '1.0.0' THEN '< 1.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 <= '1.0.0' THEN '<=1.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 > '2.0.0' THEN '>2.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 >= '2.0.0' THEN '>=2.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Default'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value.json b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value.json new file mode 100644 index 00000000..070d6f59 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value.json @@ -0,0 +1,37 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "boolDefaultFalse", + "defaultValue": true, + "returnValue": false, + "expectedLog": "off_flag.txt" + }, + { + "key": "boolDefaultTrue", + "defaultValue": false, + "returnValue": true, + "expectedLog": "on_flag.txt" + }, + { + "key": "stringDefaultCat", + "defaultValue": "Default", + "returnValue": "Cat", + "expectedLog": "text_setting.txt" + }, + { + "key": "integerDefaultOne", + "defaultValue": 0, + "returnValue": 1, + "expectedLog": "int_setting.txt" + }, + { + "testName": "double_setting", + "key": "doubleDefaultPi", + "defaultValue": 0.0, + "returnValue": 3.1415, + "expectedLog": "double_setting.txt" + } + ] +} diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/double_setting.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/double_setting.txt new file mode 100644 index 00000000..4a632f77 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/double_setting.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'doubleDefaultPi' + Returning '3.1415'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/int_setting.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/int_setting.txt new file mode 100644 index 00000000..13618431 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/int_setting.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'integerDefaultOne' + Returning '1'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/off_flag.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/off_flag.txt new file mode 100644 index 00000000..17c4a695 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/off_flag.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'boolDefaultFalse' + Returning 'False'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/on_flag.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/on_flag.txt new file mode 100644 index 00000000..a392fe1c --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/on_flag.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'boolDefaultTrue' + Returning 'True'. diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/text_setting.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/text_setting.txt new file mode 100644 index 00000000..831d7c62 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/simple_value/text_setting.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'stringDefaultCat' + Returning 'Cat'. diff --git a/src/ConfigCat.Client.Tests/data/sample_number_v5.json b/src/ConfigCat.Client.Tests/data/sample_number_v5.json deleted file mode 100644 index 1c8398a5..00000000 --- a/src/ConfigCat.Client.Tests/data/sample_number_v5.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "f" : { - "numberWithPercentage": { - "v": "Default", - "t": 1, - "p": [ - { - "o": 0, - "v": "80%", - "p": 80 - }, - { - "o": 1, - "v": "20%", - "p": 20 - } - ], - "r": [ - { - "o": 0, - "a": "Custom1", - "t": 10, - "c": "sajt", - "v": "=sajt" - }, - { - "o": 1, - "a": "Custom1", - "t": 12, - "c": "2.1", - "v": "<2.1" - }, - { - "o": 2, - "a": "Custom1", - "t": 13, - "c": "2,1", - "v": "<=2,1" - }, - { - "o": 3, - "a": "Custom1", - "t": 10, - "c": "3.5", - "v": "=3.5" - }, - { - "o": 4, - "a": "Custom1", - "t": 14, - "c": "5", - "v": ">5" - }, - { - "o": 5, - "a": "Custom1", - "t": 15, - "c": "5", - "v": ">=5" - }, - { - "o": 6, - "a": "Custom1", - "t": 11, - "c": "4.2", - "v": "<>4.2" - } - ] - }, - "number": { - "v": "Default", - "t": 1, - "p": [], - "r": [ - { - "o": 0, - "a": "Custom1", - "t": 11, - "c": "5", - "v": "<>5" - } - ] - } - } -} \ No newline at end of file diff --git a/src/ConfigCat.Client.Tests/data/sample_semantic_2_v5.json b/src/ConfigCat.Client.Tests/data/sample_semantic_2_v5.json deleted file mode 100644 index 3f7df770..00000000 --- a/src/ConfigCat.Client.Tests/data/sample_semantic_2_v5.json +++ /dev/null @@ -1,579 +0,0 @@ -{ - - "f": { - "precedenceTests": { - "v": "DEFAULT-FROM-CC-APP", - "t": 1, - "p": [], - "r": [ - { - "o": 0, - "a": "AppVersion", - "t": 6, - "c": "1.9.1-2", - "v": "< 1.9.1-2" - }, - { - "o": 1, - "a": "AppVersion", - "t": 6, - "c": "1.9.1-10", - "v": "< 1.9.1-10" - }, - { - "o": 2, - "a": "AppVersion", - "t": 6, - "c": "1.9.1-10a", - "v": "< 1.9.1-10a" - }, - { - "o": 3, - "a": "AppVersion", - "t": 6, - "c": "1.9.1-1a", - "v": "< 1.9.1-1a" - }, - { - "o": 4, - "a": "AppVersion", - "t": 6, - "c": "1.9.1-alpha", - "v": "< 1.9.1-alpha" - }, - { - "o": 5, - "a": "AppVersion", - "t": 6, - "c": "1.9.99-alpha", - "v": "< 1.9.99-alpha" - }, - { - "o": 6, - "a": "AppVersion", - "t": 4, - "c": "1.9.99-alpha", - "v": "= 1.9.99-alpha" - }, - { - "o": 7, - "a": "AppVersion", - "t": 6, - "c": "1.9.99-beta", - "v": "< 1.9.99-beta" - }, - { - "o": 8, - "a": "AppVersion", - "t": 6, - "c": "1.9.99-rc", - "v": "< 1.9.99-rc" - }, - { - "o": 9, - "a": "AppVersion", - "t": 6, - "c": "1.9.99-rc.1", - "v": "< 1.9.99-rc.1" - }, - { - "o": 10, - "a": "AppVersion", - "t": 6, - "c": "1.9.99-rc.2", - "v": "< 1.9.99-rc.2" - }, - { - "o": 11, - "a": "AppVersion", - "t": 6, - "c": "1.9.99-rc.20", - "v": "< 1.9.99-rc.20" - }, - { - "o": 12, - "a": "AppVersion", - "t": 6, - "c": "1.9.99-rc.20a", - "v": "< 1.9.99-rc.20a" - }, - { - "o": 13, - "a": "AppVersion", - "t": 6, - "c": "1.9.99-rc.2a", - "v": "< 1.9.99-rc.2a" - }, - { - "o": 14, - "a": "AppVersion", - "t": 6, - "c": "1.9.99", - "v": "< 1.9.99" - }, - { - "o": 15, - "a": "AppVersion", - "t": 6, - "c": "1.9.100", - "v": "< 1.9.100" - }, - { - "o": 16, - "a": "AppVersion", - "t": 6, - "c": "1.10.0-alpha", - "v": "< 1.10.0-alpha" - }, - { - "o": 17, - "a": "AppVersion", - "t": 7, - "c": "1.10.0-alpha", - "v": "<= 1.10.0-alpha" - }, - { - "o": 18, - "a": "AppVersion", - "t": 6, - "c": "1.10.0", - "v": "< 1.10.0" - }, - { - "o": 19, - "a": "AppVersion", - "t": 7, - "c": "1.10.0", - "v": "<= 1.10.0" - }, - { - "o": 20, - "a": "AppVersion", - "t": 7, - "c": "1.10.1", - "v": "<= 1.10.1" - }, - { - "o": 21, - "a": "AppVersion", - "t": 7, - "c": "1.10.3", - "v": "<= 1.10.3" - }, - { - "o": 22, - "a": "AppVersion", - "t": 6, - "c": "2.0.0", - "v": "< 2.0.0" - }, - { - "o": 23, - "a": "AppVersion", - "t": 4, - "c": "2.0.0", - "v": "= 2.0.0" - }, - { - "o": 24, - "a": "AppVersion", - "t": 4, - "c": "3.0.0+build3", - "v": "= 3.0.0+build3" - }, - { - "o": 25, - "a": "AppVersion", - "t": 4, - "c": "4.0.0+001", - "v": "= 4.0.0+001" - }, - { - "o": 26, - "a": "AppVersion", - "t": 4, - "c": "5.0.0+20130313144700", - "v": "= 5.0.0+20130313144700" - }, - { - "o": 27, - "a": "AppVersion", - "t": 4, - "c": "6.0.0+exp.sha.5114f85", - "v": "= 6.0.0+exp.sha.5114f85" - }, - { - "o": 28, - "a": "AppVersion", - "t": 4, - "c": "7.0.0-patch", - "v": "= 7.0.0-patch" - }, - { - "o": 29, - "a": "AppVersion", - "t": 4, - "c": "8.0.0-patch+anothermetadata", - "v": "= 8.0.0-patch+anothermetadata" - }, - { - "o": 30, - "a": "AppVersion", - "t": 4, - "c": "9.0.0-patch+metadata", - "v": "= 9.0.0-patch+metadata" - }, - { - "o": 31, - "a": "AppVersion", - "t": 8, - "c": "103.0.0", - "v": "> 103.0.0" - }, - { - "o": 32, - "a": "AppVersion", - "t": 9, - "c": "103.0.0", - "v": ">= 103.0.0" - }, - { - "o": 33, - "a": "AppVersion", - "t": 9, - "c": "101.0.0", - "v": ">= 101.0.0" - }, - { - "o": 34, - "a": "AppVersion", - "t": 8, - "c": "90.103.0", - "v": "> 90.103.0" - }, - { - "o": 35, - "a": "AppVersion", - "t": 9, - "c": "90.103.0", - "v": ">= 90.103.0" - }, - { - "o": 36, - "a": "AppVersion", - "t": 9, - "c": "90.101.0", - "v": ">= 90.101.0" - }, - { - "o": 37, - "a": "AppVersion", - "t": 8, - "c": "80.0.103", - "v": "> 80.0.103" - }, - { - "o": 38, - "a": "AppVersion", - "t": 9, - "c": "80.0.103", - "v": ">= 80.0.103" - }, - { - "o": 39, - "a": "AppVersion", - "t": 9, - "c": "80.0.101", - "v": ">= 80.0.101" - }, - { - "o": 40, - "a": "AppVersion", - "t": 9, - "c": "73.0.0-beta.2", - "v": ">= 73.0.0-beta.2" - }, - { - "o": 41, - "a": "AppVersion", - "t": 8, - "c": "72.0.0-beta.2", - "v": "> 72.0.0-beta.2" - }, - { - "o": 42, - "a": "AppVersion", - "t": 8, - "c": "72.0.0-beta.1", - "v": "> 72.0.0-beta.1" - }, - { - "o": 43, - "a": "AppVersion", - "t": 8, - "c": "72.0.0-beta", - "v": "> 72.0.0-beta" - }, - { - "o": 44, - "a": "AppVersion", - "t": 8, - "c": "72.0.0-alpha", - "v": "> 72.0.0-alpha" - }, - { - "o": 45, - "a": "AppVersion", - "t": 8, - "c": "72.0.0-1a", - "v": "> 72.0.0-1a" - }, - { - "o": 46, - "a": "AppVersion", - "t": 8, - "c": "72.0.0-10a", - "v": "> 72.0.0-10a" - }, - { - "o": 47, - "a": "AppVersion", - "t": 8, - "c": "72.0.0-2", - "v": "> 72.0.0-2" - }, - { - "o": 48, - "a": "AppVersion", - "t": 8, - "c": "72.0.0-1", - "v": "> 72.0.0-1" - }, - { - "o": 49, - "a": "AppVersion", - "t": 9, - "c": "71.0.0+anothermetadata", - "v": ">= 71.0.0+anothermetadata" - }, - { - "o": 50, - "a": "AppVersion", - "t": 9, - "c": "71.0.0-patch3+anothermetadata", - "v": ">= 71.0.0-patch3+anothermetadata" - }, - { - "o": 51, - "a": "AppVersion", - "t": 9, - "c": "71.0.0-patch2", - "v": ">= 71.0.0-patch2" - }, - { - "o": 52, - "a": "AppVersion", - "t": 9, - "c": "71.0.0-patch1+metadata", - "v": ">= 71.0.0-patch1+metadata" - }, - { - "o": 53, - "a": "AppVersion", - "t": 9, - "c": "60.73.0-beta.2", - "v": ">= 60.73.0-beta.2" - }, - { - "o": 54, - "a": "AppVersion", - "t": 8, - "c": "60.72.0-beta.2", - "v": "> 60.72.0-beta.2" - }, - { - "o": 55, - "a": "AppVersion", - "t": 8, - "c": "60.72.0-beta.1", - "v": "> 60.72.0-beta.1" - }, - { - "o": 56, - "a": "AppVersion", - "t": 8, - "c": "60.72.0-beta", - "v": "> 60.72.0-beta" - }, - { - "o": 57, - "a": "AppVersion", - "t": 8, - "c": "60.72.0-alpha", - "v": "> 60.72.0-alpha" - }, - { - "o": 58, - "a": "AppVersion", - "t": 8, - "c": "60.72.0-1a", - "v": "> 60.72.0-1a" - }, - { - "o": 59, - "a": "AppVersion", - "t": 8, - "c": "60.72.0-10a", - "v": "> 60.72.0-10a" - }, - { - "o": 60, - "a": "AppVersion", - "t": 8, - "c": "60.72.0-2", - "v": "> 60.72.0-2" - }, - { - "o": 61, - "a": "AppVersion", - "t": 8, - "c": "60.72.0-1", - "v": "> 60.72.0-1" - }, - { - "o": 62, - "a": "AppVersion", - "t": 9, - "c": "60.71.0+anothermetadata", - "v": ">= 60.71.0+anothermetadata" - }, - { - "o": 63, - "a": "AppVersion", - "t": 9, - "c": "60.71.0-patch3+anothermetadata", - "v": ">= 60.71.0-patch3+anothermetadata" - }, - { - "o": 64, - "a": "AppVersion", - "t": 9, - "c": "60.71.0-patch2", - "v": ">= 60.71.0-patch2" - }, - { - "o": 65, - "a": "AppVersion", - "t": 9, - "c": "60.71.0-patch1+metadata", - "v": ">= 60.71.0-patch1+metadata" - }, - { - "o": 66, - "a": "AppVersion", - "t": 9, - "c": "50.60.73-beta.2", - "v": ">= 50.60.73-beta.2" - }, - { - "o": 67, - "a": "AppVersion", - "t": 8, - "c": "50.60.72-beta.2", - "v": "> 50.60.72-beta.2" - }, - { - "o": 68, - "a": "AppVersion", - "t": 8, - "c": "50.60.72-beta.1", - "v": "> 50.60.72-beta.1" - }, - { - "o": 69, - "a": "AppVersion", - "t": 8, - "c": "50.60.72-beta", - "v": "> 50.60.72-beta" - }, - { - "o": 70, - "a": "AppVersion", - "t": 8, - "c": "50.60.72-alpha", - "v": "> 50.60.72-alpha" - }, - { - "o": 71, - "a": "AppVersion", - "t": 8, - "c": "50.60.72-1a", - "v": "> 50.60.72-1a" - }, - { - "o": 72, - "a": "AppVersion", - "t": 8, - "c": "50.60.72-10a", - "v": "> 50.60.72-10a" - }, - { - "o": 73, - "a": "AppVersion", - "t": 8, - "c": "50.60.72-2", - "v": "> 50.60.72-2" - }, - { - "o": 74, - "a": "AppVersion", - "t": 8, - "c": "50.60.72-1", - "v": "> 50.60.72-1" - }, - { - "o": 75, - "a": "AppVersion", - "t": 9, - "c": "50.60.71+anothermetadata", - "v": ">= 50.60.71+anothermetadata" - }, - { - "o": 76, - "a": "AppVersion", - "t": 9, - "c": "50.60.71-patch3+anothermetadata", - "v": ">= 50.60.71-patch3+anothermetadata" - }, - { - "o": 77, - "a": "AppVersion", - "t": 9, - "c": "50.60.71-patch2", - "v": ">= 50.60.71-patch2" - }, - { - "o": 78, - "a": "AppVersion", - "t": 9, - "c": "50.60.71-patch1+metadata", - "v": ">= 50.60.71-patch1+metadata" - }, - { - "o": 79, - "a": "AppVersion", - "t": 9, - "c": "40.0.0-patch", - "v": ">= 40.0.0-patch" - }, - { - "o": 80, - "a": "AppVersion", - "t": 9, - "c": "30.0.0-alpha", - "v": ">= 30.0.0-alpha" - } - ] - } - } -} \ No newline at end of file diff --git a/src/ConfigCat.Client.Tests/data/sample_semantic_v5.json b/src/ConfigCat.Client.Tests/data/sample_semantic_v5.json deleted file mode 100644 index 0cadba72..00000000 --- a/src/ConfigCat.Client.Tests/data/sample_semantic_v5.json +++ /dev/null @@ -1,219 +0,0 @@ -{ - "f": { - "isOneOf": { - "v": "Default", - "t": 1, - "p": [], - "r": [ - { - "o": 0, - "a": "Custom1", - "t": 4, - "c": "1.0.0, 2", - "v": "Is one of (1.0.0, 2)" - }, - { - "o": 1, - "a": "Custom1", - "t": 4, - "c": "1.0.0", - "v": "Is one of (1.0.0)" - }, - { - "o": 2, - "a": "Custom1", - "t": 4, - "c": " , 2.0.1, 2.0.2, ", - "v": "Is one of ( , 2.0.1, 2.0.2, )" - }, - { - "o": 3, - "a": "Custom1", - "t": 4, - "c": "3......", - "v": "Is one of (3......)" - }, - { - "o": 4, - "a": "Custom1", - "t": 4, - "c": "3....", - "v": "Is one of (3...)" - }, - { - "o": 5, - "a": "Custom1", - "t": 4, - "c": "3..0", - "v": "Is one of (3..0)" - }, - { - "o": 6, - "a": "Custom1", - "t": 4, - "c": "3.0", - "v": "Is one of (3.0)" - }, - { - "o": 7, - "a": "Custom1", - "t": 4, - "c": "3.0.", - "v": "Is one of (3.0.)" - }, - { - "o": 8, - "a": "Custom1", - "t": 4, - "c": "3.0.0", - "v": "Is one of (3.0.0)" - } - ] - }, - "isOneOfWithPercentage": { - "v": "Default", - "t": 1, - "p": [ - { - "o": 0, - "v": "20%", - "p": 20 - }, - { - "o": 1, - "v": "80%", - "p": 80 - } - ], - "r": [ - { - "o": 0, - "a": "Custom1", - "t": 4, - "c": "1.0.0", - "v": "is one of (1.0.0)" - } - ] - }, - "isNotOneOf": { - "v": "Default", - "t": 1, - "p": [], - "r": [ - { - "o": 0, - "a": "Custom1", - "t": 5, - "c": "1.0.0, 1.0.1, 2.0.0 , 2.0.1, 2.0.2, ", - "v": "Is not one of (1.0.0, 1.0.1, 2.0.0 , 2.0.1, 2.0.2, )" - }, - { - "o": 1, - "a": "Custom1", - "t": 5, - "c": "1.0.0, 3.0.1", - "v": "Is not one of (1.0.0, 3.0.1)" - } - ] - }, - "isNotOneOfWithPercentage": { - "v": "Default", - "t": 1, - "p": [ - { - "o": 0, - "v": "20%", - "p": 20 - }, - { - "o": 1, - "v": "80%", - "p": 80 - } - ], - "r": [ - { - "o": 0, - "a": "Custom1", - "t": 5, - "c": "1.0.0, 1.0.1, 2.0.0 , 2.0.1, 2.0.2, ", - "v": "Is not one of (1.0.0, 1.0.1, 2.0.0 , 2.0.1, 2.0.2, )" - }, - { - "o": 1, - "a": "Custom1", - "t": 5, - "c": "1.0.0, 3.0.1", - "v": "Is not one of (1.0.0, 3.0.1)" - } - ] - }, - "lessThanWithPercentage": { - "v": "Default", - "t": 1, - "p": [ - { - "o": 0, - "v": "20%", - "p": 20 - }, - { - "o": 1, - "v": "80%", - "p": 80 - } - ], - "r": [ - { - "o": 0, - "a": "Custom1", - "t": 6, - "c": " 1.0.0 ", - "v": "< 1.0.0" - } - ] - }, - "relations": { - "v": "Default", - "t": 1, - "p": [], - "r": [ - { - "o": 0, - "a": "Custom1", - "t": 6, - "c": "1.0.0,", - "v": "<1.0.0," - }, - { - "o": 1, - "a": "Custom1", - "t": 6, - "c": "1.0.0", - "v": "< 1.0.0" - }, - { - "o": 2, - "a": "Custom1", - "t": 7, - "c": "1.0.0", - "v": "<=1.0.0" - }, - { - "o": 3, - "a": "Custom1", - "t": 8, - "c": "2.0.0", - "v": ">2.0.0" - }, - { - "o": 4, - "a": "Custom1", - "t": 9, - "c": "2.0.0", - "v": ">=2.0.0" - } - ] - } - } -} \ No newline at end of file diff --git a/src/ConfigCat.Client.Tests/data/sample_sensitive_v5.json b/src/ConfigCat.Client.Tests/data/sample_sensitive_v5.json deleted file mode 100644 index 70d92d9f..00000000 --- a/src/ConfigCat.Client.Tests/data/sample_sensitive_v5.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "f": - { - "isNotOneOfSensitive": { - "v": "ToAll", - "t": 1, - "p": [], - "r": [ - { - "o": 0, - "a": "Identifier", - "t": 17, - "c": "68d93aa74a0aa1664f65ad6c0515f24769b15c84,8409e4e5d27a1465165012b03b2606f0e5b08250", - "v": "Kigyo" - }, - { - "o": 1, - "a": "Email", - "t": 17, - "c": "2e1c7263a639cf2719f585dfa0be3953c13dd36f,532df0aa59af3cf1d3d876316225e987e63bf8a6", - "v": "Angolna" - }, - { - "o": 2, - "a": "Country", - "t": 17, - "c": - "707fe00aa123eb0be5010f1d3065c2b6d7934ca4,ff95dc990b9440c8ff18edd8592bf43915e510b9,e2ff49d5209adefb1d572ca4ca42701ac5b167ad", - "v": "Ireland" - } - ] - }, - "isOneOfSensitive": { - "v": "ToAll", - "t": 1, - "p": [], - "r": [ - { - "o": 0, - "a": "Email", - "t": 16, - "c": "532df0aa59af3cf1d3d876316225e987e63bf8a6", - "v": "Macska" - }, - { - "o": 1, - "a": "Identifier", - "t": 16, - "c": "cc1a672b80f85ec48aa620a588864285e2b04a45,68d93aa74a0aa1664f65ad6c0515f24769b15c84", - "v": "Allat" - }, - { - "o": 2, - "a": "Country", - "t": 16, - "c": - "707fe00aa123eb0be5010f1d3065c2b6d7934ca4,ff95dc990b9440c8ff18edd8592bf43915e510b9,e2ff49d5209adefb1d572ca4ca42701ac5b167ad", - "v": "Britt" - } - ] - } - } -} \ No newline at end of file diff --git a/src/ConfigCat.Client.Tests/data/sample_v5.json b/src/ConfigCat.Client.Tests/data/sample_v5.json index 253e5320..66407ffd 100644 --- a/src/ConfigCat.Client.Tests/data/sample_v5.json +++ b/src/ConfigCat.Client.Tests/data/sample_v5.json @@ -1,334 +1,536 @@ -{ +{ + "p": { + "s": "kSBpFzVdEHN7QbjOPhKkB2FHKaSXCGo8D55r0lqxhss=" + }, "f": { "stringDefaultCat": { - "v": "Cat", "t": 1, - "p": [], - "r": [] + "v": { + "s": "Cat" + } }, "stringIsInDogDefaultCat": { - "v": "Cat", "t": 1, - "p": [], "r": [ { - "o": 0, - "a": "Email", - "t": 0, - "c": "a@configcat.com, b@configcat.com", - "v": "Dog" + "c": [ + { + "u": { + "a": "Email", + "c": 16, + "l": [ + "206b33d71717cc9d3b74834fe2e6e1b195f052b4cc614d80571eafb1ad831fd5", + "2aa6bbf5d735ca9ace441fb4641478701bd6f364122e83b3d0bf5f54fddd550c" + ] + } + } + ], + "s": { + "v": { + "s": "Dog" + } + } }, { - "o": 1, - "a": "Custom1", - "t": 0, - "c": "admin", - "v": "Dog" + "c": [ + { + "u": { + "a": "Custom1", + "c": 16, + "l": [ + "5e7d81d60e0e5b55e2ffbdfe052b02b24afdaae16626e64ae6f3d183772cf9ec" + ] + } + } + ], + "s": { + "v": { + "s": "Dog" + } + } } - ] + ], + "v": { + "s": "Cat" + } }, "stringIsNotInDogDefaultCat": { - "v": "Cat", "t": 1, - "p": [], "r": [ { - "o": 0, - "a": "Email", - "t": 1, - "c": "a@configcat.com,b@configcat.com", - "v": "Dog" + "c": [ + { + "u": { + "a": "Email", + "c": 17, + "l": [ + "f117c6948de816414d68207e8c9fe562b5c53b0e0d3af1b5abcc36f1e0955997", + "56a3573e5aa9c408bdb83878dd038e78d555aea31f98d99f0b2b8c2867463b7c" + ] + } + } + ], + "s": { + "v": { + "s": "Dog" + } + } } - ] + ], + "v": { + "s": "Cat" + } }, "stringContainsDogDefaultCat": { - "v": "Cat", "t": 1, - "p": [], "r": [ { - "o": 0, - "a": "Email", - "t": 2, - "c": "@configcat.com", - "v": "Dog" + "c": [ + { + "u": { + "a": "Email", + "c": 2, + "l": [ + "@configcat.com" + ] + } + } + ], + "s": { + "v": { + "s": "Dog" + } + } } - ] + ], + "v": { + "s": "Cat" + } }, "stringNotContainsDogDefaultCat": { - "v": "Cat", "t": 1, - "p": [], "r": [ { - "o": 0, - "a": "Email", - "t": 3, - "c": "@configcat.com", - "v": "Dog" + "c": [ + { + "u": { + "a": "Email", + "c": 3, + "l": [ + "@configcat.com" + ] + } + } + ], + "s": { + "v": { + "s": "Dog" + } + } } - ] + ], + "v": { + "s": "Cat" + } }, "string25Cat25Dog25Falcon25Horse": { - "v": "Chicken", "t": 1, "p": [ { - "o": 0, - "v": "Cat", - "p": 25 + "p": 25, + "v": { + "s": "Cat" + } }, { - "o": 1, - "v": "Dog", - "p": 25 + "p": 25, + "v": { + "s": "Dog" + } }, { - "o": 2, - "v": "Falcon", - "p": 25 + "p": 25, + "v": { + "s": "Falcon" + } }, { - "o": 3, - "v": "Horse", - "p": 25 + "p": 25, + "v": { + "s": "Horse" + } } ], - "r": [] + "v": { + "s": "Chicken" + } }, "string75Cat0Dog25Falcon0Horse": { - "v": "Chicken", "t": 1, "p": [ { - "o": 0, - "v": "Cat", - "p": 75 + "p": 75, + "v": { + "s": "Cat" + } }, { - "o": 1, - "v": "Dog", - "p": 0 + "p": 0, + "v": { + "s": "Dog" + } }, { - "o": 2, - "v": "Falcon", - "p": 25 + "p": 25, + "v": { + "s": "Falcon" + } }, { - "o": 3, - "v": "Horse", - "p": 0 + "p": 0, + "v": { + "s": "Horse" + } } ], - "r": [] + "v": { + "s": "Chicken" + } }, "string25Cat25Dog25Falcon25HorseAdvancedRules": { - "v": "Chicken", "t": 1, - "p": [ - { - "o": 0, - "v": "Cat", - "p": 25 - }, + "r": [ { - "o": 1, - "v": "Dog", - "p": 25 + "c": [ + { + "u": { + "a": "Country", + "c": 16, + "l": [ + "4af88801ac46795aac6d8e412d87eaaae27e02954464932c4d98175b3eafba9b", + "825a2eb2bdc769ad45059625349b889ee32b6a86d636b96e79de4133326d030d" + ] + } + } + ], + "s": { + "v": { + "s": "Dolphin" + } + } }, { - "o": 2, - "v": "Falcon", - "p": 25 + "c": [ + { + "u": { + "a": "Custom1", + "c": 2, + "l": [ + "admi" + ] + } + } + ], + "s": { + "v": { + "s": "Lion" + } + } }, { - "o": 3, - "v": "Horse", - "p": 25 + "c": [ + { + "u": { + "a": "Email", + "c": 2, + "l": [ + "@configcat.com" + ] + } + } + ], + "s": { + "v": { + "s": "Kitten" + } + } } ], - "r": [ + "p": [ { - "o": 0, - "a": "Country", - "t": 0, - "c": "Hungary, United Kingdom", - "v": "Dolphin" + "p": 25, + "v": { + "s": "Cat" + } }, { - "o": 1, - "a": "Custom1", - "t": 2, - "c": "admi", - "v": "Lion" + "p": 25, + "v": { + "s": "Dog" + } }, { - "o": 2, - "a": "Email", - "t": 2, - "c": "@configcat.com", - "v": "Kitten" + "p": 25, + "v": { + "s": "Falcon" + } + }, + { + "p": 25, + "v": { + "s": "Horse" + } } - ] + ], + "v": { + "s": "Chicken" + } }, "boolDefaultTrue": { - "v": true, "t": 0, - "p": [], - "r": [] + "v": { + "b": true + } }, "boolDefaultFalse": { - "v": false, "t": 0, - "p": [], - "r": [] + "v": { + "b": false + } }, "bool30TrueAdvancedRules": { - "v": true, "t": 0, - "p": [ + "r": [ { - "o": 0, - "v": true, - "p": 30 + "c": [ + { + "u": { + "a": "Email", + "c": 16, + "l": [ + "3209f667a750966e68e6f6357515b30564f5ac510d347a04cdf2f538715d3dd8", + "4632d76e9719686ebefd5ac89084019f4cd6c31e2b2d25ca2aaf566a68137d29" + ] + } + } + ], + "s": { + "v": { + "b": false + } + } }, { - "o": 1, - "v": false, - "p": 70 + "c": [ + { + "u": { + "a": "Country", + "c": 2, + "l": [ + "United" + ] + } + } + ], + "s": { + "v": { + "b": false + } + } } ], - "r": [ + "p": [ { - "o": 0, - "a": "Email", - "t": 0, - "c": "a@configcat.com, b@configcat.com", - "v": false + "p": 30, + "v": { + "b": true + } }, { - "o": 1, - "a": "Country", - "t": 2, - "c": "United", - "v": false + "p": 70, + "v": { + "b": false + } } - ] + ], + "v": { + "b": true + } }, "integer25One25Two25Three25FourAdvancedRules": { - "v": -1, "t": 2, + "r": [ + { + "c": [ + { + "u": { + "a": "Email", + "c": 2, + "l": [ + "@configcat.com" + ] + } + } + ], + "s": { + "v": { + "i": 5 + } + } + } + ], "p": [ { - "o": 0, - "v": 1, - "p": 25 + "p": 25, + "v": { + "i": 1 + } }, { - "o": 1, - "v": 2, - "p": 25 + "p": 25, + "v": { + "i": 2 + } }, { - "o": 2, - "v": 3, - "p": 25 + "p": 25, + "v": { + "i": 3 + } }, { - "o": 3, - "v": 4, - "p": 25 + "p": 25, + "v": { + "i": 4 + } } ], - "r": [ - { - "o": 0, - "a": "Email", - "t": 2, - "c": "@configcat.com", - "v": 5 - } - ] + "v": { + "i": -1 + } }, "integerDefaultOne": { - "v": 1, "t": 2, - "p": [], - "r": [] + "v": { + "i": 1 + } }, "doubleDefaultPi": { - "v": 3.1415, "t": 3, - "p": [], - "r": [] + "v": { + "d": 3.1415 + } }, "double25Pi25E25Gr25Zero": { - "v": -1.0, "t": 3, + "r": [ + { + "c": [ + { + "u": { + "a": "Email", + "c": 2, + "l": [ + "@configcat.com" + ] + } + } + ], + "s": { + "v": { + "d": 5.561 + } + } + } + ], "p": [ { - "o": 0, - "v": 3.1415, - "p": 25 + "p": 25, + "v": { + "d": 3.1415 + } }, { - "o": 1, - "v": 2.7182, - "p": 25 + "p": 25, + "v": { + "d": 2.7182 + } }, { - "o": 2, - "v": 1.61803, - "p": 25 + "p": 25, + "v": { + "d": 1.61803 + } }, { - "o": 3, - "v": 0.0, - "p": 25 + "p": 25, + "v": { + "d": 0 + } } ], - "r": [ - { - "o": 0, - "a": "Email", - "t": 2, - "c": "@configcat.com", - "v": 5.561 - } - ] + "v": { + "d": -1 + } }, "keySampleText": { - "v": "Cat", "t": 1, - "p": [ + "r": [ { - "o": 0, - "v": "Falcon", - "p": 50 + "c": [ + { + "u": { + "a": "Country", + "c": 16, + "l": [ + "569d4810dfac9d6a4b4196aaddf5ba3e8ef0a653cfa15054529e0e9ed76f5f25", + "a7d55166218ec2c197cc4b14723cc96f00cfbee0d734cb57769bb33d30387c71" + ] + } + } + ], + "s": { + "v": { + "s": "Dog" + } + } }, { - "o": 1, - "v": "Horse", - "p": 50 + "c": [ + { + "u": { + "a": "SubscriptionType", + "c": 16, + "l": [ + "ddd689eb98271df997e28f75dbe9a134af7235b1d6d84d7d6773cb48556103a6" + ] + } + } + ], + "s": { + "v": { + "s": "Lion" + } + } } ], - "r": [ + "p": [ { - "o": 0, - "a": "Country", - "t": 0, - "c": "Hungary,Bahamas", - "v": "Dog" + "p": 50, + "v": { + "s": "Falcon" + } }, { - "o": 1, - "a": "SubscriptionType", - "t": 0, - "c": "unlimited", - "v": "Lion" + "p": 50, + "v": { + "s": "Horse" + } } - ] + ], + "v": { + "s": "Cat" + } } } -} \ No newline at end of file +} diff --git a/src/ConfigCat.Client.Tests/data/sample_variationid_v5.json b/src/ConfigCat.Client.Tests/data/sample_variationid_v5.json index c11b4278..32cfdc2b 100644 --- a/src/ConfigCat.Client.Tests/data/sample_variationid_v5.json +++ b/src/ConfigCat.Client.Tests/data/sample_variationid_v5.json @@ -1,144 +1,240 @@ -{ +{ + "p": { + "s": "XNvUomOaJnfFzAfmqPLzbRgtU\u002BK\u002BPtFywkA\u002Bf/NsOhc=" + }, "f": { "boolean": { - "v": false, - "i": "a0e56eda", "t": 0, + "r": [ + { + "c": [ + { + "u": { + "a": "Email", + "c": 2, + "l": [ + "@configcat.com" + ] + } + } + ], + "s": { + "v": { + "b": true + }, + "i": "67787ae4" + } + } + ], "p": [ { - "o": 0, - "v": true, "p": 50, + "v": { + "b": true + }, "i": "67787ae4" }, { - "o": 1, - "v": false, "p": 50, + "v": { + "b": false + }, "i": "a0e56eda" } ], - "r": [ - { - "o": 0, - "a": "Email", - "t": 2, - "c": "@configcat.com", - "v": true, - "i": "67787ae4" - } - ] + "v": { + "b": false + }, + "i": "a0e56eda" }, "text": { - "v": "c", "t": 1, - "i": "3f05be89", + "r": [ + { + "c": [ + { + "u": { + "a": "Email", + "c": 2, + "l": [ + "@configcat.com" + ] + } + } + ], + "s": { + "v": { + "s": "true" + }, + "i": "9bdc6a1f" + } + }, + { + "c": [ + { + "u": { + "a": "Email", + "c": 2, + "l": [ + "@test.com" + ] + } + } + ], + "s": { + "v": { + "s": "false" + }, + "i": "65310deb" + } + } + ], "p": [ { - "o": 0, - "v": "a", "p": 50, + "v": { + "s": "a" + }, "i": "30ba32b9" }, { - "o": 1, - "v": "b", "p": 50, + "v": { + "s": "b" + }, "i": "cf19e913" } ], - "r": [ - { - "o": 0, - "a": "Email", - "t": 2, - "c": "@configcat.com", - "v": "true", - "i": "9bdc6a1f" - }, - { - "o": 1, - "a": "Email", - "t": 2, - "c": "@test.com", - "v": "false", - "i": "65310deb" - } - ] + "v": { + "s": "c" + }, + "i": "3f05be89" }, "whole": { - "v": 999999, - "i": "cf2e9162", "t": 2, + "r": [ + { + "c": [ + { + "u": { + "a": "Email", + "c": 2, + "l": [ + "@configcat.com" + ] + } + } + ], + "s": { + "v": { + "i": 1 + }, + "i": "ab30533b" + } + } + ], "p": [ { - "o": 0, - "v": 0, "p": 50, + "v": { + "i": 0 + }, "i": "ec14f6a9" }, { - "o": 1, - "v": -1, "p": 50, + "v": { + "i": -1 + }, "i": "61a5a033" } ], - "r": [ - { - "o": 0, - "a": "Email", - "t": 2, - "c": "@configcat.com", - "v": 1, - "i": "ab30533b" - } - ] + "v": { + "i": 999999 + }, + "i": "cf2e9162" }, "decimal": { - "v": 0.0, - "i": "63612d39", "t": 3, + "r": [ + { + "c": [ + { + "u": { + "a": "Email", + "c": 2, + "l": [ + "@configcat.com" + ] + } + } + ], + "s": { + "v": { + "d": -2147483647.2147484 + }, + "i": "8f9559cf" + } + }, + { + "c": [ + { + "u": { + "a": "Email", + "c": 16, + "l": [ + "16c5c406a4ab19fe4924f77e61d70ea58349db2c76311e757d0acac0d76f592f" + ] + } + } + ], + "s": { + "v": { + "d": 0.12345678912345678 + }, + "i": "d66c5781" + } + }, + { + "c": [ + { + "u": { + "a": "Email", + "c": 16, + "l": [ + "5bc0abba39810e3565c0d73ff143483a76c8aa620b9567f5edb312f3c5d17c81" + ] + } + } + ], + "s": { + "v": { + "d": 0.12345678912 + }, + "i": "d66c5781" + } + } + ], "p": [ { - "o": 0, - "v": 1.0, "p": 50, + "v": { + "d": 1 + }, "i": "d0dbc27f" }, { - "o": 1, - "v": 2.0, "p": 50, + "v": { + "d": 2 + }, "i": "8155ad7b" } ], - "r": [ - { - "o": 0, - "a": "Email", - "t": 2, - "c": "@configcat.com", - "v": -2147483647.2147484, - "i": "8f9559cf" - }, - { - "o": 1, - "a": "Email", - "t": 0, - "c": "a@test.com", - "v": 0.12345678912345678, - "i": "d66c5781" - }, - { - "o": 2, - "a": "Email", - "t": 0, - "c": "b@test.com", - "v": 0.12345678912, - "i": "d66c5781" - } - ] + "v": { + "d": 0 + }, + "i": "63612d39" } } -} \ No newline at end of file +} diff --git a/src/ConfigCat.Client.Tests/data/test_circulardependency_v6.json b/src/ConfigCat.Client.Tests/data/test_circulardependency_v6.json new file mode 100644 index 00000000..a8a9e176 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/test_circulardependency_v6.json @@ -0,0 +1,80 @@ +{ + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0 + }, + "f": { + "key1": { + "t": 1, + "v": { "s": "key1-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key1", + "c": 0, + "v": { "s": "key1-prereq" } + } + } + ], + "s": { "v": { "s": "key1-prereq" } } + } + ] + }, + "key2": { + "t": 1, + "v": { "s": "key2-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key3", + "c": 0, + "v": { "s": "key3-prereq" } + } + } + ], + "s": { "v": { "s": "key2-prereq" } } + } + ] + }, + "key3": { + "t": 1, + "v": { "s": "key3-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key2", + "c": 0, + "v": { "s": "key2-prereq" } + } + } + ], + "s": { "v": { "s": "key3-prereq" } } + } + ] + }, + "key4": { + "t": 1, + "v": { "s": "key4-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key3", + "c": 0, + "v": { "s": "key3-prereq" } + } + } + ], + "s": { "v": { "s": "key4-prereq" } } + } + ] + } + } +} diff --git a/src/ConfigCat.Client.Tests/data/test_json_complex.json b/src/ConfigCat.Client.Tests/data/test_json_complex.json index 0f45e8be..4383d896 100644 --- a/src/ConfigCat.Client.Tests/data/test_json_complex.json +++ b/src/ConfigCat.Client.Tests/data/test_json_complex.json @@ -1,19 +1,37 @@ -{ +{ + "p": { + "s": "s449fLWNwiEFQ/AqfRj13pPHVdV9g3h0HAFzWtjpZgE=" + }, "f": { "disabledFeature": { - "v": false + "t": 0, + "v": { + "b": false + } }, "enabledFeature": { - "v": true + "t": 0, + "v": { + "b": true + } }, "intSetting": { - "v": 5 + "t": 2, + "v": { + "i": 5 + } }, "doubleSetting": { - "v": 3.14 + "t": 3, + "v": { + "d": 3.14 + } }, "stringSetting": { - "v": "test" + "t": 1, + "v": { + "s": "test" + } } } -} \ No newline at end of file +} diff --git a/src/ConfigCat.Client.Tests/data/test_json_simple.json b/src/ConfigCat.Client.Tests/data/test_json_simple.json index 8388e41e..ff17f9ea 100644 --- a/src/ConfigCat.Client.Tests/data/test_json_simple.json +++ b/src/ConfigCat.Client.Tests/data/test_json_simple.json @@ -1,4 +1,4 @@ -{ +{ "flags": { "disabledFeature": false, "enabledFeature": true, @@ -6,4 +6,4 @@ "doubleSetting": 3.14, "stringSetting": "test" } -} \ No newline at end of file +} diff --git a/src/ConfigCat.Client.Tests/data/test_override_flagdependency_v6.json b/src/ConfigCat.Client.Tests/data/test_override_flagdependency_v6.json new file mode 100644 index 00000000..62e159e5 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/test_override_flagdependency_v6.json @@ -0,0 +1,44 @@ +{ + "p": { + "u": "https://test-cdn-eu.configcat.com", + "r": 0, + "s": "TsTuRHo\u002BMHs8h8j16HQY83sooJsLg34Ir5KIVOletFU=" + }, + "f": { + "mainStringFlag": { + "t": 1, + "v": { + "s": "private" + }, + "i": "24c96275" + }, + "stringDependsOnInt": { + "t": 1, + "r": [ + { + "c": [ + { + "p": { + "f": "mainIntFlag", + "c": 0, + "v": { + "i": 42 + } + } + } + ], + "s": { + "v": { + "s": "Dog" + }, + "i": "12531eec" + } + } + ], + "v": { + "s": "Cat" + }, + "i": "e227d926" + } + } +} diff --git a/src/ConfigCat.Client.Tests/data/test_override_segments_v6.json b/src/ConfigCat.Client.Tests/data/test_override_segments_v6.json new file mode 100644 index 00000000..47bf15ce --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/test_override_segments_v6.json @@ -0,0 +1,66 @@ +{ + "p": { + "u": "https://test-cdn-eu.configcat.com", + "r": 0, + "s": "80xCU/SlDz1lCiWFaxIBjyJeJecWjq46T4eu6GtozkM=" + }, + "s": [ + { + "n": "Beta Users", + "r": [ + { + "a": "Email", + "c": 16, + "l": [ + "9189c42f6035bd1d2df5eda347a4f62926d27c80540a7aa6cc72cc75bc6757ff" + ] + } + ] + }, + { + "n": "Developers", + "r": [ + { + "a": "Email", + "c": 16, + "l": [ + "a7cdf54e74b5527bd2617889ec47f6d29b825ccfc97ff00832886bcb735abded" + ] + } + ] + } + ], + "f": { + "developerAndBetaUserSegment": { + "t": 0, + "r": [ + { + "c": [ + { + "s": { + "s": 1, + "c": 0 + } + }, + { + "s": { + "s": 0, + "c": 1 + } + } + ], + "s": { + "v": { + "b": true + }, + "i": "ddc50638" + } + } + ], + "v": { + "b": false + }, + "i": "6427f4b8" + } + } +} diff --git a/src/ConfigCat.Client.Tests/data/testmatrix_and_or.csv b/src/ConfigCat.Client.Tests/data/testmatrix_and_or.csv new file mode 100644 index 00000000..5a149f4a --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/testmatrix_and_or.csv @@ -0,0 +1,15 @@ +Identifier;Email;Country;Custom1;mainFeature;dependentFeature;emailAnd;emailOr +##null##;;;;public;Chicken;Cat;Cat +;;;;public;Chicken;Cat;Cat +jane@example.com;jane@example.com;##null##;##null##;public;Chicken;Cat;Jane +john@example.com;john@example.com;##null##;##null##;public;Chicken;Cat;John +a@example.com;a@example.com;USA;##null##;target;Cat;Cat;Cat +mark@example.com;mark@example.com;USA;##null##;target;Dog;Cat;Mark +nora@example.com;nora@example.com;USA;##null##;target;Falcon;Cat;Cat +stern@msn.com;stern@msn.com;USA;##null##;target;Horse;Cat;Cat +jane@sensitivecompany.com;jane@sensitivecompany.com;England;##null##;private;Chicken;Dog;Jane +anna@sensitivecompany.com;anna@sensitivecompany.com;France;##null##;private;Chicken;Cat;Cat +jane@sensitivecompany.com;jane@sensitivecompany.com;england;##null##;public;Chicken;Dog;Jane +jane;jane;##null##;##null##;public;Chicken;Cat;Cat +@sensitivecompany.com;@sensitivecompany.com;##null##;##null##;public;Chicken;Cat;Cat +jane.sensitivecompany.com;jane.sensitivecompany.com;##null##;##null##;public;Chicken;Cat;Cat diff --git a/src/ConfigCat.Client.Tests/data/testmatrix_comparators_v6.csv b/src/ConfigCat.Client.Tests/data/testmatrix_comparators_v6.csv new file mode 100644 index 00000000..d53efb54 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/testmatrix_comparators_v6.csv @@ -0,0 +1,24 @@ +Identifier;Email;Country;Custom1;boolTrueIn202304;stringEqualsDogDefaultCat;stringEqualsCleartextDogDefaultCat;stringDoseNotEqualDogDefaultCat;stringNotEqualsCleartextDogDefaultCat;stringStartsWithDogDefaultCat;stringNotStartsWithDogDefaultCat;stringEndsWithDogDefaultCat;stringNotEndsWithDogDefaultCat;arrayContainsDogDefaultCat;arrayDoesNotContainDogDefaultCat;arrayContainsCaseCheckDogDefaultCat;arrayDoesNotContainCaseCheckDogDefaultCat;customPercentageAttribute;missingPercentageAttribute;countryPercentageAttribute;stringContainsAnyOfDogDefaultCat;stringNotContainsAnyOfDogDefaultCat;stringStartsWithAnyOfDogDefaultCat;stringStartsWithAnyOfCleartextDogDefaultCat;stringNotStartsWithAnyOfDogDefaultCat;stringNotStartsWithAnyOfCleartextDogDefaultCat;stringEndsWithAnyOfDogDefaultCat;stringEndsWithAnyOfCleartextDogDefaultCat;stringNotEndsWithAnyOfDogDefaultCat;stringNotEndsWithAnyOfCleartextDogDefaultCat;stringArrayContainsAnyOfDogDefaultCat;stringArrayContainsAnyOfCleartextDogDefaultCat;stringArrayNotContainsAnyOfDogDefaultCat;stringArrayNotContainsAnyOfCleartextDogDefaultCat +##null##;;;;False;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Chicken;Chicken;Chicken;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat +;;;;False;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Chicken;Chicken;Chicken;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat +a@configcat.com;a@configcat.com;##null##;##null##;False;Dog;Dog;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Chicken;NotFound;Chicken;Cat;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +b@configcat.com;b@configcat.com;Hungary;0;False;Cat;Cat;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Falcon;Cat;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +c@configcat.com;c@configcat.com;United Kingdom;1680307199.9;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +anna@configcat.com;anna@configcat.com;Hungary;1681118000.56;True;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +bogjobber@verizon.net;bogjobber@verizon.net;##null##;1682899200.1;False;Cat;Cat;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Horse;Chicken;Chicken;Dog;Cat;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Cat +cliffordj@aol.com;cliffordj@aol.com;Austria;1682999200;False;Cat;Cat;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Falcon;Chicken;Falcon;Dog;Cat;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Cat +reader@configcat.com;reader@configcat.com;Bahamas;read,execute;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +writer@configcat.com;writer@configcat.com;Belgium;write, execute;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +reader@configcat.com;reader@configcat.com;Canada;execute, Read;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +writer@configcat.com;writer@configcat.com;China;Write;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +admin@configcat.com;admin@configcat.com;France;read, write,execute;False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +user@configcat.com;user@configcat.com;Greece;,execute;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +reader@configcat.com;reader@configcat.com;Bahamas;["read","execute"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Dog;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +writer@configcat.com;writer@configcat.com;Belgium;["write", "execute"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +reader@configcat.com;reader@configcat.com;Canada;["execute", "Read"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +writer@configcat.com;writer@configcat.com;China;["Write"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog +admin@configcat.com;admin@configcat.com;France;["read", "write","execute"];False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Dog;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +admin@configcat.com;admin@configcat.com;France;["Read", "Write", "execute"];False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +admin@configcat.com;admin@configcat.com;France;["Read", "Write", "eXecute"];False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog +user@configcat.com;user@configcat.com;Greece;["","execute"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Dog;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +user@configcat.com;user@configcat.com;Monaco;,null, ,,nil, None;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat diff --git a/src/ConfigCat.Client.Tests/data/testmatrix_prerequisite_flag.csv b/src/ConfigCat.Client.Tests/data/testmatrix_prerequisite_flag.csv new file mode 100644 index 00000000..dcf68f4d --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/testmatrix_prerequisite_flag.csv @@ -0,0 +1,5 @@ +Identifier;Email;Country;Custom1;mainBoolFlag;mainStringFlag;mainIntFlag;mainDoubleFlag;stringDependsOnBool;stringDependsOnString;stringDependsOnStringCaseCheck;stringDependsOnInt;stringDependsOnDouble;stringDependsOnDoubleIntValue;boolDependsOnBool;intDependsOnBool;doubleDependsOnBool;boolDependsOnBoolDependsOnBool;mainBoolFlagEmpty;stringDependsOnEmptyBool;stringInverseDependsOnEmptyBool;mainBoolFlagInverse;boolDependsOnBoolInverse +##null##;;;;True;public;42;3.14;Dog;Cat;Cat;Cat;Cat;Cat;True;1;1.1;False;True;EmptyOn;EmptyOn;False;True +;;;;True;public;42;3.14;Dog;Cat;Cat;Cat;Cat;Cat;True;1;1.1;False;True;EmptyOn;EmptyOn;False;True +john@sensitivecompany.com;john@sensitivecompany.com;##null##;##null##;False;private;2;0.1;Cat;Dog;Cat;Dog;Dog;Cat;False;42;3.14;True;True;EmptyOn;EmptyOn;True;False +jane@example.com;jane@example.com;##null##;##null##;True;public;42;3.14;Dog;Cat;Cat;Cat;Cat;Cat;True;1;1.1;False;True;EmptyOn;EmptyOn;False;True diff --git a/src/ConfigCat.Client.Tests/data/testmatrix_segments.csv b/src/ConfigCat.Client.Tests/data/testmatrix_segments.csv new file mode 100644 index 00000000..b59ba3a0 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/testmatrix_segments.csv @@ -0,0 +1,6 @@ +Identifier;Email;Country;Custom1;developerAndBetaUserSegment;developerAndBetaUserCleartextSegment;notDeveloperAndNotBetaUserSegment;notDeveloperAndNotBetaUserCleartextSegment +##null##;;;;False;False;False;False +;;;;False;False;False;False +john@example.com;john@example.com;##null##;##null##;False;False;False;False +jane@example.com;jane@example.com;##null##;##null##;False;False;False;False +kate@example.com;kate@example.com;##null##;##null##;True;True;True;True diff --git a/src/ConfigCat.Client.Tests/data/testmatrix_segments_old.csv b/src/ConfigCat.Client.Tests/data/testmatrix_segments_old.csv new file mode 100644 index 00000000..9fc605ec --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/testmatrix_segments_old.csv @@ -0,0 +1,6 @@ +Identifier;Email;Country;Custom1;featureWithSegmentTargeting;featureWithSegmentTargetingCleartext;featureWithNegatedSegmentTargeting;featureWithNegatedSegmentTargetingCleartext;featureWithSegmentTargetingInverse;featureWithSegmentTargetingInverseCleartext;featureWithNegatedSegmentTargetingInverse;featureWithNegatedSegmentTargetingInverseCleartext +##null##;;;;False;False;False;False;False;False;False;False +;;;;False;False;False;False;False;False;False;False +john@example.com;john@example.com;##null##;##null##;True;True;False;False;False;False;True;True +jane@example.com;jane@example.com;##null##;##null##;True;True;False;False;False;False;True;True +kate@example.com;kate@example.com;##null##;##null##;False;False;True;True;True;True;False;False diff --git a/src/ConfigCat.Client.Tests/data/testmatrix_semantic_2.csv b/src/ConfigCat.Client.Tests/data/testmatrix_semantic_2.csv index e0da30c9..449f8632 100644 --- a/src/ConfigCat.Client.Tests/data/testmatrix_semantic_2.csv +++ b/src/ConfigCat.Client.Tests/data/testmatrix_semantic_2.csv @@ -92,4 +92,4 @@ dontcare;;;50.60.71-patch2+metadata;>= 50.60.71-patch2 dontcare;;;50.60.71-patch1;>= 50.60.71-patch1+metadata dontcare;;;50.60.71-patch1+anothermetadata;>= 50.60.71-patch1+metadata dontcare;;;40.0.0-patch;>= 40.0.0-patch -dontcare;;;30.0.0-beta;>= 30.0.0-alpha \ No newline at end of file +dontcare;;;30.0.0-beta;>= 30.0.0-alpha diff --git a/src/ConfigCat.Client.Tests/data/testmatrix_unicode.csv b/src/ConfigCat.Client.Tests/data/testmatrix_unicode.csv new file mode 100644 index 00000000..e5b01de0 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/testmatrix_unicode.csv @@ -0,0 +1,14 @@ +Identifier;Email;Country;🆃🅴🆇🆃;boolTextEqualsHashed;boolTextEqualsCleartext;boolTextNotEqualsHashed;boolTextNotEqualsCleartext;boolIsOneOfHashed;boolIsOneOfCleartext;boolIsNotOneOfHashed;boolIsNotOneOfCleartext;boolStartsWithHashed;boolStartsWithCleartext;boolNotStartsWithHashed;boolNotStartsWithCleartext;boolEndsWithHashed;boolEndsWithCleartext;boolNotEndsWithHashed;boolNotEndsWithCleartext;boolContainsCleartext;boolNotContainsCleartext;boolArrayContainsHashed;boolArrayContainsCleartext;boolArrayNotContainsHashed;boolArrayNotContainsCleartext +1;;;ʄǟռƈʏ ȶɛӼȶ;True;True;False;False;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;ʄaռƈʏ ȶɛӼȶ;False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;ÁRVÍZTŰRŐ tükörfúrógép;False;False;True;True;True;True;False;False;True;True;False;False;True;True;False;False;True;False;False;False;False;False +1;;;árvíztűrő tükörfúrógép;False;False;True;True;False;False;True;True;False;False;True;True;True;True;False;False;True;False;False;False;False;False +1;;;ÁRVÍZTŰRŐ TÜKÖRFÚRÓGÉP;False;False;True;True;False;False;True;True;True;True;False;False;False;False;True;True;True;False;False;False;False;False +1;;;árvíztűrő TÜKÖRFÚRÓGÉP;False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;u𝖓𝖎𝖈𝖔𝖉e;False;False;True;True;True;True;False;False;True;True;False;False;True;True;False;False;True;False;False;False;False;False +;;;𝖚𝖓𝖎𝖈𝖔𝖉e;False;False;True;True;False;False;True;True;False;False;True;True;True;True;False;False;True;False;False;False;False;False +;;;u𝖓𝖎𝖈𝖔𝖉𝖊;False;False;True;True;False;False;True;True;True;True;False;False;False;False;True;True;True;False;False;False;False;False +;;;𝖚𝖓𝖎𝖈𝖔𝖉𝖊;False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;["ÁRVÍZTŰRŐ tükörfúrógép", "unicode"];False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;True;False;True;True;False;False +1;;;["ÁRVÍZTŰRŐ", "tükörfúrógép", "u𝖓𝖎𝖈𝖔𝖉e"];False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;True;False;True;True;False;False +1;;;["ÁRVÍZTŰRŐ", "tükörfúrógép", "unicode"];False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;True;False;False;False;True;True diff --git a/src/ConfigCat.Client.Tests/data/testmatrix_variationid.csv b/src/ConfigCat.Client.Tests/data/testmatrix_variationid.csv index 0d2a7b7d..8f76cd4a 100644 --- a/src/ConfigCat.Client.Tests/data/testmatrix_variationid.csv +++ b/src/ConfigCat.Client.Tests/data/testmatrix_variationid.csv @@ -1,8 +1,8 @@ -Identifier;Email;Country;Custom1;boolean;decimal;text;whole +Identifier;Email;Country;Custom1;boolean;decimal;text;whole ##null##;;;;a0e56eda;63612d39;3f05be89;cf2e9162; a@configcat.com;a@configcat.com;Hungary;admin;67787ae4;8f9559cf;9bdc6a1f;ab30533b; b@configcat.com;b@configcat.com;Hungary;admin;67787ae4;8f9559cf;9bdc6a1f;ab30533b; a@test.com;a@test.com;Hungary;admin;67787ae4;d66c5781;65310deb;ec14f6a9; b@test.com;b@test.com;Hungary;admin;a0e56eda;d66c5781;65310deb;ec14f6a9; cliffordj@aol.com;cliffordj@aol.com;Hungary;admin;67787ae4;8155ad7b;cf19e913;ec14f6a9; -bryanw@verizon.net;bryanw@verizon.net;Hungary;;a0e56eda;d0dbc27f;30ba32b9;61a5a033; \ No newline at end of file +bryanw@verizon.net;bryanw@verizon.net;Hungary;;a0e56eda;d0dbc27f;30ba32b9;61a5a033; diff --git a/src/ConfigCat.Client.Tests/strongname.snk b/src/ConfigCatClient.snk similarity index 100% rename from src/ConfigCat.Client.Tests/strongname.snk rename to src/ConfigCatClient.snk diff --git a/src/ConfigCatClient/ConfigCatClient.cs b/src/ConfigCatClient/ConfigCatClient.cs index 0a66cf61..876389ed 100644 --- a/src/ConfigCatClient/ConfigCatClient.cs +++ b/src/ConfigCatClient/ConfigCatClient.cs @@ -34,6 +34,37 @@ public sealed class ConfigCatClient : IConfigCatClient // which is good enough in these cases. private volatile User? defaultUser; + private static bool IsValidSdkKey(string sdkKey, bool customBaseUrl) + { + const string proxyPrefix = "configcat-proxy/"; + + if (customBaseUrl && sdkKey.Length > proxyPrefix.Length && sdkKey.StartsWith(proxyPrefix, StringComparison.Ordinal)) + { + return true; + } + + var components = sdkKey.Split('/'); + const int keyLength = 22; + + return components.Length switch + { + 2 => components[0].Length == keyLength && components[1].Length == keyLength, + 3 => components[0] == "configcat-sdk-1" && components[1].Length == keyLength && components[2].Length == keyLength, + _ => false + }; + } + + internal static string GetProductVersion(PollingMode pollingMode) + { + return $"{pollingMode.Identifier}-{Version}"; + } + + internal static string GetCacheKey(string sdkKey) + { + var key = $"{sdkKey}_{ConfigCatClientOptions.ConfigFileName}_{ProjectConfig.SerializationFormatVersion}"; + return key.Sha1().ToHexString(); + } + /// public LogLevel LogLevel { @@ -74,7 +105,7 @@ internal ConfigCatClient(string sdkKey, ConfigCatClientOptions options) this.configService = this.overrideBehaviour != OverrideBehaviour.LocalOnly ? DetermineConfigService(pollingMode, new HttpConfigFetcher(options.CreateUri(sdkKey), - $"{pollingMode.Identifier}-{Version}", + GetProductVersion(pollingMode), this.logger, options.HttpClientHandler, options.IsCustomBaseUrl, @@ -110,7 +141,7 @@ internal ConfigCatClient(IConfigService configService, IConfigCatLogger logger, /// SDK Key to access the ConfigCat config. /// The action used to configure the client. /// is . - /// is an empty string. + /// is an empty string or in an invalid format. public static IConfigCatClient Get(string sdkKey, Action? configurationAction = null) { if (sdkKey is null) @@ -126,6 +157,11 @@ public static IConfigCatClient Get(string sdkKey, Action var options = new ConfigCatClientOptions(); configurationAction?.Invoke(options); + if (options.FlagOverrides is not { OverrideBehaviour: OverrideBehaviour.LocalOnly } && !IsValidSdkKey(sdkKey, options.IsCustomBaseUrl)) + { + throw new ArgumentException($"SDK Key '{sdkKey}' is invalid.", nameof(sdkKey)); + } + var instance = Instances.GetOrCreate(sdkKey, options, out var instanceAlreadyCreated); if (instanceAlreadyCreated && configurationAction is not null) @@ -667,12 +703,6 @@ private static IConfigService DetermineConfigService(PollingMode pollingMode, Ht }; } - internal static string GetCacheKey(string sdkKey) - { - var key = $"{sdkKey}_{ConfigCatClientOptions.ConfigFileName}_{ProjectConfig.SerializationFormatVersion}"; - return key.Hash(); - } - /// public void SetDefaultUser(User user) { diff --git a/src/ConfigCatClient/ConfigCatClient.csproj b/src/ConfigCatClient/ConfigCatClient.csproj index 3bd947a3..92c695aa 100644 --- a/src/ConfigCatClient/ConfigCatClient.csproj +++ b/src/ConfigCatClient/ConfigCatClient.csproj @@ -5,7 +5,7 @@ ConfigCat.Client true false - strongname.snk + ..\ConfigCatClient.snk 0.1.0 Copyright © ConfigCat 2020 ConfigCat @@ -42,8 +42,6 @@ ConfigCat.Client.Benchmark - ConfigCat.Client.Benchmark - ConfigCat.Client.Benchmark true @@ -106,6 +104,7 @@ + diff --git a/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs b/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs index 04f33d95..0aa90926 100644 --- a/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs +++ b/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs @@ -149,7 +149,7 @@ protected virtual void OnConfigChanged(ProjectConfig newConfig) { this.Logger.Debug("config changed"); - this.Hooks.RaiseConfigChanged(newConfig.Config ?? new SettingsWithPreferences()); + this.Hooks.RaiseConfigChanged(newConfig.Config ?? new Config()); } public bool IsOffline diff --git a/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs b/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs index dfb374c9..84471b1c 100644 --- a/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs +++ b/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs @@ -9,7 +9,7 @@ namespace ConfigCat.Client.Configuration; /// public class ConfigCatClientOptions : IProvidesHooks { - internal const string ConfigFileName = "config_v5.json"; + internal const string ConfigFileName = "config_v6.json"; internal static readonly Uri BaseUrlGlobal = new("https://cdn-global.configcat.com"); diff --git a/src/ConfigCatClient/Evaluation/EvaluateContext.cs b/src/ConfigCatClient/Evaluation/EvaluateContext.cs new file mode 100644 index 00000000..8815489c --- /dev/null +++ b/src/ConfigCatClient/Evaluation/EvaluateContext.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using ConfigCat.Client.Utils; + +namespace ConfigCat.Client.Evaluation; + +internal struct EvaluateContext +{ + public readonly string Key; + public readonly Setting Setting; + public readonly IReadOnlyDictionary Settings; + + private readonly User? user; + + [MemberNotNullWhen(true, nameof(UserAttributes))] + public readonly bool IsUserAvailable => this.user is not null; + + private IReadOnlyDictionary? userAttributes; + public IReadOnlyDictionary? UserAttributes => this.userAttributes ??= this.user?.GetAllAttributes(); + + private List? visitedFlags; + public List VisitedFlags => this.visitedFlags ??= new List(); + + public bool IsMissingUserObjectLogged; + public bool IsMissingUserObjectAttributeLogged; + + public IndentedTextBuilder? LogBuilder; + + public EvaluateContext(string key, Setting setting, User? user, IReadOnlyDictionary settings) + { + this.Key = key; + this.Setting = setting; + this.user = user; + this.Settings = settings; + + this.userAttributes = null; + this.visitedFlags = null; + this.IsMissingUserObjectLogged = this.IsMissingUserObjectAttributeLogged = false; + this.LogBuilder = null; // initialized by RolloutEvaluator.Evaluate + } + + public EvaluateContext(string key, Setting setting, ref EvaluateContext dependentFlagContext) + : this(key, setting, dependentFlagContext.user, dependentFlagContext.Settings) + { + this.userAttributes = dependentFlagContext.UserAttributes; + this.visitedFlags = dependentFlagContext.VisitedFlags; // crucial to use the property here to make sure the list is created! + this.LogBuilder = dependentFlagContext.LogBuilder; + } +} diff --git a/src/ConfigCatClient/Evaluation/EvaluateLogHelper.cs b/src/ConfigCatClient/Evaluation/EvaluateLogHelper.cs new file mode 100644 index 00000000..584cad75 --- /dev/null +++ b/src/ConfigCatClient/Evaluation/EvaluateLogHelper.cs @@ -0,0 +1,349 @@ +using ConfigCat.Client.Utils; +using System.Globalization; + +namespace ConfigCat.Client.Evaluation; + +internal static class EvaluateLogHelper +{ + public const string InvalidItemPlaceholder = ""; + public const string InvalidNamePlaceholder = ""; + public const string InvalidOperatorPlaceholder = ""; + public const string InvalidReferencePlaceholder = ""; + public const string InvalidValuePlaceholder = ""; + + internal const int StringListMaxLength = 10; + + public static IndentedTextBuilder AppendEvaluationResult(this IndentedTextBuilder builder, bool result) + { + return builder.Append(result ? "true" : "false"); + } + + private static IndentedTextBuilder AppendUserCondition(this IndentedTextBuilder builder, string? comparisonAttribute, UserComparator comparator, object? comparisonValue) + { + return builder.Append($"User.{comparisonAttribute} {comparator.ToDisplayText()} '{comparisonValue ?? InvalidValuePlaceholder}'"); + } + + private static IndentedTextBuilder AppendUserCondition(this IndentedTextBuilder builder, string? comparisonAttribute, UserComparator comparator, string? comparisonValue, bool isSensitive) + { + return builder.AppendUserCondition(comparisonAttribute, comparator, !isSensitive ? (object?)comparisonValue : ""); + } + + private static IndentedTextBuilder AppendUserCondition(this IndentedTextBuilder builder, string? comparisonAttribute, UserComparator comparator, string[]? comparisonValue, bool isSensitive) + { + if (comparisonValue is null) + { + return builder.AppendUserCondition(comparisonAttribute, comparator, (object?)null); + } + + const string valueText = "value", valuesText = "values"; + + if (isSensitive) + { + return builder.Append($"User.{comparisonAttribute} {comparator.ToDisplayText()} [<{comparisonValue.Length} hashed {(comparisonValue.Length == 1 ? valueText : valuesText)}>]"); + } + else + { + var comparisonValueFormatter = new StringListFormatter(comparisonValue, StringListMaxLength, getOmittedItemsText: static count => + $", ... <{count.ToString(CultureInfo.InvariantCulture)} more {(count == 1 ? valueText : valuesText)}>"); + + return builder.Append($"User.{comparisonAttribute} {comparator.ToDisplayText()} [{comparisonValueFormatter}]"); + } + } + + private static IndentedTextBuilder AppendUserCondition(this IndentedTextBuilder builder, string? comparisonAttribute, UserComparator comparator, double? comparisonValue, bool isDateTime = false) + { + if (comparisonValue is null) + { + return builder.AppendUserCondition(comparisonAttribute, comparator, (object?)null); + } + + return isDateTime && DateTimeUtils.TryConvertFromUnixTimeSeconds(comparisonValue.Value, out var dateTime) + ? builder.Append($"User.{comparisonAttribute} {comparator.ToDisplayText()} '{comparisonValue.Value}' ({dateTime:yyyy-MM-dd'T'HH:mm:ss.fffK} UTC)") + : builder.Append($"User.{comparisonAttribute} {comparator.ToDisplayText()} '{comparisonValue.Value}'"); + } + + public static IndentedTextBuilder AppendUserCondition(this IndentedTextBuilder builder, UserCondition condition) + { + return condition.Comparator switch + { + UserComparator.IsOneOf or + UserComparator.IsNotOneOf or + UserComparator.ContainsAnyOf or + UserComparator.NotContainsAnyOf or + UserComparator.SemVerIsOneOf or + UserComparator.SemVerIsNotOneOf or + UserComparator.TextStartsWithAnyOf or + UserComparator.TextNotStartsWithAnyOf or + UserComparator.TextEndsWithAnyOf or + UserComparator.TextNotEndsWithAnyOf or + UserComparator.ArrayContainsAnyOf or + UserComparator.ArrayNotContainsAnyOf => + builder.AppendUserCondition(condition.ComparisonAttribute, condition.Comparator, condition.StringListValue, isSensitive: false), + + UserComparator.SemVerLess or + UserComparator.SemVerLessOrEquals or + UserComparator.SemVerGreater or + UserComparator.SemVerGreaterOrEquals or + UserComparator.TextEquals or + UserComparator.TextNotEquals => + builder.AppendUserCondition(condition.ComparisonAttribute, condition.Comparator, condition.StringValue, isSensitive: false), + + UserComparator.NumberEquals or + UserComparator.NumberNotEquals or + UserComparator.NumberLess or + UserComparator.NumberLessOrEquals or + UserComparator.NumberGreater or + UserComparator.NumberGreaterOrEquals => + builder.AppendUserCondition(condition.ComparisonAttribute, condition.Comparator, condition.DoubleValue), + + UserComparator.SensitiveIsOneOf or + UserComparator.SensitiveIsNotOneOf or + UserComparator.SensitiveTextStartsWithAnyOf or + UserComparator.SensitiveTextNotStartsWithAnyOf or + UserComparator.SensitiveTextEndsWithAnyOf or + UserComparator.SensitiveTextNotEndsWithAnyOf or + UserComparator.SensitiveArrayContainsAnyOf or + UserComparator.SensitiveArrayNotContainsAnyOf => + builder.AppendUserCondition(condition.ComparisonAttribute, condition.Comparator, condition.StringListValue, isSensitive: true), + + UserComparator.DateTimeBefore or + UserComparator.DateTimeAfter => + builder.AppendUserCondition(condition.ComparisonAttribute, condition.Comparator, condition.DoubleValue, isDateTime: true), + + UserComparator.SensitiveTextEquals or + UserComparator.SensitiveTextNotEquals => + builder.AppendUserCondition(condition.ComparisonAttribute, condition.Comparator, condition.StringValue, isSensitive: true), + + _ => + builder.AppendUserCondition(condition.ComparisonAttribute, condition.Comparator, condition.GetComparisonValue(throwIfInvalid: false)), + }; + } + + public static IndentedTextBuilder AppendPrerequisiteFlagCondition(this IndentedTextBuilder builder, PrerequisiteFlagCondition condition) + { + var prerequisiteFlagKey = condition.PrerequisiteFlagKey; + var comparator = condition.Comparator; + var comparisonValue = condition.ComparisonValue.GetValue(throwIfInvalid: false); + + return builder.Append($"Flag '{prerequisiteFlagKey}' {comparator.ToDisplayText()} '{comparisonValue ?? InvalidValuePlaceholder}'"); + } + + public static IndentedTextBuilder AppendSegmentCondition(this IndentedTextBuilder builder, SegmentCondition condition) + { + var segment = condition.Segment; + var comparator = condition.Comparator; + + var segmentName = segment?.Name ?? + (segment is null ? InvalidReferencePlaceholder : InvalidNamePlaceholder); + + return builder.Append($"User {comparator.ToDisplayText()} '{segmentName}'"); + } + + public static IndentedTextBuilder AppendConditionConsequence(this IndentedTextBuilder builder, bool result) + { + builder.Append(" => ").AppendEvaluationResult(result); + return result ? builder : builder.Append(", skipping the remaining AND conditions"); + } + + private static IndentedTextBuilder AppendConditions(this IndentedTextBuilder builder, TCondition[] conditions) + where TCondition : IConditionProvider + { + for (var i = 0; i < conditions.Length; i++) + { + builder.IncreaseIndent(); + + if (i > 0) + { + builder.NewLine("AND "); + } + + _ = conditions[i].GetCondition(throwIfInvalid: false) switch + { + UserCondition userCondition => builder.AppendUserCondition(userCondition), + PrerequisiteFlagCondition prerequisiteFlagCondition => builder.AppendPrerequisiteFlagCondition(prerequisiteFlagCondition), + SegmentCondition segmentCondition => builder.AppendSegmentCondition(segmentCondition), + _ => builder.Append(InvalidItemPlaceholder), + }; + + builder.DecreaseIndent(); + } + + return builder; + } + + public static IndentedTextBuilder AppendPercentageOption(this IndentedTextBuilder builder, PercentageOption percentageOptions, string? userAttributeName = null) + { + var percentage = percentageOptions.Percentage; + var value = percentageOptions.Value; + + return userAttributeName switch + { + null => builder.Append($"{percentage}%: '{value}'"), + nameof(User.Identifier) => builder.Append($"{percentage}% of users: '{value}'"), + _ => builder.Append($"{percentage}% of all {userAttributeName} attributes: '{value}'") + }; + } + + private static IndentedTextBuilder AppendPercentageOptions(this IndentedTextBuilder builder, PercentageOption?[] percentageOptions, string? percentageOptionsAttribute = null) + { + for (var i = 0; i < percentageOptions.Length; i++) + { + if (i > 0) + { + builder.NewLine(); + } + + _ = percentageOptions[i] is { } percentageOption + ? builder.AppendPercentageOption(percentageOption, percentageOptionsAttribute) + : builder.Append(InvalidItemPlaceholder); + } + + return builder; + } + + private static IndentedTextBuilder AppendTargetingRuleThenPart(this IndentedTextBuilder builder, TargetingRule targetingRule, bool newLine, bool appendPercentageOptions = false, string? percentageOptionsAttribute = null) + { + (newLine ? builder.NewLine() : builder.Append(" ")) + .Append("THEN"); + + var percentageOptions = targetingRule.PercentageOptions; + if (percentageOptions is not { Length: > 0 }) + { + return builder.Append($" '{targetingRule.SimpleValue?.Value ?? default}'"); + } + else if (!appendPercentageOptions) + { + return builder.Append(" % options"); + } + else + { + builder.IncreaseIndent(); + builder.NewLine().AppendPercentageOptions(percentageOptions, percentageOptionsAttribute); + return builder.DecreaseIndent(); + } + } + + public static IndentedTextBuilder AppendTargetingRuleConsequence(this IndentedTextBuilder builder, TargetingRule targetingRule, string? error, bool isMatch, bool newLine) + { + builder.IncreaseIndent(); + + builder.AppendTargetingRuleThenPart(targetingRule, newLine) + .Append(" => ").Append(error ?? (isMatch ? "MATCH, applying rule" : "no match")); + + return builder.DecreaseIndent(); + } + + public static IndentedTextBuilder AppendTargetingRule(this IndentedTextBuilder builder, TargetingRule targetingRule, string? percentageOptionsAttribute = null) + { + var conditions = targetingRule.Conditions; + + return builder.Append("IF ") + .AppendConditions(conditions) + .AppendTargetingRuleThenPart(targetingRule, newLine: true, appendPercentageOptions: true, percentageOptionsAttribute); + } + + private static IndentedTextBuilder AppendTargetingRules(this IndentedTextBuilder builder, TargetingRule[] targetingRules, string percentageOptionsAttribute) + { + for (var i = 0; i < targetingRules.Length; i++) + { + if (i > 0) + { + builder.NewLine("ELSE "); + } + + _ = targetingRules[i] is { } targetingRule + ? builder.AppendTargetingRule(targetingRule, percentageOptionsAttribute) + : builder.Append(InvalidItemPlaceholder); + } + + return builder; + } + + public static IndentedTextBuilder AppendSetting(this IndentedTextBuilder builder, Setting setting) + { + var targetingRules = setting.TargetingRules; + var percentageOptions = setting.PercentageOptions; + var percentageOptionsAttribute = setting.PercentageOptionsAttribute ?? nameof(User.Identifier); + var value = setting.Value; + + builder.AppendTargetingRules(targetingRules, percentageOptionsAttribute); + + if (percentageOptions.Length > 0) + { + if (targetingRules.Length > 0) + { + builder.NewLine("OTHERWISE"); + builder.IncreaseIndent(); + builder.NewLine().AppendPercentageOptions(percentageOptions, percentageOptionsAttribute); + builder.DecreaseIndent(); + } + else + { + builder.AppendPercentageOptions(percentageOptions, percentageOptionsAttribute); + } + + return builder.NewLine().Append($"To unidentified: '{value}'"); + } + else if (targetingRules.Length > 0) + { + return builder.NewLine().Append($"To all others: '{value}'"); + } + else + { + return builder.Append($"To all users: '{value}'"); + } + } + + public static IndentedTextBuilder AppendSegment(this IndentedTextBuilder builder, Segment segment) + { + return builder.AppendConditions(segment.Conditions); + } + + public static string ToDisplayText(this UserComparator comparator) + { + return comparator switch + { + UserComparator.IsOneOf or UserComparator.SensitiveIsOneOf or UserComparator.SemVerIsOneOf => "IS ONE OF", + UserComparator.IsNotOneOf or UserComparator.SensitiveIsNotOneOf or UserComparator.SemVerIsNotOneOf => "IS NOT ONE OF", + UserComparator.ContainsAnyOf => "CONTAINS ANY OF", + UserComparator.NotContainsAnyOf => "NOT CONTAINS ANY OF", + UserComparator.SemVerLess or UserComparator.NumberLess => "<", + UserComparator.SemVerLessOrEquals or UserComparator.NumberLessOrEquals => "<=", + UserComparator.SemVerGreater or UserComparator.NumberGreater => ">", + UserComparator.SemVerGreaterOrEquals or UserComparator.NumberGreaterOrEquals => ">=", + UserComparator.NumberEquals => "=", + UserComparator.NumberNotEquals => "!=", + UserComparator.DateTimeBefore => "BEFORE", + UserComparator.DateTimeAfter => "AFTER", + UserComparator.TextEquals or UserComparator.SensitiveTextEquals => "EQUALS", + UserComparator.TextNotEquals or UserComparator.SensitiveTextNotEquals => "NOT EQUALS", + UserComparator.TextStartsWithAnyOf or UserComparator.SensitiveTextStartsWithAnyOf => "STARTS WITH ANY OF", + UserComparator.TextNotStartsWithAnyOf or UserComparator.SensitiveTextNotStartsWithAnyOf => "NOT STARTS WITH ANY OF", + UserComparator.TextEndsWithAnyOf or UserComparator.SensitiveTextEndsWithAnyOf => "ENDS WITH ANY OF", + UserComparator.TextNotEndsWithAnyOf or UserComparator.SensitiveTextNotEndsWithAnyOf => "NOT ENDS WITH ANY OF", + UserComparator.ArrayContainsAnyOf or UserComparator.SensitiveArrayContainsAnyOf => "ARRAY CONTAINS ANY OF", + UserComparator.ArrayNotContainsAnyOf or UserComparator.SensitiveArrayNotContainsAnyOf => "ARRAY NOT CONTAINS ANY OF", + _ => InvalidOperatorPlaceholder + }; + } + + public static string ToDisplayText(this PrerequisiteFlagComparator comparator) + { + return comparator switch + { + PrerequisiteFlagComparator.Equals => "EQUALS", + PrerequisiteFlagComparator.NotEquals => "NOT EQUALS", + _ => InvalidOperatorPlaceholder + }; + } + + public static string ToDisplayText(this SegmentComparator comparator) + { + return comparator switch + { + SegmentComparator.IsIn => "IS IN SEGMENT", + SegmentComparator.IsNotIn => "IS NOT IN SEGMENT", + _ => InvalidOperatorPlaceholder + }; + } +} diff --git a/src/ConfigCatClient/Evaluation/EvaluateLogger.cs b/src/ConfigCatClient/Evaluation/EvaluateLogger.cs deleted file mode 100644 index 9ec00058..00000000 --- a/src/ConfigCatClient/Evaluation/EvaluateLogger.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using System.Text; - -namespace ConfigCat.Client.Evaluation; - -internal sealed class EvaluateLogger -{ - public User? User { get; set; } - - public string? ReturnValue { get; set; } - - public string KeyName { get; set; } = null!; - - private ICollection Operations { get; } = new List(); - - public string? VariationId { get; set; } - - public void Log(string message) - { - Operations.Add(message); - } - - public override string ToString() - { - var result = new StringBuilder(); - - result.AppendLine($"Evaluating '{KeyName}'"); - foreach (var o in Operations) - { - result.AppendLine(" " + o); - } - result.Append($" Returning '{ReturnValue}' (VariationId: '{VariationId ?? "null"}')."); - - return result.ToString(); - } -} diff --git a/src/ConfigCatClient/Evaluation/EvaluateResult.cs b/src/ConfigCatClient/Evaluation/EvaluateResult.cs index acd33a95..94a33999 100644 --- a/src/ConfigCatClient/Evaluation/EvaluateResult.cs +++ b/src/ConfigCatClient/Evaluation/EvaluateResult.cs @@ -1,23 +1,18 @@ -#if USE_NEWTONSOFT_JSON -using JsonValue = Newtonsoft.Json.Linq.JValue; -#else -using JsonValue = System.Text.Json.JsonElement; -#endif - namespace ConfigCat.Client.Evaluation; internal readonly struct EvaluateResult { - public EvaluateResult(JsonValue value, string? variationId, RolloutRule? matchedTargetingRule = null, RolloutPercentageItem? matchedPercentageOption = null) + public EvaluateResult(SettingValueContainer selectedValue, TargetingRule? matchedTargetingRule = null, PercentageOption? matchedPercentageOption = null) { - Value = value; - VariationId = variationId; - MatchedTargetingRule = matchedTargetingRule; - MatchedPercentageOption = matchedPercentageOption; + this.selectedValue = selectedValue; + this.MatchedTargetingRule = matchedTargetingRule; + this.MatchedPercentageOption = matchedPercentageOption; } - public JsonValue Value { get; } - public string? VariationId { get; } - public RolloutRule? MatchedTargetingRule { get; } - public RolloutPercentageItem? MatchedPercentageOption { get; } + private readonly SettingValueContainer selectedValue; + public SettingValue Value => this.selectedValue.Value; + public string? VariationId => this.selectedValue.VariationId; + + public readonly TargetingRule? MatchedTargetingRule; + public readonly PercentageOption? MatchedPercentageOption; } diff --git a/src/ConfigCatClient/Evaluation/EvaluationDetails.cs b/src/ConfigCatClient/Evaluation/EvaluationDetails.cs index 5a87eb95..5cee4682 100644 --- a/src/ConfigCatClient/Evaluation/EvaluationDetails.cs +++ b/src/ConfigCatClient/Evaluation/EvaluationDetails.cs @@ -1,13 +1,6 @@ using System; -using System.Diagnostics; using ConfigCat.Client.Evaluation; -#if USE_NEWTONSOFT_JSON -using JsonValue = Newtonsoft.Json.Linq.JValue; -#else -using JsonValue = System.Text.Json.JsonElement; -#endif - namespace ConfigCat.Client; /// @@ -15,72 +8,22 @@ namespace ConfigCat.Client; /// public abstract record class EvaluationDetails { - private static void EnsureValidSettingValue(JsonValue value, ref SettingType settingType, string? unsupportedTypeError) - { - // Setting type is not known (it's not present in the config JSON, it's an unsupported value coming from a flag override, etc.)? - if (settingType == SettingType.Unknown) - { - // Let's try to infer it from the JSON value. - settingType = value.DetermineSettingType(); - - if (settingType == SettingType.Unknown) - { - throw new ArgumentException(unsupportedTypeError ?? $"Setting value '{value}' is of an unsupported type.", nameof(value)); - } - } - } - - private static EvaluationDetails Create(string key, JsonValue value) - { - return new EvaluationDetails(key, value.ConvertTo()); - } - - internal static EvaluationDetails FromEvaluateResult(string key, in EvaluateResult evaluateResult, SettingType settingType, string? unsupportedTypeError, + internal static EvaluationDetails FromEvaluateResult(string key, TValue value, in EvaluateResult evaluateResult, DateTime? fetchTime, User? user) { - // NOTE: We've already checked earlier in the call chain that TValue is an allowed type (see also TypeExtensions.EnsureSupportedSettingClrType). - Debug.Assert(typeof(TValue) == typeof(object) || typeof(TValue).ToSettingType() != SettingType.Unknown, "Type is not supported."); - - var value = evaluateResult.Value; - EnsureValidSettingValue(value, ref settingType, unsupportedTypeError); - - EvaluationDetails instance; - - if (typeof(TValue) != typeof(object)) + var instance = new EvaluationDetails(key, value) { - if (settingType != typeof(TValue).ToSettingType()) - { - throw new InvalidOperationException($"The type of a setting must match the type of the setting's default value.{Environment.NewLine}Setting's type was {settingType} but the default value's type was {typeof(TValue)}.{Environment.NewLine}Please use a default value which corresponds to the setting type {settingType}."); - } + User = user, + VariationId = evaluateResult.VariationId, + MatchedTargetingRule = evaluateResult.MatchedTargetingRule, + MatchedPercentageOption = evaluateResult.MatchedPercentageOption + }; - instance = Create(key, value); - } - else + if (fetchTime is not null) { - EvaluationDetails evaluationDetails = new EvaluationDetails(key, value.ConvertToObject(settingType)); - instance = (EvaluationDetails)evaluationDetails; + instance.FetchTime = fetchTime.Value; } - instance.Initialize(evaluateResult, fetchTime, user); - return instance; - } - - internal static EvaluationDetails FromEvaluateResult(string key, in EvaluateResult evaluateResult, SettingType settingType, string? unsupportedTypeError, - DateTime? fetchTime, User? user) - { - var value = evaluateResult.Value; - EnsureValidSettingValue(value, ref settingType, unsupportedTypeError); - - EvaluationDetails instance = settingType switch - { - SettingType.Boolean => Create(key, value), - SettingType.String => Create(key, value), - SettingType.Int => Create(key, value), - SettingType.Double => Create(key, value), - _ => throw new ArgumentOutOfRangeException(nameof(settingType), settingType, null) - }; - - instance.Initialize(evaluateResult, fetchTime, user); return instance; } @@ -108,18 +51,6 @@ private protected EvaluationDetails(string key) Key = key; } - private void Initialize(in EvaluateResult evaluateResult, DateTime? fetchTime, User? user) - { - VariationId = evaluateResult.VariationId; - if (fetchTime is not null) - { - FetchTime = fetchTime.Value; - } - User = user; - MatchedEvaluationRule = evaluateResult.MatchedTargetingRule; - MatchedEvaluationPercentageRule = evaluateResult.MatchedPercentageOption; - } - /// /// Key of the feature flag or setting. /// @@ -166,12 +97,12 @@ private void Initialize(in EvaluateResult evaluateResult, DateTime? fetchTime, U /// /// The targeting rule which was used to select the evaluated value (if any). /// - public ITargetingRule? MatchedEvaluationRule { get; set; } + public ITargetingRule? MatchedTargetingRule { get; set; } /// /// The percentage option which was used to select the evaluated value (if any). /// - public IPercentageOption? MatchedEvaluationPercentageRule { get; set; } + public IPercentageOption? MatchedPercentageOption { get; set; } } /// diff --git a/src/ConfigCatClient/Evaluation/IRolloutEvaluator.cs b/src/ConfigCatClient/Evaluation/IRolloutEvaluator.cs index 37b1d355..dc4954c9 100644 --- a/src/ConfigCatClient/Evaluation/IRolloutEvaluator.cs +++ b/src/ConfigCatClient/Evaluation/IRolloutEvaluator.cs @@ -1,6 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + namespace ConfigCat.Client.Evaluation; internal interface IRolloutEvaluator { - EvaluateResult Evaluate(Setting setting, string key, string? logDefaultValue, User? user); + EvaluateResult Evaluate(T defaultValue, ref EvaluateContext context, [NotNull] out T returnValue); } diff --git a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs index 25006fa4..831c8493 100644 --- a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs +++ b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs @@ -1,15 +1,21 @@ using System; -using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Linq; +using System.Text; +using ConfigCat.Client.Utils; using ConfigCat.Client.Versioning; -using static System.FormattableString; - namespace ConfigCat.Client.Evaluation; internal sealed class RolloutEvaluator : IRolloutEvaluator { + internal const string MissingUserObjectError = "cannot evaluate, User Object is missing"; + internal const string MissingUserAttributeError = "cannot evaluate, the User.{0} attribute is missing"; + internal const string InvalidUserAttributeError = "cannot evaluate, the User.{0} attribute is invalid ({1})"; + + internal const string TargetingRuleIgnoredMessage = "The current targeting rule is ignored and the evaluation continues with the next rule."; + private readonly LoggerWrapper logger; public RolloutEvaluator(LoggerWrapper logger) @@ -17,341 +23,933 @@ public RolloutEvaluator(LoggerWrapper logger) this.logger = logger; } - public EvaluateResult Evaluate(Setting setting, string key, string? logDefaultValue, User? user) + public EvaluateResult Evaluate(T defaultValue, ref EvaluateContext context, [NotNull] out T returnValue) { - var evaluateLog = new EvaluateLogger + ref var logBuilder = ref context.LogBuilder; + + // Building the evaluation log is expensive, so let's not do it if it wouldn't be logged anyway. + if (this.logger.IsEnabled(LogLevel.Info)) { - ReturnValue = logDefaultValue, - User = user, - KeyName = key, - VariationId = null - }; + logBuilder = new IndentedTextBuilder(); + + logBuilder.Append($"Evaluating '{context.Key}'"); + + if (context.IsUserAvailable) + { + logBuilder.Append($" for User '{context.UserAttributes.Serialize()}'"); + } + + logBuilder.IncreaseIndent(); + } + returnValue = default!; try { - EvaluateResult evaluateResult; + EvaluateResult result; - if (user is not null) + if (typeof(T) != typeof(object)) { - // evaluate targeting rules + var expectedSettingType = typeof(T).ToSettingType(); - if (TryEvaluateRules(setting.RolloutRules, user, evaluateLog, out evaluateResult)) - { - evaluateLog.ReturnValue = evaluateResult.Value.ToString(); - evaluateLog.VariationId = evaluateResult.VariationId; + // NOTE: We've already checked earlier in the call chain that T is an allowed type (see also TypeExtensions.EnsureSupportedSettingClrType). + Debug.Assert(expectedSettingType != Setting.UnknownType, "Type is not supported."); - return evaluateResult; + // context.Setting.SettingType can be unknown in two cases: + // 1. when the setting type is missing from the config JSON (which should occur in the case of a full config JSON flag override only) or + // 2. when the setting comes from a non-full config JSON flag override and has an unsupported value (see also ObjectExtensions.ToSetting). + // The latter case is handled by SettingValue.GetValue below. + if (context.Setting.SettingType != Setting.UnknownType && context.Setting.SettingType != expectedSettingType) + { + throw new InvalidOperationException( + "The type of a setting must match the type of the specified default value. " + + $"Setting's type was {context.Setting.SettingType} but the default value's type was {typeof(T)}. " + + $"Please use a default value which corresponds to the setting type {context.Setting.SettingType}. " + + "Learn more: https://configcat.com/docs/sdk-reference/dotnet/#setting-type-mapping"); } - // evaluate percentage options - - if (TryEvaluatePercentageRules(setting.RolloutPercentageItems, key, user, evaluateLog, out evaluateResult)) - { - evaluateLog.ReturnValue = evaluateResult.Value.ToString(); - evaluateLog.VariationId = evaluateResult.VariationId; + result = EvaluateSetting(ref context); - return evaluateResult; - } + returnValue = result.Value.GetValue(expectedSettingType)!; } - else if (setting.RolloutRules.Any() || setting.RolloutPercentageItems.Any()) + else { - this.logger.TargetingIsNotPossible(key); - } + result = EvaluateSetting(ref context); - // regular evaluate + returnValue = (T)(context.Setting.SettingType != Setting.UnknownType + ? result.Value.GetValue(context.Setting.SettingType)! + : result.Value.GetValue()!); + } - evaluateLog.ReturnValue = setting.Value.ToString(); - evaluateLog.VariationId = setting.VariationId; + return result; + } + catch + { + logBuilder?.ResetIndent().IncreaseIndent(); - evaluateResult = new EvaluateResult(setting.Value, setting.VariationId); - return evaluateResult; + returnValue = defaultValue; + throw; } finally { - this.logger.SettingEvaluated(evaluateLog); + if (logBuilder is not null) + { + logBuilder.NewLine().Append($"Returning '{returnValue}'."); + + logBuilder.DecreaseIndent(); + + this.logger.SettingEvaluated(logBuilder.ToString()); + } } } - private static bool TryEvaluatePercentageRules(ICollection rolloutPercentageItems, string key, User user, EvaluateLogger evaluateLog, out EvaluateResult result) + private EvaluateResult EvaluateSetting(ref EvaluateContext context) { - if (rolloutPercentageItems.Count > 0) + var targetingRules = context.Setting.TargetingRules; + if (targetingRules.Length > 0 && TryEvaluateTargetingRules(targetingRules, ref context, out var evaluateResult)) { - var hashCandidate = key + user.Identifier; + return evaluateResult; + } + + var percentageOptions = context.Setting.PercentageOptions; + if (percentageOptions.Length > 0 && TryEvaluatePercentageOptions(percentageOptions, targetingRule: null, ref context, out evaluateResult)) + { + return evaluateResult; + } - var hashValue = hashCandidate.Hash().Substring(0, 7); + evaluateResult = new EvaluateResult(context.Setting); + return evaluateResult; + } - var hashScale = int.Parse(hashValue, NumberStyles.HexNumber) % 100; - evaluateLog.Log(Invariant($"Applying the % option that matches the User's pseudo-random '{hashScale}' (this value is sticky and consistent across all SDKs):")); + private bool TryEvaluateTargetingRules(TargetingRule[] targetingRules, ref EvaluateContext context, out EvaluateResult result) + { + var logBuilder = context.LogBuilder; - var bucket = 0; + logBuilder?.NewLine("Evaluating targeting rules and applying the first match if any:"); - foreach (var percentageRule in rolloutPercentageItems.OrderBy(o => o.Order)) - { - bucket += percentageRule.Percentage; + for (var i = 0; i < targetingRules.Length; i++) + { + var targetingRule = targetingRules[i]; + var conditions = targetingRule.Conditions; - if (hashScale >= bucket) + var isMatch = EvaluateConditions(conditions, targetingRule, contextSalt: context.Key, ref context, out var error); + if (!isMatch) + { + if (error is not null) { - evaluateLog.Log(Invariant($" - % option: [IF {bucket} > {hashScale} THEN '{percentageRule.Value}'] => no match")); - continue; + logBuilder? + .IncreaseIndent() + .NewLine(TargetingRuleIgnoredMessage) + .DecreaseIndent(); } - result = new EvaluateResult(percentageRule.Value, percentageRule.VariationId, matchedPercentageOption: percentageRule); - evaluateLog.Log(Invariant($" - % option: [IF {bucket} > {hashScale} THEN '{percentageRule.Value}'] => MATCH, applying % option")); + continue; + } + + if (targetingRule.SimpleValue is { } simpleValue) + { + result = new EvaluateResult(simpleValue, matchedTargetingRule: targetingRule); + return true; + } + + var percentageOptions = targetingRule.PercentageOptions; + if (percentageOptions is not { Length: > 0 }) + { + throw new InvalidOperationException("Targeting rule THEN part is missing or invalid."); + } + + logBuilder?.IncreaseIndent(); + + if (TryEvaluatePercentageOptions(percentageOptions, targetingRule, ref context, out result)) + { + logBuilder?.DecreaseIndent(); return true; } + + logBuilder? + .NewLine(TargetingRuleIgnoredMessage) + .DecreaseIndent(); } result = default; return false; } - private static bool TryEvaluateRules(ICollection rules, User user, EvaluateLogger logger, out EvaluateResult result) + private bool TryEvaluatePercentageOptions(PercentageOption[] percentageOptions, TargetingRule? targetingRule, ref EvaluateContext context, out EvaluateResult result) { - if (rules.Count > 0) + var logBuilder = context.LogBuilder; + + if (!context.IsUserAvailable) + { + logBuilder?.NewLine("Skipping % options because the User Object is missing."); + + if (!context.IsMissingUserObjectLogged) + { + this.logger.UserObjectIsMissing(context.Key); + context.IsMissingUserObjectLogged = true; + } + + result = default; + return false; + } + + var percentageOptionsAttributeName = context.Setting.PercentageOptionsAttribute ?? nameof(User.Identifier); + + if (!context.UserAttributes.TryGetValue(percentageOptionsAttributeName, out var percentageOptionsAttributeValue)) + { + logBuilder?.NewLine().Append($"Skipping % options because the User.{percentageOptionsAttributeName} attribute is missing."); + + if (!context.IsMissingUserObjectAttributeLogged) + { + this.logger.UserObjectAttributeIsMissing(context.Key, percentageOptionsAttributeName); + context.IsMissingUserObjectAttributeLogged = true; + } + + result = default; + return false; + } + + logBuilder?.NewLine().Append($"Evaluating % options based on the User.{percentageOptionsAttributeName} attribute:"); + + var sha1 = (context.Key + UserAttributeValueToString(percentageOptionsAttributeValue)).Sha1(); + + // NOTE: this is equivalent to hashValue = int.Parse(sha1.ToHexString().Substring(0, 7), NumberStyles.HexNumber) % 100; + var hashValue = + ((sha1[0] << 20) + | (sha1[1] << 12) + | (sha1[2] << 4) + | (sha1[3] >> 4)) % 100; + + logBuilder?.NewLine().Append($"- Computing hash in the [0..99] range from User.{percentageOptionsAttributeName} => {hashValue} (this value is sticky and consistent across all SDKs)"); + + var bucket = 0; + + for (var i = 0; i < percentageOptions.Length; i++) { - logger.Log(Invariant($"Applying the first targeting rule that matches the User '{user.Serialize()}':")); - foreach (var rule in rules.OrderBy(o => o.Order)) + var percentageOption = percentageOptions[i]; + + bucket += percentageOption.Percentage; + + if (hashValue >= bucket) { - result = new EvaluateResult(rule.Value, rule.VariationId, matchedTargetingRule: rule); + continue; + } + + var percentageOptionValue = percentageOption.Value.GetValue(throwIfInvalid: false); + logBuilder?.NewLine().Append($"- Hash value {hashValue} selects % option {i + 1} ({percentageOption.Percentage}%), '{percentageOptionValue ?? EvaluateLogHelper.InvalidValuePlaceholder}'."); - var l = Invariant($" - rule: [IF User.{rule.ComparisonAttribute} {RolloutRule.FormatComparator(rule.Comparator)} '{rule.ComparisonValue}' THEN {rule.Value}] => "); - if (!user.AllAttributes.ContainsKey(rule.ComparisonAttribute)) + result = new EvaluateResult(percentageOption, matchedTargetingRule: targetingRule, matchedPercentageOption: percentageOption); + return true; + } + + throw new InvalidOperationException("Sum of percentage option percentages are less than 100."); + } + + private bool EvaluateConditions(TCondition[] conditions, TargetingRule? targetingRule, string contextSalt, ref EvaluateContext context, out string? error) + where TCondition : IConditionProvider + { + error = null; + var result = true; + + var logBuilder = context.LogBuilder; + var newLineBeforeThen = false; + + logBuilder?.NewLine("- "); + + for (var i = 0; i < conditions.Length; i++) + { + var condition = conditions[i].GetCondition(); + + if (logBuilder is not null) + { + if (i == 0) { - logger.Log(l + "no match"); - continue; + logBuilder + .Append("IF ") + .IncreaseIndent(); } - - var comparisonAttributeValue = user.AllAttributes[rule.ComparisonAttribute]!; - if (string.IsNullOrEmpty(comparisonAttributeValue)) + else { - logger.Log(l + "no match"); - continue; + logBuilder + .IncreaseIndent() + .NewLine("AND "); } + } + + bool conditionResult; + + switch (condition) + { + case UserCondition userCondition: + conditionResult = EvaluateUserCondition(userCondition, contextSalt, ref context, out error); + newLineBeforeThen = conditions.Length > 1; + break; + + case PrerequisiteFlagCondition prerequisiteFlagCondition: + conditionResult = EvaluatePrerequisiteFlagCondition(prerequisiteFlagCondition, ref context, out error); + newLineBeforeThen = true; + break; + + case SegmentCondition segmentCondition: + conditionResult = EvaluateSegmentCondition(segmentCondition, ref context, out error); + newLineBeforeThen = error is null || error != MissingUserObjectError || conditions.Length > 1; + break; + + default: + throw new InvalidOperationException(); // execution should never get here + } - switch (rule.Comparator) + if (logBuilder is not null) + { + if (targetingRule is null || conditions.Length > 1) { - case Comparator.In: + logBuilder.AppendConditionConsequence(conditionResult); + } - if (rule.ComparisonValue - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(t => t.Trim()) - .Contains(comparisonAttributeValue)) - { - logger.Log(l + "MATCH, applying rule"); + logBuilder.DecreaseIndent(); + } - return true; - } + if (!conditionResult) + { + result = false; + break; + } + else + { + Debug.Assert(error is null, "Unexpected error reported by condition evaluation."); + } + } - logger.Log(l + "no match"); + if (targetingRule is not null) + { + logBuilder?.AppendTargetingRuleConsequence(targetingRule, error, result, newLineBeforeThen); + } - break; + return result; + } - case Comparator.NotIn: + private bool EvaluateUserCondition(UserCondition condition, string contextSalt, ref EvaluateContext context, out string? error) + { + error = null; - if (!rule.ComparisonValue - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(t => t.Trim()) - .Contains(comparisonAttributeValue)) - { - logger.Log(l + "MATCH, applying rule"); + var logBuilder = context.LogBuilder; + logBuilder?.AppendUserCondition(condition); - return true; - } + if (!context.IsUserAvailable) + { + if (!context.IsMissingUserObjectLogged) + { + this.logger.UserObjectIsMissing(context.Key); + context.IsMissingUserObjectLogged = true; + } - logger.Log(l + "no match"); + error = MissingUserObjectError; + return false; + } - break; - case Comparator.Contains: + var userAttributeName = condition.ComparisonAttribute ?? throw new InvalidOperationException("Comparison attribute name is missing."); - if (comparisonAttributeValue.Contains(rule.ComparisonValue)) - { - logger.Log(l + "MATCH, applying rule"); + if (!context.UserAttributes.TryGetValue(userAttributeName, out var userAttributeValue) || userAttributeValue is string { Length: 0 }) + { + this.logger.UserObjectAttributeIsMissing(condition.ToString(), context.Key, userAttributeName); + error = string.Format(CultureInfo.InvariantCulture, MissingUserAttributeError, userAttributeName); + return false; + } - return true; - } + var comparator = condition.Comparator; + switch (comparator) + { + case UserComparator.TextEquals: + case UserComparator.TextNotEquals: + var text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateTextEquals(text, condition.StringValue, negate: comparator == UserComparator.TextNotEquals); + + case UserComparator.SensitiveTextEquals: + case UserComparator.SensitiveTextNotEquals: + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateSensitiveTextEquals(text, condition.StringValue, + EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, negate: comparator == UserComparator.SensitiveTextNotEquals); + + case UserComparator.IsOneOf: + case UserComparator.IsNotOneOf: + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateIsOneOf(text, condition.StringListValue, negate: comparator == UserComparator.IsNotOneOf); + + case UserComparator.SensitiveIsOneOf: + case UserComparator.SensitiveIsNotOneOf: + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateSensitiveIsOneOf(text, condition.StringListValue, + EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, negate: comparator == UserComparator.SensitiveIsNotOneOf); + + case UserComparator.TextStartsWithAnyOf: + case UserComparator.TextNotStartsWithAnyOf: + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateTextSliceEqualsAnyOf(text, condition.StringListValue, startsWith: true, negate: comparator == UserComparator.TextNotStartsWithAnyOf); + + case UserComparator.SensitiveTextStartsWithAnyOf: + case UserComparator.SensitiveTextNotStartsWithAnyOf: + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateSensitiveTextSliceEqualsAnyOf(text, condition.StringListValue, + EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, startsWith: true, negate: comparator == UserComparator.SensitiveTextNotStartsWithAnyOf); + + case UserComparator.TextEndsWithAnyOf: + case UserComparator.TextNotEndsWithAnyOf: + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateTextSliceEqualsAnyOf(text, condition.StringListValue, startsWith: false, negate: comparator == UserComparator.TextNotEndsWithAnyOf); + + case UserComparator.SensitiveTextEndsWithAnyOf: + case UserComparator.SensitiveTextNotEndsWithAnyOf: + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateSensitiveTextSliceEqualsAnyOf(text, condition.StringListValue, + EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, startsWith: false, negate: comparator == UserComparator.SensitiveTextNotEndsWithAnyOf); + + case UserComparator.ContainsAnyOf: + case UserComparator.NotContainsAnyOf: + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateContainsAnyOf(text, condition.StringListValue, negate: comparator == UserComparator.NotContainsAnyOf); + + case UserComparator.SemVerIsOneOf: + case UserComparator.SemVerIsNotOneOf: + var version = GetUserAttributeValueAsSemVer(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateSemVerIsOneOf(version!, condition.StringListValue, negate: comparator == UserComparator.SemVerIsNotOneOf); + + case UserComparator.SemVerLess: + case UserComparator.SemVerLessOrEquals: + case UserComparator.SemVerGreater: + case UserComparator.SemVerGreaterOrEquals: + version = GetUserAttributeValueAsSemVer(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateSemVerRelation(version!, comparator, condition.StringValue); + + case UserComparator.NumberEquals: + case UserComparator.NumberNotEquals: + case UserComparator.NumberLess: + case UserComparator.NumberLessOrEquals: + case UserComparator.NumberGreater: + case UserComparator.NumberGreaterOrEquals: + var number = GetUserAttributeValueAsNumber(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateNumberRelation(number, comparator, condition.DoubleValue); + + case UserComparator.DateTimeBefore: + case UserComparator.DateTimeAfter: + number = GetUserAttributeValueAsUnixTimeSeconds(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateDateTimeRelation(number, condition.DoubleValue, before: comparator == UserComparator.DateTimeBefore); + + case UserComparator.ArrayContainsAnyOf: + case UserComparator.ArrayNotContainsAnyOf: + var stringArray = GetUserAttributeValueAsStringArray(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateArrayContainsAnyOf(stringArray!, condition.StringListValue, negate: comparator == UserComparator.ArrayNotContainsAnyOf); + + case UserComparator.SensitiveArrayContainsAnyOf: + case UserComparator.SensitiveArrayNotContainsAnyOf: + stringArray = GetUserAttributeValueAsStringArray(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateSensitiveArrayContainsAnyOf(stringArray!, condition.StringListValue, + EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, negate: comparator == UserComparator.SensitiveArrayNotContainsAnyOf); + + default: + throw new InvalidOperationException("Comparison operator is invalid."); + } + } - logger.Log(l + "no match"); + private static bool EvaluateTextEquals(string text, string? comparisonValue, bool negate) + { + EnsureComparisonValue(comparisonValue); - break; - case Comparator.NotContains: + return text.Equals(comparisonValue) ^ negate; + } - if (!comparisonAttributeValue.Contains(rule.ComparisonValue)) - { - logger.Log(l + "MATCH, applying rule"); + private static bool EvaluateSensitiveTextEquals(string text, string? comparisonValue, string configJsonSalt, string contextSalt, bool negate) + { + EnsureComparisonValue(comparisonValue); - return true; - } + var hash = HashComparisonValue(text, configJsonSalt, contextSalt); - logger.Log(l + "no match"); + return hash.Equals(hexString: comparisonValue.AsSpan()) ^ negate; + } - break; - case Comparator.SemVerIn: - case Comparator.SemVerNotIn: - case Comparator.SemVerLessThan: - case Comparator.SemVerLessThanEqual: - case Comparator.SemVerGreaterThan: - case Comparator.SemVerGreaterThanEqual: + private static bool EvaluateIsOneOf(string text, string[]? comparisonValues, bool negate) + { + EnsureComparisonValue(comparisonValues); - if (EvaluateSemVer(comparisonAttributeValue, rule.ComparisonValue, rule.Comparator)) - { - logger.Log(l + "MATCH, applying rule"); + for (var i = 0; i < comparisonValues.Length; i++) + { + if (text.Equals(EnsureComparisonValue(comparisonValues[i]))) + { + return !negate; + } + } - return true; - } + return negate; + } - logger.Log(l + "no match"); + private static bool EvaluateSensitiveIsOneOf(string text, string[]? comparisonValues, string configJsonSalt, string contextSalt, bool negate) + { + EnsureComparisonValue(comparisonValues); - break; + var hash = HashComparisonValue(text, configJsonSalt, contextSalt); - case Comparator.NumberEqual: - case Comparator.NumberNotEqual: - case Comparator.NumberLessThan: - case Comparator.NumberLessThanEqual: - case Comparator.NumberGreaterThan: - case Comparator.NumberGreaterThanEqual: + for (var i = 0; i < comparisonValues.Length; i++) + { + if (hash.Equals(hexString: EnsureComparisonValue(comparisonValues[i]).AsSpan())) + { + return !negate; + } + } - if (EvaluateNumber(comparisonAttributeValue, rule.ComparisonValue, rule.Comparator)) - { - logger.Log(l + "MATCH, applying rule"); + return negate; + } - return true; - } + private static bool EvaluateTextSliceEqualsAnyOf(string text, string[]? comparisonValues, bool startsWith, bool negate) + { + EnsureComparisonValue(comparisonValues); - logger.Log(l + "no match"); + for (var i = 0; i < comparisonValues.Length; i++) + { + var item = EnsureComparisonValue(comparisonValues[i]); - break; - case Comparator.SensitiveOneOf: - if (rule.ComparisonValue - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(t => t.Trim()) - .Contains(comparisonAttributeValue.Hash())) - { - logger.Log(l + "MATCH, applying rule"); + if (text.Length < item.Length) + { + continue; + } - return true; - } + var slice = startsWith ? text.AsSpan(0, item.Length) : text.AsSpan(text.Length - item.Length); - logger.Log(l + "no match"); + if (slice.SequenceEqual(item.AsSpan())) + { + return !negate; + } + } - break; - case Comparator.SensitiveNotOneOf: - if (!rule.ComparisonValue - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(t => t.Trim()) - .Contains(comparisonAttributeValue.Hash())) - { - logger.Log(l + "MATCH, applying rule"); + return negate; + } - return true; - } + private static bool EvaluateSensitiveTextSliceEqualsAnyOf(string text, string[]? comparisonValues, string configJsonSalt, string contextSalt, bool startsWith, bool negate) + { + EnsureComparisonValue(comparisonValues); - logger.Log(l + "no match"); + var textUtf8 = Encoding.UTF8.GetBytes(text); - break; - default: - break; - } + for (var i = 0; i < comparisonValues.Length; i++) + { + var item = EnsureComparisonValue(comparisonValues[i]); + + ReadOnlySpan hash2; + + var index = item.IndexOf('_'); + if (index < 0 + || !int.TryParse(item.AsSpan(0, index).ToParsable(), NumberStyles.None, CultureInfo.InvariantCulture, out var sliceLength) + || (hash2 = item.AsSpan(index + 1)).IsEmpty) + { + EnsureComparisonValue(null); + break; // execution should never get here (this is just for keeping the compiler happy) + } + + if (textUtf8.Length < sliceLength) + { + continue; + } + + var slice = startsWith ? textUtf8.AsSpan(0, sliceLength) : textUtf8.AsSpan(textUtf8.Length - sliceLength); + + var hash = HashComparisonValue(slice, configJsonSalt, contextSalt); + if (hash.Equals(hexString: hash2)) + { + return !negate; } } - result = default; - return false; + return negate; } - private static bool EvaluateNumber(string s1, string s2, Comparator comparator) + private static bool EvaluateContainsAnyOf(string text, string[]? comparisonValues, bool negate) { - if (!double.TryParse(s1.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d1) - || !double.TryParse(s2.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d2)) + EnsureComparisonValue(comparisonValues); + + for (var i = 0; i < comparisonValues.Length; i++) + { + if (text.Contains(EnsureComparisonValue(comparisonValues[i]))) + { + return !negate; + } + } + + return negate; + } + + private static bool EvaluateSemVerIsOneOf(SemVersion version, string[]? comparisonValues, bool negate) + { + EnsureComparisonValue(comparisonValues); + + var result = false; + + for (var i = 0; i < comparisonValues.Length; i++) + { + var item = EnsureComparisonValue(comparisonValues[i]); + + // NOTE: Previous versions of the evaluation algorithm ignore empty comparison values. + // We keep this behavior for backward compatibility. + if (item.Length == 0) + { + continue; + } + + if (!SemVersion.TryParse(item.Trim(), out var version2, strict: true)) + { + // NOTE: Previous versions of the evaluation algorithm ignored invalid comparison values. + // We keep this behavior for backward compatibility. + return false; + } + + if (!result && version.PrecedenceMatches(version2)) + { + // NOTE: Previous versions of the evaluation algorithm require that + // none of the comparison values are empty or invalid, that is, we can't stop when finding a match. + // We keep this behavior for backward compatibility. + result = true; + } + } + + return result ^ negate; + } + + private static bool EvaluateSemVerRelation(SemVersion version, UserComparator comparator, string? comparisonValue) + { + EnsureComparisonValue(comparisonValue); + + if (!SemVersion.TryParse(comparisonValue.Trim(), out var version2, strict: true)) { return false; } + var comparisonResult = version.CompareByPrecedence(version2); + return comparator switch { - Comparator.NumberEqual => d1 == d2, - Comparator.NumberNotEqual => d1 != d2, - Comparator.NumberLessThan => d1 < d2, - Comparator.NumberLessThanEqual => d1 <= d2, - Comparator.NumberGreaterThan => d1 > d2, - Comparator.NumberGreaterThanEqual => d1 >= d2, - _ => false + UserComparator.SemVerLess => comparisonResult < 0, + UserComparator.SemVerLessOrEquals => comparisonResult <= 0, + UserComparator.SemVerGreater => comparisonResult > 0, + UserComparator.SemVerGreaterOrEquals => comparisonResult >= 0, + _ => throw new ArgumentOutOfRangeException(nameof(comparator), comparator, null) }; } - private static bool EvaluateSemVer(string s1, string s2, Comparator comparator) + private static bool EvaluateNumberRelation(double number, UserComparator comparator, double? comparisonValue) { - if (!SemVersion.TryParse(s1?.Trim(), out SemVersion v1, true)) return false; - s2 = string.IsNullOrWhiteSpace(s2) ? string.Empty : s2.Trim(); + var number2 = EnsureComparisonValue(comparisonValue).Value; - switch (comparator) + return comparator switch { - case Comparator.SemVerIn: + UserComparator.NumberEquals => number == number2, + UserComparator.NumberNotEquals => number != number2, + UserComparator.NumberLess => number < number2, + UserComparator.NumberLessOrEquals => number <= number2, + UserComparator.NumberGreater => number > number2, + UserComparator.NumberGreaterOrEquals => number >= number2, + _ => throw new ArgumentOutOfRangeException(nameof(comparator), comparator, null) + }; + } - var rsvi = s2 - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => - { - if (SemVersion.TryParse(s.Trim(), out SemVersion ns, true)) - { - return ns; - } + private static bool EvaluateDateTimeRelation(double number, double? comparisonValue, bool before) + { + var number2 = EnsureComparisonValue(comparisonValue).Value; - return null; - }) - .ToList(); + return before ? number < number2 : number > number2; + } - return !rsvi.Contains(null) && rsvi.Any(v => v!.PrecedenceMatches(v1)); + private static bool EvaluateArrayContainsAnyOf(string[] array, string[]? comparisonValues, bool negate) + { + EnsureComparisonValue(comparisonValues); - case Comparator.SemVerNotIn: + for (var i = 0; i < array.Length; i++) + { + var text = array[i]; - var rsvni = s2 - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => - { - if (SemVersion.TryParse(s?.Trim(), out SemVersion ns, true)) - { - return ns; - } + for (var j = 0; j < comparisonValues.Length; j++) + { + if (text.Equals(EnsureComparisonValue(comparisonValues[j]))) + { + return !negate; + } + } + } - return null; - }) - .ToList(); + return negate; + } - return !rsvni.Contains(null) && !rsvni.Any(v => v!.PrecedenceMatches(v1)); + private static bool EvaluateSensitiveArrayContainsAnyOf(string[] array, string[]? comparisonValues, string configJsonSalt, string contextSalt, bool negate) + { + EnsureComparisonValue(comparisonValues); - case Comparator.SemVerLessThan: + for (var i = 0; i < array.Length; i++) + { + var hash = HashComparisonValue(array[i], configJsonSalt, contextSalt); - if (SemVersion.TryParse(s2, out SemVersion v20, true)) + for (var j = 0; j < comparisonValues.Length; j++) + { + if (hash.Equals(hexString: EnsureComparisonValue(comparisonValues[j]).AsSpan())) { - return v1.CompareByPrecedence(v20) < 0; + return !negate; } + } + } - break; - case Comparator.SemVerLessThanEqual: + return negate; + } - if (SemVersion.TryParse(s2, out SemVersion v21, true)) - { - return v1.CompareByPrecedence(v21) <= 0; - } + private bool EvaluatePrerequisiteFlagCondition(PrerequisiteFlagCondition condition, ref EvaluateContext context, out string? error) + { + error = null; - break; - case Comparator.SemVerGreaterThan: + var logBuilder = context.LogBuilder; + logBuilder?.AppendPrerequisiteFlagCondition(condition); - if (SemVersion.TryParse(s2, out SemVersion v22, true)) - { - return v1.CompareByPrecedence(v22) > 0; - } + var prerequisiteFlagKey = condition.PrerequisiteFlagKey; + if (prerequisiteFlagKey is null || !context.Settings.TryGetValue(prerequisiteFlagKey, out var prerequisiteFlag)) + { + throw new InvalidOperationException("Prerequisite flag key is missing or invalid."); + } - break; - case Comparator.SemVerGreaterThanEqual: + var comparisonValue = EnsureComparisonValue(condition.ComparisonValue.GetValue(throwIfInvalid: false)); - if (SemVersion.TryParse(s2, out SemVersion v23, true)) - { - return v1.CompareByPrecedence(v23) >= 0; - } + var expectedSettingType = comparisonValue.GetType().ToSettingType(); + if (prerequisiteFlag.SettingType != Setting.UnknownType && prerequisiteFlag.SettingType != expectedSettingType) + { + throw new InvalidOperationException($"Type mismatch between comparison value '{comparisonValue}' and prerequisite flag '{prerequisiteFlagKey}'."); + } - break; + context.VisitedFlags.Add(context.Key); + if (context.VisitedFlags.Contains(prerequisiteFlagKey!)) + { + context.VisitedFlags.Add(prerequisiteFlagKey!); + var dependencyCycle = new StringListFormatter(context.VisitedFlags).ToString("a", CultureInfo.InvariantCulture); + throw new InvalidOperationException($"Circular dependency detected between the following depending flags: {dependencyCycle}."); } - return false; + var prerequisiteFlagContext = new EvaluateContext(prerequisiteFlagKey!, prerequisiteFlag!, ref context); + + logBuilder? + .NewLine("(") + .IncreaseIndent() + .NewLine().Append($"Evaluating prerequisite flag '{prerequisiteFlagKey}':"); + + var prerequisiteFlagEvaluateResult = EvaluateSetting(ref prerequisiteFlagContext); + + context.VisitedFlags.RemoveAt(context.VisitedFlags.Count - 1); + + var prerequisiteFlagValue = prerequisiteFlagEvaluateResult.Value.GetValue(expectedSettingType, throwIfInvalid: true)!; + + var comparator = condition.Comparator; + var result = comparator switch + { + PrerequisiteFlagComparator.Equals => prerequisiteFlagValue.Equals(comparisonValue), + PrerequisiteFlagComparator.NotEquals => !prerequisiteFlagValue.Equals(comparisonValue), + _ => throw new InvalidOperationException("Comparison operator is invalid.") + }; + + logBuilder? + .NewLine().Append($"Prerequisite flag evaluation result: '{prerequisiteFlagValue ?? EvaluateLogHelper.InvalidValuePlaceholder}'.") + .NewLine("Condition (") + .AppendPrerequisiteFlagCondition(condition) + .Append(") evaluates to ").AppendEvaluationResult(result).Append(".") + .DecreaseIndent() + .NewLine(")"); + + return result; + } + + private bool EvaluateSegmentCondition(SegmentCondition condition, ref EvaluateContext context, out string? error) + { + error = null; + + var logBuilder = context.LogBuilder; + logBuilder?.AppendSegmentCondition(condition); + + if (!context.IsUserAvailable) + { + if (!context.IsMissingUserObjectLogged) + { + this.logger.UserObjectIsMissing(context.Key); + context.IsMissingUserObjectLogged = true; + } + + error = MissingUserObjectError; + return false; + } + + var segment = condition.Segment ?? throw new InvalidOperationException("Segment reference is invalid."); + + if (segment.Name is not { Length: > 0 }) + { + throw new InvalidOperationException("Segment name is missing."); + } + + logBuilder? + .NewLine("(") + .IncreaseIndent() + .NewLine().Append($"Evaluating segment '{segment.Name}':"); + + var segmentResult = EvaluateConditions(segment.Conditions, targetingRule: null, contextSalt: segment.Name, ref context, out error); + + var comparator = condition.Comparator; + var result = error is null && comparator switch + { + SegmentComparator.IsIn => segmentResult, + SegmentComparator.IsNotIn => !segmentResult, + _ => throw new InvalidOperationException("Comparison operator is invalid.") + }; + + if (logBuilder is not null) + { + logBuilder.NewLine("Segment evaluation result: "); + (error is null + ? logBuilder.Append($"User {(segmentResult ? SegmentComparator.IsIn : SegmentComparator.IsNotIn).ToDisplayText()}") + : logBuilder.Append(error)) + .Append("."); + + logBuilder.NewLine("Condition (").AppendSegmentCondition(condition).Append(")"); + (error is null + ? logBuilder.Append(" evaluates to ").AppendEvaluationResult(result) + : logBuilder.Append(" failed to evaluate")) + .Append("."); + + logBuilder + .DecreaseIndent() + .NewLine(")"); + } + + return result; + } + + private static byte[] HashComparisonValue(string value, string configJsonSalt, string contextSalt) + { + var valueByteCount = Encoding.UTF8.GetByteCount(value); + var configJsonSaltByteCount = Encoding.UTF8.GetByteCount(configJsonSalt); + var contextSaltByteCount = Encoding.UTF8.GetByteCount(contextSalt); + var bytes = new byte[valueByteCount + configJsonSaltByteCount + contextSaltByteCount]; + + Encoding.UTF8.GetBytes(value, 0, value.Length, bytes, 0); + Encoding.UTF8.GetBytes(configJsonSalt, 0, configJsonSalt.Length, bytes, valueByteCount); + Encoding.UTF8.GetBytes(contextSalt, 0, contextSalt.Length, bytes, valueByteCount + configJsonSaltByteCount); + + return bytes.Sha256(); + } + + private static byte[] HashComparisonValue(ReadOnlySpan valueUtf8, string configJsonSalt, string contextSalt) + { + var valueByteCount = valueUtf8.Length; + var configJsonSaltByteCount = Encoding.UTF8.GetByteCount(configJsonSalt); + var contextSaltByteCount = Encoding.UTF8.GetByteCount(contextSalt); + var bytes = new byte[valueByteCount + configJsonSaltByteCount + contextSaltByteCount]; + + valueUtf8.CopyTo(bytes); + Encoding.UTF8.GetBytes(configJsonSalt, 0, configJsonSalt.Length, bytes, valueByteCount); + Encoding.UTF8.GetBytes(contextSalt, 0, contextSalt.Length, bytes, valueByteCount + configJsonSaltByteCount); + + return bytes.Sha256(); + } + + private static string EnsureConfigJsonSalt([NotNull] string? value) + { + return value ?? throw new InvalidOperationException("Config JSON salt is missing."); + } + + [return: NotNull] + private static T EnsureComparisonValue([NotNull] T? value) + { + return value ?? throw new InvalidOperationException("Comparison value is missing or invalid."); + } + + private static string UserAttributeValueToString(object attributeValue) + { + if (attributeValue is string text) + { + return text; + } + else if (attributeValue is string[] stringArray) + { + return stringArray.Serialize(); + } + else if (attributeValue.TryConvertNumericToDouble(out var number)) + { + return number.ToString(CultureInfo.InvariantCulture); + } + else if (attributeValue.TryConvertDateTimeToDateTimeOffset(out var dateTimeOffset)) + { + var unixTimeSeconds = DateTimeUtils.ToUnixTimeMilliseconds(dateTimeOffset.UtcDateTime) / 1000.0; + return unixTimeSeconds.ToString(CultureInfo.InvariantCulture); + } + + return Convert.ToString(attributeValue, CultureInfo.InvariantCulture) ?? string.Empty; + } + + private string GetUserAttributeValueAsText(string attributeName, object attributeValue, UserCondition condition, string key) + { + if (attributeValue is string text) + { + return text; + } + + text = UserAttributeValueToString(attributeValue); + this.logger.UserObjectAttributeIsAutoConverted(condition.ToString(), key, attributeName, text); + return text; + } + + private SemVersion? GetUserAttributeValueAsSemVer(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) + { + if (attributeValue is string text && SemVersion.TryParse(text.Trim(), out var version, strict: true)) + { + error = null; + return version; + } + + error = HandleInvalidUserAttribute(condition, key, attributeName, $"'{attributeValue}' is not a valid semantic version"); + return default; + } + + private double GetUserAttributeValueAsNumber(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) + { + if (attributeValue.TryConvertNumericToDouble(out var number) + || attributeValue is string text && double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out number)) + { + error = null; + return number; + } + + error = HandleInvalidUserAttribute(condition, key, attributeName, $"'{attributeValue}' is not a valid decimal number"); + return default; + } + + private double GetUserAttributeValueAsUnixTimeSeconds(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) + { + if (attributeValue.TryConvertDateTimeToDateTimeOffset(out var dateTimeOffset)) + { + error = null; + return DateTimeUtils.ToUnixTimeMilliseconds(dateTimeOffset.UtcDateTime) / 1000.0; + } + else if (attributeValue.TryConvertNumericToDouble(out var number) + || attributeValue is string text && double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out number)) + { + error = null; + return number; + } + + error = HandleInvalidUserAttribute(condition, key, attributeName, $"'{attributeValue}' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)"); + return default; + } + + private string[]? GetUserAttributeValueAsStringArray(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) + { + if (attributeValue is string[] stringArray + || attributeValue is string json && (stringArray = json.DeserializeOrDefault()!) is not null) + { + error = null; + return stringArray; + } + + error = HandleInvalidUserAttribute(condition, key, attributeName, $"'{attributeValue}' is not a valid string array"); + return default; + } + + private string HandleInvalidUserAttribute(UserCondition condition, string key, string attributeName, string reason) + { + this.logger.UserObjectAttributeIsInvalid(condition.ToString(), key, reason, attributeName); + return string.Format(CultureInfo.InvariantCulture, InvalidUserAttributeError, attributeName, reason); } } diff --git a/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs b/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs index 955cdad1..f0a8b52e 100644 --- a/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs +++ b/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs @@ -1,23 +1,13 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; using ConfigCat.Client.Utils; namespace ConfigCat.Client.Evaluation; internal static class RolloutEvaluatorExtensions { - public static EvaluationDetails Evaluate(this IRolloutEvaluator evaluator, Setting setting, string key, T defaultValue, User? user, - ProjectConfig? remoteConfig) - { - var logDefaultValue = defaultValue is not null ? Convert.ToString(defaultValue, CultureInfo.InvariantCulture) : null; - var evaluateResult = evaluator.Evaluate(setting, key, logDefaultValue, user); - return EvaluationDetails.FromEvaluateResult(key, evaluateResult, setting.SettingType, setting.UnsupportedTypeError, fetchTime: remoteConfig?.TimeStamp, user); - } - - public static EvaluationDetails Evaluate(this IRolloutEvaluator evaluator, IReadOnlyDictionary? settings, string key, T defaultValue, User? user, + public static EvaluationDetails Evaluate(this IRolloutEvaluator evaluator, Dictionary? settings, string key, T defaultValue, User? user, ProjectConfig? remoteConfig, LoggerWrapper logger) { FormattableLogMessage logMessage; @@ -30,22 +20,21 @@ public static EvaluationDetails Evaluate(this IRolloutEvaluator evaluator, if (!settings.TryGetValue(key, out var setting)) { - logMessage = logger.SettingEvaluationFailedDueToMissingKey(key, nameof(defaultValue), defaultValue, KeysToString(settings)); + var availableKeys = new StringListFormatter(settings.Keys).ToString(); + logMessage = logger.SettingEvaluationFailedDueToMissingKey(key, nameof(defaultValue), defaultValue, availableKeys); return EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: remoteConfig?.TimeStamp, user, logMessage.InvariantFormattedMessage); } - return evaluator.Evaluate(setting, key, defaultValue, user, remoteConfig); + var evaluateContext = new EvaluateContext(key, setting, user, settings); + // NOTE: It's better to avoid virtual generic method calls as they are slow and may be problematic for older AOT compilers (like Mono AOT or IL2CPP), + // especially, when targeting platforms which disallow the execution of dynamically generated code (e.g. Xamarin.iOS). + var evaluateResult = evaluator is RolloutEvaluator rolloutEvaluator + ? rolloutEvaluator.Evaluate(defaultValue, ref evaluateContext, out var value) + : evaluator.Evaluate(defaultValue, ref evaluateContext, out value); + return EvaluationDetails.FromEvaluateResult(key, value, evaluateResult, fetchTime: remoteConfig?.TimeStamp, user); } - public static EvaluationDetails Evaluate(this IRolloutEvaluator evaluator, Setting setting, string key, object? defaultValue, User? user, - ProjectConfig? remoteConfig) - { - var logDefaultValue = defaultValue is not null ? Convert.ToString(defaultValue, CultureInfo.InvariantCulture) : null; - var evaluateResult = evaluator.Evaluate(setting, key, logDefaultValue, user); - return EvaluationDetails.FromEvaluateResult(key, evaluateResult, setting.SettingType, setting.UnsupportedTypeError, fetchTime: remoteConfig?.TimeStamp, user); - } - - public static EvaluationDetails[] EvaluateAll(this IRolloutEvaluator evaluator, IReadOnlyDictionary? settings, User? user, + public static EvaluationDetails[] EvaluateAll(this IRolloutEvaluator evaluator, Dictionary? settings, User? user, ProjectConfig? remoteConfig, LoggerWrapper logger, string defaultReturnValue, out IReadOnlyList? exceptions) { if (!CheckSettingsAvailable(settings, logger, defaultReturnValue)) @@ -56,6 +45,7 @@ public static EvaluationDetails[] EvaluateAll(this IRolloutEvaluator evaluator, var evaluationDetailsArray = new EvaluationDetails[settings.Count]; List? exceptionList = null; + var rolloutEvaluator = evaluator as RolloutEvaluator; var index = 0; foreach (var kvp in settings) @@ -63,13 +53,19 @@ public static EvaluationDetails[] EvaluateAll(this IRolloutEvaluator evaluator, EvaluationDetails evaluationDetails; try { - evaluationDetails = evaluator.Evaluate(kvp.Value, kvp.Key, defaultValue: null, user, remoteConfig); + var evaluateContext = new EvaluateContext(kvp.Key, kvp.Value, user, settings); + // NOTE: It's better to avoid virtual generic method calls as they are slow and may be problematic for older AOT compilers (like Mono AOT or IL2CPP), + // especially, when targeting platforms which disallow the execution of dynamically generated code (e.g. Xamarin.iOS). + var evaluateResult = rolloutEvaluator is not null + ? rolloutEvaluator.Evaluate(defaultValue: null, ref evaluateContext, out var value) + : evaluator.Evaluate(defaultValue: null, ref evaluateContext, out value); + evaluationDetails = EvaluationDetails.FromEvaluateResult(kvp.Key, value, evaluateResult, fetchTime: remoteConfig?.TimeStamp, user); } catch (Exception ex) { exceptionList ??= new List(); exceptionList.Add(ex); - evaluationDetails = EvaluationDetails.FromDefaultValue(kvp.Key, defaultValue: (object?)null, fetchTime: remoteConfig?.TimeStamp, user, ex.Message, ex); + evaluationDetails = EvaluationDetails.FromDefaultValue(kvp.Key, defaultValue: null, fetchTime: remoteConfig?.TimeStamp, user, ex.Message, ex); } evaluationDetailsArray[index++] = evaluationDetails; @@ -79,7 +75,7 @@ public static EvaluationDetails[] EvaluateAll(this IRolloutEvaluator evaluator, return evaluationDetailsArray; } - internal static bool CheckSettingsAvailable([NotNullWhen(true)] IReadOnlyDictionary? settings, LoggerWrapper logger, string defaultReturnValue) + internal static bool CheckSettingsAvailable([NotNullWhen(true)] Dictionary? settings, LoggerWrapper logger, string defaultReturnValue) { if (settings is null) { @@ -89,9 +85,4 @@ internal static bool CheckSettingsAvailable([NotNullWhen(true)] IReadOnlyDiction return true; } - - private static string KeysToString(IReadOnlyDictionary settings) - { - return string.Join(", ", settings.Keys.Select(s => $"'{s}'").ToArray()); - } } diff --git a/src/ConfigCatClient/Extensions/ObjectExtensions.cs b/src/ConfigCatClient/Extensions/ObjectExtensions.cs index c41e1087..00606d83 100644 --- a/src/ConfigCatClient/Extensions/ObjectExtensions.cs +++ b/src/ConfigCatClient/Extensions/ObjectExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Runtime.CompilerServices; using ConfigCat.Client; #if USE_NEWTONSOFT_JSON @@ -44,111 +45,99 @@ private static bool IsWithinAllowedDoubleRange(IConvertible value) return value.GetTypeCode() is TypeCode.Single or TypeCode.Double; } - public static SettingType DetermineSettingType(this JsonValue value) + internal static SettingValue ToSettingValue(this JsonValue value, out SettingType settingType) { #if USE_NEWTONSOFT_JSON - return value.Type switch + switch (value.Type) { - Newtonsoft.Json.Linq.JTokenType.String => - SettingType.String, - Newtonsoft.Json.Linq.JTokenType.Boolean => - SettingType.Boolean, - Newtonsoft.Json.Linq.JTokenType.Integer when IsWithinAllowedIntRange(value) => - SettingType.Int, - Newtonsoft.Json.Linq.JTokenType.Float when IsWithinAllowedDoubleRange(value) => - SettingType.Double, - _ => - SettingType.Unknown, - }; + case Newtonsoft.Json.Linq.JTokenType.String: + settingType = SettingType.String; + return new SettingValue { StringValue = value.ConvertTo() }; + + case Newtonsoft.Json.Linq.JTokenType.Boolean: + settingType = SettingType.Boolean; + return new SettingValue { BoolValue = value.ConvertTo() }; + + case Newtonsoft.Json.Linq.JTokenType.Integer when IsWithinAllowedIntRange(value): + settingType = SettingType.Int; + return new SettingValue { IntValue = value.ConvertTo() }; + + case Newtonsoft.Json.Linq.JTokenType.Float when IsWithinAllowedDoubleRange(value): + settingType = SettingType.Double; + return new SettingValue { DoubleValue = value.ConvertTo() }; + } #else - return value.ValueKind switch + switch (value.ValueKind) { - Text.Json.JsonValueKind.String => - SettingType.String, - Text.Json.JsonValueKind.False or - Text.Json.JsonValueKind.True => - SettingType.Boolean, - Text.Json.JsonValueKind.Number when value.TryGetInt32(out var _) => - SettingType.Int, - Text.Json.JsonValueKind.Number when value.TryGetDouble(out var _) => - SettingType.Double, - _ => - SettingType.Unknown, - }; + case Text.Json.JsonValueKind.String: + settingType = SettingType.String; + return new SettingValue { StringValue = value.ConvertTo() }; + + case Text.Json.JsonValueKind.False or Text.Json.JsonValueKind.True: + settingType = SettingType.Boolean; + return new SettingValue { BoolValue = value.ConvertTo() }; + + case Text.Json.JsonValueKind.Number when value.TryGetInt32(out var _): + settingType = SettingType.Int; + return new SettingValue { IntValue = value.ConvertTo() }; + + case Text.Json.JsonValueKind.Number when value.TryGetDouble(out var _): + settingType = SettingType.Double; + return new SettingValue { DoubleValue = value.ConvertTo() }; + } #endif + + settingType = Setting.UnknownType; + return new SettingValue { UnsupportedValue = value }; } - public static SettingType DetermineSettingType(this object? value) + public static SettingValue ToSettingValue(this object? value, out SettingType settingType) { - if (value is null) + if (value is not null) { - return SettingType.Unknown; + switch (Type.GetTypeCode(value.GetType())) + { + case TypeCode.String: + settingType = SettingType.String; + return new SettingValue { StringValue = (string)value }; + + case TypeCode.Boolean: + settingType = SettingType.Boolean; + return new SettingValue { BoolValue = (bool)value }; + + case TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 or TypeCode.Int32: + case TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64 when IsWithinAllowedIntRange((IConvertible)value): + settingType = SettingType.Int; + return new SettingValue { IntValue = ((IConvertible)value).ToInt32(CultureInfo.InvariantCulture) }; + + case TypeCode.Single or TypeCode.Double when IsWithinAllowedDoubleRange((IConvertible)value): + settingType = SettingType.Double; + return new SettingValue { DoubleValue = ((IConvertible)value).ToDouble(CultureInfo.InvariantCulture) }; + } } - if (value is JsonValue jsonValue) - { - return jsonValue.DetermineSettingType(); - } - - return Type.GetTypeCode(value.GetType()) switch - { - TypeCode.String => - SettingType.String, - TypeCode.Boolean => - SettingType.Boolean, - TypeCode.SByte or - TypeCode.Byte or - TypeCode.Int16 or - TypeCode.UInt16 or - TypeCode.Int32 or - TypeCode.UInt32 or - TypeCode.Int64 or - TypeCode.UInt64 when IsWithinAllowedIntRange((IConvertible)value) => - SettingType.Int, - TypeCode.Single or - TypeCode.Double when IsWithinAllowedDoubleRange((IConvertible)value) => - SettingType.Double, - _ => - SettingType.Unknown, - }; + settingType = Setting.UnknownType; + return new SettingValue { UnsupportedValue = value }; } public static Setting ToSetting(this object? value) { - var settingType = DetermineSettingType(value); - - JsonValue jsonValue; - string? unsupportedTypeError; - if (settingType != SettingType.Unknown) + var setting = new Setting { -#if USE_NEWTONSOFT_JSON - jsonValue = new Newtonsoft.Json.Linq.JValue(value); -#else - jsonValue = Text.Json.JsonSerializer.SerializeToElement(value); -#endif - unsupportedTypeError = null; - } - else + Value = value is JsonValue jsonValue + ? jsonValue.ToSettingValue(out var settingType) + : value.ToSettingValue(out settingType), + }; + + if (settingType != Setting.UnknownType) { -#if USE_NEWTONSOFT_JSON - jsonValue = JsonValue.CreateUndefined(); -#else - jsonValue = default; -#endif - unsupportedTypeError = value is not null - ? $"Setting value '{value}' is of an unsupported type ({value.GetType()})." - : $"Setting value is null."; + setting.SettingType = settingType; } - return new Setting - { - Value = jsonValue, - SettingType = settingType, - UnsupportedTypeError = unsupportedTypeError, - }; + return setting; } - public static TValue ConvertTo(this JsonValue value) + private static TValue ConvertTo(this JsonValue value) { Debug.Assert(typeof(TValue) != typeof(object), "Conversion to object is not supported."); @@ -161,15 +150,57 @@ public static TValue ConvertTo(this JsonValue value) #endif } - public static object ConvertToObject(this JsonValue value, SettingType settingType) + public static bool TryConvertNumericToDouble(this object value, out double number) { - return settingType switch + if (Type.GetTypeCode(value.GetType()) is + TypeCode.SByte or + TypeCode.Byte or + TypeCode.Int16 or + TypeCode.UInt16 or + TypeCode.Int32 or + TypeCode.UInt32 or + TypeCode.Int64 or + TypeCode.UInt64 or + TypeCode.Single or + TypeCode.Double or + TypeCode.Decimal) { - SettingType.Boolean => value.ConvertTo(), - SettingType.String => value.ConvertTo(), - SettingType.Int => value.ConvertTo(), - SettingType.Double => value.ConvertTo(), - _ => throw new ArgumentOutOfRangeException(nameof(settingType), settingType, null) - }; + number = ((IConvertible)value).ToDouble(CultureInfo.InvariantCulture); + return true; + } + + number = default; + return false; } + + public static bool TryConvertDateTimeToDateTimeOffset(this object value, out DateTimeOffset dateTimeOffset) + { + if (value is DateTimeOffset dateTimeOffsetLocal) + { + dateTimeOffset = dateTimeOffsetLocal; + return true; + } + else if (value is DateTime dateTime) + { + dateTimeOffset = new DateTimeOffset(dateTime.Kind != DateTimeKind.Unspecified ? dateTime : DateTime.SpecifyKind(dateTime, DateTimeKind.Utc)); + return true; + } + + dateTimeOffset = default; + return false; + } + + private static readonly object BoxedTrue = true; + private static readonly object BoxedFalse = false; + + public static object AsCachedObject(this bool value) => value ? BoxedTrue : BoxedFalse; + + // In generic methods, we can't cast from/to the generic type directly even if we know that the conversion would be ok, that is, + // something like (TValue)BoolValue won't work, we'd need (TValue)(object)BoolValue, which would mean boxing (memory allocation). + // However, using the following trick involving delegates we can avoid boxing (see also https://stackoverflow.com/a/45508419). + + public static readonly Delegate BoxedIntToLong = new Func(value => (int)value); + public static readonly Delegate BoxedIntToNullableLong = new Func(value => (int)value); + + public static TTo Cast(this TFrom from, Delegate conversion) => ((Func)conversion)(from); } diff --git a/src/ConfigCatClient/Extensions/SerializationExtensions.cs b/src/ConfigCatClient/Extensions/SerializationExtensions.cs index a4612c65..684b3818 100644 --- a/src/ConfigCatClient/Extensions/SerializationExtensions.cs +++ b/src/ConfigCatClient/Extensions/SerializationExtensions.cs @@ -2,6 +2,7 @@ using System.IO; using Newtonsoft.Json; #else +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; #endif @@ -12,6 +13,11 @@ internal static class SerializationExtensions { #if USE_NEWTONSOFT_JSON private static readonly JsonSerializer Serializer = JsonSerializer.Create(); +#else + private static readonly JsonSerializerOptions SerializerOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; #endif public static T? Deserialize(this string json) => json.AsSpan().Deserialize(); @@ -46,7 +52,7 @@ public static string Serialize(this T objectToSerialize) #if USE_NEWTONSOFT_JSON return JsonConvert.SerializeObject(objectToSerialize); #else - return JsonSerializer.Serialize(objectToSerialize); + return JsonSerializer.Serialize(objectToSerialize, SerializerOptions); #endif } } diff --git a/src/ConfigCatClient/Extensions/StringExtensions.cs b/src/ConfigCatClient/Extensions/StringExtensions.cs index 0d76d317..27bc2461 100644 --- a/src/ConfigCatClient/Extensions/StringExtensions.cs +++ b/src/ConfigCatClient/Extensions/StringExtensions.cs @@ -1,24 +1,43 @@ using System.Security.Cryptography; using System.Text; -using ConfigCat.Client.Utils; namespace System; internal static class StringExtensions { - public static string Hash(this string text) + public static byte[] Sha1(this string text) { - byte[] hashedBytes; var textBytes = Encoding.UTF8.GetBytes(text); #if NET5_0_OR_GREATER - hashedBytes = SHA1.HashData(textBytes); + return SHA1.HashData(textBytes); #else - using (var hash = SHA1.Create()) - { - hashedBytes = hash.ComputeHash(textBytes); - } + using var hash = SHA1.Create(); + return hash.ComputeHash(textBytes); #endif + } + + public static byte[] Sha256(this byte[] bytes) + { +#if NET5_0_OR_GREATER + return SHA256.HashData(bytes); +#else + using var hash = SHA256.Create(); + return hash.ComputeHash(bytes); +#endif + } - return hashedBytes.ToHexString(); + public static +#if NET5_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER + ReadOnlySpan +#else + string +#endif + ToParsable(this ReadOnlySpan s) + { +#if NET5_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER + return s; +#else + return s.ToString(); +#endif } } diff --git a/src/ConfigCatClient/Extensions/TypeExtensions.cs b/src/ConfigCatClient/Extensions/TypeExtensions.cs index becb66a8..bb66ba97 100644 --- a/src/ConfigCatClient/Extensions/TypeExtensions.cs +++ b/src/ConfigCatClient/Extensions/TypeExtensions.cs @@ -6,7 +6,7 @@ internal static class TypeExtensions { public static void EnsureSupportedSettingClrType(this Type type, string paramName) { - if (type != typeof(object) && type.ToSettingType() == SettingType.Unknown) + if (type != typeof(object) && type.ToSettingType() == Setting.UnknownType) { throw new ArgumentException($"Only the following types are supported: {typeof(string)}, {typeof(bool)}, {typeof(int)}, {typeof(long)}, {typeof(double)} and {typeof(object)} (both nullable and non-nullable).", paramName); } @@ -31,7 +31,7 @@ TypeCode.Int32 or TypeCode.Double => SettingType.Double, _ => - SettingType.Unknown, + Setting.UnknownType, }; } } diff --git a/src/ConfigCatClient/HttpConfigFetcher.cs b/src/ConfigCatClient/HttpConfigFetcher.cs index 4d812b3c..9e109d48 100644 --- a/src/ConfigCatClient/HttpConfigFetcher.cs +++ b/src/ConfigCatClient/HttpConfigFetcher.cs @@ -6,9 +6,9 @@ using System.Threading.Tasks; #if NET45 -using ResponseWithBody = System.Tuple; +using ResponseWithBody = System.Tuple; #else -using ResponseWithBody = System.ValueTuple; +using ResponseWithBody = System.ValueTuple; #endif namespace ConfigCat.Client; @@ -177,7 +177,7 @@ private async ValueTask FetchRequestAsync(string? httpETag, Ur var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); #endif - var config = responseBody.DeserializeOrDefault(); + var config = responseBody.DeserializeOrDefault(); if (config is null) { return new ResponseWithBody(response, null, null); @@ -185,7 +185,7 @@ private async ValueTask FetchRequestAsync(string? httpETag, Ur if (config.Preferences is not null) { - var newBaseUrl = config.Preferences.Url; + var newBaseUrl = config.Preferences.BaseUrl; if (newBaseUrl is null || requestUri.Host == new Uri(newBaseUrl).Host) { diff --git a/src/ConfigCatClient/Logging/FormattableLogMessage.cs b/src/ConfigCatClient/Logging/FormattableLogMessage.cs index 168e8355..0a147c2c 100644 --- a/src/ConfigCatClient/Logging/FormattableLogMessage.cs +++ b/src/ConfigCatClient/Logging/FormattableLogMessage.cs @@ -5,7 +5,7 @@ namespace ConfigCat.Client; /// -/// Represents a plain log message or a log message format with names arguments. +/// Represents a plain log message or a log message format with named arguments. /// public struct FormattableLogMessage : IFormattable { @@ -51,17 +51,17 @@ public FormattableLogMessage(string format, string[] argNames, object?[] argValu /// /// Log message format. /// - public string Format => this.format ?? ToFormatString(this.invariantFormattedMessage ?? string.Empty); + public readonly string Format => this.format ?? ToFormatString(this.invariantFormattedMessage ?? string.Empty); /// /// Names of the named arguments. /// - public string[] ArgNames { get; } + public readonly string[] ArgNames { get; } /// /// Values of the named arguments. /// - public object?[] ArgValues { get; } + public readonly object?[] ArgValues { get; } private string? invariantFormattedMessage; /// @@ -72,7 +72,7 @@ public FormattableLogMessage(string format, string[] argNames, object?[] argValu /// /// Returns the log message formatted using . /// - public override string ToString() + public override readonly string ToString() { return ToString(formatProvider: null); } @@ -80,7 +80,7 @@ public override string ToString() /// /// Returns the log message formatted using the specified . /// - public string ToString(IFormatProvider? formatProvider) + public readonly string ToString(IFormatProvider? formatProvider) { return this.format is not null ? string.Format(formatProvider, this.format, ArgValues) @@ -88,7 +88,7 @@ public string ToString(IFormatProvider? formatProvider) } /// - public string ToString(string? format, IFormatProvider? formatProvider) + public readonly string ToString(string? format, IFormatProvider? formatProvider) { return ToString(formatProvider); } diff --git a/src/ConfigCatClient/Logging/LogMessages.cs b/src/ConfigCatClient/Logging/LogMessages.cs index 740e2def..f523a6c8 100644 --- a/src/ConfigCatClient/Logging/LogMessages.cs +++ b/src/ConfigCatClient/Logging/LogMessages.cs @@ -1,6 +1,5 @@ using System; using ConfigCat.Client.ConfigService; -using ConfigCat.Client.Evaluation; namespace ConfigCat.Client; @@ -113,7 +112,7 @@ public static FormattableLogMessage ClientIsAlreadyCreated(this LoggerWrapper lo $"There is an existing client instance for the specified SDK Key. No new client instance will be created and the specified configuration action is ignored. Returning the existing client instance. SDK Key: '{sdkKey}'.", "SDK_KEY"); - public static FormattableLogMessage TargetingIsNotPossible(this LoggerWrapper logger, string key) => logger.LogInterpolated( + public static FormattableLogMessage UserObjectIsMissing(this LoggerWrapper logger, string key) => logger.LogInterpolated( LogLevel.Warning, 3001, $"Cannot evaluate targeting rules and % options for setting '{key}' (User Object is missing). You should pass a User Object to the evaluation methods like `GetValue()`/`GetValueAsync()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/", "KEY"); @@ -122,6 +121,26 @@ public static FormattableLogMessage DataGovernanceIsOutOfSync(this LoggerWrapper LogLevel.Warning, 3002, "The `dataGovernance` parameter specified at the client initialization is not in sync with the preferences on the ConfigCat Dashboard. Read more: https://configcat.com/docs/advanced/data-governance/"); + public static FormattableLogMessage UserObjectAttributeIsMissing(this LoggerWrapper logger, string key, string attributeName) => logger.LogInterpolated( + LogLevel.Warning, 3003, + $"Cannot evaluate % options for setting '{key}' (the User.{attributeName} attribute is missing). You should set the User.{attributeName} attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/", + "KEY", "ATTRIBUTE_NAME", "ATTRIBUTE_NAME"); + + public static FormattableLogMessage UserObjectAttributeIsMissing(this LoggerWrapper logger, string condition, string key, string attributeName) => logger.LogInterpolated( + LogLevel.Warning, 3003, + $"Cannot evaluate condition ({condition}) for setting '{key}' (the User.{attributeName} attribute is missing). You should set the User.{attributeName} attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/", + "CONDITION", "KEY", "ATTRIBUTE_NAME", "ATTRIBUTE_NAME"); + + public static FormattableLogMessage UserObjectAttributeIsInvalid(this LoggerWrapper logger, string condition, string key, string reason, string attributeName) => logger.LogInterpolated( + LogLevel.Warning, 3004, + $"Cannot evaluate condition ({condition}) for setting '{key}' ({reason}). Please check the User.{attributeName} attribute and make sure that its value corresponds to the comparison operator.", + "CONDITION", "KEY", "REASON", "ATTRIBUTE_NAME"); + + public static FormattableLogMessage UserObjectAttributeIsAutoConverted(this LoggerWrapper logger, string condition, string key, string attributeName, string attributeValue) => logger.LogInterpolated( + LogLevel.Warning, 3005, + $"Evaluation of condition ({condition}) for setting '{key}' may not produce the expected result (the User.{attributeName} attribute is not a string value, thus it was automatically converted to the string value '{attributeValue}'). Please make sure that using a non-string value was intended.", + "CONDITION", "KEY", "ATTRIBUTE_NAME", "ATTRIBUTE_VALUE"); + public static FormattableLogMessage ConfigServiceCannotInitiateHttpCalls(this LoggerWrapper logger) => logger.Log( LogLevel.Warning, 3200, "Client is in offline mode, it cannot initiate HTTP calls."); @@ -144,7 +163,7 @@ public static FormattableLogMessage ConfigServiceMethodHasNoEffectDueToOverrideB #region Common info messages (5000-5999) - public static FormattableLogMessage SettingEvaluated(this LoggerWrapper logger, EvaluateLogger evaluateLog) => logger.LogInterpolated( + public static FormattableLogMessage SettingEvaluated(this LoggerWrapper logger, string evaluateLog) => logger.LogInterpolated( LogLevel.Info, 5000, $"{evaluateLog}", "EVALUATE_LOG"); diff --git a/src/ConfigCatClient/Logging/LoggerWrapper.cs b/src/ConfigCatClient/Logging/LoggerWrapper.cs index 6d2d39e6..e58ebce3 100644 --- a/src/ConfigCatClient/Logging/LoggerWrapper.cs +++ b/src/ConfigCatClient/Logging/LoggerWrapper.cs @@ -19,15 +19,15 @@ internal LoggerWrapper(IConfigCatLogger logger, SafeHooksWrapper hooks = default this.hooks = hooks; } - private bool TargetLogEnabled(LogLevel targetTrace) + public bool IsEnabled(LogLevel level) { - return (byte)targetTrace <= (byte)LogLevel; + return (byte)level <= (byte)LogLevel; } /// public void Log(LogLevel level, LogEventId eventId, ref FormattableLogMessage message, Exception? exception = null) { - if (TargetLogEnabled(level)) + if (IsEnabled(level)) { this.logger.Log(level, eventId, ref message, exception); } diff --git a/src/ConfigCatClient/Models/Comparator.cs b/src/ConfigCatClient/Models/Comparator.cs deleted file mode 100644 index 71ec74d6..00000000 --- a/src/ConfigCatClient/Models/Comparator.cs +++ /dev/null @@ -1,97 +0,0 @@ -namespace ConfigCat.Client; - -/// -/// Targeting rule comparison operator. -/// -public enum Comparator : byte -{ - /// - /// Does the comparison value interpreted as a comma-separated list of strings contain the comparison attribute? - /// - In = 0, - - /// - /// Does the comparison value interpreted as a comma-separated list of strings not contain the comparison attribute? - /// - NotIn = 1, - - /// - /// Is the comparison value contained by the comparison attribute as a substring? - /// - Contains = 2, - - /// - /// Is the comparison value not contained by the comparison attribute as a substring? - /// - NotContains = 3, - - /// - /// Does the comparison value interpreted as a comma-separated list of semantic versions contain the comparison attribute? - /// - SemVerIn = 4, - - /// - /// Does the comparison value interpreted as a comma-separated list of semantic versions not contain the comparison attribute? - /// - SemVerNotIn = 5, - - /// - /// Is the comparison value interpreted as a semantic version less than the comparison attribute? - /// - SemVerLessThan = 6, - - /// - /// Is the comparison value interpreted as a semantic version less than or equal to the comparison attribute? - /// - SemVerLessThanEqual = 7, - - /// - /// Is the comparison value interpreted as a semantic version greater than the comparison attribute? - /// - SemVerGreaterThan = 8, - - /// - /// Is the comparison value interpreted as a semantic version greater than or equal to the comparison attribute? - /// - SemVerGreaterThanEqual = 9, - - /// - /// Is the comparison value interpreted as a number equal to the comparison attribute? - /// - NumberEqual = 10, - - /// - /// Is the comparison value interpreted as a number not equal to the comparison attribute? - /// - NumberNotEqual = 11, - - /// - /// Is the comparison value interpreted as a number less than the comparison attribute? - /// - NumberLessThan = 12, - - /// - /// Is the comparison value interpreted as a number less than or equal to the comparison attribute? - /// - NumberLessThanEqual = 13, - - /// - /// Is the comparison value interpreted as a number greater than the comparison attribute? - /// - NumberGreaterThan = 14, - - /// - /// Is the comparison value interpreted as a number greater than or equal to the comparison attribute? - /// - NumberGreaterThanEqual = 15, - - /// - /// Does the comparison value interpreted as a comma-separated list of hashes of strings contain the hash of the comparison attribute? - /// - SensitiveOneOf = 16, - - /// - /// Does the comparison value interpreted as a comma-separated list of hashes of strings not contain the hash of the comparison attribute? - /// - SensitiveNotOneOf = 17 -} diff --git a/src/ConfigCatClient/Models/Condition.cs b/src/ConfigCatClient/Models/Condition.cs new file mode 100644 index 00000000..ce3ef21c --- /dev/null +++ b/src/ConfigCatClient/Models/Condition.cs @@ -0,0 +1,17 @@ +namespace ConfigCat.Client; + +/// +/// Represents a condition. +/// Can be one of the following types: , or . +/// +public interface ICondition { } + +internal interface IConditionProvider +{ + Condition? GetCondition(bool throwIfInvalid = true); +} + +internal abstract class Condition : ICondition, IConditionProvider +{ + public Condition? GetCondition(bool throwIfInvalid = true) => this; +} diff --git a/src/ConfigCatClient/Models/ConditionContainer.cs b/src/ConfigCatClient/Models/ConditionContainer.cs new file mode 100644 index 00000000..6913cc29 --- /dev/null +++ b/src/ConfigCatClient/Models/ConditionContainer.cs @@ -0,0 +1,54 @@ +using System; +using ConfigCat.Client.Utils; + +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +internal struct ConditionContainer : IConditionProvider +{ + private object? condition; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "u")] +#else + [JsonPropertyName("u")] +#endif + public UserCondition? UserCondition + { + readonly get => this.condition as UserCondition; + set => ModelHelper.SetOneOf(ref this.condition, value); + } + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "s")] +#else + [JsonPropertyName("s")] +#endif + public SegmentCondition? SegmentCondition + { + readonly get => this.condition as SegmentCondition; + set => ModelHelper.SetOneOf(ref this.condition, value); + } + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "p")] +#else + [JsonPropertyName("p")] +#endif + public PrerequisiteFlagCondition? PrerequisiteFlagCondition + { + readonly get => this.condition as PrerequisiteFlagCondition; + set => ModelHelper.SetOneOf(ref this.condition, value); + } + + public readonly Condition? GetCondition(bool throwIfInvalid = true) + { + return this.condition as Condition + ?? (!throwIfInvalid ? null : throw new InvalidOperationException("Condition is missing or invalid.")); + } +} diff --git a/src/ConfigCatClient/Models/Config.cs b/src/ConfigCatClient/Models/Config.cs new file mode 100644 index 00000000..98f19491 --- /dev/null +++ b/src/ConfigCatClient/Models/Config.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using ConfigCat.Client.Utils; + +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +using System.Runtime.Serialization; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +/// +/// Details of a ConfigCat config. +/// +public interface IConfig +{ + /// + /// The salt that was used to hash sensitive comparison values. + /// + string? Salt { get; } + + /// + /// The list of segments. + /// + IReadOnlyList Segments { get; } + + /// + /// The dictionary of settings. + /// + IReadOnlyDictionary Settings { get; } +} + +internal sealed class Config : IConfig +#if !USE_NEWTONSOFT_JSON + , IJsonOnDeserialized +#endif +{ +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "p")] +#else + [JsonPropertyName("p")] +#endif + public Preferences? Preferences { get; set; } + + string? IConfig.Salt => Preferences?.Salt; + + private Segment[]? segments; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "s")] +#else + [JsonPropertyName("s")] +#endif + [NotNull] + public Segment[]? Segments + { + get => this.segments ?? ArrayUtils.EmptyArray(); + set => this.segments = value; + } + + private IReadOnlyList? segmentsReadOnly; + IReadOnlyList IConfig.Segments => this.segmentsReadOnly ??= this.segments is { Length: > 0 } + ? new ReadOnlyCollection(this.segments) + : ArrayUtils.EmptyArray(); + + private Dictionary? settings; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "f")] +#else + [JsonPropertyName("f")] +#endif + [NotNull] + public Dictionary? Settings + { + get => this.settings ??= new Dictionary(); + set => this.settings = value; + } + + private IReadOnlyDictionary? settingsReadOnly; + IReadOnlyDictionary IConfig.Settings => this.settingsReadOnly ??= this.settings is { Count: > 0 } + ? this.settings.ToDictionary(kvp => kvp.Key, kvp => (ISetting)kvp.Value) + : new Dictionary(); + +#if USE_NEWTONSOFT_JSON + [OnDeserialized] + internal void OnDeserialized(StreamingContext context) => OnDeserialized(); +#endif + + public void OnDeserialized() + { + if (this.settings is { Count: > 0 }) + { + foreach (var setting in this.settings.Values) + { + setting.OnConfigDeserialized(this); + } + } + } +} diff --git a/src/ConfigCatClient/Models/PercentageOption.cs b/src/ConfigCatClient/Models/PercentageOption.cs new file mode 100644 index 00000000..986f7426 --- /dev/null +++ b/src/ConfigCatClient/Models/PercentageOption.cs @@ -0,0 +1,38 @@ +using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Utils; + +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +/// +/// Represents a percentage option. +/// +public interface IPercentageOption : ISettingValueContainer +{ + /// + /// A number between 0 and 100 that represents a randomly allocated fraction of the users. + /// + byte Percentage { get; } +} + +internal sealed class PercentageOption : SettingValueContainer, IPercentageOption +{ +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "p")] +#else + [JsonPropertyName("p")] +#endif + public byte Percentage { get; set; } + + public override string ToString() + { + return new IndentedTextBuilder() + .AppendPercentageOption(this) + .ToString(); + } +} diff --git a/src/ConfigCatClient/Models/Preferences.cs b/src/ConfigCatClient/Models/Preferences.cs index 01d9655d..4dee805c 100644 --- a/src/ConfigCatClient/Models/Preferences.cs +++ b/src/ConfigCatClient/Models/Preferences.cs @@ -1,3 +1,5 @@ +using ConfigCat.Client.Utils; + #if USE_NEWTONSOFT_JSON using Newtonsoft.Json; #else @@ -13,12 +15,25 @@ internal sealed class Preferences #else [JsonPropertyName("u")] #endif - public string? Url { get; set; } + public string? BaseUrl { get; set; } + + private RedirectMode redirectMode; #if USE_NEWTONSOFT_JSON [JsonProperty(PropertyName = "r")] #else [JsonPropertyName("r")] #endif - public RedirectMode RedirectMode { get; set; } + public RedirectMode RedirectMode + { + get => this.redirectMode; + set => ModelHelper.SetEnum(ref this.redirectMode, value); + } + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "s")] +#else + [JsonPropertyName("s")] +#endif + public string? Salt { get; set; } } diff --git a/src/ConfigCatClient/Models/PrerequisiteFlagComparator.cs b/src/ConfigCatClient/Models/PrerequisiteFlagComparator.cs new file mode 100644 index 00000000..2414e97d --- /dev/null +++ b/src/ConfigCatClient/Models/PrerequisiteFlagComparator.cs @@ -0,0 +1,17 @@ +namespace ConfigCat.Client; + +/// +/// Prerequisite flag comparison operator used during the evaluation process. +/// +public enum PrerequisiteFlagComparator : byte +{ + /// + /// EQUALS - It matches when the evaluated value of the specified prerequisite flag is equal to the comparison value. + /// + Equals = 0, + + /// + /// NOT EQUALS - It matches when the evaluated value of the specified prerequisite flag is not equal to the comparison value. + /// + NotEquals = 1 +} diff --git a/src/ConfigCatClient/Models/PrerequisiteFlagCondition.cs b/src/ConfigCatClient/Models/PrerequisiteFlagCondition.cs new file mode 100644 index 00000000..059cf721 --- /dev/null +++ b/src/ConfigCatClient/Models/PrerequisiteFlagCondition.cs @@ -0,0 +1,76 @@ +using System; +using ConfigCat.Client.Utils; +using ConfigCat.Client.Evaluation; + +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +/// +/// Describes a condition that is based on a prerequisite flag. +/// +public interface IPrerequisiteFlagCondition : ICondition +{ + /// + /// The key of the prerequisite flag that the condition is based on. + /// + string PrerequisiteFlagKey { get; } + + /// + /// The operator which defines the relation between the evaluated value of the prerequisite flag and the comparison value. + /// + PrerequisiteFlagComparator Comparator { get; } + + /// + /// The value that the evaluated value of the prerequisite flag is compared to. + /// Can be a value of the following types: , , or . + /// + object ComparisonValue { get; } +} + +internal sealed class PrerequisiteFlagCondition : Condition, IPrerequisiteFlagCondition +{ + public const PrerequisiteFlagComparator UnknownComparator = (PrerequisiteFlagComparator)byte.MaxValue; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "f")] +#else + [JsonPropertyName("f")] +#endif + public string? PrerequisiteFlagKey { get; set; } + + string IPrerequisiteFlagCondition.PrerequisiteFlagKey => PrerequisiteFlagKey ?? throw new InvalidOperationException("Prerequisite flag key is missing."); + + private PrerequisiteFlagComparator comparator = UnknownComparator; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "c")] +#else + [JsonPropertyName("c")] +#endif + public PrerequisiteFlagComparator Comparator + { + get => this.comparator; + set => ModelHelper.SetEnum(ref this.comparator, value); + } + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "v")] +#else + [JsonPropertyName("v")] +#endif + public SettingValue ComparisonValue { get; set; } + + object IPrerequisiteFlagCondition.ComparisonValue => ComparisonValue.GetValue()!; + + public override string ToString() + { + return new IndentedTextBuilder() + .AppendPrerequisiteFlagCondition(this) + .ToString(); + } +} diff --git a/src/ConfigCatClient/Models/RolloutPercentageItem.cs b/src/ConfigCatClient/Models/RolloutPercentageItem.cs deleted file mode 100644 index 4d4d7a1c..00000000 --- a/src/ConfigCatClient/Models/RolloutPercentageItem.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; - -#if USE_NEWTONSOFT_JSON -using Newtonsoft.Json; -using JsonValue = Newtonsoft.Json.Linq.JValue; -#else -using System.Text.Json.Serialization; -using JsonValue = System.Text.Json.JsonElement; -#endif - -namespace ConfigCat.Client; - -/// -/// Percentage option. -/// -public interface IPercentageOption -{ - /// - /// A numeric value which determines the order of evaluation. - /// - short Order { get; } - - /// - /// The value associated with the percentage option. - /// - object Value { get; } - - /// - /// A number between 0 and 100 that represents a randomly allocated fraction of the users. - /// - int Percentage { get; } - - /// - /// Variation ID. - /// - string? VariationId { get; } -} - -internal sealed class RolloutPercentageItem : IPercentageOption -{ -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "o")] -#else - [JsonPropertyName("o")] -#endif - public short Order { get; set; } - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "v")] -#else - [JsonPropertyName("v")] -#endif - public JsonValue Value { get; set; } = default!; - - object IPercentageOption.Value => Value.ConvertToObject(Value.DetermineSettingType()); - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "p")] -#else - [JsonPropertyName("p")] -#endif - public int Percentage { get; set; } - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "i")] -#else - [JsonPropertyName("i")] -#endif - public string? VariationId { get; set; } - - /// - public override string ToString() - { - var variationIdString = !string.IsNullOrEmpty(VariationId) ? " [" + VariationId + "]" : string.Empty; - return $"({Order + 1}) {Percentage}% percent of users => {Value}{variationIdString}"; - } -} diff --git a/src/ConfigCatClient/Models/RolloutRule.cs b/src/ConfigCatClient/Models/RolloutRule.cs deleted file mode 100644 index 4128d9c3..00000000 --- a/src/ConfigCatClient/Models/RolloutRule.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; - -#if USE_NEWTONSOFT_JSON -using Newtonsoft.Json; -using JsonValue = Newtonsoft.Json.Linq.JValue; -#else -using System.Text.Json.Serialization; -using JsonValue = System.Text.Json.JsonElement; -#endif - -namespace ConfigCat.Client; - -/// -/// Targeting rule. -/// -public interface ITargetingRule -{ - /// - /// A numeric value which determines the order of evaluation. - /// - short Order { get; } - - /// - /// The attribute that the targeting rule is based on. Can be "User ID", "Email", "Country" or any custom attribute. - /// - string ComparisonAttribute { get; } - - /// - /// The comparison operator. Defines the connection between the attribute and the value. - /// - Comparator Comparator { get; } - - /// - /// The value that the attribute is compared to. Can be a string, a number, a semantic version or a comma-separated list, depending on the comparator. - /// - string ComparisonValue { get; } - - /// - /// The value associated with the targeting rule. - /// - object Value { get; } - - /// - /// Variation ID. - /// - string? VariationId { get; } -} - -internal sealed class RolloutRule : ITargetingRule -{ -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "o")] -#else - [JsonPropertyName("o")] -#endif - public short Order { get; set; } - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "a")] -#else - [JsonPropertyName("a")] -#endif - public string ComparisonAttribute { get; set; } = null!; - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "t")] -#else - [JsonPropertyName("t")] -#endif - public Comparator Comparator { get; set; } - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "c")] -#else - [JsonPropertyName("c")] -#endif - public string ComparisonValue { get; set; } = null!; - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "v")] -#else - [JsonPropertyName("v")] -#endif - public JsonValue Value { get; set; } = default!; - - object ITargetingRule.Value => Value.ConvertToObject(Value.DetermineSettingType()); - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "i")] -#else - [JsonPropertyName("i")] -#endif - public string? VariationId { get; set; } - - internal static string FormatComparator(Comparator comparator) - { - return comparator switch - { - Comparator.In => "IS ONE OF", - Comparator.SemVerIn => "IS ONE OF", - Comparator.NotIn => "IS NOT ONE OF", - Comparator.SemVerNotIn => "IS NOT ONE OF", - Comparator.Contains => "CONTAINS", - Comparator.NotContains => "DOES NOT CONTAIN", - Comparator.SemVerLessThan => "<", - Comparator.NumberLessThan => "<", - Comparator.SemVerLessThanEqual => "<=", - Comparator.NumberLessThanEqual => "<=", - Comparator.SemVerGreaterThan => ">", - Comparator.NumberGreaterThan => ">", - Comparator.SemVerGreaterThanEqual => ">=", - Comparator.NumberGreaterThanEqual => ">=", - Comparator.NumberEqual => "=", - Comparator.NumberNotEqual => "!=", - Comparator.SensitiveOneOf => "IS ONE OF (hashed)", - Comparator.SensitiveNotOneOf => "IS NOT ONE OF (hashed)", - _ => comparator.ToString() - }; - } - - /// - public override string ToString() - { - var variationIdString = !string.IsNullOrEmpty(VariationId) ? " [" + VariationId + "]" : string.Empty; - return $"({Order + 1}) {(Order > 0 ? "ELSE " : string.Empty)}IF user's {ComparisonAttribute} {FormatComparator(Comparator)} '{ComparisonValue}' => {Value}{variationIdString}"; - } -} diff --git a/src/ConfigCatClient/Models/Segment.cs b/src/ConfigCatClient/Models/Segment.cs new file mode 100644 index 00000000..081c62e0 --- /dev/null +++ b/src/ConfigCatClient/Models/Segment.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using ConfigCat.Client.Utils; +using ConfigCat.Client.Evaluation; + +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +/// +/// Describes a segment. +/// +public interface ISegment +{ + /// + /// The name of the segment. + /// + string Name { get; } + + /// + /// The list of segment rule conditions (where there is a logical AND relation between the items). + /// + IReadOnlyList Conditions { get; } +} + +internal sealed class Segment : ISegment +{ +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "n")] +#else + [JsonPropertyName("n")] +#endif + public string? Name { get; set; } + + string ISegment.Name => Name ?? throw new InvalidOperationException("Segment name is missing."); + + private UserCondition[]? conditions; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "r")] +#else + [JsonPropertyName("r")] +#endif + [NotNull] + public UserCondition[]? Conditions + { + get => this.conditions ?? ArrayUtils.EmptyArray(); + set => this.conditions = value; + } + + private IReadOnlyList? conditionsReadOnly; + IReadOnlyList ISegment.Conditions => this.conditionsReadOnly ??= this.conditions is { Length: > 0 } + ? new ReadOnlyCollection(this.conditions) + : ArrayUtils.EmptyArray(); + + public override string ToString() + { + return new IndentedTextBuilder() + .AppendSegment(this) + .ToString(); + } +} diff --git a/src/ConfigCatClient/Models/SegmentComparator.cs b/src/ConfigCatClient/Models/SegmentComparator.cs new file mode 100644 index 00000000..561cbe41 --- /dev/null +++ b/src/ConfigCatClient/Models/SegmentComparator.cs @@ -0,0 +1,17 @@ +namespace ConfigCat.Client; + +/// +/// Segment comparison operator used during the evaluation process. +/// +public enum SegmentComparator : byte +{ + /// + /// IS IN SEGMENT - It matches when the conditions of the specified segment are evaluated to true. + /// + IsIn, + + /// + /// IS NOT IN SEGMENT - It matches when the conditions of the specified segment are evaluated to false. + /// + IsNotIn, +} diff --git a/src/ConfigCatClient/Models/SegmentCondition.cs b/src/ConfigCatClient/Models/SegmentCondition.cs new file mode 100644 index 00000000..95ab69e3 --- /dev/null +++ b/src/ConfigCatClient/Models/SegmentCondition.cs @@ -0,0 +1,73 @@ +using System; +using ConfigCat.Client.Utils; +using ConfigCat.Client.Evaluation; + +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +/// +/// Describes a condition that is based on a segment. +/// +public interface ISegmentCondition : ICondition +{ + /// + /// The segment that the condition is based on. + /// + ISegment Segment { get; } + + /// + /// The operator which defines the expected result of the evaluation of the segment. + /// + SegmentComparator Comparator { get; } +} + +internal sealed class SegmentCondition : Condition, ISegmentCondition +{ + public const SegmentComparator UnknownComparator = (SegmentComparator)byte.MaxValue; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "s")] +#else + [JsonPropertyName("s")] +#endif + public int SegmentIndex { get; set; } = -1; + + [JsonIgnore] + public Segment? Segment { get; private set; } + + ISegment ISegmentCondition.Segment => Segment ?? throw new InvalidOperationException("Segment reference is invalid."); + + private SegmentComparator comparator = UnknownComparator; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "c")] +#else + [JsonPropertyName("c")] +#endif + public SegmentComparator Comparator + { + get => this.comparator; + set => ModelHelper.SetEnum(ref this.comparator, value); + } + + internal void OnConfigDeserialized(Config config) + { + var segments = config.Segments; + if (0 <= SegmentIndex && SegmentIndex < segments.Length) + { + Segment = segments[SegmentIndex]; + } + } + + public override string ToString() + { + return new IndentedTextBuilder() + .AppendSegmentCondition(this) + .ToString(); + } +} diff --git a/src/ConfigCatClient/Models/Setting.cs b/src/ConfigCatClient/Models/Setting.cs index 0e93141a..9acea6bd 100644 --- a/src/ConfigCatClient/Models/Setting.cs +++ b/src/ConfigCatClient/Models/Setting.cs @@ -1,14 +1,13 @@ -using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using ConfigCat.Client.Utils; +using ConfigCat.Client.Evaluation; #if USE_NEWTONSOFT_JSON using Newtonsoft.Json; -using JsonValue = Newtonsoft.Json.Linq.JValue; #else using System.Text.Json.Serialization; -using JsonValue = System.Text.Json.JsonElement; #endif namespace ConfigCat.Client; @@ -16,91 +15,112 @@ namespace ConfigCat.Client; /// /// Feature flag or setting. /// -public interface ISetting +public interface ISetting : ISettingValueContainer { - /// - /// The (fallback) value of the setting. - /// - object Value { get; } - /// /// Setting type. /// SettingType SettingType { get; } /// - /// List of percentage options. + /// The User Object attribute which serves as the basis of percentage options evaluation. /// - IReadOnlyList PercentageOptions { get; } + string PercentageOptionsAttribute { get; } /// - /// List of targeting rules. + /// The list of targeting rules (where there is a logical OR relation between the items). /// IReadOnlyList TargetingRules { get; } /// - /// Variation ID. + /// The list of percentage options. /// - string? VariationId { get; } + IReadOnlyList PercentageOptions { get; } } -internal sealed class Setting : ISetting +internal sealed class Setting : SettingValueContainer, ISetting { -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "v")] -#else - [JsonPropertyName("v")] -#endif - public JsonValue Value { get; set; } = default!; + public const SettingType UnknownType = (SettingType)byte.MaxValue; - object ISetting.Value => Value.ConvertToObject(Value.DetermineSettingType()); + private SettingType settingType = UnknownType; #if USE_NEWTONSOFT_JSON [JsonProperty(PropertyName = "t")] #else [JsonPropertyName("t")] #endif - public SettingType SettingType { get; set; } = SettingType.Unknown; - - private RolloutPercentageItem[]? rolloutPercentageItems; + public SettingType SettingType + { + get => this.settingType; + set => ModelHelper.SetEnum(ref this.settingType, value); + } #if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "p")] + [JsonProperty(PropertyName = "a")] #else - [JsonPropertyName("p"), JsonInclude] + [JsonPropertyName("a")] #endif - public RolloutPercentageItem[] RolloutPercentageItems - { - get => this.rolloutPercentageItems ??= ArrayUtils.EmptyArray(); - private set => this.rolloutPercentageItems = value; - } + [NotNull] + public string? PercentageOptionsAttribute { get; set; } - private IReadOnlyList? percentageOptionsReadOnly; - IReadOnlyList ISetting.PercentageOptions => this.percentageOptionsReadOnly ??= new ReadOnlyCollection(RolloutPercentageItems); + string ISetting.PercentageOptionsAttribute => PercentageOptionsAttribute ?? nameof(User.Identifier); - private RolloutRule[]? rolloutRules; + private TargetingRule[]? targetingRules; #if USE_NEWTONSOFT_JSON [JsonProperty(PropertyName = "r")] #else - [JsonPropertyName("r"), JsonInclude] + [JsonPropertyName("r")] #endif - public RolloutRule[] RolloutRules + [NotNull] + public TargetingRule[]? TargetingRules { - get => this.rolloutRules ??= ArrayUtils.EmptyArray(); - private set => this.rolloutRules = value; + get => this.targetingRules ?? ArrayUtils.EmptyArray(); + set => this.targetingRules = value; } private IReadOnlyList? targetingRulesReadOnly; - IReadOnlyList ISetting.TargetingRules => this.targetingRulesReadOnly ??= new ReadOnlyCollection(RolloutRules); + + IReadOnlyList ISetting.TargetingRules => this.targetingRulesReadOnly ??= this.targetingRules is { Length: > 0 } + ? new ReadOnlyCollection(this.targetingRules) + : ArrayUtils.EmptyArray(); + + private PercentageOption[]? percentageOptions; #if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "i")] + [JsonProperty(PropertyName = "p")] #else - [JsonPropertyName("i")] + [JsonPropertyName("p")] #endif - public string? VariationId { get; set; } + [NotNull] + public PercentageOption[]? PercentageOptions + { + get => this.percentageOptions ?? ArrayUtils.EmptyArray(); + set => this.percentageOptions = value; + } + + private IReadOnlyList? percentageOptionsReadOnly; + IReadOnlyList ISetting.PercentageOptions => this.percentageOptionsReadOnly ??= this.percentageOptions is { Length: > 0 } + ? new ReadOnlyCollection(this.percentageOptions) + : ArrayUtils.EmptyArray(); [JsonIgnore] - public string? UnsupportedTypeError { get; set; } + public string? ConfigJsonSalt { get; private set; } + + internal void OnConfigDeserialized(Config config) + { + ConfigJsonSalt = config.Preferences?.Salt; + + foreach (var targetingRule in TargetingRules) + { + targetingRule.OnConfigDeserialized(config); + } + } + + public override string ToString() + { + return new IndentedTextBuilder() + .AppendSetting(this) + .ToString(); + } } diff --git a/src/ConfigCatClient/Models/SettingType.cs b/src/ConfigCatClient/Models/SettingType.cs index ddbea9ee..89ae30b1 100644 --- a/src/ConfigCatClient/Models/SettingType.cs +++ b/src/ConfigCatClient/Models/SettingType.cs @@ -21,8 +21,4 @@ public enum SettingType : byte /// Decimal number type. /// Double = 3, - /// - /// Unknown type. - /// - Unknown = byte.MaxValue, } diff --git a/src/ConfigCatClient/Models/SettingValue.cs b/src/ConfigCatClient/Models/SettingValue.cs new file mode 100644 index 00000000..15b5300c --- /dev/null +++ b/src/ConfigCatClient/Models/SettingValue.cs @@ -0,0 +1,131 @@ +using System; +using System.Globalization; +using System.Runtime.CompilerServices; +using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Utils; + +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +internal struct SettingValue +{ + private object? value; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "b")] +#else + [JsonPropertyName("b")] +#endif + public bool? BoolValue + { + readonly get => this.value as bool?; + set => ModelHelper.SetOneOf(ref this.value, value?.AsCachedObject()); + } + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "s")] +#else + [JsonPropertyName("s")] +#endif + public string? StringValue + { + readonly get => this.value as string; + set => ModelHelper.SetOneOf(ref this.value, value); + } + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "i")] +#else + [JsonPropertyName("i")] +#endif + public int? IntValue + { + readonly get => this.value as int?; + set => ModelHelper.SetOneOf(ref this.value, value); + } + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "d")] +#else + [JsonPropertyName("d")] +#endif + public double? DoubleValue + { + readonly get => this.value as double?; + set => ModelHelper.SetOneOf(ref this.value, value); + } + + [JsonIgnore] + public object? UnsupportedValue + { + readonly get => (this.value as StrongBox)?.Value; + set => this.value = new StrongBox(value); + } + + [JsonIgnore] + public readonly bool HasUnsupportedValue => this.value is StrongBox; + + public readonly object? GetValue(bool throwIfInvalid = true) + { + if (!ModelHelper.IsValidOneOf(this.value) || HasUnsupportedValue) + { + if (!throwIfInvalid) + { + return null; + } + + // Value comes from a dictionary or simplified JSON flag override? + if (HasUnsupportedValue) + { + var unsupportedValue = UnsupportedValue; + throw new InvalidOperationException(unsupportedValue is not null + ? $"Setting value '{unsupportedValue}' is of an unsupported type ({unsupportedValue.GetType()})." + : "Setting value is null."); + } + // Value is missing or multiple values specified in the config JSON? + else + { + throw new InvalidOperationException("Setting value is missing or invalid."); + } + } + + return this.value; + } + + public readonly object? GetValue(SettingType settingType, bool throwIfInvalid = true) + { + var value = GetValue(throwIfInvalid); + + if (value is null || value.GetType().ToSettingType() != settingType) + { + return !throwIfInvalid ? null : throw new InvalidOperationException($"Setting value is not of the expected type {settingType}."); + } + + return value; + } + + public readonly TValue GetValue(SettingType settingType) + { + var value = GetValue(settingType)!; + + // In the case of Int settings, we also allow long and long? return types. + return typeof(TValue) switch + { + _ when typeof(TValue) == typeof(long) => value.Cast(ObjectExtensions.BoxedIntToLong), + _ when typeof(TValue) == typeof(long?) => value.Cast(ObjectExtensions.BoxedIntToNullableLong), + _ => (TValue)value, + }; + } + + public override readonly string ToString() + { + return GetValue(throwIfInvalid: false) is { } value + ? Convert.ToString(value, CultureInfo.InvariantCulture)! + : EvaluateLogHelper.InvalidValuePlaceholder; + } +} diff --git a/src/ConfigCatClient/Models/SettingValueContainer.cs b/src/ConfigCatClient/Models/SettingValueContainer.cs new file mode 100644 index 00000000..278bd6af --- /dev/null +++ b/src/ConfigCatClient/Models/SettingValueContainer.cs @@ -0,0 +1,49 @@ +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +/// +/// A model object which contains a setting value along with related data. +/// +public interface ISettingValueContainer +{ + /// + /// Setting value. + /// Can be a value of the following types: , , or . + /// + object Value { get; } + + /// + /// Variation ID. + /// + string? VariationId { get; } +} + +internal abstract class SettingValueContainer : ISettingValueContainer +{ +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "v")] +#else + [JsonPropertyName("v")] +#endif + public SettingValue Value { get; set; } + + object ISettingValueContainer.Value => Value.GetValue()!; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "i")] +#else + [JsonPropertyName("i")] +#endif + public string? VariationId { get; set; } +} + +// NOTE: This sealed class is for fast type checking in TargetingRule.SimpleValue +// (see also https://stackoverflow.com/a/70065177/8656352). +internal sealed class SimpleSettingValue : SettingValueContainer +{ +} diff --git a/src/ConfigCatClient/Models/SettingsWithPreferences.cs b/src/ConfigCatClient/Models/SettingsWithPreferences.cs deleted file mode 100644 index 6f7ed835..00000000 --- a/src/ConfigCatClient/Models/SettingsWithPreferences.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -#if USE_NEWTONSOFT_JSON -using Newtonsoft.Json; -#else -using System.Text.Json.Serialization; -#endif - -namespace ConfigCat.Client; - -/// -/// ConfigCat config. -/// -public interface IConfig -{ - /// - /// The dictionary of settings. - /// - IReadOnlyDictionary Settings { get; } -} - -internal sealed class SettingsWithPreferences : IConfig -{ - private Dictionary? settings; - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "f")] -#else - [JsonPropertyName("f"), JsonInclude] -#endif - public Dictionary Settings - { - get => this.settings ??= new Dictionary(); - private set => this.settings = value; - } - - private IReadOnlyDictionary? settingsReadOnly; - IReadOnlyDictionary IConfig.Settings => this.settingsReadOnly ??= Settings.ToDictionary(kvp => kvp.Key, kvp => (ISetting)kvp.Value); - -#if USE_NEWTONSOFT_JSON - [JsonProperty(PropertyName = "p")] -#else - [JsonPropertyName("p")] -#endif - public Preferences? Preferences { get; set; } -} diff --git a/src/ConfigCatClient/Models/TargetingRule.cs b/src/ConfigCatClient/Models/TargetingRule.cs new file mode 100644 index 00000000..6cb3ccbb --- /dev/null +++ b/src/ConfigCatClient/Models/TargetingRule.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using ConfigCat.Client.Utils; +using ConfigCat.Client.Evaluation; + +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +/// +/// Describes a targeting rule. +/// +public interface ITargetingRule +{ + /// + /// The list of conditions that are combined with the AND logical operator. + /// Items can be one of the following types: , or . + /// + IReadOnlyList Conditions { get; } + + /// + /// The list of percentage options associated with the targeting rule or if the targeting rule has a simple value THEN part. + /// + IReadOnlyList? PercentageOptions { get; } + + /// + /// The simple value associated with the targeting rule or if the targeting rule has percentage options THEN part. + /// + ISettingValueContainer? SimpleValue { get; } +} + +internal sealed class TargetingRule : ITargetingRule +{ + private ConditionContainer[]? conditions; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "c")] +#else + [JsonPropertyName("c")] +#endif + [NotNull] + public ConditionContainer[]? Conditions + { + get => this.conditions ?? ArrayUtils.EmptyArray(); + set => this.conditions = value; + } + + private IReadOnlyList? conditionsReadOnly; + IReadOnlyList ITargetingRule.Conditions => this.conditionsReadOnly ??= this.conditions is { Length: > 0 } conditions + ? conditions.Select(condition => condition.GetCondition()!).ToArray() + : ArrayUtils.EmptyArray(); + + private object? then; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "p")] +#else + [JsonPropertyName("p")] +#endif + public PercentageOption[]? PercentageOptions + { + get => this.then as PercentageOption[]; + set => ModelHelper.SetOneOf(ref this.then, value); + } + + private IReadOnlyList? percentageOptionsReadOnly; + IReadOnlyList? ITargetingRule.PercentageOptions => this.percentageOptionsReadOnly ??= this.then is PercentageOption[] percentageOptions + ? (percentageOptions.Length > 0 ? new ReadOnlyCollection(percentageOptions) : ArrayUtils.EmptyArray()) + : null; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "s")] +#else + [JsonPropertyName("s")] +#endif + public SimpleSettingValue? SimpleValue + { + get => this.then as SimpleSettingValue; + set => ModelHelper.SetOneOf(ref this.then, value); + } + + ISettingValueContainer? ITargetingRule.SimpleValue => SimpleValue; + + internal void OnConfigDeserialized(Config config) + { + foreach (var condition in Conditions) + { + if (condition.GetCondition(throwIfInvalid: false) is SegmentCondition segmentCondition) + { + segmentCondition.OnConfigDeserialized(config); + } + } + } + + public override string ToString() + { + return new IndentedTextBuilder() + .AppendTargetingRule(this) + .ToString(); + } +} diff --git a/src/ConfigCatClient/Models/UserComparator.cs b/src/ConfigCatClient/Models/UserComparator.cs new file mode 100644 index 00000000..0ea2273a --- /dev/null +++ b/src/ConfigCatClient/Models/UserComparator.cs @@ -0,0 +1,187 @@ +namespace ConfigCat.Client; + +/// +/// User Object attribute comparison operator used during the evaluation process. +/// +public enum UserComparator : byte +{ + /// + /// IS ONE OF (cleartext) - It matches when the comparison attribute is equal to any of the comparison values. + /// + IsOneOf = 0, + + /// + /// IS NOT ONE OF (cleartext) - It matches when the comparison attribute is not equal to any of the comparison values. + /// + IsNotOneOf = 1, + + /// + /// CONTAINS ANY OF (cleartext) - It matches when the comparison attribute contains any comparison values as a substring. + /// + ContainsAnyOf = 2, + + /// + /// NOT CONTAINS ANY OF (cleartext) - It matches when the comparison attribute does not contain any comparison values as a substring. + /// + NotContainsAnyOf = 3, + + /// + /// IS ONE OF (semver) - It matches when the comparison attribute interpreted as a semantic version is equal to any of the comparison values. + /// + SemVerIsOneOf = 4, + + /// + /// IS NOT ONE OF (semver) - It matches when the comparison attribute interpreted as a semantic version is not equal to any of the comparison values. + /// + SemVerIsNotOneOf = 5, + + /// + /// < (semver) - It matches when the comparison attribute interpreted as a semantic version is less than the comparison value. + /// + SemVerLess = 6, + + /// + /// <= (semver) - It matches when the comparison attribute interpreted as a semantic version is less than or equal to the comparison value. + /// + SemVerLessOrEquals = 7, + + /// + /// > (semver) - It matches when the comparison attribute interpreted as a semantic version is greater than the comparison value. + /// + SemVerGreater = 8, + + /// + /// >= (semver) - It matches when the comparison attribute interpreted as a semantic version is greater than or equal to the comparison value. + /// + SemVerGreaterOrEquals = 9, + + /// + /// = (number) - It matches when the comparison attribute interpreted as a decimal number is equal to the comparison value. + /// + NumberEquals = 10, + + /// + /// != (number) - It matches when the comparison attribute interpreted as a decimal number is not equal to the comparison value. + /// + NumberNotEquals = 11, + + /// + /// < (number) - It matches when the comparison attribute interpreted as a decimal number is less than the comparison value. + /// + NumberLess = 12, + + /// + /// <= (number) - It matches when the comparison attribute interpreted as a decimal number is less than or equal to the comparison value. + /// + NumberLessOrEquals = 13, + + /// + /// > (number) - It matches when the comparison attribute interpreted as a decimal number is greater than the comparison value. + /// + NumberGreater = 14, + + /// + /// >= (number) - It matches when the comparison attribute interpreted as a decimal number is greater than or equal to the comparison value. + /// + NumberGreaterOrEquals = 15, + + /// + /// IS ONE OF (hashed) - It matches when the comparison attribute is equal to any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveIsOneOf = 16, + + /// + /// IS NOT ONE OF (hashed) - It matches when the comparison attribute is not equal to any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveIsNotOneOf = 17, + + /// + /// BEFORE (UTC datetime) - It matches when the comparison attribute interpreted as the seconds elapsed since Unix Epoch is less than the comparison value. + /// + DateTimeBefore = 18, + + /// + /// AFTER (UTC datetime) - It matches when the comparison attribute interpreted as the seconds elapsed since Unix Epoch is greater than the comparison value. + /// + DateTimeAfter = 19, + + /// + /// EQUALS (hashed) - It matches when the comparison attribute is equal to the comparison value (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveTextEquals = 20, + + /// + /// NOT EQUALS (hashed) - It matches when the comparison attribute is not equal to the comparison value (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveTextNotEquals = 21, + + /// + /// STARTS WITH ANY OF (hashed) - It matches when the comparison attribute starts with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveTextStartsWithAnyOf = 22, + + /// + /// NOT STARTS WITH ANY OF (hashed) - It matches when the comparison attribute does not start with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveTextNotStartsWithAnyOf = 23, + + /// + /// ENDS WITH ANY OF (hashed) - It matches when the comparison attribute ends with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveTextEndsWithAnyOf = 24, + + /// + /// NOT ENDS WITH ANY OF (hashed) - It matches when the comparison attribute does not end with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveTextNotEndsWithAnyOf = 25, + + /// + /// ARRAY CONTAINS ANY OF (hashed) - It matches when the comparison attribute interpreted as a comma-separated list contains any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveArrayContainsAnyOf = 26, + + /// + /// ARRAY NOT CONTAINS ANY OF (hashed) - It matches when the comparison attribute interpreted as a comma-separated list does not contain any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). + /// + SensitiveArrayNotContainsAnyOf = 27, + + /// + /// EQUALS (cleartext) - It matches when the comparison attribute is equal to the comparison value. + /// + TextEquals = 28, + + /// + /// NOT EQUALS (cleartext) - It matches when the comparison attribute is not equal to the comparison value. + /// + TextNotEquals = 29, + + /// + /// STARTS WITH ANY OF (cleartext) - It matches when the comparison attribute starts with any of the comparison values. + /// + TextStartsWithAnyOf = 30, + + /// + /// NOT STARTS WITH ANY OF (cleartext) - It matches when the comparison attribute does not start with any of the comparison values. + /// + TextNotStartsWithAnyOf = 31, + + /// + /// ENDS WITH ANY OF (cleartext) - It matches when the comparison attribute ends with any of the comparison values. + /// + TextEndsWithAnyOf = 32, + + /// + /// NOT ENDS WITH ANY OF (cleartext) - It matches when the comparison attribute does not end with any of the comparison values. + /// + TextNotEndsWithAnyOf = 33, + + /// + /// ARRAY CONTAINS ANY OF (cleartext) - It matches when the comparison attribute interpreted as a comma-separated list contains any of the comparison values. + /// + ArrayContainsAnyOf = 34, + + /// + /// ARRAY NOT CONTAINS ANY OF (cleartext) - It matches when the comparison attribute interpreted as a comma-separated list does not contain any of the comparison values. + /// + ArrayNotContainsAnyOf = 35, +} diff --git a/src/ConfigCatClient/Models/UserCondition.cs b/src/ConfigCatClient/Models/UserCondition.cs new file mode 100644 index 00000000..654176ae --- /dev/null +++ b/src/ConfigCatClient/Models/UserCondition.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.ObjectModel; +using ConfigCat.Client.Utils; +using ConfigCat.Client.Evaluation; +using System.Collections.Generic; + +#if USE_NEWTONSOFT_JSON +using Newtonsoft.Json; +#else +using System.Text.Json.Serialization; +#endif + +namespace ConfigCat.Client; + +/// +/// Describes a condition that is based on a User Object attribute. +/// +public interface IUserCondition : ICondition +{ + /// + /// The User Object attribute that the condition is based on. Can be "Identifier", "Email", "Country" or any custom attribute. + /// + string ComparisonAttribute { get; } + + /// + /// The operator which defines the relation between the comparison attribute and the comparison value. + /// + UserComparator Comparator { get; } + + /// + /// The value that the User Object attribute is compared to. + /// Can be a value of the following types: (including a semantic version), or where T is . + /// + object ComparisonValue { get; } +} + +internal sealed class UserCondition : Condition, IUserCondition +{ + public const UserComparator UnknownComparator = (UserComparator)byte.MaxValue; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "a")] +#else + [JsonPropertyName("a")] +#endif + public string? ComparisonAttribute { get; set; } + + string IUserCondition.ComparisonAttribute => ComparisonAttribute ?? throw new InvalidOperationException("Comparison attribute name is missing."); + + private UserComparator comparator = UnknownComparator; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "c")] +#else + [JsonPropertyName("c")] +#endif + public UserComparator Comparator + { + get => this.comparator; + set => ModelHelper.SetEnum(ref this.comparator, value); + } + + private object? comparisonValue; + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "s")] +#else + [JsonPropertyName("s")] +#endif + public string? StringValue + { + get => this.comparisonValue as string; + set => ModelHelper.SetOneOf(ref this.comparisonValue, value); + } + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "d")] +#else + [JsonPropertyName("d")] +#endif + public double? DoubleValue + { + get => this.comparisonValue as double?; + set => ModelHelper.SetOneOf(ref this.comparisonValue, value); + } + +#if USE_NEWTONSOFT_JSON + [JsonProperty(PropertyName = "l")] +#else + [JsonPropertyName("l")] +#endif + public string[]? StringListValue + { + get => this.comparisonValue as string[]; + set => ModelHelper.SetOneOf(ref this.comparisonValue, value); + } + + private object? comparisonValueReadOnly; + + object IUserCondition.ComparisonValue => this.comparisonValueReadOnly ??= GetComparisonValue() is var comparisonValue && comparisonValue is string[] stringListValue + ? (stringListValue.Length > 0 ? new ReadOnlyCollection(stringListValue) : ArrayUtils.EmptyArray()) + : comparisonValue!; + + public object? GetComparisonValue(bool throwIfInvalid = true) + { + return ModelHelper.IsValidOneOf(this.comparisonValue) + ? this.comparisonValue + : (!throwIfInvalid ? null : throw new InvalidOperationException("Comparison value is missing or invalid.")); + } + + public override string ToString() + { + return new IndentedTextBuilder() + .AppendUserCondition(this) + .ToString(); + } +} diff --git a/src/ConfigCatClient/NullableAttributes.cs b/src/ConfigCatClient/NullableAttributes.cs index 36b70736..0911e8da 100644 --- a/src/ConfigCatClient/NullableAttributes.cs +++ b/src/ConfigCatClient/NullableAttributes.cs @@ -9,6 +9,11 @@ namespace System.Diagnostics.CodeAnalysis { // These attributes already shipped with .NET Core 3.1 in System.Runtime #if !NETCOREAPP3_0_OR_GREATER && !NETSTANDARD2_1_OR_GREATER + /// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class NotNullAttribute : Attribute + { } + /// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] internal sealed class NotNullWhenAttribute : Attribute diff --git a/src/ConfigCatClient/Override/LocalFileDataSource.cs b/src/ConfigCatClient/Override/LocalFileDataSource.cs index a9eaa3e3..67579fd1 100644 --- a/src/ConfigCatClient/Override/LocalFileDataSource.cs +++ b/src/ConfigCatClient/Override/LocalFileDataSource.cs @@ -111,7 +111,7 @@ private async Task ReloadFileAsync(bool isAsync, CancellationToken cancellationT break; } - var deserialized = content.Deserialize() + var deserialized = content.Deserialize() ?? throw new InvalidOperationException("Invalid config JSON content: " + content); this.overrideValues = deserialized.Settings; break; diff --git a/src/ConfigCatClient/ProjectConfig.cs b/src/ConfigCatClient/ProjectConfig.cs index 0b57bda9..87b22cf8 100644 --- a/src/ConfigCatClient/ProjectConfig.cs +++ b/src/ConfigCatClient/ProjectConfig.cs @@ -11,9 +11,10 @@ internal sealed class ProjectConfig public static readonly ProjectConfig Empty = new(null, null, DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc), null); - public ProjectConfig(string? configJson, SettingsWithPreferences? config, DateTime timeStamp, string? httpETag) + public ProjectConfig(string? configJson, Config? config, DateTime timeStamp, string? httpETag) { Debug.Assert(!(configJson is null ^ config is null), $"{nameof(configJson)} and {nameof(config)} must be both null or both not null."); + Debug.Assert(timeStamp.Kind == DateTimeKind.Utc, "Timestamp must be a UTC datetime."); ConfigJson = configJson; Config = config; @@ -24,7 +25,7 @@ public ProjectConfig(string? configJson, SettingsWithPreferences? config, DateTi public ProjectConfig With(DateTime timeStamp) => new ProjectConfig(ConfigJson, Config, timeStamp, HttpETag); public string? ConfigJson { get; } - public SettingsWithPreferences? Config { get; } + public Config? Config { get; } public DateTime TimeStamp { get; } public string? HttpETag { get; } @@ -82,11 +83,11 @@ public static ProjectConfig Deserialize(string value) index = endIndex + 1; var configJsonSpan = value.AsSpan(index); - SettingsWithPreferences? config; + Config? config; string? configJson; if (configJsonSpan.Length > 0) { - config = configJsonSpan.DeserializeOrDefault(); + config = configJsonSpan.DeserializeOrDefault(); if (config is null) { throw new FormatException("Invalid config JSON content: " + configJsonSpan.ToString()); diff --git a/src/ConfigCatClient/User.cs b/src/ConfigCatClient/User.cs index f4b6c920..2291ba2c 100644 --- a/src/ConfigCatClient/User.cs +++ b/src/ConfigCatClient/User.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; + #if USE_NEWTONSOFT_JSON using Newtonsoft.Json; #else @@ -10,6 +12,10 @@ namespace ConfigCat.Client; /// /// User Object. Contains user attributes which are used for evaluating targeting rules and percentage options. /// +/// +/// Please note that the class is not designed to be used as a DTO (data transfer object). +/// (Since the type of the property is polymorphic, it's not guaranteed that deserializing a serialized instance produces an instance with an identical or even valid data content.) +/// public class User { internal const string DefaultIdentifierValue = ""; @@ -17,7 +23,7 @@ public class User /// /// The unique identifier of the user or session (e.g. email address, primary key, session ID, etc.) /// - public string Identifier { get; private set; } + public string Identifier { get; } /// /// Email address of the user. @@ -29,39 +35,82 @@ public class User /// public string? Country { get; set; } + private IDictionary? custom; + /// /// Custom attributes of the user for advanced targeting rule definitions (e.g. user role, subscription type, etc.) /// - public IDictionary Custom { get; set; } - + /// + /// The set of allowed attribute values depends on the comparison type of the condition which references the User Object attribute.
+ /// values are supported by all comparison types (in some cases they need to be provided in a specific format though).
+ /// Some of the comparison types work with other types of values, as descibed below. + /// + /// Text-based comparisons (EQUALS, IS ONE OF, etc.)
+ /// * accept values,
+ /// * all other values are automatically converted to string (a warning will be logged but evaluation will continue as normal). + ///
+ /// + /// SemVer-based comparisons (IS ONE OF, <, >=, etc.)
+ /// * accept values containing a properly formatted, valid semver value,
+ /// * all other values are considered invalid (a warning will be logged and the currently evaluated targeting rule will be skipped). + ///
+ /// + /// Number-based comparisons (=, <, >=, etc.)
+ /// * accept values and all other numeric values which can safely be converted to ,
+ /// * accept values containing a properly formatted, valid value,
+ /// * all other values are considered invalid (a warning will be logged and the currently evaluated targeting rule will be skipped). + ///
+ /// + /// Date time-based comparisons (BEFORE / AFTER)
+ /// * accept or values, which are automatically converted to a second-based Unix timestamp,
+ /// * accept values representing a second-based Unix timestamp and all other numeric values which can safely be converted to ,
+ /// * accept values containing a properly formatted, valid value,
+ /// * all other values are considered invalid (a warning will be logged and the currently evaluated targeting rule will be skipped). + ///
+ /// + /// String array-based comparisons (ARRAY CONTAINS ANY OF / ARRAY NOT CONTAINS ANY OF)
+ /// * accept arrays of ,
+ /// * accept values containing a valid JSON string which can be deserialized to an array of ,
+ /// * all other values are considered invalid (a warning will be logged and the currently evaluated targeting rule will be skipped). + ///
+ /// In case a non-string attribute value needs to be converted to during evaluation, it will always be done using the same format which is accepted by the comparisons. + ///
+ public IDictionary Custom + { + get => this.custom ??= new Dictionary(); + set => this.custom = value; + } /// /// Returns all attributes of the user. /// - [JsonIgnore] - public IReadOnlyDictionary AllAttributes + public IReadOnlyDictionary GetAllAttributes() { - get + var result = new Dictionary(); + + result[nameof(Identifier)] = Identifier; + + if (Email is not null) { - var result = new Dictionary - { - { nameof(Identifier), Identifier}, - { nameof(Email), Email}, - { nameof(Country), Country}, - }; + result[nameof(Email)] = Email; + } - if (Custom is not { Count: > 0 }) - return result; + if (Country is not null) + { + result[nameof(Country)] = Country; + } - foreach (var item in Custom) + if (this.custom is { Count: > 0 }) + { + foreach (var item in this.custom) { - if (item.Key is not (nameof(Identifier) or nameof(Email) or nameof(Country))) + if (item.Value is not null && item.Key is not (nameof(Identifier) or nameof(Email) or nameof(Country))) { result.Add(item.Key, item.Value); } } - - return result; } + + return result; } /// @@ -71,7 +120,6 @@ public class User public User(string identifier) { Identifier = string.IsNullOrEmpty(identifier) ? DefaultIdentifierValue : identifier; - Custom = new Dictionary(capacity: 0); } /// diff --git a/src/ConfigCatClient/Utils/ArrayUtils.cs b/src/ConfigCatClient/Utils/ArrayUtils.cs index daa8c0eb..b9cd24c5 100644 --- a/src/ConfigCatClient/Utils/ArrayUtils.cs +++ b/src/ConfigCatClient/Utils/ArrayUtils.cs @@ -42,4 +42,37 @@ public static string ToHexString(this byte[] bytes) return new string(chars); #endif } + + public static bool Equals(this byte[] bytes, ReadOnlySpan hexString) + { + if (bytes.Length * 2 != hexString.Length) + { + return false; + } + + for (int i = 0, j = 0; i < bytes.Length; i++) + { + int hi, lo; + if ((hi = GetDigitValue(hexString[j++])) < 0 + || (lo = GetDigitValue(hexString[j++])) < 0) + { + return false; + } + + var decodedByte = (byte)(hi << 4 | lo); + if (decodedByte != bytes[i]) + { + return false; + } + } + + return true; + + static int GetDigitValue(char digit) => digit switch + { + >= '0' and <= '9' => digit - 0x30, + >= 'a' and <= 'f' => digit - 0x57, + _ => -1, + }; + } } diff --git a/src/ConfigCatClient/Utils/DateTimeUtils.cs b/src/ConfigCatClient/Utils/DateTimeUtils.cs index dba653d8..38a58ece 100644 --- a/src/ConfigCatClient/Utils/DateTimeUtils.cs +++ b/src/ConfigCatClient/Utils/DateTimeUtils.cs @@ -1,40 +1,39 @@ using System; +using System.Diagnostics; using System.Globalization; namespace ConfigCat.Client.Utils; internal static class DateTimeUtils { - public static string ToUnixTimeStamp(this DateTime dateTime) + public static long ToUnixTimeMilliseconds(this DateTime dateTime) { + // NOTE: Internally we should always work with UTC datetime values (as DateTimeKind.Unspecified can lead to incorrect results). + Debug.Assert(dateTime.Kind == DateTimeKind.Utc, "Non-UTC datetime encountered."); + #if !NET45 - var milliseconds = new DateTimeOffset(dateTime).ToUnixTimeMilliseconds(); + return new DateTimeOffset(dateTime).ToUnixTimeMilliseconds(); #else // Based on: https://github.com/dotnet/runtime/blob/v6.0.13/src/libraries/System.Private.CoreLib/src/System/DateTimeOffset.cs#L629 const long unixEpochMilliseconds = 62_135_596_800_000L; - var milliseconds = dateTime.Ticks / TimeSpan.TicksPerMillisecond - unixEpochMilliseconds; + return dateTime.Ticks / TimeSpan.TicksPerMillisecond - unixEpochMilliseconds; #endif - - return milliseconds.ToString(CultureInfo.InvariantCulture); } - public static bool TryParseUnixTimeStamp(ReadOnlySpan span, out DateTime dateTime) + public static string ToUnixTimeStamp(this DateTime dateTime) { -#if NET5_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - var slice = span; -#else - var slice = span.ToString(); -#endif + return ToUnixTimeMilliseconds(dateTime).ToString(CultureInfo.InvariantCulture); + } - if (!long.TryParse(slice, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var milliseconds)) + public static bool TryConvertFromUnixTimeMilliseconds(long milliseconds, out DateTime dateTime) + { +#if !NET45 + try { - dateTime = default; - return false; + dateTime = DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).UtcDateTime; + return true; } - -#if !NET45 - try { dateTime = DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).UtcDateTime; } catch (ArgumentOutOfRangeException) { dateTime = default; @@ -55,8 +54,31 @@ public static bool TryParseUnixTimeStamp(ReadOnlySpan span, out DateTime d var ticks = (milliseconds + unixEpochMilliseconds) * TimeSpan.TicksPerMillisecond; dateTime = new DateTime(ticks, DateTimeKind.Utc); + return true; #endif + } - return true; + public static bool TryConvertFromUnixTimeSeconds(double seconds, out DateTime dateTime) + { + long milliseconds; + try { milliseconds = checked((long)(seconds * 1000)); } + catch (OverflowException) + { + dateTime = default; + return false; + } + + return TryConvertFromUnixTimeMilliseconds(milliseconds, out dateTime); + } + + public static bool TryParseUnixTimeStamp(ReadOnlySpan span, out DateTime dateTime) + { + if (!long.TryParse(span.ToParsable(), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var milliseconds)) + { + dateTime = default; + return false; + } + + return TryConvertFromUnixTimeMilliseconds(milliseconds, out dateTime); } } diff --git a/src/ConfigCatClient/Utils/IndentedTextBuilder.cs b/src/ConfigCatClient/Utils/IndentedTextBuilder.cs new file mode 100644 index 00000000..b445b87f --- /dev/null +++ b/src/ConfigCatClient/Utils/IndentedTextBuilder.cs @@ -0,0 +1,99 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; + +namespace ConfigCat.Client.Utils; + +internal sealed class IndentedTextBuilder +{ + private readonly StringBuilder stringBuilder = new(); + private int indentLevel; + + public IndentedTextBuilder ResetIndent() + { + this.indentLevel = 0; + return this; + } + + public IndentedTextBuilder IncreaseIndent() + { + this.indentLevel++; + return this; + } + + public IndentedTextBuilder DecreaseIndent() + { + Debug.Assert(this.indentLevel > 0, "Indentation got invalid."); + this.indentLevel--; + return this; + } + + public IndentedTextBuilder NewLine() + { + this.stringBuilder.AppendLine().Insert(this.stringBuilder.Length, " ", count: this.indentLevel); + return this; + } + + public IndentedTextBuilder NewLine(string message) + { + return NewLine().Append(message); + } + + public IndentedTextBuilder Append(object value) + { + this.stringBuilder.Append(Convert.ToString(value, CultureInfo.InvariantCulture)); + return this; + } + +#if !NET6_0_OR_GREATER + public IndentedTextBuilder Append(FormattableString value) + { + this.stringBuilder.AppendFormat(CultureInfo.InvariantCulture, value.Format, value.GetArguments()); + return this; + } +#else + public IndentedTextBuilder Append([InterpolatedStringHandlerArgument("")] ref AppendInterpolatedStringHandler _) + { + // NOTE: The actual work is done by AppendInterpolatedStringHandler. + return this; + } + + // Using this wrapper struct we can benefit from .NET 6's performance improvements to interpolated strings + // (see also https://blog.jetbrains.com/dotnet/2022/02/07/improvements-and-optimizations-for-interpolated-strings-a-look-at-new-language-features-in-csharp-10/). + [InterpolatedStringHandler] + public ref struct AppendInterpolatedStringHandler + { + private StringBuilder.AppendInterpolatedStringHandler handler; + + public AppendInterpolatedStringHandler(int literalLength, int formattedCount, IndentedTextBuilder logBuilder) + { + this.handler = new StringBuilder.AppendInterpolatedStringHandler(literalLength, formattedCount, logBuilder.stringBuilder, CultureInfo.InvariantCulture); + } + + public void AppendLiteral(string value) => this.handler.AppendLiteral(value); + + public void AppendFormatted(T value) => this.handler.AppendFormatted(value); + public void AppendFormatted(T value, string? format) => this.handler.AppendFormatted(value, format); + public void AppendFormatted(T value, int alignment) => this.handler.AppendFormatted(value, alignment); + public void AppendFormatted(T value, int alignment, string? format) => this.handler.AppendFormatted(value, alignment, format); + + public void AppendFormatted(ReadOnlySpan value) => this.handler.AppendFormatted(value); + public void AppendFormatted(ReadOnlySpan value, int alignment = 0, string? format = null) => this.handler.AppendFormatted(value, alignment, format); + + public void AppendFormatted(string? value) => this.handler.AppendFormatted(value); + public void AppendFormatted(string? value, int alignment = 0, string? format = null) => this.handler.AppendFormatted(value, alignment, format); + + public void AppendFormatted(object? value, int alignment = 0, string? format = null) => this.handler.AppendFormatted(value, alignment, format); + + public void AppendFormatted(StringListFormatter value) => value.AppendWith(ref this.handler); + public void AppendFormatted(StringListFormatter value, string? format) => value.AppendWith(ref this.handler, format); + } +#endif + + public override string ToString() + { + return this.stringBuilder.ToString(); + } +} diff --git a/src/ConfigCatClient/Utils/ModelHelper.cs b/src/ConfigCatClient/Utils/ModelHelper.cs new file mode 100644 index 00000000..8a9eae3b --- /dev/null +++ b/src/ConfigCatClient/Utils/ModelHelper.cs @@ -0,0 +1,38 @@ +using System; + +namespace ConfigCat.Client.Utils; + +internal static class ModelHelper +{ + private static readonly object MultipleValuesToken = new(); + + public static void SetOneOf(ref object? field, T? value) + { + if (value is not null) + { + field = field is null ? value : MultipleValuesToken; + } + } + + public static bool IsValidOneOf(object? field) + { + return field is not null && !ReferenceEquals(field, MultipleValuesToken); + } + + public static void SetEnum(ref TEnum field, TEnum value) where TEnum : struct, Enum + { + // NOTE: System.Text.Json throws when it encounters an undefined enum value but Newtonsoft.Json doesn't. + // It just sets the property to the undefined numeric value. Unfortunately, there's no simple solution to this. + // Multiple workarounds exist, probably this is the lesser evil: https://github.com/dotnet/runtime/issues/42093#issuecomment-692276834 + // TODO: get rid of the workaround when we drop support for .NET 4.5. + + field = +#if NET5_0_OR_GREATER + Enum.IsDefined(value) +#else + Enum.IsDefined(typeof(TEnum), value) +#endif + ? value + : throw new ArgumentOutOfRangeException(nameof(value), value, null); + } +} diff --git a/src/ConfigCatClient/Utils/StringListFormatter.cs b/src/ConfigCatClient/Utils/StringListFormatter.cs new file mode 100644 index 00000000..924e007c --- /dev/null +++ b/src/ConfigCatClient/Utils/StringListFormatter.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace ConfigCat.Client.Utils; + +internal readonly struct StringListFormatter : IFormattable +{ + private readonly ICollection collection; + private readonly int maxLength; + private readonly Func? getOmittedItemsText; + + public StringListFormatter(ICollection collection, int maxLength = 0, Func? getOmittedItemsText = null) + { + this.collection = collection; + this.maxLength = maxLength; + this.getOmittedItemsText = getOmittedItemsText; + } + + private static string GetSeparator(string? format) => format == "a" ? "' -> '" : "', '"; + +#if NET6_0_OR_GREATER + public void AppendWith(ref StringBuilder.AppendInterpolatedStringHandler handler, string? format = null) + { + if (this.collection is { Count: > 0 }) + { + var i = 0; + var n = this.maxLength > 0 && this.collection.Count > this.maxLength ? this.maxLength : this.collection.Count; + var separator = GetSeparator(format); + var currentSeparator = string.Empty; + + handler.AppendLiteral("'"); + foreach (var item in this.collection) + { + handler.AppendLiteral(currentSeparator); + handler.AppendLiteral(item); + currentSeparator = separator; + + if (++i >= n) + { + break; + } + } + handler.AppendLiteral("'"); + + if (this.getOmittedItemsText is not null && n < this.collection.Count) + { + handler.AppendLiteral(this.getOmittedItemsText(this.collection.Count - this.maxLength)); + } + } + } +#endif + + public string ToString(string? format, IFormatProvider? formatProvider) + { + if (this.collection is { Count: > 0 }) + { + IEnumerable items = this.collection; + string appendix; + + if (this.maxLength > 0 && this.collection.Count > this.maxLength) + { + items = items.Take(this.maxLength); + appendix = this.getOmittedItemsText?.Invoke(this.collection.Count - this.maxLength) ?? string.Empty; + } + else + { + appendix = string.Empty; + } + + return "'" + string.Join(GetSeparator(format), items) + "'" + appendix; + } + + return string.Empty; + } + + public override string ToString() => ToString(null, null); +} diff --git a/src/ConfigCatClient/strongname.snk b/src/ConfigCatClient/strongname.snk deleted file mode 100644 index b6c29622f4faa690bf62767836051fa8d18717d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50098o>p%3wB1%8=<$6sQgGOVHBb2Uhp+ zvT4#Vc>_m+#X~?6MkE`1y-n^OAY8Sh@L&ASXchpWSBqxwCukz`nS+U9bc7V=>^Hdb zxctO=b3hPAgNHv)oo4TH>`;ol)@T$a9&}Cg<6!}bl|AAx7At#ki@vY!f+SwYhU=3t zk*x-gTSMX68|q12HhWkpmDNrlH6!?-IbWhunWHO`d8G$xQesC5#X*K)tmg73X9>w&SC}grT=aM#aD?Eg7N0*|`OMoLI44HkL^H&GW{?@_4)b|~haqalna+zvTW`ssok$C`c{9l@BB9^P;HxXDraS zM`rIMW_KXd;;|Q5fm!6?xQAtYto`aTL-A=-&c@!QdF}#X3dy^n7mV;^Y;lMDaz<nGdJMsc#g%Zf@Q3@=DL*q~uigQPTIOiTJJQ_l^fjreIovl-GxSVxQU@gBk4b}X*f z(F$K$*N#u{cG}U=y{jZzDO9|)|5%Qu?U*wP_Yp$o?Bv{F^tO_&oQ{ZWe^m+Vq>DuX z=myPQpGzQt>8~Dw0o4_Du41x!ZxsFX#LXYI`k?KvNQ(|pFJ$T5&1@6bO02}+n+lr6 zAV&QPFnAZm>Ck<1d=S%8iT)26m>9n0%O!3Es^973y$LO@6l3kn_7V;d%Ubz)?&#ct i0Lm|?+Gu!4wSn3a=omchCR;I)K6jAtVnSZlIQ$yuq$nr=