diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java index 473a377..708a3bd 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java @@ -1,15 +1,10 @@ package com.octopus.openfeature.provider.v4; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; /** * Base type for a client-side rule condition, selected from the camelCase {@code type} discriminator - * when deserializing a v4 evaluation response. These types model the wire shape only. - * - *

A discriminator this version of the provider does not recognise — or an absent one — - * deserializes to {@link UnknownCondition} rather than failing, so a condition type - * introduced by a newer server degrades safely on an older client. + * when deserializing a v4 evaluation response. * *

The conditions sit alongside the rest of the v4 types rather than in a {@code conditions} * sub-package as the .NET provider has them. Java package access is not hierarchical, so a @@ -17,17 +12,13 @@ * following that layout would mean making every condition public — part of the library's supported * API, which is what keeping these types package-private is meant to avoid. */ -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "type", - visible = true, - defaultImpl = UnknownCondition.class -) -@JsonSubTypes({ - @JsonSubTypes.Type(value = PercentageByContextCondition.class, name = ConditionTypeNames.PERCENTAGE_BY_CONTEXT), - @JsonSubTypes.Type(value = ContextAttributeIsOneOfCondition.class, name = ConditionTypeNames.CONTEXT_ATTRIBUTE_IS_ONE_OF), - @JsonSubTypes.Type(value = ContextAttributeIsNotOneOfCondition.class, name = ConditionTypeNames.CONTEXT_ATTRIBUTE_IS_NOT_ONE_OF) -}) +@JsonDeserialize(using = ClientSideConditionDeserializer.class) abstract class ClientSideCondition { + + /** + * Whether this condition is met. A condition that did not arrive in a shape its type can evaluate + * throws {@link dev.openfeature.sdk.exceptions.ParseError} rather than reading a value it was not + * sent. + */ + abstract boolean matches(ClientSideEvaluationContext context); } diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializer.java b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializer.java new file mode 100644 index 0000000..fab619f --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializer.java @@ -0,0 +1,53 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.IOException; + +/** + * Selects the concrete {@link ClientSideCondition} from the camelCase {@code type} discriminator. An + * unrecognised discriminator deserializes to {@link UnknownCondition} rather than throwing, so a + * condition type introduced by a newer server degrades safely on an older client. + * + *

Written by hand rather than driven by {@code @JsonTypeInfo}, which cannot distinguish a + * discriminator that is not a string — Jackson coerces {@code "type": 123} to {@code "123"} and + * treats it as merely unrecognised. Here a non-string (or absent) discriminator yields an + * {@link UnknownCondition} carrying no type, which fails evaluation as the malformed response it is. + * + *

The provider only ever reads these conditions, so serialization is left to Jackson's defaults. + */ +final class ClientSideConditionDeserializer extends JsonDeserializer { + + private static final String DISCRIMINATOR = "type"; + + @Override + public ClientSideCondition deserialize(JsonParser parser, DeserializationContext context) throws IOException { + ObjectCodec codec = parser.getCodec(); + JsonNode node = codec.readTree(parser); + + // Matched exactly, as the .NET provider's converter does: the server always sends "type". + JsonNode discriminator = node.get(DISCRIMINATOR); + String type = discriminator != null && discriminator.isTextual() ? discriminator.textValue() : null; + + // Deserializing the concrete type targets that type directly, so this deserializer — registered + // on the base type only — is not re-entered. + if (type == null) { + return new UnknownCondition(null); + } + + switch (type) { + case ConditionTypeNames.PERCENTAGE_BY_CONTEXT: + return codec.treeToValue(node, PercentageByContextCondition.class); + case ConditionTypeNames.CONTEXT_ATTRIBUTE_IS_ONE_OF: + return codec.treeToValue(node, ContextAttributeIsOneOfCondition.class); + case ConditionTypeNames.CONTEXT_ATTRIBUTE_IS_NOT_ONE_OF: + return codec.treeToValue(node, ContextAttributeIsNotOneOfCondition.class); + default: + return new UnknownCondition(type); + } + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideEvaluationContext.java b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideEvaluationContext.java new file mode 100644 index 0000000..73a334c --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideEvaluationContext.java @@ -0,0 +1,30 @@ +package com.octopus.openfeature.provider.v4; + +import dev.openfeature.sdk.EvaluationContext; + +/** + * What a flag's rules and conditions are evaluated against. + */ +final class ClientSideEvaluationContext { + private final String evaluationKey; + private final EvaluationContext openFeatureContext; + + ClientSideEvaluationContext(String evaluationKey, EvaluationContext openFeatureContext) { + this.evaluationKey = evaluationKey; + this.openFeatureContext = openFeatureContext; + } + + /** + * The key {@code percentage-by-context} buckets against. + */ + String getEvaluationKey() { + return evaluationKey; + } + + /** + * The caller's context, or {@code null} if they supplied none. + */ + EvaluationContext getOpenFeatureContext() { + return openFeatureContext; + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java index 4e2537d..90f19dd 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java +++ b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java @@ -2,12 +2,13 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import dev.openfeature.sdk.exceptions.ParseError; import java.util.List; /** - * A named rule the provider library still has to evaluate on the client side. The rule matches when - * every one of its conditions matches. + * A named rule the provider library evaluates on the client side. The rule matches when every one of + * its conditions matches. */ final class ClientSideRule { private final String name; @@ -15,11 +16,11 @@ final class ClientSideRule { @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) ClientSideRule( - @JsonProperty(value = "name", required = true) String name, - @JsonProperty(value = "conditions", required = true) List conditions + @JsonProperty("name") String name, + @JsonProperty("conditions") List conditions ) { this.name = name; - this.conditions = List.copyOf(conditions); + this.conditions = ListUtils.copyOrNull(conditions); } public String getName() { @@ -29,4 +30,28 @@ public String getName() { public List getConditions() { return conditions; } + + boolean matches(ClientSideEvaluationContext context) { + // The server only defers a named rule carrying at least one condition, so anything else is a + // response it could not have sent. + if (name == null) { + throw new ParseError("A rule has no name."); + } + + if (conditions == null || conditions.isEmpty()) { + throw new ParseError("Rule '" + name + "' has no conditions."); + } + + for (ClientSideCondition condition : conditions) { + if (condition == null) { + throw new ParseError("Rule '" + name + "' has a missing condition."); + } + + if (!condition.matches(context)) { + return false; + } + } + + return true; + } } diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java index 12f9dae..6a1f037 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java @@ -3,24 +3,28 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import java.util.List; /** - * Matches when the context attribute {@code key} is not one of {@code values}. + * Matches when the context attribute {@code key} is not one of {@code values}. A missing attribute + * matches. */ -@JsonIgnoreProperties("type") // The discriminator is visible to subtypes; this type does not model it. +@JsonIgnoreProperties("type") // The discriminator selects this type; it is not modelled as a property. +@JsonDeserialize(using = JsonDeserializer.None.class) // Resets the base type's deserializer; see ClientSideConditionDeserializer. final class ContextAttributeIsNotOneOfCondition extends ClientSideCondition { private final String key; private final List values; @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) ContextAttributeIsNotOneOfCondition( - @JsonProperty(value = "key", required = true) String key, - @JsonProperty(value = "values", required = true) List values + @JsonProperty("key") String key, + @JsonProperty("values") List values ) { this.key = key; - this.values = List.copyOf(values); + this.values = ListUtils.copyOrNull(values); } public String getKey() { @@ -30,4 +34,9 @@ public String getKey() { public List getValues() { return values; } + + @Override + boolean matches(ClientSideEvaluationContext context) { + return !ContextAttributes.isOneOf(context, key, values); + } } diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java index dbc8e10..9d50ac5 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java @@ -3,24 +3,28 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import java.util.List; /** - * Matches when the context attribute {@code key} is one of {@code values}. + * Matches when the context attribute {@code key} is one of {@code values}. A missing attribute does + * not match. */ -@JsonIgnoreProperties("type") // The discriminator is visible to subtypes; this type does not model it. +@JsonIgnoreProperties("type") // The discriminator selects this type; it is not modelled as a property. +@JsonDeserialize(using = JsonDeserializer.None.class) // Resets the base type's deserializer; see ClientSideConditionDeserializer. final class ContextAttributeIsOneOfCondition extends ClientSideCondition { private final String key; private final List values; @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) ContextAttributeIsOneOfCondition( - @JsonProperty(value = "key", required = true) String key, - @JsonProperty(value = "values", required = true) List values + @JsonProperty("key") String key, + @JsonProperty("values") List values ) { this.key = key; - this.values = List.copyOf(values); + this.values = ListUtils.copyOrNull(values); } public String getKey() { @@ -30,4 +34,9 @@ public String getKey() { public List getValues() { return values; } + + @Override + boolean matches(ClientSideEvaluationContext context) { + return ContextAttributes.isOneOf(context, key, values); + } } diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributes.java b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributes.java new file mode 100644 index 0000000..5f8f2fe --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributes.java @@ -0,0 +1,51 @@ +package com.octopus.openfeature.provider.v4; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.exceptions.ParseError; + +import java.util.List; +import java.util.Objects; + +/** + * The attribute lookup shared by both attribute conditions. + */ +final class ContextAttributes { + + private ContextAttributes() { + } + + /** + * Whether the context holds an attribute named {@code key} with one of {@code values}. + * + *

Mirrors v3 segment matching: keys and values compare case-insensitively and a non-string + * value counts as absent. Every entry whose key matches is checked, not just the first — a context + * can hold several case variants of one key. + */ + static boolean isOneOf(ClientSideEvaluationContext context, String key, List values) { + if (key == null) { + throw new ParseError("A condition is missing a key."); + } + + if (values == null || values.isEmpty()) { + throw new ParseError("A condition is missing values."); + } + + if (values.stream().anyMatch(Objects::isNull)) { + throw new ParseError("A condition is missing a value."); + } + + EvaluationContext openFeatureContext = context.getOpenFeatureContext(); + if (openFeatureContext == null) { + return false; + } + + return openFeatureContext.asMap().entrySet().stream().anyMatch(entry -> { + if (!entry.getKey().equalsIgnoreCase(key)) { + return false; + } + + String attribute = entry.getValue().asString(); + return attribute != null && values.stream().anyMatch(value -> value.equalsIgnoreCase(attribute)); + }); + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/EvaluationReasons.java b/src/main/java/com/octopus/openfeature/provider/v4/EvaluationReasons.java new file mode 100644 index 0000000..072c0d0 --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/EvaluationReasons.java @@ -0,0 +1,19 @@ +package com.octopus.openfeature.provider.v4; + +/** + * Reasons returned alongside a client-side evaluation. Both match the strings the Feature Flags service + * produces server-side, so a flag reads the same whichever side resolved it. + */ +final class EvaluationReasons { + + private EvaluationReasons() { + } + + static String matchedRule(String ruleName) { + return "Matched rule '" + ruleName + "'."; + } + + static String didNotMatchAnyRules() { + return "Did not match any rules."; + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ListUtils.java b/src/main/java/com/octopus/openfeature/provider/v4/ListUtils.java new file mode 100644 index 0000000..fad17f4 --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ListUtils.java @@ -0,0 +1,25 @@ +package com.octopus.openfeature.provider.v4; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * List handling for deserialized payloads. + */ +final class ListUtils { + + private ListUtils() { + } + + /** + * An unmodifiable copy of a deserialized list, or {@code null} if the property was absent. + * + *

Not {@code List.copyOf}, which rejects null elements: a null in the payload has to survive + * deserialization so that evaluation can report it as the malformed response it is, rather than + * failing the whole response as it is read. + */ + static List copyOrNull(List list) { + return list == null ? null : Collections.unmodifiableList(new ArrayList<>(list)); + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java index 0194643..4dc156a 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java @@ -3,22 +3,57 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.exceptions.ParseError; + +import java.util.Optional; /** * Matches when the OpenFeature targeting key falls within the {@code percentage}% rollout. */ -@JsonIgnoreProperties("type") // The discriminator is visible to subtypes; this type does not model it. +@JsonIgnoreProperties("type") // The discriminator selects this type; it is not modelled as a property. +@JsonDeserialize(using = JsonDeserializer.None.class) // Resets the base type's deserializer; see ClientSideConditionDeserializer. final class PercentageByContextCondition extends ClientSideCondition { - private final int percentage; + private final Integer percentage; @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) PercentageByContextCondition( - @JsonProperty(value = "percentage", required = true) int percentage + @JsonProperty("percentage") Integer percentage ) { this.percentage = percentage; } - public int getPercentage() { - return percentage; + /** + * The rollout percentage, 0–100. Boxed so an absent {@code percentage} stays distinguishable from + * an explicit {@code 0}, which is a legitimate "nobody". + */ + public Optional getPercentage() { + return Optional.ofNullable(percentage); + } + + @Override + boolean matches(ClientSideEvaluationContext context) { + if (percentage == null) { + throw new ParseError("A condition is missing a percentage value."); + } + + // Rejected rather than clamped: reading 101 as "everyone" would turn a flag on off the back of + // a bad payload. + if (percentage < 0 || percentage > 100) { + throw new ParseError("A condition has a percentage of " + percentage + "."); + } + + EvaluationContext openFeatureContext = context.getOpenFeatureContext(); + String targetingKey = openFeatureContext == null ? null : openFeatureContext.getTargetingKey(); + + // Nothing to bucket, so only a full rollout matches — as the server treats an untenanted caller. + if (targetingKey == null || targetingKey.isEmpty()) { + return percentage >= 100; + } + + // Shared with v3 so a rollout lands on the same users across versions and provider libraries. + return PercentageRollout.includes(context.getEvaluationKey(), targetingKey, percentage); } } diff --git a/src/main/java/com/octopus/openfeature/provider/v4/PercentageRollout.java b/src/main/java/com/octopus/openfeature/provider/v4/PercentageRollout.java new file mode 100644 index 0000000..06814a7 --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/PercentageRollout.java @@ -0,0 +1,51 @@ +package com.octopus.openfeature.provider.v4; + +import org.apache.commons.codec.digest.MurmurHash3; + +import java.nio.charset.StandardCharsets; + +/** + * Buckets a targeting key into a percentage rollout. Hashing the flag's evaluation key together with + * the targeting key keeps a bucket stable across evaluations, while giving each flag an independent + * spread of targeting keys. + * + *

The v3 path has its own copy of this hash in {@code OctopusContext}, because Java package access + * is not hierarchical and a package-private type here is invisible to that package. Keeping both + * package-private is worth the duplication: the alternative is a public type that consumers could + * bind to, and this one is due to disappear along with v3. Both copies are pinned to the same shared + * vectors — see {@code RolloutVectors} in the tests — so the two cannot drift apart unnoticed. + */ +final class PercentageRollout { + + private PercentageRollout() { + } + + /** + * Whether {@code targetingKey} falls within the first {@code percentage} percent of targeting keys + * for the flag identified by {@code evaluationKey}. + */ + static boolean includes(String evaluationKey, String targetingKey, int percentage) { + return getNormalizedNumber(evaluationKey, targetingKey) <= percentage; + } + + /** + * A deterministic bucket in the inclusive range 1–100 for the given evaluation and targeting keys. + * + *

Exposed rather than private so the shared cross-library vectors can assert on the bucket + * itself, as the other provider libraries do. + */ + static int getNormalizedNumber(String evaluationKey, String targetingKey) { + byte[] bytes = (evaluationKey + ":" + targetingKey).getBytes(StandardCharsets.UTF_8); + + // MurmurHash3 32-bit, seed 0. hash32x86 processes tail bytes in little-endian order, + // matching the reference C spec and equivalent to .NET's MurmurHash.Create32() + + // BinaryPrimitives.ReadUInt32LittleEndian(). + int hash = MurmurHash3.hash32x86(bytes, 0, bytes.length, 0); + + // Java has no unsigned integer type. Integer.toUnsignedLong() reinterprets the signed + // int as an unsigned 32-bit value (widened to long) — equivalent to casting to uint in C#. + long unsignedHash = Integer.toUnsignedLong(hash); + + return (int) (unsignedHash % 100) + 1; + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java b/src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java index c9c0028..5a4c99b 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java +++ b/src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java @@ -2,21 +2,17 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.exceptions.ParseError; import java.util.List; import java.util.Optional; /** - * A single feature flag as returned by the OctoToggle v4 evaluations endpoint. The endpoint returns - * an array of these. - * - *

A flag is returned in one of two shapes: - *

- * Properties that do not apply to the returned shape are omitted from the JSON. + * A single feature flag from the v4 evaluations endpoint, either resolved by the server + * ({@code value} and {@code reason}) or deferred to the client ({@code evaluationKey} and + * {@code rules}). Properties that do not apply to the returned shape are omitted from the JSON. */ final class ServerSideEvaluation { private final String slug; @@ -27,7 +23,7 @@ final class ServerSideEvaluation { @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) ServerSideEvaluation( - @JsonProperty(value = "slug", required = true) String slug, + @JsonProperty("slug") String slug, @JsonProperty("value") Boolean value, @JsonProperty("reason") String reason, @JsonProperty("evaluationKey") String evaluationKey, @@ -37,7 +33,7 @@ final class ServerSideEvaluation { this.value = value; this.reason = reason; this.evaluationKey = evaluationKey; - this.rules = rules == null ? null : List.copyOf(rules); + this.rules = ListUtils.copyOrNull(rules); } public String getSlug() { @@ -59,4 +55,58 @@ public Optional getEvaluationKey() { public Optional> getRules() { return Optional.ofNullable(rules); } + + /** + * Resolves the flag, evaluating the client-side rules if the server left any: the flag is enabled + * when any rule matches. + * + *

A response in neither shape throws {@link ParseError}, which the OpenFeature SDK turns into + * the caller's default value. + */ + ProviderEvaluation evaluate(EvaluationContext context) { + if (value != null) { + if (reason == null) { + throw new ParseError("The flag has a value but has no reason."); + } + + if (evaluationKey != null || rules != null) { + throw new ParseError("The flag has both a server-resolved value and client-side rules."); + } + + return resolved(value, reason); + } + + if (rules == null) { + throw new ParseError("The flag has neither a value nor rules."); + } + + if (evaluationKey == null) { + throw new ParseError("The flag defers to the client but has no evaluation key."); + } + + if (rules.isEmpty()) { + throw new ParseError("The flag defers to the client with no rules."); + } + + var ruleContext = new ClientSideEvaluationContext(evaluationKey, context); + + for (ClientSideRule rule : rules) { + if (rule == null) { + throw new ParseError("The flag has a missing rule."); + } + + if (rule.matches(ruleContext)) { + return resolved(true, EvaluationReasons.matchedRule(rule.getName())); + } + } + + return resolved(false, EvaluationReasons.didNotMatchAnyRules()); + } + + private static ProviderEvaluation resolved(boolean value, String reason) { + return ProviderEvaluation.builder() + .value(value) + .reason(reason) + .build(); + } } diff --git a/src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java index a62aab4..058846d 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java @@ -2,18 +2,20 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import dev.openfeature.sdk.exceptions.ParseError; import java.util.Optional; /** - * A client-side condition whose {@code type} discriminator this version of the provider does not - * recognise, or which carried no discriminator at all. Rather than failing the whole evaluation - * response, an unrecognised condition is preserved as this type. It always evaluates to - * {@code false}, so a rule containing an unknown condition can never match — a newer server - * capability is safely treated as "not met" by an older client. + * A condition whose {@code type} this version of the provider does not recognise. It never matches, + * so a newer server's capability is treated as "not met" by an older client rather than failing the + * flag. * *

The raw payload of an unknown condition is not retained, only its discriminator. */ +@JsonDeserialize(using = JsonDeserializer.None.class) // Resets the base type's deserializer; see ClientSideConditionDeserializer. final class UnknownCondition extends ClientSideCondition { private final String type; @@ -25,9 +27,20 @@ final class UnknownCondition extends ClientSideCondition { } /** - * The unrecognised discriminator value, or empty if none was present. + * The unrecognised discriminator value, or empty if none was present or it was not a string. */ public Optional getType() { return Optional.ofNullable(type); } + + @Override + boolean matches(ClientSideEvaluationContext context) { + // No server version emits a condition without a type, so unlike an unrecognised type this is a + // response that could not have been sent. + if (type == null) { + throw new ParseError("A condition is missing a type."); + } + + return false; + } } diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java index 32817c4..c2e00f3 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java @@ -280,133 +280,12 @@ private EvaluationContext buildContext(List> entries, return context; } - // These cases verify that getNormalizedNumber produces the same bucketing values as the equivalent - // implementations in other Octopus OpenFeature provider libraries (e.g. .NET). The expected values - // are derived from the reference MurmurHash3 little-endian algorithm and are duplicated verbatim - // across all libraries. DO NOT modify the input arguments or expected values — doing so would mask - // a real divergence in evaluation behaviour between libraries and defeat the purpose of this test. + // The vectors live in RolloutVectors, shared with the v4 rollout so both implementations are held + // to the same expected buckets. @ParameterizedTest(name = "[{index}] ({0}, {1}) -> {2}") - @MethodSource("cases") + @MethodSource("com.octopus.openfeature.provider.RolloutVectors#cases") void getNormalizedNumberMatchesExpectedValue(String evaluationKey, String targetingKey, int expected) { assertThat(OctopusContext.getNormalizedNumber(evaluationKey, targetingKey)).isEqualTo(expected); } - static Stream cases() { - return Stream.of( - Arguments.of("ef70b4c0-1773-44a3-9b95-f239ae97d9db", "780c4b16-a510-49fa-a2b2-bbd1c38dbe31", 48), - Arguments.of("055665f0-fbb3-484e-8ef1-52125425b7b2", "6a33c211-5af8-4c34-ba82-4d79846cb045", 85), - Arguments.of("8e63257f-07f1-43b6-871f-4e8fcaa1718e", "0a8dc2d9-fb7c-42a9-9bfd-200f65717c5f", 47), - Arguments.of("b8a1abcd-1a69-46c7-8da4-f9fc3c6da5d7", "30433819-7096-420f-9b9f-ff555504a5dd", 79), - Arguments.of("277582f0-93f5-4c2c-888e-44f94ecc6c7f", "3238dad5-c7d4-460e-868d-f8de4770a1e8", 97), - Arguments.of("41f60be0-7cef-4aa3-aaed-cf4a4599a084", "user-0007", 41), - Arguments.of("rollout", "fd7f2fc4-7ecc-46a0-9ee1-073bac59d6dd", 7), - Arguments.of("83a52df2-49a6-47ec-89f1-b6106a2ca9a3", "a43ec79d-db51-4371-8827-cb5e7beac345", 90), - Arguments.of("7b89296c-6dcb-4c50-8857-7eb1924770d3", "5932b247-0569-4069-9688-867975502818", 60), - Arguments.of("7aeff54e-808c-46a6-9f49-2fba47a1fca7", "48c16871-3872-4ddc-a828-c3dd26f30f0b", 90), - Arguments.of("b08969ee-f0d8-45e3-b763-c84ebdde03f1", "6b9bb2f6-535a-4e07-b6df-fce8112d9d11", 68), - Arguments.of("5bc8fbbc-bde5-4099-8164-d8399f767c45", "545c10b7-eb4d-4a28-a1a6-e4dc0b46adec", 79), - Arguments.of("5d357ffe-4423-460d-9b0e-da407f5e8e61", "2b263e0c-e7b2-4237-beb7-aa5d9f5ddbf2", 93), - Arguments.of("8d62d777-8090-44bd-96a7-4dbe3e572e0f", "b5e8480a-d3dd-4a47-b108-781d1e644773", 60), - Arguments.of("688bf8ca-c418-4fcd-9db8-4aa91325af10", "11eb4678-7889-4121-898d-83332035bce5", 28), - Arguments.of("8623121d-e0bb-437a-9459-4d8b75673fca", "dff8494d-3bb3-4b25-9f81-20c447f0e17e", 76), - Arguments.of("c33f4584-b23b-41d8-893c-d01609de8895", "48b0083a-3c39-4aba-9b9f-3e14c7608387", 85), - Arguments.of("e008364f-306d-4b3d-bce8-0083255d4b38", "tenant-001", 84), - Arguments.of("cc73abe9-9eba-409f-822a-87dc52e17fd9", "3df9d4e4-bda3-42f7-9b7c-85bf909d05d8", 11), - Arguments.of("2507759b-36af-471e-ad2e-f1c113d1e9e3", "a", 13), - Arguments.of("d7f20e07-ed42-42ed-84bb-895c608099f6", "f97f4108-4e1a-4c75-942f-8ea30236beb7", 9), - Arguments.of("48f165d5-7b00-47f4-b81e-f86f5c8cc1ab", "8700cfed-571a-41b0-a32e-a7fbb2bec49f", 18), - Arguments.of("46f7c9ea-b38c-445a-bad9-8a70a603e9e1", "user-0004", 32), - Arguments.of("f7ae9466-2c20-48f7-9732-3b35156199d0", "2d885f55-3d4e-42ba-a6b5-3b4cb3362773", 82), - Arguments.of("7450bc56-6fc6-4ccb-95b5-582a736a9625", "user-0008", 97), - Arguments.of("b39cfd4b-8abe-4d78-8520-10116895cea8", "b86759d1-3ba6-4a4a-839f-64bfbf5c8eed", 2), - Arguments.of("4cdac1b3-1894-45b6-b00a-65ccd081a3d4", "org-001", 74), - Arguments.of("6b0404f2-b094-40b8-ab01-a1c12a3a2107", "d34923a2-4d4b-4d7e-a96d-7e83b47a99c9", 93), - Arguments.of("fbc1af89-7d69-4938-84f7-18fd250c67f3", "org-003", 4), - Arguments.of("8bb01460-217f-471c-be0a-e8fa1ceac2cc", "org-002", 37), - Arguments.of("21636369-8b52-4b4a-97b7-50923ceb3ffd", "xxxxxxxxxxxxxx", 7), - Arguments.of("bdd640fb-0667-4ad1-9c80-317fa3b1799d", "c927b3d7-c9df-4be2-8065-5831f2396945", 29), - Arguments.of("705e3831-2331-4265-babf-7430e9e4817a", "87bb47cb-7f00-4264-8706-b39b55d9e4b8", 15), - Arguments.of("bd023447-34aa-43ff-b278-e0a594ac807a", "tenant-002", 24), - Arguments.of("cd28037c-1888-4b25-898c-cb7caf2a6a52", "user-0010", 51), - Arguments.of("06a3f5be-62a9-401b-8279-530735b8cfae", "user-0003", 15), - Arguments.of("eb85403c-7e8d-4475-9962-1895e98f559d", "xxxxxxxx", 71), - Arguments.of("8dbb5b2a-6e20-4f8e-9001-a6625a1298a1", "1a8d7c50-c0d3-4ac5-88a4-3616b02b0cd8", 41), - Arguments.of("e60922ca-8aba-43ed-a4d9-4a354580711b", "7dfb4d2e-17f5-413e-ace3-8d733ed6ff1a", 2), - Arguments.of("6513270e-269e-4d37-b2a7-4de452e6b438", "org-005", 65), - Arguments.of("c393fd0e-1cc6-4be5-b836-46bf0324aac3", "0ebd0956-dd97-4698-83c7-520f5783b05f", 60), - Arguments.of("72e63ac7-a953-4322-9f70-d5dc2e675fc7", "xxxxxxxxx", 92), - Arguments.of("07158ab7-95f3-4183-9b69-13cd87684f34", "bc087a02-3415-49ca-a6cd-1c580bad0d02", 79), - Arguments.of("21b8c26b-c023-43ab-95da-cb8f8c773fe6", "c50c6475-83c3-4a03-b99d-b98358c913cb", 59), - Arguments.of("e8d79f49-af6d-414c-8a6f-188a424e617b", "2f3e9786-a457-4917-b52b-e67cdbd76923", 70), - Arguments.of("377e6ff8-8e83-4961-ae84-3b2c7e96ba87", "tenant-005", 50), - Arguments.of("02f16d3d-8f4c-49e7-992b-0703f7467ac9", "e895eba8-39a6-4ad3-a3aa-7533875fd5a9", 86), - Arguments.of("323d3ab0-f35c-48a9-bce5-e9d717208331", "tenant-003", 88), - Arguments.of("d95bafc8-f2a4-427b-9cf4-bb99f4bea973", "9aa4e5cd-db41-4f65-9532-f707456606fe", 98), - Arguments.of("993955be-5888-4f39-937c-56af8c5187c1", "76f939de-30a5-46cc-980d-e48610541d3f", 32), - Arguments.of("d2e270f9-1fc6-4186-aebc-12938bfb2fcf", "ecd4771a-15e0-4c75-9c36-af0e659ba9df", 25), - Arguments.of("fe1b1434-3b10-4980-950c-aef9618a9261", "6f5bb29e-1705-4cec-9f02-2d65cbbdc47d", 66), - Arguments.of("dark-launch", "xxxxxxxxxxxx", 25), - Arguments.of("0a3aee49-6666-4879-938d-da71e3658966", "505f886a-8692-491e-a530-3537fa5dcfb4", 13), - Arguments.of("db5b5fab-8f4d-4e27-9da1-494c73cf256d", "xxxxxxxxxx", 44), - Arguments.of("3bb427c1-a1da-459d-aad1-245c92010b38", "f10cc4f3-ae39-489a-81cf-e9d7627bffe1", 76), - Arguments.of("374f469f-e3e0-4eda-9a6b-b5639dfcfbd4", "xxxxxxxxxxxxx", 58), - Arguments.of("ee34cf80-4b49-41f1-b1b9-016371f4a4e4", "2b57b54f-05fa-4c58-a83e-395b8e9a0d48", 66), - Arguments.of("4dad2986-ce83-4960-aa06-e9ab85a0bcc1", "9b58e9a0-ab47-4b99-a8b5-8dcf8187afc1", 78), - Arguments.of("9af49740-96f5-40ee-9e25-02420ae59635", "user-0006", 6), - Arguments.of("e539a78b-c8ef-4346-8b12-ae6ead581e57", "7dfa7c26-d794-4374-8ad7-57b6173beda4", 88), - Arguments.of("e3e70682-c209-4cac-a29f-6fbed82c07cd", "ed146fe6-b963-4511-aa7b-4732d636b6ce", 38), - Arguments.of("cd613e30-d8f1-4adf-91b7-584a2265b1f5", "193988fd-b97b-4177-bb55-68426f35f0bb", 97), - Arguments.of("10adf348-2c4b-4d89-9344-7cbaed90dafc", "xxxxxxxxxxx", 67), - Arguments.of("32833106-536e-45df-80b2-d002cc92d33d", "abcd", 10), - Arguments.of("c15521b1-b3dc-450a-9daa-37e51b591d75", "a814e7ff-b207-453b-8167-7ccdeb6c1acc", 41), - Arguments.of("de04cece-83e9-40e4-9c51-e692dc1729ca", "6347afc2-8444-4e8e-8f6d-a0d8ed54c5d8", 44), - Arguments.of("c12776e4-6dd4-41b2-abce-fab3a3b48c4a", "user-0002", 68), - Arguments.of("d1f36a09-9d74-4646-b388-d25833183edb", "user-0005", 86), - Arguments.of("85750621-02fb-4d4f-b57f-bc5af71a1bfc", "abcde", 30), - Arguments.of("feature-toggle", "tenant-004", 57), - Arguments.of("7b24b13f-17ba-4194-b6ae-4b1d3423bf15", "ab", 19), - Arguments.of("e149bd09-0df5-4245-84b0-6badfa7576c5", "201af639-f64b-4b83-8594-4e9cdac12538", 25), - Arguments.of("5532e8ba-3083-449e-b945-e4b665c1d4b4", "2f3adacf-3c50-4499-8960-90bdf5d7e2fd", 39), - Arguments.of("8e91579a-21c3-439e-90c1-91728c541241", "2ca20ef1-a0d9-47e9-8d6e-5c0baeb6ce87", 71), - Arguments.of("c6601970-e9ff-441d-8899-3a14bb459fdf", "c3565f1d-1bda-4d13-afc1-3e734ce48c7d", 99), - Arguments.of("c963cfe0-afae-4a3b-b909-6a04e7d80068", "user-0009", 94), - Arguments.of("ec7d4222-6f41-4481-8fde-580f122088a5", "user-0001", 6), - Arguments.of("988a0c48-5979-41d1-b000-368d2534c02d", "org-004", 10), - Arguments.of("bd0558b4-9828-4a61-883e-dd4112cc16df", "xxxxxxxxxxxxxxx", 7), - Arguments.of("a689ee27-eec1-43b6-95a8-f48f39643825", "097ac32f-0f04-4b42-9dcc-9e88dd29c0f4", 21), - Arguments.of("9530fcd9-d6fd-4d9b-a203-2801b65c1c28", "abc", 9), - Arguments.of("3e1c26d3-23ef-423e-a848-f808f54d35bf", "f4e663c3-d0eb-4c6c-a8c2-bf7e6628ac93", 32), - Arguments.of("75bd5125-db54-435f-9802-db897f041728", "780c4b16-a510-49fa-a2b2-bbd1c38dbe31", 64), - Arguments.of("8cd272e0-909e-4060-8425-07646bc9947a", "6a33c211-5af8-4c34-ba82-4d79846cb045", 23), - Arguments.of("experiment-a", "0a8dc2d9-fb7c-42a9-9bfd-200f65717c5f", 93), - Arguments.of("eeee3183-69ca-47e7-9826-00e9111f4efd", "30433819-7096-420f-9b9f-ff555504a5dd", 76), - Arguments.of("b3fb08f3-4b48-438b-9e51-a921e8e6a305", "3238dad5-c7d4-460e-868d-f8de4770a1e8", 84), - Arguments.of("14a03569-d26b-4496-92e5-dfe8cb1855fe", "user-0007", 65), - Arguments.of("9cecdeee-a156-4927-9ff6-3c0179e58218", "fd7f2fc4-7ecc-46a0-9ee1-073bac59d6dd", 2), - Arguments.of("568cec2b-740a-4798-96f1-813481854f8a", "a43ec79d-db51-4371-8827-cb5e7beac345", 73), - Arguments.of("6018366c-f658-47a7-9ed3-4fe53a096533", "5932b247-0569-4069-9688-867975502818", 83), - Arguments.of("87751d4c-a850-4e2c-84dc-da6a797d76de", "48c16871-3872-4ddc-a828-c3dd26f30f0b", 38), - Arguments.of("4a37fa2d-f2d7-440f-8785-9faeecc3f80c", "6b9bb2f6-535a-4e07-b6df-fce8112d9d11", 68), - Arguments.of("a8d42934-33e7-48a0-a81f-9b0cbf4e7af6", "545c10b7-eb4d-4a28-a1a6-e4dc0b46adec", 34), - Arguments.of("9c6ab710-4a08-4720-8ede-24428a013fda", "2b263e0c-e7b2-4237-beb7-aa5d9f5ddbf2", 11), - Arguments.of("b406dd29-9b57-4d64-8490-5c0914c25b99", "b5e8480a-d3dd-4a47-b108-781d1e644773", 28), - Arguments.of("checkout-v2", "11eb4678-7889-4121-898d-83332035bce5", 21), - Arguments.of("4462ebfc-5f91-4ef0-9cfb-ac6e7687a66e", "dff8494d-3bb3-4b25-9f81-20c447f0e17e", 43), - Arguments.of("63bf9de9-f33f-4a58-b698-0fbe5edcccc1", "48b0083a-3c39-4aba-9b9f-3e14c7608387", 11), - Arguments.of("test", "az", 1), - Arguments.of("bucket", "j", 1), - Arguments.of("test", "y", 100), - Arguments.of("flag", "c", 100), - Arguments.of("test-feature", "用户", 30), - Arguments.of("test-feature", "مستخدم", 19), - Arguments.of("test-feature", "ユーザー", 73), - Arguments.of("test-feature", "🎉", 54), - Arguments.of("test-feature", "café", 31), - Arguments.of("test-feature", "naïve", 28), - Arguments.of("rollout", "用户-001", 20), - Arguments.of("experiment-a", "пользователь", 81), - Arguments.of("test-feature", "사용자", 62), - Arguments.of("dark-launch", "テナント-001", 8) - ); - } } diff --git a/src/test/java/com/octopus/openfeature/provider/RolloutVectors.java b/src/test/java/com/octopus/openfeature/provider/RolloutVectors.java new file mode 100644 index 0000000..2f8360c --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/RolloutVectors.java @@ -0,0 +1,144 @@ +package com.octopus.openfeature.provider; + +import org.junit.jupiter.params.provider.Arguments; + +import java.util.stream.Stream; + +/** + * The shared bucketing vectors, asserted against every percentage-rollout implementation in this + * library. + * + *

These verify that a bucket comes out the same as it does in the equivalent implementations in the + * other Octopus OpenFeature provider libraries (e.g. .NET). The expected values are derived from the + * reference MurmurHash3 little-endian algorithm and are duplicated verbatim across all libraries. DO + * NOT modify the input arguments or expected values — doing so would mask a real divergence in + * evaluation behaviour between libraries and defeat the purpose of these cases. + * + *

Public and in this package so both the v3 path and the v4 rollout can consume one list: the two + * hold separate copies of the hash, and a single set of vectors is what stops them drifting apart. + * Test-only, so it is never published. + */ +public final class RolloutVectors { + + private RolloutVectors() { + } + + public static Stream cases() { + return Stream.of( + Arguments.of("ef70b4c0-1773-44a3-9b95-f239ae97d9db", "780c4b16-a510-49fa-a2b2-bbd1c38dbe31", 48), + Arguments.of("055665f0-fbb3-484e-8ef1-52125425b7b2", "6a33c211-5af8-4c34-ba82-4d79846cb045", 85), + Arguments.of("8e63257f-07f1-43b6-871f-4e8fcaa1718e", "0a8dc2d9-fb7c-42a9-9bfd-200f65717c5f", 47), + Arguments.of("b8a1abcd-1a69-46c7-8da4-f9fc3c6da5d7", "30433819-7096-420f-9b9f-ff555504a5dd", 79), + Arguments.of("277582f0-93f5-4c2c-888e-44f94ecc6c7f", "3238dad5-c7d4-460e-868d-f8de4770a1e8", 97), + Arguments.of("41f60be0-7cef-4aa3-aaed-cf4a4599a084", "user-0007", 41), + Arguments.of("rollout", "fd7f2fc4-7ecc-46a0-9ee1-073bac59d6dd", 7), + Arguments.of("83a52df2-49a6-47ec-89f1-b6106a2ca9a3", "a43ec79d-db51-4371-8827-cb5e7beac345", 90), + Arguments.of("7b89296c-6dcb-4c50-8857-7eb1924770d3", "5932b247-0569-4069-9688-867975502818", 60), + Arguments.of("7aeff54e-808c-46a6-9f49-2fba47a1fca7", "48c16871-3872-4ddc-a828-c3dd26f30f0b", 90), + Arguments.of("b08969ee-f0d8-45e3-b763-c84ebdde03f1", "6b9bb2f6-535a-4e07-b6df-fce8112d9d11", 68), + Arguments.of("5bc8fbbc-bde5-4099-8164-d8399f767c45", "545c10b7-eb4d-4a28-a1a6-e4dc0b46adec", 79), + Arguments.of("5d357ffe-4423-460d-9b0e-da407f5e8e61", "2b263e0c-e7b2-4237-beb7-aa5d9f5ddbf2", 93), + Arguments.of("8d62d777-8090-44bd-96a7-4dbe3e572e0f", "b5e8480a-d3dd-4a47-b108-781d1e644773", 60), + Arguments.of("688bf8ca-c418-4fcd-9db8-4aa91325af10", "11eb4678-7889-4121-898d-83332035bce5", 28), + Arguments.of("8623121d-e0bb-437a-9459-4d8b75673fca", "dff8494d-3bb3-4b25-9f81-20c447f0e17e", 76), + Arguments.of("c33f4584-b23b-41d8-893c-d01609de8895", "48b0083a-3c39-4aba-9b9f-3e14c7608387", 85), + Arguments.of("e008364f-306d-4b3d-bce8-0083255d4b38", "tenant-001", 84), + Arguments.of("cc73abe9-9eba-409f-822a-87dc52e17fd9", "3df9d4e4-bda3-42f7-9b7c-85bf909d05d8", 11), + Arguments.of("2507759b-36af-471e-ad2e-f1c113d1e9e3", "a", 13), + Arguments.of("d7f20e07-ed42-42ed-84bb-895c608099f6", "f97f4108-4e1a-4c75-942f-8ea30236beb7", 9), + Arguments.of("48f165d5-7b00-47f4-b81e-f86f5c8cc1ab", "8700cfed-571a-41b0-a32e-a7fbb2bec49f", 18), + Arguments.of("46f7c9ea-b38c-445a-bad9-8a70a603e9e1", "user-0004", 32), + Arguments.of("f7ae9466-2c20-48f7-9732-3b35156199d0", "2d885f55-3d4e-42ba-a6b5-3b4cb3362773", 82), + Arguments.of("7450bc56-6fc6-4ccb-95b5-582a736a9625", "user-0008", 97), + Arguments.of("b39cfd4b-8abe-4d78-8520-10116895cea8", "b86759d1-3ba6-4a4a-839f-64bfbf5c8eed", 2), + Arguments.of("4cdac1b3-1894-45b6-b00a-65ccd081a3d4", "org-001", 74), + Arguments.of("6b0404f2-b094-40b8-ab01-a1c12a3a2107", "d34923a2-4d4b-4d7e-a96d-7e83b47a99c9", 93), + Arguments.of("fbc1af89-7d69-4938-84f7-18fd250c67f3", "org-003", 4), + Arguments.of("8bb01460-217f-471c-be0a-e8fa1ceac2cc", "org-002", 37), + Arguments.of("21636369-8b52-4b4a-97b7-50923ceb3ffd", "xxxxxxxxxxxxxx", 7), + Arguments.of("bdd640fb-0667-4ad1-9c80-317fa3b1799d", "c927b3d7-c9df-4be2-8065-5831f2396945", 29), + Arguments.of("705e3831-2331-4265-babf-7430e9e4817a", "87bb47cb-7f00-4264-8706-b39b55d9e4b8", 15), + Arguments.of("bd023447-34aa-43ff-b278-e0a594ac807a", "tenant-002", 24), + Arguments.of("cd28037c-1888-4b25-898c-cb7caf2a6a52", "user-0010", 51), + Arguments.of("06a3f5be-62a9-401b-8279-530735b8cfae", "user-0003", 15), + Arguments.of("eb85403c-7e8d-4475-9962-1895e98f559d", "xxxxxxxx", 71), + Arguments.of("8dbb5b2a-6e20-4f8e-9001-a6625a1298a1", "1a8d7c50-c0d3-4ac5-88a4-3616b02b0cd8", 41), + Arguments.of("e60922ca-8aba-43ed-a4d9-4a354580711b", "7dfb4d2e-17f5-413e-ace3-8d733ed6ff1a", 2), + Arguments.of("6513270e-269e-4d37-b2a7-4de452e6b438", "org-005", 65), + Arguments.of("c393fd0e-1cc6-4be5-b836-46bf0324aac3", "0ebd0956-dd97-4698-83c7-520f5783b05f", 60), + Arguments.of("72e63ac7-a953-4322-9f70-d5dc2e675fc7", "xxxxxxxxx", 92), + Arguments.of("07158ab7-95f3-4183-9b69-13cd87684f34", "bc087a02-3415-49ca-a6cd-1c580bad0d02", 79), + Arguments.of("21b8c26b-c023-43ab-95da-cb8f8c773fe6", "c50c6475-83c3-4a03-b99d-b98358c913cb", 59), + Arguments.of("e8d79f49-af6d-414c-8a6f-188a424e617b", "2f3e9786-a457-4917-b52b-e67cdbd76923", 70), + Arguments.of("377e6ff8-8e83-4961-ae84-3b2c7e96ba87", "tenant-005", 50), + Arguments.of("02f16d3d-8f4c-49e7-992b-0703f7467ac9", "e895eba8-39a6-4ad3-a3aa-7533875fd5a9", 86), + Arguments.of("323d3ab0-f35c-48a9-bce5-e9d717208331", "tenant-003", 88), + Arguments.of("d95bafc8-f2a4-427b-9cf4-bb99f4bea973", "9aa4e5cd-db41-4f65-9532-f707456606fe", 98), + Arguments.of("993955be-5888-4f39-937c-56af8c5187c1", "76f939de-30a5-46cc-980d-e48610541d3f", 32), + Arguments.of("d2e270f9-1fc6-4186-aebc-12938bfb2fcf", "ecd4771a-15e0-4c75-9c36-af0e659ba9df", 25), + Arguments.of("fe1b1434-3b10-4980-950c-aef9618a9261", "6f5bb29e-1705-4cec-9f02-2d65cbbdc47d", 66), + Arguments.of("dark-launch", "xxxxxxxxxxxx", 25), + Arguments.of("0a3aee49-6666-4879-938d-da71e3658966", "505f886a-8692-491e-a530-3537fa5dcfb4", 13), + Arguments.of("db5b5fab-8f4d-4e27-9da1-494c73cf256d", "xxxxxxxxxx", 44), + Arguments.of("3bb427c1-a1da-459d-aad1-245c92010b38", "f10cc4f3-ae39-489a-81cf-e9d7627bffe1", 76), + Arguments.of("374f469f-e3e0-4eda-9a6b-b5639dfcfbd4", "xxxxxxxxxxxxx", 58), + Arguments.of("ee34cf80-4b49-41f1-b1b9-016371f4a4e4", "2b57b54f-05fa-4c58-a83e-395b8e9a0d48", 66), + Arguments.of("4dad2986-ce83-4960-aa06-e9ab85a0bcc1", "9b58e9a0-ab47-4b99-a8b5-8dcf8187afc1", 78), + Arguments.of("9af49740-96f5-40ee-9e25-02420ae59635", "user-0006", 6), + Arguments.of("e539a78b-c8ef-4346-8b12-ae6ead581e57", "7dfa7c26-d794-4374-8ad7-57b6173beda4", 88), + Arguments.of("e3e70682-c209-4cac-a29f-6fbed82c07cd", "ed146fe6-b963-4511-aa7b-4732d636b6ce", 38), + Arguments.of("cd613e30-d8f1-4adf-91b7-584a2265b1f5", "193988fd-b97b-4177-bb55-68426f35f0bb", 97), + Arguments.of("10adf348-2c4b-4d89-9344-7cbaed90dafc", "xxxxxxxxxxx", 67), + Arguments.of("32833106-536e-45df-80b2-d002cc92d33d", "abcd", 10), + Arguments.of("c15521b1-b3dc-450a-9daa-37e51b591d75", "a814e7ff-b207-453b-8167-7ccdeb6c1acc", 41), + Arguments.of("de04cece-83e9-40e4-9c51-e692dc1729ca", "6347afc2-8444-4e8e-8f6d-a0d8ed54c5d8", 44), + Arguments.of("c12776e4-6dd4-41b2-abce-fab3a3b48c4a", "user-0002", 68), + Arguments.of("d1f36a09-9d74-4646-b388-d25833183edb", "user-0005", 86), + Arguments.of("85750621-02fb-4d4f-b57f-bc5af71a1bfc", "abcde", 30), + Arguments.of("feature-toggle", "tenant-004", 57), + Arguments.of("7b24b13f-17ba-4194-b6ae-4b1d3423bf15", "ab", 19), + Arguments.of("e149bd09-0df5-4245-84b0-6badfa7576c5", "201af639-f64b-4b83-8594-4e9cdac12538", 25), + Arguments.of("5532e8ba-3083-449e-b945-e4b665c1d4b4", "2f3adacf-3c50-4499-8960-90bdf5d7e2fd", 39), + Arguments.of("8e91579a-21c3-439e-90c1-91728c541241", "2ca20ef1-a0d9-47e9-8d6e-5c0baeb6ce87", 71), + Arguments.of("c6601970-e9ff-441d-8899-3a14bb459fdf", "c3565f1d-1bda-4d13-afc1-3e734ce48c7d", 99), + Arguments.of("c963cfe0-afae-4a3b-b909-6a04e7d80068", "user-0009", 94), + Arguments.of("ec7d4222-6f41-4481-8fde-580f122088a5", "user-0001", 6), + Arguments.of("988a0c48-5979-41d1-b000-368d2534c02d", "org-004", 10), + Arguments.of("bd0558b4-9828-4a61-883e-dd4112cc16df", "xxxxxxxxxxxxxxx", 7), + Arguments.of("a689ee27-eec1-43b6-95a8-f48f39643825", "097ac32f-0f04-4b42-9dcc-9e88dd29c0f4", 21), + Arguments.of("9530fcd9-d6fd-4d9b-a203-2801b65c1c28", "abc", 9), + Arguments.of("3e1c26d3-23ef-423e-a848-f808f54d35bf", "f4e663c3-d0eb-4c6c-a8c2-bf7e6628ac93", 32), + Arguments.of("75bd5125-db54-435f-9802-db897f041728", "780c4b16-a510-49fa-a2b2-bbd1c38dbe31", 64), + Arguments.of("8cd272e0-909e-4060-8425-07646bc9947a", "6a33c211-5af8-4c34-ba82-4d79846cb045", 23), + Arguments.of("experiment-a", "0a8dc2d9-fb7c-42a9-9bfd-200f65717c5f", 93), + Arguments.of("eeee3183-69ca-47e7-9826-00e9111f4efd", "30433819-7096-420f-9b9f-ff555504a5dd", 76), + Arguments.of("b3fb08f3-4b48-438b-9e51-a921e8e6a305", "3238dad5-c7d4-460e-868d-f8de4770a1e8", 84), + Arguments.of("14a03569-d26b-4496-92e5-dfe8cb1855fe", "user-0007", 65), + Arguments.of("9cecdeee-a156-4927-9ff6-3c0179e58218", "fd7f2fc4-7ecc-46a0-9ee1-073bac59d6dd", 2), + Arguments.of("568cec2b-740a-4798-96f1-813481854f8a", "a43ec79d-db51-4371-8827-cb5e7beac345", 73), + Arguments.of("6018366c-f658-47a7-9ed3-4fe53a096533", "5932b247-0569-4069-9688-867975502818", 83), + Arguments.of("87751d4c-a850-4e2c-84dc-da6a797d76de", "48c16871-3872-4ddc-a828-c3dd26f30f0b", 38), + Arguments.of("4a37fa2d-f2d7-440f-8785-9faeecc3f80c", "6b9bb2f6-535a-4e07-b6df-fce8112d9d11", 68), + Arguments.of("a8d42934-33e7-48a0-a81f-9b0cbf4e7af6", "545c10b7-eb4d-4a28-a1a6-e4dc0b46adec", 34), + Arguments.of("9c6ab710-4a08-4720-8ede-24428a013fda", "2b263e0c-e7b2-4237-beb7-aa5d9f5ddbf2", 11), + Arguments.of("b406dd29-9b57-4d64-8490-5c0914c25b99", "b5e8480a-d3dd-4a47-b108-781d1e644773", 28), + Arguments.of("checkout-v2", "11eb4678-7889-4121-898d-83332035bce5", 21), + Arguments.of("4462ebfc-5f91-4ef0-9cfb-ac6e7687a66e", "dff8494d-3bb3-4b25-9f81-20c447f0e17e", 43), + Arguments.of("63bf9de9-f33f-4a58-b698-0fbe5edcccc1", "48b0083a-3c39-4aba-9b9f-3e14c7608387", 11), + Arguments.of("test", "az", 1), + Arguments.of("bucket", "j", 1), + Arguments.of("test", "y", 100), + Arguments.of("flag", "c", 100), + Arguments.of("test-feature", "用户", 30), + Arguments.of("test-feature", "مستخدم", 19), + Arguments.of("test-feature", "ユーザー", 73), + Arguments.of("test-feature", "🎉", 54), + Arguments.of("test-feature", "café", 31), + Arguments.of("test-feature", "naïve", 28), + Arguments.of("rollout", "用户-001", 20), + Arguments.of("experiment-a", "пользователь", 81), + Arguments.of("test-feature", "사용자", 62), + Arguments.of("dark-launch", "テナント-001", 8) + ); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializationTests.java b/src/test/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializationTests.java new file mode 100644 index 0000000..d03baf0 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializationTests.java @@ -0,0 +1,116 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.octopus.openfeature.provider.TestObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.InputStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Polymorphic deserialization of a single condition, using the provider's own + * {@code OctopusObjectMapper} as the client does in production. Whole responses are covered by + * {@link ServerSideEvaluationDeserializationTests}. + */ +class ClientSideConditionDeserializationTests { + + private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE; + + private InputStream resource(String name) { + return getClass().getResourceAsStream(name); + } + + @Test + void shouldDeserializePercentageByContextConditionToConcreteType() throws Exception { + var condition = objectMapper.readValue( + resource("condition-percentage-by-context.json"), ClientSideCondition.class); + + assertThat(condition) + .isInstanceOfSatisfying(PercentageByContextCondition.class, + percentage -> assertThat(percentage.getPercentage()).hasValue(50)); + } + + @Test + void shouldDeserializeContextAttributeIsOneOfConditionToConcreteType() throws Exception { + var condition = objectMapper.readValue( + resource("condition-context-attribute-is-one-of.json"), ClientSideCondition.class); + + assertThat(condition) + .isInstanceOfSatisfying(ContextAttributeIsOneOfCondition.class, isOneOf -> { + assertThat(isOneOf.getKey()).isEqualTo("user-id"); + assertThat(isOneOf.getValues()).containsExactly("1234", "5678"); + }); + } + + @Test + void shouldDeserializeContextAttributeIsNotOneOfConditionToConcreteType() throws Exception { + var condition = objectMapper.readValue( + resource("condition-context-attribute-is-not-one-of.json"), ClientSideCondition.class); + + assertThat(condition) + .isInstanceOfSatisfying(ContextAttributeIsNotOneOfCondition.class, isNotOneOf -> { + assertThat(isNotOneOf.getKey()).isEqualTo("region"); + assertThat(isNotOneOf.getValues()).containsExactly("us", "eu"); + }); + } + + @Test + void shouldDeserializeMixedConditionListToConcreteTypes() throws Exception { + var conditions = objectMapper.readValue( + resource("condition-list-mixed.json"), + new TypeReference>() {} + ); + + assertThat(conditions).hasExactlyElementsOfTypes( + PercentageByContextCondition.class, + ContextAttributeIsOneOfCondition.class, + ContextAttributeIsNotOneOfCondition.class + ); + } + + @Test + void shouldDeserializeUnknownConditionTypeToUnknownConditionInsteadOfThrowing() throws Exception { + var condition = objectMapper.readValue( + resource("condition-unknown-type.json"), ClientSideCondition.class); + + assertThat(condition) + .isInstanceOfSatisfying(UnknownCondition.class, + unknown -> assertThat(unknown.getType()).hasValue("not-a-real-condition")); + } + + @Test + void shouldDeserializeConditionWithoutTypeDiscriminatorToUnknownCondition() throws Exception { + var condition = objectMapper.readValue( + resource("condition-missing-type.json"), ClientSideCondition.class); + + assertThat(condition) + .isInstanceOfSatisfying(UnknownCondition.class, + unknown -> assertThat(unknown.getType()).isEmpty()); + } + + // A discriminator that is not a string is a response no server sends, so it is kept distinct from + // a merely unrecognised one: it carries no type, and so fails evaluation rather than degrading + // quietly. This is the distinction Jackson's own polymorphic handling cannot express — it coerces + // any scalar type id to a string, which would make 123 indistinguishable from "123" — and the + // reason ClientSideConditionDeserializer is written by hand rather than driven by @JsonTypeInfo. + @ParameterizedTest(name = "[{index}] {0}") + @ValueSource(strings = { + "{ 'type': 123, 'percentage': 50 }", + "{ 'type': true, 'percentage': 50 }", + "{ 'type': null, 'percentage': 50 }", + "{ 'type': {}, 'percentage': 50 }", + "{ 'type': [], 'percentage': 50 }" + }) + void shouldDeserializeUnusableDiscriminatorToUnknownConditionCarryingNoType(String conditionJson) throws Exception { + var condition = objectMapper.readValue(Contexts.json(conditionJson), ClientSideCondition.class); + + assertThat(condition) + .isInstanceOfSatisfying(UnknownCondition.class, + unknown -> assertThat(unknown.getType()).isEmpty()); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ClientSideRuleTests.java b/src/test/java/com/octopus/openfeature/provider/v4/ClientSideRuleTests.java new file mode 100644 index 0000000..a4d3296 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/ClientSideRuleTests.java @@ -0,0 +1,85 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.octopus.openfeature.provider.TestObjectMapper; +import dev.openfeature.sdk.exceptions.ParseError; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ClientSideRuleTests { + + private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE; + + private static ClientSideRule rule(ClientSideCondition... conditions) { + return new ClientSideRule("Rule 1", Arrays.asList(conditions)); + } + + @Test + void aSingleMatchingConditionMatches() { + assertThat(rule(new ContextAttributeIsOneOfCondition("plan", List.of("pro"))) + .matches(Contexts.forRules(null, "plan", "pro"))).isTrue(); + } + + @Test + void conditionsAreCombinedWithAnd() { + var rule = rule( + new PercentageByContextCondition(100), + new ContextAttributeIsOneOfCondition("plan", List.of("pro"))); + + assertThat(rule.matches(Contexts.forRules(Contexts.TARGETING_KEY, "plan", "pro"))) + .as("both conditions match").isTrue(); + assertThat(rule.matches(Contexts.forRules(Contexts.TARGETING_KEY, "plan", "free"))) + .as("one condition fails").isFalse(); + } + + @Test + void aMalformedConditionBehindAFailingOneIsNeverRead() { + // Conditions stop at the first that does not match, so the rest are never read. + var rule = rule( + new ContextAttributeIsOneOfCondition("plan", List.of("pro")), + new PercentageByContextCondition(null)); + + assertThat(rule.matches(Contexts.forRules(Contexts.TARGETING_KEY, "plan", "free"))).isFalse(); + } + + @Test + void aNamedRuleWithConditionsEvaluates() { + assertThat(rule(new ContextAttributeIsOneOfCondition("plan", List.of("pro"))) + .matches(Contexts.forRules(null, "plan", "pro"))).isTrue(); + assertThat(rule(new UnknownCondition("some-future-condition")) + .matches(Contexts.forRules(Contexts.TARGETING_KEY))) + .as("a condition from a newer server is well-formed, it just never matches").isFalse(); + } + + // Deserialized rather than constructed: the server only defers a named rule carrying at least one + // condition, so these shapes only arrive off the wire. + @ParameterizedTest(name = "[{index}] {1}") + @MethodSource("malformedRules") + void aMalformedRuleThrowsAParseErrorDescribingTheProblem(String ruleJson, String expectedProblem) throws Exception { + var rule = objectMapper.readValue(Contexts.json(ruleJson), ClientSideRule.class); + + assertThatThrownBy(() -> rule.matches(Contexts.forRules(Contexts.TARGETING_KEY))) + .isInstanceOf(ParseError.class) + .hasMessage(expectedProblem); + } + + static Stream malformedRules() { + return Stream.of( + Arguments.of("{ 'conditions': [ { 'type': 'percentage-by-context', 'percentage': 50 } ] }", + "A rule has no name."), + Arguments.of("{ 'name': 'R', 'conditions': [] }", "Rule 'R' has no conditions."), + Arguments.of("{ 'name': 'R' }", "Rule 'R' has no conditions."), + Arguments.of("{ 'name': 'R', 'conditions': null }", "Rule 'R' has no conditions."), + Arguments.of("{ 'name': 'R', 'conditions': [ null ] }", "Rule 'R' has a missing condition.") + ); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfConditionTests.java b/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfConditionTests.java new file mode 100644 index 0000000..317c815 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfConditionTests.java @@ -0,0 +1,68 @@ +package com.octopus.openfeature.provider.v4; + +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.exceptions.ParseError; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContextAttributeIsNotOneOfConditionTests { + + @Test + void matchesUnlessTheAttributeValueIsListed() { + var condition = new ContextAttributeIsNotOneOfCondition("region", List.of("eu")); + + assertThat(condition.matches(Contexts.forRules(null, "region", "us"))).isTrue(); + assertThat(condition.matches(Contexts.forRules(null, "region", "eu"))).isFalse(); + assertThat(condition.matches(Contexts.forRules(null))) + .as("a missing attribute is not one of the values").isTrue(); + } + + @Test + void theKeyAndValueAreCaseInsensitive() { + var condition = new ContextAttributeIsNotOneOfCondition("Region", List.of("EU")); + + assertThat(condition.matches(Contexts.forRules(null, "region", "eu"))).isFalse(); + } + + @Test + void aNonStringValueIsTreatedAsAbsent() { + // Absent means "not one of", so the condition matches. + var context = new ClientSideEvaluationContext( + Contexts.EVALUATION_KEY, new MutableContext().add("user-id", 1234)); + + assertThat(new ContextAttributeIsNotOneOfCondition("user-id", List.of("1234")).matches(context)).isTrue(); + } + + @Test + void aNullOpenFeatureContextMatches() { + assertThat(new ContextAttributeIsNotOneOfCondition("region", List.of("eu")) + .matches(Contexts.withoutOpenFeatureContext())).isTrue(); + } + + @ParameterizedTest(name = "[{index}] {2}") + @MethodSource("nothingToMatchOn") + void aConditionWithNothingToMatchOnThrowsAParseError(String key, List values, String expectedProblem) { + assertThatThrownBy(() -> new ContextAttributeIsNotOneOfCondition(key, values) + .matches(Contexts.forRules(null, "region", "us"))) + .isInstanceOf(ParseError.class) + .hasMessage(expectedProblem); + } + + static Stream nothingToMatchOn() { + return Stream.of( + Arguments.of(null, List.of("eu"), "A condition is missing a key."), + Arguments.of("region", null, "A condition is missing values."), + Arguments.of("region", List.of(), "A condition is missing values."), + Arguments.of("region", Arrays.asList("eu", null), "A condition is missing a value.") + ); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfConditionTests.java b/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfConditionTests.java new file mode 100644 index 0000000..674eb62 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfConditionTests.java @@ -0,0 +1,85 @@ +package com.octopus.openfeature.provider.v4; + +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.exceptions.ParseError; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContextAttributeIsOneOfConditionTests { + + @Test + void matchesWhenTheAttributeValueIsListed() { + var condition = new ContextAttributeIsOneOfCondition("user-id", List.of("1234", "5678")); + + assertThat(condition.matches(Contexts.forRules(null, "user-id", "5678"))).isTrue(); + assertThat(condition.matches(Contexts.forRules(null, "user-id", "9999"))).isFalse(); + assertThat(condition.matches(Contexts.forRules(null))) + .as("a missing attribute is not one of the values").isFalse(); + } + + @Test + void theKeyAndValueAreCaseInsensitive() { + var condition = new ContextAttributeIsOneOfCondition("Region", List.of("EU", "US")); + + assertThat(condition.matches(Contexts.forRules(null, "region", "eu"))).isTrue(); + assertThat(condition.matches(Contexts.forRules(null, "REGION", "Us"))).isTrue(); + } + + @ParameterizedTest(name = "[{index}] {0}={1}, {2}={3}") + @CsvSource({"Plan,free,plan,pro", "plan,pro,Plan,free"}) + void everyEntryWhoseKeyMatchesIsChecked(String firstKey, String firstValue, String secondKey, String secondValue) { + // A context can hold several case variants of one key, and the map's iteration order is not + // guaranteed, so checking only the first matching entry would evaluate inconsistently. + var condition = new ContextAttributeIsOneOfCondition("plan", List.of("pro")); + + var context = Contexts.forRules(null, firstKey, firstValue, secondKey, secondValue); + + assertThat(condition.matches(context)) + .as("one of the 'plan' entries is 'pro', whichever order they are iterated in").isTrue(); + } + + @Test + void aNonStringValueIsTreatedAsAbsent() { + // Value.asString() is null for a non-string, and v3 segment matching skips those entries too, so + // a numeric attribute never matches a string value. + var context = new ClientSideEvaluationContext( + Contexts.EVALUATION_KEY, new MutableContext().add("user-id", 1234)); + + assertThat(new ContextAttributeIsOneOfCondition("user-id", List.of("1234")).matches(context)).isFalse(); + } + + @Test + void aNullOpenFeatureContextDoesNotMatch() { + assertThat(new ContextAttributeIsOneOfCondition("plan", List.of("pro")) + .matches(Contexts.withoutOpenFeatureContext())).isFalse(); + } + + // A condition with nothing to match on has no defensible answer, so it fails the evaluation. + @ParameterizedTest(name = "[{index}] {2}") + @MethodSource("nothingToMatchOn") + void aConditionWithNothingToMatchOnThrowsAParseError(String key, List values, String expectedProblem) { + assertThatThrownBy(() -> new ContextAttributeIsOneOfCondition(key, values) + .matches(Contexts.forRules(null, "plan", "pro"))) + .isInstanceOf(ParseError.class) + .hasMessage(expectedProblem); + } + + static Stream nothingToMatchOn() { + return Stream.of( + Arguments.of(null, List.of("pro"), "A condition is missing a key."), + Arguments.of("plan", null, "A condition is missing values."), + Arguments.of("plan", List.of(), "A condition is missing values."), + Arguments.of("plan", Arrays.asList("pro", null), "A condition is missing a value.") + ); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/Contexts.java b/src/test/java/com/octopus/openfeature/provider/v4/Contexts.java new file mode 100644 index 0000000..92fc0fb --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/Contexts.java @@ -0,0 +1,64 @@ +package com.octopus.openfeature.provider.v4; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.MutableContext; + +/** + * Builds the contexts the v4 evaluation tests run against. + */ +final class Contexts { + + // getNormalizedNumber("evaluation-key", "targeting-key") == 13, so this targeting key is inside a + // >=13% rollout and outside a <13% one. The rollout tests either side of the bucket pin that value, + // which is also what the .NET provider's equivalent tests use. + static final String SLUG = "my-feature"; + static final String EVALUATION_KEY = "evaluation-key"; + static final String TARGETING_KEY = "targeting-key"; + static final int TARGETING_KEY_BUCKET = 13; + + private Contexts() { + } + + /** + * A JSON payload written with single quotes, so the malformed-response cases stay readable on one + * line without escaping. No payload here contains an apostrophe. + */ + static String json(String singleQuoted) { + return singleQuoted.replace('\'', '"'); + } + + /** + * An OpenFeature context with the given targeting key and string attributes, supplied as + * alternating key and value arguments. + */ + static EvaluationContext openFeature(String targetingKey, String... attributes) { + if (attributes.length % 2 != 0) { + throw new IllegalArgumentException("Attributes must be alternating keys and values."); + } + + var context = new MutableContext(); + for (int i = 0; i < attributes.length; i += 2) { + context.add(attributes[i], attributes[i + 1]); + } + + if (targetingKey != null) { + context.setTargetingKey(targetingKey); + } + + return context; + } + + /** + * What a rule or condition is evaluated against. + */ + static ClientSideEvaluationContext forRules(String targetingKey, String... attributes) { + return new ClientSideEvaluationContext(EVALUATION_KEY, openFeature(targetingKey, attributes)); + } + + /** + * A rule context whose caller supplied no context at all. + */ + static ClientSideEvaluationContext withoutOpenFeatureContext() { + return new ClientSideEvaluationContext(EVALUATION_KEY, null); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/MalformedEvaluationTests.java b/src/test/java/com/octopus/openfeature/provider/v4/MalformedEvaluationTests.java new file mode 100644 index 0000000..763ba43 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/MalformedEvaluationTests.java @@ -0,0 +1,145 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.octopus.openfeature.provider.TestObjectMapper; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.exceptions.ParseError; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A response the server could not legitimately have sent throws {@link ParseError}. The one + * exception — a condition type this client does not recognise — is covered by + * {@link UnrecognisedConditionTests}. + * + *

Every case is deserialized rather than constructed: these shapes are only reachable off the + * wire. + */ +class MalformedEvaluationTests { + + private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE; + + /** + * Satisfies every rule below, so a flag that failed to throw would visibly turn on. + */ + private static EvaluationContext matchingContext() { + return Contexts.openFeature(Contexts.TARGETING_KEY, "license", "trial", "ring", "beta"); + } + + private ServerSideEvaluation flag(String singleQuotedJson) throws Exception { + return objectMapper.readValue(Contexts.json(singleQuotedJson), ServerSideEvaluation.class); + } + + @ParameterizedTest(name = "[{index}] {1}") + @MethodSource("malformedFlags") + void aMalformedFlagThrowsAParseError(String flagJson, String expectedProblem) throws Exception { + var flag = flag(flagJson); + + assertThatThrownBy(() -> flag.evaluate(matchingContext())) + .isInstanceOf(ParseError.class) + .hasMessage(expectedProblem) + .extracting(thrown -> ((ParseError) thrown).getErrorCode()).isEqualTo(ErrorCode.PARSE_ERROR); + } + + static Stream malformedFlags() { + return Stream.of( + // Neither shape, or both at once. + Arguments.of("{ 'slug': 'my-feature' }", + "The flag has neither a value nor rules."), + Arguments.of("{ 'slug': 'my-feature', 'value': true }", + "The flag has a value but has no reason."), + Arguments.of("{ 'slug': 'my-feature', 'value': true, 'reason': 'Enabled.', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Beta ring', 'conditions': [ { 'type': 'context-attribute-is-one-of', 'key': 'ring', 'values': [ 'beta' ] } ] } ] }", + "The flag has both a server-resolved value and client-side rules."), + // Deferred, but not evaluable. + Arguments.of("{ 'slug': 'my-feature', 'rules': [ { 'name': 'Beta ring', 'conditions': [ { 'type': 'context-attribute-is-one-of', 'key': 'ring', 'values': [ 'beta' ] } ] } ] }", + "The flag defers to the client but has no evaluation key."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [] }", + "The flag defers to the client with no rules."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ null ] }", + "The flag has a missing rule."), + // Rules. + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'conditions': [ { 'type': 'context-attribute-is-one-of', 'key': 'ring', 'values': [ 'beta' ] } ] } ] }", + "A rule has no name."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Beta ring', 'conditions': [] } ] }", + "Rule 'Beta ring' has no conditions."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Beta ring' } ] }", + "Rule 'Beta ring' has no conditions."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Beta ring', 'conditions': null } ] }", + "Rule 'Beta ring' has no conditions."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Beta ring', 'conditions': [ null ] } ] }", + "Rule 'Beta ring' has a missing condition."), + // Conditions with no usable type. Unlike an unrecognised type, no server version emits these. + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Trial licences', 'conditions': [ { 'key': 'license', 'values': [ 'trial' ] } ] } ] }", + "A condition is missing a type."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Trial licences', 'conditions': [ { 'type': 123, 'key': 'license', 'values': [ 'trial' ] } ] } ] }", + "A condition is missing a type."), + // percentage-by-context. + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Partial rollout', 'conditions': [ { 'type': 'percentage-by-context' } ] } ] }", + "A condition is missing a percentage value."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Partial rollout', 'conditions': [ { 'type': 'percentage-by-context', 'percentage': 101 } ] } ] }", + "A condition has a percentage of 101."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Partial rollout', 'conditions': [ { 'type': 'percentage-by-context', 'percentage': -1 } ] } ] }", + "A condition has a percentage of -1."), + // Attribute conditions. + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Trial licences', 'conditions': [ { 'type': 'context-attribute-is-one-of', 'key': 'license' } ] } ] }", + "A condition is missing values."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Trial licences', 'conditions': [ { 'type': 'context-attribute-is-one-of', 'key': 'license', 'values': [] } ] } ] }", + "A condition is missing values."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Trial licences', 'conditions': [ { 'type': 'context-attribute-is-one-of', 'key': 'license', 'values': [ null ] } ] } ] }", + "A condition is missing a value."), + Arguments.of("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [ { 'name': 'Trial licences', 'conditions': [ { 'type': 'context-attribute-is-not-one-of', 'values': [ 'trial' ] } ] } ] }", + "A condition is missing a key.") + ); + } + + @Test + void aMalformedRuleFailsTheFlagEvenWhenALaterRuleMatches() throws Exception { + // The second rule matches, but the first is read before it: a rule the client cannot make sense + // of is not skipped in favour of the ones that happened to parse. + var flag = flag("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [" + + " { 'name': 'Trial licences', 'conditions': [ { 'key': 'license', 'values': [ 'trial' ] } ] }," + + " { 'name': 'Beta ring', 'conditions': [ { 'type': 'context-attribute-is-one-of', 'key': 'ring', 'values': [ 'beta' ] } ] } ] }"); + + assertThatThrownBy(() -> flag.evaluate(matchingContext())) + .isInstanceOf(ParseError.class) + .hasMessage("A condition is missing a type."); + } + + @Test + void aMalformedRuleBehindAMatchingRuleIsNeverRead() throws Exception { + // Nothing checks the response up front, so a rule only fails the flag if evaluation reaches it. + var flag = flag("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [" + + " { 'name': 'Beta ring', 'conditions': [ { 'type': 'context-attribute-is-one-of', 'key': 'ring', 'values': [ 'beta' ] } ] }," + + " { 'name': 'Trial licences', 'conditions': [ { 'key': 'license', 'values': [ 'trial' ] } ] } ] }"); + + var result = flag.evaluate(matchingContext()); + + assertThat(result.getValue()).isTrue(); + assertThat(result.getReason()).isEqualTo("Matched rule 'Beta ring'."); + } + + @Test + void aMalformedFlagDoesNotAffectTheRestOfTheResponse() throws Exception { + List flags = objectMapper.readValue( + Contexts.json("[ { 'slug': 'malformed-feature' }," + + " { 'slug': 'well-formed-feature', 'value': true, 'reason': 'The flag is enabled for this environment.' } ]"), + new TypeReference>() {}); + + assertThatThrownBy(() -> flags.get(0).evaluate(matchingContext())).isInstanceOf(ParseError.class); + + var wellFormed = flags.get(1).evaluate(matchingContext()); + assertThat(wellFormed.getValue()).isTrue(); + assertThat(wellFormed.getErrorCode()).isNull(); + assertThat(wellFormed.getReason()).isEqualTo("The flag is enabled for this environment."); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/PercentageByContextConditionTests.java b/src/test/java/com/octopus/openfeature/provider/v4/PercentageByContextConditionTests.java new file mode 100644 index 0000000..09a8ad9 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/PercentageByContextConditionTests.java @@ -0,0 +1,69 @@ +package com.octopus.openfeature.provider.v4; + +import dev.openfeature.sdk.exceptions.ParseError; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PercentageByContextConditionTests { + + @Test + void targetingKeyInsideTheRolloutMatches() { + assertThat(new PercentageByContextCondition(Contexts.TARGETING_KEY_BUCKET) + .matches(Contexts.forRules(Contexts.TARGETING_KEY))).isTrue(); + } + + @Test + void targetingKeyOutsideTheRolloutDoesNotMatch() { + assertThat(new PercentageByContextCondition(Contexts.TARGETING_KEY_BUCKET - 1) + .matches(Contexts.forRules(Contexts.TARGETING_KEY))).isFalse(); + } + + @Test + void withoutATargetingKeyOnlyAFullRolloutMatches() { + assertThat(new PercentageByContextCondition(100).matches(Contexts.forRules(null))) + .as("a 100% rollout matches even without a targeting key").isTrue(); + assertThat(new PercentageByContextCondition(99).matches(Contexts.forRules(null))) + .as("a partial rollout cannot bucket without a targeting key").isFalse(); + assertThat(new PercentageByContextCondition(50).matches(Contexts.forRules(""))) + .as("an empty targeting key is treated the same as none").isFalse(); + } + + @Test + void atZeroPercentNothingMatches() { + // The lowest bucket is 1, so nothing is included at 0%. + assertThat(new PercentageByContextCondition(0) + .matches(Contexts.forRules(Contexts.TARGETING_KEY))).isFalse(); + } + + @Test + void withANullOpenFeatureContextOnlyAFullRolloutMatches() { + var context = Contexts.withoutOpenFeatureContext(); + + assertThat(new PercentageByContextCondition(100).matches(context)).isTrue(); + assertThat(new PercentageByContextCondition(99).matches(context)).isFalse(); + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @MethodSource("invalidPercentages") + void anAbsentOrOutOfRangePercentageThrowsAParseError(Integer percentage, String expectedProblem) { + assertThatThrownBy(() -> new PercentageByContextCondition(percentage) + .matches(Contexts.forRules(Contexts.TARGETING_KEY))) + .isInstanceOf(ParseError.class) + .hasMessage(expectedProblem); + } + + static Stream invalidPercentages() { + return Stream.of( + Arguments.of(null, "A condition is missing a percentage value."), + Arguments.of(101, "A condition has a percentage of 101."), + Arguments.of(-1, "A condition has a percentage of -1.") + ); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/PercentageRolloutTests.java b/src/test/java/com/octopus/openfeature/provider/v4/PercentageRolloutTests.java new file mode 100644 index 0000000..ba03dde --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/PercentageRolloutTests.java @@ -0,0 +1,36 @@ +package com.octopus.openfeature.provider.v4; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class PercentageRolloutTests { + + // The same vectors the v3 path is held to. v3 keeps its own copy of this hash — package access is + // not hierarchical, so neither can call the other while both stay package-private — and running one + // list against both is what stops the two drifting apart. + @ParameterizedTest(name = "[{index}] ({0}, {1}) -> {2}") + @MethodSource("com.octopus.openfeature.provider.RolloutVectors#cases") + void getNormalizedNumberMatchesExpectedValue(String evaluationKey, String targetingKey, int expected) { + assertThat(PercentageRollout.getNormalizedNumber(evaluationKey, targetingKey)).isEqualTo(expected); + } + + @ParameterizedTest(name = "[{index}] ({0}, {1}) -> {2}") + @MethodSource("com.octopus.openfeature.provider.RolloutVectors#cases") + void includesEveryPercentageFromTheBucketUpwards(String evaluationKey, String targetingKey, int bucket) { + assertThat(PercentageRollout.includes(evaluationKey, targetingKey, bucket)) + .as("the bucket itself is inside the rollout").isTrue(); + assertThat(PercentageRollout.includes(evaluationKey, targetingKey, bucket - 1)) + .as("one percent below the bucket is outside it").isFalse(); + assertThat(PercentageRollout.includes(evaluationKey, targetingKey, 100)) + .as("a full rollout includes every bucket").isTrue(); + } + + @Test + void nothingIsIncludedAtZeroPercent() { + // The lowest bucket is 1, so a 0% rollout can never include a targeting key. + assertThat(PercentageRollout.includes(Contexts.EVALUATION_KEY, Contexts.TARGETING_KEY, 0)).isFalse(); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java b/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java index b93caba..cd207d8 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java +++ b/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.octopus.openfeature.provider.TestObjectMapper; import org.junit.jupiter.api.Test; @@ -13,10 +12,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Exercises polymorphic JSON deserialization of the v4 evaluation response. Everything is - * deserialized with the provider's own {@code OctopusObjectMapper} — the same mapper the client - * uses in production — so discriminator matching, property binding and the absent-property - * behaviour are all covered end to end. + * Deserialization of a v4 evaluation response: both flag shapes and the array the endpoint returns. + * Uses the provider's own {@code OctopusObjectMapper}, as the client does in production; individual + * conditions are covered by {@link ClientSideConditionDeserializationTests}. */ class ServerSideEvaluationDeserializationTests { @@ -26,87 +24,6 @@ private InputStream resource(String name) { return getClass().getResourceAsStream(name); } - @Test - void shouldDeserializePercentageByContextConditionToConcreteType() throws Exception { - var condition = objectMapper.readValue( - resource("condition-percentage-by-context.json"), ClientSideCondition.class); - - assertThat(condition) - .isInstanceOfSatisfying(PercentageByContextCondition.class, - percentage -> assertThat(percentage.getPercentage()).isEqualTo(50)); - } - - @Test - void shouldDeserializeContextAttributeIsOneOfConditionToConcreteType() throws Exception { - var condition = objectMapper.readValue( - resource("condition-context-attribute-is-one-of.json"), ClientSideCondition.class); - - assertThat(condition) - .isInstanceOfSatisfying(ContextAttributeIsOneOfCondition.class, isOneOf -> { - assertThat(isOneOf.getKey()).isEqualTo("user-id"); - assertThat(isOneOf.getValues()).containsExactly("1234", "5678"); - }); - } - - @Test - void shouldDeserializeContextAttributeIsNotOneOfConditionToConcreteType() throws Exception { - var condition = objectMapper.readValue( - resource("condition-context-attribute-is-not-one-of.json"), ClientSideCondition.class); - - assertThat(condition) - .isInstanceOfSatisfying(ContextAttributeIsNotOneOfCondition.class, isNotOneOf -> { - assertThat(isNotOneOf.getKey()).isEqualTo("region"); - assertThat(isNotOneOf.getValues()).containsExactly("us", "eu"); - }); - } - - @Test - void shouldDeserializeMixedConditionListToConcreteTypes() throws Exception { - var conditions = objectMapper.readValue( - resource("condition-list-mixed.json"), - new TypeReference>() {} - ); - - assertThat(conditions).hasExactlyElementsOfTypes( - PercentageByContextCondition.class, - ContextAttributeIsOneOfCondition.class, - ContextAttributeIsNotOneOfCondition.class - ); - } - - @Test - void shouldDeserializeUnknownConditionTypeToUnknownConditionInsteadOfThrowing() throws Exception { - var condition = objectMapper.readValue( - resource("condition-unknown-type.json"), ClientSideCondition.class); - - assertThat(condition) - .isInstanceOfSatisfying(UnknownCondition.class, - unknown -> assertThat(unknown.getType()).hasValue("not-a-real-condition")); - } - - @Test - void shouldDeserializeConditionWithoutTypeDiscriminatorToUnknownCondition() throws Exception { - var condition = objectMapper.readValue( - resource("condition-missing-type.json"), ClientSideCondition.class); - - assertThat(condition) - .isInstanceOfSatisfying(UnknownCondition.class, - unknown -> assertThat(unknown.getType()).isEmpty()); - } - - @Test - void shouldPreserveUnknownConditionAlongsideKnownConditionsWithoutFailingTheResponse() throws Exception { - var evaluation = objectMapper.readValue( - resource("evaluation-with-unknown-condition.json"), ServerSideEvaluation.class); - - var conditions = evaluation.getRules().orElseThrow().get(0).getConditions(); - - assertThat(conditions.get(0)).isInstanceOf(PercentageByContextCondition.class); - assertThat(conditions.get(1)) - .isInstanceOfSatisfying(UnknownCondition.class, - unknown -> assertThat(unknown.getType()).hasValue("some-future-condition")); - } - @Test void shouldDeserializeServerResolvedEvaluation() throws Exception { var evaluation = objectMapper.readValue( @@ -138,7 +55,7 @@ void shouldDeserializeEvaluationDeferredToTheClientWithPolymorphicConditions() t assertThat(rule.getConditions().get(0)) .isInstanceOfSatisfying(PercentageByContextCondition.class, - percentage -> assertThat(percentage.getPercentage()).isEqualTo(50)); + percentage -> assertThat(percentage.getPercentage()).hasValue(50)); assertThat(rule.getConditions().get(1)) .isInstanceOfSatisfying(ContextAttributeIsOneOfCondition.class, isOneOf -> { assertThat(isOneOf.getKey()).isEqualTo("user-id"); @@ -146,6 +63,19 @@ void shouldDeserializeEvaluationDeferredToTheClientWithPolymorphicConditions() t }); } + @Test + void shouldPreserveUnknownConditionAlongsideKnownConditionsWithoutFailingTheResponse() throws Exception { + var evaluation = objectMapper.readValue( + resource("evaluation-with-unknown-condition.json"), ServerSideEvaluation.class); + + var conditions = evaluation.getRules().orElseThrow().get(0).getConditions(); + + assertThat(conditions.get(0)).isInstanceOf(PercentageByContextCondition.class); + assertThat(conditions.get(1)) + .isInstanceOfSatisfying(UnknownCondition.class, + unknown -> assertThat(unknown.getType()).hasValue("some-future-condition")); + } + @Test void shouldDeserializeEvaluationsResponseAsListOfEvaluations() throws Exception { var evaluations = objectMapper.readValue( @@ -168,10 +98,26 @@ void shouldDeserializeEvaluationsResponseAsListOfEvaluations() throws Exception } @Test - void shouldFailDeserializationWhenSlugIsMissing() { - assertThatThrownBy(() -> objectMapper.readValue( - resource("evaluation-missing-slug.json"), ServerSideEvaluation.class)) - .isInstanceOf(MismatchedInputException.class); + void shouldDeserializeEvaluationWithoutASlugRatherThanFailingTheResponse() throws Exception { + // No property is required at parse time: a malformed flag is reported when it is evaluated, so + // it costs only itself rather than every other flag in the response. + var evaluation = objectMapper.readValue( + resource("evaluation-missing-slug.json"), ServerSideEvaluation.class); + + assertThat(evaluation.getSlug()).isNull(); + assertThat(evaluation.getValue()).hasValue(true); + } + + @Test + void shouldDeserializeEveryFlagWhenOneOfThemIsMissingItsSlug() throws Exception { + var evaluations = objectMapper.readValue( + resource("evaluation-list-one-missing-slug.json"), + new TypeReference>() {} + ); + + assertThat(evaluations).hasSize(2); + assertThat(evaluations.get(0).getSlug()).isNull(); + assertThat(evaluations.get(1).getSlug()).isEqualTo("well-formed-feature"); } @Test @@ -184,7 +130,7 @@ void shouldIgnoreExtraneousProperties() throws Exception { var conditions = evaluation.getRules().orElseThrow().get(0).getConditions(); assertThat(conditions.get(0)) .isInstanceOfSatisfying(PercentageByContextCondition.class, - percentage -> assertThat(percentage.getPercentage()).isEqualTo(50)); + percentage -> assertThat(percentage.getPercentage()).hasValue(50)); } @Test diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationTests.java b/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationTests.java new file mode 100644 index 0000000..8c818c2 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationTests.java @@ -0,0 +1,103 @@ +package com.octopus.openfeature.provider.v4; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.exceptions.ParseError; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@link ServerSideEvaluation#evaluate} against a well-formed response. Rules and conditions have + * their own suites; malformed responses are covered by {@link MalformedEvaluationTests}. + */ +class ServerSideEvaluationTests { + + private static ServerSideEvaluation serverResolved(boolean value, String reason) { + return new ServerSideEvaluation(Contexts.SLUG, value, reason, null, null); + } + + private static ServerSideEvaluation deferred(ClientSideRule... rules) { + return new ServerSideEvaluation(Contexts.SLUG, null, null, Contexts.EVALUATION_KEY, List.of(rules)); + } + + private static ClientSideRule ruleMatching(String name, String plan) { + return new ClientSideRule(name, List.of(new ContextAttributeIsOneOfCondition("plan", List.of(plan)))); + } + + @ParameterizedTest(name = "[{index}] value={0}") + @ValueSource(booleans = {true, false}) + void serverResolvedFlagReturnsTheServerValueAndReason(boolean value) { + var result = serverResolved(value, "the server said so").evaluate(Contexts.openFeature(null)); + + assertThat(result.getValue()).isEqualTo(value); + assertThat(result.getReason()).isEqualTo("the server said so"); + assertThat(result.getErrorCode()).isNull(); + } + + @Test + void serverResolvedFlagWithNoReasonThrowsAParseError() { + assertThatThrownBy(() -> serverResolved(true, null).evaluate(Contexts.openFeature(null))) + .isInstanceOf(ParseError.class) + .hasMessage("The flag has a value but has no reason.") + .extracting(thrown -> ((ParseError) thrown).getErrorCode()).isEqualTo(ErrorCode.PARSE_ERROR); + } + + @Test + void matchingRuleResolvesToTrueWithTheMatchedRuleReason() { + var flag = deferred(ruleMatching("beta-testers", "beta")); + + var result = flag.evaluate(Contexts.openFeature(null, "plan", "beta")); + + assertThat(result.getValue()).isTrue(); + assertThat(result.getReason()).isEqualTo("Matched rule 'beta-testers'."); + assertThat(result.getErrorCode()).isNull(); + } + + @Test + void noMatchingRuleResolvesToFalseWithTheDidNotMatchReason() { + // Off, not defaulted: this resolves rather than erroring, so no default value is involved. + var flag = deferred(ruleMatching("beta-testers", "beta")); + + var result = flag.evaluate(Contexts.openFeature(null, "plan", "free")); + + assertThat(result.getValue()).isFalse(); + assertThat(result.getReason()).isEqualTo("Did not match any rules."); + assertThat(result.getErrorCode()).isNull(); + } + + @Test + void rulesAcrossAFlagAreCombinedWithOr() { + var flag = deferred( + ruleMatching("beta-testers", "beta"), + new ClientSideRule("internal", + List.of(new ContextAttributeIsOneOfCondition("email", List.of("staff@octopus.com"))))); + + // Both rules match, so the reason names the first one that did. + var both = flag.evaluate( + Contexts.openFeature(null, "plan", "beta", "email", "staff@octopus.com")); + assertThat(both.getValue()).isTrue(); + assertThat(both.getReason()).isEqualTo("Matched rule 'beta-testers'."); + + var second = flag.evaluate(Contexts.openFeature(null, "email", "staff@octopus.com")); + assertThat(second.getValue()).as("second rule matches").isTrue(); + assertThat(second.getReason()).isEqualTo("Matched rule 'internal'."); + + var neither = flag.evaluate(Contexts.openFeature(null, "plan", "free")); + assertThat(neither.getValue()).as("no rule matches").isFalse(); + assertThat(neither.getReason()).isEqualTo("Did not match any rules."); + } + + @Test + void aNullContextIsTreatedAsAnEmptyContext() { + assertThat(deferred(ruleMatching("pro-users", "pro")).evaluate(null).getValue()) + .as("there is no attribute to match").isFalse(); + assertThat(deferred(new ClientSideRule("everyone", List.of(new PercentageByContextCondition(100)))) + .evaluate(null).getValue()) + .as("a 100% rollout matches without a targeting key").isTrue(); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/UnknownConditionTests.java b/src/test/java/com/octopus/openfeature/provider/v4/UnknownConditionTests.java new file mode 100644 index 0000000..cf68f58 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/UnknownConditionTests.java @@ -0,0 +1,25 @@ +package com.octopus.openfeature.provider.v4; + +import dev.openfeature.sdk.exceptions.ParseError; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UnknownConditionTests { + + @Test + void anUnrecognisedTypeNeverMatches() { + assertThat(new UnknownCondition("some-future-condition") + .matches(Contexts.forRules(Contexts.TARGETING_KEY))).isFalse(); + } + + @Test + void noTypeAtAllThrowsAParseError() { + // No server version emits a condition without a type, unlike one with a type we do not know. + assertThatThrownBy(() -> new UnknownCondition(null) + .matches(Contexts.forRules(Contexts.TARGETING_KEY))) + .isInstanceOf(ParseError.class) + .hasMessage("A condition is missing a type."); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/UnrecognisedConditionTests.java b/src/test/java/com/octopus/openfeature/provider/v4/UnrecognisedConditionTests.java new file mode 100644 index 0000000..78e1631 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/UnrecognisedConditionTests.java @@ -0,0 +1,50 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.octopus.openfeature.provider.TestObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A condition naming a type this version does not recognise is a capability from a newer server, not + * a bad payload: it fails its own rule and nothing else. The deliberate departure from + * {@link MalformedEvaluationTests}, which covers every other shape — including a condition with no + * type at all. + */ +class UnrecognisedConditionTests { + + private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE; + + private ServerSideEvaluation flag(String singleQuotedJson) throws Exception { + return objectMapper.readValue(Contexts.json(singleQuotedJson), ServerSideEvaluation.class); + } + + @Test + void anUnrecognisedConditionTypeFailsItsRuleWithoutAnError() throws Exception { + var flag = flag("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [" + + " { 'name': 'Something newer than this library'," + + " 'conditions': [ { 'type': 'not-a-real-condition', 'key': 'license', 'values': [ 'trial' ] } ] } ] }"); + + var result = flag.evaluate(Contexts.openFeature(Contexts.TARGETING_KEY, "license", "trial")); + + assertThat(result.getValue()).isFalse(); + assertThat(result.getErrorCode()).isNull(); + assertThat(result.getReason()).isEqualTo("Did not match any rules."); + } + + @Test + void anUnrecognisedConditionInOneRuleLeavesTheOtherRulesToDecide() throws Exception { + var flag = flag("{ 'slug': 'my-feature', 'evaluationKey': 'evaluation-key', 'rules': [" + + " { 'name': 'Something newer than this library'," + + " 'conditions': [ { 'type': 'not-a-real-condition', 'key': 'license', 'values': [ 'trial' ] } ] }," + + " { 'name': 'Beta ring'," + + " 'conditions': [ { 'type': 'context-attribute-is-one-of', 'key': 'ring', 'values': [ 'beta' ] } ] } ] }"); + + var result = flag.evaluate(Contexts.openFeature(Contexts.TARGETING_KEY, "license", "trial", "ring", "beta")); + + assertThat(result.getValue()).isTrue(); + assertThat(result.getErrorCode()).isNull(); + assertThat(result.getReason()).isEqualTo("Matched rule 'Beta ring'."); + } +} diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list-one-missing-slug.json b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list-one-missing-slug.json new file mode 100644 index 0000000..898a7e6 --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list-one-missing-slug.json @@ -0,0 +1,11 @@ +[ + { + "value": true, + "reason": "The flag is enabled for this environment." + }, + { + "slug": "well-formed-feature", + "value": true, + "reason": "The flag is enabled for this environment." + } +]