From b0dd7ab250be2059edc87613da5441ca264b7224 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Mon, 23 Feb 2026 14:02:40 +0530 Subject: [PATCH 1/3] fix nested object json parser issue. --- .../com/chargebee/v4/internal/JsonUtil.java | 169 +- .../v4/internal/CustomFieldsTest.java | 155 ++ .../v4/internal/CustomerParserTest.java | 275 +++ .../v4/internal/EventParserTest.java | 231 +++ .../v4/internal/InvoiceParserTest.java | 233 +++ .../v4/internal/ItemPriceParserTest.java | 109 ++ .../chargebee/v4/internal/JsonUtilTest.java | 1517 +++++++++-------- .../v4/internal/SubscriptionParserTest.java | 153 ++ .../v4/internal/TransactionParserTest.java | 109 ++ src/test/resources/fixtures/customer.json | 35 + src/test/resources/fixtures/events.json | 200 +++ src/test/resources/fixtures/invoice.json | 115 ++ src/test/resources/fixtures/itemPrices.json | 21 + src/test/resources/fixtures/subscription.json | 48 + src/test/resources/fixtures/transactioin.json | 24 + 15 files changed, 2636 insertions(+), 758 deletions(-) create mode 100644 src/test/java/com/chargebee/v4/internal/CustomFieldsTest.java create mode 100644 src/test/java/com/chargebee/v4/internal/CustomerParserTest.java create mode 100644 src/test/java/com/chargebee/v4/internal/EventParserTest.java create mode 100644 src/test/java/com/chargebee/v4/internal/InvoiceParserTest.java create mode 100644 src/test/java/com/chargebee/v4/internal/ItemPriceParserTest.java create mode 100644 src/test/java/com/chargebee/v4/internal/SubscriptionParserTest.java create mode 100644 src/test/java/com/chargebee/v4/internal/TransactionParserTest.java create mode 100644 src/test/resources/fixtures/customer.json create mode 100644 src/test/resources/fixtures/events.json create mode 100644 src/test/resources/fixtures/invoice.json create mode 100644 src/test/resources/fixtures/itemPrices.json create mode 100644 src/test/resources/fixtures/subscription.json create mode 100644 src/test/resources/fixtures/transactioin.json diff --git a/src/main/java/com/chargebee/v4/internal/JsonUtil.java b/src/main/java/com/chargebee/v4/internal/JsonUtil.java index 45db5ada..e97c48c7 100644 --- a/src/main/java/com/chargebee/v4/internal/JsonUtil.java +++ b/src/main/java/com/chargebee/v4/internal/JsonUtil.java @@ -14,13 +14,15 @@ public class JsonUtil { /** * Extract string value from JSON for a given key. + * Only matches top-level keys (not inside nested objects/arrays). */ public static String getString(String json, String key) { if (json == null || key == null) { return null; } + String flat = stripNested(json); Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\""); - Matcher matcher = pattern.matcher(json); + Matcher matcher = pattern.matcher(flat); if (matcher.find()) { return unescapeJsonString(matcher.group(1)); } @@ -29,13 +31,15 @@ public static String getString(String json, String key) { /** * Extract long value from JSON for a given key. + * Only matches top-level keys (not inside nested objects/arrays). */ public static Long getLong(String json, String key) { if (json == null || key == null) { return null; } + String flat = stripNested(json); Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+)"); - Matcher matcher = pattern.matcher(json); + Matcher matcher = pattern.matcher(flat); if (matcher.find()) { return Long.parseLong(matcher.group(1)); } @@ -44,13 +48,15 @@ public static Long getLong(String json, String key) { /** * Extract integer value from JSON for a given key. + * Only matches top-level keys (not inside nested objects/arrays). */ public static Integer getInteger(String json, String key) { if (json == null || key == null) { return null; } + String flat = stripNested(json); Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+)"); - Matcher matcher = pattern.matcher(json); + Matcher matcher = pattern.matcher(flat); if (matcher.find()) { return Integer.parseInt(matcher.group(1)); } @@ -59,13 +65,15 @@ public static Integer getInteger(String json, String key) { /** * Extract boolean value from JSON for a given key. + * Only matches top-level keys (not inside nested objects/arrays). */ public static Boolean getBoolean(String json, String key) { if (json == null || key == null) { return null; } + String flat = stripNested(json); Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(true|false)"); - Matcher matcher = pattern.matcher(json); + Matcher matcher = pattern.matcher(flat); if (matcher.find()) { return Boolean.parseBoolean(matcher.group(1)); } @@ -74,13 +82,15 @@ public static Boolean getBoolean(String json, String key) { /** * Extract double value from JSON for a given key. + * Only matches top-level keys (not inside nested objects/arrays). */ public static Double getDouble(String json, String key) { if (json == null || key == null) { return null; } + String flat = stripNested(json); Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)"); - Matcher matcher = pattern.matcher(json); + Matcher matcher = pattern.matcher(flat); if (matcher.find()) { return Double.parseDouble(matcher.group(1)); } @@ -100,13 +110,15 @@ public static Number getNumber(String json, String key) { /** * Extract BigDecimal value from JSON for a given key. + * Only matches top-level keys (not inside nested objects/arrays). */ public static java.math.BigDecimal getBigDecimal(String json, String key) { if (json == null || key == null) { return null; } + String flat = stripNested(json); Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)"); - Matcher matcher = pattern.matcher(json); + Matcher matcher = pattern.matcher(flat); if (matcher.find()) { return new java.math.BigDecimal(matcher.group(1)); } @@ -115,34 +127,20 @@ public static java.math.BigDecimal getBigDecimal(String json, String key) { /** * Extract nested object as JSON string for a given key. + * Only matches top-level keys (not inside nested objects/arrays). */ public static String getObject(String json, String key) { if (json == null || key == null) { return null; } - // Find the key position - String keyPattern = "\"" + Pattern.quote(key) + "\"\\s*:"; - Pattern pattern = Pattern.compile(keyPattern); - Matcher matcher = pattern.matcher(json); - if (!matcher.find()) { + int start = findTopLevelValueStart(json, key); + if (start < 0 || start >= json.length() || json.charAt(start) != '{') { return null; } - // Find the start of the object value (skip whitespace after colon) - int start = matcher.end(); - while (start < json.length() && Character.isWhitespace(json.charAt(start))) { - start++; - } - - if (start >= json.length() || json.charAt(start) != '{') { - return null; - } - - // Extract the object by tracking brace depth int depth = 0; boolean inString = false; boolean escaped = false; - int objectStart = start; for (int i = start; i < json.length(); i++) { char c = json.charAt(i); @@ -168,7 +166,7 @@ public static String getObject(String json, String key) { } else if (c == '}') { depth--; if (depth == 0) { - return json.substring(objectStart, i + 1); + return json.substring(start, i + 1); } } } @@ -179,23 +177,17 @@ public static String getObject(String json, String key) { /** * Extract array as JSON string for a given key. - * Handles nested arrays and objects by tracking bracket depth. + * Only matches top-level keys (not inside nested objects/arrays). */ public static String getArray(String json, String key) { if (json == null || key == null) { return null; } - - // Find the key position - String keyPattern = "\"" + Pattern.quote(key) + "\"\\s*:\\s*\\["; - Pattern p = Pattern.compile(keyPattern); - Matcher matcher = p.matcher(json); - if (!matcher.find()) { + int start = findTopLevelValueStart(json, key); + if (start < 0 || start >= json.length() || json.charAt(start) != '[') { return null; } - // Start from the opening bracket - int start = matcher.end() - 1; // Position of '[' int depth = 0; boolean inString = false; boolean escaped = false; @@ -232,6 +224,53 @@ public static String getArray(String json, String key) { return null; } + /** + * Find the index in the original JSON where the value starts for a + * top-level key (depth 1 inside the outermost braces). + * + * @return index of the first non-whitespace character after the colon, + * or {@code -1} if the key is not found at the top level. + */ + private static int findTopLevelValueStart(String json, String key) { + String target = "\"" + key + "\""; + int depth = 0; + boolean inString = false; + boolean escaped = false; + + for (int i = 0; i < json.length(); i++) { + char c = json.charAt(i); + + if (escaped) { + escaped = false; + continue; + } + if (c == '\\' && inString) { + escaped = true; + continue; + } + if (c == '"') { + if (!inString && depth == 1 + && i + target.length() <= json.length() + && json.regionMatches(i, target, 0, target.length())) { + int j = i + target.length(); + while (j < json.length() && Character.isWhitespace(json.charAt(j))) j++; + if (j < json.length() && json.charAt(j) == ':') { + j++; + while (j < json.length() && Character.isWhitespace(json.charAt(j))) j++; + return j; + } + } + inString = !inString; + continue; + } + if (!inString) { + if (c == '{' || c == '[') depth++; + else if (c == '}' || c == ']') depth--; + } + } + return -1; + } + /** * Parse array of objects and extract each object as JSON string. */ @@ -286,19 +325,21 @@ public static List parseObjectArray(String arrayJson) { /** * Check if a key exists and has non-null value. + * Only checks top-level keys (not inside nested objects/arrays). */ public static boolean hasValue(String json, String key) { if (json == null || key == null) { return false; } + String flat = stripNested(json); // First check if the key exists with null value Pattern nullPattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*null\\b"); - if (nullPattern.matcher(json).find()) { + if (nullPattern.matcher(flat).find()) { return false; } // Then check if the key exists at all Pattern keyPattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:"); - return keyPattern.matcher(json).find(); + return keyPattern.matcher(flat).find(); } /** @@ -630,6 +671,64 @@ else if (c == ']') { return map; } + /** + * Returns a flattened version of the JSON with nested objects and arrays + * replaced by {@code null}, so regex-based extraction only matches + * top-level keys. + */ + private static String stripNested(String json) { + if (json == null) return null; + StringBuilder sb = new StringBuilder(); + int depth = 0; + boolean inString = false; + boolean escaped = false; + + for (int i = 0; i < json.length(); i++) { + char c = json.charAt(i); + + if (escaped) { + escaped = false; + if (depth == 1) sb.append(c); + continue; + } + + if (c == '\\' && inString) { + escaped = true; + if (depth == 1) sb.append(c); + continue; + } + + if (c == '"') { + inString = !inString; + if (depth == 1) sb.append(c); + continue; + } + + if (!inString) { + if (c == '{' || c == '[') { + if (depth == 0) { + sb.append(c); + } else if (depth == 1) { + sb.append("null"); + } + depth++; + continue; + } + if (c == '}' || c == ']') { + depth--; + if (depth == 0) { + sb.append(c); + } + continue; + } + } + + if (depth == 1) sb.append(c); + } + + return sb.toString(); + } + /** * Unescape JSON string. * Processes escape sequences correctly by handling \\\\ last to avoid diff --git a/src/test/java/com/chargebee/v4/internal/CustomFieldsTest.java b/src/test/java/com/chargebee/v4/internal/CustomFieldsTest.java new file mode 100644 index 00000000..d65529be --- /dev/null +++ b/src/test/java/com/chargebee/v4/internal/CustomFieldsTest.java @@ -0,0 +1,155 @@ +package com.chargebee.v4.internal; + +import com.chargebee.v4.models.customer.Customer; +import com.chargebee.v4.models.invoice.Invoice; +import com.chargebee.v4.models.subscription.Subscription; +import org.junit.jupiter.api.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies that model fromJson correctly extracts root-level custom fields + * (cf_*) while ignoring those buried inside nested objects and arrays. + * Tests multiple models to ensure the shared extractCustomFields pattern works generically. + */ +@DisplayName("Custom Fields (cf_*) extraction across models") +class CustomFieldsTest { + + private static final String NESTED_JSON_TEMPLATE = "{" + + "\"id\": \"%s\"," + + "\"billing_address\": {" + + " \"first_name\": \"John\"," + + " \"cf_addr_note\": \"should not leak\"" + + "}," + + "\"line_items\": [{" + + " \"id\": \"li_1\"," + + " \"cf_line_note\": \"should not leak either\"" + + "}]," + + "\"cf_root_field\": \"extracted\"" + + "}"; + + // ========== Invoice ========== + @Nested + @DisplayName("Invoice") + class InvoiceTests { + + @Test void rootCustomFields() { + String json = "{\"id\": \"inv_1\", \"status\": \"paid\"," + + "\"cf_department\": \"engineering\"," + + "\"cf_cost_center\": \"CC-100\"," + + "\"cf_notes\": \"rush order\"}"; + Invoice inv = Invoice.fromJson(json); + assertEquals("inv_1", inv.getId()); + assertEquals("engineering", inv.getCustomField("cf_department")); + assertEquals("CC-100", inv.getCustomField("cf_cost_center")); + assertEquals("rush order", inv.getCustomField("cf_notes")); + assertEquals(3, inv.getCustomFields().size()); + } + + @Test void nestedCfFieldsNotLeaked() { + Invoice inv = Invoice.fromJson(String.format(NESTED_JSON_TEMPLATE, "inv_1")); + assertEquals("extracted", inv.getCustomField("cf_root_field")); + assertNull(inv.getCustomField("cf_addr_note")); + assertNull(inv.getCustomField("cf_line_note")); + assertEquals(1, inv.getCustomFields().size()); + } + + @Test void emptyWhenNoCfPresent() { + Invoice inv = Invoice.fromJson("{\"id\": \"inv_1\", \"status\": \"paid\"}"); + assertNotNull(inv.getCustomFields()); + assertTrue(inv.getCustomFields().isEmpty()); + } + + @Test void rootCfWinsSameKeyAtNested() { + String json = "{\"id\": \"inv_1\"," + + "\"billing_address\": {\"cf_priority\": \"nested\"}," + + "\"cf_priority\": \"root\"}"; + Invoice inv = Invoice.fromJson(json); + assertEquals("root", inv.getCustomField("cf_priority")); + assertEquals(1, inv.getCustomFields().size()); + } + } + + // ========== Customer ========== + @Nested + @DisplayName("Customer") + class CustomerTests { + + @Test void rootCustomFields() { + String json = "{\"id\": \"cust_1\", \"first_name\": \"Jane\"," + + "\"cf_tier\": \"gold\", \"cf_region\": \"APAC\"}"; + Customer cust = Customer.fromJson(json); + assertEquals("cust_1", cust.getId()); + assertEquals("gold", cust.getCustomField("cf_tier")); + assertEquals("APAC", cust.getCustomField("cf_region")); + assertEquals(2, cust.getCustomFields().size()); + } + + @Test void nestedCfFieldsNotLeaked() { + String json = "{\"id\": \"cust_1\"," + + "\"billing_address\": {\"cf_addr_tag\": \"nested\"}," + + "\"cf_visible\": \"yes\"}"; + Customer cust = Customer.fromJson(json); + assertEquals("yes", cust.getCustomField("cf_visible")); + assertNull(cust.getCustomField("cf_addr_tag")); + } + + @Test void emptyWhenNoCfPresent() { + Customer cust = Customer.fromJson("{\"id\": \"cust_1\"}"); + assertNotNull(cust.getCustomFields()); + assertTrue(cust.getCustomFields().isEmpty()); + } + } + + // ========== Subscription ========== + @Nested + @DisplayName("Subscription") + class SubscriptionTests { + + @Test void rootCustomFields() { + String json = "{\"id\": \"sub_1\", \"status\": \"active\"," + + "\"cf_channel\": \"web\", \"cf_campaign_id\": \"camp_42\"}"; + Subscription sub = Subscription.fromJson(json); + assertEquals("sub_1", sub.getId()); + assertEquals("web", sub.getCustomField("cf_channel")); + assertEquals("camp_42", sub.getCustomField("cf_campaign_id")); + assertEquals(2, sub.getCustomFields().size()); + } + + @Test void nestedCfFieldsNotLeaked() { + String json = "{\"id\": \"sub_1\"," + + "\"subscription_items\": [{\"item_price_id\": \"p1\", \"cf_item_tag\": \"nested\"}]," + + "\"cf_plan_note\": \"visible\"}"; + Subscription sub = Subscription.fromJson(json); + assertEquals("visible", sub.getCustomField("cf_plan_note")); + assertNull(sub.getCustomField("cf_item_tag")); + assertEquals(1, sub.getCustomFields().size()); + } + + @Test void emptyWhenNoCfPresent() { + Subscription sub = Subscription.fromJson("{\"id\": \"sub_1\"}"); + assertNotNull(sub.getCustomFields()); + assertTrue(sub.getCustomFields().isEmpty()); + } + } + + // ========== Consent Fields (Customer-specific cs_*) ========== + @Nested + @DisplayName("Consent Fields (cs_*) - Customer") + class ConsentFieldsTests { + + @Test void rootConsentFields() { + String json = "{\"id\": \"cust_1\"," + + "\"cs_marketing\": true, \"cs_analytics\": false}"; + Customer cust = Customer.fromJson(json); + assertEquals(true, cust.getConsentFieldAsBoolean("cs_marketing")); + assertEquals(false, cust.getConsentFieldAsBoolean("cs_analytics")); + } + + @Test void emptyWhenNoCsPresent() { + Customer cust = Customer.fromJson("{\"id\": \"cust_1\"}"); + assertNotNull(cust.getConsentFields()); + assertTrue(cust.getConsentFields().isEmpty()); + } + } +} diff --git a/src/test/java/com/chargebee/v4/internal/CustomerParserTest.java b/src/test/java/com/chargebee/v4/internal/CustomerParserTest.java new file mode 100644 index 00000000..c40ff7ab --- /dev/null +++ b/src/test/java/com/chargebee/v4/internal/CustomerParserTest.java @@ -0,0 +1,275 @@ +package com.chargebee.v4.internal; + +import com.chargebee.v4.models.customer.Customer; +import com.chargebee.v4.models.customer.Customer.BillingAddress; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Customer JSON parsing") +class CustomerParserTest { + + private static Customer customer; + + @BeforeAll + static void loadFixture() throws IOException { + String customerJson; + try (InputStream is = CustomerParserTest.class.getResourceAsStream("/fixtures/customer.json")) { + assertNotNull(is, "fixtures/customer.json not found on classpath"); + customerJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + customer = Customer.fromJson(customerJson); + } + + @Nested + @DisplayName("String fields") + class StringFields { + + @Test void id() { + assertEquals("__test__KyVnHhSBWl7eY2bl", customer.getId()); + } + + @Test void firstName() { + assertEquals("John", customer.getFirstName()); + } + + @Test void lastName() { + assertEquals("Doe", customer.getLastName()); + } + + @Test void email() { + assertEquals("john@test.com", customer.getEmail()); + } + + @Test void locale() { + assertEquals("fr-CA", customer.getLocale()); + } + + @Test void preferredCurrencyCode() { + assertEquals("USD", customer.getPreferredCurrencyCode()); + } + } + + @Nested + @DisplayName("Boolean fields") + class BooleanFields { + + @Test void allowDirectDebit() { + assertEquals(false, customer.getAllowDirectDebit()); + } + + @Test void deleted() { + assertEquals(false, customer.getDeleted()); + } + } + + @Nested + @DisplayName("Integer fields") + class IntegerFields { + + @Test void netTermDays() { + assertEquals(0, customer.getNetTermDays()); + } + } + + @Nested + @DisplayName("Long fields") + class LongFields { + + @Test void resourceVersion() { + assertEquals(1517505731000L, customer.getResourceVersion()); + } + + @Test void promotionalCredits() { + assertEquals(0L, customer.getPromotionalCredits()); + } + + @Test void unbilledCharges() { + assertEquals(0L, customer.getUnbilledCharges()); + } + + @Test void refundableCredits() { + assertEquals(0L, customer.getRefundableCredits()); + } + + @Test void excessPayments() { + assertEquals(0L, customer.getExcessPayments()); + } + } + + @Nested + @DisplayName("Enum fields") + class EnumFields { + + @Test void autoCollection() { + assertEquals(Customer.AutoCollection.ON, customer.getAutoCollection()); + } + + @Test void piiCleared() { + assertEquals(Customer.PiiCleared.ACTIVE, customer.getPiiCleared()); + } + + @Test void taxability() { + assertEquals(Customer.Taxability.TAXABLE, customer.getTaxability()); + } + } + + @Nested + @DisplayName("Timestamp fields") + class TimestampFields { + + @Test void createdAt() { + assertEquals(new Timestamp(1517505731L * 1000), customer.getCreatedAt()); + } + + @Test void updatedAt() { + assertEquals(new Timestamp(1517505731L * 1000), customer.getUpdatedAt()); + } + } + + @Nested + @DisplayName("Absent fields parse as null") + class AbsentFields { + + @Test void phone() { + assertNull(customer.getPhone()); + } + + @Test void company() { + assertNull(customer.getCompany()); + } + + @Test void vatNumber() { + assertNull(customer.getVatNumber()); + } + + @Test void createdFromIp() { + assertNull(customer.getCreatedFromIp()); + } + + @Test void billingDate() { + assertNull(customer.getBillingDate()); + } + + @Test void channel() { + assertEquals(Customer.Channel._UNKNOWN, customer.getChannel()); + } + + @Test void fraudFlag() { + assertEquals(Customer.FraudFlag._UNKNOWN, customer.getFraudFlag()); + } + + @Test void paymentMethod() { + assertNull(customer.getPaymentMethod()); + } + + @Test void relationship() { + assertNull(customer.getRelationship()); + } + } + + @Nested + @DisplayName("Billing address (nested object)") + class BillingAddressTests { + + @Test void billingAddressNotNull() { + assertNotNull(customer.getBillingAddress()); + } + + @Test void city() { + assertEquals("Walnut", customer.getBillingAddress().getCity()); + } + + @Test void country() { + assertEquals("US", customer.getBillingAddress().getCountry()); + } + + @Test void billingFirstName() { + assertEquals("John", customer.getBillingAddress().getFirstName()); + } + + @Test void billingLastName() { + assertEquals("Mike", customer.getBillingAddress().getLastName()); + } + + @Test void line1() { + assertEquals("PO Box 9999", customer.getBillingAddress().getLine1()); + } + + @Test void state() { + assertEquals("California", customer.getBillingAddress().getState()); + } + + @Test void stateCode() { + assertEquals("CA", customer.getBillingAddress().getStateCode()); + } + + @Test void zip() { + assertEquals("91789", customer.getBillingAddress().getZip()); + } + + @Test void validationStatus() { + assertEquals( + BillingAddress.ValidationStatus.NOT_VALIDATED, + customer.getBillingAddress().getValidationStatus()); + } + + @Test void absentLine2() { + assertNull(customer.getBillingAddress().getLine2()); + } + + @Test void absentLine3() { + assertNull(customer.getBillingAddress().getLine3()); + } + } + + @Nested + @DisplayName("Empty collections for absent arrays") + class EmptyCollections { + + @Test void referralUrls() { + assertNotNull(customer.getReferralUrls()); + assertTrue(customer.getReferralUrls().isEmpty()); + } + + @Test void contacts() { + assertNotNull(customer.getContacts()); + assertTrue(customer.getContacts().isEmpty()); + } + + @Test void balances() { + assertNotNull(customer.getBalances()); + assertTrue(customer.getBalances().isEmpty()); + } + + @Test void entityIdentifiers() { + assertNotNull(customer.getEntityIdentifiers()); + assertTrue(customer.getEntityIdentifiers().isEmpty()); + } + + @Test void taxProvidersFields() { + assertNotNull(customer.getTaxProvidersFields()); + assertTrue(customer.getTaxProvidersFields().isEmpty()); + } + } + + @Nested + @DisplayName("Custom and consent fields") + class DynamicFields { + + @Test void customFieldsEmpty() { + assertNotNull(customer.getCustomFields()); + assertTrue(customer.getCustomFields().isEmpty()); + } + + @Test void consentFieldsEmpty() { + assertNotNull(customer.getConsentFields()); + assertTrue(customer.getConsentFields().isEmpty()); + } + } +} diff --git a/src/test/java/com/chargebee/v4/internal/EventParserTest.java b/src/test/java/com/chargebee/v4/internal/EventParserTest.java new file mode 100644 index 00000000..59d92d6f --- /dev/null +++ b/src/test/java/com/chargebee/v4/internal/EventParserTest.java @@ -0,0 +1,231 @@ +package com.chargebee.v4.internal; + +import com.chargebee.v4.models.card.Card; +import com.chargebee.v4.models.customer.Customer; +import com.chargebee.v4.models.event.Event; +import com.chargebee.v4.models.event.Event.*; +import com.chargebee.v4.models.invoice.Invoice; +import com.chargebee.v4.models.subscription.Subscription; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Event JSON parsing") +class EventParserTest { + + private static Event event; + + @BeforeAll + static void loadFixture() throws IOException { + String raw; + try (InputStream is = EventParserTest.class.getResourceAsStream("/fixtures/events.json")) { + assertNotNull(is, "fixtures/events.json not found on classpath"); + raw = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + String eventJson = JsonUtil.getObject(raw, "event"); + assertNotNull(eventJson, "could not extract 'event' wrapper from fixture"); + event = Event.fromJson(eventJson); + } + + @Nested + @DisplayName("Top-level scalar fields") + class TopLevelFields { + + @Test void parsesStringFields() { + assertEquals("ev_16BPgETyVrQbiGhA", event.getId()); + assertEquals("sarah@sarah.com", event.getUser()); + } + + @Test void parsesEnumFields() { + assertEquals(Event.Source.ADMIN_CONSOLE, event.getSource()); + assertEquals(Event.EventType.SUBSCRIPTION_CREATED, event.getEventType()); + assertEquals(Event.ApiVersion.V2, event.getApiVersion()); + assertEquals(Event.WebhookStatus.NOT_CONFIGURED, event.getWebhookStatus()); + } + + @Test void parsesTimestampFields() { + assertEquals(new Timestamp(1702645601L * 1000), event.getOccurredAt()); + } + + @Test void absentFieldsAreNull() { + assertNull(event.getOriginUser()); + assertNull(event.getWebhookFailureReason()); + } + } + + @Nested + @DisplayName("Content — embedded subscription") + class ContentSubscriptionTests { + + @Test void parsesSubscription() { + Map content = event.getContent(); + String subJson = (String) content.get("subscription"); + assertNotNull(subJson); + Subscription sub = Subscription.fromJson(subJson); + + assertEquals("16BPgETyVrQVHGh1", sub.getId()); + assertEquals("sarah", sub.getCustomerId()); + assertEquals("INR", sub.getCurrencyCode()); + assertEquals(Subscription.Status.ACTIVE, sub.getStatus()); + assertEquals(Subscription.Channel.WEB, sub.getChannel()); + assertEquals(1, sub.getBillingPeriod()); + assertEquals(Subscription.BillingPeriodUnit.MONTH, sub.getBillingPeriodUnit()); + assertEquals(false, sub.getHasScheduledChanges()); + assertEquals(false, sub.getDeleted()); + assertEquals(0L, sub.getMrr()); + assertEquals(0, sub.getDueInvoicesCount()); + assertEquals("16CQtCTrgrYwi9n2E", sub.getBusinessEntityId()); + assertEquals(new Timestamp(1702645601L * 1000), sub.getCreatedAt()); + assertEquals(new Timestamp(1702578600L * 1000), sub.getStartedAt()); + assertEquals(new Timestamp(1702578600L * 1000), sub.getActivatedAt()); + + assertNotNull(sub.getSubscriptionItems()); + assertEquals(1, sub.getSubscriptionItems().size()); + Subscription.SubscriptionItems item = sub.getSubscriptionItems().get(0); + assertEquals("cross-train-advanced-INR-1_MONTH", item.getItemPriceId()); + assertEquals(Subscription.SubscriptionItems.ItemType.PLAN, item.getItemType()); + assertEquals(1, item.getQuantity()); + assertEquals(11667L, item.getUnitPrice()); + assertEquals(11667L, item.getAmount()); + } + } + + @Nested + @DisplayName("Content — embedded customer") + class ContentCustomerTests { + + @Test void parsesCustomer() { + Map content = event.getContent(); + String custJson = (String) content.get("customer"); + assertNotNull(custJson); + Customer cust = Customer.fromJson(custJson); + + assertEquals("sarah", cust.getId()); + assertEquals(Customer.AutoCollection.ON, cust.getAutoCollection()); + assertEquals(0, cust.getNetTermDays()); + assertEquals(false, cust.getAllowDirectDebit()); + assertEquals(Customer.Taxability.TAXABLE, cust.getTaxability()); + assertEquals(Customer.PiiCleared.ACTIVE, cust.getPiiCleared()); + assertEquals(Customer.Channel.WEB, cust.getChannel()); + assertEquals(Customer.CardStatus.VALID, cust.getCardStatus()); + assertEquals("INR", cust.getPreferredCurrencyCode()); + assertEquals(0L, cust.getPromotionalCredits()); + assertEquals(0L, cust.getRefundableCredits()); + assertEquals(0L, cust.getExcessPayments()); + assertEquals(0L, cust.getUnbilledCharges()); + assertEquals("pm_169vujTyVrL5fFDl", cust.getPrimaryPaymentSourceId()); + assertEquals(false, cust.getDeleted()); + assertEquals(true, cust.getAutoCloseInvoices()); + + assertNotNull(cust.getPaymentMethod()); + assertEquals(Customer.PaymentMethod.Type.CARD, cust.getPaymentMethod().getType()); + assertEquals(Customer.PaymentMethod.Gateway.CHARGEBEE, cust.getPaymentMethod().getGateway()); + assertEquals("gw_1mk51R4QrLmQtYMht", cust.getPaymentMethod().getGatewayAccountId()); + assertEquals(Customer.PaymentMethod.Status.VALID, cust.getPaymentMethod().getStatus()); + assertEquals("tok_169vujTyVrL5LFDk", cust.getPaymentMethod().getReferenceId()); + } + } + + @Nested + @DisplayName("Content — embedded card") + class ContentCardTests { + + @Test void parsesCard() { + Map content = event.getContent(); + String cardJson = (String) content.get("card"); + assertNotNull(cardJson); + Card card = Card.fromJson(cardJson); + + assertEquals("pm_169vujTyVrL5fFDl", card.getPaymentSourceId()); + assertEquals("boom", card.getCustomerId()); + assertEquals(Card.Status.VALID, card.getStatus()); + assertEquals(Card.Gateway.CHARGEBEE, card.getGateway()); + assertEquals("gw_1mk51R4QrLmQtYMht", card.getGatewayAccountId()); + assertEquals("411111", card.getIin()); + assertEquals("1111", card.getLast4()); + assertEquals(Card.CardType.VISA, card.getCardType()); + assertEquals(Card.FundingType.CREDIT, card.getFundingType()); + assertEquals(12, card.getExpiryMonth()); + assertEquals(2024, card.getExpiryYear()); + assertEquals("************1111", card.getMaskedNumber()); + assertEquals("10.0.0.1", card.getIpAddress()); + assertEquals(new Timestamp(1702645580L * 1000), card.getCreatedAt()); + assertEquals(new Timestamp(1702645580L * 1000), card.getUpdatedAt()); + assertEquals(1702645580740L, card.getResourceVersion()); + } + } + + @Nested + @DisplayName("Content — embedded invoice") + class ContentInvoiceTests { + + @Test void parsesInvoice() { + Map content = event.getContent(); + String invJson = (String) content.get("invoice"); + assertNotNull(invJson); + Invoice inv = Invoice.fromJson(invJson); + + assertEquals("203", inv.getId()); + assertEquals("boom", inv.getCustomerId()); + assertEquals("16BPgETyVrQVHGh1", inv.getSubscriptionId()); + assertEquals(true, inv.getRecurring()); + assertEquals(Invoice.Status.PAID, inv.getStatus()); + assertEquals(Invoice.PriceType.TAX_EXCLUSIVE, inv.getPriceType()); + assertEquals(Invoice.Channel.WEB, inv.getChannel()); + assertEquals("INR", inv.getCurrencyCode()); + assertEquals(11667L, inv.getTotal()); + assertEquals(11667L, inv.getAmountPaid()); + assertEquals(11667L, inv.getSubTotal()); + assertEquals(11667L, inv.getNewSalesAmount()); + assertEquals(0L, inv.getTax()); + assertEquals(0L, inv.getAmountDue()); + assertEquals(0L, inv.getWriteOffAmount()); + assertEquals(0L, inv.getCreditsApplied()); + assertEquals(true, inv.getFirstInvoice()); + assertEquals(false, inv.getIsGifted()); + assertEquals(true, inv.getTermFinalized()); + assertEquals(false, inv.getDeleted()); + assertEquals("16CQtCTrgrYwi9n2E", inv.getBusinessEntityId()); + + assertNotNull(inv.getLineItems()); + assertEquals(1, inv.getLineItems().size()); + Invoice.LineItems li = inv.getLineItems().get(0); + assertEquals("li_16BPgETyVrQWBGh3", li.getId()); + assertEquals(11667L, li.getAmount()); + assertEquals(Invoice.LineItems.EntityType.PLAN_ITEM_PRICE, li.getEntityType()); + assertEquals(Invoice.LineItems.TaxExemptReason.EXPORT, li.getTaxExemptReason()); + + assertNotNull(inv.getLinkedPayments()); + assertEquals(1, inv.getLinkedPayments().size()); + Invoice.LinkedPayments lp = inv.getLinkedPayments().get(0); + assertEquals("txn_16BPgETyVrQXVGh4", lp.getTxnId()); + assertEquals(11667L, lp.getAppliedAmount()); + assertEquals(Invoice.LinkedPayments.TxnStatus.SUCCESS, lp.getTxnStatus()); + + assertNotNull(inv.getNotes()); + assertEquals(1, inv.getNotes().size()); + } + } + + @Nested + @DisplayName("Webhooks array") + class WebhooksTests { + + @Test void parsesOneWebhook() { + assertNotNull(event.getWebhooks()); + assertEquals(1, event.getWebhooks().size()); + } + + @Test void webhookFields() { + Webhooks wh = event.getWebhooks().get(0); + assertEquals("whv2_Azz5aITsMVdKtVWV", wh.getId()); + assertEquals(Webhooks.WebhookStatus.NOT_APPLICABLE, wh.getWebhookStatus()); + } + } +} diff --git a/src/test/java/com/chargebee/v4/internal/InvoiceParserTest.java b/src/test/java/com/chargebee/v4/internal/InvoiceParserTest.java new file mode 100644 index 00000000..5b238645 --- /dev/null +++ b/src/test/java/com/chargebee/v4/internal/InvoiceParserTest.java @@ -0,0 +1,233 @@ +package com.chargebee.v4.internal; + +import com.chargebee.v4.models.invoice.Invoice; +import com.chargebee.v4.models.invoice.Invoice.*; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Invoice JSON parsing") +class InvoiceParserTest { + + private static String invoiceJson; + private static Invoice invoice; + + @BeforeAll + static void loadFixture() throws IOException { + try (InputStream is = InvoiceParserTest.class.getResourceAsStream("/fixtures/invoice.json")) { + assertNotNull(is, "fixtures/invoice.json not found on classpath"); + invoiceJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + invoice = Invoice.fromJson(invoiceJson); + } + + @Nested + @DisplayName("Top-level scalar fields") + class TopLevelFields { + + @Test void parsesStringFields() { + assertEquals("__demo_inv__1", invoice.getId()); + assertEquals("__test__KyVkkWS1xLskm8", invoice.getCustomerId()); + assertEquals("USD", invoice.getCurrencyCode()); + } + + @Test void parsesBooleanFields() { + assertEquals(false, invoice.getRecurring()); + assertEquals(true, invoice.getFirstInvoice()); + assertEquals(false, invoice.getHasAdvanceCharges()); + assertEquals(true, invoice.getTermFinalized()); + assertEquals(false, invoice.getIsGifted()); + assertEquals(false, invoice.getDeleted()); + } + + @Test void parsesIntegerFields() { + assertEquals(0, invoice.getNetTermDays()); + } + + @Test void parsesLongFields() { + assertEquals(0L, invoice.getAmountAdjusted()); + assertEquals(0L, invoice.getAmountDue()); + assertEquals(2000L, invoice.getAmountPaid()); + assertEquals(0L, invoice.getAmountToCollect()); + assertEquals(0L, invoice.getCreditsApplied()); + assertEquals(2000L, invoice.getNewSalesAmount()); + assertEquals(0L, invoice.getRoundOffAmount()); + assertEquals(1517463750000L, invoice.getResourceVersion()); + assertEquals(2000L, invoice.getSubTotal()); + assertEquals(0L, invoice.getTax()); + assertEquals(2000L, invoice.getTotal()); + assertEquals(0L, invoice.getWriteOffAmount()); + } + + @Test void parsesBigDecimalFields() { + assertEquals(0, new BigDecimal("1").compareTo(invoice.getExchangeRate())); + } + + @Test void parsesEnumFields() { + assertEquals(Invoice.Status.PAID, invoice.getStatus()); + assertEquals(Invoice.PriceType.TAX_EXCLUSIVE, invoice.getPriceType()); + assertEquals(Invoice.Channel._UNKNOWN, invoice.getChannel()); + } + + @Test void parsesTimestampFields() { + assertEquals(new Timestamp(1517463749L * 1000), invoice.getDate()); + assertEquals(new Timestamp(1517463749L * 1000), invoice.getDueDate()); + assertEquals(new Timestamp(1517463750L * 1000), invoice.getPaidAt()); + assertEquals(new Timestamp(1517463750L * 1000), invoice.getUpdatedAt()); + } + + @Test void absentFieldsAreNull() { + assertNull(invoice.getSubscriptionId()); + assertNull(invoice.getPaymentOwner()); + assertNull(invoice.getPoNumber()); + assertNull(invoice.getVatNumber()); + assertNull(invoice.getVoidReasonCode()); + assertNull(invoice.getBusinessEntityId()); + assertNull(invoice.getStatementDescriptor()); + assertNull(invoice.getEinvoice()); + assertNull(invoice.getSiteDetailsAtCreation()); + assertNull(invoice.getTaxOrigin()); + } + } + + @Nested + @DisplayName("Billing address") + class BillingAddressTests { + + @Test void parsesAllFields() { + BillingAddress ba = invoice.getBillingAddress(); + assertNotNull(ba); + assertEquals("John", ba.getFirstName()); + assertEquals("Mathew", ba.getLastName()); + assertEquals(BillingAddress.ValidationStatus.NOT_VALIDATED, ba.getValidationStatus()); + } + + @Test void absentSubFieldsAreNull() { + BillingAddress ba = invoice.getBillingAddress(); + assertNull(ba.getCity()); + assertNull(ba.getCountry()); + assertNull(ba.getLine1()); + assertNull(ba.getZip()); + } + } + + @Nested + @DisplayName("Shipping address") + class ShippingAddressTests { + + @Test void parsesAllFields() { + ShippingAddress sa = invoice.getShippingAddress(); + assertNotNull(sa); + assertEquals("John", sa.getFirstName()); + assertEquals("Mathew", sa.getLastName()); + assertEquals("Walnut", sa.getCity()); + assertEquals("US", sa.getCountry()); + assertEquals("California", sa.getState()); + assertEquals("CA", sa.getStateCode()); + assertEquals("91789", sa.getZip()); + assertEquals(ShippingAddress.ValidationStatus.NOT_VALIDATED, sa.getValidationStatus()); + } + } + + @Nested + @DisplayName("Line items array") + class LineItemsTests { + + @Test void parsesTwoLineItems() { + assertNotNull(invoice.getLineItems()); + assertEquals(2, invoice.getLineItems().size()); + } + + @Test void firstLineItemFields() { + LineItems li = invoice.getLineItems().get(0); + assertEquals("li___test__KyVkkWS1xLt9LF", li.getId()); + assertEquals("__test__KyVkkWS1xLskm8", li.getCustomerId()); + assertEquals("SSL Charge USD Monthly", li.getDescription()); + assertEquals("ssl-charge-USD", li.getEntityId()); + assertEquals(2000L, li.getAmount()); + assertEquals(2000L, li.getUnitAmount()); + assertEquals(1, li.getQuantity()); + assertEquals(0L, li.getDiscountAmount()); + assertEquals(0L, li.getItemLevelDiscountAmount()); + assertEquals(0L, li.getTaxAmount()); + assertEquals(false, li.getIsTaxed()); + assertEquals(LineItems.PricingModel.FLAT_FEE, li.getPricingModel()); + assertEquals(LineItems.EntityType.CHARGE_ITEM_PRICE, li.getEntityType()); + assertEquals(LineItems.TaxExemptReason.TAX_NOT_CONFIGURED, li.getTaxExemptReason()); + assertEquals(new Timestamp(1517463749L * 1000), li.getDateFrom()); + assertEquals(new Timestamp(1517463749L * 1000), li.getDateTo()); + } + } + + @Nested + @DisplayName("Linked payments array") + class LinkedPaymentsTests { + + @Test void parsesTwoLinkedPayments() { + assertNotNull(invoice.getLinkedPayments()); + assertEquals(2, invoice.getLinkedPayments().size()); + } + + @Test void firstLinkedPaymentFields() { + LinkedPayments lp = invoice.getLinkedPayments().get(0); + assertEquals("txn___test__KyVkkWS1xLtFiG", lp.getTxnId()); + assertEquals(2000L, lp.getAppliedAmount()); + assertEquals(2000L, lp.getTxnAmount()); + assertEquals(LinkedPayments.TxnStatus.SUCCESS, lp.getTxnStatus()); + assertEquals(new Timestamp(1517463750L * 1000), lp.getAppliedAt()); + assertEquals(new Timestamp(1517463750L * 1000), lp.getTxnDate()); + } + + @Test void secondLinkedPaymentHasZeroAppliedAmount() { + LinkedPayments lp = invoice.getLinkedPayments().get(1); + assertEquals(0L, lp.getAppliedAmount()); + assertEquals(2000L, lp.getTxnAmount()); + } + } + + @Nested + @DisplayName("Empty/absent collections") + class EmptyCollections { + + @Test void emptyArraysAreEmptyLists() { + assertNotNull(invoice.getLineItemTiers()); + assertTrue(invoice.getLineItemTiers().isEmpty()); + + assertNotNull(invoice.getLineItemDiscounts()); + assertTrue(invoice.getLineItemDiscounts().isEmpty()); + + assertNotNull(invoice.getLineItemTaxes()); + assertTrue(invoice.getLineItemTaxes().isEmpty()); + + assertNotNull(invoice.getDiscounts()); + assertTrue(invoice.getDiscounts().isEmpty()); + + assertNotNull(invoice.getTaxes()); + assertTrue(invoice.getTaxes().isEmpty()); + + assertNotNull(invoice.getReferenceTransactions()); + assertTrue(invoice.getReferenceTransactions().isEmpty()); + + assertNotNull(invoice.getNotes()); + assertTrue(invoice.getNotes().isEmpty()); + } + } + + @Nested + @DisplayName("Custom fields") + class CustomFieldsTests { + + @Test void parsesCustomFields() { + assertNotNull(invoice.getCustomFields()); + assertEquals(2, invoice.getCustomFields().size()); + assertEquals("engineering", invoice.getCustomField("cf_department")); + assertEquals("CC-1042", invoice.getCustomField("cf_cost_center")); + } + } +} diff --git a/src/test/java/com/chargebee/v4/internal/ItemPriceParserTest.java b/src/test/java/com/chargebee/v4/internal/ItemPriceParserTest.java new file mode 100644 index 00000000..bfd536a0 --- /dev/null +++ b/src/test/java/com/chargebee/v4/internal/ItemPriceParserTest.java @@ -0,0 +1,109 @@ +package com.chargebee.v4.internal; + +import com.chargebee.v4.models.itemPrice.ItemPrice; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ItemPrice JSON parsing") +class ItemPriceParserTest { + + private static String itemPriceJson; + private static ItemPrice itemPrice; + + @BeforeAll + static void loadFixture() throws IOException { + try (InputStream is = ItemPriceParserTest.class.getResourceAsStream("/fixtures/itemPrices.json")) { + assertNotNull(is, "fixtures/itemPrices.json not found on classpath"); + itemPriceJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + itemPrice = ItemPrice.fromJson(itemPriceJson); + } + + @Nested + @DisplayName("Top-level scalar fields") + class TopLevelFields { + + @Test void parsesStringFields() { + assertEquals("silver-USD-monthly", itemPrice.getId()); + assertEquals("silver USD monthly", itemPrice.getName()); + assertEquals("silver USD", itemPrice.getExternalName()); + assertEquals("silver", itemPrice.getItemId()); + assertEquals("USD", itemPrice.getCurrencyCode()); + } + + @Test void parsesBooleanFields() { + assertEquals(true, itemPrice.getIsTaxable()); + } + + @Test void parsesIntegerFields() { + assertEquals(1, itemPrice.getPeriod()); + assertEquals(0, itemPrice.getFreeQuantity()); + } + + @Test void parsesLongFields() { + assertEquals(1000L, itemPrice.getPrice()); + assertEquals(1594106928574L, itemPrice.getResourceVersion()); + } + + @Test void parsesEnumFields() { + assertEquals(ItemPrice.Status.ACTIVE, itemPrice.getStatus()); + assertEquals(ItemPrice.PricingModel.PER_UNIT, itemPrice.getPricingModel()); + assertEquals(ItemPrice.PeriodUnit.MONTH, itemPrice.getPeriodUnit()); + assertEquals(ItemPrice.ItemType.PLAN, itemPrice.getItemType()); + assertEquals(ItemPrice.Channel._UNKNOWN, itemPrice.getChannel()); + } + + @Test void parsesTimestampFields() { + Timestamp ts = new Timestamp(1594106928L * 1000); + assertEquals(ts, itemPrice.getCreatedAt()); + assertEquals(ts, itemPrice.getUpdatedAt()); + } + + @Test void absentFieldsAreNull() { + assertNull(itemPrice.getDescription()); + assertNull(itemPrice.getItemFamilyId()); + assertNull(itemPrice.getPriceVariantId()); + assertNull(itemPrice.getPriceInDecimal()); + assertNull(itemPrice.getTrialPeriod()); + assertNull(itemPrice.getBillingCycles()); + assertNull(itemPrice.getShippingPeriod()); + assertNull(itemPrice.getArchivedAt()); + assertNull(itemPrice.getInvoiceNotes()); + assertNull(itemPrice.getBusinessEntityId()); + assertNull(itemPrice.getTaxDetail()); + assertNull(itemPrice.getAccountingDetail()); + assertNull(itemPrice.getDeleted()); + } + } + + @Nested + @DisplayName("Empty collections") + class EmptyCollections { + + @Test void emptyArraysAreEmptyLists() { + assertNotNull(itemPrice.getTiers()); + assertTrue(itemPrice.getTiers().isEmpty()); + + assertNotNull(itemPrice.getTaxProvidersFields()); + assertTrue(itemPrice.getTaxProvidersFields().isEmpty()); + } + } + + @Nested + @DisplayName("Custom fields") + class CustomFieldsTests { + + @Test void parsesCustomFields() { + assertNotNull(itemPrice.getCustomFields()); + assertEquals(2, itemPrice.getCustomFields().size()); + assertEquals("saas", itemPrice.getCustomField("cf_product_line")); + assertEquals("silver", itemPrice.getCustomField("cf_tier_level")); + } + } +} diff --git a/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java b/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java index 5fc09d30..f95be9a9 100644 --- a/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java +++ b/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java @@ -9,942 +9,1047 @@ import static org.junit.jupiter.api.Assertions.*; -/** - * Comprehensive test suite for JsonUtil. - * Inspired by Gson's testing strategies to battle-test our JSON parsing. - */ @DisplayName("JsonUtil Tests") class JsonUtilTest { - // ========== getString Tests ========== + // ========== getString ========== @Nested - @DisplayName("getString Tests") + @DisplayName("getString") class GetStringTests { - @Test - @DisplayName("should extract simple string value") - void shouldExtractSimpleString() { - String json = "{\"name\": \"John\"}"; - assertEquals("John", JsonUtil.getString(json, "name")); + @Test void simpleString() { + assertEquals("John", JsonUtil.getString("{\"name\": \"John\"}", "name")); } - @Test - @DisplayName("should extract string with spaces") - void shouldExtractStringWithSpaces() { - String json = "{\"message\": \"Hello World\"}"; - assertEquals("Hello World", JsonUtil.getString(json, "message")); + @Test void stringWithSpaces() { + assertEquals("Hello World", JsonUtil.getString("{\"message\": \"Hello World\"}", "message")); } - @Test - @DisplayName("should handle escaped quotes in string") - void shouldHandleEscapedQuotes() { - String json = "{\"text\": \"He said \\\"Hello\\\"\"}"; - assertEquals("He said \"Hello\"", JsonUtil.getString(json, "text")); + @Test void escapedQuotes() { + assertEquals("He said \"Hello\"", + JsonUtil.getString("{\"text\": \"He said \\\"Hello\\\"\"}", "text")); } - @Test - @DisplayName("should handle escaped backslashes") - void shouldHandleEscapedBackslashes() { - String json = "{\"path\": \"C:\\\\Users\\\\test\"}"; - assertEquals("C:\\Users\\test", JsonUtil.getString(json, "path")); + @Test void escapedBackslashes() { + assertEquals("C:\\Users\\test", + JsonUtil.getString("{\"path\": \"C:\\\\Users\\\\test\"}", "path")); } - @Test - @DisplayName("should handle newlines and tabs") - void shouldHandleNewlinesAndTabs() { - String json = "{\"text\": \"line1\\nline2\\ttab\"}"; - assertEquals("line1\nline2\ttab", JsonUtil.getString(json, "text")); + @Test void newlinesAndTabs() { + assertEquals("line1\nline2\ttab", + JsonUtil.getString("{\"text\": \"line1\\nline2\\ttab\"}", "text")); } - @Test - @DisplayName("should return null for missing key") - void shouldReturnNullForMissingKey() { - String json = "{\"name\": \"John\"}"; - assertNull(JsonUtil.getString(json, "missing")); + @Test void missingKey() { + assertNull(JsonUtil.getString("{\"name\": \"John\"}", "missing")); } - @Test - @DisplayName("should return null for null json") - void shouldReturnNullForNullJson() { + @Test void nullJson() { assertNull(JsonUtil.getString(null, "key")); } - @Test - @DisplayName("should return null for null key") - void shouldReturnNullForNullKey() { + @Test void nullKey() { assertNull(JsonUtil.getString("{\"name\": \"John\"}", null)); } - @Test - @DisplayName("should extract empty string") - void shouldExtractEmptyString() { - String json = "{\"empty\": \"\"}"; - assertEquals("", JsonUtil.getString(json, "empty")); + @Test void emptyString() { + assertEquals("", JsonUtil.getString("{\"empty\": \"\"}", "empty")); } - @Test - @DisplayName("should extract string with unicode characters") - void shouldExtractUnicodeString() { - String json = "{\"text\": \"日本語 中文 한국어\"}"; - assertEquals("日本語 中文 한국어", JsonUtil.getString(json, "text")); + @Test void unicodeString() { + assertEquals("日本語 中文 한국어", + JsonUtil.getString("{\"text\": \"日本語 中文 한국어\"}", "text")); } - @Test - @DisplayName("should extract string with special JSON characters") - void shouldExtractStringWithSpecialChars() { - String json = "{\"text\": \"test: \\\"value\\\", more\"}"; - assertEquals("test: \"value\", more", JsonUtil.getString(json, "text")); + @Test void specialJsonChars() { + assertEquals("test: \"value\", more", + JsonUtil.getString("{\"text\": \"test: \\\"value\\\", more\"}", "text")); } } - // ========== getLong Tests ========== + // ========== getLong ========== @Nested - @DisplayName("getLong Tests") + @DisplayName("getLong") class GetLongTests { - @Test - @DisplayName("should extract positive long") - void shouldExtractPositiveLong() { - String json = "{\"id\": 12345678901234}"; - assertEquals(12345678901234L, JsonUtil.getLong(json, "id")); + @Test void positiveLong() { + assertEquals(12345678901234L, JsonUtil.getLong("{\"id\": 12345678901234}", "id")); } - @Test - @DisplayName("should extract negative long") - void shouldExtractNegativeLong() { - String json = "{\"value\": -9876543210}"; - assertEquals(-9876543210L, JsonUtil.getLong(json, "value")); + @Test void negativeLong() { + assertEquals(-9876543210L, JsonUtil.getLong("{\"value\": -9876543210}", "value")); } - @Test - @DisplayName("should extract zero") - void shouldExtractZero() { - String json = "{\"count\": 0}"; - assertEquals(0L, JsonUtil.getLong(json, "count")); + @Test void zero() { + assertEquals(0L, JsonUtil.getLong("{\"count\": 0}", "count")); } - @Test - @DisplayName("should return null for missing key") - void shouldReturnNullForMissingKey() { - String json = "{\"id\": 123}"; - assertNull(JsonUtil.getLong(json, "missing")); + @Test void missingKey() { + assertNull(JsonUtil.getLong("{\"id\": 123}", "missing")); } - @Test - @DisplayName("should return null for null json") - void shouldReturnNullForNullJson() { + @Test void nullJson() { assertNull(JsonUtil.getLong(null, "key")); } - @Test - @DisplayName("should extract epoch timestamp") - void shouldExtractEpochTimestamp() { - String json = "{\"created_at\": 1605530769}"; - assertEquals(1605530769L, JsonUtil.getLong(json, "created_at")); + @Test void nullKey() { + assertNull(JsonUtil.getLong("{\"a\": 1}", null)); } - @Test - @DisplayName("should extract resource_version (large long)") - void shouldExtractResourceVersion() { - String json = "{\"resource_version\": 1605530769000}"; - assertEquals(1605530769000L, JsonUtil.getLong(json, "resource_version")); + @Test void epochTimestamp() { + assertEquals(1605530769L, JsonUtil.getLong("{\"created_at\": 1605530769}", "created_at")); + } + + @Test void resourceVersion() { + assertEquals(1605530769000L, + JsonUtil.getLong("{\"resource_version\": 1605530769000}", "resource_version")); } } - // ========== getInteger Tests ========== + // ========== getInteger ========== @Nested - @DisplayName("getInteger Tests") + @DisplayName("getInteger") class GetIntegerTests { - @Test - @DisplayName("should extract positive integer") - void shouldExtractPositiveInteger() { - String json = "{\"count\": 42}"; - assertEquals(42, JsonUtil.getInteger(json, "count")); + @Test void positiveInteger() { + assertEquals(42, JsonUtil.getInteger("{\"count\": 42}", "count")); + } + + @Test void negativeInteger() { + assertEquals(-10, JsonUtil.getInteger("{\"offset\": -10}", "offset")); } - @Test - @DisplayName("should extract negative integer") - void shouldExtractNegativeInteger() { - String json = "{\"offset\": -10}"; - assertEquals(-10, JsonUtil.getInteger(json, "offset")); + @Test void missingKey() { + assertNull(JsonUtil.getInteger("{\"count\": 42}", "missing")); } - @Test - @DisplayName("should return null for missing key") - void shouldReturnNullForMissingKey() { - String json = "{\"count\": 42}"; - assertNull(JsonUtil.getInteger(json, "missing")); + @Test void nullJson() { + assertNull(JsonUtil.getInteger(null, "k")); + } + + @Test void nullKey() { + assertNull(JsonUtil.getInteger("{\"a\": 1}", null)); } } - // ========== getBoolean Tests ========== + // ========== getBoolean ========== @Nested - @DisplayName("getBoolean Tests") + @DisplayName("getBoolean") class GetBooleanTests { - @Test - @DisplayName("should extract true") - void shouldExtractTrue() { - String json = "{\"active\": true}"; - assertTrue(JsonUtil.getBoolean(json, "active")); + @Test void extractTrue() { + assertTrue(JsonUtil.getBoolean("{\"active\": true}", "active")); } - @Test - @DisplayName("should extract false") - void shouldExtractFalse() { - String json = "{\"deleted\": false}"; - assertFalse(JsonUtil.getBoolean(json, "deleted")); + @Test void extractFalse() { + assertFalse(JsonUtil.getBoolean("{\"deleted\": false}", "deleted")); } - @Test - @DisplayName("should return null for missing key") - void shouldReturnNullForMissingKey() { - String json = "{\"active\": true}"; - assertNull(JsonUtil.getBoolean(json, "missing")); + @Test void missingKey() { + assertNull(JsonUtil.getBoolean("{\"active\": true}", "missing")); } - @Test - @DisplayName("should return null for null json") - void shouldReturnNullForNullJson() { + @Test void nullJson() { assertNull(JsonUtil.getBoolean(null, "key")); } + + @Test void nullKey() { + assertNull(JsonUtil.getBoolean("{\"a\": true}", null)); + } } - // ========== getDouble Tests ========== + // ========== getDouble ========== @Nested - @DisplayName("getDouble Tests") + @DisplayName("getDouble") class GetDoubleTests { - @Test - @DisplayName("should extract positive double") - void shouldExtractPositiveDouble() { - String json = "{\"price\": 99.99}"; - assertEquals(99.99, JsonUtil.getDouble(json, "price"), 0.001); + @Test void positiveDouble() { + assertEquals(99.99, JsonUtil.getDouble("{\"price\": 99.99}", "price"), 0.001); + } + + @Test void negativeDouble() { + assertEquals(-123.45, JsonUtil.getDouble("{\"balance\": -123.45}", "balance"), 0.001); + } + + @Test void exchangeRate() { + assertEquals(1.0, JsonUtil.getDouble("{\"exchange_rate\": 1.0}", "exchange_rate"), 0.001); + } + + @Test void integerAsDouble() { + assertEquals(10000.0, JsonUtil.getDouble("{\"amount\": 10000}", "amount"), 0.001); + } + + @Test void missingKey() { + assertNull(JsonUtil.getDouble("{\"a\": 1.0}", "missing")); } - @Test - @DisplayName("should extract negative double") - void shouldExtractNegativeDouble() { - String json = "{\"balance\": -123.45}"; - assertEquals(-123.45, JsonUtil.getDouble(json, "balance"), 0.001); + @Test void nullJson() { + assertNull(JsonUtil.getDouble(null, "k")); } - @Test - @DisplayName("should extract exchange rate") - void shouldExtractExchangeRate() { - String json = "{\"exchange_rate\": 1.0}"; - assertEquals(1.0, JsonUtil.getDouble(json, "exchange_rate"), 0.001); + @Test void nullKey() { + assertNull(JsonUtil.getDouble("{\"a\": 1.0}", null)); + } + } + + // ========== getNumber ========== + @Nested + @DisplayName("getNumber") + class GetNumberTests { + + @Test void extractsValue() { + assertEquals(42.0, JsonUtil.getNumber("{\"n\": 42}", "n").doubleValue(), 0.001); + } + + @Test void nullJson() { + assertNull(JsonUtil.getNumber(null, "n")); } - @Test - @DisplayName("should extract integer as double") - void shouldExtractIntegerAsDouble() { - String json = "{\"amount\": 10000}"; - assertEquals(10000.0, JsonUtil.getDouble(json, "amount"), 0.001); + @Test void nullKey() { + assertNull(JsonUtil.getNumber("{\"n\": 1}", null)); } } - // ========== getBigDecimal Tests ========== + // ========== getBigDecimal ========== @Nested - @DisplayName("getBigDecimal Tests") + @DisplayName("getBigDecimal") class GetBigDecimalTests { - @Test - @DisplayName("should extract decimal value") - void shouldExtractDecimalValue() { - String json = "{\"amount\": 1234.56}"; - assertEquals(new BigDecimal("1234.56"), JsonUtil.getBigDecimal(json, "amount")); + @Test void decimalValue() { + assertEquals(new BigDecimal("1234.56"), + JsonUtil.getBigDecimal("{\"amount\": 1234.56}", "amount")); + } + + @Test void integerAsBigDecimal() { + assertEquals(new BigDecimal("10000"), + JsonUtil.getBigDecimal("{\"amount\": 10000}", "amount")); + } + + @Test void missingKey() { + assertNull(JsonUtil.getBigDecimal("{\"a\": 1}", "missing")); + } + + @Test void nullJson() { + assertNull(JsonUtil.getBigDecimal(null, "k")); } - @Test - @DisplayName("should extract integer as BigDecimal") - void shouldExtractIntegerAsBigDecimal() { - String json = "{\"amount\": 10000}"; - assertEquals(new BigDecimal("10000"), JsonUtil.getBigDecimal(json, "amount")); + @Test void nullKey() { + assertNull(JsonUtil.getBigDecimal("{\"a\": 1}", null)); } } - // ========== getObject Tests ========== + // ========== getTimestamp ========== @Nested - @DisplayName("getObject Tests") + @DisplayName("getTimestamp") + class GetTimestampTests { + + @Test void epochSecondsToTimestamp() { + Timestamp result = JsonUtil.getTimestamp("{\"created_at\": 1605530769}", "created_at"); + assertNotNull(result); + assertEquals(1605530769000L, result.getTime()); + } + + @Test void missingKey() { + assertNull(JsonUtil.getTimestamp("{\"updated_at\": 1605530769}", "created_at")); + } + } + + // ========== getObject ========== + @Nested + @DisplayName("getObject") class GetObjectTests { - @Test - @DisplayName("should extract simple nested object") - void shouldExtractSimpleNestedObject() { - String json = "{\"customer\": {\"id\": \"cust_123\", \"name\": \"John\"}}"; - String result = JsonUtil.getObject(json, "customer"); + @Test void simpleNestedObject() { + String result = JsonUtil.getObject( + "{\"customer\": {\"id\": \"cust_123\", \"name\": \"John\"}}", "customer"); assertNotNull(result); assertTrue(result.contains("\"id\": \"cust_123\"")); - assertTrue(result.contains("\"name\": \"John\"")); } - @Test - @DisplayName("should extract deeply nested object") - void shouldExtractDeeplyNestedObject() { - String json = "{\"data\": {\"customer\": {\"address\": {\"city\": \"NYC\"}}}}"; - String dataObj = JsonUtil.getObject(json, "data"); + @Test void deeplyNestedObject() { + String dataObj = JsonUtil.getObject( + "{\"data\": {\"customer\": {\"address\": {\"city\": \"NYC\"}}}}", "data"); assertNotNull(dataObj); - String customerObj = JsonUtil.getObject(dataObj, "customer"); - assertNotNull(customerObj); - String addressObj = JsonUtil.getObject(customerObj, "address"); - assertNotNull(addressObj); + String addressObj = JsonUtil.getObject( + JsonUtil.getObject(dataObj, "customer"), "address"); assertEquals("NYC", JsonUtil.getString(addressObj, "city")); } - @Test - @DisplayName("should handle object with nested arrays") - void shouldHandleObjectWithNestedArrays() { - String json = "{\"transaction\": {\"id\": \"txn_123\", \"linked_invoices\": [{\"id\": \"inv_1\"}]}}"; - String result = JsonUtil.getObject(json, "transaction"); + @Test void objectWithNestedArrays() { + String result = JsonUtil.getObject( + "{\"txn\": {\"id\": \"t1\", \"invoices\": [{\"id\": \"i1\"}]}}", "txn"); assertNotNull(result); - assertTrue(result.contains("linked_invoices")); - assertTrue(result.contains("inv_1")); + assertTrue(result.contains("invoices")); } - @Test - @DisplayName("should handle object with escaped strings") - void shouldHandleObjectWithEscapedStrings() { - String json = "{\"data\": {\"text\": \"Hello \\\"World\\\"\"}}"; - String result = JsonUtil.getObject(json, "data"); + @Test void objectWithEscapedStrings() { + String result = JsonUtil.getObject( + "{\"data\": {\"text\": \"Hello \\\"World\\\"\"}}", "data"); assertNotNull(result); assertTrue(result.contains("Hello \\\"World\\\"")); } - @Test - @DisplayName("should return null for missing key") - void shouldReturnNullForMissingKey() { - String json = "{\"customer\": {\"id\": \"123\"}}"; - assertNull(JsonUtil.getObject(json, "missing")); + @Test void objectWithBackslashInsideValue() { + String obj = JsonUtil.getObject( + "{\"obj\": {\"path\": \"C:\\\\Users\\\\test\"}}", "obj"); + assertNotNull(obj); + assertTrue(obj.contains("C:\\\\Users")); + } + + @Test void missingKey() { + assertNull(JsonUtil.getObject("{\"customer\": {\"id\": \"123\"}}", "missing")); + } + + @Test void valueIsNotObject() { + assertNull(JsonUtil.getObject("{\"name\": \"John\"}", "name")); + } + + @Test void emptyObject() { + assertEquals("{}", JsonUtil.getObject("{\"metadata\": {}}", "metadata")); + } + + @Test void nullJson() { + assertNull(JsonUtil.getObject(null, "k")); } - @Test - @DisplayName("should return null when value is not object") - void shouldReturnNullWhenNotObject() { - String json = "{\"name\": \"John\"}"; - assertNull(JsonUtil.getObject(json, "name")); + @Test void nullKey() { + assertNull(JsonUtil.getObject("{\"a\": {}}", null)); } - @Test - @DisplayName("should extract empty object") - void shouldExtractEmptyObject() { - String json = "{\"metadata\": {}}"; - assertEquals("{}", JsonUtil.getObject(json, "metadata")); + @Test void unterminatedObject() { + assertNull(JsonUtil.getObject("{\"k\": {\"a\": 1", "k")); + } + + @Test void valueIsScalar() { + assertNull(JsonUtil.getObject("{\"k\": 123}", "k")); } } - // ========== getArray Tests - THE CRITICAL FIX ========== + // ========== getArray ========== @Nested - @DisplayName("getArray Tests") + @DisplayName("getArray") class GetArrayTests { - @Test - @DisplayName("should extract simple array of strings") - void shouldExtractSimpleArrayOfStrings() { - String json = "{\"tags\": [\"a\", \"b\", \"c\"]}"; - String result = JsonUtil.getArray(json, "tags"); - assertNotNull(result); - assertEquals("[\"a\", \"b\", \"c\"]", result); + @Test void simpleArrayOfStrings() { + assertEquals("[\"a\", \"b\", \"c\"]", + JsonUtil.getArray("{\"tags\": [\"a\", \"b\", \"c\"]}", "tags")); } - @Test - @DisplayName("should extract empty array") - void shouldExtractEmptyArray() { - String json = "{\"items\": []}"; - assertEquals("[]", JsonUtil.getArray(json, "items")); + @Test void emptyArray() { + assertEquals("[]", JsonUtil.getArray("{\"items\": []}", "items")); } - @Test - @DisplayName("should extract array of objects") - void shouldExtractArrayOfObjects() { - String json = "{\"list\": [{\"id\": 1}, {\"id\": 2}]}"; - String result = JsonUtil.getArray(json, "list"); + @Test void arrayOfObjects() { + String result = JsonUtil.getArray("{\"list\": [{\"id\": 1}, {\"id\": 2}]}", "list"); assertNotNull(result); - assertTrue(result.startsWith("[")); - assertTrue(result.endsWith("]")); assertTrue(result.contains("{\"id\": 1}")); - assertTrue(result.contains("{\"id\": 2}")); } - @Test - @DisplayName("should handle array with nested arrays - THE BUG FIX TEST") - void shouldHandleArrayWithNestedArrays() { - // This is the exact case that was failing before the fix! - String json = "{\"list\": [{\"transaction\": {\"linked_invoices\": [{\"id\": \"inv_1\"}], \"linked_refunds\": []}}]}"; + @Test void arrayWithNestedArrays() { + String json = "{\"list\": [{\"txn\": {\"inv\": [{\"id\": \"i1\"}], \"ref\": []}}]}"; String result = JsonUtil.getArray(json, "list"); assertNotNull(result); - assertTrue(result.startsWith("[")); - assertTrue(result.endsWith("]")); - // Verify the entire array is extracted, not truncated at first ] - assertTrue(result.contains("linked_invoices")); - assertTrue(result.contains("linked_refunds")); - assertTrue(result.contains("inv_1")); - } - - @Test - @DisplayName("should handle complex nested structure from transaction API") - void shouldHandleComplexNestedStructure() { - // Real-world example from the bug report - String json = "{\"list\": [{\"transaction\": {" + - "\"id\": \"txn_AzZhUGSPAkLskJQo\"," + - "\"customer_id\": \"cbdemo_dave\"," + - "\"amount\": 10000," + - "\"linked_invoices\": [{" + - "\"invoice_id\": \"DemoInv_103\"," + - "\"applied_amount\": 10000," + - "\"applied_at\": 1605530769" + - "}]," + - "\"linked_refunds\": []," + - "\"payment_method_details\": \"{\\\"card\\\":{\\\"iin\\\":\\\"555555\\\"}}\"" + - "}}]}"; - + assertTrue(result.contains("inv")); + assertTrue(result.contains("ref")); + } + + @Test void complexNestedStructure() { + String json = "{\"list\": [{\"transaction\": {" + + "\"id\": \"txn_1\"," + + "\"linked_invoices\": [{\"invoice_id\": \"inv_1\"}]," + + "\"linked_refunds\": []," + + "\"details\": \"{\\\"card\\\":{\\\"iin\\\":\\\"555\\\"}}\"" + + "}}]}"; String result = JsonUtil.getArray(json, "list"); assertNotNull(result); - assertTrue(result.startsWith("[")); - assertTrue(result.endsWith("]")); - - // Critical: verify we got the complete array, not truncated - assertTrue(result.contains("txn_AzZhUGSPAkLskJQo")); - assertTrue(result.contains("linked_invoices")); - assertTrue(result.contains("DemoInv_103")); + assertTrue(result.contains("txn_1")); assertTrue(result.contains("linked_refunds")); } - @Test - @DisplayName("should handle array with deeply nested objects") - void shouldHandleArrayWithDeeplyNestedObjects() { - String json = "{\"data\": [{\"level1\": {\"level2\": {\"level3\": [{\"value\": 1}]}}}]}"; + @Test void arrayWithDeeplyNestedObjects() { + String json = "{\"data\": [{\"l1\": {\"l2\": {\"l3\": [{\"v\": 1}]}}}]}"; String result = JsonUtil.getArray(json, "data"); assertNotNull(result); - assertTrue(result.contains("level1")); - assertTrue(result.contains("level2")); - assertTrue(result.contains("level3")); - assertTrue(result.contains("value")); + assertTrue(result.contains("l3")); } - @Test - @DisplayName("should handle array with escaped strings containing brackets") - void shouldHandleArrayWithEscapedBrackets() { - String json = "{\"messages\": [\"text with [brackets]\", \"another [one]\"]}"; - String result = JsonUtil.getArray(json, "messages"); + @Test void escapedBracketsInStrings() { + String result = JsonUtil.getArray( + "{\"msg\": [\"text [brackets]\", \"another [one]\"]}", "msg"); assertNotNull(result); assertTrue(result.contains("[brackets]")); - assertTrue(result.contains("[one]")); } - @Test - @DisplayName("should handle array with JSON string field containing brackets") - void shouldHandleArrayWithJsonStringField() { - // payment_method_details contains a JSON string with brackets - String json = "{\"list\": [{\"details\": \"{\\\"array\\\":[1,2,3]}\"}]}"; - String result = JsonUtil.getArray(json, "list"); + @Test void jsonStringFieldContainingBrackets() { + String result = JsonUtil.getArray( + "{\"list\": [{\"d\": \"{\\\"arr\\\":[1,2,3]}\"}]}", "list"); assertNotNull(result); - assertTrue(result.contains("details")); } - @Test - @DisplayName("should return null for missing array key") - void shouldReturnNullForMissingKey() { - String json = "{\"data\": [1, 2, 3]}"; - assertNull(JsonUtil.getArray(json, "missing")); + @Test void arrayWithBackslashInsideValue() { + assertNotNull(JsonUtil.getArray("{\"arr\": [\"a\\\\b\", \"c\\\"d\"]}", "arr")); } - @Test - @DisplayName("should return null when value is not array") - void shouldReturnNullWhenNotArray() { - String json = "{\"name\": \"John\"}"; - assertNull(JsonUtil.getArray(json, "name")); + @Test void missingKey() { + assertNull(JsonUtil.getArray("{\"data\": [1, 2, 3]}", "missing")); } - @Test - @DisplayName("should handle multiple arrays in same object") - void shouldHandleMultipleArrays() { - String json = "{\"first\": [1, 2], \"second\": [3, 4], \"third\": [5, 6]}"; - assertEquals("[1, 2]", JsonUtil.getArray(json, "first")); - assertEquals("[3, 4]", JsonUtil.getArray(json, "second")); - assertEquals("[5, 6]", JsonUtil.getArray(json, "third")); + @Test void valueIsNotArray() { + assertNull(JsonUtil.getArray("{\"name\": \"John\"}", "name")); } - @Test - @DisplayName("should handle array with null values") - void shouldHandleArrayWithNulls() { - String json = "{\"values\": [null, \"a\", null, \"b\"]}"; - String result = JsonUtil.getArray(json, "values"); + @Test void multipleArrays() { + String json = "{\"a\": [1, 2], \"b\": [3, 4], \"c\": [5, 6]}"; + assertEquals("[1, 2]", JsonUtil.getArray(json, "a")); + assertEquals("[3, 4]", JsonUtil.getArray(json, "b")); + assertEquals("[5, 6]", JsonUtil.getArray(json, "c")); + } + + @Test void arrayWithNulls() { + String result = JsonUtil.getArray("{\"v\": [null, \"a\", null]}", "v"); assertNotNull(result); assertTrue(result.contains("null")); } + + @Test void nullJson() { + assertNull(JsonUtil.getArray(null, "k")); + } + + @Test void nullKey() { + assertNull(JsonUtil.getArray("{\"a\": []}", null)); + } + + @Test void unterminatedArray() { + assertNull(JsonUtil.getArray("{\"k\": [1, 2", "k")); + } + + @Test void valueIsScalar() { + assertNull(JsonUtil.getArray("{\"k\": 123}", "k")); + } + } + + // ========== hasValue ========== + @Nested + @DisplayName("hasValue") + class HasValueTests { + + @Test void existingNonNullValue() { + assertTrue(JsonUtil.hasValue("{\"name\": \"John\", \"age\": 30}", "name")); + assertTrue(JsonUtil.hasValue("{\"name\": \"John\", \"age\": 30}", "age")); + } + + @Test void nullValue() { + assertFalse(JsonUtil.hasValue("{\"name\": null}", "name")); + } + + @Test void missingKey() { + assertFalse(JsonUtil.hasValue("{\"name\": \"John\"}", "missing")); + } + + @Test void nullJson() { + assertFalse(JsonUtil.hasValue(null, "k")); + } + + @Test void nullKey() { + assertFalse(JsonUtil.hasValue("{\"a\": 1}", null)); + } } - // ========== parseObjectArray Tests ========== + // ========== parseObjectArray ========== @Nested - @DisplayName("parseObjectArray Tests") + @DisplayName("parseObjectArray") class ParseObjectArrayTests { - @Test - @DisplayName("should parse array of simple objects") - void shouldParseArrayOfSimpleObjects() { - String arrayJson = "[{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]"; - List objects = JsonUtil.parseObjectArray(arrayJson); + @Test void simpleObjects() { + List objects = JsonUtil.parseObjectArray("[{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]"); assertEquals(3, objects.size()); assertTrue(objects.get(0).contains("\"id\": 1")); - assertTrue(objects.get(1).contains("\"id\": 2")); - assertTrue(objects.get(2).contains("\"id\": 3")); } - @Test - @DisplayName("should parse array of complex objects") - void shouldParseArrayOfComplexObjects() { - String arrayJson = "[{\"transaction\": {\"id\": \"txn_1\", \"items\": [1, 2]}}, {\"transaction\": {\"id\": \"txn_2\"}}]"; - List objects = JsonUtil.parseObjectArray(arrayJson); + @Test void complexObjects() { + List objects = JsonUtil.parseObjectArray( + "[{\"txn\": {\"id\": \"t1\", \"items\": [1, 2]}}, {\"txn\": {\"id\": \"t2\"}}]"); assertEquals(2, objects.size()); - assertTrue(objects.get(0).contains("txn_1")); - assertTrue(objects.get(1).contains("txn_2")); + assertTrue(objects.get(0).contains("t1")); } - @Test - @DisplayName("should return empty list for null input") - void shouldReturnEmptyListForNull() { - List objects = JsonUtil.parseObjectArray(null); - assertTrue(objects.isEmpty()); + @Test void nullInput() { + assertTrue(JsonUtil.parseObjectArray(null).isEmpty()); } - @Test - @DisplayName("should return empty list for empty array") - void shouldReturnEmptyListForEmptyArray() { - List objects = JsonUtil.parseObjectArray("[]"); - assertTrue(objects.isEmpty()); + @Test void emptyArray() { + assertTrue(JsonUtil.parseObjectArray("[]").isEmpty()); } - @Test - @DisplayName("should return empty list for non-array input") - void shouldReturnEmptyListForNonArray() { - List objects = JsonUtil.parseObjectArray("{\"id\": 1}"); - assertTrue(objects.isEmpty()); + @Test void nonArrayInput() { + assertTrue(JsonUtil.parseObjectArray("{\"id\": 1}").isEmpty()); } - @Test - @DisplayName("should handle objects with escaped strings") - void shouldHandleObjectsWithEscapedStrings() { - String arrayJson = "[{\"text\": \"Hello \\\"World\\\"\"}]"; - List objects = JsonUtil.parseObjectArray(arrayJson); + @Test void objectsWithEscapedStrings() { + List objects = JsonUtil.parseObjectArray("[{\"text\": \"Hello \\\"World\\\"\"}]"); assertEquals(1, objects.size()); assertTrue(objects.get(0).contains("Hello \\\"World\\\"")); } } - // ========== parseArrayOf* Tests ========== + // ========== parseArrayOf* ========== @Nested - @DisplayName("parseArrayOf* Tests") + @DisplayName("parseArrayOf*") class ParseArrayOfTests { - @Test - @DisplayName("should parse array of strings") - void shouldParseArrayOfStrings() { - String arrayJson = "[\"a\", \"b\", \"c\"]"; - List result = JsonUtil.parseArrayOfString(arrayJson); + @Test void arrayOfStrings() { + List result = JsonUtil.parseArrayOfString("[\"a\", \"b\", \"c\"]"); assertEquals(3, result.size()); assertEquals("a", result.get(0)); - assertEquals("b", result.get(1)); - assertEquals("c", result.get(2)); } - @Test - @DisplayName("should parse array of integers") - void shouldParseArrayOfIntegers() { - String arrayJson = "[1, 2, 3, -4, 0]"; - List result = JsonUtil.parseArrayOfInteger(arrayJson); + @Test void arrayOfIntegers() { + List result = JsonUtil.parseArrayOfInteger("[1, 2, 3, -4, 0]"); assertEquals(5, result.size()); - assertEquals(1, result.get(0)); assertEquals(-4, result.get(3)); - assertEquals(0, result.get(4)); } - @Test - @DisplayName("should parse array of longs") - void shouldParseArrayOfLongs() { - String arrayJson = "[1605530769000, 1605530770000]"; - List result = JsonUtil.parseArrayOfLong(arrayJson); + @Test void arrayOfLongs() { + List result = JsonUtil.parseArrayOfLong("[1605530769000, 1605530770000]"); assertEquals(2, result.size()); assertEquals(1605530769000L, result.get(0)); } - @Test - @DisplayName("should parse array of booleans") - void shouldParseArrayOfBooleans() { - String arrayJson = "[true, false, true]"; - List result = JsonUtil.parseArrayOfBoolean(arrayJson); + @Test void arrayOfBooleans() { + List result = JsonUtil.parseArrayOfBoolean("[true, false, true]"); assertEquals(3, result.size()); assertTrue(result.get(0)); assertFalse(result.get(1)); - assertTrue(result.get(2)); } - @Test - @DisplayName("should parse array of doubles") - void shouldParseArrayOfDoubles() { - String arrayJson = "[1.5, 2.7, 3.14]"; - List result = JsonUtil.parseArrayOfDouble(arrayJson); + @Test void arrayOfDoubles() { + List result = JsonUtil.parseArrayOfDouble("[1.5, 2.7, 3.14]"); assertEquals(3, result.size()); assertEquals(1.5, result.get(0), 0.001); } - @Test - @DisplayName("should parse array of BigDecimal") - void shouldParseArrayOfBigDecimal() { - String arrayJson = "[123.45, 678.90]"; - List result = JsonUtil.parseArrayOfBigDecimal(arrayJson); + @Test void arrayOfBigDecimal() { + List result = JsonUtil.parseArrayOfBigDecimal("[123.45, 678.90]"); assertEquals(2, result.size()); } - @Test - @DisplayName("should return empty list for empty arrays") - void shouldReturnEmptyListForEmptyArrays() { + @Test void emptyArrays() { assertTrue(JsonUtil.parseArrayOfString("[]").isEmpty()); assertTrue(JsonUtil.parseArrayOfInteger("[]").isEmpty()); assertTrue(JsonUtil.parseArrayOfLong("[]").isEmpty()); assertTrue(JsonUtil.parseArrayOfBoolean("[]").isEmpty()); assertTrue(JsonUtil.parseArrayOfDouble("[]").isEmpty()); + assertTrue(JsonUtil.parseArrayOfBigDecimal("[]").isEmpty()); } - @Test - @DisplayName("should return empty list for null input") - void shouldReturnEmptyListForNull() { + @Test void nullInputs() { assertTrue(JsonUtil.parseArrayOfString(null).isEmpty()); assertTrue(JsonUtil.parseArrayOfInteger(null).isEmpty()); + assertTrue(JsonUtil.parseArrayOfLong(null).isEmpty()); + assertTrue(JsonUtil.parseArrayOfBoolean(null).isEmpty()); + assertTrue(JsonUtil.parseArrayOfDouble(null).isEmpty()); + assertTrue(JsonUtil.parseArrayOfBigDecimal(null).isEmpty()); } } - // ========== getTimestamp Tests ========== + // ========== parseJsonObjectToMap ========== @Nested - @DisplayName("getTimestamp Tests") - class GetTimestampTests { + @DisplayName("parseJsonObjectToMap") + class ParseJsonObjectToMapTests { - @Test - @DisplayName("should parse epoch seconds to timestamp") - void shouldParseEpochSecondsToTimestamp() { - String json = "{\"created_at\": 1605530769}"; - Timestamp result = JsonUtil.getTimestamp(json, "created_at"); - assertNotNull(result); - assertEquals(1605530769000L, result.getTime()); + @Test void simpleObject() { + Map map = JsonUtil.parseJsonObjectToMap( + "{\"name\": \"John\", \"age\": 30, \"active\": true}"); + assertEquals("John", map.get("name")); + assertEquals(30L, map.get("age")); + assertEquals(true, map.get("active")); } - @Test - @DisplayName("should return null for missing key") - void shouldReturnNullForMissingKey() { - String json = "{\"updated_at\": 1605530769}"; - assertNull(JsonUtil.getTimestamp(json, "created_at")); + @Test void nestedObjects() { + Map map = JsonUtil.parseJsonObjectToMap("{\"user\": {\"name\": \"John\"}}"); + assertTrue(map.get("user") instanceof String); + assertTrue(((String) map.get("user")).contains("name")); } - } - // ========== hasValue Tests ========== - @Nested - @DisplayName("hasValue Tests") - class HasValueTests { + @Test void arraysInMap() { + Map map = JsonUtil.parseJsonObjectToMap("{\"tags\": [\"a\", \"b\"]}"); + assertTrue(((String) map.get("tags")).contains("\"a\"")); + } - @Test - @DisplayName("should return true for existing non-null value") - void shouldReturnTrueForExistingValue() { - String json = "{\"name\": \"John\", \"age\": 30}"; - assertTrue(JsonUtil.hasValue(json, "name")); - assertTrue(JsonUtil.hasValue(json, "age")); + @Test void emptyObject() { + assertTrue(JsonUtil.parseJsonObjectToMap("{}").isEmpty()); } - @Test - @DisplayName("should return false for null value") - void shouldReturnFalseForNullValue() { - String json = "{\"name\": null}"; - assertFalse(JsonUtil.hasValue(json, "name")); + @Test void nullInput() { + assertTrue(JsonUtil.parseJsonObjectToMap(null).isEmpty()); } - @Test - @DisplayName("should return false for missing key") - void shouldReturnFalseForMissingKey() { - String json = "{\"name\": \"John\"}"; - assertFalse(JsonUtil.hasValue(json, "missing")); + @Test void emptyStringInput() { + assertTrue(JsonUtil.parseJsonObjectToMap("").isEmpty()); } - } - // ========== parseJsonObjectToMap Tests ========== - @Nested - @DisplayName("parseJsonObjectToMap Tests") - class ParseJsonObjectToMapTests { + @Test void whitespaceOnlyContent() { + assertTrue(JsonUtil.parseJsonObjectToMap("{ }").isEmpty()); + } - @Test - @DisplayName("should parse simple object to map") - void shouldParseSimpleObjectToMap() { - String json = "{\"name\": \"John\", \"age\": 30, \"active\": true}"; - Map map = JsonUtil.parseJsonObjectToMap(json); - assertEquals("John", map.get("name")); - assertEquals(30L, map.get("age")); - assertEquals(true, map.get("active")); + @Test void nullValues() { + Map map = JsonUtil.parseJsonObjectToMap("{\"value\": null}"); + assertTrue(map.containsKey("value")); + assertNull(map.get("value")); } - @Test - @DisplayName("should handle nested objects") - void shouldHandleNestedObjects() { - String json = "{\"user\": {\"name\": \"John\"}}"; - Map map = JsonUtil.parseJsonObjectToMap(json); - assertTrue(map.get("user") instanceof String); - assertTrue(((String) map.get("user")).contains("name")); + @Test void doubleValues() { + Map map = JsonUtil.parseJsonObjectToMap("{\"price\": 99.99}"); + assertEquals(99.99, (Double) map.get("price"), 0.001); } - @Test - @DisplayName("should handle arrays in map") - void shouldHandleArraysInMap() { - String json = "{\"tags\": [\"a\", \"b\"]}"; - Map map = JsonUtil.parseJsonObjectToMap(json); - assertTrue(map.get("tags") instanceof String); - assertTrue(((String) map.get("tags")).contains("\"a\"")); + @Test void booleanFalse() { + Map map = JsonUtil.parseJsonObjectToMap("{\"flag\": false}"); + assertEquals(Boolean.FALSE, map.get("flag")); + } + + @Test void negativeNumber() { + assertEquals(-42L, JsonUtil.parseJsonObjectToMap("{\"val\": -42}").get("val")); + } + + @Test void escapedStringValue() { + Map map = JsonUtil.parseJsonObjectToMap( + "{\"text\": \"hello\\\\world\\\"quoted\\\"\"}"); + assertEquals("hello\\world\"quoted\"", map.get("text")); + } + + @Test void escapedKey() { + Map map = JsonUtil.parseJsonObjectToMap("{\"k\\\"ey\": \"val\"}"); + assertEquals("val", map.get("k\"ey")); + } + + @Test void nestedObjectWithEscapedStrings() { + assertNotNull(JsonUtil.parseJsonObjectToMap( + "{\"obj\": {\"k\": \"v\\\\x\\\"y\"}}").get("obj")); + } + + @Test void nestedArrayWithEscapedStrings() { + assertNotNull(JsonUtil.parseJsonObjectToMap( + "{\"arr\": [\"a\\\\b\", \"c\\\"d\"]}").get("arr")); + } + + @Test void nestedObjBackslashInString() { + assertNotNull(JsonUtil.parseJsonObjectToMap( + "{\"obj\": {\"path\": \"C:\\\\Users\"}}").get("obj")); + } + + @Test void nestedArrayBackslashInString() { + assertNotNull(JsonUtil.parseJsonObjectToMap( + "{\"arr\": [\"path\\\\to\\\"file\"]}").get("arr")); + } + + @Test void deeplyNestedObject() { + String nested = (String) JsonUtil.parseJsonObjectToMap( + "{\"a\": {\"b\": {\"c\": 1}}}").get("a"); + assertNotNull(nested); + assertTrue(nested.contains("\"b\"")); + } + + @Test void deeplyNestedArray() { + assertNotNull(JsonUtil.parseJsonObjectToMap("{\"a\": [[1, 2], [3, 4]]}").get("a")); + } + + @Test void scientificNotation() { + assertEquals(150.0, + (Double) JsonUtil.parseJsonObjectToMap("{\"val\": 1.5e2}").get("val"), 0.001); + } + + @Test void scientificE() { + assertEquals(2500.0, + (Double) JsonUtil.parseJsonObjectToMap("{\"v\": 2.5E3}").get("v"), 0.001); + } + + @Test void numberPlusSign() { + assertEquals(100.0, + (Double) JsonUtil.parseJsonObjectToMap("{\"v\": 1e+2}").get("v"), 0.001); + } + + @Test void noBraces() { + assertNotNull(JsonUtil.parseJsonObjectToMap("\"key\": \"val\"")); } - @Test - @DisplayName("should return empty map for empty object") - void shouldReturnEmptyMapForEmptyObject() { - Map map = JsonUtil.parseJsonObjectToMap("{}"); - assertTrue(map.isEmpty()); + @Test void unknownValueType() { + assertTrue(JsonUtil.parseJsonObjectToMap("{\"k\": undefined}").containsKey("k")); } - @Test - @DisplayName("should return empty map for null") - void shouldReturnEmptyMapForNull() { - Map map = JsonUtil.parseJsonObjectToMap(null); - assertTrue(map.isEmpty()); + @Test void leadingWhitespaceKeys() { + Map map = JsonUtil.parseJsonObjectToMap("{ \"a\" : 1 , \"b\" : 2 }"); + assertEquals(1L, map.get("a")); + assertEquals(2L, map.get("b")); } - @Test - @DisplayName("should handle null values") - void shouldHandleNullValues() { - String json = "{\"value\": null}"; + @Test void customFieldsAtRootOnly() { + String json = "{" + + "\"id\": \"inv_1\"," + + "\"billing_address\": {\"cf_addr\": \"nested\"}," + + "\"line_items\": [{\"cf_line\": \"also_nested\"}]," + + "\"cf_root\": \"visible\"" + + "}"; Map map = JsonUtil.parseJsonObjectToMap(json); - assertTrue(map.containsKey("value")); - assertNull(map.get("value")); + assertEquals("visible", map.get("cf_root")); + assertFalse(map.containsKey("cf_addr")); + assertFalse(map.containsKey("cf_line")); } - @Test - @DisplayName("should handle double values") - void shouldHandleDoubleValues() { - String json = "{\"price\": 99.99}"; + @Test void customFieldVariousTypes() { + String json = "{\"cf_str\": \"text\", \"cf_num\": 5, \"cf_dec\": 99.5," + + "\"cf_bool\": true, \"cf_nil\": null}"; Map map = JsonUtil.parseJsonObjectToMap(json); - assertEquals(99.99, (Double) map.get("price"), 0.001); + assertEquals("text", map.get("cf_str")); + assertEquals(5L, map.get("cf_num")); + assertEquals(99.5, (Double) map.get("cf_dec"), 0.001); + assertEquals(true, map.get("cf_bool")); + assertNull(map.get("cf_nil")); } } - // ========== toJson Tests ========== + // ========== toJson ========== @Nested - @DisplayName("toJson Tests") + @DisplayName("toJson") class ToJsonTests { - @Test - @DisplayName("should serialize map to JSON") - void shouldSerializeMapToJson() { + @Test void serializeMap() { Map map = new java.util.LinkedHashMap<>(); map.put("name", "John"); map.put("age", 30); - String json = JsonUtil.toJson(map); assertTrue(json.contains("\"name\":\"John\"")); assertTrue(json.contains("\"age\":30")); } - @Test - @DisplayName("should serialize list to JSON") - void shouldSerializeListToJson() { - List list = java.util.Arrays.asList("a", "b", "c"); - String json = JsonUtil.toJson(list); - assertEquals("[\"a\",\"b\",\"c\"]", json); + @Test void serializeList() { + assertEquals("[\"a\",\"b\",\"c\"]", + JsonUtil.toJson(java.util.Arrays.asList("a", "b", "c"))); } - @Test - @DisplayName("should serialize empty map") - void shouldSerializeEmptyMap() { + @Test void emptyMap() { assertEquals("{}", JsonUtil.toJson(new java.util.HashMap<>())); } - @Test - @DisplayName("should serialize empty list") - void shouldSerializeEmptyList() { + @Test void emptyList() { assertEquals("[]", JsonUtil.toJson(new java.util.ArrayList<>())); } - @Test - @DisplayName("should escape special characters") - void shouldEscapeSpecialCharacters() { - Map map = new java.util.HashMap<>(); - map.put("text", "Hello \"World\"\nNew line"); - - String json = JsonUtil.toJson(map); - assertTrue(json.contains("\\\"World\\\"")); - assertTrue(json.contains("\\n")); + @Test void nullMap() { + assertEquals("{}", JsonUtil.toJson((Map) null)); } - @Test - @DisplayName("should serialize null values") - void shouldSerializeNullValues() { - Map map = new java.util.HashMap<>(); - map.put("value", null); - - String json = JsonUtil.toJson(map); - assertTrue(json.contains("\"value\":null")); + @Test void nullList() { + assertEquals("[]", JsonUtil.toJson((List) null)); } - @Test - @DisplayName("should serialize nested structures") - void shouldSerializeNestedStructures() { + @Test void nullValues() { + Map m = new java.util.HashMap<>(); + m.put("value", null); + assertTrue(JsonUtil.toJson(m).contains("\"value\":null")); + } + + @Test void booleanValue() { + Map m = new java.util.HashMap<>(); + m.put("flag", true); + assertTrue(JsonUtil.toJson(m).contains("true")); + } + + @Test void listValue() { + Map m = new java.util.LinkedHashMap<>(); + m.put("items", java.util.Arrays.asList("a", "b")); + assertTrue(JsonUtil.toJson(m).contains("[\"a\",\"b\"]")); + } + + @Test void nestedMapValue() { Map inner = new java.util.HashMap<>(); inner.put("id", 123); - Map outer = new java.util.HashMap<>(); outer.put("data", inner); - String json = JsonUtil.toJson(outer); assertTrue(json.contains("\"data\":{")); assertTrue(json.contains("\"id\":123")); } + + @Test void unknownType() { + Map m = new java.util.HashMap<>(); + m.put("ts", java.sql.Timestamp.valueOf("2020-01-01 00:00:00")); + assertTrue(JsonUtil.toJson(m).contains("2020")); + } + + @Test @SuppressWarnings("all") + void nullKeyInMap() { + Map m = new java.util.HashMap<>(); + m.put(null, "val"); + assertNotNull(JsonUtil.toJson(m)); + } + + @Test void escapeQuotes() { + Map m = new java.util.HashMap<>(); + m.put("t", "Hello \"World\"\nNew line"); + String json = JsonUtil.toJson(m); + assertTrue(json.contains("\\\"World\\\"")); + assertTrue(json.contains("\\n")); + } + + @Test void escapeBackslash() { + Map m = new java.util.HashMap<>(); + m.put("p", "a\\b"); + assertTrue(JsonUtil.toJson(m).contains("a\\\\b")); + } + + @Test void escapeTab() { + Map m = new java.util.HashMap<>(); + m.put("t", "a\tb"); + assertTrue(JsonUtil.toJson(m).contains("\\t")); + } + + @Test void escapeCarriageReturn() { + Map m = new java.util.HashMap<>(); + m.put("r", "a\rb"); + assertTrue(JsonUtil.toJson(m).contains("\\r")); + } + + @Test void escapeBackspace() { + Map m = new java.util.HashMap<>(); + m.put("b", "a\bb"); + assertTrue(JsonUtil.toJson(m).contains("\\b")); + } + + @Test void escapeFormFeed() { + Map m = new java.util.HashMap<>(); + m.put("f", "a\fb"); + assertTrue(JsonUtil.toJson(m).contains("\\f")); + } + + @Test void escapeControlChar() { + Map m = new java.util.HashMap<>(); + m.put("c", "a\u0001b"); + assertTrue(JsonUtil.toJson(m).contains("\\u0001")); + } + } + + // ========== Top-Level Key Resolution ========== + @Nested + @DisplayName("Top-Level Key Resolution (duplicate keys across nesting)") + class TopLevelKeyResolutionTests { + + @Test void getString_shadowedByNestedObject() { + assertEquals("outer", + JsonUtil.getString("{\"child\": {\"id\": \"inner\"}, \"id\": \"outer\"}", "id")); + } + + @Test void getString_shadowedByNestedArray() { + assertEquals("top", + JsonUtil.getString("{\"items\": [{\"id\": \"a\"}], \"id\": \"top\"}", "id")); + } + + @Test void getString_topLevelFirst() { + assertEquals("top", + JsonUtil.getString("{\"id\": \"top\", \"child\": {\"id\": \"inner\"}}", "id")); + } + + @Test void getString_deeplyNested() { + assertEquals("top", JsonUtil.getString( + "{\"l1\": {\"l2\": {\"status\": \"deep\"}}, \"status\": \"top\"}", "status")); + } + + @Test void getString_missingAtTopLevel() { + assertNull(JsonUtil.getString("{\"child\": {\"secret\": \"hidden\"}}", "secret")); + } + + @Test void getString_keyInValue() { + assertEquals("real", + JsonUtil.getString("{\"label\": \"id is here\", \"id\": \"real\"}", "id")); + } + + @Test void getString_escapedValueContainingBraces() { + assertEquals("top", JsonUtil.getString( + "{\"data\": \"{\\\"id\\\": \\\"inner\\\"}\", \"id\": \"top\"}", "id")); + } + + @Test void getLong_shadowedByNested() { + assertEquals(Long.valueOf(42), + JsonUtil.getLong("{\"nested\": {\"amount\": 999}, \"amount\": 42}", "amount")); + } + + @Test void getLong_shadowedByMultipleArrayElements() { + assertEquals(Long.valueOf(500), JsonUtil.getLong( + "{\"items\": [{\"amount\": 100},{\"amount\": 200}], \"amount\": 500}", "amount")); + } + + @Test void getLong_missingAtTopLevel() { + assertNull(JsonUtil.getLong("{\"child\": {\"total\": 100}}", "total")); + } + + @Test void getInteger_shadowedByNested() { + assertEquals(Integer.valueOf(1), + JsonUtil.getInteger("{\"d\": {\"qty\": 5}, \"qty\": 1}", "qty")); + } + + @Test void getBoolean_shadowedByNested() { + assertEquals(false, + JsonUtil.getBoolean("{\"n\": {\"active\": true}, \"active\": false}", "active")); + } + + @Test void getBoolean_deeplyNested() { + assertEquals(false, JsonUtil.getBoolean( + "{\"a\": {\"b\": {\"c\": {\"del\": true}}}, \"del\": false}", "del")); + } + + @Test void getDouble_shadowedByNested() { + assertEquals(3.75, + JsonUtil.getDouble("{\"d\": {\"rate\": 1.5}, \"rate\": 3.75}", "rate"), 0.001); + } + + @Test void getBigDecimal_shadowedByNested() { + assertEquals(new BigDecimal("1.25"), JsonUtil.getBigDecimal( + "{\"d\": {\"exchange_rate\": 0.85}, \"exchange_rate\": 1.25}", "exchange_rate")); + } + + @Test void getTimestamp_shadowedByNested() { + Timestamp ts = JsonUtil.getTimestamp( + "{\"items\": [{\"date\": 1000000}], \"date\": 1605530769}", "date"); + assertNotNull(ts); + assertEquals(1605530769000L, ts.getTime()); + } + + @Test void getObject_shadowedByNested() { + String obj = JsonUtil.getObject( + "{\"p\": {\"addr\": {\"city\": \"nested\"}}, \"addr\": {\"city\": \"top\"}}", "addr"); + assertEquals("top", JsonUtil.getString(obj, "city")); + } + + @Test void getObject_missingAtTopLevel() { + assertNull(JsonUtil.getObject("{\"w\": {\"inner\": {\"v\": 1}}}", "inner")); + } + + @Test void getObject_escapedCharsBeforeObject() { + String obj = JsonUtil.getObject( + "{\"text\": \"a\\\\b\\\"c\", \"obj\": {\"k\": 1}}", "obj"); + assertNotNull(obj); + assertEquals(Long.valueOf(1), JsonUtil.getLong(obj, "k")); + } + + @Test void getObject_afterStringLookingLikeObject() { + String obj = JsonUtil.getObject( + "{\"raw\": \"not an object\", \"obj\": {\"a\": 1}}", "obj"); + assertNotNull(obj); + } + + @Test void getArray_shadowedByNested() { + String arr = JsonUtil.getArray( + "{\"w\": {\"tags\": [\"i1\"]}, \"tags\": [\"t1\", \"t2\", \"t3\"]}", "tags"); + List tags = JsonUtil.parseArrayOfString(arr); + assertEquals(3, tags.size()); + assertEquals("t1", tags.get(0)); + } + + @Test void getArray_missingAtTopLevel() { + assertNull(JsonUtil.getArray("{\"w\": {\"items\": [1, 2]}}", "items")); + } + + @Test void getArray_escapedCharsBeforeArray() { + assertNotNull(JsonUtil.getArray( + "{\"text\": \"a\\\\b\\\"c\", \"arr\": [1, 2]}", "arr")); + } + + @Test void hasValue_topLevelNull() { + assertFalse(JsonUtil.hasValue( + "{\"inner\": {\"name\": \"hidden\"}, \"name\": null}", "name")); + } + + @Test void hasValue_nestedNull() { + assertTrue(JsonUtil.hasValue( + "{\"inner\": {\"name\": null}, \"name\": \"visible\"}", "name")); + } + + @Test void hasValue_missingAtTopLevel() { + assertFalse(JsonUtil.hasValue("{\"inner\": {\"key\": \"val\"}}", "key")); + } + + @Test void stripNested_escapedCharInNestedString() { + assertEquals("top", JsonUtil.getString( + "{\"child\": {\"k\": \"a\\\\b\"}, \"id\": \"top\"}", "id")); + } + + @Test void allScalarTypes_invoiceLikeStructure() { + String json = "{" + + "\"line_items\": [{\"id\": \"li_1\", \"amount\": 500, \"tax\": 50," + + " \"is_taxed\": true, \"description\": \"item\", \"exchange_rate\": 0.85," + + " \"date_from\": 1000000}]," + + "\"billing_address\": {\"first_name\": \"nested\"}," + + "\"id\": \"inv_top\", \"amount\": 2000, \"tax\": 80," + + "\"is_taxed\": false, \"description\": \"top desc\"," + + "\"exchange_rate\": 1.25, \"date_from\": 9999999" + + "}"; + + assertEquals("inv_top", JsonUtil.getString(json, "id")); + assertEquals(Long.valueOf(2000), JsonUtil.getLong(json, "amount")); + assertEquals(Long.valueOf(80), JsonUtil.getLong(json, "tax")); + assertEquals(false, JsonUtil.getBoolean(json, "is_taxed")); + assertEquals("top desc", JsonUtil.getString(json, "description")); + assertEquals(new BigDecimal("1.25"), JsonUtil.getBigDecimal(json, "exchange_rate")); + assertEquals(9999999000L, JsonUtil.getTimestamp(json, "date_from").getTime()); + assertEquals("nested", + JsonUtil.getString(JsonUtil.getObject(json, "billing_address"), "first_name")); + assertEquals("li_1", + JsonUtil.getString(JsonUtil.parseObjectArray( + JsonUtil.getArray(json, "line_items")).get(0), "id")); + } } - // ========== Edge Cases and Real-World Scenarios ========== + // ========== Real-World Scenarios ========== @Nested @DisplayName("Real-World Scenarios") class RealWorldScenarios { - @Test - @DisplayName("should parse transaction list response correctly") - void shouldParseTransactionListResponse() { - String json = "{" + - "\"list\": [{\"transaction\": {" + - "\"id\": \"txn_AzZhUGSPAkLskJQo\"," + - "\"customer_id\": \"cbdemo_dave\"," + - "\"subscription_id\": \"cbdemo_dave-sub1\"," + - "\"gateway_account_id\": \"gw_AzZhUGSPAkLeQJPg\"," + - "\"payment_method\": \"card\"," + - "\"gateway\": \"chargebee\"," + - "\"type\": \"payment\"," + - "\"date\": 1605530769," + - "\"exchange_rate\": 1.0," + - "\"amount\": 10000," + - "\"id_at_gateway\": \"cb___dev__KyVnqiSIrqRVUEN\"," + - "\"status\": \"success\"," + - "\"updated_at\": 1605530769," + - "\"resource_version\": 1605530769000," + - "\"deleted\": false," + - "\"object\": \"transaction\"," + - "\"masked_card_number\": \"************4444\"," + - "\"currency_code\": \"USD\"," + - "\"base_currency_code\": \"USD\"," + - "\"amount_unused\": 0," + - "\"linked_invoices\": [{" + - "\"invoice_id\": \"DemoInv_103\"," + - "\"applied_amount\": 10000," + - "\"applied_at\": 1605530769," + - "\"invoice_date\": 1605530769," + - "\"invoice_total\": 10000," + - "\"invoice_status\": \"paid\"" + - "}]," + - "\"linked_refunds\": []," + - "\"payment_method_details\": \"{\\\"card\\\":{\\\"iin\\\":\\\"555555\\\",\\\"last4\\\":\\\"4444\\\"}}\"" + - "}}]," + - "\"next_offset\": null" + - "}"; - - // Test getArray with nested structures - String listArray = JsonUtil.getArray(json, "list"); - assertNotNull(listArray, "list array should not be null"); - assertTrue(listArray.startsWith("["), "Should start with ["); - assertTrue(listArray.endsWith("]"), "Should end with ]"); - - // Verify it contains the complete transaction data - assertTrue(listArray.contains("txn_AzZhUGSPAkLskJQo")); - assertTrue(listArray.contains("linked_invoices")); - assertTrue(listArray.contains("DemoInv_103")); - assertTrue(listArray.contains("linked_refunds")); - assertTrue(listArray.contains("payment_method_details")); - - // Test parseObjectArray - List objects = JsonUtil.parseObjectArray(listArray); - assertEquals(1, objects.size()); - - // Test nested object extraction - String transactionWrapper = objects.get(0); - String transaction = JsonUtil.getObject(transactionWrapper, "transaction"); - assertNotNull(transaction); - - assertEquals("txn_AzZhUGSPAkLskJQo", JsonUtil.getString(transaction, "id")); - assertEquals("cbdemo_dave", JsonUtil.getString(transaction, "customer_id")); - assertEquals(10000, JsonUtil.getInteger(transaction, "amount")); - assertEquals(1.0, JsonUtil.getDouble(transaction, "exchange_rate"), 0.001); - assertFalse(JsonUtil.getBoolean(transaction, "deleted")); - assertEquals(1605530769000L, JsonUtil.getLong(transaction, "resource_version")); - - // Test nested array extraction - String linkedInvoices = JsonUtil.getArray(transaction, "linked_invoices"); - assertNotNull(linkedInvoices); - assertTrue(linkedInvoices.contains("DemoInv_103")); - - String linkedRefunds = JsonUtil.getArray(transaction, "linked_refunds"); - assertNotNull(linkedRefunds); - assertEquals("[]", linkedRefunds); - } - - @Test - @DisplayName("should handle customer list response") - void shouldHandleCustomerListResponse() { - String json = "{" + - "\"list\": [" + - "{\"customer\": {\"id\": \"cust_123\", \"email\": \"test@example.com\"}}," + - "{\"customer\": {\"id\": \"cust_456\", \"email\": \"user@example.com\"}}" + - "]," + - "\"next_offset\": \"offset_abc123\"" + - "}"; + @Test void transactionListResponse() { + String json = "{\"list\": [{\"transaction\": {" + + "\"id\": \"txn_1\", \"customer_id\": \"cust_1\"," + + "\"amount\": 10000, \"exchange_rate\": 1.0," + + "\"status\": \"success\", \"resource_version\": 1605530769000," + + "\"deleted\": false," + + "\"linked_invoices\": [{\"invoice_id\": \"inv_1\", \"applied_amount\": 10000}]," + + "\"linked_refunds\": []," + + "\"payment_method_details\": \"{\\\"card\\\":{\\\"iin\\\":\\\"555\\\"}}\"" + + "}}], \"next_offset\": null}"; String listArray = JsonUtil.getArray(json, "list"); assertNotNull(listArray); - - List items = JsonUtil.parseObjectArray(listArray); + String txn = JsonUtil.getObject(JsonUtil.parseObjectArray(listArray).get(0), "transaction"); + assertEquals("txn_1", JsonUtil.getString(txn, "id")); + assertEquals(10000, JsonUtil.getInteger(txn, "amount")); + assertFalse(JsonUtil.getBoolean(txn, "deleted")); + assertTrue(JsonUtil.getArray(txn, "linked_invoices").contains("inv_1")); + assertEquals("[]", JsonUtil.getArray(txn, "linked_refunds")); + } + + @Test void customerListResponse() { + String json = "{\"list\": [" + + "{\"customer\": {\"id\": \"c1\", \"email\": \"a@b.com\"}}," + + "{\"customer\": {\"id\": \"c2\", \"email\": \"x@y.com\"}}" + + "], \"next_offset\": \"off_1\"}"; + + List items = JsonUtil.parseObjectArray(JsonUtil.getArray(json, "list")); assertEquals(2, items.size()); - - String customer1 = JsonUtil.getObject(items.get(0), "customer"); - assertEquals("cust_123", JsonUtil.getString(customer1, "id")); - - String nextOffset = JsonUtil.getString(json, "next_offset"); - assertEquals("offset_abc123", nextOffset); - } - - @Test - @DisplayName("should handle subscription with multiple nested arrays") - void shouldHandleSubscriptionWithMultipleNestedArrays() { - String json = "{" + - "\"subscription\": {" + - "\"id\": \"sub_123\"," + - "\"subscription_items\": [" + - "{\"item_price_id\": \"price_1\", \"quantity\": 1}," + - "{\"item_price_id\": \"price_2\", \"quantity\": 2}" + - "]," + - "\"addons\": [{\"id\": \"addon_1\"}]," + - "\"coupons\": []," + - "\"discounts\": [{\"id\": \"disc_1\", \"apply_till\": [1, 2, 3]}]" + - "}" + - "}"; - - String subscription = JsonUtil.getObject(json, "subscription"); - assertNotNull(subscription); - - String items = JsonUtil.getArray(subscription, "subscription_items"); - assertNotNull(items); - List itemsList = JsonUtil.parseObjectArray(items); - assertEquals(2, itemsList.size()); - - String addons = JsonUtil.getArray(subscription, "addons"); - assertNotNull(addons); - - String coupons = JsonUtil.getArray(subscription, "coupons"); - assertEquals("[]", coupons); - - String discounts = JsonUtil.getArray(subscription, "discounts"); - assertNotNull(discounts); - // Verify nested array within object within array is handled - assertTrue(discounts.contains("apply_till")); - assertTrue(discounts.contains("[1, 2, 3]") || discounts.contains("[1,2,3]")); + assertEquals("c1", JsonUtil.getString(JsonUtil.getObject(items.get(0), "customer"), "id")); + assertEquals("off_1", JsonUtil.getString(json, "next_offset")); + } + + @Test void subscriptionWithMultipleNestedArrays() { + String json = "{\"subscription\": {" + + "\"id\": \"sub_1\"," + + "\"subscription_items\": [{\"item_price_id\": \"p1\"}, {\"item_price_id\": \"p2\"}]," + + "\"addons\": [{\"id\": \"a1\"}], \"coupons\": []," + + "\"discounts\": [{\"id\": \"d1\", \"apply_till\": [1, 2, 3]}]" + + "}}"; + + String sub = JsonUtil.getObject(json, "subscription"); + assertEquals(2, JsonUtil.parseObjectArray( + JsonUtil.getArray(sub, "subscription_items")).size()); + assertEquals("[]", JsonUtil.getArray(sub, "coupons")); + assertTrue(JsonUtil.getArray(sub, "discounts").contains("apply_till")); } } @@ -953,85 +1058,51 @@ void shouldHandleSubscriptionWithMultipleNestedArrays() { @DisplayName("Edge Cases") class EdgeCases { - @Test - @DisplayName("should handle whitespace variations") - void shouldHandleWhitespaceVariations() { - String json1 = "{\"key\":\"value\"}"; - String json2 = "{ \"key\" : \"value\" }"; - String json3 = "{\n \"key\"\t:\n \"value\"\n}"; - - assertEquals("value", JsonUtil.getString(json1, "key")); - assertEquals("value", JsonUtil.getString(json2, "key")); - assertEquals("value", JsonUtil.getString(json3, "key")); - } - - @Test - @DisplayName("should handle keys with special characters") - void shouldHandleKeysWithSpecialCharacters() { - String json = "{\"my-key\": \"value1\", \"my_key\": \"value2\", \"my.key\": \"value3\"}"; - assertEquals("value1", JsonUtil.getString(json, "my-key")); - assertEquals("value2", JsonUtil.getString(json, "my_key")); - assertEquals("value3", JsonUtil.getString(json, "my.key")); - } - - @Test - @DisplayName("should handle very long strings") - void shouldHandleVeryLongStrings() { - StringBuilder longValue = new StringBuilder(); - for (int i = 0; i < 10000; i++) { - longValue.append("x"); - } - String json = "{\"long\": \"" + longValue + "\"}"; - assertEquals(longValue.toString(), JsonUtil.getString(json, "long")); - } - - @Test - @DisplayName("should handle deeply nested structures") - void shouldHandleDeeplyNestedStructures() { - // Create 10 levels of nesting + @Test void whitespaceVariations() { + assertEquals("value", JsonUtil.getString("{\"key\":\"value\"}", "key")); + assertEquals("value", JsonUtil.getString("{ \"key\" : \"value\" }", "key")); + assertEquals("value", JsonUtil.getString("{\n \"key\"\t:\n \"value\"\n}", "key")); + } + + @Test void keysWithSpecialCharacters() { + String json = "{\"my-key\": \"v1\", \"my_key\": \"v2\", \"my.key\": \"v3\"}"; + assertEquals("v1", JsonUtil.getString(json, "my-key")); + assertEquals("v2", JsonUtil.getString(json, "my_key")); + assertEquals("v3", JsonUtil.getString(json, "my.key")); + } + + @Test void veryLongStrings() { + String longVal = "x".repeat(10000); + assertEquals(longVal, JsonUtil.getString("{\"long\": \"" + longVal + "\"}", "long")); + } + + @Test void deeplyNestedStructures() { StringBuilder json = new StringBuilder(); - for (int i = 0; i < 10; i++) { - json.append("{\"level").append(i).append("\": "); - } + for (int i = 0; i < 10; i++) json.append("{\"level").append(i).append("\": "); json.append("\"deep_value\""); - for (int i = 0; i < 10; i++) { - json.append("}"); - } - - String result = json.toString(); - String level0 = JsonUtil.getObject(result, "level0"); - assertNotNull(level0); - assertTrue(level0.contains("deep_value")); - } - - @Test - @DisplayName("should handle array with mixed types") - void shouldHandleArrayWithMixedTypes() { - String json = "{\"mixed\": [1, \"two\", true, null, {\"key\": \"value\"}, [1, 2]]}"; - String array = JsonUtil.getArray(json, "mixed"); - assertNotNull(array); - assertTrue(array.contains("1")); - assertTrue(array.contains("\"two\"")); - assertTrue(array.contains("true")); - assertTrue(array.contains("null")); - } - - @Test - @DisplayName("should handle colons in string values") - void shouldHandleColonsInStringValues() { - String json = "{\"url\": \"https://example.com:8080/path\"}"; - assertEquals("https://example.com:8080/path", JsonUtil.getString(json, "url")); - } - - @Test - @DisplayName("should handle quotes in key names") - void shouldHandleFirstMatchForDuplicateKeys() { - // JSON spec says duplicate keys have undefined behavior, but we should handle gracefully - String json = "{\"key\": \"first\", \"key\": \"second\"}"; - String result = JsonUtil.getString(json, "key"); - // Should return one of the values (typically first) - assertNotNull(result); + for (int i = 0; i < 10; i++) json.append("}"); + assertNotNull(JsonUtil.getObject(json.toString(), "level0")); + } + + @Test void arrayWithMixedTypes() { + String arr = JsonUtil.getArray( + "{\"mixed\": [1, \"two\", true, null, {\"k\": \"v\"}, [1, 2]]}", "mixed"); + assertNotNull(arr); + assertTrue(arr.contains("\"two\"")); + assertTrue(arr.contains("null")); + } + + @Test void colonsInStringValues() { + assertEquals("https://example.com:8080/path", + JsonUtil.getString("{\"url\": \"https://example.com:8080/path\"}", "url")); + } + + @Test void duplicateKeysAtSameLevel() { + assertNotNull(JsonUtil.getString("{\"key\": \"first\", \"key\": \"second\"}", "key")); + } + + @Test void constructorAccessible() { + assertNotNull(new JsonUtil()); } } } - diff --git a/src/test/java/com/chargebee/v4/internal/SubscriptionParserTest.java b/src/test/java/com/chargebee/v4/internal/SubscriptionParserTest.java new file mode 100644 index 00000000..788b0057 --- /dev/null +++ b/src/test/java/com/chargebee/v4/internal/SubscriptionParserTest.java @@ -0,0 +1,153 @@ +package com.chargebee.v4.internal; + +import com.chargebee.v4.models.subscription.Subscription; +import com.chargebee.v4.models.subscription.Subscription.*; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Subscription JSON parsing") +class SubscriptionParserTest { + + private static String subscriptionJson; + private static Subscription subscription; + + @BeforeAll + static void loadFixture() throws IOException { + try (InputStream is = SubscriptionParserTest.class.getResourceAsStream("/fixtures/subscription.json")) { + assertNotNull(is, "fixtures/subscription.json not found on classpath"); + subscriptionJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + subscription = Subscription.fromJson(subscriptionJson); + } + + @Nested + @DisplayName("Top-level scalar fields") + class TopLevelFields { + + @Test void parsesStringFields() { + assertEquals("__test__8asukSOXe0W3SU", subscription.getId()); + assertEquals("USD", subscription.getCurrencyCode()); + assertEquals("__test__8asukSOXe0QYSR", subscription.getCustomerId()); + } + + @Test void parsesBooleanFields() { + assertEquals(false, subscription.getDeleted()); + assertEquals(false, subscription.getHasScheduledChanges()); + } + + @Test void parsesIntegerFields() { + assertEquals(1, subscription.getBillingPeriod()); + assertEquals(1, subscription.getRemainingBillingCycles()); + assertEquals(1, subscription.getDueInvoicesCount()); + } + + @Test void parsesLongFields() { + assertEquals(1612890938000L, subscription.getResourceVersion()); + assertEquals(0L, subscription.getMrr()); + assertEquals(1100L, subscription.getTotalDues()); + } + + @Test void parsesEnumFields() { + assertEquals(Subscription.Status.ACTIVE, subscription.getStatus()); + assertEquals(Subscription.BillingPeriodUnit.MONTH, subscription.getBillingPeriodUnit()); + assertEquals(Subscription.Channel._UNKNOWN, subscription.getChannel()); + } + + @Test void parsesTimestampFields() { + Timestamp ts = new Timestamp(1612890938L * 1000); + assertEquals(ts, subscription.getActivatedAt()); + assertEquals(ts, subscription.getCreatedAt()); + assertEquals(ts, subscription.getStartedAt()); + assertEquals(ts, subscription.getDueSince()); + assertEquals(ts, subscription.getUpdatedAt()); + assertEquals(new Timestamp(1612890938L * 1000), subscription.getCurrentTermStart()); + assertEquals(new Timestamp(1615310138L * 1000), subscription.getCurrentTermEnd()); + assertEquals(new Timestamp(1615310138L * 1000), subscription.getNextBillingAt()); + } + + @Test void absentFieldsAreNull() { + assertNull(subscription.getTrialStart()); + assertNull(subscription.getTrialEnd()); + assertNull(subscription.getPoNumber()); + assertNull(subscription.getCancelledAt()); + assertEquals(Subscription.CancelReason._UNKNOWN, subscription.getCancelReason()); + assertNull(subscription.getPaymentSourceId()); + assertNull(subscription.getShippingAddress()); + assertNull(subscription.getReferralInfo()); + assertNull(subscription.getContractTerm()); + assertNull(subscription.getBusinessEntityId()); + } + } + + @Nested + @DisplayName("Subscription items array") + class SubscriptionItemsTests { + + @Test void parsesTwoItems() { + assertNotNull(subscription.getSubscriptionItems()); + assertEquals(2, subscription.getSubscriptionItems().size()); + } + + @Test void firstItemIsPlan() { + SubscriptionItems item = subscription.getSubscriptionItems().get(0); + assertEquals("basic-USD", item.getItemPriceId()); + assertEquals(SubscriptionItems.ItemType.PLAN, item.getItemType()); + assertEquals(1, item.getQuantity()); + assertEquals(1000L, item.getUnitPrice()); + assertEquals(1000L, item.getAmount()); + assertEquals(0, item.getFreeQuantity()); + assertEquals(1, item.getBillingCycles()); + } + + @Test void secondItemIsAddon() { + SubscriptionItems item = subscription.getSubscriptionItems().get(1); + assertEquals("addon-USD", item.getItemPriceId()); + assertEquals(SubscriptionItems.ItemType.ADDON, item.getItemType()); + assertEquals(1, item.getQuantity()); + assertEquals(100L, item.getUnitPrice()); + assertEquals(100L, item.getAmount()); + assertEquals(0, item.getFreeQuantity()); + assertEquals(1, item.getBillingCycles()); + } + } + + @Nested + @DisplayName("Empty/absent collections") + class EmptyCollections { + + @Test void emptyArraysAreEmptyLists() { + assertNotNull(subscription.getItemTiers()); + assertTrue(subscription.getItemTiers().isEmpty()); + + assertNotNull(subscription.getChargedItems()); + assertTrue(subscription.getChargedItems().isEmpty()); + + assertNotNull(subscription.getCoupons()); + assertTrue(subscription.getCoupons().isEmpty()); + + assertNotNull(subscription.getDiscounts()); + assertTrue(subscription.getDiscounts().isEmpty()); + + assertNotNull(subscription.getAddons()); + assertTrue(subscription.getAddons().isEmpty()); + } + } + + @Nested + @DisplayName("Custom fields") + class CustomFieldsTests { + + @Test void parsesCustomFields() { + assertNotNull(subscription.getCustomFields()); + assertEquals(2, subscription.getCustomFields().size()); + assertEquals("enterprise", subscription.getCustomField("cf_license_tier")); + assertEquals("Jane Smith", subscription.getCustomField("cf_account_manager")); + } + } +} diff --git a/src/test/java/com/chargebee/v4/internal/TransactionParserTest.java b/src/test/java/com/chargebee/v4/internal/TransactionParserTest.java new file mode 100644 index 00000000..5bef8630 --- /dev/null +++ b/src/test/java/com/chargebee/v4/internal/TransactionParserTest.java @@ -0,0 +1,109 @@ +package com.chargebee.v4.internal; + +import com.chargebee.v4.models.transaction.Transaction; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Transaction JSON parsing") +class TransactionParserTest { + + private static String transactionJson; + private static Transaction transaction; + + @BeforeAll + static void loadFixture() throws IOException { + try (InputStream is = TransactionParserTest.class.getResourceAsStream("/fixtures/transactioin.json")) { + assertNotNull(is, "fixtures/transactioin.json not found on classpath"); + transactionJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + transaction = Transaction.fromJson(transactionJson); + } + + @Nested + @DisplayName("Top-level scalar fields") + class TopLevelFields { + + @Test void parsesStringFields() { + assertEquals("txn___test__KyVnHhSBWltv42pB", transaction.getId()); + assertEquals("__test__KyVnHhSBWltgF2p6", transaction.getCustomerId()); + assertEquals("gw___test__KyVnGlSBWltbL2AB", transaction.getGatewayAccountId()); + assertEquals("pm___test__KyVnHhSBWltu42p7", transaction.getPaymentSourceId()); + assertEquals("USD", transaction.getCurrencyCode()); + assertEquals("ch_1HUyC0Jv9j0DyntJiR6W37yv", transaction.getIdAtGateway()); + assertEquals("Payment complete.", transaction.getFraudReason()); + assertEquals("************1111", transaction.getMaskedCardNumber()); + } + + @Test void parsesBooleanFields() { + assertEquals(false, transaction.getDeleted()); + } + + @Test void parsesLongFields() { + assertEquals(1000L, transaction.getAmount()); + assertEquals(1000L, transaction.getAmountCapturable()); + assertEquals(1517505917000L, transaction.getResourceVersion()); + } + + @Test void parsesBigDecimalFields() { + assertEquals(0, new BigDecimal("1").compareTo(transaction.getExchangeRate())); + } + + @Test void parsesEnumFields() { + assertEquals(Transaction.Status.SUCCESS, transaction.getStatus()); + assertEquals(Transaction.Type.AUTHORIZATION, transaction.getType()); + assertEquals(Transaction.PaymentMethod.CARD, transaction.getPaymentMethod()); + assertEquals(Transaction.Gateway.STRIPE, transaction.getGateway()); + assertEquals(Transaction.AuthorizationReason.BLOCKING_FUNDS, transaction.getAuthorizationReason()); + assertEquals(Transaction.FraudFlag._UNKNOWN, transaction.getFraudFlag()); + assertEquals(Transaction.InitiatorType._UNKNOWN, transaction.getInitiatorType()); + } + + @Test void parsesTimestampFields() { + assertEquals(new Timestamp(1600968316L * 1000), transaction.getDate()); + assertEquals(new Timestamp(1517505917L * 1000), transaction.getUpdatedAt()); + } + + @Test void absentFieldsAreNull() { + assertNull(transaction.getSubscriptionId()); + assertNull(transaction.getReferenceNumber()); + assertNull(transaction.getSettledAt()); + assertNull(transaction.getThreeDSecure()); + assertNull(transaction.getErrorCode()); + assertNull(transaction.getErrorText()); + assertNull(transaction.getVoidedAt()); + assertNull(transaction.getAmountUnused()); + assertNull(transaction.getReferenceTransactionId()); + assertNull(transaction.getRefundedTxnId()); + assertNull(transaction.getReferenceAuthorizationId()); + assertNull(transaction.getReversalTransactionId()); + assertNull(transaction.getBusinessEntityId()); + assertNull(transaction.getErrorDetail()); + } + } + + @Nested + @DisplayName("Empty/absent collections") + class EmptyCollections { + + @Test void emptyArraysAreEmptyLists() { + assertNotNull(transaction.getLinkedInvoices()); + assertTrue(transaction.getLinkedInvoices().isEmpty()); + + assertNotNull(transaction.getLinkedCreditNotes()); + assertTrue(transaction.getLinkedCreditNotes().isEmpty()); + + assertNotNull(transaction.getLinkedRefunds()); + assertTrue(transaction.getLinkedRefunds().isEmpty()); + + assertNotNull(transaction.getLinkedPayments()); + assertTrue(transaction.getLinkedPayments().isEmpty()); + } + } +} diff --git a/src/test/resources/fixtures/customer.json b/src/test/resources/fixtures/customer.json new file mode 100644 index 00000000..d035c14e --- /dev/null +++ b/src/test/resources/fixtures/customer.json @@ -0,0 +1,35 @@ +{ + "allow_direct_debit": false, + "auto_collection": "on", + "billing_address": { + "city": "Walnut", + "country": "US", + "first_name": "John", + "last_name": "Mike", + "line1": "PO Box 9999", + "object": "billing_address", + "state": "California", + "state_code": "CA", + "validation_status": "not_validated", + "zip": "91789" + }, + "card_status": "no_card", + "created_at": 1517505731, + "deleted": false, + "email": "john@test.com", + "excess_payments": 0, + "first_name": "John", + "id": "__test__KyVnHhSBWl7eY2bl", + "last_name": "Doe", + "locale": "fr-CA", + "net_term_days": 0, + "object": "customer", + "pii_cleared": "active", + "preferred_currency_code": "USD", + "promotional_credits": 0, + "refundable_credits": 0, + "resource_version": 1517505731000, + "taxability": "taxable", + "unbilled_charges": 0, + "updated_at": 1517505731 +} \ No newline at end of file diff --git a/src/test/resources/fixtures/events.json b/src/test/resources/fixtures/events.json new file mode 100644 index 00000000..fc0b42fc --- /dev/null +++ b/src/test/resources/fixtures/events.json @@ -0,0 +1,200 @@ +{ + "event": { + "id": "ev_16BPgETyVrQbiGhA", + "occurred_at": 1702645601, + "source": "admin_console", + "user": "sarah@sarah.com", + "object": "event", + "api_version": "v2", + "content": { + "subscription": { + "id": "16BPgETyVrQVHGh1", + "billing_period": 1, + "billing_period_unit": "month", + "customer_id": "sarah", + "status": "active", + "current_term_start": 1702578600, + "current_term_end": 1705256999, + "next_billing_at": 1705257000, + "created_at": 1702645601, + "started_at": 1702578600, + "activated_at": 1702578600, + "created_from_ip": "10.0.0.1", + "updated_at": 1702645601, + "has_scheduled_changes": false, + "channel": "web", + "resource_version": 1702645601793, + "deleted": false, + "object": "subscription", + "currency_code": "INR", + "subscription_items": [ + { + "item_price_id": "cross-train-advanced-INR-1_MONTH", + "item_type": "plan", + "quantity": 1, + "quantity_in_decimal": "1.0000", + "unit_price": 11667, + "unit_price_in_decimal": "116.66667", + "amount": 11667, + "amount_in_decimal": "116.66667", + "free_quantity": 0, + "free_quantity_in_decimal": "0.0000", + "object": "subscription_item" + } + ], + "due_invoices_count": 0, + "mrr": 0, + "has_scheduled_advance_invoices": false, + "override_relationship": false, + "create_pending_invoices": false, + "auto_close_invoices": true, + "business_entity_id": "16CQtCTrgrYwi9n2E" + }, + "customer": { + "id": "sarah", + "auto_collection": "on", + "net_term_days": 0, + "allow_direct_debit": false, + "created_at": 1700038561, + "created_from_ip": "10.0.0.2", + "taxability": "taxable", + "updated_at": 1702645580, + "pii_cleared": "active", + "channel": "web", + "resource_version": 1702645580741, + "deleted": false, + "object": "customer", + "card_status": "valid", + "promotional_credits": 0, + "refundable_credits": 0, + "excess_payments": 0, + "unbilled_charges": 0, + "preferred_currency_code": "INR", + "mrr": 0, + "primary_payment_source_id": "pm_169vujTyVrL5fFDl", + "payment_method": { + "object": "payment_method", + "type": "card", + "reference_id": "tok_169vujTyVrL5LFDk", + "gateway": "chargebee", + "gateway_account_id": "gw_1mk51R4QrLmQtYMht", + "status": "valid" + }, + "business_entity_id": "16CQtCTrgrYwi9n2E", + "tax_providers_fields": {}, + "auto_close_invoices": true + }, + "card": { + "status": "valid", + "gateway": "chargebee", + "gateway_account_id": "gw_1mk51R4QrLmQtYMht", + "iin": "411111", + "last4": "1111", + "card_type": "visa", + "funding_type": "credit", + "expiry_month": 12, + "expiry_year": 2024, + "created_at": 1702645580, + "updated_at": 1702645580, + "ip_address": "10.0.0.1", + "resource_version": 1702645580740, + "object": "card", + "masked_number": "************1111", + "customer_id": "boom", + "payment_source_id": "pm_169vujTyVrL5fFDl" + }, + "invoice": { + "id": "203", + "customer_id": "boom", + "subscription_id": "16BPgETyVrQVHGh1", + "recurring": true, + "status": "paid", + "price_type": "tax_exclusive", + "date": 1702578600, + "due_date": 1702578600, + "net_term_days": 0, + "exchange_rate": 83.283543, + "total": 11667, + "amount_paid": 11667, + "amount_adjusted": 0, + "write_off_amount": 0, + "credits_applied": 0, + "amount_due": 0, + "paid_at": 1702645601, + "updated_at": 1702645601, + "resource_version": 1702645601783, + "deleted": false, + "object": "invoice", + "first_invoice": true, + "amount_to_collect": 0, + "round_off_amount": 0, + "new_sales_amount": 11667, + "has_advance_charges": false, + "currency_code": "INR", + "base_currency_code": "USD", + "generated_at": 1702578600, + "is_gifted": false, + "term_finalized": true, + "channel": "web", + "tax": 0, + "line_items": [ + { + "id": "li_16BPgETyVrQWBGh3", + "date_from": 1702578600, + "date_to": 1705256999, + "unit_amount": 11667, + "quantity": 1, + "amount": 11667, + "pricing_model": "per_unit", + "is_taxed": false, + "tax_amount": 0, + "unit_amount_in_decimal": "116.66667", + "quantity_in_decimal": "1.0000", + "amount_in_decimal": "116.66667", + "object": "line_item", + "subscription_id": "16BPgETyVrQVHGh1", + "customer_id": "boom", + "description": "cross-train-advanced-INR-1_MONTH", + "entity_type": "plan_item_price", + "entity_id": "cross-train-advanced-INR-1_MONTH", + "metered": false, + "tax_exempt_reason": "export", + "discount_amount": 0, + "item_level_discount_amount": 0 + } + ], + "sub_total": 11667, + "linked_payments": [ + { + "txn_id": "txn_16BPgETyVrQXVGh4", + "applied_amount": 11667, + "applied_at": 1702645601, + "txn_status": "success", + "txn_date": 1702645601, + "txn_amount": 11667 + } + ], + "applied_credits": {}, + "adjustment_credit_notes": {}, + "issued_credit_notes": {}, + "linked_orders": {}, + "dunning_attempts": {}, + "notes": [ + { + "note": "You can pay card." + } + ], + "business_entity_id": "16CQtCTrgrYwi9n2E" + } + }, + "event_type": "subscription_created", + "webhook_status": "not_configured", + "webhooks": [ + { + "id": "whv2_Azz5aITsMVdKtVWV", + "webhook_status": "not_applicable", + "object": "webhook" + } + ] + } +} \ No newline at end of file diff --git a/src/test/resources/fixtures/invoice.json b/src/test/resources/fixtures/invoice.json new file mode 100644 index 00000000..be25e37e --- /dev/null +++ b/src/test/resources/fixtures/invoice.json @@ -0,0 +1,115 @@ +{ + "adjustment_credit_notes": {}, + "amount_adjusted": 0, + "amount_due": 0, + "amount_paid": 2000, + "amount_to_collect": 0, + "applied_credits": {}, + "base_currency_code": "USD", + "billing_address": { + "first_name": "John", + "last_name": "Mathew", + "object": "billing_address", + "validation_status": "not_validated" + }, + "credits_applied": 0, + "currency_code": "USD", + "customer_id": "__test__KyVkkWS1xLskm8", + "date": 1517463749, + "deleted": false, + "due_date": 1517463749, + "dunning_attempts": {}, + "exchange_rate": 1, + "first_invoice": true, + "has_advance_charges": false, + "id": "__demo_inv__1", + "is_gifted": false, + "issued_credit_notes": {}, + "line_items": [ + { + "amount": 2000, + "customer_id": "__test__KyVkkWS1xLskm8", + "date_from": 1517463749, + "date_to": 1517463749, + "description": "SSL Charge USD Monthly", + "discount_amount": 0, + "entity_id": "ssl-charge-USD", + "entity_type": "charge_item_price", + "id": "li___test__KyVkkWS1xLt9LF", + "is_taxed": false, + "item_level_discount_amount": 0, + "object": "line_item", + "pricing_model": "flat_fee", + "quantity": 1, + "tax_amount": 0, + "tax_exempt_reason": "tax_not_configured", + "unit_amount": 2000 + }, + { + "amount": 2000, + "customer_id": "__test__KyVkkWS1xLskm8", + "date_from": 1517463749, + "date_to": 1517463749, + "description": "SSL Charge USD Monthly", + "discount_amount": 0, + "entity_id": "ssl-charge-USD", + "entity_type": "charge_item_price", + "id": "li___test__KyVkkWS1xLt9LF", + "is_taxed": false, + "item_level_discount_amount": 0, + "object": "line_item", + "pricing_model": "flat_fee", + "quantity": 1, + "tax_amount": 0, + "tax_exempt_reason": "tax_not_configured", + "unit_amount": 2000 + } + ], + "linked_orders": {}, + "linked_payments": [ + { + "applied_amount": 2000, + "applied_at": 1517463750, + "txn_amount": 2000, + "txn_date": 1517463750, + "txn_id": "txn___test__KyVkkWS1xLtFiG", + "txn_status": "success" + }, + { + "applied_amount": 0, + "applied_at": 1517463750, + "txn_amount": 2000, + "txn_date": 1517463750, + "txn_id": "txn___test__KyVkkWS1xLtFiG", + "txn_status": "success" + } + ], + "net_term_days": 0, + "new_sales_amount": 2000, + "object": "invoice", + "paid_at": 1517463750, + "price_type": "tax_exclusive", + "recurring": false, + "resource_version": 1517463750000, + "round_off_amount": 0, + "shipping_address": { + "city": "Walnut", + "country": "US", + "first_name": "John", + "last_name": "Mathew", + "object": "shipping_address", + "state": "California", + "state_code": "CA", + "validation_status": "not_validated", + "zip": "91789" + }, + "status": "paid", + "sub_total": 2000, + "tax": 0, + "term_finalized": true, + "total": 2000, + "updated_at": 1517463750, + "write_off_amount": 0, + "cf_department": "engineering", + "cf_cost_center": "CC-1042", +} \ No newline at end of file diff --git a/src/test/resources/fixtures/itemPrices.json b/src/test/resources/fixtures/itemPrices.json new file mode 100644 index 00000000..b227bbeb --- /dev/null +++ b/src/test/resources/fixtures/itemPrices.json @@ -0,0 +1,21 @@ +{ + "created_at": 1594106928, + "currency_code": "USD", + "external_name": "silver USD", + "free_quantity": 0, + "id": "silver-USD-monthly", + "is_taxable": true, + "item_id": "silver", + "item_type": "plan", + "name": "silver USD monthly", + "object": "item_price", + "period": 1, + "period_unit": "month", + "price": 1000, + "pricing_model": "per_unit", + "resource_version": 1594106928574, + "status": "active", + "updated_at": 1594106928, + "cf_product_line": "saas", + "cf_tier_level": "silver" +} \ No newline at end of file diff --git a/src/test/resources/fixtures/subscription.json b/src/test/resources/fixtures/subscription.json new file mode 100644 index 00000000..7269a666 --- /dev/null +++ b/src/test/resources/fixtures/subscription.json @@ -0,0 +1,48 @@ +{ + "activated_at": 1612890938, + "billing_period": 1, + "billing_period_unit": "month", + "created_at": 1612890938, + "currency_code": "USD", + "current_term_end": 1615310138, + "current_term_start": 1612890938, + "customer_id": "__test__8asukSOXe0QYSR", + "deleted": false, + "due_invoices_count": 1, + "due_since": 1612890938, + "has_scheduled_changes": false, + "id": "__test__8asukSOXe0W3SU", + "mrr": 0, + "next_billing_at": 1615310138, + "object": "subscription", + "remaining_billing_cycles": 1, + "resource_version": 1612890938000, + "started_at": 1612890938, + "status": "active", + "subscription_items": [ + { + "amount": 1000, + "billing_cycles": 1, + "free_quantity": 0, + "item_price_id": "basic-USD", + "item_type": "plan", + "object": "subscription_item", + "quantity": 1, + "unit_price": 1000 + }, + { + "amount": 100, + "billing_cycles": 1, + "free_quantity": 0, + "item_price_id": "addon-USD", + "item_type": "addon", + "object": "subscription_item", + "quantity": 1, + "unit_price": 100 + } + ], + "total_dues": 1100, + "updated_at": 1612890938, + "cf_license_tier": "enterprise", + "cf_account_manager": "Jane Smith" +} \ No newline at end of file diff --git a/src/test/resources/fixtures/transactioin.json b/src/test/resources/fixtures/transactioin.json new file mode 100644 index 00000000..166a07ff --- /dev/null +++ b/src/test/resources/fixtures/transactioin.json @@ -0,0 +1,24 @@ +{ + "amount": 1000, + "amount_capturable": 1000, + "authorization_reason": "blocking_funds", + "currency_code": "USD", + "customer_id": "__test__KyVnHhSBWltgF2p6", + "date": 1600968316, + "deleted": false, + "exchange_rate": 1, + "fraud_reason": "Payment complete.", + "gateway": "stripe", + "gateway_account_id": "gw___test__KyVnGlSBWltbL2AB", + "id": "txn___test__KyVnHhSBWltv42pB", + "id_at_gateway": "ch_1HUyC0Jv9j0DyntJiR6W37yv", + "linked_payments": {}, + "masked_card_number": "************1111", + "object": "transaction", + "payment_method": "card", + "payment_source_id": "pm___test__KyVnHhSBWltu42p7", + "resource_version": 1517505917000, + "status": "success", + "type": "authorization", + "updated_at": 1517505917 +} \ No newline at end of file From b7a400098509f65ee43a3858e9d35ce903bf3ba9 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Mon, 23 Feb 2026 14:25:25 +0530 Subject: [PATCH 2/3] Unify JsonUtil on findTopLevelValueStart --- .../com/chargebee/v4/internal/JsonUtil.java | 222 ++++++++---------- .../chargebee/v4/internal/JsonUtilTest.java | 18 ++ .../v4/internal/TransactionParserTest.java | 4 +- src/test/resources/fixtures/invoice.json | 2 +- .../{transactioin.json => transaction.json} | 0 5 files changed, 124 insertions(+), 122 deletions(-) rename src/test/resources/fixtures/{transactioin.json => transaction.json} (100%) diff --git a/src/main/java/com/chargebee/v4/internal/JsonUtil.java b/src/main/java/com/chargebee/v4/internal/JsonUtil.java index e97c48c7..1f64cb1d 100644 --- a/src/main/java/com/chargebee/v4/internal/JsonUtil.java +++ b/src/main/java/com/chargebee/v4/internal/JsonUtil.java @@ -11,7 +11,16 @@ * Avoids heavy dependencies while providing essential JSON functionality. */ public class JsonUtil { - + + private static final Pattern ARRAY_STRING_PATTERN = + Pattern.compile("\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\""); + private static final Pattern ARRAY_INT_PATTERN = + Pattern.compile("(-?\\d+)(?![.\\d])"); + private static final Pattern ARRAY_BOOL_PATTERN = + Pattern.compile("\\b(true|false)\\b"); + private static final Pattern ARRAY_DECIMAL_PATTERN = + Pattern.compile("(-?\\d+(?:\\.\\d+)?)"); + /** * Extract string value from JSON for a given key. * Only matches top-level keys (not inside nested objects/arrays). @@ -20,11 +29,22 @@ public static String getString(String json, String key) { if (json == null || key == null) { return null; } - String flat = stripNested(json); - Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\""); - Matcher matcher = pattern.matcher(flat); - if (matcher.find()) { - return unescapeJsonString(matcher.group(1)); + int start = findTopLevelValueStart(json, key); + if (start < 0 || start >= json.length() || json.charAt(start) != '"') { + return null; + } + int i = start + 1; + boolean escaped = false; + while (i < json.length()) { + char c = json.charAt(i); + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + return unescapeJsonString(json.substring(start + 1, i)); + } + i++; } return null; } @@ -37,13 +57,15 @@ public static Long getLong(String json, String key) { if (json == null || key == null) { return null; } - String flat = stripNested(json); - Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+)"); - Matcher matcher = pattern.matcher(flat); - if (matcher.find()) { - return Long.parseLong(matcher.group(1)); + String numStr = extractNumericString(json, key); + if (numStr == null) { + return null; + } + try { + return Long.parseLong(numStr); + } catch (NumberFormatException e) { + return null; } - return null; } /** @@ -54,13 +76,15 @@ public static Integer getInteger(String json, String key) { if (json == null || key == null) { return null; } - String flat = stripNested(json); - Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+)"); - Matcher matcher = pattern.matcher(flat); - if (matcher.find()) { - return Integer.parseInt(matcher.group(1)); + String numStr = extractNumericString(json, key); + if (numStr == null) { + return null; + } + try { + return Integer.parseInt(numStr); + } catch (NumberFormatException e) { + return null; } - return null; } /** @@ -71,11 +95,15 @@ public static Boolean getBoolean(String json, String key) { if (json == null || key == null) { return null; } - String flat = stripNested(json); - Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(true|false)"); - Matcher matcher = pattern.matcher(flat); - if (matcher.find()) { - return Boolean.parseBoolean(matcher.group(1)); + int start = findTopLevelValueStart(json, key); + if (start < 0 || start >= json.length()) { + return null; + } + if (json.regionMatches(start, "true", 0, 4)) { + return Boolean.TRUE; + } + if (json.regionMatches(start, "false", 0, 5)) { + return Boolean.FALSE; } return null; } @@ -84,17 +112,19 @@ public static Boolean getBoolean(String json, String key) { * Extract double value from JSON for a given key. * Only matches top-level keys (not inside nested objects/arrays). */ - public static Double getDouble(String json, String key) { + public static Double getDouble(String json, String key) { if (json == null || key == null) { return null; } - String flat = stripNested(json); - Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)"); - Matcher matcher = pattern.matcher(flat); - if (matcher.find()) { - return Double.parseDouble(matcher.group(1)); + String numStr = extractNumericString(json, key); + if (numStr == null) { + return null; + } + try { + return Double.parseDouble(numStr); + } catch (NumberFormatException e) { + return null; } - return null; } /** @@ -116,13 +146,15 @@ public static java.math.BigDecimal getBigDecimal(String json, String key) { if (json == null || key == null) { return null; } - String flat = stripNested(json); - Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)"); - Matcher matcher = pattern.matcher(flat); - if (matcher.find()) { - return new java.math.BigDecimal(matcher.group(1)); + String numStr = extractNumericString(json, key); + if (numStr == null) { + return null; + } + try { + return new java.math.BigDecimal(numStr); + } catch (NumberFormatException e) { + return null; } - return null; } /** @@ -270,6 +302,32 @@ private static int findTopLevelValueStart(String json, String key) { } return -1; } + + /** + * Locate a top-level numeric value for the given key and return it as a + * raw string (e.g. "-123", "3.75"). Returns {@code null} when the key + * is absent or the value is not a number. + */ + private static String extractNumericString(String json, String key) { + int start = findTopLevelValueStart(json, key); + if (start < 0 || start >= json.length()) { + return null; + } + char c = json.charAt(start); + if (c != '-' && !Character.isDigit(c)) { + return null; + } + int end = start + 1; + while (end < json.length()) { + c = json.charAt(end); + if (Character.isDigit(c) || c == '.') { + end++; + } else { + break; + } + } + return json.substring(start, end); + } /** * Parse array of objects and extract each object as JSON string. @@ -331,15 +389,11 @@ public static boolean hasValue(String json, String key) { if (json == null || key == null) { return false; } - String flat = stripNested(json); - // First check if the key exists with null value - Pattern nullPattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*null\\b"); - if (nullPattern.matcher(flat).find()) { + int start = findTopLevelValueStart(json, key); + if (start < 0 || start >= json.length()) { return false; } - // Then check if the key exists at all - Pattern keyPattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:"); - return keyPattern.matcher(flat).find(); + return !json.regionMatches(start, "null", 0, 4); } /** @@ -351,9 +405,7 @@ public static List parseArrayOfString(String arrayJson) { return result; } - // Extract string values from array - Pattern pattern = Pattern.compile("\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\""); - Matcher matcher = pattern.matcher(arrayJson); + Matcher matcher = ARRAY_STRING_PATTERN.matcher(arrayJson); while (matcher.find()) { result.add(unescapeJsonString(matcher.group(1))); } @@ -369,9 +421,7 @@ public static List parseArrayOfInteger(String arrayJson) { return result; } - // Extract integer values from array - Pattern pattern = Pattern.compile("(-?\\d+)(?![.\\d])"); - Matcher matcher = pattern.matcher(arrayJson); + Matcher matcher = ARRAY_INT_PATTERN.matcher(arrayJson); while (matcher.find()) { try { result.add(Integer.parseInt(matcher.group(1))); @@ -391,9 +441,7 @@ public static List parseArrayOfLong(String arrayJson) { return result; } - // Extract long values from array - Pattern pattern = Pattern.compile("(-?\\d+)(?![.\\d])"); - Matcher matcher = pattern.matcher(arrayJson); + Matcher matcher = ARRAY_INT_PATTERN.matcher(arrayJson); while (matcher.find()) { try { result.add(Long.parseLong(matcher.group(1))); @@ -413,9 +461,7 @@ public static List parseArrayOfBoolean(String arrayJson) { return result; } - // Extract boolean values from array - Pattern pattern = Pattern.compile("\\b(true|false)\\b"); - Matcher matcher = pattern.matcher(arrayJson); + Matcher matcher = ARRAY_BOOL_PATTERN.matcher(arrayJson); while (matcher.find()) { result.add(Boolean.parseBoolean(matcher.group(1))); } @@ -431,9 +477,7 @@ public static List parseArrayOfDouble(String arrayJson) { return result; } - // Extract double values from array - Pattern pattern = Pattern.compile("(-?\\d+(?:\\.\\d+)?)"); - Matcher matcher = pattern.matcher(arrayJson); + Matcher matcher = ARRAY_DECIMAL_PATTERN.matcher(arrayJson); while (matcher.find()) { try { result.add(Double.parseDouble(matcher.group(1))); @@ -453,9 +497,7 @@ public static List parseArrayOfBigDecimal(String arrayJson return result; } - // Extract BigDecimal values from array - Pattern pattern = Pattern.compile("(-?\\d+(?:\\.\\d+)?)"); - Matcher matcher = pattern.matcher(arrayJson); + Matcher matcher = ARRAY_DECIMAL_PATTERN.matcher(arrayJson); while (matcher.find()) { try { result.add(new java.math.BigDecimal(matcher.group(1))); @@ -671,64 +713,6 @@ else if (c == ']') { return map; } - /** - * Returns a flattened version of the JSON with nested objects and arrays - * replaced by {@code null}, so regex-based extraction only matches - * top-level keys. - */ - private static String stripNested(String json) { - if (json == null) return null; - StringBuilder sb = new StringBuilder(); - int depth = 0; - boolean inString = false; - boolean escaped = false; - - for (int i = 0; i < json.length(); i++) { - char c = json.charAt(i); - - if (escaped) { - escaped = false; - if (depth == 1) sb.append(c); - continue; - } - - if (c == '\\' && inString) { - escaped = true; - if (depth == 1) sb.append(c); - continue; - } - - if (c == '"') { - inString = !inString; - if (depth == 1) sb.append(c); - continue; - } - - if (!inString) { - if (c == '{' || c == '[') { - if (depth == 0) { - sb.append(c); - } else if (depth == 1) { - sb.append("null"); - } - depth++; - continue; - } - if (c == '}' || c == ']') { - depth--; - if (depth == 0) { - sb.append(c); - } - continue; - } - } - - if (depth == 1) sb.append(c); - } - - return sb.toString(); - } - /** * Unescape JSON string. * Processes escape sequences correctly by handling \\\\ last to avoid diff --git a/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java b/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java index f95be9a9..993ca8a5 100644 --- a/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java +++ b/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java @@ -968,6 +968,24 @@ class TopLevelKeyResolutionTests { assertFalse(JsonUtil.hasValue("{\"inner\": {\"key\": \"val\"}}", "key")); } + @Test void hasValue_topLevelArray() { + assertTrue(JsonUtil.hasValue( + "{\"tags\": [1, 2, 3], \"name\": \"test\"}", "tags")); + } + + @Test void hasValue_topLevelObject() { + assertTrue(JsonUtil.hasValue( + "{\"billing_address\": {\"city\": \"SF\"}, \"id\": \"inv_1\"}", "billing_address")); + } + + @Test void hasValue_topLevelEmptyArray() { + assertTrue(JsonUtil.hasValue("{\"items\": [], \"id\": \"x\"}", "items")); + } + + @Test void hasValue_topLevelEmptyObject() { + assertTrue(JsonUtil.hasValue("{\"meta\": {}, \"id\": \"x\"}", "meta")); + } + @Test void stripNested_escapedCharInNestedString() { assertEquals("top", JsonUtil.getString( "{\"child\": {\"k\": \"a\\\\b\"}, \"id\": \"top\"}", "id")); diff --git a/src/test/java/com/chargebee/v4/internal/TransactionParserTest.java b/src/test/java/com/chargebee/v4/internal/TransactionParserTest.java index 5bef8630..7c8ed4dc 100644 --- a/src/test/java/com/chargebee/v4/internal/TransactionParserTest.java +++ b/src/test/java/com/chargebee/v4/internal/TransactionParserTest.java @@ -19,8 +19,8 @@ class TransactionParserTest { @BeforeAll static void loadFixture() throws IOException { - try (InputStream is = TransactionParserTest.class.getResourceAsStream("/fixtures/transactioin.json")) { - assertNotNull(is, "fixtures/transactioin.json not found on classpath"); + try (InputStream is = TransactionParserTest.class.getResourceAsStream("/fixtures/transaction.json")) { + assertNotNull(is, "fixtures/transaction.json not found on classpath"); transactionJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); } transaction = Transaction.fromJson(transactionJson); diff --git a/src/test/resources/fixtures/invoice.json b/src/test/resources/fixtures/invoice.json index be25e37e..7823746d 100644 --- a/src/test/resources/fixtures/invoice.json +++ b/src/test/resources/fixtures/invoice.json @@ -111,5 +111,5 @@ "updated_at": 1517463750, "write_off_amount": 0, "cf_department": "engineering", - "cf_cost_center": "CC-1042", + "cf_cost_center": "CC-1042" } \ No newline at end of file diff --git a/src/test/resources/fixtures/transactioin.json b/src/test/resources/fixtures/transaction.json similarity index 100% rename from src/test/resources/fixtures/transactioin.json rename to src/test/resources/fixtures/transaction.json From 0f811630cd4c2736b8ea94546fe42ca48c010466 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Mon, 23 Feb 2026 15:27:12 +0530 Subject: [PATCH 3/3] add changelog and version bumpup --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbce63f5..247eee99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +### v4.3.1 (2026-02-23) +* * * + +### Bug Fixes: +* Fixed `JsonUtil` to only extract top-level keys from JSON objects. Previously, regex-based matching could return values from nested objects or arrays (e.g., `id` or `amount` inside `line_items`) instead of the correct root-level value. +* Custom fields (`cf_*`) are now correctly extracted from the root level only and no longer leak from nested objects or arrays. +* Numeric parsing methods (`getLong`, `getInteger`, `getDouble`, `getBigDecimal`) now return `null` instead of throwing `NumberFormatException` on malformed or overflow values. + ### v4.3.0 (2026-02-20) * * * diff --git a/VERSION b/VERSION index 80895903..f77856a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.3.0 +4.3.1