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:
- *
- * - Resolved by the server — {@code value} and {@code reason} are populated.
- * - Deferred to the client — {@code evaluationKey} and {@code rules} are populated and the
- * provider library must evaluate the remaining client-side conditions.
- *
- * 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."
+ }
+]