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..41619d41 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; } } @@ -1014,26 +1054,20 @@ 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(); - 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: + *

+ * + *

+ * 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/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/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/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/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/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/main/java/com/riskified/models/RideLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java index d8c69c01..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,5 +1,9 @@ package com.riskified.models; +import com.google.gson.annotations.JsonAdapter; + +import com.riskified.adapters.NaiveDateTypeAdapter; + import java.lang.reflect.Field; import java.util.Date; @@ -8,11 +12,19 @@ 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; + // 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/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..1cff016e --- /dev/null +++ b/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java @@ -0,0 +1,347 @@ +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" }, + }); + } + + /** + * 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[][] { + // 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.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 + { 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(); + + // 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]; + 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); + } + + /** + * 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() { + 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..96d93b1d --- /dev/null +++ b/riskified-sdk/src/test/java/com/riskified/RiskifiedClientErrorTest.java @@ -0,0 +1,221 @@ +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.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; +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()); + } + + /** + * 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() { + 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/KycDetailsTest.java b/riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java new file mode 100644 index 00000000..a9833813 --- /dev/null +++ b/riskified-sdk/src/test/java/com/riskified/models/KycDetailsTest.java @@ -0,0 +1,86 @@ +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. + * Derived names are only as good as the field they are derived from. + */ +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/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/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()); + } +} 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..d9002670 --- /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 contract wire name for the dropoff latitude is {@code dropoff_latitude}. + * + *

+ * 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 testDropoffLatitudeUsesTheContractWireName() { + RideLineItem inputItem = fullyPopulatedRideLineItem(); + + String actualJson = JSONFormater.toJson(inputItem); + + 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\"")); + } + + /** + * 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_latitude", + "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\"")); + } +}