From d96542896280cf37d311eda3fb7a9b7b4882505e Mon Sep 17 00:00:00 2001 From: tomas-amaro Date: Wed, 26 Aug 2026 10:52:31 +0100 Subject: [PATCH 1/3] fix(wire-contract): serializer and error-mapping parity with the reference SDK Five wire-contract parity defects from docs/parity/FLEET.md sections 4 and 6, all verified against the corpus in docs/flows/ and the C# reference at sdk_net@9165cf5. 1. dropoff_latitiude. RideLineItem.dropoffLatitude had no @SerializedName, so Gson's LOWER_CASE_WITH_UNDERSCORES derived dropoff_latitude. The live wire name is the transposed dropoff_latitiude (RideTicketLineItem.cs:101) and the API expects it; the "corrected" spelling silently dropped ride dropoff geolocation with no error. Pinned explicitly, with a comment and a test that assert the misspelling so it is not "fixed" again. The other 19 RideLineItem fields derive correctly and are now locked by a test. 2. HMAC key charset. SHA256Handler used authKey.getBytes(), the platform default, making the signature depend on a JVM locale setting. Pinned to UTF-8, which matches the reference's ASCII byte for byte for the hex tokens Riskified issues. The corpus reference vector is now asserted. 3. The date split. The contract splits 25 offset-bearing fields from 13 naive ones, following no principle. One global Date adapter cannot express that, so the 13 naive fields carry @JsonAdapter(NaiveDateTypeAdapter.class), which Gson gives precedence over the registered adapter; everything else keeps the offset format. Both formats now render in UTC with Locale.US, so the same Date no longer serializes differently on two machines -- and neither does the HMAC computed over those bytes. 4. The method discriminator. payment_details carries no type discriminator on the wire; the variant is expressed by which keys are present, with a constant payment_type as the de-facto discriminator. The RuntimeTypeAdapterFactory injected a "method" key whose values disagreed with the payment_type emitted alongside. Replaced with PaymentDetailsAdapterFactory, which dispatches on runtime type and emits no label. The replacement is necessary rather than cosmetic: BaseOrder and DecisionOrder declare the field as List, and Gson's built-in runtime-type promotion fires only for a Class element type, not a WildcardType -- simply unregistering the old factory serializes every element as {}. StripePaymentDetails is left in place, minus the method key. 5. Error mapping. The default: branch rewrote every unmatched status to 500 "Contact Riskified support" and discarded the body, so a 502, a 503 and a genuine 500 were indistinguishable. Statuses are now reported as themselves through RiskifiedHttpException, which extends HttpResponseException (so existing catch blocks and getStatusCode() are unaffected) and carries statusText, the raw body, and the parsed error as separate structured fields that logging can redact. The checkout path no longer dereferences getError().getMessage() before establishing that the body parsed as that shape: six of the seven documented error shapes used to raise a NullPointerException in place of the HTTP error. Deliberately unchanged: null-handling and always-emitted-default behaviour (corpus open question 5), the Accept header, User-Agent, the Version header, and the webhook receiver. A before/after payload diff confirms the only key-set changes are the dropoff_latitiude rename and the removal of method. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/com/riskified/JSONFormater.java | 52 ++- .../java/com/riskified/RiskifiedClient.java | 96 ++++-- .../com/riskified/RiskifiedHttpException.java | 78 +++++ .../java/com/riskified/SHA256Handler.java | 6 +- .../adapters/NaiveDateTypeAdapter.java | 129 +++++++ .../PaymentDetailsAdapterFactory.java | 82 +++++ .../models/AccommodationLineItem.java | 8 + .../models/AuthenticationResult.java | 4 + .../com/riskified/models/EventLineItem.java | 6 + .../java/com/riskified/models/LineItem.java | 6 + .../main/java/com/riskified/models/Login.java | 6 + .../java/com/riskified/models/Passenger.java | 10 + .../com/riskified/models/RideLineItem.java | 15 + .../com/riskified/models/TravelLineItem.java | 8 + .../java/com/riskified/DateSplitTest.java | 318 ++++++++++++++++++ .../riskified/RiskifiedClientErrorTest.java | 174 ++++++++++ .../java/com/riskified/SHA256HandlerTest.java | 90 +++++ .../riskified/models/PaymentDetailsTest.java | 73 +++- .../riskified/models/RideLineItemTest.java | 117 +++++++ 19 files changed, 1230 insertions(+), 48 deletions(-) create mode 100644 riskified-sdk/src/main/java/com/riskified/RiskifiedHttpException.java create mode 100644 riskified-sdk/src/main/java/com/riskified/adapters/NaiveDateTypeAdapter.java create mode 100644 riskified-sdk/src/main/java/com/riskified/adapters/PaymentDetailsAdapterFactory.java create mode 100644 riskified-sdk/src/test/java/com/riskified/DateSplitTest.java create mode 100644 riskified-sdk/src/test/java/com/riskified/RiskifiedClientErrorTest.java create mode 100644 riskified-sdk/src/test/java/com/riskified/SHA256HandlerTest.java create mode 100644 riskified-sdk/src/test/java/com/riskified/models/RideLineItemTest.java diff --git a/riskified-sdk/src/main/java/com/riskified/JSONFormater.java b/riskified-sdk/src/main/java/com/riskified/JSONFormater.java index 58fbbc56..92bd0fb8 100644 --- a/riskified-sdk/src/main/java/com/riskified/JSONFormater.java +++ b/riskified-sdk/src/main/java/com/riskified/JSONFormater.java @@ -2,10 +2,11 @@ import java.lang.reflect.Type; import java.text.DateFormat; -import java.text.SimpleDateFormat; import java.util.Date; import com.google.gson.*; +import com.riskified.adapters.NaiveDateTypeAdapter; +import com.riskified.adapters.PaymentDetailsAdapterFactory; import com.riskified.models.BankWirePaymentDetails; import com.riskified.models.CreditCardPaymentDetails; import com.riskified.models.IPaymentDetails; @@ -18,17 +19,60 @@ public class JSONFormater { public static String toJson(Object obj) { Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .registerTypeAdapter(Date.class, new DateTimeSerializer()) - .registerTypeAdapterFactory(paymentDetailsSerializer()) + .registerTypeAdapterFactory(new PaymentDetailsAdapterFactory()) .create(); return gson.toJson(obj); } + /** + * Serializer for the 25 offset-bearing date fields of the wire contract — every + * {@link Date} that is not explicitly annotated with + * {@code @JsonAdapter(NaiveDateTypeAdapter.class)}. + * + *

+ * Two properties matter here: + *

