diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ValidationsTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ValidationsTest.java new file mode 100644 index 000000000000..35d43967f85e --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ValidationsTest.java @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import com.azure.spring.cloud.feature.management.filters.TargetingFilter; +import com.azure.spring.cloud.feature.management.filters.TargetingFilterTestContextAccessor; +import com.azure.spring.cloud.feature.management.filters.TimeWindowFilter; +import com.azure.spring.cloud.feature.management.implementation.FeatureManagementConfigProperties; +import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; +import com.azure.spring.cloud.feature.management.validationstests.models.ValidationTestCase; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.type.CollectionType; +import com.fasterxml.jackson.databind.type.TypeFactory; + +@ExtendWith(SpringExtension.class) +public class ValidationsTest { + @Mock + private ApplicationContext context; + + @Mock + private FeatureManagementConfigProperties configProperties; + + private static final Logger LOGGER = LoggerFactory.getLogger(ValidationsTest.class); + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true).build(); + + private static final String TEST_CASE_FOLDER_PATH = "validations-tests"; + + private final String inputsUser = "user"; + + private final String inputsGroups = "groups"; + + private static final String SAMPLE_FILE_NAME_FILTER = "sample"; + + private static final String TESTS_FILE_NAME_FILTER = "tests"; + + @BeforeEach + public void setup() { + MockitoAnnotations.openMocks(this); + when(configProperties.isFailFast()).thenReturn(true); + when(context.getBean(Mockito.contains("TimeWindow"))).thenReturn(new TimeWindowFilter()); + } + + @AfterEach + public void cleanup() throws Exception { + MockitoAnnotations.openMocks(this).close(); + } + + private boolean hasException(ValidationTestCase testCase) { + final String exceptionStr = testCase.getIsEnabled().getException(); + return exceptionStr != null && !exceptionStr.isEmpty(); + } + + private boolean hasInput(ValidationTestCase testCase) { + final LinkedHashMap inputsMap = testCase.getInputs(); + return inputsMap != null && !inputsMap.isEmpty(); + } + + private static File[] getFileList(String fileNameFilter) { + final URL folderUrl = Thread.currentThread().getContextClassLoader().getResource(TEST_CASE_FOLDER_PATH); + assert folderUrl != null; + + final File folderFile = new File(folderUrl.getFile()); + final File[] filteredFiles = folderFile + .listFiles(pathname -> pathname.getName().toLowerCase().contains(fileNameFilter)); + assert filteredFiles != null; + + Arrays.sort(filteredFiles, Comparator.comparing(File::getName)); + return filteredFiles; + } + + private List readTestcasesFromFile(File testFile) throws IOException { + final String jsonString = Files.readString(testFile.toPath()); + final CollectionType typeReference = TypeFactory.defaultInstance().constructCollectionType(List.class, + ValidationTestCase.class); + return OBJECT_MAPPER.readValue(jsonString, typeReference); + } + + @SuppressWarnings("unchecked") + private static LinkedHashMap readConfigurationFromFile(File sampleFile) throws IOException { + final String jsonString = Files.readString(sampleFile.toPath()); + final LinkedHashMap configurations = OBJECT_MAPPER.readValue(jsonString, new TypeReference<>() { + }); + final Object featureManagementSection = configurations.get("feature_management"); + if (featureManagementSection.getClass().isAssignableFrom(LinkedHashMap.class)) { + return (LinkedHashMap) featureManagementSection; + } + throw new IllegalArgumentException("feature_management part is not a map"); + } + + static Stream testProvider() throws IOException { + List arguments = new ArrayList<>(); + File[] files = getFileList(TESTS_FILE_NAME_FILTER); + + final File[] sampleFiles = getFileList(SAMPLE_FILE_NAME_FILTER); + List properties = new ArrayList<>(); + for (File sampleFile : sampleFiles) { + final FeatureManagementProperties managementProperties = new FeatureManagementProperties(); + managementProperties.putAll(readConfigurationFromFile(sampleFile)); + properties.add(managementProperties); + } + + for (int i = 0; i < files.length; i++) { + if (files[i].getName().contains(("TargetingFilter"))) { + continue; // TODO(mametcal). Not run the test case until we release the little endian fix + } + arguments.add(Arguments.of(files[i].getName(), files[i], properties.get(i))); + } + + return arguments.stream(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("testProvider") + void validationTest(String name, File testsFile, FeatureManagementProperties managementProperties) + throws IOException { + LOGGER.debug("Running test case from file: " + name); + final FeatureManager featureManager = new FeatureManager(context, managementProperties, configProperties); + List testCases = readTestcasesFromFile(testsFile); + for (ValidationTestCase testCase : testCases) { + LOGGER.debug("Test case : " + testCase.getDescription()); + if (hasException(testCase)) { // TODO(mametcal). Currently we didn't throw the exception when parameter is + // invalid + assertNull(managementProperties.getOnOff().get(testCase.getFeatureFlagName())); + continue; + } + if (hasInput(testCase)) { // Set inputs + final Object userObj = testCase.getInputs().get(inputsUser); + final Object groupsObj = testCase.getInputs().get(inputsGroups); + final String user = userObj != null ? userObj.toString() : null; + @SuppressWarnings("unchecked") + final List groups = groupsObj != null ? (List) groupsObj : null; + when(context.getBean(Mockito.contains("Targeting"))) + .thenReturn(new TargetingFilter(new TargetingFilterTestContextAccessor(user, groups))); + } + + final Boolean result = featureManager.isEnabled(testCase.getFeatureFlagName()); + assertEquals(result.toString(), testCase.getIsEnabled().getResult()); + } + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java index 19c06b3ce4c7..5d1323510c7f 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java @@ -18,8 +18,6 @@ import com.azure.spring.cloud.feature.management.implementation.TestConfiguration; import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; import com.azure.spring.cloud.feature.management.models.TargetingException; -import com.azure.spring.cloud.feature.management.targeting.TargetingContext; -import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; import com.azure.spring.cloud.feature.management.targeting.TargetingEvaluationOptions; @SpringBootTest(classes = { TestConfiguration.class, SpringBootTest.class }) @@ -48,10 +46,10 @@ public void targetedUser() { parameters.put(GROUPS, new LinkedHashMap()); parameters.put(DEFAULT_ROLLOUT_PERCENTAGE, 0); parameters.put("Exclusion", emptyExclusion()); - + Map excludes = new LinkedHashMap<>(); Map excludedGroups = new LinkedHashMap<>(); - + excludes.put(GROUPS, excludedGroups); context.setParameters(parameters); @@ -341,20 +339,20 @@ public void excludeUser() { TargetingFilter filter = new TargetingFilter(new TargetingFilterTestContextAccessor("Doe", null)); assertTrue(filter.evaluate(context)); - + // Now the users is excluded Map excludes = new LinkedHashMap<>(); Map excludedUsers = new LinkedHashMap<>(); excludedUsers.put("0", "Doe"); - + excludes.put(USERS, excludedUsers); parameters.put("Exclusion", excludes); - + context.setParameters(parameters); - + assertFalse(filter.evaluate(context)); } - + @Test public void excludeGroup() { FeatureFilterEvaluationContext context = new FeatureFilterEvaluationContext(); @@ -380,20 +378,20 @@ public void excludeGroup() { TargetingFilter filter = new TargetingFilter(new TargetingFilterTestContextAccessor(null, targetedGroups)); assertTrue(filter.evaluate(context)); - + // Now the users is excluded Map excludes = new LinkedHashMap<>(); Map excludedGroups = new LinkedHashMap<>(); excludedGroups.put("0", "g1"); - + excludes.put(GROUPS, excludedGroups); parameters.put("Exclusion", excludes); - + context.setParameters(parameters); - + assertFalse(filter.evaluate(context)); } - + private Map emptyExclusion() { Map excludes = new LinkedHashMap<>(); List excludedUsers = new ArrayList<>(); @@ -402,23 +400,4 @@ private Map emptyExclusion() { excludes.put(GROUPS, excludedGroups); return excludes; } - - class TargetingFilterTestContextAccessor implements TargetingContextAccessor { - - private String user; - - private ArrayList groups; - - TargetingFilterTestContextAccessor(String user, ArrayList groups) { - this.user = user; - this.groups = groups; - } - - @Override - public void configureTargetingContext(TargetingContext context) { - context.setUserId(user); - context.setGroups(groups); - } - - } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTestContextAccessor.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTestContextAccessor.java new file mode 100644 index 000000000000..a61de4cb0919 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTestContextAccessor.java @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.filters; + +import com.azure.spring.cloud.feature.management.targeting.TargetingContext; +import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; + +import java.util.List; + +public class TargetingFilterTestContextAccessor implements TargetingContextAccessor { + + private String user; + + private List groups; + + public TargetingFilterTestContextAccessor(String user, List groups) { + this.user = user; + this.groups = groups; + } + + @Override + public void configureTargetingContext(TargetingContext context) { + context.setUserId(user); + context.setGroups(groups); + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/validationstests/models/IsEnabled.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/validationstests/models/IsEnabled.java new file mode 100644 index 000000000000..0f10f4175e5e --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/validationstests/models/IsEnabled.java @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.validationstests.models; + +public class IsEnabled { + private String result; + private String exception; + + /** + * @return result + * */ + public String getResult() { + return result; + } + + /** + * @param result the result of validation test case + * */ + public void setResult(String result) { + this.result = result; + } + + /** + * @return exception + * */ + public String getException() { + return exception; + } + + /** + * @param exception the exception message throws when run test case + * */ + public void setException(String exception) { + this.exception = exception; + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/validationstests/models/ValidationTestCase.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/validationstests/models/ValidationTestCase.java new file mode 100644 index 000000000000..3d2891b148e9 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/validationstests/models/ValidationTestCase.java @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.validationstests.models; + +import java.util.LinkedHashMap; + +public class ValidationTestCase { + private String friendlyName; + private String featureFlagName; + private LinkedHashMap inputs; + private IsEnabled isEnabled; + private Variant variant; + private String description; + + /** + * @return friendly name of test case + * */ + public String getFriendlyName() { + return friendlyName; + } + + /** + * @param friendlyName the friendly name of test case + * */ + public void setFriendlyName(String friendlyName) { + this.friendlyName = friendlyName; + } + + /** + * @return the name of feature flag + * */ + public String getFeatureFlagName() { + return featureFlagName; + } + + /** + * @param featureFlagName the name of feature flag + * */ + public void setFeatureFlagName(String featureFlagName) { + this.featureFlagName = featureFlagName; + } + + /** + * @return the inputs of feature flag + * */ + public LinkedHashMap getInputs() { + return inputs; + } + + /** + * @param inputs the inputs of feature flag + * */ + public void setInputs(LinkedHashMap inputs) { + this.inputs = inputs; + } + + /** + * @return IsEnabled object to represent result of feature flag, enabled or exception + * */ + public IsEnabled getIsEnabled() { + return isEnabled; + } + + /** + * @param isEnabled the result of feature flag, enabled or exception + * */ + public void setIsEnabled(IsEnabled isEnabled) { + this.isEnabled = isEnabled; + } + + /** + * @return variant + * */ + public Variant getVariant() { + return variant; + } + + /** + * @param variant the variant of test case + * */ + public void setVariant(Variant variant) { + this.variant = variant; + } + + /** + * @return description + * */ + public String getDescription() { + return description; + } + + /** + * @param description the description of test case + * */ + public void setDescription(String description) { + this.description = description; + } +} + diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/validationstests/models/Variant.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/validationstests/models/Variant.java new file mode 100644 index 000000000000..ee209ab255e3 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/validationstests/models/Variant.java @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.validationstests.models; + +public class Variant { + private String result; + private String exception; + + /** + * @return result + * */ + public String getResult() { + return result; + } + + /** + * @param result the result of variant feature flag + * */ + public void setResult(String result) { + this.result = result; + } + + /** + * @return exception + * */ + public String getException() { + return exception; + } + + /** + * @param exception the exception message throws when run variant test case + * */ + public void setException(String exception) { + this.exception = exception; + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/NoFilters.sample.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/NoFilters.sample.json new file mode 100644 index 000000000000..6860ba53c506 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/NoFilters.sample.json @@ -0,0 +1,44 @@ +{ + "feature_management": { + "feature_flags": [ + { + "id": "BooleanTrue", + "description": "A feature flag with no Filters, that returns true.", + "enabled": true, + "conditions": { + "client_filters": [] + } + }, + { + "id": "BooleanFalse", + "description": "A feature flag with no Filters, that returns false.", + "enabled": false, + "conditions": { + "client_filters": [] + } + }, + { + "id": "InvalidEnabled", + "description": "A feature flag with an invalid 'enabled' value, that returns false.", + "enabled": "invalid", + "conditions": { + "client_filters": [] + } + }, + { + "id": "Minimal", + "enabled": true + }, + { + "id": "NoEnabled" + }, + { + "id": "EmptyConditions", + "description": "A feature flag with no values in conditions, that returns true.", + "enabled": true, + "conditions": { + } + } + ] + } +} \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/NoFilters.tests.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/NoFilters.tests.json new file mode 100644 index 000000000000..01445d679299 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/NoFilters.tests.json @@ -0,0 +1,68 @@ +[ + { + "FeatureFlagName": "BooleanTrue", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "An Enabled Feature Flag with no Filters." + }, + { + "FeatureFlagName": "BooleanFalse", + "Inputs": {}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "A Disabled Feature Flag with no Filters." + }, + { + "FeatureFlagName": "InvalidEnabled", + "Inputs": {}, + "IsEnabled": { + "Exception": "Invalid setting 'enabled' with value 'invalid' for feature 'InvalidEnabled'." + }, + "Variant": { + "Result": null + }, + "Description": "A Feature Flag with an invalid Enabled Value." + }, + { + "FeatureFlagName": "Minimal", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "A Feature Flag with just a key and enabled." + }, + { + "FeatureFlagName": "NoEnabled", + "Inputs": {}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Validates that the default value of enabled is False." + }, + { + "FeatureFlagName": "EmptyConditions", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "A feature flag with no Conditions, returns true as it's enabled." + } +] \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/RequirementType.sample.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/RequirementType.sample.json new file mode 100644 index 000000000000..5bf5b6ecfe5e --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/RequirementType.sample.json @@ -0,0 +1,144 @@ +{ + "feature_management": { + "feature_flags": [ + { + "id": "DefaultRequirementTypeFirstFilterPassed", + "description": "A feature flag that has multiple filters, but doesn't specify any requirement type, which is the default. Will always return true.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Tue, 27 Jun 2023 06:00:00 GMT" + } + }, + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Thu, 29 Jun 2023 07:00:00 GMT", + "End": "Wed, 30 Aug 2023 07:00:00 GMT" + } + } + ] + } + }, + { + "id": "DefaultRequirementTypeLastFilterPassed", + "description": "Same as DefaultRequirementTypeFirstFilterPassed, but filter order is switched. Will always return true.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Thu, 29 Jun 2023 07:00:00 GMT", + "End": "Wed, 30 Aug 2023 07:00:00 GMT" + } + }, + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Tue, 27 Jun 2023 06:00:00 GMT" + } + } + ] + } + }, + { + "id": "RequirementTypeAnyFirstFilterPassed", + "description": "Same as DefaultRequirementTypeFirstFilterPassed, but requirement type is specified. Will always return true.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Tue, 27 Jun 2023 06:00:00 GMT" + } + }, + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Thu, 29 Jun 2023 07:00:00 GMT", + "End": "Wed, 30 Aug 2023 07:00:00 GMT" + } + } + ], + "requirement_type": "Any" + } + }, + { + "id": "RequirementTypeAnyLastFilterPassed", + "description": "Same as DefaultRequirementTypeLastFilterPassed, but requirement type is specified. Will always return true.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Thu, 29 Jun 2023 07:00:00 GMT", + "End": "Wed, 30 Aug 2023 07:00:00 GMT" + } + }, + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Tue, 27 Jun 2023 06:00:00 GMT" + } + } + ], + "requirement_type": "Any" + } + }, + { + "id": "RequirementTypeAllPassed", + "description": "Requirement type All. Will always return true.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "DefaultRolloutPercentage": 100 + } + } + }, + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Tue, 27 Jun 2023 06:00:00 GMT" + } + } + ], + "requirement_type": "All" + } + }, + { + "id": "RequirementTypeAllLastFilterFailed", + "description": "Requirement type All. Will always return false.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "DefaultRolloutPercentage": 100 + } + } + }, + { + "name": "Microsoft.TimeWindow", + "parameters": { + "End": "Tue, 27 Jun 2023 06:00:00 GMT" + } + } + ], + "requirement_type": "All" + } + } + ] + } +} \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/RequirementType.tests.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/RequirementType.tests.json new file mode 100644 index 000000000000..74df4d8c4133 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/RequirementType.tests.json @@ -0,0 +1,68 @@ +[ + { + "FeatureFlagName": "DefaultRequirementTypeFirstFilterPassed", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Feature Flag with two feature filters, first returns true, so it's enabled." + }, + { + "FeatureFlagName": "DefaultRequirementTypeLastFilterPassed", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Feature Flag with two feature filters, second returns true, so it's enabled." + }, + { + "FeatureFlagName": "RequirementTypeAnyFirstFilterPassed", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Feature Flag with two feature filters and requirement type specified as Any. Second filter returns true." + }, + { + "FeatureFlagName": "RequirementTypeAnyLastFilterPassed", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Feature Flag with two feature filters and requirement type specified as Any. Neither filter returns true." + }, + { + "FeatureFlagName": "RequirementTypeAllPassed", + "Inputs": {"user":"Adam"}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Feature Flag with two feature filters and requirement type specified as All. Both filters return true." + }, + { + "FeatureFlagName": "RequirementTypeAllLastFilterFailed", + "Inputs": {"user":"Adam"}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Feature Flag with two feature filters and requirement type specified as All. Only the first filter returns true." + } +] diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.modified.sample.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.modified.sample.json new file mode 100644 index 000000000000..8f332e65c804 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.modified.sample.json @@ -0,0 +1,60 @@ +{ + "feature_management": { + "feature_flags": [ + { + "id": "ComplexTargeting", + "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2, and false for Dave and Stage3. The default rollout percentage is 25%.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [ + "Alice" + ], + "Groups": [ + { + "Name": "Stage1", + "RolloutPercentage": 100 + }, + { + "Name": "Stage2", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 25, + "Exclusion": { + "Users": ["Dave"], + "Groups": ["Stage3"] + } + } + } + } + ] + } + }, + { + "id": "RolloutPercentageUpdate", + "description": "A feature flag using a targeting filter, that will return true 62% of the time.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [], + "Groups": [], + "DefaultRolloutPercentage": 62, + "Exclusion": {} + } + } + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.modified.tests.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.modified.tests.json new file mode 100644 index 000000000000..1c1425bf3d47 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.modified.tests.json @@ -0,0 +1,98 @@ +[ + { + "FriendlyName": "Aiden62", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Aiden"}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, 62% default rollout Aiden is part of it." + }, + { + "FriendlyName": "Aiden62 - Stage1", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Aiden", "groups":["Stage1"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Aiden62 - Stage2", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Aiden", "groups":["Stage2"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Aiden62 - Stage3", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Aiden", "groups":["Stage3"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Brittney62", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Brittney"}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, 62% default rollout Brittney is part of it." + }, + { + "FriendlyName": "Brittney62 - Stage1", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Brittney", "groups":["Stage1"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Brittney62 - Stage2", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Brittney", "groups":["Stage2"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Brittney62 - Stage3", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Brittney", "groups":["Stage3"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + } +] \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.sample.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.sample.json new file mode 100644 index 000000000000..4342df5d20b0 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.sample.json @@ -0,0 +1,60 @@ +{ + "feature_management": { + "feature_flags": [ + { + "id": "ComplexTargeting", + "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [ + "Alice" + ], + "Groups": [ + { + "Name": "Stage1", + "RolloutPercentage": 100 + }, + { + "Name": "Stage2", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 25, + "Exclusion": { + "Users": ["Dave"], + "Groups": ["Stage3"] + } + } + } + } + ] + } + }, + { + "id": "RolloutPercentageUpdate", + "description": "A feature flag using a targeting filter, that will return true 61% of the time. Changing to 62% makes the user Brittney true.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [], + "Groups": [], + "DefaultRolloutPercentage": 61, + "Exclusion": {} + } + } + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.tests.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.tests.json new file mode 100644 index 000000000000..60e2f412ab5e --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TargetingFilter.tests.json @@ -0,0 +1,230 @@ +[ + { + "FriendlyName": "DisabledDefaultRollout", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"user":"Aiden"}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, Aiden is not part of the default rollout." + }, + { + "FriendlyName": "EnabledDefaultRollout", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"user":"Blossom"}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, Blossom is part of the default rollout." + }, + { + "FriendlyName": "TargetedUser", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"user":"Alice"}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, Alice is a targeted user." + }, + { + "FriendlyName": "TargetedGroup", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"user":"Aiden", "groups":["Stage1"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, Aiden is now targeted because Stage1 is 100% rolled out." + }, + { + "FriendlyName": "DisabledTargetedGroup", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"groups":["Stage2"]}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "empty/no user will hit the 50% rollout of group stage 2, so it is targeted." + }, + { + "FriendlyName": "EnabledTargetedGroup50", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"user":"Aiden", "groups":["Stage2"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, Aiden who is not part of the default rollout is part of the first 50% of Stage 2." + }, + { + "FriendlyName": "DisabledTargetedGroup50", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"user":"Chris", "groups":["Stage2"]}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, Chris is neither part of the default rollout nor part of the first 50% of Stage 2." + }, + { + "FriendlyName": "ExcludedGroup", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"groups":["Stage3"]}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, the Stage 3 is the group on the exclusion list." + }, + { + "FriendlyName": "ExcludedGroupTargetedUser", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"user":"Alice", "groups":["Stage3"]}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Alice is excluded because she is part of the Stage 3 group, even if she is an included user. " + }, + { + "FriendlyName": "ExcludedGroupDefaultRollout", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"user":"Blossom", "groups":["Stage3"]}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, Blossom who was Expected Result by the default rollout is now excluded as part of the Stage 3 group." + }, + { + "FriendlyName": "ExcludedUser", + "FeatureFlagName": "ComplexTargeting", + "Inputs": {"user":"Dave", "groups":["Stage1"]}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, Dave is on the exclusion list, is still excluded even though he is part of the 100% rolled out Stage 1." + }, + { + "FriendlyName": "Aiden61", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Aiden"}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, 62% default rollout Aiden is part of it." + }, + { + "FriendlyName": "Aiden61 - Stage1", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Aiden", "groups":["Stage1"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Aiden61 - Stage2", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Aiden", "groups":["Stage2"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Aiden61 - Stage3", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Aiden", "groups":["Stage3"]}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Brittney61", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Brittney"}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, 62% default rollout Brittney is not part of it." + }, + { + "FriendlyName": "Brittney61 - Stage1", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Brittney", "groups":["Stage1"]}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Brittney61 - Stage2", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Brittney", "groups":["Stage2"]}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + }, + { + "FriendlyName": "Brittney61 - Stage3", + "FeatureFlagName": "RolloutPercentageUpdate", + "Inputs": {"user":"Brittney", "groups":["Stage3"]}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Targeting Filter, group is not part of default rollout calculation, no change." + } +] \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TimeWindowFilter.sample.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TimeWindowFilter.sample.json new file mode 100644 index 000000000000..462277b8f62c --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TimeWindowFilter.sample.json @@ -0,0 +1,84 @@ +{ + "feature_management": { + "feature_flags": [ + { + "id": "PastTimeWindow", + "description": "A feature flag using a time window filter, that is active from 2023-06-29 07:00:00 to 2023-08-30 07:00:00. Will always return false as the current time is outside the time window.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Thu, 29 Jun 2023 07:00:00 GMT", + "End": "Wed, 30 Aug 2023 07:00:00 GMT" + } + } + ] + } + }, + { + "id": "FutureTimeWindow", + "description": "A feature flag using a time window filter, that is active from 3023-06-27 06:00:00 to 3023-06-28 06:05:00. Will always return false as the time window has yet been reached.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Fri, 27 Jun 3023 06:00:00 GMT", + "End": "Sat, 28 Jun 3023 06:05:00 GMT" + } + } + ] + } + }, + { + "id": "PresentTimeWindow", + "description": "A feature flag using a time window filter, that is active from 2023-06-27 06:00:00 to 3023-06-28 06:05:00. Will always return true as we are in the time window.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Thu, 29 Jun 2023 07:00:00 GMT", + "End": "Sat, 28 Jun 3023 06:05:00 GMT" + } + } + ] + } + }, + { + "id": "StartedTimeWindow", + "description": "A feature flag using a time window filter, that will always return true as the current time is within the time window.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Tue, 27 Jun 2023 06:00:00 GMT" + } + } + ] + } + }, + { + "id": "WillEndTimeWindow", + "description": "A feature flag using a time window filter, that will always return true as the current time is within the time window.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "End": "Sat, 28 Jun 3023 06:05:00 GMT" + } + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TimeWindowFilter.tests.json b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TimeWindowFilter.tests.json new file mode 100644 index 000000000000..1bbd40f57d47 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/validations-tests/TimeWindowFilter.tests.json @@ -0,0 +1,57 @@ +[ + { + "FeatureFlagName": "PastTimeWindow", + "Inputs": {}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Time Window filter where both Start and End have already passed." + }, + { + "FeatureFlagName": "FutureTimeWindow", + "Inputs": {}, + "IsEnabled": { + "Result": "false" + }, + "Variant": { + "Result": null + }, + "Description": "Time Window filter where neither Start nor End have happened." + }, + { + "FeatureFlagName": "PresentTimeWindow", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Time Window filter where Start has happened but End hasn't happened." + }, + { + "FeatureFlagName": "StartedTimeWindow", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Time Window filter with a Start that has passed." + }, + { + "FeatureFlagName": "WillEndTimeWindow", + "Inputs": {}, + "IsEnabled": { + "Result": "true" + }, + "Variant": { + "Result": null + }, + "Description": "Time Window filter where the End hasn't passed." + } +]