diff --git a/CHANGELOG.md b/CHANGELOG.md index b60b4903d..6453730a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +### v3.50.0 (2026-06-22) +* * * +### Bug Fixes: +- Fixed JSON request body serialization where `Timestamp` parameters were sent in a human-readable format (e.g. `"expires_at":"2026-06-23 09:54:44.513"`) instead of Unix seconds. They are now serialized as numeric Unix seconds (e.g. `"expires_at":1782189229`), matching the form-url-encoded path and the format expected by the API. This affects all JSON content-type endpoints, such as [`create_promotional_grant`](https://apidocs.chargebee.com/docs/api/promotional_grants/create-promotional-grant) in [`PromotionalGrant`](https://apidocs.chargebee.com/docs/api/promotional_grants). + + + ### v3.49.0 (2026-06-12) * * * ### New Resources: diff --git a/VERSION b/VERSION index 549b777ea..ca25ff637 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.49.0 +3.50.0 diff --git a/pom.xml b/pom.xml index eaa845229..cf165cd0c 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.chargebee chargebee-java - 3.49.0 + 3.50.0 jar diff --git a/src/main/java/com/chargebee/Environment.java b/src/main/java/com/chargebee/Environment.java index 792a93d3a..012b3657c 100644 --- a/src/main/java/com/chargebee/Environment.java +++ b/src/main/java/com/chargebee/Environment.java @@ -38,7 +38,7 @@ public class Environment { public static final String API_VERSION = "v2"; - public static final String LIBRARY_VERSION = "3.49.0"; + public static final String LIBRARY_VERSION = "3.50.0"; private final String apiBaseUrl; diff --git a/src/main/java/com/chargebee/internal/Params.java b/src/main/java/com/chargebee/internal/Params.java index e2bfaf2d0..a4654b1cb 100644 --- a/src/main/java/com/chargebee/internal/Params.java +++ b/src/main/java/com/chargebee/internal/Params.java @@ -79,10 +79,52 @@ else if(c.isEnum()) { } public String toJson() { - JSONObject jsonObject = new JSONObject(rawMap); + Map jsonMap = new HashMap(rawMap.size()); + for (Map.Entry e : rawMap.entrySet()) { + if (e.getValue() == null) { + continue; + } + jsonMap.put(e.getKey(), toJsonValue(e.getValue())); + } + JSONObject jsonObject = new JSONObject(jsonMap); return jsonObject.toString(); } + private static Object toJsonValue(Object value) { + if (value == null) { + return JSONObject.NULL; + } + Class c = value.getClass(); + if (c == Timestamp.class) { + return asUnixTimestamp((Timestamp) value); + } else if (c == Date.class) { + return new SimpleDateFormat("yyyy-MM-dd").format((Date) value); + } else if (c.isEnum()) { + return value.toString().toLowerCase(); + } else if (value instanceof List) { + List out = new ArrayList(((List) value).size()); + for (Object item : (List) value) { + out.add(toJsonValue(item)); + } + return out; + } else if (value instanceof Object[]) { + Object[] arr = (Object[]) value; + List out = new ArrayList(arr.length); + for (Object item : arr) { + out.add(toJsonValue(item)); + } + return out; + } else if (value instanceof Map) { + Map src = (Map) value; + Map out = new HashMap(src.size()); + for (Map.Entry e : src.entrySet()) { + out.put(String.valueOf(e.getKey()), toJsonValue(e.getValue())); + } + return out; + } + return value; + } + public static Long asUnixTimestamp(Timestamp ts) { return ts.getTime() / 1000; } diff --git a/src/test/java/com/chargebee/internal/ParamsToJsonTest.java b/src/test/java/com/chargebee/internal/ParamsToJsonTest.java new file mode 100644 index 000000000..6e7f024dd --- /dev/null +++ b/src/test/java/com/chargebee/internal/ParamsToJsonTest.java @@ -0,0 +1,264 @@ +package com.chargebee.internal; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link Params#toJson()}. + * + * Regression coverage for a bug where {@link java.sql.Timestamp} values were + * serialized in human-readable form (e.g. "2026-06-23 09:54:44.513") because + * the original implementation handed the raw value map straight to + * {@code new JSONObject(rawMap)}, which falls back to {@code Timestamp.toString()}. + * The Chargebee API expects Unix seconds. + */ +public class ParamsToJsonTest { + + private enum Status { ACTIVE, IN_TRIAL, NON_RENEWING } + + /** Build a Timestamp at a known UTC instant so tests are timezone-stable. */ + private static Timestamp utcTimestamp(int year, int month, int day, int hour, int min, int sec) { + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + cal.clear(); + cal.set(year, month - 1, day, hour, min, sec); + return new Timestamp(cal.getTimeInMillis()); + } + + @Test + void timestampIsSerializedAsUnixSecondsNumber() { + Params p = new Params(); + Timestamp ts = utcTimestamp(2026, 6, 23, 9, 54, 44); + long expectedUnixSeconds = ts.getTime() / 1000; + + p.add("subscription_id", "overage-check"); + p.add("amount", "500"); + p.add("expires_at", ts); + p.add("unit_id", "ai_credits"); + + JSONObject json = new JSONObject(p.toJson()); + + assertEquals("overage-check", json.getString("subscription_id")); + assertEquals("500", json.getString("amount")); + assertEquals("ai_credits", json.getString("unit_id")); + + // The key regression assertion: must be a number, not a stringified date. + assertEquals(expectedUnixSeconds, json.getLong("expires_at"), + "Timestamp must be serialized as Unix-seconds number, not a human-readable string"); + + // And it must NOT look like "yyyy-MM-dd HH:mm:ss(.SSS)?". + String raw = p.toJson(); + assertFalse(raw.contains("\"expires_at\":\"" + ts.toString() + "\""), + "Timestamp should not be serialized via Timestamp.toString()"); + assertFalse(raw.matches(".*\"expires_at\"\\s*:\\s*\"\\d{4}-\\d{2}-\\d{2} .*"), + "Timestamp should not be serialized in 'yyyy-MM-dd HH:mm:ss' form"); + } + + @Test + void dateIsSerializedAsYyyyMmDdString() { + Params p = new Params(); + Calendar cal = Calendar.getInstance(TimeZone.getDefault()); + cal.clear(); + cal.set(2025, Calendar.DECEMBER, 31, 10, 0, 0); + Date d = cal.getTime(); + p.add("trial_end_date", d); + + String expected = new SimpleDateFormat("yyyy-MM-dd").format(d); + JSONObject json = new JSONObject(p.toJson()); + assertEquals(expected, json.getString("trial_end_date")); + } + + @Test + void enumIsSerializedAsLowercaseString() { + Params p = new Params(); + p.add("status", Status.IN_TRIAL); + + JSONObject json = new JSONObject(p.toJson()); + assertEquals("in_trial", json.getString("status")); + } + + @Test + void numericTypesArePreservedAsJsonNumbers() { + Params p = new Params(); + p.add("int_val", 42); + p.add("long_val", 1234567890123L); + p.add("double_val", 3.14); + p.add("decimal_val", new BigDecimal("19.99")); + p.add("bool_val", true); + + JSONObject json = new JSONObject(p.toJson()); + assertEquals(42, json.getInt("int_val")); + assertEquals(1234567890123L, json.getLong("long_val")); + assertEquals(3.14, json.getDouble("double_val"), 0.0001); + assertEquals(new BigDecimal("19.99"), json.getBigDecimal("decimal_val")); + assertTrue(json.getBoolean("bool_val")); + } + + @Test + void stringIsSerializedAsJsonString() { + Params p = new Params(); + p.add("name", "Alice \"Q\" \n line2"); + + JSONObject json = new JSONObject(p.toJson()); + assertEquals("Alice \"Q\" \n line2", json.getString("name")); + } + + @Test + void nestedJsonObjectIsPreservedAsStructuredJson() { + Params p = new Params(); + JSONObject billing = new JSONObject(); + billing.put("city", "NYC"); + billing.put("zip", "10001"); + p.add("billing_address", billing); + + JSONObject json = new JSONObject(p.toJson()); + JSONObject got = json.getJSONObject("billing_address"); + assertEquals("NYC", got.getString("city")); + assertEquals("10001", got.getString("zip")); + } + + @Test + void nestedJsonArrayIsPreservedAsStructuredJson() { + Params p = new Params(); + JSONArray arr = new JSONArray(); + arr.put("a"); + arr.put("b"); + p.add("tags", arr); + + JSONObject json = new JSONObject(p.toJson()); + JSONArray got = json.getJSONArray("tags"); + assertEquals(2, got.length()); + assertEquals("a", got.getString(0)); + assertEquals("b", got.getString(1)); + } + + @Test + void mapValueIsPreservedAsStructuredJsonAndTimestampsInsideAreConverted() { + Params p = new Params(); + Timestamp ts = utcTimestamp(2026, 1, 1, 0, 0, 0); + long expected = ts.getTime() / 1000; + + Map meta = new LinkedHashMap(); + meta.put("source", "ui"); + meta.put("seen_at", ts); + p.add("metadata", meta); + + JSONObject json = new JSONObject(p.toJson()); + JSONObject got = json.getJSONObject("metadata"); + assertEquals("ui", got.getString("source")); + assertEquals(expected, got.getLong("seen_at")); + } + + @Test + void listOfTimestampsConvertsEachElement() { + Params p = new Params(); + Timestamp t1 = utcTimestamp(2026, 1, 1, 0, 0, 0); + Timestamp t2 = utcTimestamp(2026, 2, 1, 0, 0, 0); + List values = new ArrayList(); + values.add(t1); + values.add(t2); + p.add("checkpoints", values); + + JSONObject json = new JSONObject(p.toJson()); + JSONArray arr = json.getJSONArray("checkpoints"); + assertEquals(2, arr.length()); + assertEquals(t1.getTime() / 1000, arr.getLong(0)); + assertEquals(t2.getTime() / 1000, arr.getLong(1)); + } + + @Test + void objectArrayIsConvertedRecursively() { + Params p = new Params(); + Timestamp t1 = utcTimestamp(2026, 3, 1, 0, 0, 0); + Object[] arr = new Object[] { t1, "hello", 7 }; + p.add("mixed", arr); + + JSONObject json = new JSONObject(p.toJson()); + JSONArray got = json.getJSONArray("mixed"); + assertEquals(3, got.length()); + assertEquals(t1.getTime() / 1000, got.getLong(0)); + assertEquals("hello", got.getString(1)); + assertEquals(7, got.getInt(2)); + } + + @Test + void deeplyNestedMapAndListAreFullyTraversed() { + Params p = new Params(); + Timestamp ts = utcTimestamp(2026, 4, 15, 12, 0, 0); + long expected = ts.getTime() / 1000; + + Map inner = new HashMap(); + inner.put("at", ts); + inner.put("status", Status.ACTIVE); + + List list = new ArrayList(); + list.add(inner); + list.add(Arrays.asList(ts, "x")); + + Map outer = new HashMap(); + outer.put("events", list); + p.add("payload", outer); + + JSONObject json = new JSONObject(p.toJson()); + JSONArray events = json.getJSONObject("payload").getJSONArray("events"); + + JSONObject first = events.getJSONObject(0); + assertEquals(expected, first.getLong("at")); + assertEquals("active", first.getString("status")); + + JSONArray second = events.getJSONArray(1); + assertEquals(expected, second.getLong(0)); + assertEquals("x", second.getString(1)); + } + + @Test + void addOptWithNullValueIsExcludedFromJson() { + Params p = new Params(); + p.add("id", "sub_1"); + p.addOpt("trial_end", null); + + JSONObject json = new JSONObject(p.toJson()); + assertEquals("sub_1", json.getString("id")); + assertFalse(json.has("trial_end"), + "Optional null params should be omitted from the JSON body (matches original behavior)"); + } + + @Test + void addOptWithTimestampIsSerializedAsUnixSecondsNumber() { + Params p = new Params(); + Timestamp ts = utcTimestamp(2027, 7, 4, 18, 30, 0); + p.addOpt("trial_end", ts); + + JSONObject json = new JSONObject(p.toJson()); + assertEquals(ts.getTime() / 1000, json.getLong("trial_end")); + } + + @Test + void emptyParamsProducesEmptyJsonObject() { + Params p = new Params(); + assertEquals("{}", p.toJson()); + } + + @Test + void toValStrPathStillReturnsUnixSecondsStringForTimestamp() { + // Sanity check that the form-encoded path (which uses toValStr) is unaffected. + Timestamp ts = utcTimestamp(2026, 6, 23, 9, 54, 44); + Object converted = Params.toValStr(ts); + assertEquals(String.valueOf(ts.getTime() / 1000), converted); + } +} diff --git a/src/test/java/com/chargebee/internal/RequestBodySerializationTest.java b/src/test/java/com/chargebee/internal/RequestBodySerializationTest.java new file mode 100644 index 000000000..957eebc3b --- /dev/null +++ b/src/test/java/com/chargebee/internal/RequestBodySerializationTest.java @@ -0,0 +1,369 @@ +package com.chargebee.internal; + +import com.chargebee.Environment; +import com.chargebee.Result; +import com.chargebee.models.PromotionalGrant; +import com.chargebee.models.Subscription; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Verifies that request body serialization is correct end-to-end across both + * transport encodings: + * + * 1. JSON requests (e.g. {@link PromotionalGrant#promotionalGrants()}): Timestamps + * must be serialized as Unix-seconds numbers, NOT human-readable strings such + * as "2026-06-23 09:54:44.513". This is the regression scenario. + * + * 2. Form-url-encoded requests: behavior is unchanged. Timestamps continue to + * flow through {@link Params#toValStr(Object)} → {@link Params#asUnixTimestamp(Timestamp)} + * and are encoded as {@code expires_at=}. + */ +public class RequestBodySerializationTest { + + private Environment env; + + @BeforeEach + void setUp() { + env = new Environment("test-site", "test-key"); + env.enableDebugLogging = false; + } + + // --------------------------------------------------------------------- + // JSON path: end-to-end via the real PromotionalGrant request primitive. + // --------------------------------------------------------------------- + + @Test + void promotionalGrantJsonBodySerializesTimestampAsUnixSecondsNumber() throws Exception { + // The exact call shape from the bug report. + Timestamp expiresAt = Timestamp.from(Instant.parse("2026-06-23T09:54:44Z")); + long expectedUnixSeconds = expiresAt.getTime() / 1000; + + CapturedRequest captured = stubHttpForJsonPost( + buildPromotionalGrantResponse("1mGETgZVF2umUZq", "ai_credits", "500", expectedUnixSeconds) + ); + + try (MockedStatic mocked = mockStatic(HttpUtil.class, CALLS_REAL_METHODS)) { + mocked.when(() -> HttpUtil.createConnection(anyString(), eq(HttpUtil.Method.POST), any(), eq(env))) + .thenReturn(captured.conn); + + Result result = PromotionalGrant.promotionalGrants() + .subscriptionId("1mGETgZVF2umUZq") + .unitId("ai_credits") + .amount("500") + .expiresAt(expiresAt) + .request(env); + + assertNotNull(result); + assertEquals(200, result.httpCode); + + String body = captured.body(); + JSONObject sent = new JSONObject(body); + + assertEquals("1mGETgZVF2umUZq", sent.getString("subscription_id")); + assertEquals("ai_credits", sent.getString("unit_id")); + assertEquals("500", sent.getString("amount")); + + // Core regression assertions: + // - expires_at must be a JSON number equal to Unix seconds. + assertEquals(expectedUnixSeconds, sent.getLong("expires_at"), + "expires_at must be Unix seconds (number) in the JSON body"); + // - The raw body must NOT contain a quoted, human-readable timestamp. + assertFalse(body.matches(".*\"expires_at\"\\s*:\\s*\"[^\"]+\".*"), + "expires_at must be a JSON number, not a quoted string. Body was: " + body); + assertFalse(body.contains(expiresAt.toString()), + "Body must not contain Timestamp.toString() output. Body was: " + body); + } + } + + @Test + void promotionalGrantJsonBodyMatchesUserExampleShape() throws Exception { + Timestamp expiresAt = Timestamp.from(Instant.now().plus(1, ChronoUnit.DAYS)); + long expectedUnixSeconds = expiresAt.getTime() / 1000; + + CapturedRequest captured = stubHttpForJsonPost( + buildPromotionalGrantResponse("1mGETgZVF2umUZq", "ai_credits", "500", expectedUnixSeconds) + ); + + try (MockedStatic mocked = mockStatic(HttpUtil.class, CALLS_REAL_METHODS)) { + mocked.when(() -> HttpUtil.createConnection(anyString(), eq(HttpUtil.Method.POST), any(), eq(env))) + .thenReturn(captured.conn); + + PromotionalGrant.promotionalGrants() + .subscriptionId("1mGETgZVF2umUZq") + .unitId("ai_credits") + .amount("500") + .expiresAt(expiresAt) + .request(env); + + JSONObject sent = new JSONObject(captured.body()); + assertEquals(expectedUnixSeconds, sent.getLong("expires_at")); + } + } + + @Test + void promotionalGrantSetsJsonContentTypeHeader() throws Exception { + Timestamp expiresAt = Timestamp.from(Instant.parse("2026-06-23T09:54:44Z")); + long expectedUnixSeconds = expiresAt.getTime() / 1000; + + CapturedRequest captured = stubHttpForJsonPost( + buildPromotionalGrantResponse("sub_1", "ai_credits", "500", expectedUnixSeconds) + ); + + try (MockedStatic mocked = mockStatic(HttpUtil.class, CALLS_REAL_METHODS)) { + mocked.when(() -> HttpUtil.createConnection(anyString(), eq(HttpUtil.Method.POST), + argThat(h -> h != null + && ("application/json;charset=" + Environment.CHARSET).equals(h.get("Content-Type"))), + eq(env))) + .thenReturn(captured.conn); + + assertDoesNotThrow(() -> PromotionalGrant.promotionalGrants() + .subscriptionId("sub_1") + .unitId("ai_credits") + .amount("500") + .expiresAt(expiresAt) + .request(env)); + + // If the matcher above didn't match, the static mock would have returned null and + // the call would have NPE'd. Reaching here proves the JSON Content-Type was set. + mocked.verify(() -> HttpUtil.createConnection(anyString(), eq(HttpUtil.Method.POST), any(), eq(env))); + } + } + + // --------------------------------------------------------------------- + // Form-url-encoded path: behavior must NOT change. + // --------------------------------------------------------------------- + + @Test + void formEncodedPathStillEncodesTimestampAsUnixSeconds() throws Exception { + Timestamp ts = Timestamp.from(Instant.parse("2026-06-23T09:54:44Z")); + long expectedUnixSeconds = ts.getTime() / 1000; + + Params p = new Params(); + p.add("subscription_id", "overage-check"); + p.add("amount", "500"); + p.add("expires_at", ts); + p.add("unit_id", "ai_credits"); + + String query = HttpUtil.toQueryStr(p); + + assertTrue(query.contains("expires_at=" + expectedUnixSeconds), + "Form-encoded body must contain 'expires_at='. Got: " + query); + assertTrue(query.contains("subscription_id=overage-check")); + assertTrue(query.contains("amount=500")); + assertTrue(query.contains("unit_id=ai_credits")); + + // And must NOT contain any URL-encoded human-readable timestamp fragments. + assertFalse(query.contains("%20"), // a space-encoded char would only appear in a date-time string + "Form-encoded body unexpectedly contains a space-encoded character: " + query); + assertFalse(query.toLowerCase().contains(ts.toString().substring(0, 4).toLowerCase() + "-"), + "Form-encoded body must not contain a yyyy-MM-dd fragment for the timestamp."); + } + + @Test + void formEncodedPathSerializesDateAndOtherPrimitivesUnchanged() throws Exception { + Date date = new Date(0L); // 1970-01-01 in UTC + Params p = new Params(); + p.add("name", "alice"); + p.add("count", 7); + p.add("active", true); + p.add("trial_end_date", date); + + String query = HttpUtil.toQueryStr(p); + + assertTrue(query.contains("name=alice")); + assertTrue(query.contains("count=7")); + assertTrue(query.contains("active=true")); + // Date is formatted via SimpleDateFormat("yyyy-MM-dd") with default TZ; verify the encoded "-" survives. + assertTrue(query.matches(".*trial_end_date=\\d{4}-\\d{2}-\\d{2}.*"), + "Date must remain serialized as yyyy-MM-dd. Got: " + query); + } + + @Test + void subscriptionCreateWithItemsFormBodySerializesTimestampsAsUnixSeconds() throws Exception { + // The exact user snippet, augmented with the Timestamp fields exposed by this request + // primitive (trialEnd, startDate, subscriptionItemTrialEnd) so the form-url-encoded + // path is exercised end-to-end. + Timestamp trialEnd = Timestamp.from(Instant.parse("2026-07-01T00:00:00Z")); + Timestamp startDate = Timestamp.from(Instant.parse("2026-06-25T00:00:00Z")); + Timestamp itemTrialEnd = Timestamp.from(Instant.parse("2026-06-30T12:00:00Z")); + long expectedTrialEnd = trialEnd.getTime() / 1000; + long expectedStartDate = startDate.getTime() / 1000; + long expectedItemTrialEnd = itemTrialEnd.getTime() / 1000; + + CapturedRequest captured = stubHttpForFormPost("{\"subscription\":{\"id\":\"__test__8asz8Ru9WhHOJO\"}}"); + + try (MockedStatic mocked = mockStatic(HttpUtil.class, CALLS_REAL_METHODS)) { + mocked.when(() -> HttpUtil.createConnection(anyString(), eq(HttpUtil.Method.POST), any(), eq(env))) + .thenReturn(captured.conn); + + Result result = Subscription.createWithItems("__test__8asz8Ru9WhHOJO") + .subscriptionItemItemPriceId(0, "basic-USD") + .subscriptionItemBillingCycles(0, 2) + .subscriptionItemQuantity(0, 1) + .subscriptionItemItemPriceId(1, "day-pass-USD") + .subscriptionItemUnitPrice(1, 100L) + .trialEnd(trialEnd) + .startDate(startDate) + .subscriptionItemTrialEnd(0, itemTrialEnd) + .request(env); + + assertNotNull(result); + assertEquals(200, result.httpCode); + + String body = captured.body(); + + // Body must look like a form-url-encoded string, not JSON. + assertFalse(body.startsWith("{"), "Form-encoded body must not be JSON. Got: " + body); + assertTrue(body.contains("&") || body.contains("="), + "Form-encoded body must contain '=' / '&'. Got: " + body); + + // Headers passed to createConnection must NOT have been switched to JSON. + // (The form path leaves the Content-Type header to be set inside createConnection itself.) + mocked.verify(() -> HttpUtil.createConnection( + anyString(), + eq(HttpUtil.Method.POST), + argThat(h -> h == null + || !("application/json;charset=" + Environment.CHARSET).equals(h.get("Content-Type"))), + eq(env))); + + // Customer id is part of the URL path, not the body. + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + mocked.verify(() -> HttpUtil.createConnection( + urlCaptor.capture(), eq(HttpUtil.Method.POST), any(), eq(env))); + assertTrue(urlCaptor.getValue().contains("/customers/__test__8asz8Ru9WhHOJO/subscription_for_items"), + "Customer id must be embedded in the URL path. Got: " + urlCaptor.getValue()); + + Map params = parseFormBody(body); + + // All non-timestamp params are present and intact. + assertEquals("basic-USD", params.get("subscription_items[item_price_id][0]")); + assertEquals("2", params.get("subscription_items[billing_cycles][0]")); + assertEquals("1", params.get("subscription_items[quantity][0]")); + assertEquals("day-pass-USD", params.get("subscription_items[item_price_id][1]")); + assertEquals("100", params.get("subscription_items[unit_price][1]")); + + // Core regression assertions for the form path: each Timestamp comes through + // as a Unix-seconds string, NOT a human-readable date-time. + assertEquals(String.valueOf(expectedTrialEnd), params.get("trial_end")); + assertEquals(String.valueOf(expectedStartDate), params.get("start_date")); + assertEquals(String.valueOf(expectedItemTrialEnd), params.get("subscription_items[trial_end][0]")); + + // And belt-and-braces: none of the timestamp keys should hold a value containing + // a space (which would indicate Timestamp.toString() leaked into the body). + assertFalse(params.get("trial_end").contains(" ")); + assertFalse(params.get("start_date").contains(" ")); + assertFalse(params.get("subscription_items[trial_end][0]").contains(" ")); + } + } + + @Test + void paramsEntriesUsedByFormPathStoreTimestampAsUnixSecondsString() { + // The form-encoded path iterates Params.entries(), which reads the `m` map. + // The `m` map stores values produced by toValStr(...). Pin that contract. + Timestamp ts = Timestamp.from(Instant.parse("2026-06-23T09:54:44Z")); + long expectedUnixSeconds = ts.getTime() / 1000; + + Params p = new Params(); + p.add("expires_at", ts); + + Object stored = null; + for (java.util.Map.Entry e : p.entries()) { + if (e.getKey().equals("expires_at")) { + stored = e.getValue(); + break; + } + } + assertEquals(String.valueOf(expectedUnixSeconds), stored, + "Form-path storage for Timestamp must remain a Unix-seconds string"); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static final class CapturedRequest { + final HttpURLConnection conn; + final ByteArrayOutputStream out; + + CapturedRequest(HttpURLConnection conn, ByteArrayOutputStream out) { + this.conn = conn; + this.out = out; + } + + String body() throws Exception { + return out.toString(StandardCharsets.UTF_8.name()); + } + } + + private CapturedRequest stubHttpForJsonPost(String responseJson) throws Exception { + return stubHttpForPost(responseJson, "https://test-site.chargebee.com/api/v2/promotional_grants"); + } + + private CapturedRequest stubHttpForFormPost(String responseJson) throws Exception { + return stubHttpForPost(responseJson, "https://test-site.chargebee.com/api/v2/subscriptions"); + } + + private CapturedRequest stubHttpForPost(String responseJson, String url) throws Exception { + HttpURLConnection conn = mock(HttpURLConnection.class); + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + + when(conn.getOutputStream()).thenReturn(captured); + when(conn.getResponseCode()).thenReturn(200); + when(conn.getInputStream()) + .thenReturn(new ByteArrayInputStream(responseJson.getBytes(StandardCharsets.UTF_8))); + when(conn.getHeaderFields()).thenReturn(new HashMap<>()); + when(conn.getURL()).thenReturn(new URL(url)); + when(conn.getRequestMethod()).thenReturn("POST"); + + return new CapturedRequest(conn, captured); + } + + private static Map parseFormBody(String body) throws Exception { + Map out = new LinkedHashMap(); + if (body == null || body.isEmpty()) { + return out; + } + for (String pair : body.split("&")) { + int eq = pair.indexOf('='); + String rawKey = eq < 0 ? pair : pair.substring(0, eq); + String rawVal = eq < 0 ? "" : pair.substring(eq + 1); + String key = URLDecoder.decode(rawKey, StandardCharsets.UTF_8.name()); + String val = URLDecoder.decode(rawVal, StandardCharsets.UTF_8.name()); + out.put(key, val); + } + return out; + } + + private static String buildPromotionalGrantResponse(String subId, String unitId, String amount, long expiresAt) { + JSONObject grant = new JSONObject(); + grant.put("subscription_id", subId); + grant.put("unit_id", unitId); + grant.put("amount", amount); + grant.put("expires_at", expiresAt); + JSONObject root = new JSONObject(); + root.put("promotional_grant", grant); + return root.toString(); + } +}