+ * + * @see NaiveDateTypeAdapter for the 13 fields that must carry no offset at all + */ public static class DateTimeSerializer implements JsonSerializer { + /** Zero offset spelled out, to match how .NET renders a UTC {@code DateTimeOffset}. */ + public static final String OFFSET_PATTERN = "yyyy-MM-dd'T'HH:mm:ss'+00:00'"; + public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) { - DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX"); + DateFormat df = NaiveDateTypeAdapter.utcFormat(OFFSET_PATTERN); return new JsonPrimitive(df.format(src)); } } + + /** + * The polymorphic {@code payment_details} adapter that this SDK used to register. + * + *

+ * No longer used, and deliberately so. It injected a {@code "method"} key into every + * {@code payment_details} object with values ({@code credit_card}, {@code bank_wire}, + * {@code digital_wallet}) that disagreed with the {@code payment_type} value emitted alongside + * it. The wire contract carries no type discriminator on {@code payment_details}: the + * variant is expressed by which keys are present, with the constant {@code payment_type} acting + * as the de-facto discriminator. No other SDK in the fleet emitted {@code method}. + * + *

+ * Gson dispatches on the runtime type of each element of a {@code List} on its + * own, so nothing is lost by dropping the factory. It is retained here only so that callers who + * built their own {@code Gson} against it still compile; do not register it. + * + * @return the legacy discriminator-injecting factory + * @deprecated the {@code method} key it emits is not part of the Riskified wire contract. + */ + @Deprecated public static RuntimeTypeAdapterFactory paymentDetailsSerializer() { return RuntimeTypeAdapterFactory .of(IPaymentDetails.class, "method") @@ -38,5 +82,5 @@ public static RuntimeTypeAdapterFactory paymentDetailsSerializer() { .registerSubtype(BankWirePaymentDetails.class, "bank_wire") .registerSubtype(WalletPaymentDetails.class, "digital_wallet"); } - + } diff --git a/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java b/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java index d3814c27..5854497b 100644 --- a/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java +++ b/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java @@ -5,6 +5,8 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonSyntaxException; import com.riskified.models.*; +// Explicit: com.riskified.models.Error would otherwise be ambiguous with java.lang.Error. +import com.riskified.models.Error; import com.riskified.validations.FieldBadFormatException; import com.riskified.validations.IValidated; import com.riskified.validations.Validation; @@ -901,22 +903,60 @@ private Response postCheckoutOrder(Object data, String url) throws IOException, response = executeClient(client, request); String postBody = EntityUtils.toString(response.getEntity(), "UTF-8"); int status = response.getStatusLine().getStatusCode(); - Response responseObject = getCheckoutResponseObject(postBody); - switch (status) { - case 200: - return responseObject; - case 400: - throw new HttpResponseException(status, responseObject.getError().getMessage()); - case 401: - throw new HttpResponseException(status, responseObject.getError().getMessage()); - case 404: - throw new HttpResponseException(status, responseObject.getError().getMessage()); - case 429: - throw new HttpResponseException(status, responseObject.getError().getMessage()); - case 504: - throw new HttpResponseException(status, "Temporary error, please retry"); - default: - throw new HttpResponseException(500, "Contact Riskified support"); + String statusText = response.getStatusLine().getReasonPhrase(); + if (status == 200) { + return getCheckoutResponseObject(postBody); + } + // Parsing happens only after the 200 check, and never decides whether the call failed. + // Previously the body was parsed first and dereferenced unguarded, so any error body that + // was not the {"error":{"message":...}} shape raised a NullPointerException in place of the + // HTTP error the caller was waiting for. + throw buildHttpException(status, statusText, postBody, tryParseCheckoutResponse(postBody)); + } + + /** + * Turns a non-2xx response into an exception that keeps the real status, the raw body and the + * parsed error, whatever shape the body turned out to be. + * + *

+ * There is deliberately no {@code default} case rewriting the status: an unmatched status is + * reported as itself. Collapsing 502, 503 and 500 into one substituted 500 with the body + * discarded is what used to make retry decisions impossible. + */ + static RiskifiedHttpException buildHttpException(int status, String statusText, String body, Response parsed) { + Error error = parsed == null ? null : parsed.getError(); + String message; + if (status == 504) { + // Documented by the CBG and Account Secure specs with no content schema. + message = "Temporary error, please retry"; + } else if (error != null && error.getMessage() != null && !error.getMessage().isEmpty()) { + message = error.getMessage(); + } else if (body != null && !body.trim().isEmpty()) { + message = body; + } else if (statusText != null && !statusText.isEmpty()) { + message = statusText; + } else { + message = "HTTP " + status; + } + return new RiskifiedHttpException(status, statusText, body, error, message); + } + + /** + * Best-effort parse of an error body into the common {@code {"error":{"message":...}}} shape. + * Returns {@code null} for any of the other six documented shapes, for a bare JSON string, and + * for an empty or malformed body. Never throws: the caller already knows the request failed and + * the raw body is preserved regardless. + */ + private static Response tryParseCheckoutResponse(String postBody) { + try { + CheckoutResponse res = new Gson().fromJson(postBody, CheckoutResponse.class); + if (res == null) { + return null; + } + res.setOrder(res.getCheckout()); + return res; + } catch (RuntimeException e) { + return null; } } @@ -1017,23 +1057,13 @@ private Response postOrder(Object data, String url) throws IOException { String postBody = EntityUtils.toString(response.getEntity()); int status = response.getStatusLine().getStatusCode(); - Response responseObject = getResponseObject(postBody); - switch (status) { - case 200: - return responseObject; - case 400: - throw new HttpResponseException(status, postBody); - case 401: - throw new HttpResponseException(status, postBody); - case 404: - throw new HttpResponseException(status, postBody); - case 429: - throw new HttpResponseException(status, postBody); - case 504: - throw new HttpResponseException(status, "Temporary error, please retry"); - default: - throw new HttpResponseException(500, "Contact Riskified support"); - } + String statusText = response.getStatusLine().getReasonPhrase(); + + if (status == 200) { + return getResponseObject(postBody); + } + // Same rule as the checkout path: report the status the server sent, keep the body. + throw buildHttpException(status, statusText, postBody, getResponseObject(postBody)); } private Response getResponseObject(String postBody) throws IOException { diff --git a/riskified-sdk/src/main/java/com/riskified/RiskifiedHttpException.java b/riskified-sdk/src/main/java/com/riskified/RiskifiedHttpException.java new file mode 100644 index 00000000..284c02b3 --- /dev/null +++ b/riskified-sdk/src/main/java/com/riskified/RiskifiedHttpException.java @@ -0,0 +1,78 @@ +package com.riskified; + +import com.riskified.models.Error; +import org.apache.http.client.HttpResponseException; + +/** + * A non-2xx response from the Riskified API, with the transport facts preserved as structured + * fields rather than flattened into a message string. + * + *

+ * It extends {@link HttpResponseException}, so every existing {@code catch (HttpResponseException)} + * keeps working and {@link #getStatusCode()} keeps returning what it always did. What is new is + * that the real status now survives: the client used to rewrite every unmatched status to + * {@code 500 "Contact Riskified support"} and discard the body, which made a 502, a 503 and a + * genuine 500 indistinguishable to a caller trying to decide whether to retry. + * + *

+ * {@link #getResponseBody()} carries the raw body as a separate field so that logging can redact + * it. Response bodies can contain customer PII, and a message string is the one place logging + * cannot reach — a defect the reference implementation has + * ({@code Riskified.SDK/Utils/HttpUtils.cs:224}) and that this SDK should not deepen. + * + *

+ * The API returns seven mutually incompatible error body shapes across its six specs + * ({@code docs/flows/00-shared-contract.md} section 6). {@link #getError()} is populated only when + * the body matched the common {@code {"error": {"message": ...}}} shape; for the other six it is + * {@code null} and the body is still available verbatim. It is never a reason to fail. + * + * @since 6.4.1 + */ +public class RiskifiedHttpException extends HttpResponseException { + + private static final long serialVersionUID = 1L; + + private final String statusText; + private final String responseBody; + private final Error error; + + /** + * @param statusCode the real HTTP status code, never a substituted one + * @param statusText the HTTP reason phrase, may be {@code null} + * @param responseBody the raw response body, may be {@code null} + * @param error the parsed error object when the body matched the common shape, else + * {@code null} + * @param message the exception message + */ + public RiskifiedHttpException(int statusCode, String statusText, String responseBody, Error error, + String message) { + super(statusCode, message); + this.statusText = statusText; + this.responseBody = responseBody; + this.error = error; + } + + /** + * @return the HTTP reason phrase, or {@code null} if the response carried none + */ + public String getStatusText() { + return statusText; + } + + /** + * @return the raw response body, or {@code null} if the response had none. Attach this to a + * structured log field, not to a message string — it can contain customer PII. + */ + public String getResponseBody() { + return responseBody; + } + + /** + * @return the parsed error, or {@code null} when the body did not match the common + * {@code {"error": {"message": ...}}} shape. A {@code null} here says nothing about + * whether the request failed. + */ + public Error getError() { + return error; + } +} diff --git a/riskified-sdk/src/main/java/com/riskified/SHA256Handler.java b/riskified-sdk/src/main/java/com/riskified/SHA256Handler.java index 7b75d576..5d00b3fd 100644 --- a/riskified-sdk/src/main/java/com/riskified/SHA256Handler.java +++ b/riskified-sdk/src/main/java/com/riskified/SHA256Handler.java @@ -5,6 +5,7 @@ import java.security.InvalidKeyException; import java.security.Key; import java.security.NoSuchAlgorithmException; +import java.nio.charset.StandardCharsets; import java.util.Formatter; public class SHA256Handler { @@ -21,7 +22,10 @@ public synchronized String createSHA256(byte[] data) throws IllegalStateExceptio } private Mac createSHA256Key(String authKey) throws RiskifiedError { - Key sk = new SecretKeySpec(authKey.getBytes(), "HmacSHA256"); + // UTF-8, never the platform default charset: the default makes the signature depend on + // a JVM locale setting. Riskified tokens are hex, so UTF-8 and the reference + // implementation's ASCII (Riskified.SDK/Utils/HttpUtils.cs:152) agree byte for byte. + Key sk = new SecretKeySpec(authKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); Mac mac; try { mac = Mac.getInstance(sk.getAlgorithm()); diff --git a/riskified-sdk/src/main/java/com/riskified/adapters/NaiveDateTypeAdapter.java b/riskified-sdk/src/main/java/com/riskified/adapters/NaiveDateTypeAdapter.java new file mode 100644 index 00000000..38734795 --- /dev/null +++ b/riskified-sdk/src/main/java/com/riskified/adapters/NaiveDateTypeAdapter.java @@ -0,0 +1,129 @@ +package com.riskified.adapters; + +import com.google.gson.JsonSyntaxException; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Gson {@link TypeAdapter} for the naive (offset-free) date fields of the Riskified wire + * contract. + * + *

+ * The contract carries two date formats and the split between them is per field, not per type: + *

    + *
  • 25 offset-bearing fields — order-level and money-related timestamps — serialize as + * {@code 2026-08-13T10:00:00+00:00}. These are handled by the {@code Date} adapter registered + * globally in {@code JSONFormater}.
  • + *
  • 13 naive fields — line-item, travel and passenger dates — serialize as + * {@code 2026-08-13T10:00:00}, with no offset. Those thirteen fields carry + * {@code @JsonAdapter(NaiveDateTypeAdapter.class)}, which takes precedence over the globally + * registered adapter.
  • + *
+ * + *

+ * The reference implementation reaches the same split by accident, through two CLR types + * ({@code DateTimeOffset?} vs {@code DateTime?}) and no configured converter. Java has a single + * {@link Date} type, so the split has to be declared field by field instead. The authoritative + * lists live in the contract corpus at {@code docs/flows/01-model-catalog.md} section 3. + * + *

+ * Both formats are rendered in UTC. Rendering in the JVM default timezone would make the + * same {@link Date} serialize differently on two machines, and the HMAC is computed over the + * serialized bytes. + * + *

+ * On read the adapter is deliberately tolerant: a naive string, an offset-bearing string, a + * date-only string, or epoch milliseconds are all accepted, because responses are not guaranteed to + * echo the format the SDK sent. + * + * @since 6.4.1 + */ +public class NaiveDateTypeAdapter extends TypeAdapter { + + /** The wire format for the 13 naive fields: no offset, no trailing {@code Z}. */ + public static final String NAIVE_PATTERN = "yyyy-MM-dd'T'HH:mm:ss"; + + private static final String[] READ_PATTERNS = { + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "yyyy-MM-dd'T'HH:mm:ssXXX", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + NAIVE_PATTERN, + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd", + }; + + /** + * A {@link SimpleDateFormat} pinned to UTC. {@code SimpleDateFormat} is not thread safe, so a + * fresh instance is created per call rather than cached in a field. + * + *

+ * {@link Locale#US} is passed explicitly: the default locale can select a non-Gregorian + * calendar (Thai Buddhist, Japanese imperial), which would render a different year for the same + * instant on a differently configured JVM — the same class of machine-dependence as the + * timezone. + * + * @param pattern the {@link SimpleDateFormat} pattern + * @return a non-lenient formatter fixed to UTC and to {@link Locale#US} + */ + public static SimpleDateFormat utcFormat(String pattern) { + SimpleDateFormat format = new SimpleDateFormat(pattern, Locale.US); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + format.setLenient(false); + return format; + } + + @Override + public void write(JsonWriter out, Date value) throws IOException { + if (value == null) { + // Leaves the deferred field name unwritten while serializeNulls is off, so a null date + // is omitted exactly as it was before this adapter existed. + out.nullValue(); + return; + } + out.value(utcFormat(NAIVE_PATTERN).format(value)); + } + + @Override + public Date read(JsonReader in) throws IOException { + JsonToken token = in.peek(); + if (token == JsonToken.NULL) { + in.nextNull(); + return null; + } + if (token == JsonToken.NUMBER) { + return new Date(in.nextLong()); + } + String raw = in.nextString(); + if (raw == null || raw.trim().isEmpty()) { + return null; + } + return parse(raw.trim()); + } + + /** + * Parses a date written in any of the formats the Riskified API is known to return. + * + * @param raw the date string, already trimmed + * @return the parsed date + * @throws JsonSyntaxException if no known format matches + */ + public static Date parse(String raw) { + for (String pattern : READ_PATTERNS) { + try { + return utcFormat(pattern).parse(raw); + } catch (ParseException ignored) { + // try the next pattern + } + } + throw new JsonSyntaxException("Unparseable date: \"" + raw + "\""); + } +} diff --git a/riskified-sdk/src/main/java/com/riskified/adapters/PaymentDetailsAdapterFactory.java b/riskified-sdk/src/main/java/com/riskified/adapters/PaymentDetailsAdapterFactory.java new file mode 100644 index 00000000..d03f8a5e --- /dev/null +++ b/riskified-sdk/src/main/java/com/riskified/adapters/PaymentDetailsAdapterFactory.java @@ -0,0 +1,82 @@ +package com.riskified.adapters; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.riskified.models.IPaymentDetails; + +import java.io.IOException; + +/** + * Serializes each element of a {@code payment_details} array as its own runtime type, and emits + * no type discriminator. + * + *

+ * {@code payment_details} carries no discriminator on the wire: the variant is expressed by which + * keys are present, with the constant {@code payment_type} acting as the de-facto discriminator + * ({@code docs/flows/01-model-catalog.md} section 6). This SDK previously used a + * {@code RuntimeTypeAdapterFactory} for the dispatch, which injected a {@code "method"} key with + * values that disagreed with the {@code payment_type} emitted alongside it. + * + *

+ * Why a factory is still needed at all. Gson normally dispatches on an element's runtime type + * by itself, via {@code TypeAdapterRuntimeTypeWrapper}. It does not here: {@code BaseOrder} and + * {@code DecisionOrder} declare the field as {@code List}, and Gson's + * runtime-type promotion fires only when the declared element type is a {@code Class} — a + * {@code WildcardType} is left alone. Without this factory the reflective adapter for the bare + * interface runs instead and every element serializes as {@code {}}: total, silent loss of the + * payment details. Dropping the old factory without replacing it is therefore not a safe no-op. + * + *

+ * Only the interface itself is matched, so the delegate lookup for the concrete class cannot re-enter + * this factory. + * + * @since 6.4.1 + */ +public class PaymentDetailsAdapterFactory implements TypeAdapterFactory { + + @Override + @SuppressWarnings("unchecked") + public TypeAdapter create(Gson gson, TypeToken type) { + // Matched by raw type on purpose: the declared element type is a WildcardType + // (? extends IPaymentDetails), not IPaymentDetails.class, so TypeToken equality misses it. + if (type.getRawType() != IPaymentDetails.class) { + return null; + } + return (TypeAdapter) new PaymentDetailsTypeAdapter(gson); + } + + private static class PaymentDetailsTypeAdapter extends TypeAdapter { + + private final Gson gson; + + PaymentDetailsTypeAdapter(Gson gson) { + this.gson = gson; + } + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void write(JsonWriter out, IPaymentDetails value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + TypeAdapter runtimeAdapter = gson.getAdapter(TypeToken.get(value.getClass())); + runtimeAdapter.write(out, value); + } + + @Override + public IPaymentDetails read(JsonReader in) throws IOException { + // No response in the contract carries payment_details, and with no discriminator on the + // wire the variant is not recoverable from the keys alone. Deserialize the concrete + // class instead of the interface. + throw new JsonParseException("payment_details cannot be deserialized through the " + + "IPaymentDetails interface: it carries no type discriminator. Deserialize a " + + "concrete payment-details class instead."); + } + } +} diff --git a/riskified-sdk/src/main/java/com/riskified/models/AccommodationLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/AccommodationLineItem.java index 2f97435c..1b88d74c 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/AccommodationLineItem.java +++ b/riskified-sdk/src/main/java/com/riskified/models/AccommodationLineItem.java @@ -1,5 +1,9 @@ package com.riskified.models; +import com.google.gson.annotations.JsonAdapter; + +import com.riskified.adapters.NaiveDateTypeAdapter; + import java.util.Date; import com.riskified.validations.*; @@ -10,7 +14,11 @@ public class AccommodationLineItem extends LineItem { private String roomType; private String city; private String countryCode; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date checkInDate; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date checkOutDate; private String rating; private Integer numberOfGuests; diff --git a/riskified-sdk/src/main/java/com/riskified/models/AuthenticationResult.java b/riskified-sdk/src/main/java/com/riskified/models/AuthenticationResult.java index 098d277b..f5273350 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/AuthenticationResult.java +++ b/riskified-sdk/src/main/java/com/riskified/models/AuthenticationResult.java @@ -1,5 +1,7 @@ package com.riskified.models; +import com.riskified.adapters.NaiveDateTypeAdapter; + import java.util.Date; import com.google.gson.annotations.JsonAdapter; @@ -14,6 +16,8 @@ public class AuthenticationResult implements IValidated { private String eci; private String cavv; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date createdAt; private TransStatus transStatus; private TransStatusReason transStatusReason; diff --git a/riskified-sdk/src/main/java/com/riskified/models/EventLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/EventLineItem.java index c9232cea..11587bc6 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/EventLineItem.java +++ b/riskified-sdk/src/main/java/com/riskified/models/EventLineItem.java @@ -1,5 +1,9 @@ package com.riskified.models; +import com.google.gson.annotations.JsonAdapter; + +import com.riskified.adapters.NaiveDateTypeAdapter; + import java.util.Date; public class EventLineItem extends LineItem { @@ -9,6 +13,8 @@ public class EventLineItem extends LineItem { private String city; private float latitude; private float longitude; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date eventDate; public EventLineItem(double price, int quantity, String title, diff --git a/riskified-sdk/src/main/java/com/riskified/models/LineItem.java b/riskified-sdk/src/main/java/com/riskified/models/LineItem.java index 037abfff..257f8995 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/LineItem.java +++ b/riskified-sdk/src/main/java/com/riskified/models/LineItem.java @@ -1,5 +1,9 @@ package com.riskified.models; +import com.google.gson.annotations.JsonAdapter; + +import com.riskified.adapters.NaiveDateTypeAdapter; + import java.util.*; import java.util.jar.Attributes; @@ -32,6 +36,8 @@ public class LineItem implements IValidated { private String brand; private String productType; private String size; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date deliveredAt; private String deliveredTo; private String color; diff --git a/riskified-sdk/src/main/java/com/riskified/models/Login.java b/riskified-sdk/src/main/java/com/riskified/models/Login.java index 4bf1fee4..2114c1e6 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/Login.java +++ b/riskified-sdk/src/main/java/com/riskified/models/Login.java @@ -1,5 +1,9 @@ package com.riskified.models; +import com.google.gson.annotations.JsonAdapter; + +import com.riskified.adapters.NaiveDateTypeAdapter; + import com.riskified.validations.*; import java.util.Date; @@ -7,6 +11,8 @@ public class Login implements IValidated { private String customerId; private String email; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date customerCreatedAt; private Boolean loginAtCheckout; private SocialType socialLoginType; diff --git a/riskified-sdk/src/main/java/com/riskified/models/Passenger.java b/riskified-sdk/src/main/java/com/riskified/models/Passenger.java index afccf335..db15224f 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/Passenger.java +++ b/riskified-sdk/src/main/java/com/riskified/models/Passenger.java @@ -1,5 +1,9 @@ package com.riskified.models; +import com.google.gson.annotations.JsonAdapter; + +import com.riskified.adapters.NaiveDateTypeAdapter; + import java.util.Date; import com.riskified.validations.*; @@ -8,13 +12,19 @@ public class Passenger implements IValidated { private String firstName; private String lastName; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date dateOfBirth; private String nationalityCode; private String insuranceType; private float insurancePrice; private String documentNumber; private String documentType; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date documentIssueDate; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date documentExpirationDate; private String passengerType; diff --git a/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java index d8c69c01..f71db8b6 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java +++ b/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java @@ -1,5 +1,10 @@ package com.riskified.models; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; + +import com.riskified.adapters.NaiveDateTypeAdapter; + import java.lang.reflect.Field; import java.util.Date; @@ -8,11 +13,21 @@ public class RideLineItem extends LineItem { // Ride Industry fields + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date pickupDate; private Float pickupLatitude; private Float pickupLongitude; private Address pickupAddress; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date dropoffDate; + // The live wire name is the transposed "dropoff_latitiude", not "dropoff_latitude". + // This is an upstream typo the Riskified API expects: the reference C# SDK sends it + // (OrderElements/RideTicketLineItem.cs:101) and the service reads it. "Correcting" the + // spelling silently drops ride dropoff geolocation with no error anywhere. + // DO NOT "FIX" THE SPELLING. See docs/flows/01-model-catalog.md:289. + @SerializedName("dropoff_latitiude") private Float dropoffLatitude; private Float dropoffLongitude; private Address dropoffAddress; diff --git a/riskified-sdk/src/main/java/com/riskified/models/TravelLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/TravelLineItem.java index e96efcd2..b536e097 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/TravelLineItem.java +++ b/riskified-sdk/src/main/java/com/riskified/models/TravelLineItem.java @@ -1,5 +1,9 @@ package com.riskified.models; +import com.google.gson.annotations.JsonAdapter; + +import com.riskified.adapters.NaiveDateTypeAdapter; + import java.util.Date; import com.riskified.validations.*; @@ -10,7 +14,11 @@ public class TravelLineItem extends LineItem { private int legIndex; private String departurePortCode; private String arrivalPortCode; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date departureDate; + // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. + @JsonAdapter(NaiveDateTypeAdapter.class) private Date arrivalDate; private String departureCountryCode; private String arrivalCountryCode; diff --git a/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java b/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java new file mode 100644 index 00000000..8ccd7c80 --- /dev/null +++ b/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java @@ -0,0 +1,318 @@ +package com.riskified; + +import com.google.gson.annotations.JsonAdapter; +import com.riskified.adapters.NaiveDateTypeAdapter; +import com.riskified.models.AccommodationLineItem; +import com.riskified.models.AccountBalance; +import com.riskified.models.AuthenticationResult; +import com.riskified.models.AuthorizationError; +import com.riskified.models.BaseOrder; +import com.riskified.models.CancelOrder; +import com.riskified.models.ChargebackDetails; +import com.riskified.models.CreditCardPaymentDetails; +import com.riskified.models.Customer; +import com.riskified.models.DecisionDetails; +import com.riskified.models.DisputeDetails; +import com.riskified.models.EventLineItem; +import com.riskified.models.FulfillmentDetails; +import com.riskified.models.KycDetails; +import com.riskified.models.LineItem; +import com.riskified.models.Login; +import com.riskified.models.Order; +import com.riskified.models.Passenger; +import com.riskified.models.RefundDetails; +import com.riskified.models.RideLineItem; +import com.riskified.models.SessionDetails; +import com.riskified.models.TravelLineItem; +import com.riskified.models.WalletPaymentDetails; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * The date split. + * + *

+ * The contract carries two date formats. 25 fields are offset-bearing + * ({@code 2026-08-13T10:00:00+00:00}) and 13 are naive, with no offset at all + * ({@code 2026-08-13T10:00:00}). The reference implementation reaches the split through two CLR + * types and no configured converter, so the split follows no principle and has to be reproduced + * field by field. Both lists are in {@code docs/flows/01-model-catalog.md} section 3. + * + *

+ * Java has a single {@link Date}, so a single registered type adapter cannot express the split. The + * 13 naive fields carry {@code @JsonAdapter(NaiveDateTypeAdapter.class)}, which Gson gives + * precedence over the globally registered adapter; everything else takes the offset format. + */ +public class DateSplitTest { + + /** 1970-01-01T00:00:00Z plus 10h — a fixed instant, so the expected strings are literals. */ + private static final Date FIXED_INSTANT = new Date(36000000L); + private static final String EXPECTED_OFFSET_RENDERING = "1970-01-01T10:00:00+00:00"; + private static final String EXPECTED_NAIVE_RENDERING = "1970-01-01T10:00:00"; + + private TimeZone originalTimeZone; + + @Before + public void setUp() { + originalTimeZone = TimeZone.getDefault(); + } + + @After + public void tearDown() { + TimeZone.setDefault(originalTimeZone); + } + + /** + * One offset-bearing field and one naive field in the same payload — the case the fleet report + * asks for as the first golden fixture. + */ + @Test + public void testOneOffsetFieldAndOneNaiveFieldInTheSamePayload() { + Order inputOrder = new Order(); + inputOrder.setId("ORDER-1"); + inputOrder.setCreatedAt(FIXED_INSTANT); + RideLineItem inputRide = new RideLineItem(42.5, 1, "Airport transfer", FIXED_INSTANT, 0, 0); + List inputLineItems = new ArrayList(); + inputLineItems.add(inputRide); + inputOrder.setLineItems(inputLineItems); + + String actualJson = JSONFormater.toJson(inputOrder); + + // created_at is one of the 25 — offset present. + assertTrue(actualJson, actualJson.contains("\"created_at\":\"" + EXPECTED_OFFSET_RENDERING + "\"")); + // pickup_date is one of the 13 — no offset, and no trailing Z either. + assertTrue(actualJson, actualJson.contains("\"pickup_date\":\"" + EXPECTED_NAIVE_RENDERING + "\"")); + assertFalse("pickup_date must not carry an offset: " + actualJson, + actualJson.contains("\"pickup_date\":\"" + EXPECTED_OFFSET_RENDERING + "\"")); + assertFalse("no naive field may be rendered with a trailing Z: " + actualJson, + actualJson.contains("\"pickup_date\":\"" + EXPECTED_NAIVE_RENDERING + "Z\"")); + } + + /** + * The same {@link Date} must serialize identically whatever the JVM default timezone is. It did + * not before: the formatter used the default timezone, so two machines produced two different + * payloads — and two different HMACs — for the same object. + */ + @Test + public void testSerializationIsIndependentOfTheJvmDefaultTimezone() { + Order inputOrder = new Order(); + inputOrder.setCreatedAt(FIXED_INSTANT); + RideLineItem inputRide = new RideLineItem(1.0, 1, "Ride", FIXED_INSTANT, 0, 0); + List inputLineItems = new ArrayList(); + inputLineItems.add(inputRide); + inputOrder.setLineItems(inputLineItems); + + TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Kiritimati")); // UTC+14 + String actualJsonFarEast = JSONFormater.toJson(inputOrder); + TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Niue")); // UTC-11 + String actualJsonFarWest = JSONFormater.toJson(inputOrder); + + assertEquals(actualJsonFarEast, actualJsonFarWest); + assertTrue(actualJsonFarEast, + actualJsonFarEast.contains("\"created_at\":\"" + EXPECTED_OFFSET_RENDERING + "\"")); + assertTrue(actualJsonFarEast, + actualJsonFarEast.contains("\"pickup_date\":\"" + EXPECTED_NAIVE_RENDERING + "\"")); + } + + /** + * All 13 naive fields of {@code docs/flows/01-model-catalog.md} section 3, by declaring class + * and Java field name. Two of the 25 offset-bearing fields have no Java counterpart at all and + * are recorded in {@link #testOffsetBearingFieldsCarryNoNaiveAdapter()}. + */ + private static List naiveFields() { + return Arrays.asList(new Object[][] { + // LineItem.cs:167 delivered_at + { LineItem.class, "deliveredAt" }, + // AccommodationLineItem.cs:74 check_in_date, :77 check_out_date + { AccommodationLineItem.class, "checkInDate" }, + { AccommodationLineItem.class, "checkOutDate" }, + // EventTicketLineItem.cs:81 event_date + { EventLineItem.class, "eventDate" }, + // RideTicketLineItem.cs:86 pickup_date, :98 dropoff_date + { RideLineItem.class, "pickupDate" }, + { RideLineItem.class, "dropoffDate" }, + // TravelTicketLineItem.cs:125 departure_date, :131 arrival_date + { TravelLineItem.class, "departureDate" }, + { TravelLineItem.class, "arrivalDate" }, + // Passenger.cs:54 date_of_birth, :72 document_issue_date, :75 document_expiration_date + { Passenger.class, "dateOfBirth" }, + { Passenger.class, "documentIssueDate" }, + { Passenger.class, "documentExpirationDate" }, + // AuthenticationResult.cs:41 created_at + { AuthenticationResult.class, "createdAt" }, + // Login.cs:21 customer_created_at + { Login.class, "customerCreatedAt" }, + }); + } + + /** + * The 23 offset-bearing fields that have a Java counterpart. + * + *

+ * Two of the contract's 25 are not modelled by this SDK at all — + * {@code Customer.verified_email_at} ({@code Customer.cs:143}) and + * {@code Customer.first_purchase_at} ({@code Customer.cs:167}). They are missing fields, not + * misformatted ones, and are out of scope here; when they are added they must take the offset + * format, which is the default, so no annotation is needed. + */ + private static List offsetBearingFields() { + return Arrays.asList(new Object[][] { + // OrderBase.cs:35 closed_at, :41 created_at, :80 updated_at + { BaseOrder.class, "closedAt" }, + { BaseOrder.class, "createdAt" }, + { BaseOrder.class, "updatedAt" }, + // OrderCancellation.cs:53 cancelled_at. Java also declares cancelled_at on the order + // base, which .NET does not; it takes the same format. + { CancelOrder.class, "cancelledAt" }, + { BaseOrder.class, "cancelledAt" }, + // Customer.cs:113 created_at, :116 updated_at, :149 verified_phone_at, :182 date_of_birth + { Customer.class, "createdAt" }, + { Customer.class, "updatedAt" }, + { Customer.class, "verifiedPhoneAt" }, + { Customer.class, "dateOfBirth" }, + // CreditCardPaymentDetails.cs:110/:113, WalletPaymentDetails.cs:106/:109 + { CreditCardPaymentDetails.class, "storedPaymentCreatedAt" }, + { CreditCardPaymentDetails.class, "storedPaymentUpdatedAt" }, + { WalletPaymentDetails.class, "storedPaymentCreatedAt" }, + { WalletPaymentDetails.class, "storedPaymentUpdatedAt" }, + // ChargebackDetails.cs:65 chargeback_at, :123 respond_by + { ChargebackDetails.class, "chargebackAt" }, + { ChargebackDetails.class, "respondBy" }, + // DisputeDetails.cs:56 disputed_at, :62 expected_resolution_date + { DisputeDetails.class, "disputedAt" }, + { DisputeDetails.class, "expectedResolutionDate" }, + // AuthorizationError.cs:38 created_at + { AuthorizationError.class, "createdAt" }, + // AccountBalance.cs:51 updated_at + { AccountBalance.class, "updatedAt" }, + // DecisionDetails.cs:53 decided_at + { DecisionDetails.class, "decidedAt" }, + // FulfillmentDetails.cs:57 created_at + { FulfillmentDetails.class, "createdAt" }, + // KycDetails.cs:18 updated_at. NOTE: the Java field is named updateAt, so it derives + // the wire key update_at rather than updated_at — a separate, unfixed divergence. + { KycDetails.class, "updateAt" }, + // SessionDetails.cs:18 created_at + { SessionDetails.class, "createdAt" }, + // PartialRefundDetails.cs:42 refunded_at + { RefundDetails.class, "refundedAt" }, + }); + } + + /** Every one of the 13 naive fields carries the naive adapter. */ + @Test + public void testAllThirteenNaiveFieldsCarryTheNaiveAdapter() throws NoSuchFieldException { + List inputFields = naiveFields(); + + assertEquals("the contract lists exactly 13 naive fields", 13, inputFields.size()); + for (Object[] inputField : inputFields) { + Class declaringClass = (Class) inputField[0]; + String fieldName = (String) inputField[1]; + Field actualField = declaringClass.getDeclaredField(fieldName); + assertEquals(Date.class, actualField.getType()); + JsonAdapter actualAnnotation = actualField.getAnnotation(JsonAdapter.class); + assertNotNull(declaringClass.getSimpleName() + "." + fieldName + + " is a naive date field and must carry @JsonAdapter(NaiveDateTypeAdapter.class)", + actualAnnotation); + assertEquals(declaringClass.getSimpleName() + "." + fieldName, + NaiveDateTypeAdapter.class, actualAnnotation.value()); + } + } + + /** None of the offset-bearing fields carries it — they fall through to the global adapter. */ + @Test + public void testOffsetBearingFieldsCarryNoNaiveAdapter() throws NoSuchFieldException { + List inputFields = offsetBearingFields(); + + // 23 of the contract's 25, plus one field Java declares that .NET does not + // (cancelled_at on the order base, where .NET has it only on OrderCancellation). + assertEquals(24, inputFields.size()); + for (Object[] inputField : inputFields) { + Class declaringClass = (Class) inputField[0]; + String fieldName = (String) inputField[1]; + Field actualField = declaringClass.getDeclaredField(fieldName); + assertEquals(Date.class, actualField.getType()); + JsonAdapter actualAnnotation = actualField.getAnnotation(JsonAdapter.class); + assertFalse(declaringClass.getSimpleName() + "." + fieldName + + " is offset-bearing and must not use the naive adapter", + actualAnnotation != null && actualAnnotation.value() == NaiveDateTypeAdapter.class); + } + } + + /** + * No {@link Date} field anywhere in the model package carries the naive adapter unless it is one + * of the 13. Without this, a future field could pick up the annotation by copy-paste and diverge + * in the direction the reflective test above cannot see. + */ + @Test + public void testOnlyTheThirteenListedFieldsUseTheNaiveAdapter() { + List expectedAnnotated = new ArrayList(); + for (Object[] naiveField : naiveFields()) { + expectedAnnotated.add(((Class) naiveField[0]).getName() + "#" + naiveField[1]); + } + + List actualAnnotated = new ArrayList(); + for (Class modelClass : modelClassesWithDateFields()) { + for (Field field : modelClass.getDeclaredFields()) { + if (field.getType() != Date.class) { + continue; + } + JsonAdapter annotation = field.getAnnotation(JsonAdapter.class); + if (annotation != null && annotation.value() == NaiveDateTypeAdapter.class) { + actualAnnotated.add(modelClass.getName() + "#" + field.getName()); + } + } + } + + java.util.Collections.sort(expectedAnnotated); + java.util.Collections.sort(actualAnnotated); + assertEquals(expectedAnnotated, actualAnnotated); + } + + /** + * Every model class in this SDK that declares a {@link Date} field. Reflection cannot enumerate + * a package, so the list is explicit — which is the point: adding a {@link Date} field to a new + * class is a moment to decide which of the two formats it takes. + */ + private static List> modelClassesWithDateFields() { + return Arrays.> asList( + AccommodationLineItem.class, AccountBalance.class, AuthenticationResult.class, + AuthorizationError.class, com.riskified.models.BankWirePaymentDetails.class, + BaseOrder.class, CancelOrder.class, ChargebackDetails.class, + CreditCardPaymentDetails.class, Customer.class, DecisionDetails.class, + DisputeDetails.class, EventLineItem.class, FulfillmentDetails.class, + KycDetails.class, LineItem.class, Login.class, Passenger.class, + com.riskified.models.Recipient.class, RefundDetails.class, RideLineItem.class, + SessionDetails.class, TravelLineItem.class, com.riskified.models.Verification.class, + com.riskified.models.VerificationData.class, WalletPaymentDetails.class); + } + + /** A naive date read back yields the instant it was written from. */ + @Test + public void testNaiveDateRoundTrips() { + Date actualParsed = NaiveDateTypeAdapter.parse(EXPECTED_NAIVE_RENDERING); + + assertEquals(FIXED_INSTANT, actualParsed); + } + + /** An offset-bearing string is still readable by the naive adapter — responses may send either. */ + @Test + public void testNaiveAdapterAlsoReadsOffsetBearingStrings() { + Date actualParsed = NaiveDateTypeAdapter.parse("1970-01-01T12:00:00+02:00"); + + assertEquals(FIXED_INSTANT, actualParsed); + } +} diff --git a/riskified-sdk/src/test/java/com/riskified/RiskifiedClientErrorTest.java b/riskified-sdk/src/test/java/com/riskified/RiskifiedClientErrorTest.java new file mode 100644 index 00000000..d479646c --- /dev/null +++ b/riskified-sdk/src/test/java/com/riskified/RiskifiedClientErrorTest.java @@ -0,0 +1,174 @@ +package com.riskified; + +import com.google.gson.Gson; +import com.riskified.models.CheckoutResponse; +import com.riskified.models.Response; +import org.apache.http.client.HttpResponseException; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Error-mapping regression tests. + * + *

+ * Two defects are covered. The client used to rewrite every unmatched status to + * {@code 500 "Contact Riskified support"} and throw the body away, so a 503, a 502 and a genuine 500 + * were indistinguishable to a caller deciding whether to retry. And the checkout path read + * {@code responseObject.getError().getMessage()} before establishing that the body had parsed as + * that shape at all, so any of the other six documented error shapes + * ({@code docs/flows/00-shared-contract.md} section 6) produced a {@link NullPointerException} in + * place of the HTTP error. + */ +public class RiskifiedClientErrorTest { + + /** Parses an error body the way the checkout path does: best effort, never throwing. */ + private static Response parseCheckoutBody(String body) { + try { + CheckoutResponse parsed = new Gson().fromJson(body, CheckoutResponse.class); + if (parsed == null) { + return null; + } + parsed.setOrder(parsed.getCheckout()); + return parsed; + } catch (RuntimeException e) { + return null; + } + } + + @Test + public void testUnmatchedStatusKeepsItsOwnStatusCode() { + RiskifiedHttpException actual503 = + RiskifiedClient.buildHttpException(503, "Service Unavailable", "upstream down", null); + RiskifiedHttpException actual502 = + RiskifiedClient.buildHttpException(502, "Bad Gateway", "bad gateway", null); + RiskifiedHttpException actual500 = + RiskifiedClient.buildHttpException(500, "Internal Server Error", "boom", null); + + assertEquals(503, actual503.getStatusCode()); + assertEquals(502, actual502.getStatusCode()); + assertEquals(500, actual500.getStatusCode()); + assertEquals("Service Unavailable", actual503.getStatusText()); + } + + @Test + public void testUnmatchedStatusKeepsTheResponseBody() { + String inputBody = "502 Bad Gateway"; + + RiskifiedHttpException actualException = + RiskifiedClient.buildHttpException(502, "Bad Gateway", inputBody, null); + + assertEquals(inputBody, actualException.getResponseBody()); + assertNull("a non-JSON body parses to no error object, and that is not a failure", + actualException.getError()); + } + + @Test + public void testUnmatchedStatusIsNoLongerRewrittenTo500() { + RiskifiedHttpException actualException = + RiskifiedClient.buildHttpException(429, "Too Many Requests", "slow down", null); + + assertTrue(actualException instanceof HttpResponseException); + assertEquals(429, actualException.getStatusCode()); + assertNotEquals("Contact Riskified support", actualException.getReasonPhrase()); + } + + /** Shape A — the one shape the SDK parses. The message comes from the parsed error. */ + @Test + public void testShapeAErrorBodyYieldsTheParsedMessage() { + String inputBody = "{\"error\":{\"message\":\"order id is missing\",\"code\":\"invalid\"}}"; + + RiskifiedHttpException actualException = + RiskifiedClient.buildHttpException(400, "Bad Request", inputBody, parseCheckoutBody(inputBody)); + + assertEquals(400, actualException.getStatusCode()); + assertNotNull(actualException.getError()); + assertEquals("order id is missing", actualException.getError().getMessage()); + assertEquals("order id is missing", actualException.getReasonPhrase()); + assertEquals(inputBody, actualException.getResponseBody()); + } + + /** + * Shape B — a flat {@code {"message": ...}} body, used by the Policy specs. Parses to no + * {@code error} object; previously this was the {@link NullPointerException}. + */ + @Test + public void testFlatMessageErrorBodyDoesNotThrow() { + String inputBody = "{\"message\":\"JSON malformed - missing 'claim_reason' field\"}"; + + RiskifiedHttpException actualException = + RiskifiedClient.buildHttpException(400, "Bad Request", inputBody, parseCheckoutBody(inputBody)); + + assertEquals(400, actualException.getStatusCode()); + assertNull(actualException.getError()); + assertEquals(inputBody, actualException.getResponseBody()); + assertEquals(inputBody, actualException.getReasonPhrase()); + } + + /** Shape G — a bare JSON string rather than an object, which the OTP spec returns on 400/403/500. */ + @Test + public void testBareStringErrorBodyDoesNotThrow() { + String inputBody = "\"invalid phone number\""; + + RiskifiedHttpException actualException = + RiskifiedClient.buildHttpException(400, "Bad Request", inputBody, parseCheckoutBody(inputBody)); + + assertEquals(400, actualException.getStatusCode()); + assertNull(actualException.getError()); + assertEquals(inputBody, actualException.getResponseBody()); + } + + /** Shape C/E — {@code statusCode} arrives as a JSON number under a schema that declares a string. */ + @Test + public void testErrorWithCodeBodyDoesNotThrow() { + String inputBody = "{\"statusCode\":429,\"message\":\"Too many requests\"}"; + + RiskifiedHttpException actualException = + RiskifiedClient.buildHttpException(429, "Too Many Requests", inputBody, parseCheckoutBody(inputBody)); + + assertEquals(429, actualException.getStatusCode()); + assertNull(actualException.getError()); + assertEquals(inputBody, actualException.getResponseBody()); + } + + /** Shape D — {@code {"messages": [...]}}, returns spec only. */ + @Test + public void testMessagesArrayErrorBodyDoesNotThrow() { + String inputBody = "{\"messages\":[\"a\",\"b\"]}"; + + RiskifiedHttpException actualException = + RiskifiedClient.buildHttpException(400, "Bad Request", inputBody, parseCheckoutBody(inputBody)); + + assertEquals(400, actualException.getStatusCode()); + assertNull(actualException.getError()); + } + + /** An empty body, and a malformed one, are both survivable. */ + @Test + public void testEmptyAndMalformedBodiesDoNotThrow() { + RiskifiedHttpException actualEmpty = + RiskifiedClient.buildHttpException(500, "Internal Server Error", "", parseCheckoutBody("")); + RiskifiedHttpException actualMalformed = + RiskifiedClient.buildHttpException(500, "Internal Server Error", "{not json", + parseCheckoutBody("{not json")); + + assertEquals(500, actualEmpty.getStatusCode()); + assertEquals("Internal Server Error", actualEmpty.getReasonPhrase()); + assertEquals(500, actualMalformed.getStatusCode()); + assertEquals("{not json", actualMalformed.getResponseBody()); + } + + /** 504 is documented with no content schema, so its message stays the fixed retry hint. */ + @Test + public void test504KeepsItsDocumentedRetryMessage() { + RiskifiedHttpException actualException = + RiskifiedClient.buildHttpException(504, "Gateway Timeout", "", null); + + assertEquals(504, actualException.getStatusCode()); + assertEquals("Temporary error, please retry", actualException.getReasonPhrase()); + } +} diff --git a/riskified-sdk/src/test/java/com/riskified/SHA256HandlerTest.java b/riskified-sdk/src/test/java/com/riskified/SHA256HandlerTest.java new file mode 100644 index 00000000..5948f56d --- /dev/null +++ b/riskified-sdk/src/test/java/com/riskified/SHA256HandlerTest.java @@ -0,0 +1,90 @@ +package com.riskified; + +import org.junit.Test; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +/** + * HMAC regression tests. + * + *

+ * The signature is a function of the request body and the auth token alone — no timestamp, no + * nonce, no method, no path, no headers — and it is lowercase hex. See + * {@code docs/flows/00-shared-contract.md} section 1. + */ +public class SHA256HandlerTest { + + /** {@code docs/flows/00-shared-contract.md} section 1, "Reference test vector". */ + private static final String REFERENCE_TOKEN = "test_token"; + private static final String REFERENCE_BODY = "{\"order\":{\"id\":\"TEST-1\"}}"; + private static final String REFERENCE_HMAC = + "b07f97d1466dfe33f74c2c006e16cc3d17d97c7f337a96eaaaff35ce20a83131"; + + /** + * The corpus reference vector. A match proves the key encoding, the message encoding, the hex + * casing and the body-only rule all at once. + */ + @Test + public void testReferenceVectorReproduces() throws RiskifiedError { + SHA256Handler inputHandler = new SHA256Handler(REFERENCE_TOKEN); + + String actualHmac = inputHandler.createSHA256(REFERENCE_BODY.getBytes(StandardCharsets.UTF_8)); + + assertEquals(REFERENCE_HMAC, actualHmac); + } + + /** + * The key is encoded as UTF-8, not with the platform default charset. + * + *

+ * For the hex tokens Riskified issues this changes nothing — that is exactly why the old + * {@code authKey.getBytes()} went unnoticed. The point of pinning it is that the signature must + * not depend on a JVM locale setting, so this asserts the encoding directly for a token whose + * bytes differ between charsets. + */ + @Test + public void testKeyIsEncodedAsUtf8NotThePlatformDefault() throws Exception { + String inputNonAsciiToken = "t\u00f6k\u00e9n-\u00e9\u00e0"; + SHA256Handler inputHandler = new SHA256Handler(inputNonAsciiToken); + + String actualHmac = inputHandler.createSHA256(REFERENCE_BODY.getBytes(StandardCharsets.UTF_8)); + String expectedUtf8KeyHmac = hmacWithKeyBytes(inputNonAsciiToken.getBytes(StandardCharsets.UTF_8)); + String latin1KeyHmac = hmacWithKeyBytes(inputNonAsciiToken.getBytes(StandardCharsets.ISO_8859_1)); + + assertEquals(expectedUtf8KeyHmac, actualHmac); + // Guards the test itself: if these two agreed, the assertion above would prove nothing. + assertNotEquals(expectedUtf8KeyHmac, latin1KeyHmac); + assertEquals(64, actualHmac.length()); + assertEquals(actualHmac.toLowerCase(Locale.US), actualHmac); + } + + /** An independent HMAC-SHA256 implementation, so the assertions above are not self-referential. */ + private static String hmacWithKeyBytes(byte[] keyBytes) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(keyBytes, "HmacSHA256")); + byte[] digest = mac.doFinal(REFERENCE_BODY.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(String.format(Locale.US, "%02x", b)); + } + return hex.toString(); + } + + /** A trailing newline changes the digest — the byte-identity rule, demonstrated. */ + @Test + public void testTrailingNewlineChangesTheDigest() throws RiskifiedError { + SHA256Handler inputHandler = new SHA256Handler(REFERENCE_TOKEN); + + String actualHmac = + inputHandler.createSHA256((REFERENCE_BODY + "\n").getBytes(StandardCharsets.UTF_8)); + + assertEquals(64, actualHmac.length()); + assertNotEquals(REFERENCE_HMAC, actualHmac); + } +} diff --git a/riskified-sdk/src/test/java/com/riskified/models/PaymentDetailsTest.java b/riskified-sdk/src/test/java/com/riskified/models/PaymentDetailsTest.java index e05253ff..aa44fe05 100644 --- a/riskified-sdk/src/test/java/com/riskified/models/PaymentDetailsTest.java +++ b/riskified-sdk/src/test/java/com/riskified/models/PaymentDetailsTest.java @@ -162,19 +162,72 @@ public void testWalletValidateRejectsBadAcquirerRegion() throws FieldBadFormatEx wallet.validate(Validation.ALL); } + /** + * {@code payment_details} carries no type discriminator on the wire. + * + *

+ * The variant is expressed by which keys are present, with the constant {@code payment_type} + * acting as the de-facto discriminator — see {@code docs/flows/01-model-catalog.md} section 6. + * This SDK used to register a {@code RuntimeTypeAdapterFactory} that injected a {@code "method"} + * key whose values ({@code credit_card}, {@code bank_wire}, {@code digital_wallet}) disagreed + * with the {@code payment_type} emitted right next to it. No other SDK in the fleet sent it. + * Unrecognised keys are dropped rather than rejected, which is why it went unnoticed. + */ @Test - public void testWalletSerializesWithMethodDiscriminator() { - Gson polymorphicGson = new GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .registerTypeAdapterFactory(JSONFormater.paymentDetailsSerializer()) - .create(); + public void testPaymentDetailsCarryNoMethodDiscriminator() { + List inputPaymentDetails = new ArrayList(); + inputPaymentDetails.add(new WalletPaymentDetails(PaymentType.WECHAT_PAY, "12345", "X")); + inputPaymentDetails.add(new CreditCardPaymentDetails("411111", "Y", "M", "XXXX-1234", "Visa")); + inputPaymentDetails.add(new BankWirePaymentDetails("123456789", "021000021")); + inputPaymentDetails.add(new PaypalPaymentDetails("buyer@example.com", "verified", "confirmed", "eligible")); + // Java-only variant, present in no other SDK and in no spec. Kept in place deliberately; it + // must simply stop emitting "method" like every other variant. + inputPaymentDetails.add(new StripePaymentDetails("auth-1")); + + String actualJson = JSONFormater.toJson(inputPaymentDetails); + + assertFalse("payment_details must carry no type discriminator: " + actualJson, + actualJson.contains("\"method\"")); + assertFalse(actualJson, actualJson.contains("digital_wallet")); + assertFalse(actualJson, actualJson.contains("bank_wire")); + } + + /** + * Dropping the discriminator must not cost the concrete fields. Gson dispatches on each + * element's runtime type inside a {@code List}, so the variant-specific keys — + * and the {@code payment_type} that actually identifies the variant — still ship. + */ + @Test + public void testEachVariantStillSerializesItsOwnPaymentType() { + List inputPaymentDetails = new ArrayList(); + inputPaymentDetails.add(new WalletPaymentDetails(PaymentType.WECHAT_PAY, "12345", "X")); + inputPaymentDetails.add(new CreditCardPaymentDetails("411111", "Y", "M", "XXXX-1234", "Visa")); + inputPaymentDetails.add(new BankWirePaymentDetails("123456789", "021000021")); + inputPaymentDetails.add(new PaypalPaymentDetails("buyer@example.com", "verified", "confirmed", "eligible")); + + String actualJson = JSONFormater.toJson(inputPaymentDetails); - List paymentDetails = new ArrayList(); - paymentDetails.add(new WalletPaymentDetails(PaymentType.WECHAT_PAY, "12345", "X")); + assertTrue(actualJson, actualJson.contains("\"payment_type\":\"wechat_pay\"")); + assertTrue(actualJson, actualJson.contains("\"payment_type\":\"card\"")); + assertTrue(actualJson, actualJson.contains("\"payment_type\":\"bank_transfer\"")); + assertTrue(actualJson, actualJson.contains("\"payment_type\":\"paypal\"")); + assertTrue(actualJson, actualJson.contains("\"credit_card_bin\":\"411111\"")); + assertTrue(actualJson, actualJson.contains("\"routing_number\":\"021000021\"")); + assertTrue(actualJson, actualJson.contains("\"authorization_id\":\"12345\"")); + } + + /** The order payload itself must not carry the injected key either. */ + @Test + public void testOrderPayloadCarriesNoMethodDiscriminator() { + List inputPaymentDetails = new ArrayList(); + inputPaymentDetails.add(new CreditCardPaymentDetails("411111", "Y", "M", "XXXX-1234", "Visa")); + Order inputOrder = new Order(); + inputOrder.setPaymentDetails(inputPaymentDetails); - String json = polymorphicGson.toJson(paymentDetails, new TypeToken>() {}.getType()); + String actualJson = JSONFormater.toJson(inputOrder); - assertTrue(json.contains("\"method\":\"digital_wallet\"")); - assertTrue(json.contains("\"payment_type\":\"wechat_pay\"")); + assertTrue(actualJson, actualJson.contains("\"payment_details\"")); + assertFalse(actualJson, actualJson.contains("\"method\"")); + assertTrue(actualJson, actualJson.contains("\"payment_type\":\"card\"")); } } diff --git a/riskified-sdk/src/test/java/com/riskified/models/RideLineItemTest.java b/riskified-sdk/src/test/java/com/riskified/models/RideLineItemTest.java new file mode 100644 index 00000000..76db803b --- /dev/null +++ b/riskified-sdk/src/test/java/com/riskified/models/RideLineItemTest.java @@ -0,0 +1,117 @@ +package com.riskified.models; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.riskified.JSONFormater; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Wire-name regression tests for {@link RideLineItem}. + * + *

+ * Java derives wire names from field names via + * {@code FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES}, so a field whose Java name does not + * snake-case to the contract's key diverges silently. There is no error, no warning, and no + * response difference — the data simply stops arriving. + */ +public class RideLineItemTest { + + private static RideLineItem fullyPopulatedRideLineItem() { + RideLineItem inputItem = new RideLineItem(42.5, 1, "Airport transfer", new Date(0L), 0, 0); + inputItem.setPickupLatitude(32.0853f); + inputItem.setPickupLongitude(34.7818f); + inputItem.setPickupAddress( + new Address("Ada", "Lovelace", "1 Rothschild Blvd", "Tel Aviv", "+972500000000", "IL")); + inputItem.setDropoffDate(new Date(3600000L)); + inputItem.setDropoffLatitude(31.7683f); + inputItem.setDropoffLongitude(35.2137f); + inputItem.setDropoffAddress( + new Address("Ada", "Lovelace", "1 Jaffa St", "Jerusalem", "+972500000000", "IL")); + inputItem.setTransportMethod("car"); + inputItem.setPriceBy("distance"); + inputItem.setVehicleClass("business"); + inputItem.setCarrierName("Acme Rides"); + inputItem.setDriverId("driver-1"); + inputItem.setTariff("flat"); + inputItem.setNoteToDriver("Second gate"); + inputItem.setMeetNGreet("yes"); + inputItem.setCancellationPolicy("free"); + inputItem.setAuthorizedPayments(42.5f); + return inputItem; + } + + /** + * The live wire name for the dropoff latitude is the transposed {@code dropoff_latitiude}. + * + *

+ * This misspelling is the contract, not a mistake in this test. The reference C# SDK + * sends it ({@code OrderElements/RideTicketLineItem.cs:101}) and the Riskified API expects it. + * Java, PHP and JavaScript each independently "corrected" the spelling to + * {@code dropoff_latitude}, and the consequence is silent: ride dropoff geolocation stops + * arriving and nothing errors. Do not "fix" the spelling here or in {@link RideLineItem}. + */ + @Test + public void testDropoffLatitudeUsesTheTransposedLiveWireName() { + RideLineItem inputItem = fullyPopulatedRideLineItem(); + + String actualJson = JSONFormater.toJson(inputItem); + + assertTrue("expected the transposed live wire name dropoff_latitiude, got: " + actualJson, + actualJson.contains("\"dropoff_latitiude\":31.7683")); + assertFalse("dropoff_latitude (the corrected spelling) is not the contract and must not ship: " + + actualJson, actualJson.contains("\"dropoff_latitude\"")); + } + + /** + * Locks the whole derived wire-name map for this class, so a renamed or newly added field + * cannot drift without a failing test. Names come from + * {@code docs/flows/01-model-catalog.md} section 8, {@code OrderElements/RideTicketLineItem.cs}. + */ + @Test + public void testEveryRideFieldUsesItsContractWireName() { + List expectedWireNames = Arrays.asList( + "pickup_date", + "pickup_latitude", + "pickup_longitude", + "pickup_address", + "dropoff_date", + "dropoff_latitiude", + "dropoff_longitude", + "dropoff_address", + "transport_method", + "price_by", + "vehicle_class", + "carrier_name", + "driver_id", + "tariff", + "note_to_driver", + "meet_n_greet", + "cancellation_policy", + "authorized_payments", + "route_index", + "leg_index"); + + JsonObject actualObject = JsonParser.parseString(JSONFormater.toJson(fullyPopulatedRideLineItem())) + .getAsJsonObject(); + + for (String expectedWireName : expectedWireNames) { + assertTrue("missing wire key " + expectedWireName + " in " + actualObject, + actualObject.has(expectedWireName)); + } + } + + /** {@code product_type} is the {@code line_items} discriminator and is set by the constructor. */ + @Test + public void testProductTypeIsRide() { + String actualJson = JSONFormater.toJson(fullyPopulatedRideLineItem()); + + assertTrue(actualJson, actualJson.contains("\"product_type\":\"ride\"")); + } +} From 14771288518a4c9247fad54af10df3d0a8d4d5c2 Mon Sep 17 00:00:00 2001 From: tomas-amaro Date: Wed, 26 Aug 2026 11:03:26 +0100 Subject: [PATCH 2/3] fix(wire-contract): KycDetails updated_at, two Customer timestamps, body charset Three follow-up divergences found while doing the transport tranche, approved for fixing after review. Same guardrails: null-handling and the 30 always-emitted defaults are untouched (corpus open question 5). 1. KycDetails.updateAt derived the wire key "update_at"; the contract key is "updated_at" (KycDetails.cs:18). The derived name was a key the API ignores, so KYC update timestamps were silently not arriving -- the same class of failure as dropoff_latitiude, in the opposite direction: there the SDK "corrected" a wire typo, here it inherited a Java-side one. Pinned with @SerializedName rather than renaming the field, which would break every caller of getUpdateAt()/setUpdateAt(). 2. Added Customer.verifiedEmailAt and Customer.firstPurchaseAt, absent from the Java model entirely (Customer.cs:143, :167). Both are DateTimeOffset? in the reference, so they are boxed Date here: omitted when null, offset-bearing by default, no @JsonAdapter. The date split now covers 25/25 offset-bearing fields rather than 23/25. A before/after probe confirms the payload for a default Customer is byte-identical. 3. postOrder read the response body with EntityUtils.toString(entity) and no charset. The fallback is subtler than it first looks -- for application/json httpclient resolves UTF-8 from ContentType.APPLICATION_JSON's registered default -- but it is ISO-8859-1 for text/html, text/plain, and for a body with no Content-Type at all, which is exactly the 502/503-from-a-proxy and bare-string-OTP-error case the unmatched-status work is about. Now passes "UTF-8" explicitly, matching postCheckoutOrder, so the body preserved on RiskifiedHttpException is not mojibake. 4. Response.getReceived() unboxed a null Integer, raising NullPointerException on every response that omits "received" -- which is every error response. It now returns 0, keeping the primitive return type so existing callers still compile, and getReceivedOrNull() exposes the absent/zero distinction. The CheckoutResponse copy constructor uses the latter so absence survives. Also audited all 28 non-derivable wire names in 01-model-catalog.md section 5 against their Java fields. 26 are correct; the other two -- group_founder_order_id (OrderBase.cs:188) and buyer_anonymous_id (Customer.cs:155) -- are not modelled in Java at all. Reported, not fixed. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/riskified/RiskifiedClient.java | 6 +- .../java/com/riskified/models/Customer.java | 17 ++++ .../java/com/riskified/models/KycDetails.java | 6 ++ .../java/com/riskified/models/Response.java | 16 +++- .../java/com/riskified/DateSplitTest.java | 51 ++++++++--- .../riskified/RiskifiedClientErrorTest.java | 47 ++++++++++ .../com/riskified/models/KycDetailsTest.java | 87 +++++++++++++++++++ .../com/riskified/models/ResponseTest.java | 80 +++++++++++++++++ 8 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java create mode 100644 riskified-sdk/src/test/java/com/riskified/models/ResponseTest.java diff --git a/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java b/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java index 5854497b..41619d41 100644 --- a/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java +++ b/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java @@ -1054,7 +1054,11 @@ private Response postOrder(Object data, String url) throws IOException { HttpResponse response; HttpClient client = constructHttpClient(); response = executeClient(client, request); - String postBody = EntityUtils.toString(response.getEntity()); + // Explicit UTF-8. Without it EntityUtils falls back to ISO-8859-1 whenever the response + // omits a charset parameter, which turns any non-ASCII error message into mojibake -- + // and this body is now preserved verbatim on the exception, so mangling it here would + // corrupt the thing the caller is meant to read. postCheckoutOrder already did this. + String postBody = EntityUtils.toString(response.getEntity(), "UTF-8"); int status = response.getStatusLine().getStatusCode(); String statusText = response.getStatusLine().getReasonPhrase(); diff --git a/riskified-sdk/src/main/java/com/riskified/models/Customer.java b/riskified-sdk/src/main/java/com/riskified/models/Customer.java index 30fbc17b..268c4d11 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/Customer.java +++ b/riskified-sdk/src/main/java/com/riskified/models/Customer.java @@ -32,6 +32,8 @@ public class Customer implements IValidated { private String documentType; private String phone; private Boolean verifiedPhone; + private Date verifiedEmailAt; + private Date firstPurchaseAt; private Date verifiedPhoneAt; private String userName; private Boolean hasDefaulted; @@ -257,6 +259,21 @@ public Integer getLinkedAccounts() { public void setVerifiedPhone(Boolean verifiedPhone) { this.verifiedPhone = verifiedPhone; } + /** + * {@code verified_email_at} — {@code Customer.cs:143}, {@code DateTimeOffset?}. Offset-bearing, + * which is the default format, so it needs no adapter annotation. + */ + public Date getVerifiedEmailAt() { return verifiedEmailAt; } + + public void setVerifiedEmailAt(Date verifiedEmailAt) { this.verifiedEmailAt = verifiedEmailAt; } + + /** + * {@code first_purchase_at} — {@code Customer.cs:167}, {@code DateTimeOffset?}. Offset-bearing. + */ + public Date getFirstPurchaseAt() { return firstPurchaseAt; } + + public void setFirstPurchaseAt(Date firstPurchaseAt) { this.firstPurchaseAt = firstPurchaseAt; } + public Date getVerifiedPhoneAt() { return verifiedPhoneAt; } public void setVerifiedPhoneAt(Date verifiedPhoneAt) { this.verifiedPhoneAt = verifiedPhoneAt; } diff --git a/riskified-sdk/src/main/java/com/riskified/models/KycDetails.java b/riskified-sdk/src/main/java/com/riskified/models/KycDetails.java index beca90cc..ef817015 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/KycDetails.java +++ b/riskified-sdk/src/main/java/com/riskified/models/KycDetails.java @@ -1,5 +1,6 @@ package com.riskified.models; +import com.google.gson.annotations.SerializedName; import com.riskified.validations.FieldBadFormatException; import com.riskified.validations.IValidated; import com.riskified.validations.Validation; @@ -9,6 +10,11 @@ public class KycDetails implements IValidated { private String vendorName; + // The Java field name is updateAt, which LOWER_CASE_WITH_UNDERSCORES derives as "update_at". + // The contract key is "updated_at" (KycDetails.cs:18), so the derived name was a key the API + // ignores: KYC update timestamps were silently not arriving. Pinned explicitly rather than + // renaming the field, which would break every caller of getUpdateAt()/setUpdateAt(). + @SerializedName("updated_at") private Date updateAt; private Boolean kyc_verified; private String kycType; diff --git a/riskified-sdk/src/main/java/com/riskified/models/Response.java b/riskified-sdk/src/main/java/com/riskified/models/Response.java index f10d3db2..51507d77 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/Response.java +++ b/riskified-sdk/src/main/java/com/riskified/models/Response.java @@ -20,7 +20,7 @@ public Response() { public Response(CheckoutResponse checkoutResponse) { this.order = checkoutResponse.getCheckout(); - this.received = checkoutResponse.getReceived(); + this.received = checkoutResponse.getReceivedOrNull(); this.warnings = checkoutResponse.getWarnings(); this.error = checkoutResponse.getError(); } @@ -45,7 +45,21 @@ public void setDecision(String decision) { this.decision = decision; } + /** + * @return the {@code received} count, or {@code 0} when the response carried no such field. + * Unboxing a null {@link Integer} here used to raise a {@link NullPointerException} on + * every response that omits it — which is every error response and several success + * ones. Use {@link #getReceivedOrNull()} when the difference between absent and zero + * matters; the primitive return type is kept so existing callers still compile. + */ public int getReceived() { + return received == null ? 0 : received; + } + + /** + * @return the {@code received} count, or {@code null} when the response carried no such field. + */ + public Integer getReceivedOrNull() { return received; } diff --git a/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java b/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java index 8ccd7c80..1cff016e 100644 --- a/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java +++ b/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java @@ -159,14 +159,8 @@ private static List naiveFields() { } /** - * The 23 offset-bearing fields that have a Java counterpart. - * - *

- * Two of the contract's 25 are not modelled by this SDK at all — - * {@code Customer.verified_email_at} ({@code Customer.cs:143}) and - * {@code Customer.first_purchase_at} ({@code Customer.cs:167}). They are missing fields, not - * misformatted ones, and are out of scope here; when they are added they must take the offset - * format, which is the default, so no annotation is needed. + * All 25 offset-bearing fields of {@code docs/flows/01-model-catalog.md} section 3, plus the one + * {@code cancelled_at} that Java declares on the order base and .NET does not. */ private static List offsetBearingFields() { return Arrays.asList(new Object[][] { @@ -181,6 +175,9 @@ private static List offsetBearingFields() { // Customer.cs:113 created_at, :116 updated_at, :149 verified_phone_at, :182 date_of_birth { Customer.class, "createdAt" }, { Customer.class, "updatedAt" }, + // Customer.cs:143 verified_email_at, :167 first_purchase_at + { Customer.class, "verifiedEmailAt" }, + { Customer.class, "firstPurchaseAt" }, { Customer.class, "verifiedPhoneAt" }, { Customer.class, "dateOfBirth" }, // CreditCardPaymentDetails.cs:110/:113, WalletPaymentDetails.cs:106/:109 @@ -237,9 +234,9 @@ public void testAllThirteenNaiveFieldsCarryTheNaiveAdapter() throws NoSuchFieldE public void testOffsetBearingFieldsCarryNoNaiveAdapter() throws NoSuchFieldException { List inputFields = offsetBearingFields(); - // 23 of the contract's 25, plus one field Java declares that .NET does not - // (cancelled_at on the order base, where .NET has it only on OrderCancellation). - assertEquals(24, inputFields.size()); + // All 25 of the contract's offset-bearing fields, plus one field Java declares that .NET + // does not (cancelled_at on the order base, where .NET has it only on OrderCancellation). + assertEquals(26, inputFields.size()); for (Object[] inputField : inputFields) { Class declaringClass = (Class) inputField[0]; String fieldName = (String) inputField[1]; @@ -300,6 +297,38 @@ private static List> modelClassesWithDateFields() { com.riskified.models.VerificationData.class, WalletPaymentDetails.class); } + /** + * The two {@link Customer} timestamps that used to be missing from the model derive their + * contract wire names, and take the offset format rather than the naive one. + */ + @Test + public void testNewlyModelledCustomerTimestampsAreOffsetBearing() { + Customer inputCustomer = new Customer("a@b.com", "Ada", "Lovelace"); + inputCustomer.setVerifiedEmailAt(FIXED_INSTANT); + inputCustomer.setFirstPurchaseAt(FIXED_INSTANT); + + String actualJson = JSONFormater.toJson(inputCustomer); + + assertTrue(actualJson, + actualJson.contains("\"verified_email_at\":\"" + EXPECTED_OFFSET_RENDERING + "\"")); + assertTrue(actualJson, + actualJson.contains("\"first_purchase_at\":\"" + EXPECTED_OFFSET_RENDERING + "\"")); + } + + /** + * Adding those two boxed fields must not change the payload for a customer that never sets them. + * Whether a missing field is equivalent to its .NET default is corpus open question 5 and is + * explicitly out of scope, so the default payload has to stay exactly as it was. + */ + @Test + public void testNewCustomerTimestampsAreOmittedWhenUnset() { + Customer inputCustomer = new Customer("a@b.com", "Ada", "Lovelace"); + + String actualJson = JSONFormater.toJson(inputCustomer); + + assertEquals("{\"email\":\"a@b.com\",\"first_name\":\"Ada\",\"last_name\":\"Lovelace\"}", actualJson); + } + /** A naive date read back yields the instant it was written from. */ @Test public void testNaiveDateRoundTrips() { diff --git a/riskified-sdk/src/test/java/com/riskified/RiskifiedClientErrorTest.java b/riskified-sdk/src/test/java/com/riskified/RiskifiedClientErrorTest.java index d479646c..96d93b1d 100644 --- a/riskified-sdk/src/test/java/com/riskified/RiskifiedClientErrorTest.java +++ b/riskified-sdk/src/test/java/com/riskified/RiskifiedClientErrorTest.java @@ -4,8 +4,13 @@ import com.riskified.models.CheckoutResponse; import com.riskified.models.Response; import org.apache.http.client.HttpResponseException; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.entity.ContentType; +import org.apache.http.util.EntityUtils; import org.junit.Test; +import java.nio.charset.StandardCharsets; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; @@ -162,6 +167,48 @@ public void testEmptyAndMalformedBodiesDoNotThrow() { assertEquals("{not json", actualMalformed.getResponseBody()); } + /** + * A non-ASCII error body read without an explicit charset. + * + *

+ * The fallback in {@code EntityUtils.toString(entity)} is subtler than "always ISO-8859-1": for + * {@code application/json} with no charset parameter, httpclient 4.5.13 resolves UTF-8 from + * {@code ContentType.APPLICATION_JSON}'s registered default, so the happy path was never broken. + * It falls back to ISO-8859-1 when the mime type's registered default is ISO-8859-1 + * ({@code text/html}, {@code text/plain}) or when there is no {@code Content-Type} header + * at all — which is precisely the 502/503-from-a-proxy and bare-string-OTP-error case that the + * unmatched-status work is about. Passing {@code "UTF-8"} explicitly makes the read independent + * of what the server chose to label the body. + */ + @Test + public void testNonAsciiErrorBodyIsReadAsUtf8RegardlessOfContentType() throws Exception { + String inputMessage = "Requête invalide — coût 12€"; + byte[] inputBytes = inputMessage.getBytes(StandardCharsets.UTF_8); + // A proxy error page, and a body with no Content-Type at all. + ByteArrayEntity inputHtmlEntity = new ByteArrayEntity(inputBytes, ContentType.create("text/html")); + ByteArrayEntity inputUntypedEntity = new ByteArrayEntity(inputBytes); + + assertEquals(inputMessage, EntityUtils.toString(inputHtmlEntity, "UTF-8")); + assertEquals(inputMessage, EntityUtils.toString(inputUntypedEntity, "UTF-8")); + // Guards the test: without the explicit charset these two are mojibake, which is the bug. + assertNotEquals(inputMessage, EntityUtils.toString(new ByteArrayEntity(inputBytes, + ContentType.create("text/html")))); + assertNotEquals(inputMessage, EntityUtils.toString(new ByteArrayEntity(inputBytes))); + } + + /** The non-ASCII body survives onto the exception verbatim, in both the body and the message. */ + @Test + public void testNonAsciiErrorBodyIsPreservedOnTheException() { + String inputBody = "{\"error\":{\"message\":\"coût invalide 12€\"}}"; + + RiskifiedHttpException actualException = + RiskifiedClient.buildHttpException(400, "Bad Request", inputBody, parseCheckoutBody(inputBody)); + + assertEquals(inputBody, actualException.getResponseBody()); + assertNotNull(actualException.getError()); + assertEquals("coût invalide 12€", actualException.getError().getMessage()); + } + /** 504 is documented with no content schema, so its message stays the fixed retry hint. */ @Test public void test504KeepsItsDocumentedRetryMessage() { diff --git a/riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java b/riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java new file mode 100644 index 00000000..995894ee --- /dev/null +++ b/riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java @@ -0,0 +1,87 @@ +package com.riskified.models; + +import com.riskified.JSONFormater; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Date; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Wire-name regression tests for {@link KycDetails}. + * + *

+ * The Java field is named {@code updateAt}, which {@code LOWER_CASE_WITH_UNDERSCORES} derives as + * {@code update_at}. The contract key is {@code updated_at} ({@code KycDetails.cs:18}), so the + * derived name was a key the API ignores and KYC update timestamps were silently not arriving. Same + * class of failure as {@code dropoff_latitiude}, in the opposite direction: there the SDK + * "corrected" a wire typo, here it inherited a Java-side one. + */ +public class KycDetailsTest { + + @Test + public void testUpdatedAtUsesTheContractWireName() { + KycDetails inputDetails = new KycDetails(); + inputDetails.setUpdateAt(new Date(36000000L)); + + String actualJson = JSONFormater.toJson(inputDetails); + + assertTrue("expected the contract key updated_at, got: " + actualJson, + actualJson.contains("\"updated_at\":\"1970-01-01T10:00:00+00:00\"")); + assertFalse("update_at is the derived name, not the contract key: " + actualJson, + actualJson.contains("\"update_at\"")); + } + + /** {@code updated_at} is one of the 25 offset-bearing fields, not one of the 13 naive ones. */ + @Test + public void testUpdatedAtIsOffsetBearing() { + KycDetails inputDetails = new KycDetails(); + inputDetails.setUpdateAt(new Date(36000000L)); + + String actualJson = JSONFormater.toJson(inputDetails); + + assertFalse(actualJson, actualJson.contains("\"updated_at\":\"1970-01-01T10:00:00\"")); + } + + /** The remaining three keys derive correctly; locked so they cannot drift. */ + @Test + public void testRemainingFieldsUseTheirContractWireNames() { + KycDetails inputDetails = new KycDetails(); + inputDetails.setVendorName("Acme KYC"); + inputDetails.setKycVerified(true); + inputDetails.setKycType("full"); + + String actualJson = JSONFormater.toJson(inputDetails); + + for (String expectedWireName : Arrays.asList("vendor_name", "kyc_verified", "kyc_type")) { + assertTrue("missing wire key " + expectedWireName + " in " + actualJson, + actualJson.contains("\"" + expectedWireName + "\"")); + } + } + + /** A customer carrying KYC details serializes the corrected key through the nested path too. */ + @Test + public void testUpdatedAtSurvivesNestingUnderCustomer() { + KycDetails inputDetails = new KycDetails(); + inputDetails.setUpdateAt(new Date(36000000L)); + Customer inputCustomer = new Customer("a@b.com", "Ada", "Lovelace"); + inputCustomer.setKycDetails(Arrays.asList(inputDetails)); + + String actualJson = JSONFormater.toJson(inputCustomer); + + assertTrue(actualJson, actualJson.contains("\"kyc_details\"")); + assertTrue(actualJson, actualJson.contains("\"updated_at\"")); + assertFalse(actualJson, actualJson.contains("\"update_at\"")); + } + + /** Unset fields stay absent — the always-emitted-defaults boundary is unchanged. */ + @Test + public void testDefaultKycDetailsSerializesEmpty() { + String actualJson = JSONFormater.toJson(new KycDetails()); + + assertEquals("{}", actualJson); + } +} diff --git a/riskified-sdk/src/test/java/com/riskified/models/ResponseTest.java b/riskified-sdk/src/test/java/com/riskified/models/ResponseTest.java new file mode 100644 index 00000000..f602300f --- /dev/null +++ b/riskified-sdk/src/test/java/com/riskified/models/ResponseTest.java @@ -0,0 +1,80 @@ +package com.riskified.models; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * {@link Response} regression tests. + * + *

+ * {@code getReceived()} returns a primitive {@code int} from a boxed {@link Integer} field, so + * unboxing raised a {@link NullPointerException} on every response that omits {@code received} — + * which is every error response. The primitive return type is kept, so existing callers still + * compile; absence is now reachable through {@code getReceivedOrNull()}. + */ +public class ResponseTest { + + private Gson gson; + + @Before + public void setUp() { + gson = new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create(); + } + + @Test + public void testGetReceivedDoesNotThrowWhenTheFieldIsAbsent() { + Response inputResponse = gson.fromJson("{\"decision\":\"approve\"}", Response.class); + + int actualReceived = inputResponse.getReceived(); + + assertEquals(0, actualReceived); + } + + @Test + public void testGetReceivedOrNullDistinguishesAbsentFromZero() { + Response inputAbsent = gson.fromJson("{\"decision\":\"approve\"}", Response.class); + Response inputZero = gson.fromJson("{\"received\":0}", Response.class); + + assertNull(inputAbsent.getReceivedOrNull()); + assertEquals(Integer.valueOf(0), inputZero.getReceivedOrNull()); + } + + @Test + public void testGetReceivedReturnsThePresentValue() { + Response inputResponse = gson.fromJson("{\"received\":3}", Response.class); + + assertEquals(3, inputResponse.getReceived()); + assertEquals(Integer.valueOf(3), inputResponse.getReceivedOrNull()); + } + + /** An error-shaped body has no {@code received}; reading it must not throw. */ + @Test + public void testErrorShapedBodyIsReadableWithoutThrowing() { + Response inputResponse = + gson.fromJson("{\"error\":{\"message\":\"order id is missing\"}}", Response.class); + + assertEquals(0, inputResponse.getReceived()); + assertNull(inputResponse.getReceivedOrNull()); + assertEquals("order id is missing", inputResponse.getError().getMessage()); + } + + /** The copy constructor preserves absence rather than turning a missing field into 0. */ + @Test + public void testCheckoutCopyConstructorPreservesAbsentReceived() { + CheckoutResponse inputCheckout = + gson.fromJson("{\"checkout\":{\"id\":\"C-1\"}}", CheckoutResponse.class); + + Response actualResponse = new Response(inputCheckout); + + assertNull(actualResponse.getReceivedOrNull()); + assertEquals(0, actualResponse.getReceived()); + } +} From 585b4eaec568aad612388bf0671030e792eb34c3 Mon Sep 17 00:00:00 2001 From: tomas-amaro Date: Wed, 26 Aug 2026 15:31:08 +0100 Subject: [PATCH 3/3] fix(wire-contract): dropoff_latitude is the contract; drop the transposed override Reverts the `@SerializedName("dropoff_latitiude")` pin added earlier on this branch, and inverts the test that asserted it. The pin was based on the contract corpus, which claimed the transposed `dropoff_latitiude` was the live wire name because the reference C# SDK sends it. That inference was wrong: * Both OpenAPI specs declare `dropoff_latitude`, with a description and an example. The transposed spelling appears in neither. * It occurs exactly once in all of sdk_net (Riskified.SDK/Model/OrderElements/RideTicketLineItem.cs:100), with no test covering it, while pickup_latitude (:89) and dropoff_longitude (:104) in the same class are spelled correctly. So Gson's derived `dropoff_latitude` was right all along, and this branch was about to break a field that already worked. The transposition is a defect in the C# SDK. * RideLineItem: override and its DO-NOT-FIX comment removed; the now-unused SerializedName import goes with it. The field derives to the contract name. * RideLineItemTest: asserts `dropoff_latitude` is emitted and `dropoff_latitiude` is not -- guarded in both directions, since this branch already pinned it the wrong way once. * The wire-name map test drops the transposed entry. * KycDetailsTest: javadoc no longer cites the typo as a contract quirk. The corpus has been corrected in Riskified/sdk-orchestrator. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/riskified/models/RideLineItem.java | 11 ++++----- .../com/riskified/models/KycDetailsTest.java | 5 ++-- .../riskified/models/RideLineItemTest.java | 24 +++++++++---------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java index f71db8b6..7f709441 100644 --- a/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java +++ b/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java @@ -1,7 +1,6 @@ package com.riskified.models; import com.google.gson.annotations.JsonAdapter; -import com.google.gson.annotations.SerializedName; import com.riskified.adapters.NaiveDateTypeAdapter; @@ -22,12 +21,10 @@ public class RideLineItem extends LineItem { // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter. @JsonAdapter(NaiveDateTypeAdapter.class) private Date dropoffDate; - // The live wire name is the transposed "dropoff_latitiude", not "dropoff_latitude". - // This is an upstream typo the Riskified API expects: the reference C# SDK sends it - // (OrderElements/RideTicketLineItem.cs:101) and the service reads it. "Correcting" the - // spelling silently drops ride dropoff geolocation with no error anywhere. - // DO NOT "FIX" THE SPELLING. See docs/flows/01-model-catalog.md:289. - @SerializedName("dropoff_latitiude") + // Derives to "dropoff_latitude", which is the contract name in both OpenAPI specs. + // The reference C# SDK sends a transposed "dropoff_latitiude" + // (OrderElements/RideTicketLineItem.cs:100) -- that is a defect in that SDK, not the wire + // contract, so no @SerializedName override belongs here. private Float dropoffLatitude; private Float dropoffLongitude; private Address dropoffAddress; diff --git a/riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java b/riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java index 995894ee..a9833813 100644 --- a/riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java +++ b/riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java @@ -16,9 +16,8 @@ *

* The Java field is named {@code updateAt}, which {@code LOWER_CASE_WITH_UNDERSCORES} derives as * {@code update_at}. The contract key is {@code updated_at} ({@code KycDetails.cs:18}), so the - * derived name was a key the API ignores and KYC update timestamps were silently not arriving. Same - * class of failure as {@code dropoff_latitiude}, in the opposite direction: there the SDK - * "corrected" a wire typo, here it inherited a Java-side one. + * derived name was a key the API ignores and KYC update timestamps were silently not arriving. + * Derived names are only as good as the field they are derived from. */ public class KycDetailsTest { diff --git a/riskified-sdk/src/test/java/com/riskified/models/RideLineItemTest.java b/riskified-sdk/src/test/java/com/riskified/models/RideLineItemTest.java index 76db803b..d9002670 100644 --- a/riskified-sdk/src/test/java/com/riskified/models/RideLineItemTest.java +++ b/riskified-sdk/src/test/java/com/riskified/models/RideLineItemTest.java @@ -48,25 +48,25 @@ private static RideLineItem fullyPopulatedRideLineItem() { } /** - * The live wire name for the dropoff latitude is the transposed {@code dropoff_latitiude}. + * The contract wire name for the dropoff latitude is {@code dropoff_latitude}. * *

- * This misspelling is the contract, not a mistake in this test. The reference C# SDK - * sends it ({@code OrderElements/RideTicketLineItem.cs:101}) and the Riskified API expects it. - * Java, PHP and JavaScript each independently "corrected" the spelling to - * {@code dropoff_latitude}, and the consequence is silent: ride dropoff geolocation stops - * arriving and nothing errors. Do not "fix" the spelling here or in {@link RideLineItem}. + * Both OpenAPI specs declare that spelling, with a description and an example. The reference C# + * SDK sends a transposed {@code dropoff_latitiude} + * ({@code OrderElements/RideTicketLineItem.cs:100}); that is a defect in the C# SDK, not the + * contract, and this SDK must not copy it. Guarded in both directions, because an earlier + * revision of this branch pinned the transposition deliberately. */ @Test - public void testDropoffLatitudeUsesTheTransposedLiveWireName() { + public void testDropoffLatitudeUsesTheContractWireName() { RideLineItem inputItem = fullyPopulatedRideLineItem(); String actualJson = JSONFormater.toJson(inputItem); - assertTrue("expected the transposed live wire name dropoff_latitiude, got: " + actualJson, - actualJson.contains("\"dropoff_latitiude\":31.7683")); - assertFalse("dropoff_latitude (the corrected spelling) is not the contract and must not ship: " - + actualJson, actualJson.contains("\"dropoff_latitude\"")); + assertTrue("expected the contract wire name dropoff_latitude, got: " + actualJson, + actualJson.contains("\"dropoff_latitude\":31.7683")); + assertFalse("dropoff_latitiude is the C# SDK's transposition and must not ship: " + + actualJson, actualJson.contains("\"dropoff_latitiude\"")); } /** @@ -82,7 +82,7 @@ public void testEveryRideFieldUsesItsContractWireName() { "pickup_longitude", "pickup_address", "dropoff_date", - "dropoff_latitiude", + "dropoff_latitude", "dropoff_longitude", "dropoff_address", "transport_method",