diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java index b39928b..3b4497f 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java @@ -1,12 +1,16 @@ package com.launchdarkly.sdk.internal; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.launchdarkly.sdk.internal.fdv2.payloads.IntentCode; /** * General-purpose Gson helpers. */ public abstract class GsonHelpers { - private static final Gson GSON_INSTANCE = new Gson(); + private static final Gson GSON_INSTANCE = new GsonBuilder() + .registerTypeAdapter(IntentCode.class, new IntentCode.IntentCodeTypeAdapter()) + .create(); /** * A singleton instance of Gson with the default configuration. diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/DeleteObject.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/DeleteObject.java new file mode 100644 index 0000000..7959d41 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/DeleteObject.java @@ -0,0 +1,111 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.Objects; + +/** + * Represents the delete-object event, which contains a payload object that should be deleted. + */ +public final class DeleteObject { + private final int version; + private final String kind; + private final String key; + + /** + * Constructs a new DeleteObject. + * + * @param version the minimum payload version this change applies to + * @param kind the kind of object being deleted ("flag" or "segment") + * @param key the identifier of the object being deleted + */ + public DeleteObject(int version, String kind, String key) { + this.version = version; + this.kind = Objects.requireNonNull(kind, "kind"); + this.key = Objects.requireNonNull(key, "key"); + } + + /** + * Returns the minimum payload version this change applies to. + * + * @return the version + */ + public int getVersion() { + return version; + } + + /** + * Returns the kind of the object being deleted ("flag" or "segment"). + * + * @return the kind + */ + public String getKind() { + return kind; + } + + /** + * Returns the identifier of the object being deleted. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Parses a DeleteObject from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed DeleteObject + * @throws SerializationException if the JSON is invalid + */ + public static DeleteObject parse(JsonReader reader) throws SerializationException { + Integer version = null; + String kind = null; + String key = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "version": + version = reader.nextInt(); + break; + case "kind": + kind = reader.nextString(); + break; + case "key": + key = reader.nextString(); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (version == null) { + throw new SerializationException("delete object missing required property 'version'"); + } + if (kind == null) { + throw new SerializationException("delete object missing required property 'kind'"); + } + if (key == null) { + throw new SerializationException("delete object missing required property 'key'"); + } + + return new DeleteObject(version, kind, key); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Error.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Error.java new file mode 100644 index 0000000..21639de --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Error.java @@ -0,0 +1,91 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.Objects; + +/** + * Represents the error event, which indicates an error encountered server-side affecting + * the payload transfer. SDKs must discard partially transferred data. The SDK remains + * connected and expects the server to recover. + */ +public final class Error { + private final String id; + private final String reason; + + /** + * Constructs a new Error. + * + * @param id the unique string identifier of the entity the error relates to + * @param reason human-readable reason the error occurred + */ + public Error(String id, String reason) { + this.id = id; + this.reason = Objects.requireNonNull(reason, "reason"); + } + + /** + * Returns the unique string identifier of the entity the error relates to. + * + * @return the identifier, or null if not present + */ + public String getId() { + return id; + } + + /** + * Returns the human-readable reason the error occurred. + * + * @return the reason + */ + public String getReason() { + return reason; + } + + /** + * Parses an Error from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed Error + * @throws SerializationException if the JSON is invalid + */ + public static Error parse(JsonReader reader) throws SerializationException { + String id = null; + String reason = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "id": + id = reader.nextString(); + break; + case "reason": + reason = reader.nextString(); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (reason == null) { + throw new SerializationException("error missing required property 'reason'"); + } + + return new Error(id, reason); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java new file mode 100644 index 0000000..37254a0 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java @@ -0,0 +1,267 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; + +/** + * Represents an FDv2 event. This event may be constructed from an SSE event or directly parsed + * from a polling response. + */ +public final class FDv2Event { + private static final String EVENT_SERVER_INTENT = "server-intent"; + private static final String EVENT_PUT_OBJECT = "put-object"; + private static final String EVENT_DELETE_OBJECT = "delete-object"; + private static final String EVENT_PAYLOAD_TRANSFERRED = "payload-transferred"; + private static final String EVENT_ERROR = "error"; + private static final String EVENT_GOODBYE = "goodbye"; + + private final String eventType; + private final JsonElement data; + + /** + * Exception thrown when attempting to deserialize an FDv2Event as the wrong event type. + */ + public static final class FDv2EventTypeMismatchException extends SerializationException { + private static final long serialVersionUID = 1L; + private final String actualEventType; + private final String expectedEventType; + + public FDv2EventTypeMismatchException(String actualEventType, String expectedEventType) { + super(String.format("Cannot deserialize event type '%s' as '%s'.", actualEventType, expectedEventType)); + this.actualEventType = actualEventType; + this.expectedEventType = expectedEventType; + } + + public String getActualEventType() { + return actualEventType; + } + + public String getExpectedEventType() { + return expectedEventType; + } + } + + /** + * Constructs a new FDv2Event. + * + * @param eventType the type of event + * @param data the event data as a raw JSON element + */ + public FDv2Event(String eventType, JsonElement data) { + this.eventType = Objects.requireNonNull(eventType, "eventType"); + this.data = Objects.requireNonNull(data, "data"); + } + + /** + * Returns the event type. + * + * @return the event type + */ + public String getEventType() { + return eventType; + } + + /** + * Returns the event data as a raw JSON element. + * + * @return the event data + */ + public JsonElement getData() { + return data; + } + + /** + * Parses an FDv2Event from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed FDv2Event + * @throws SerializationException if the JSON is invalid + */ + public static FDv2Event parse(JsonReader reader) throws SerializationException { + String eventType = null; + JsonElement data = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "event": + eventType = reader.nextString(); + break; + case "data": + // Store the raw JSON element for later deserialization based on the event type + data = gsonInstance().fromJson(reader, JsonElement.class); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (eventType == null) { + throw new SerializationException("event missing required property 'event'"); + } + if (data == null) { + throw new SerializationException("event missing required property 'data'"); + } + + return new FDv2Event(eventType, data); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } + + /** + * Deserializes the data element as a ServerIntent. + * + * @return the deserialized ServerIntent + * @throws SerializationException if the event type does not match or the JSON cannot be deserialized + */ + public ServerIntent asServerIntent() throws SerializationException { + return deserializeAs(EVENT_SERVER_INTENT, ServerIntent::parse); + } + + /** + * Deserializes the data element as a PutObject. + * + * @return the deserialized PutObject + * @throws SerializationException if deserialization fails + */ + public PutObject asPutObject() throws SerializationException { + return deserializeAs(EVENT_PUT_OBJECT, PutObject::parse); + } + + /** + * Deserializes the data element as a DeleteObject. + * + * @return the deserialized DeleteObject + * @throws SerializationException if deserialization fails + */ + public DeleteObject asDeleteObject() throws SerializationException { + return deserializeAs(EVENT_DELETE_OBJECT, DeleteObject::parse); + } + + /** + * Deserializes the data element as a PayloadTransferred. + * + * @return the deserialized PayloadTransferred + * @throws SerializationException if deserialization fails + */ + public PayloadTransferred asPayloadTransferred() throws SerializationException { + return deserializeAs(EVENT_PAYLOAD_TRANSFERRED, PayloadTransferred::parse); + } + + /** + * Deserializes the data element as an Error. + * + * @return the deserialized Error + * @throws SerializationException if deserialization fails + */ + public Error asError() throws SerializationException { + return deserializeAs(EVENT_ERROR, Error::parse); + } + + /** + * Deserializes the data element as a Goodbye. + * + * @return the deserialized Goodbye + * @throws SerializationException if deserialization fails + */ + public Goodbye asGoodbye() throws SerializationException { + return deserializeAs(EVENT_GOODBYE, Goodbye::parse); + } + + /** + * Deserializes an FDv2 polling response containing an "events" array. + * + * @param jsonString JSON string with an "events" array + * @return the list of deserialized events + * @throws SerializationException if the JSON is malformed or an event cannot be deserialized + */ + public static List parseEventsArray(String jsonString) throws SerializationException { + JsonObject root; + try { + root = gsonInstance().fromJson(jsonString, JsonObject.class); + } catch (RuntimeException e) { + throw new SerializationException(e); + } + + if (root == null || !root.has("events")) { + throw new SerializationException("FDv2 polling response missing 'events' property"); + } + + JsonElement eventsElement = root.get("events"); + if (!eventsElement.isJsonArray()) { + throw new SerializationException("FDv2 polling response 'events' is not an array"); + } + + JsonArray eventsArray = eventsElement.getAsJsonArray(); + List events = new ArrayList<>(eventsArray.size()); + int index = 0; + for (JsonElement eventElement : eventsArray) { + if (eventElement == null || eventElement.isJsonNull()) { + throw new SerializationException("FDv2 polling response contains null event at index " + index); + } + events.add(parseEventElement(eventElement, index)); + index++; + } + return events; + } + + private static FDv2Event parseEventElement(JsonElement element, int index) throws SerializationException { + if (!element.isJsonObject()) { + throw new SerializationException("FDv2 polling response event at index " + index + " is not an object"); + } + + JsonObject obj = element.getAsJsonObject(); + JsonElement eventTypeElement = obj.get("event"); + JsonElement dataElement = obj.get("data"); + + if (eventTypeElement == null || eventTypeElement.isJsonNull()) { + throw new SerializationException("event at index " + index + " missing required property 'event'"); + } + if (dataElement == null || dataElement.isJsonNull()) { + throw new SerializationException("event at index " + index + " missing required property 'data'"); + } + + return new FDv2Event(eventTypeElement.getAsString(), dataElement); + } + + private T deserializeAs(String expectedEventType, Parser parser) throws SerializationException { + if (!expectedEventType.equals(eventType)) { + throw new FDv2EventTypeMismatchException(eventType, expectedEventType); + } + + try { + JsonReader reader = new JsonReader(new StringReader(data.toString())); + return parser.parse(reader); + } catch (SerializationException e) { + throw e; + } catch (Exception e) { + throw new SerializationException(e); + } + } + + private interface Parser { + T parse(JsonReader reader) throws Exception; + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Goodbye.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Goodbye.java new file mode 100644 index 0000000..01c5272 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Goodbye.java @@ -0,0 +1,68 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; + +/** + * Represents the goodbye event, which indicates that the server is about to disconnect. + */ +public final class Goodbye { + private final String reason; + + /** + * Constructs a new Goodbye. + * + * @param reason reason for the disconnection + */ + public Goodbye(String reason) { + this.reason = reason; + } + + /** + * Returns the reason for the disconnection. + * + * @return the reason + */ + public String getReason() { + return reason; + } + + /** + * Parses a Goodbye from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed Goodbye + * @throws SerializationException if the JSON is invalid + */ + public static Goodbye parse(JsonReader reader) throws SerializationException { + String reason = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "reason": + reason = reader.nextString(); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + return new Goodbye(reason); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/IntentCode.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/IntentCode.java new file mode 100644 index 0000000..21e252d --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/IntentCode.java @@ -0,0 +1,88 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; + +/** + * Represents the intent code indicating how the server intends to transfer data. + */ +public enum IntentCode { + NONE("none"), + TRANSFER_FULL("xfer-full"), + TRANSFER_CHANGES("xfer-changes"); + + private final String stringValue; + + IntentCode(String stringValue) { + this.stringValue = stringValue; + } + + /** + * Returns the string representation of the intent code. + * + * @return the string value + */ + public String getStringValue() { + return stringValue; + } + + /** + * Parses a string into an IntentCode. + * + * @param value the string value + * @return the parsed IntentCode + * @throws SerializationException if the value is unknown or null + */ + public static IntentCode parse(String value) throws SerializationException { + if (value == null) { + throw new SerializationException("intentCode missing required value"); + } + + switch (value) { + case "none": + return NONE; + case "xfer-full": + return TRANSFER_FULL; + case "xfer-changes": + return TRANSFER_CHANGES; + default: + throw new SerializationException("unknown intent code: " + value); + } + } + + @Override + public String toString() { + return stringValue; + } + + /** + * Gson TypeAdapter for serializing and deserializing IntentCode. + * Serializes using the string value (e.g., "xfer-full") rather than the enum name. + */ + public static final class IntentCodeTypeAdapter extends TypeAdapter { + @Override + public void write(JsonWriter out, IntentCode value) throws IOException { + if (value == null) { + out.nullValue(); + } else { + out.value(value.getStringValue()); + } + } + + @Override + public IntentCode read(JsonReader in) throws IOException { + String value = in.nextString(); + try { + return IntentCode.parse(value); + } catch (SerializationException e) { + throw new IOException(e); + } + } + } +} + + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PayloadTransferred.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PayloadTransferred.java new file mode 100644 index 0000000..916b80a --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PayloadTransferred.java @@ -0,0 +1,93 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.Objects; + +/** + * Represents the payload-transferred event, which is sent after all messages for a payload update + * have been transmitted. + */ +public final class PayloadTransferred { + private final String state; + private final int version; + + /** + * Constructs a new PayloadTransferred. + * + * @param state the unique string representing the payload state + * @param version the version of the payload that was transferred to the client + */ + public PayloadTransferred(String state, int version) { + this.state = Objects.requireNonNull(state, "state"); + this.version = version; + } + + /** + * Returns the unique string representing the payload state. + * + * @return the state + */ + public String getState() { + return state; + } + + /** + * Returns the version of the payload that was transferred. + * + * @return the version + */ + public int getVersion() { + return version; + } + + /** + * Parses a PayloadTransferred from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed PayloadTransferred + * @throws SerializationException if the JSON is invalid + */ + public static PayloadTransferred parse(JsonReader reader) throws SerializationException { + String state = null; + Integer version = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "state": + state = reader.nextString(); + break; + case "version": + version = reader.nextInt(); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (state == null) { + throw new SerializationException("payload-transferred missing required property 'state'"); + } + if (version == null) { + throw new SerializationException("payload-transferred missing required property 'version'"); + } + + return new PayloadTransferred(state, version); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PutObject.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PutObject.java new file mode 100644 index 0000000..80cd3ab --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PutObject.java @@ -0,0 +1,134 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.JsonElement; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.Objects; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; + +/** + * Represents the put-object event, which contains a payload object that should be accepted with + * upsert semantics. The object can be either a flag or a segment. + */ +public final class PutObject { + private final int version; + private final String kind; + private final String key; + private final JsonElement object; + + /** + * Constructs a new PutObject. + * + * @param version the minimum payload version this change applies to + * @param kind the kind of object being PUT ("flag" or "segment") + * @param key the identifier of the object + * @param object the raw JSON object being PUT + */ + public PutObject(int version, String kind, String key, JsonElement object) { + this.version = version; + this.kind = Objects.requireNonNull(kind, "kind"); + this.key = Objects.requireNonNull(key, "key"); + this.object = Objects.requireNonNull(object, "object"); + } + + /** + * Returns the minimum payload version this change applies to. + * + * @return the version + */ + public int getVersion() { + return version; + } + + /** + * Returns the kind of the object being PUT ("flag" or "segment"). + * + * @return the kind + */ + public String getKind() { + return kind; + } + + /** + * Returns the identifier of the object. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Returns the raw JSON object being PUT. + * + * @return the object + */ + public JsonElement getObject() { + return object; + } + + /** + * Parses a PutObject from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed PutObject + * @throws SerializationException if the JSON is invalid + */ + public static PutObject parse(JsonReader reader) throws SerializationException { + Integer version = null; + String kind = null; + String key = null; + JsonElement object = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "version": + version = reader.nextInt(); + break; + case "kind": + kind = reader.nextString(); + break; + case "key": + key = reader.nextString(); + break; + case "object": + object = gsonInstance().fromJson(reader, JsonElement.class); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (version == null) { + throw new SerializationException("put object missing required property 'version'"); + } + if (kind == null) { + throw new SerializationException("put object missing required property 'kind'"); + } + if (key == null) { + throw new SerializationException("put object missing required property 'key'"); + } + if (object == null) { + throw new SerializationException("put object missing required property 'object'"); + } + + return new PutObject(version, kind, key, object); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/ServerIntent.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/ServerIntent.java new file mode 100644 index 0000000..897a7fc --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/ServerIntent.java @@ -0,0 +1,183 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; + +/** + * Represents the server-intent event, which is the first message sent by flag delivery upon + * connecting to FDv2. Contains information about how flag delivery intends to handle payloads. + */ +public final class ServerIntent { + private final List payloads; + + /** + * Constructs a new ServerIntent. + * + * @param payloads the payloads the server will be transferring data for + */ + public ServerIntent(List payloads) { + this.payloads = Collections.unmodifiableList(new ArrayList<>(Objects.requireNonNull(payloads, "payloads"))); + } + + /** + * Returns the list of payloads the server will be transferring data for. + * + * @return the payloads + */ + public List getPayloads() { + return payloads; + } + + /** + * Parses a ServerIntent from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed ServerIntent + * @throws SerializationException if the JSON is invalid + */ + public static ServerIntent parse(JsonReader reader) throws SerializationException { + List payloads = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "payloads": + JsonArray payloadArray = gsonInstance().fromJson(reader, JsonArray.class); + payloads = new ArrayList<>(payloadArray.size()); + int index = 0; + for (JsonElement payloadElement : payloadArray) { + if (payloadElement == null || payloadElement.isJsonNull()) { + throw new SerializationException("server-intent contains null payload at index " + index); + } + payloads.add(ServerIntentPayload.parse(payloadElement)); + index++; + } + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (payloads == null) { + throw new SerializationException("server-intent missing required property 'payloads'"); + } + + return new ServerIntent(payloads); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } + + /** + * Description of server intent to transfer a specific payload. + */ + public static final class ServerIntentPayload { + private final String id; + private final int target; + private final IntentCode intentCode; + private final String reason; + + /** + * Constructs a new ServerIntentPayload. + * + * @param id the unique string identifier + * @param target the target version for the payload + * @param intentCode how the server intends to operate with respect to sending payload data + * @param reason reason the server is operating with the provided code + */ + public ServerIntentPayload(String id, int target, IntentCode intentCode, String reason) { + this.id = Objects.requireNonNull(id, "id"); + this.target = target; + this.intentCode = Objects.requireNonNull(intentCode, "intentCode"); + this.reason = Objects.requireNonNull(reason, "reason"); + } + + public String getId() { + return id; + } + + public int getTarget() { + return target; + } + + public IntentCode getIntentCode() { + return intentCode; + } + + public String getReason() { + return reason; + } + + static ServerIntentPayload parse(JsonElement element) throws SerializationException { + String id = null; + Integer target = null; + IntentCode intentCode = null; + String reason = null; + + if (!element.isJsonObject()) { + throw new SerializationException("expected payload object"); + } + + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + String name = entry.getKey(); + JsonElement value = entry.getValue(); + switch (name) { + case "id": + id = value.isJsonNull() ? null : value.getAsString(); + break; + case "target": + if (!value.isJsonNull()) { + target = value.getAsInt(); + } + break; + case "intentCode": + if (!value.isJsonNull()) { + intentCode = IntentCode.parse(value.getAsString()); + } + break; + case "reason": + reason = value.isJsonNull() ? null : value.getAsString(); + break; + default: + break; + } + } + + if (id == null) { + throw new SerializationException("server-intent payload missing required property 'id'"); + } + if (target == null) { + throw new SerializationException("server-intent payload missing required property 'target'"); + } + if (intentCode == null) { + throw new SerializationException("server-intent payload missing required property 'intentCode'"); + } + if (reason == null) { + throw new SerializationException("server-intent payload missing required property 'reason'"); + } + + return new ServerIntentPayload(id, target, intentCode, reason); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java new file mode 100644 index 0000000..faf1f9a --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java @@ -0,0 +1,10 @@ +/** + * This package contains FDv2 payload types and event handling. + *

+ * All types in this package are for internal LaunchDarkly use only, and are subject to change. + * They are not part of the public supported API of the SDKs, and they should not be referenced + * by application code. They have public scope only because they need to be available to + * LaunchDarkly SDK code in other packages. + */ +package com.launchdarkly.sdk.internal.fdv2.payloads; + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java new file mode 100644 index 0000000..8ffc69e --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java @@ -0,0 +1,155 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +import com.google.gson.JsonElement; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Change tracking structures for FDv2. + */ +public final class FDv2ChangeSet { + /** + * Represents the type of change operation. + */ + public enum FDv2ChangeType { + /** + * Indicates an upsert operation (insert or update). + */ + PUT, + + /** + * Indicates a delete operation. + */ + DELETE + } + + /** + * Represents the type of changeset. + */ + public enum FDv2ChangeSetType { + /** + * Changeset represents a full payload to use as a basis. + */ + FULL, + + /** + * Changeset represents a partial payload to be applied to a basis. + */ + PARTIAL, + + /** + * A changeset which indicates that no changes should be made. + */ + NONE + } + + /** + * Represents a single change to a data object. + */ + public static final class FDv2Change { + private final FDv2ChangeType type; + private final String kind; + private final String key; + private final int version; + private final JsonElement object; + + /** + * Constructs a new Change. + * + * @param type the type of change operation + * @param kind the kind of object being changed + * @param key the key identifying the object + * @param version the version of the change + * @param object the raw JSON representing the object data (required for put operations) + */ + public FDv2Change(FDv2ChangeType type, String kind, String key, int version, JsonElement object) { + this.type = Objects.requireNonNull(type, "type"); + this.kind = Objects.requireNonNull(kind, "kind"); + this.key = Objects.requireNonNull(key, "key"); + this.version = version; + this.object = object; + } + + public FDv2ChangeType getType() { + return type; + } + + public String getKind() { + return kind; + } + + public String getKey() { + return key; + } + + public int getVersion() { + return version; + } + + /** + * The raw JSON string representing the object data (only present for Put operations). + * + * @return the raw JSON element representing the object data + */ + public JsonElement getObject() { + return object; + } + } + + private final FDv2ChangeSetType type; + private final List changes; + private final Selector selector; + + /** + * Constructs a new ChangeSet. + * + * @param type the type of the changeset + * @param changes the list of changes (required) + * @param selector the selector for this changeset + */ + public FDv2ChangeSet(FDv2ChangeSetType type, List changes, Selector selector) { + this.type = Objects.requireNonNull(type, "type"); + this.changes = Collections.unmodifiableList(Objects.requireNonNull(changes, "changes")); + this.selector = selector; + } + + /** + * The intent code indicating how the server intends to transfer data. + * + * @return the type of changeset + */ + public FDv2ChangeSetType getType() { + return type; + } + + /** + * The list of changes in this changeset. May be empty if there are no changes. + * + * @return the list of changes in this changeset + */ + public List getChanges() { + return changes; + } + + /** + * The selector (version identifier) for this changeset. + * + * @return the selector for this changeset + */ + public Selector getSelector() { + return selector; + } + + /** + * An empty changeset that indicates no changes are required. + */ + public static final FDv2ChangeSet NONE = new FDv2ChangeSet( + FDv2ChangeSetType.NONE, + Collections.emptyList(), + Selector.EMPTY + ); +} + + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2EventTypes.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2EventTypes.java new file mode 100644 index 0000000..b89aed2 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2EventTypes.java @@ -0,0 +1,18 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +/** + * Types of events that FDv2 can receive. + */ +public final class FDv2EventTypes { + private FDv2EventTypes() {} + + public static final String SERVER_INTENT = "server-intent"; + public static final String PUT_OBJECT = "put-object"; + public static final String DELETE_OBJECT = "delete-object"; + public static final String ERROR = "error"; + public static final String GOODBYE = "goodbye"; + public static final String HEARTBEAT = "heartbeat"; + public static final String PAYLOAD_TRANSFERRED = "payload-transferred"; +} + + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java new file mode 100644 index 0000000..5bdd7fc --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java @@ -0,0 +1,343 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +import com.launchdarkly.sdk.internal.fdv2.payloads.DeleteObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.Error; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.payloads.Goodbye; +import com.launchdarkly.sdk.internal.fdv2.payloads.PayloadTransferred; +import com.launchdarkly.sdk.internal.fdv2.payloads.PutObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.ServerIntent; +import com.launchdarkly.sdk.json.SerializationException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Implements the FDv2 protocol state machine for handling payload communication events. + * See: FDV2PL-payload-communication specification. + */ +public final class FDv2ProtocolHandler { + /** + * State of the protocol handler. + */ + private enum FDv2ProtocolState { + /** + * No server intent has been expressed. + */ + INACTIVE, + /** + * Currently receiving incremental changes. + */ + CHANGES, + /** + * Currently receiving a full transfer. + */ + FULL + } + + /** + * Actions emitted by the protocol handler. + */ + public enum FDv2ProtocolActionType { + /** + * Indicates that a changeset should be emitted. + */ + CHANGESET, + /** + * Indicates that an error has been encountered and should be logged. + */ + ERROR, + /** + * Indicates that the server intends to disconnect and the SDK should log the reason. + */ + GOODBYE, + /** + * Indicates that no special action should be taken. + */ + NONE, + /** + * Indicates an internal error that should be logged. + */ + INTERNAL_ERROR + } + + /** + * Error categories produced by the protocol handler. + */ + public enum FDv2ProtocolErrorType { + /** + * Received a protocol event which is not recognized. + */ + UNKNOWN_EVENT, + /** + * Server intent was received without any payloads. + */ + MISSING_PAYLOAD, + /** + * The JSON couldn't be parsed or didn't conform to the schema. + */ + JSON_ERROR, + /** + * Represents an implementation defect. + */ + IMPLEMENTATION_ERROR, + /** + * Represents a violation of the protocol flow. + */ + PROTOCOL_ERROR + } + + public interface IFDv2ProtocolAction { + FDv2ProtocolActionType getAction(); + } + + public static final class FDv2ActionChangeset implements IFDv2ProtocolAction { + private final FDv2ChangeSet changeset; + + public FDv2ActionChangeset(FDv2ChangeSet changeset) { + this.changeset = Objects.requireNonNull(changeset, "changeset"); + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.CHANGESET; + } + + public FDv2ChangeSet getChangeset() { + return changeset; + } + } + + public static final class FDv2ActionError implements IFDv2ProtocolAction { + private final String id; + private final String reason; + + public FDv2ActionError(String id, String reason) { + this.id = id; + this.reason = reason; + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.ERROR; + } + + public String getId() { + return id; + } + + public String getReason() { + return reason; + } + } + + public static final class FDv2ActionGoodbye implements IFDv2ProtocolAction { + private final String reason; + + public FDv2ActionGoodbye(String reason) { + this.reason = reason; + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.GOODBYE; + } + + public String getReason() { + return reason; + } + } + + public static final class FDv2ActionInternalError implements IFDv2ProtocolAction { + private final String message; + private final FDv2ProtocolErrorType errorType; + + public FDv2ActionInternalError(String message, FDv2ProtocolErrorType errorType) { + this.message = message; + this.errorType = errorType; + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.INTERNAL_ERROR; + } + + public String getMessage() { + return message; + } + + public FDv2ProtocolErrorType getErrorType() { + return errorType; + } + } + + public static final class FDv2ActionNone implements IFDv2ProtocolAction { + private static final FDv2ActionNone INSTANCE = new FDv2ActionNone(); + + private FDv2ActionNone() {} + + public static FDv2ActionNone getInstance() { + return INSTANCE; + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.NONE; + } + } + + private final List changes = new ArrayList<>(); + private FDv2ProtocolState state = FDv2ProtocolState.INACTIVE; + + private IFDv2ProtocolAction serverIntent(ServerIntent intent) { + List payloads = intent.getPayloads(); + ServerIntent.ServerIntentPayload payload = (payloads == null || payloads.isEmpty()) + ? null : payloads.get(0); + if (payload == null) { + return new FDv2ActionInternalError("No payload present in server-intent", + FDv2ProtocolErrorType.MISSING_PAYLOAD); + } + + switch (payload.getIntentCode()) { + case NONE: + state = FDv2ProtocolState.CHANGES; + changes.clear(); + return new FDv2ActionChangeset(FDv2ChangeSet.NONE); + case TRANSFER_FULL: + state = FDv2ProtocolState.FULL; + break; + case TRANSFER_CHANGES: + state = FDv2ProtocolState.CHANGES; + break; + default: + return new FDv2ActionInternalError("Unhandled event code: " + payload.getIntentCode(), + FDv2ProtocolErrorType.IMPLEMENTATION_ERROR); + } + + changes.clear(); + return FDv2ActionNone.getInstance(); + } + + private void putObject(PutObject put) { + changes.add(new FDv2ChangeSet.FDv2Change( + FDv2ChangeSet.FDv2ChangeType.PUT, put.getKind(), put.getKey(), put.getVersion(), put.getObject())); + } + + private void deleteObject(DeleteObject delete) { + changes.add(new FDv2ChangeSet.FDv2Change( + FDv2ChangeSet.FDv2ChangeType.DELETE, delete.getKind(), delete.getKey(), delete.getVersion(), null)); + } + + private IFDv2ProtocolAction payloadTransferred(PayloadTransferred payload) { + FDv2ChangeSet.FDv2ChangeSetType changeSetType; + switch (state) { + case INACTIVE: + return new FDv2ActionInternalError( + "A payload transferred has been received without an intent having been established.", + FDv2ProtocolErrorType.PROTOCOL_ERROR); + case CHANGES: + changeSetType = FDv2ChangeSet.FDv2ChangeSetType.PARTIAL; + break; + case FULL: + changeSetType = FDv2ChangeSet.FDv2ChangeSetType.FULL; + break; + default: + return new FDv2ActionInternalError("Unhandled protocol state: " + state, + FDv2ProtocolErrorType.IMPLEMENTATION_ERROR); + } + + FDv2ChangeSet changeset = new FDv2ChangeSet( + changeSetType, + new ArrayList<>(changes), + Selector.make(payload.getVersion(), payload.getState())); + state = FDv2ProtocolState.CHANGES; + changes.clear(); + return new FDv2ActionChangeset(changeset); + } + + private IFDv2ProtocolAction error(Error error) { + changes.clear(); + return new FDv2ActionError(error.getId(), error.getReason()); + } + + private IFDv2ProtocolAction goodbye(Goodbye intent) { + return new FDv2ActionGoodbye(intent.getReason()); + } + + /** + * Process an FDv2 event and update the protocol state accordingly. + * + * @param evt the event to process + * @return an action indicating what the caller should do in response to this event + */ + public IFDv2ProtocolAction handleEvent(FDv2Event evt) { + try { + switch (evt.getEventType()) { + case FDv2EventTypes.SERVER_INTENT: + return serverIntent(evt.asServerIntent()); + case FDv2EventTypes.DELETE_OBJECT: + deleteObject(evt.asDeleteObject()); + break; + case FDv2EventTypes.PUT_OBJECT: + putObject(evt.asPutObject()); + break; + case FDv2EventTypes.ERROR: + return error(evt.asError()); + case FDv2EventTypes.GOODBYE: + return goodbye(evt.asGoodbye()); + case FDv2EventTypes.PAYLOAD_TRANSFERRED: + return payloadTransferred(evt.asPayloadTransferred()); + case FDv2EventTypes.HEARTBEAT: + break; + default: + return new FDv2ActionInternalError( + "Received an unknown event of type " + evt.getEventType(), + FDv2ProtocolErrorType.UNKNOWN_EVENT); + } + + return FDv2ActionNone.getInstance(); + } catch (FDv2Event.FDv2EventTypeMismatchException ex) { + return new FDv2ActionInternalError( + "Event type mismatch: " + ex.getMessage(), + FDv2ProtocolErrorType.IMPLEMENTATION_ERROR); + } catch (SerializationException ex) { + return new FDv2ActionInternalError( + "Failed to deserialize " + evt.getEventType() + " event: " + ex.getMessage(), + FDv2ProtocolErrorType.JSON_ERROR); + } + } + + /** + * Get a list of event types which are handled by the protocol handler. + * + * @return the list of handled event types + */ + public static List getHandledEventTypes() { + return HANDLED_EVENT_TYPES; + } + + private static final List HANDLED_EVENT_TYPES; + static { + List types = new ArrayList<>(); + types.add(FDv2EventTypes.SERVER_INTENT); + types.add(FDv2EventTypes.DELETE_OBJECT); + types.add(FDv2EventTypes.PUT_OBJECT); + types.add(FDv2EventTypes.ERROR); + types.add(FDv2EventTypes.GOODBYE); + types.add(FDv2EventTypes.PAYLOAD_TRANSFERRED); + types.add(FDv2EventTypes.HEARTBEAT); + HANDLED_EVENT_TYPES = Collections.unmodifiableList(types); + } + + /** + * Reset the protocol handler. This should be done whenever a connection to the source of data is reset. + */ + public void reset() { + changes.clear(); + state = FDv2ProtocolState.INACTIVE; + } +} + + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java new file mode 100644 index 0000000..79f83a3 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java @@ -0,0 +1,58 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +/** + * A selector can either be empty or it can contain state and a version. + */ +public final class Selector { + private final boolean isEmpty; + private final int version; + private final String state; + + private Selector(int version, String state, boolean isEmpty) { + this.version = version; + this.state = state; + this.isEmpty = isEmpty; + } + + /** + * If true, then this selector is empty. An empty selector cannot be used as a basis for a data source. + * + * @return whether the selector is empty + */ + public boolean isEmpty() { + return isEmpty; + } + + /** + * The version of the data associated with this selector. + * + * @return the version + */ + public int getVersion() { + return version; + } + + /** + * The state associated with the payload. + * + * @return the state identifier, or null if empty + */ + public String getState() { + return state; + } + + static Selector empty() { + return new Selector(0, null, true); + } + + static Selector make(int version, String state) { + return new Selector(version, state, false); + } + + /** + * An empty selector instance. + */ + public static final Selector EMPTY = empty(); +} + + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java new file mode 100644 index 0000000..09d584d --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java @@ -0,0 +1,10 @@ +/** + * This package contains FDv2 protocol handler and related source functionality. + *

+ * All types in this package are for internal LaunchDarkly use only, and are subject to change. + * They are not part of the public supported API of the SDKs, and they should not be referenced + * by application code. They have public scope only because they need to be available to + * LaunchDarkly SDK code in other packages. + */ +package com.launchdarkly.sdk.internal.fdv2.sources; + diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java new file mode 100644 index 0000000..ae64534 --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java @@ -0,0 +1,877 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.JsonElement; +import com.google.gson.stream.JsonReader; +import com.launchdarkly.sdk.internal.BaseInternalTest; +import com.launchdarkly.sdk.internal.fdv2.payloads.DeleteObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.Error; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.payloads.Goodbye; +import com.launchdarkly.sdk.internal.fdv2.payloads.IntentCode; +import com.launchdarkly.sdk.internal.fdv2.payloads.PayloadTransferred; +import com.launchdarkly.sdk.internal.fdv2.payloads.PutObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.ServerIntent; +import com.launchdarkly.sdk.json.SerializationException; + +import org.junit.Test; + +import java.io.StringReader; +import java.util.List; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class FDv2PayloadsTest extends BaseInternalTest { + + @Test + public void serverIntent_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-123\",\n" + + " \"target\": 42,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + "}"; + + ServerIntent serverIntent = ServerIntent.parse(new JsonReader(new StringReader(json))); + + assertNotNull(serverIntent); + assertEquals(1, serverIntent.getPayloads().size()); + assertEquals("payload-123", serverIntent.getPayloads().get(0).getId()); + assertEquals(42, serverIntent.getPayloads().get(0).getTarget()); + assertEquals(IntentCode.TRANSFER_FULL, serverIntent.getPayloads().get(0).getIntentCode()); + assertEquals("payload-missing", serverIntent.getPayloads().get(0).getReason()); + + // Reserialize and verify + String reserialized = gsonInstance().toJson(serverIntent); + ServerIntent deserialized2 = ServerIntent.parse(new JsonReader(new StringReader(reserialized))); + assertEquals("payload-123", deserialized2.getPayloads().get(0).getId()); + assertEquals(42, deserialized2.getPayloads().get(0).getTarget()); + assertEquals(IntentCode.TRANSFER_FULL, deserialized2.getPayloads().get(0).getIntentCode()); + assertEquals("payload-missing", deserialized2.getPayloads().get(0).getReason()); + } + + @Test + public void serverIntent_CanDeserializeMultiplePayloads() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 10,\n" + + " \"intentCode\": \"xfer-changes\",\n" + + " \"reason\": \"stale\"\n" + + " },\n" + + " {\n" + + " \"id\": \"payload-2\",\n" + + " \"target\": 20,\n" + + " \"intentCode\": \"none\",\n" + + " \"reason\": \"up-to-date\"\n" + + " }\n" + + " ]\n" + + "}"; + + ServerIntent serverIntent = ServerIntent.parse(new JsonReader(new StringReader(json))); + + assertNotNull(serverIntent); + assertEquals(2, serverIntent.getPayloads().size()); + assertEquals("payload-1", serverIntent.getPayloads().get(0).getId()); + assertEquals(IntentCode.TRANSFER_CHANGES, serverIntent.getPayloads().get(0).getIntentCode()); + assertEquals("payload-2", serverIntent.getPayloads().get(1).getId()); + assertEquals(IntentCode.NONE, serverIntent.getPayloads().get(1).getIntentCode()); + } + + @Test + public void putObject_CanDeserializeWithFlag() throws Exception { + String json = "{\n" + + " \"version\": 10,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\",\n" + + " \"object\": {\n" + + " \"key\": \"test-flag\",\n" + + " \"version\": 5,\n" + + " \"on\": true,\n" + + " \"fallthrough\": { \"variation\": 0 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [true, false],\n" + + " \"salt\": \"abc123\",\n" + + " \"trackEvents\": false,\n" + + " \"trackEventsFallthrough\": false,\n" + + " \"debugEventsUntilDate\": null,\n" + + " \"clientSide\": true,\n" + + " \"deleted\": false\n" + + " }\n" + + "}"; + + PutObject putObject = PutObject.parse(new JsonReader(new StringReader(json))); + + assertNotNull(putObject); + assertEquals(10, putObject.getVersion()); + assertEquals("flag", putObject.getKind()); + assertEquals("test-flag", putObject.getKey()); + + // Verify the object JsonElement contains the expected flag data + JsonElement objectElement = putObject.getObject(); + assertNotNull(objectElement); + assertTrue(objectElement.isJsonObject()); + assertEquals("test-flag", objectElement.getAsJsonObject().get("key").getAsString()); + assertEquals(5, objectElement.getAsJsonObject().get("version").getAsInt()); + assertTrue(objectElement.getAsJsonObject().get("on").getAsBoolean()); + assertEquals("abc123", objectElement.getAsJsonObject().get("salt").getAsString()); + } + + @Test + public void putObject_CanReserializeWithFlag() throws Exception { + // Create a flag JSON + String flagJson = "{\n" + + " \"key\": \"my-flag\",\n" + + " \"version\": 3,\n" + + " \"on\": true,\n" + + " \"fallthrough\": { \"variation\": 0 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [true, false],\n" + + " \"salt\": \"salt123\",\n" + + " \"clientSide\": true,\n" + + " \"deleted\": false\n" + + "}"; + + JsonElement flagElement = gsonInstance().fromJson(flagJson, JsonElement.class); + PutObject putObject = new PutObject(15, "flag", "my-flag", flagElement); + + String serialized = gsonInstance().toJson(putObject); + PutObject deserialized = PutObject.parse(new JsonReader(new StringReader(serialized))); + + assertEquals(15, deserialized.getVersion()); + assertEquals("flag", deserialized.getKind()); + assertEquals("my-flag", deserialized.getKey()); + + JsonElement deserializedFlagElement = deserialized.getObject(); + assertEquals("my-flag", deserializedFlagElement.getAsJsonObject().get("key").getAsString()); + assertEquals(3, deserializedFlagElement.getAsJsonObject().get("version").getAsInt()); + assertTrue(deserializedFlagElement.getAsJsonObject().get("on").getAsBoolean()); + assertEquals("salt123", deserializedFlagElement.getAsJsonObject().get("salt").getAsString()); + assertTrue(deserializedFlagElement.getAsJsonObject().get("clientSide").getAsBoolean()); + assertEquals(0, deserializedFlagElement.getAsJsonObject().get("fallthrough") + .getAsJsonObject().get("variation").getAsInt()); + assertEquals(1, deserializedFlagElement.getAsJsonObject().get("offVariation").getAsInt()); + assertEquals(2, deserializedFlagElement.getAsJsonObject().get("variations").getAsJsonArray().size()); + assertTrue(deserializedFlagElement.getAsJsonObject().get("variations").getAsJsonArray().get(0).getAsBoolean()); + } + + @Test + public void putObject_CanDeserializeWithSegment() throws Exception { + String json = "{\n" + + " \"version\": 20,\n" + + " \"kind\": \"segment\",\n" + + " \"key\": \"test-segment\",\n" + + " \"object\": {\n" + + " \"key\": \"test-segment\",\n" + + " \"version\": 7,\n" + + " \"included\": [\"user1\", \"user2\"],\n" + + " \"salt\": \"seg-salt\",\n" + + " \"deleted\": false\n" + + " }\n" + + "}"; + + PutObject putObject = PutObject.parse(new JsonReader(new StringReader(json))); + + assertNotNull(putObject); + assertEquals(20, putObject.getVersion()); + assertEquals("segment", putObject.getKind()); + assertEquals("test-segment", putObject.getKey()); + + // Verify the object JsonElement contains the expected segment data + JsonElement objectElement = putObject.getObject(); + assertNotNull(objectElement); + assertTrue(objectElement.isJsonObject()); + assertEquals("test-segment", objectElement.getAsJsonObject().get("key").getAsString()); + assertEquals(7, objectElement.getAsJsonObject().get("version").getAsInt()); + assertEquals(2, objectElement.getAsJsonObject().get("included").getAsJsonArray().size()); + assertTrue(objectElement.getAsJsonObject().get("included").getAsJsonArray().toString().contains("user1")); + assertTrue(objectElement.getAsJsonObject().get("included").getAsJsonArray().toString().contains("user2")); + } + + @Test + public void putObject_CanReserializeWithSegment() throws Exception { + // Create a segment JSON + String segmentJson = "{\n" + + " \"key\": \"my-segment\",\n" + + " \"version\": 5,\n" + + " \"included\": [\"alice\", \"bob\"],\n" + + " \"salt\": \"segment-salt\",\n" + + " \"deleted\": false\n" + + "}"; + + JsonElement segmentElement = gsonInstance().fromJson(segmentJson, JsonElement.class); + PutObject putObject = new PutObject(25, "segment", "my-segment", segmentElement); + + String serialized = gsonInstance().toJson(putObject); + PutObject deserialized = PutObject.parse(new JsonReader(new StringReader(serialized))); + + assertEquals(25, deserialized.getVersion()); + assertEquals("segment", deserialized.getKind()); + assertEquals("my-segment", deserialized.getKey()); + + JsonElement deserializedSegmentElement = deserialized.getObject(); + assertEquals("my-segment", deserializedSegmentElement.getAsJsonObject().get("key").getAsString()); + assertEquals(5, deserializedSegmentElement.getAsJsonObject().get("version").getAsInt()); + assertEquals(2, deserializedSegmentElement.getAsJsonObject().get("included").getAsJsonArray().size()); + assertTrue(deserializedSegmentElement.getAsJsonObject().get("included").getAsJsonArray().toString().contains("alice")); + assertTrue(deserializedSegmentElement.getAsJsonObject().get("included").getAsJsonArray().toString().contains("bob")); + assertEquals("segment-salt", deserializedSegmentElement.getAsJsonObject().get("salt").getAsString()); + } + + @Test + public void deleteObject_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"version\": 30,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"deleted-flag\"\n" + + "}"; + + DeleteObject deleteObject = DeleteObject.parse(new JsonReader(new StringReader(json))); + + assertNotNull(deleteObject); + assertEquals(30, deleteObject.getVersion()); + assertEquals("flag", deleteObject.getKind()); + assertEquals("deleted-flag", deleteObject.getKey()); + + // Reserialize + String reserialized = gsonInstance().toJson(deleteObject); + DeleteObject deserialized2 = DeleteObject.parse(new JsonReader(new StringReader(reserialized))); + assertEquals(30, deserialized2.getVersion()); + assertEquals("flag", deserialized2.getKind()); + assertEquals("deleted-flag", deserialized2.getKey()); + } + + @Test + public void deleteObject_CanDeserializeSegment() throws Exception { + String json = "{\n" + + " \"version\": 12,\n" + + " \"kind\": \"segment\",\n" + + " \"key\": \"removed-segment\"\n" + + "}"; + + DeleteObject deleteObject = DeleteObject.parse(new JsonReader(new StringReader(json))); + + assertEquals(12, deleteObject.getVersion()); + assertEquals("segment", deleteObject.getKind()); + assertEquals("removed-segment", deleteObject.getKey()); + } + + @Test + public void payloadTransferred_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"state\": \"(p:ABC123:42)\",\n" + + " \"version\": 42\n" + + "}"; + + PayloadTransferred payloadTransferred = PayloadTransferred.parse(new JsonReader(new StringReader(json))); + + assertNotNull(payloadTransferred); + assertEquals("(p:ABC123:42)", payloadTransferred.getState()); + assertEquals(42, payloadTransferred.getVersion()); + + // Reserialize + String reserialized = gsonInstance().toJson(payloadTransferred); + PayloadTransferred deserialized2 = PayloadTransferred.parse(new JsonReader(new StringReader(reserialized))); + assertEquals("(p:ABC123:42)", deserialized2.getState()); + assertEquals(42, deserialized2.getVersion()); + } + + @Test + public void error_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"id\": \"error-123\",\n" + + " \"reason\": \"Something went wrong\"\n" + + "}"; + + Error error = Error.parse(new JsonReader(new StringReader(json))); + + assertNotNull(error); + assertEquals("error-123", error.getId()); + assertEquals("Something went wrong", error.getReason()); + + // Reserialize + String reserialized = gsonInstance().toJson(error); + Error deserialized2 = Error.parse(new JsonReader(new StringReader(reserialized))); + assertEquals("error-123", deserialized2.getId()); + assertEquals("Something went wrong", deserialized2.getReason()); + } + + @Test + public void goodbye_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"reason\": \"Server is shutting down\"\n" + + "}"; + + Goodbye goodbye = Goodbye.parse(new JsonReader(new StringReader(json))); + + assertNotNull(goodbye); + assertEquals("Server is shutting down", goodbye.getReason()); + + // Reserialize + String reserialized = gsonInstance().toJson(goodbye); + Goodbye deserialized2 = Goodbye.parse(new JsonReader(new StringReader(reserialized))); + assertEquals("Server is shutting down", deserialized2.getReason()); + } + + @Test + public void fDv2PollEvent_CanDeserializeServerIntent() throws Exception { + String json = "{\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"evt-123\",\n" + + " \"target\": 50,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + + FDv2Event pollEvent = FDv2Event.parse(new JsonReader(new StringReader(json))); + + assertNotNull(pollEvent); + assertEquals("server-intent", pollEvent.getEventType()); + + ServerIntent serverIntent = pollEvent.asServerIntent(); + assertNotNull(serverIntent); + assertEquals(1, serverIntent.getPayloads().size()); + assertEquals("evt-123", serverIntent.getPayloads().get(0).getId()); + assertEquals(50, serverIntent.getPayloads().get(0).getTarget()); + } + + @Test + public void fDv2PollEvent_CanDeserializePutObject() throws Exception { + String json = "{\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {\n" + + " \"version\": 100,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"event-flag\",\n" + + " \"object\": {\n" + + " \"key\": \"event-flag\",\n" + + " \"version\": 1,\n" + + " \"on\": false,\n" + + " \"fallthrough\": { \"variation\": 1 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [\"A\", \"B\", \"C\"],\n" + + " \"salt\": \"evt-salt\",\n" + + " \"trackEvents\": false,\n" + + " \"trackEventsFallthrough\": false,\n" + + " \"debugEventsUntilDate\": null,\n" + + " \"clientSide\": false,\n" + + " \"deleted\": false\n" + + " }\n" + + " }\n" + + "}"; + + FDv2Event pollEvent = FDv2Event.parse(new JsonReader(new StringReader(json))); + + assertNotNull(pollEvent); + assertEquals("put-object", pollEvent.getEventType()); + + PutObject putObject = pollEvent.asPutObject(); + assertNotNull(putObject); + assertEquals(100, putObject.getVersion()); + assertEquals("flag", putObject.getKind()); + assertEquals("event-flag", putObject.getKey()); + + JsonElement flagElement = putObject.getObject(); + assertEquals("event-flag", flagElement.getAsJsonObject().get("key").getAsString()); + assertTrue(!flagElement.getAsJsonObject().get("on").getAsBoolean()); + assertEquals(3, flagElement.getAsJsonObject().get("variations").getAsJsonArray().size()); + } + + @Test + public void fDv2PollEvent_CanDeserializeDeleteObject() throws Exception { + String json = "{\n" + + " \"event\": \"delete-object\",\n" + + " \"data\": {\n" + + " \"version\": 99,\n" + + " \"kind\": \"segment\",\n" + + " \"key\": \"old-segment\"\n" + + " }\n" + + "}"; + + FDv2Event pollEvent = FDv2Event.parse(new JsonReader(new StringReader(json))); + + assertEquals("delete-object", pollEvent.getEventType()); + + DeleteObject deleteObject = pollEvent.asDeleteObject(); + assertEquals(99, deleteObject.getVersion()); + assertEquals("segment", deleteObject.getKind()); + assertEquals("old-segment", deleteObject.getKey()); + } + + @Test + public void fDv2PollEvent_CanDeserializePayloadTransferred() throws Exception { + String json = "{\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:XYZ789:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + "}"; + + FDv2Event pollEvent = FDv2Event.parse(new JsonReader(new StringReader(json))); + + assertEquals("payload-transferred", pollEvent.getEventType()); + + PayloadTransferred payloadTransferred = pollEvent.asPayloadTransferred(); + assertEquals("(p:XYZ789:100)", payloadTransferred.getState()); + assertEquals(100, payloadTransferred.getVersion()); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadsFieldMissing() throws Exception { + String json = "{}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadIdFieldMissing() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"target\": 42,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + "}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadTargetFieldMissing() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-123\",\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + "}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadIntentCodeFieldMissing() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-123\",\n" + + " \"target\": 42,\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + "}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadReasonFieldMissing() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-123\",\n" + + " \"target\": 42,\n" + + " \"intentCode\": \"xfer-full\"\n" + + " }\n" + + " ]\n" + + "}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void putObject_ThrowsWhenVersionFieldMissing() throws Exception { + String json = "{\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\",\n" + + " \"object\": {}\n" + + "}"; + PutObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void putObject_ThrowsWhenKindFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 10,\n" + + " \"key\": \"test-flag\",\n" + + " \"object\": {}\n" + + "}"; + PutObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void putObject_ThrowsWhenKeyFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 10,\n" + + " \"kind\": \"flag\",\n" + + " \"object\": {}\n" + + "}"; + PutObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void putObject_ThrowsWhenObjectFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 10,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\"\n" + + "}"; + PutObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void deleteObject_ThrowsWhenVersionFieldMissing() throws Exception { + String json = "{\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\"\n" + + "}"; + DeleteObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void deleteObject_ThrowsWhenKindFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 30,\n" + + " \"key\": \"test-flag\"\n" + + "}"; + DeleteObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void deleteObject_ThrowsWhenKeyFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 30,\n" + + " \"kind\": \"flag\"\n" + + "}"; + DeleteObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void payloadTransferred_ThrowsWhenStateFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 42\n" + + "}"; + PayloadTransferred.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void payloadTransferred_ThrowsWhenVersionFieldMissing() throws Exception { + String json = "{\n" + + " \"state\": \"(p:ABC123:42)\"\n" + + "}"; + PayloadTransferred.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void error_ThrowsWhenReasonFieldMissing() throws Exception { + String json = "{\n" + + " \"id\": \"error-123\"\n" + + "}"; + Error.parse(new JsonReader(new StringReader(json))); + } + + @Test + public void goodbye_CanDeserializeWithoutReason() throws Exception { + // Goodbye has no required fields, so an empty object should be valid + String json = "{}"; + Goodbye goodbye = Goodbye.parse(new JsonReader(new StringReader(json))); + assertNotNull(goodbye); + assertNull(goodbye.getReason()); + } + + @Test(expected = SerializationException.class) + public void fDv2PollEvent_ThrowsWhenEventFieldMissing() throws Exception { + String json = "{\n" + + " \"data\": {\n" + + " \"state\": \"(p:XYZ:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + "}"; + FDv2Event.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void fDv2PollEvent_ThrowsWhenDataFieldMissing() throws Exception { + String json = "{\n" + + " \"event\": \"payload-transferred\"\n" + + "}"; + FDv2Event.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = NullPointerException.class) + public void serverIntent_ThrowsArgumentNullExceptionWhenPayloadsIsNull() { + new ServerIntent(null); + } + + // Note: ServerIntentPayload constructor is package-private, so we can't test null checks directly. + // The null checks are tested indirectly through the parsing logic in the tests above. + + @Test(expected = NullPointerException.class) + public void putObject_ThrowsArgumentNullExceptionWhenKindIsNull() { + JsonElement emptyObject = gsonInstance().fromJson("{}", JsonElement.class); + new PutObject(1, null, "key", emptyObject); + } + + @Test(expected = NullPointerException.class) + public void putObject_ThrowsArgumentNullExceptionWhenKeyIsNull() { + JsonElement emptyObject = gsonInstance().fromJson("{}", JsonElement.class); + new PutObject(1, "flag", null, emptyObject); + } + + @Test(expected = NullPointerException.class) + public void deleteObject_ThrowsArgumentNullExceptionWhenKindIsNull() { + new DeleteObject(1, null, "key"); + } + + @Test(expected = NullPointerException.class) + public void deleteObject_ThrowsArgumentNullExceptionWhenKeyIsNull() { + new DeleteObject(1, "flag", null); + } + + @Test(expected = NullPointerException.class) + public void payloadTransferred_ThrowsArgumentNullExceptionWhenStateIsNull() { + new PayloadTransferred(null, 42); + } + + @Test(expected = NullPointerException.class) + public void error_ThrowsArgumentNullExceptionWhenReasonIsNull() { + new Error("id", null); + } + + @Test + public void fullPollingResponse_CanDeserialize() throws Exception { + String json = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"poll-payload-1\",\n" + + " \"target\": 200,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {\n" + + " \"version\": 150,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"flag-one\",\n" + + " \"object\": {\n" + + " \"key\": \"flag-one\",\n" + + " \"version\": 1,\n" + + " \"on\": true,\n" + + " \"fallthrough\": { \"variation\": 0 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [true, false],\n" + + " \"salt\": \"flag-one-salt\",\n" + + " \"trackEvents\": false,\n" + + " \"trackEventsFallthrough\": false,\n" + + " \"debugEventsUntilDate\": null,\n" + + " \"clientSide\": true,\n" + + " \"deleted\": false\n" + + " }\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {\n" + + " \"version\": 160,\n" + + " \"kind\": \"segment\",\n" + + " \"key\": \"segment-one\",\n" + + " \"object\": {\n" + + " \"key\": \"segment-one\",\n" + + " \"version\": 2,\n" + + " \"included\": [\"user-a\", \"user-b\"],\n" + + " \"salt\": \"seg-salt\",\n" + + " \"deleted\": false\n" + + " }\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"delete-object\",\n" + + " \"data\": {\n" + + " \"version\": 170,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"old-flag\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:poll-payload-1:200)\",\n" + + " \"version\": 200\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + // Parse the polling response + List eventsList = FDv2Event.parseEventsArray(json); + + assertNotNull(eventsList); + assertEquals(5, eventsList.size()); + + // Verify server-intent + assertEquals("server-intent", eventsList.get(0).getEventType()); + ServerIntent serverIntent = eventsList.get(0).asServerIntent(); + assertEquals("poll-payload-1", serverIntent.getPayloads().get(0).getId()); + assertEquals(200, serverIntent.getPayloads().get(0).getTarget()); + + // Verify first put-object (flag) + assertEquals("put-object", eventsList.get(1).getEventType()); + PutObject putFlag = eventsList.get(1).asPutObject(); + assertEquals("flag", putFlag.getKind()); + assertEquals("flag-one", putFlag.getKey()); + JsonElement flagElement = putFlag.getObject(); + assertEquals("flag-one", flagElement.getAsJsonObject().get("key").getAsString()); + assertTrue(flagElement.getAsJsonObject().get("on").getAsBoolean()); + + // Verify second put-object (segment) + assertEquals("put-object", eventsList.get(2).getEventType()); + PutObject putSegment = eventsList.get(2).asPutObject(); + assertEquals("segment", putSegment.getKind()); + assertEquals("segment-one", putSegment.getKey()); + JsonElement segmentElement = putSegment.getObject(); + assertEquals("segment-one", segmentElement.getAsJsonObject().get("key").getAsString()); + assertEquals(2, segmentElement.getAsJsonObject().get("included").getAsJsonArray().size()); + + // Verify delete-object + assertEquals("delete-object", eventsList.get(3).getEventType()); + DeleteObject deleteObj = eventsList.get(3).asDeleteObject(); + assertEquals("flag", deleteObj.getKind()); + assertEquals("old-flag", deleteObj.getKey()); + + // Verify payload-transferred + assertEquals("payload-transferred", eventsList.get(4).getEventType()); + PayloadTransferred transferred = eventsList.get(4).asPayloadTransferred(); + assertEquals("(p:poll-payload-1:200)", transferred.getState()); + assertEquals(200, transferred.getVersion()); + } + + @Test(expected = SerializationException.class) + public void deserializeEventsArray_ThrowsWhenEventsPropertyMissing() throws Exception { + String json = "{}"; + FDv2Event.parseEventsArray(json); + } + + @Test(expected = SerializationException.class) + public void deserializeEventsArray_ThrowsWhenEventIsNull() throws Exception { + String json = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " null,\n" + + " {\n" + + " \"event\": \"heartbeat\",\n" + + " \"data\": {}\n" + + " }\n" + + " ]\n" + + "}"; + + FDv2Event.parseEventsArray(json); + } + + @Test + public void deserializeEventsArray_CanDeserializeEmptyArray() throws Exception { + String json = "{\n" + + " \"events\": []\n" + + "}"; + + List events = FDv2Event.parseEventsArray(json); + assertNotNull(events); + assertTrue(events.isEmpty()); + } + + @Test + public void deserializeEventsArray_CanDeserializeValidEventsArray() throws Exception { + String json = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {\n" + + " \"version\": 150,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\",\n" + + " \"object\": {\n" + + " \"key\": \"test-flag\",\n" + + " \"version\": 1,\n" + + " \"on\": true,\n" + + " \"fallthrough\": { \"variation\": 0 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [true, false],\n" + + " \"salt\": \"test-salt\",\n" + + " \"trackEvents\": false,\n" + + " \"trackEventsFallthrough\": false,\n" + + " \"debugEventsUntilDate\": null,\n" + + " \"clientSide\": false,\n" + + " \"deleted\": false\n" + + " }\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:payload-1:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + List events = FDv2Event.parseEventsArray(json); + + assertNotNull(events); + assertEquals(3, events.size()); + + assertEquals("server-intent", events.get(0).getEventType()); + ServerIntent serverIntent = events.get(0).asServerIntent(); + assertEquals("payload-1", serverIntent.getPayloads().get(0).getId()); + + assertEquals("put-object", events.get(1).getEventType()); + PutObject putObject = events.get(1).asPutObject(); + assertEquals("test-flag", putObject.getKey()); + + assertEquals("payload-transferred", events.get(2).getEventType()); + PayloadTransferred transferred = events.get(2).asPayloadTransferred(); + assertEquals(100, transferred.getVersion()); + } +} + diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java new file mode 100644 index 0000000..94d2ff1 --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java @@ -0,0 +1,5 @@ +/** + * Test classes and methods for testing FDv2 payload functionality. + */ +package com.launchdarkly.sdk.internal.fdv2.payloads; + diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandlerTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandlerTest.java new file mode 100644 index 0000000..ae32c71 --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandlerTest.java @@ -0,0 +1,1066 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +import com.google.gson.JsonElement; +import com.launchdarkly.sdk.internal.BaseInternalTest; +import com.launchdarkly.sdk.internal.fdv2.payloads.DeleteObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.Error; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.FDv2EventTypeMismatchException; +import com.launchdarkly.sdk.internal.fdv2.payloads.Goodbye; +import com.launchdarkly.sdk.internal.fdv2.payloads.IntentCode; +import com.launchdarkly.sdk.internal.fdv2.payloads.PayloadTransferred; +import com.launchdarkly.sdk.internal.fdv2.payloads.PutObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.ServerIntent; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class FDv2ProtocolHandlerTest extends BaseInternalTest { + + private static FDv2Event createServerIntentEvent(IntentCode intentCode, String payloadId, int target, String reason) { + List payloads = Collections.singletonList( + new ServerIntent.ServerIntentPayload(payloadId, target, intentCode, reason)); + ServerIntent intent = new ServerIntent(payloads); + String json = gsonInstance().toJson(intent); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.SERVER_INTENT, data); + } + + private static FDv2Event createServerIntentEvent(IntentCode intentCode) { + return createServerIntentEvent(intentCode, "test-payload", 1, "test-reason"); + } + + private static FDv2Event createPutObjectEvent(String kind, String key, int version, String jsonStr) { + JsonElement objectElement = gsonInstance().fromJson(jsonStr, JsonElement.class); + PutObject putObj = new PutObject(version, kind, key, objectElement); + String json = gsonInstance().toJson(putObj); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.PUT_OBJECT, data); + } + + private static FDv2Event createPutObjectEvent(String kind, String key, int version) { + return createPutObjectEvent(kind, key, version, "{}"); + } + + private static FDv2Event createDeleteObjectEvent(String kind, String key, int version) { + DeleteObject deleteObj = new DeleteObject(version, kind, key); + String json = gsonInstance().toJson(deleteObj); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.DELETE_OBJECT, data); + } + + private static FDv2Event createPayloadTransferredEvent(String state, int version) { + PayloadTransferred transferred = new PayloadTransferred(state, version); + String json = gsonInstance().toJson(transferred); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.PAYLOAD_TRANSFERRED, data); + } + + private static FDv2Event createErrorEvent(String id, String reason) { + Error error = new Error(id, reason); + String json = gsonInstance().toJson(error); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.ERROR, data); + } + + private static FDv2Event createGoodbyeEvent(String reason) { + Goodbye goodbye = new Goodbye(reason); + String json = gsonInstance().toJson(goodbye); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.GOODBYE, data); + } + + private static FDv2Event createHeartbeatEvent() { + JsonElement data = gsonInstance().fromJson("{}", JsonElement.class); + return new FDv2Event(FDv2EventTypes.HEARTBEAT, data); + } + + // Section 2.2.2: SDK has up to date saved payload + + /** + * Tests the scenario from section 2.2.2 where the SDK has an up-to-date payload. + * The server responds with intentCode: none indicating no changes are needed. + */ + @Test + public void serverIntent_WithIntentCodeNone_ReturnsChangesetImmediately() { + // Section 2.2.2: SDK has up to date saved payload + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + FDv2Event evt = createServerIntentEvent(IntentCode.NONE, "payload-123", 52, "up-to-date"); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.NONE, changesetAction.getChangeset().getType()); + assertTrue(changesetAction.getChangeset().getChanges().isEmpty()); + } + + // Section 2.1.1 & 2.2.1: SDK has no saved payload (Full Transfer) + + /** + * Tests the scenario from sections 2.1.1 and 2.2.1 where the SDK has no saved payload. + * The server responds with intentCode: xfer-full and sends a complete payload. + */ + @Test + public void fullTransfer_AccumulatesChangesAndEmitsOnPayloadTransferred() { + // Section 2.1.1 & 2.2.1: SDK has no saved payload and continues to get changes + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Server-intent with xfer-full + FDv2Event intentEvt = createServerIntentEvent(IntentCode.TRANSFER_FULL, "payload-123", 52, "payload-missing"); + FDv2ProtocolHandler.IFDv2ProtocolAction intentAction = handler.handleEvent(intentEvt); + assertTrue(intentAction instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Put some objects + FDv2Event put1 = createPutObjectEvent("flag", "flag-123", 12); + FDv2ProtocolHandler.IFDv2ProtocolAction put1Action = handler.handleEvent(put1); + assertTrue(put1Action instanceof FDv2ProtocolHandler.FDv2ActionNone); + + FDv2Event put2 = createPutObjectEvent("flag", "flag-abc", 12); + FDv2ProtocolHandler.IFDv2ProtocolAction put2Action = handler.handleEvent(put2); + assertTrue(put2Action instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Payload-transferred finalizes the changeset + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:payload-123:52)", 52); + FDv2ProtocolHandler.IFDv2ProtocolAction transferredAction = handler.handleEvent(transferredEvt); + + assertTrue(transferredAction instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) transferredAction; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + assertEquals(2, changesetAction.getChangeset().getChanges().size()); + assertEquals("flag-123", changesetAction.getChangeset().getChanges().get(0).getKey()); + assertEquals("flag-abc", changesetAction.getChangeset().getChanges().get(1).getKey()); + assertEquals("(p:payload-123:52)", changesetAction.getChangeset().getSelector().getState()); + assertEquals(52, changesetAction.getChangeset().getSelector().getVersion()); + } + + /** + * Tests that a full transfer properly replaces any partial state. + * Requirement 3.3.1: SDK must prepare to fully replace its local payload representation. + */ + @Test + public void fullTransfer_ReplacesPartialState() { + // Requirement 3.3.1: Prepare to fully replace local payload representation + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Start with an intent to transfer changes + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "flag-1", 1)); + + // Now receive xfer-full - should replace/reset + FDv2Event fullIntent = createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 2, "outdated"); + handler.handleEvent(fullIntent); + + // Send new full payload + handler.handleEvent(createPutObjectEvent("flag", "flag-2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + // Should only have flag-2, not flag-1 + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("flag-2", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + // Section 2.1.2 & 2.2.3: SDK has stale saved payload (Incremental Changes) + + /** + * Tests the scenario from sections 2.1.2 and 2.2.3 where the SDK has a stale payload. + * The server responds with intentCode: xfer-changes and sends incremental updates. + */ + @Test + public void incrementalTransfer_AccumulatesChangesAndEmitsOnPayloadTransferred() { + // Section 2.1.2 & 2.2.3: SDK has stale saved payload + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Server-intent with xfer-changes + FDv2Event intentEvt = createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "payload-123", 52, "stale"); + FDv2ProtocolHandler.IFDv2ProtocolAction intentAction = handler.handleEvent(intentEvt); + assertTrue(intentAction instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Put and delete objects + FDv2Event put1 = createPutObjectEvent("flag", "flag-cat", 13); + handler.handleEvent(put1); + + FDv2Event put2 = createPutObjectEvent("flag", "flag-dog", 13); + handler.handleEvent(put2); + + FDv2Event delete1 = createDeleteObjectEvent("flag", "flag-bat", 13); + handler.handleEvent(delete1); + + FDv2Event put3 = createPutObjectEvent("flag", "flag-cow", 14); + handler.handleEvent(put3); + + // Payload-transferred finalizes the changeset + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:payload-123:52)", 52); + FDv2ProtocolHandler.IFDv2ProtocolAction transferredAction = handler.handleEvent(transferredEvt); + + assertTrue(transferredAction instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) transferredAction; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changesetAction.getChangeset().getType()); + assertEquals(4, changesetAction.getChangeset().getChanges().size()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changesetAction.getChangeset().getChanges().get(0).getType()); + assertEquals("flag-cat", changesetAction.getChangeset().getChanges().get(0).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changesetAction.getChangeset().getChanges().get(1).getType()); + assertEquals("flag-dog", changesetAction.getChangeset().getChanges().get(1).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.DELETE, changesetAction.getChangeset().getChanges().get(2).getType()); + assertEquals("flag-bat", changesetAction.getChangeset().getChanges().get(2).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changesetAction.getChangeset().getChanges().get(3).getType()); + assertEquals("flag-cow", changesetAction.getChangeset().getChanges().get(3).getKey()); + } + + // Requirement 3.3.2: Payload State Validity + + /** + * Requirement 3.3.2: SDK must not consider its local payload state X as valid until + * receiving the payload-transferred event for the corresponding payload state X. + */ + @Test + public void payloadTransferred_OnlyEmitsChangesetAfterReceivingEvent() { + // Requirement 3.3.2: Only consider payload valid after payload-transferred + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + + // Accumulate changes - should not emit changeset yet + FDv2ProtocolHandler.IFDv2ProtocolAction action1 = handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + assertTrue(action1 instanceof FDv2ProtocolHandler.FDv2ActionNone); + + FDv2ProtocolHandler.IFDv2ProtocolAction action2 = handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + assertTrue(action2 instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Only after payload-transferred should we get a changeset + FDv2ProtocolHandler.IFDv2ProtocolAction action3 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + assertTrue(action3 instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + } + + /** + * Tests that payload-transferred event returns protocol error if received without prior server-intent. + */ + @Test + public void payloadTransferred_WithoutServerIntent_ReturnsProtocolError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Attempt to send payload-transferred without server-intent + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:payload-123:52)", 52); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(transferredEvt); + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.PROTOCOL_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("without an intent")); + } + + // Requirement 3.3.7 & 3.3.8: Error Handling + + /** + * Requirement 3.3.7: SDK must discard partially transferred data when an error event is encountered. + * Requirement 3.3.8: SDK should stay connected after receiving an application level error event. + */ + @Test + public void error_DiscardsPartiallyTransferredData() { + // Requirements 3.3.7 & 3.3.8: Discard partial data on error, stay connected + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + + // Error occurs - partial data should be discarded + FDv2Event errorEvt = createErrorEvent("p1", "Something went wrong"); + FDv2ProtocolHandler.IFDv2ProtocolAction errorAction = handler.handleEvent(errorEvt); + + assertTrue(errorAction instanceof FDv2ProtocolHandler.FDv2ActionError); + FDv2ProtocolHandler.FDv2ActionError errorActionTyped = (FDv2ProtocolHandler.FDv2ActionError) errorAction; + assertEquals("p1", errorActionTyped.getId()); + assertEquals("Something went wrong", errorActionTyped.getReason()); + + // Server recovers and resends + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "retry")); + handler.handleEvent(createPutObjectEvent("flag", "f3", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + // Should only have f3, not f1 or f2 + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f3", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that error maintains the current state (Full vs. Changes) after clearing partial data. + */ + @Test + public void error_MaintainsCurrentState() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Start with an intent to transfer changes + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Error occurs + handler.handleEvent(createErrorEvent("p1", "error")); + + // Continue receiving changes (no new server-intent) + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + // Should still be Partial (the state is maintained). + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changesetAction.getChangeset().getType()); + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f2", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + // Requirement 3.3.5: Goodbye Handling + + /** + * Requirement 3.3.5: SDK must log a message at the info level when a goodbye event is encountered. + * The message must include the reason. + */ + @Test + public void goodbye_ReturnsGoodbyeActionWithReason() { + // Requirement 3.3.5: Log goodbye with reason + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + FDv2Event goodbyeEvt = createGoodbyeEvent("Server is shutting down"); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(goodbyeEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionGoodbye); + FDv2ProtocolHandler.FDv2ActionGoodbye goodbyeAction = (FDv2ProtocolHandler.FDv2ActionGoodbye) action; + assertEquals("Server is shutting down", goodbyeAction.getReason()); + } + + // Requirement 3.3.9: Heartbeat Handling + + /** + * Requirement 3.3.9: SDK must silently handle/ignore heartbeat events. + */ + @Test + public void heartbeat_IsSilentlyIgnored() { + // Requirement 3.3.9: Silently ignore heartbeat events + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + FDv2Event heartbeatEvt = createHeartbeatEvent(); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(heartbeatEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionNone); + } + + // Requirement 3.4.2: Multiple Payloads Handling + + /** + * Requirement 3.4.2: SDK must ignore all but the first payload of the server-intent event + * and must not crash/error when receiving messages that contain multiple payloads. + */ + @Test + public void serverIntent_WithMultiplePayloads_UsesOnlyFirstPayload() { + // Requirement 3.4.2: Ignore all but the first payload + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + List payloads = new ArrayList<>(); + payloads.add(new ServerIntent.ServerIntentPayload("payload-1", 10, IntentCode.TRANSFER_CHANGES, "stale")); + payloads.add(new ServerIntent.ServerIntentPayload("payload-2", 20, IntentCode.NONE, "up-to-date")); + ServerIntent intent = new ServerIntent(payloads); + String json = gsonInstance().toJson(intent); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.SERVER_INTENT, data); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + // Should return None because the first payload is TransferChanges (not None) + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Verify we're in Changes state by sending changes + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = + (FDv2ProtocolHandler.FDv2ActionChangeset) handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changesetAction.getChangeset().getType()); + } + + // Error Type Handling + + /** + * Tests that unknown event types are handled gracefully with UnknownEvent error type. + */ + @Test + public void unknownEventType_ReturnsUnknownEventError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + JsonElement data = gsonInstance().fromJson("{}", JsonElement.class); + FDv2Event unknownEvt = new FDv2Event("unknown-event-type", data); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(unknownEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.UNKNOWN_EVENT, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("unknown-event-type")); + } + + /** + * Tests that server-intent with empty payload list returns MissingPayload error type. + */ + @Test + public void serverIntent_WithEmptyPayloadList_ReturnsMissingPayloadError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + ServerIntent intent = new ServerIntent(Collections.emptyList()); + String json = gsonInstance().toJson(intent); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.SERVER_INTENT, data); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.MISSING_PAYLOAD, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("No payload present")); + } + + /** + * Tests that payload-transferred without server-intent returns ProtocolError error type. + */ + @Test + public void payloadTransferred_WithoutServerIntent_ReturnsProtocolErrorType() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:payload-123:52)", 52); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(transferredEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.PROTOCOL_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("without an intent")); + } + + // State Transitions + + /** + * Tests that after payload-transferred, the handler transitions to Changes state + * to receive subsequent incremental updates. + */ + @Test + public void payloadTransferred_TransitionsToChangesState() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Start with full transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action1 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset1 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action1).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changeset1.getType()); + + // Now send more changes without new server-intent - should be Partial + handler.handleEvent(createPutObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action2 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:2)", 2)); + + FDv2ChangeSet changeset2 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action2).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changeset2.getType()); + } + + /** + * Tests that IntentCode.None properly sets the state to Changes. + */ + @Test + public void serverIntent_WithIntentCodeNone_TransitionsToChangesState() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Receive intent with None + handler.handleEvent(createServerIntentEvent(IntentCode.NONE, "p1", 1, "up-to-date")); + + // Now send incremental changes + handler.handleEvent(createPutObjectEvent("flag", "f1", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:2)", 2)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changeset.getType()); + } + + // Put and Delete Operations + + /** + * Tests that put-object events correctly accumulate with all required fields. + * Section 3.2: put-object contains payload objects that should be accepted with upsert semantics. + */ + @Test + public void putObject_AccumulatesWithAllFields() { + // Section 3.2: put-object with upsert semantics + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + + String flagData = "{\"key\":\"test-flag\",\"on\":true, \"version\": 314}"; + FDv2Event putEvt = createPutObjectEvent("flag", "test-flag", 42, flagData); + handler.handleEvent(putEvt); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(1, changeset.getChanges().size()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changeset.getChanges().get(0).getType()); + assertEquals("flag", changeset.getChanges().get(0).getKind()); + assertEquals("test-flag", changeset.getChanges().get(0).getKey()); + assertEquals(42, changeset.getChanges().get(0).getVersion()); + assertNotNull(changeset.getChanges().get(0).getObject()); + + // Verify we can access the stored JSON element + JsonElement flagElement = changeset.getChanges().get(0).getObject(); + assertEquals("test-flag", flagElement.getAsJsonObject().get("key").getAsString()); + assertEquals(314, flagElement.getAsJsonObject().get("version").getAsInt()); + assertTrue(flagElement.getAsJsonObject().get("on").getAsBoolean()); + } + + /** + * Tests that delete-object events correctly accumulate. + * Section 3.3: delete-object contains payload objects that should be deleted. + */ + @Test + public void deleteObject_AccumulatesWithAllFields() { + // Section 3.3: delete-object + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + + FDv2Event deleteEvt = createDeleteObjectEvent("segment", "old-segment", 99); + handler.handleEvent(deleteEvt); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(1, changeset.getChanges().size()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.DELETE, changeset.getChanges().get(0).getType()); + assertEquals("segment", changeset.getChanges().get(0).getKind()); + assertEquals("old-segment", changeset.getChanges().get(0).getKey()); + assertEquals(99, changeset.getChanges().get(0).getVersion()); + assertNull(changeset.getChanges().get(0).getObject()); + } + + /** + * Tests that put and delete operations can be mixed in a single changeset. + */ + @Test + public void putAndDelete_CanBeMixedInSameChangeset() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createDeleteObjectEvent("flag", "f2", 1)); + handler.handleEvent(createPutObjectEvent("segment", "s1", 1)); + handler.handleEvent(createDeleteObjectEvent("segment", "s2", 1)); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(4, changeset.getChanges().size()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changeset.getChanges().get(0).getType()); + assertEquals("f1", changeset.getChanges().get(0).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.DELETE, changeset.getChanges().get(1).getType()); + assertEquals("f2", changeset.getChanges().get(1).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changeset.getChanges().get(2).getType()); + assertEquals("s1", changeset.getChanges().get(2).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.DELETE, changeset.getChanges().get(3).getType()); + assertEquals("s2", changeset.getChanges().get(3).getKey()); + } + + // Multiple Transfer Cycles + + /** + * Tests that the handler can process multiple complete transfer cycles. + * Simulates a streaming connection receiving multiple payload updates over time. + */ + @Test + public void multipleTransferCycles_AreHandledCorrectly() { + // Section 2.1.1: "some time later" - multiple transfers + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // First full transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 52, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action1 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:52)", 52)); + + FDv2ChangeSet changeset1 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action1).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changeset1.getType()); + assertEquals(2, changeset1.getChanges().size()); + + // Second incremental transfer (some time later) + handler.handleEvent(createPutObjectEvent("flag", "f1", 2)); + handler.handleEvent(createDeleteObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action2 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:53)", 53)); + + FDv2ChangeSet changeset2 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action2).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changeset2.getType()); + assertEquals(2, changeset2.getChanges().size()); + + // Third incremental transfer + handler.handleEvent(createPutObjectEvent("flag", "f3", 3)); + FDv2ProtocolHandler.IFDv2ProtocolAction action3 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:54)", 54)); + + FDv2ChangeSet changeset3 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action3).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changeset3.getType()); + assertEquals(1, changeset3.getChanges().size()); + } + + /** + * Tests that receiving a new server-intent during an ongoing transfer properly resets state. + * Per spec: "The SDK may receive multiple server-intent messages with xfer-full within one connection's lifespan." + */ + @Test + public void newServerIntent_DuringTransfer_ResetsState() { + // Requirement 3.3.1: SDK may receive multiple server-intent messages + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Start first transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Receive new server-intent before payload-transferred (e.g., server restarted) + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 2, "reset")); + handler.handleEvent(createPutObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:2)", 2)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + // Should only have f2, the first transfer was abandoned + assertEquals(1, changeset.getChanges().size()); + assertEquals("f2", changeset.getChanges().get(0).getKey()); + } + + // Empty Payloads and Edge Cases + + /** + * Tests handling of a transfer with no objects. + */ + @Test + public void transfer_WithNoObjects_EmitsEmptyChangeset() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + // No put or delete events + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changeset.getType()); + assertTrue(changeset.getChanges().isEmpty()); + } + + // Selector Verification + + /** + * Tests that the selector is properly populated from payload-transferred event. + */ + @Test + public void payloadTransferred_PopulatesSelector() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "test-payload-id", 42, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:test-payload-id:42)", 42)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertFalse(changeset.getSelector().isEmpty()); + assertEquals("(p:test-payload-id:42)", changeset.getSelector().getState()); + assertEquals(42, changeset.getSelector().getVersion()); + } + + /** + * Tests that ChangeSet.None has an empty selector. + */ + @Test + public void changeSetNone_HasEmptySelector() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createServerIntentEvent(IntentCode.NONE, "p1", 1, "up-to-date")); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertTrue(changeset.getSelector().isEmpty()); + } + + // FDv2Event Type Validation + + /** + * Tests that AsServerIntent throws FDv2EventTypeMismatchException when called on a non-server-intent event. + */ + @Test + public void asServerIntent_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createPutObjectEvent("flag", "f1", 1); + try { + evt.asServerIntent(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.PUT_OBJECT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsPutObject throws FDv2EventTypeMismatchException when called on a non-put-object event. + */ + @Test + public void asPutObject_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asPutObject(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.PUT_OBJECT, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsDeleteObject throws FDv2EventTypeMismatchException when called on a non-delete-object event. + */ + @Test + public void asDeleteObject_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asDeleteObject(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.DELETE_OBJECT, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsPayloadTransferred throws FDv2EventTypeMismatchException when called on a non-payload-transferred event. + */ + @Test + public void asPayloadTransferred_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asPayloadTransferred(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.PAYLOAD_TRANSFERRED, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsError throws FDv2EventTypeMismatchException when called on a non-error event. + */ + @Test + public void asError_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asError(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.ERROR, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsGoodbye throws FDv2EventTypeMismatchException when called on a non-goodbye event. + */ + @Test + public void asGoodbye_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asGoodbye(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.GOODBYE, ex.getExpectedEventType()); + } + } + + // JSON Deserialization Error Handling + + /** + * Tests that HandleEvent returns JsonError when event data is malformed JSON. + */ + @Test + public void handleEvent_WithMalformedJson_ReturnsJsonError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Create an event with invalid JSON data for server-intent + JsonElement badData = gsonInstance().fromJson("{\"invalid\":\"data\"}", JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.SERVER_INTENT, badData); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.JSON_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("Failed to deserialize")); + assertTrue(internalError.getMessage().contains(FDv2EventTypes.SERVER_INTENT)); + } + + /** + * Tests that HandleEvent returns JsonError when put-object data is malformed. + */ + @Test + public void handleEvent_WithMalformedPutObject_ReturnsJsonError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // First set up the state with a valid server-intent + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + + // Now send a malformed put-object + JsonElement badData = gsonInstance().fromJson("{\"missing\":\"required fields\"}", JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.PUT_OBJECT, badData); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.JSON_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("Failed to deserialize")); + assertTrue(internalError.getMessage().contains(FDv2EventTypes.PUT_OBJECT)); + } + + /** + * Tests that HandleEvent returns JsonError when payload-transferred data is malformed. + */ + @Test + public void handleEvent_WithMalformedPayloadTransferred_ReturnsJsonError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // First set up the state with a valid server-intent + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + + // Now send a malformed payload-transferred + JsonElement badData = gsonInstance().fromJson("{\"incomplete\":\"data\"}", JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.PAYLOAD_TRANSFERRED, badData); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.JSON_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("Failed to deserialize")); + assertTrue(internalError.getMessage().contains(FDv2EventTypes.PAYLOAD_TRANSFERRED)); + } + + // Reset Method + + /** + * Tests that Reset clears accumulated changes and resets state to Inactive. + */ + @Test + public void reset_ClearsAccumulatedChanges() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Set up state with accumulated changes + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + + // Reset the handler + handler.reset(); + + // Attempting to send payload-transferred without new server-intent should return protocol error + // because reset puts the handler back to Inactive state + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:p1:1)", 1); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(transferredEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.PROTOCOL_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("without an intent")); + } + + /** + * Tests that Reset allows starting a new transfer cycle. + */ + @Test + public void reset_AllowsNewTransferCycle() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // First transfer cycle + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Reset + handler.reset(); + + // New transfer cycle should work + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p2", 2, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + // Should only have f2, not f1 (which was cleared by reset) + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f2", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that Reset during an ongoing Full transfer properly clears partial data. + */ + @Test + public void reset_DuringFullTransfer_ClearsPartialData() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f3", 1)); + + // Reset before payload-transferred + handler.reset(); + + // Start new transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p2", 2, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "f4", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changesetAction.getChangeset().getType()); + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f4", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that Reset during an ongoing Changes transfer properly clears partial data. + */ + @Test + public void reset_DuringChangesTransfer_ClearsPartialData() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createDeleteObjectEvent("flag", "f2", 1)); + + // Reset before payload-transferred + handler.reset(); + + // Start new transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p2", 2, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f3", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f3", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that Reset can be called multiple times safely. + */ + @Test + public void reset_CanBeCalledMultipleTimes() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Reset on fresh handler + handler.reset(); + + // Set up state + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Reset again + handler.reset(); + + // Reset yet again + handler.reset(); + + // Should still work normally + handler.handleEvent(createServerIntentEvent(IntentCode.NONE, "p1", 1, "up-to-date")); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createServerIntentEvent(IntentCode.NONE, "p1", 1, "up-to-date")); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.NONE, changesetAction.getChangeset().getType()); + } + + /** + * Tests that Reset after a completed transfer works correctly. + * Simulates connection reset after successful data transfer. + */ + @Test + public void reset_AfterCompletedTransfer_WorksCorrectly() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Complete a full transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action1 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + assertTrue(action1 instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + + // Reset (simulating connection reset) + handler.reset(); + + // Start new transfer after reset + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p2", 2, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action2 = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action2; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f2", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that Reset after receiving an error properly clears state. + */ + @Test + public void reset_AfterError_ClearsState() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Receive error + FDv2ProtocolHandler.IFDv2ProtocolAction errorAction = handler.handleEvent(createErrorEvent("p1", "Something went wrong")); + assertTrue(errorAction instanceof FDv2ProtocolHandler.FDv2ActionError); + + // Reset after error + handler.reset(); + + // Verify state is Inactive by attempting payload-transferred without intent + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:p1:1)", 1); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(transferredEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.PROTOCOL_ERROR, internalError.getErrorType()); + } + + /** + * Tests that Reset properly handles the case where mixed put and delete operations were accumulated. + */ + @Test + public void reset_WithMixedOperations_ClearsAllChanges() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createDeleteObjectEvent("flag", "f2", 1)); + handler.handleEvent(createPutObjectEvent("segment", "s1", 1)); + handler.handleEvent(createDeleteObjectEvent("segment", "s2", 1)); + + // Reset + handler.reset(); + + // New transfer should not include any of the previous changes + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p2", 2, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f-new", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f-new", changesetAction.getChangeset().getChanges().get(0).getKey()); + } +} + diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java new file mode 100644 index 0000000..054730f --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java @@ -0,0 +1,5 @@ +/** + * Test classes and methods for testing FDv2 protocol handler functionality. + */ +package com.launchdarkly.sdk.internal.fdv2.sources; +