From 0783490970cfd62706c41e1e03dacabfbeb54ecf Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 12 May 2026 18:48:31 +0300 Subject: [PATCH 1/3] Bump SDK version to 2.15.1 --- example/app/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/app/build.gradle b/example/app/build.gradle index 9e21cd21..660c20da 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -92,7 +92,7 @@ dependencies { implementation 'com.google.code.gson:gson:2.11.0' //Mindbox - implementation 'cloud.mindbox:mobile-sdk:2.15.0' + implementation 'cloud.mindbox:mobile-sdk:2.15.1' implementation 'cloud.mindbox:mindbox-firebase' implementation 'cloud.mindbox:mindbox-huawei' implementation 'cloud.mindbox:mindbox-rustore' diff --git a/gradle.properties b/gradle.properties index 16c42c7b..3649f2cc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,7 +20,7 @@ android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # SDK version property -SDK_VERSION_NAME=2.15.0 +SDK_VERSION_NAME=2.15.1 USE_LOCAL_MINDBOX_COMMON=true android.nonTransitiveRClass=false kotlin.mpp.androidGradlePluginCompatibility.nowarn=true From 101c0c0a39fb199bf8ad057bc3f699cc1fa14722 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 8 May 2026 11:07:23 +0300 Subject: [PATCH 2/3] MOBILE-173: Add anntations SerializedName for WebviewBridge --- sdk/consumer-rules.pro | 5 +-- .../inapp/presentation/view/WebViewAction.kt | 36 +++++++++---------- .../view/WebViewInappViewHolder.kt | 2 ++ 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/sdk/consumer-rules.pro b/sdk/consumer-rules.pro index 9d3c19fb..80767f1d 100644 --- a/sdk/consumer-rules.pro +++ b/sdk/consumer-rules.pro @@ -1,9 +1,6 @@ # Keep model classes -keepclassmembers class cloud.mindbox.mobile_sdk.models** { *; } --keepclassmembers enum cloud.mindbox.mobile_sdk.models** { *; } -keep class cloud.mindbox.mobile_sdk.MindboxConfiguration { *; } -keep class cloud.mindbox.mobile_sdk.pushes.PushAction { *; } --keep class cloud.mindbox.mobile_sdk.inapp.data** { *; } +-keepclassmembers class cloud.mindbox.mobile_sdk.inapp.data.dto.** { *; } -keep class cloud.mindbox.mobile_sdk.inapp.domain.models** { *; } - --keep public class * extends android.preference.Preference \ No newline at end of file diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index ce539766..f44440c6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -84,30 +84,30 @@ public sealed class BridgeMessage { public abstract val timestamp: Long public data class Request( - override val version: Int, - override val action: WebViewAction, - override val payload: String?, - override val id: String, - override val timestamp: Long, - override val type: String = TYPE_REQUEST, + @SerializedName("version") override val version: Int, + @SerializedName("action") override val action: WebViewAction, + @SerializedName("payload") override val payload: String?, + @SerializedName("id") override val id: String, + @SerializedName("timestamp") override val timestamp: Long, + @SerializedName("type") override val type: String = TYPE_REQUEST, ) : BridgeMessage() public data class Response( - override val version: Int, - override val action: WebViewAction, - override val payload: String?, - override val id: String, - override val timestamp: Long, - override val type: String = TYPE_RESPONSE, + @SerializedName("version") override val version: Int, + @SerializedName("action") override val action: WebViewAction, + @SerializedName("payload") override val payload: String?, + @SerializedName("id") override val id: String, + @SerializedName("timestamp") override val timestamp: Long, + @SerializedName("type") override val type: String = TYPE_RESPONSE, ) : BridgeMessage() public data class Error( - override val version: Int, - override val action: WebViewAction, - override val payload: String?, - override val id: String, - override val timestamp: Long, - override val type: String = TYPE_ERROR, + @SerializedName("version") override val version: Int, + @SerializedName("action") override val action: WebViewAction, + @SerializedName("payload") override val payload: String?, + @SerializedName("id") override val id: String, + @SerializedName("timestamp") override val timestamp: Long, + @SerializedName("type") override val type: String = TYPE_ERROR, ) : BridgeMessage() public companion object { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 41d8ba2e..76c5ccca 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -782,10 +782,12 @@ internal class WebViewInAppViewHolder( } private data class NavigationInterceptedPayload( + @SerializedName("url") val url: String ) private data class ErrorPayload( + @SerializedName("error") val error: String ) From 2b2865edfbe15c20b72647f4578a1542c39de2aa Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 8 May 2026 11:55:35 +0300 Subject: [PATCH 3/3] MOBILE-173: Add test for gson serialization --- .../models/MindboxErrorAdapterTest.kt | 260 ++++++++++++++++++ .../adapters/CustomerFieldsAdapterTest.kt | 152 ++++++++++ .../operation/adapters/DateOnlyAdapterTest.kt | 144 ++++++++++ .../operation/adapters/DateTimeAdapterTest.kt | 161 +++++++++++ .../operation/adapters/IdsAdapterTest.kt | 132 +++++++++ .../ProductListResponseAdapterTest.kt | 146 ++++++++++ 6 files changed, 995 insertions(+) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/MindboxErrorAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/CustomerFieldsAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateOnlyAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateTimeAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/IdsAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/ProductListResponseAdapterTest.kt diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/MindboxErrorAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/MindboxErrorAdapterTest.kt new file mode 100644 index 00000000..39689ad5 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/MindboxErrorAdapterTest.kt @@ -0,0 +1,260 @@ +package cloud.mindbox.mobile_sdk.models + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [MindboxErrorAdapter] via the public [MindboxError.toJson] API. + * + * The adapter handles two directions: + * - write (toJson) — used by SDK clients to log/pass errors around — fully implemented. + * - read (fromJson) — parsing is effectively non-functional in the current implementation + * (the read() method reads the JSON key name instead of key value, causing it to always + * fall through to `else -> null`). Tests below document this known behavior. + */ +class MindboxErrorAdapterTest { + + // MindboxError subtypes have @JsonAdapter(MindboxErrorAdapter::class) + private val gson = Gson() + + // region write / toJson — Validation + + @Test + fun `toJson Validation - contains type MindboxError`() { + val error = MindboxError.Validation( + statusCode = 200, + status = "ValidationError", + validationMessages = emptyList(), + ) + val json = error.toJson() + assertTrue(json.contains(""""type":"MindboxError"""")) + } + + @Test + fun `toJson Validation - contains statusCode`() { + val error = MindboxError.Validation(200, "ValidationError", emptyList()) + val json = error.toJson() + assertTrue(json.contains(""""statusCode":200""")) + } + + @Test + fun `toJson Validation - contains status`() { + val error = MindboxError.Validation(200, "ValidationError", emptyList()) + val json = error.toJson() + assertTrue(json.contains(""""status":"ValidationError"""")) + } + + @Test + fun `toJson Validation - contains empty validationMessages array`() { + val error = MindboxError.Validation(200, "ValidationError", emptyList()) + val json = error.toJson() + assertTrue(json.contains(""""validationMessages":[]""")) + } + + @Test + fun `toJson Validation - contains validationMessages with entries`() { + val error = MindboxError.Validation( + statusCode = 200, + status = "ValidationError", + validationMessages = listOf( + ValidationMessage(message = "field required", location = "email"), + ), + ) + val json = error.toJson() + assertTrue(json.contains(""""message":"field required"""")) + assertTrue(json.contains(""""location":"email"""")) + } + + @Test + fun `toJson Validation - full JSON structure`() { + val error = MindboxError.Validation(200, "Ok", emptyList()) + val json = error.toJson() + assertEquals( + """{"type":"MindboxError","data":{"statusCode":200,"status":"Ok","validationMessages":[]}}""", + json, + ) + } + + // endregion + + // region write / toJson — Protocol + + @Test + fun `toJson Protocol - contains type MindboxError`() { + val error = MindboxError.Protocol( + statusCode = 400, + status = "Error", + errorMessage = "Bad request", + errorId = "err-1", + httpStatusCode = 400, + ) + val json = error.toJson() + assertTrue(json.contains(""""type":"MindboxError"""")) + } + + @Test + fun `toJson Protocol - full JSON structure`() { + val error = MindboxError.Protocol(400, "Error", "Bad request", "err-1", 400) + val json = error.toJson() + assertEquals( + """{"type":"MindboxError","data":{"statusCode":400,"status":"Error","errorMessage":"Bad request","errorId":"err-1","httpStatusCode":400}}""", + json, + ) + } + + @Test + fun `toJson Protocol - null optional fields omitted from JSON`() { + // GSON's nullValue() silently skips a name+value pair when serializeNulls = false + // (the default). MindboxErrorAdapter does not override this, so null fields are + // absent from the output — not present as "null". This is the current behavior. + val error = MindboxError.Protocol(403, "Forbidden", null, null, null) + val json = error.toJson() + assertFalse("null errorMessage should be omitted", json.contains("errorMessage")) + assertFalse("null errorId should be omitted", json.contains("errorId")) + assertFalse("null httpStatusCode should be omitted", json.contains("httpStatusCode")) + } + + // endregion + + // region write / toJson — InternalServer + + @Test + fun `toJson InternalServer - contains type MindboxError`() { + val error = MindboxError.InternalServer(500, "ServerError", "Internal error", "id-1", 500) + val json = error.toJson() + assertTrue(json.contains(""""type":"MindboxError"""")) + } + + @Test + fun `toJson InternalServer - full JSON structure`() { + val error = MindboxError.InternalServer(500, "ServerError", "Internal error", "id-1", 500) + val json = error.toJson() + assertEquals( + """{"type":"MindboxError","data":{"statusCode":500,"status":"ServerError","errorMessage":"Internal error","errorId":"id-1","httpStatusCode":500}}""", + json, + ) + } + + // endregion + + // region write / toJson — UnknownServer + + @Test + fun `toJson UnknownServer - contains type NetworkError`() { + val error = MindboxError.UnknownServer() + val json = error.toJson() + assertTrue(json.contains(""""type":"NetworkError"""")) + } + + @Test + fun `toJson UnknownServer - default constructor full JSON`() { + val error = MindboxError.UnknownServer() + val json = error.toJson() + // Default constructor sets errorMessage = "Cannot reach server", all else null + assertTrue(json.contains(""""errorMessage":"Cannot reach server"""")) + } + + @Test + fun `toJson UnknownServer - with all fields`() { + val error = MindboxError.UnknownServer(503, "Unavailable", "Service down", "id-2", 503) + val json = error.toJson() + assertEquals( + """{"type":"NetworkError","data":{"statusCode":503,"status":"Unavailable","errorMessage":"Service down","errorId":"id-2","httpStatusCode":503}}""", + json, + ) + } + + // endregion + + // region write / toJson — Unknown + + @Test + fun `toJson Unknown - contains type InternalError`() { + val error = MindboxError.Unknown() + val json = error.toJson() + assertTrue(json.contains(""""type":"InternalError"""")) + } + + @Test + fun `toJson Unknown - null throwable produces empty data object`() { + // Both errorName and errorMessage are null → both name+null pairs are silently + // dropped by GSON (serializeNulls = false). The data object is empty. + val error = MindboxError.Unknown(throwable = null) + val json = error.toJson() + assertFalse("null errorName should be omitted", json.contains("errorName")) + assertFalse("null errorMessage should be omitted", json.contains("errorMessage")) + assertTrue("data object should be present but empty", json.contains(""""data":{}""")) + } + + @Test + fun `toJson Unknown - throwable class name and message included`() { + val throwable = RuntimeException("something went wrong") + val error = MindboxError.Unknown(throwable) + val json = error.toJson() + assertTrue(json.contains("RuntimeException")) + assertTrue(json.contains("something went wrong")) + } + + // endregion + + // region read / fromJson — current behavior documentation + + @Test + fun `fromJson Validation - current behavior returns null (read is not implemented)`() { + // MindboxErrorAdapter.read() calls nextString() after beginObject() which reads + // the key name "type" instead of its value. None of the when-branches match "type", + // so the method always returns null. This test documents that known limitation so + // that a future fix or migration will be noticed immediately. + val json = MindboxError.Validation(200, "Ok", emptyList()).toJson() + val result = gson.fromJson(json, MindboxError.Validation::class.java) + assertNull(result) + } + + @Test + fun `fromJson Protocol - current behavior returns null`() { + val json = MindboxError.Protocol(400, "Error", null, null, null).toJson() + val result = gson.fromJson(json, MindboxError.Protocol::class.java) + assertNull(result) + } + + @Test + fun `fromJson UnknownServer - current behavior returns null`() { + val json = MindboxError.UnknownServer().toJson() + val result = gson.fromJson(json, MindboxError.UnknownServer::class.java) + assertNull(result) + } + + // endregion + + // region toJson output is valid JSON + + @Test + fun `toJson output is parseable by Gson as JsonObject`() { + listOf( + MindboxError.Validation(200, "Ok", emptyList()), + MindboxError.Protocol(400, "Error", null, null, null), + MindboxError.InternalServer(500, "Err", null, null, null), + MindboxError.UnknownServer(), + MindboxError.Unknown(), + ).forEach { error -> + val json = error.toJson() + val parsed = gson.fromJson(json, com.google.gson.JsonObject::class.java) + assertNotNull("toJson() should produce valid JSON for ${error::class.simpleName}", parsed) + assertTrue( + "${error::class.simpleName} JSON should have 'type' key", + parsed.has("type"), + ) + assertTrue( + "${error::class.simpleName} JSON should have 'data' key", + parsed.has("data"), + ) + } + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/CustomerFieldsAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/CustomerFieldsAdapterTest.kt new file mode 100644 index 00000000..756fdf7c --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/CustomerFieldsAdapterTest.kt @@ -0,0 +1,152 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.CustomFields +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CustomerFieldsAdapterTest { + + // CustomFields has @JsonAdapter(CustomerFieldsAdapter::class) so plain Gson() picks it up. + private val gson = Gson() + + // region read + + @Test + fun `read - string field deserialized correctly`() { + val fields = gson.fromJson("""{"name":"John"}""", CustomFields::class.java) + assertNotNull(fields) + assertEquals("John", fields.fields?.get("name")) + } + + @Test + fun `read - numeric field deserialized as Double (GSON default for numbers)`() { + val fields = gson.fromJson("""{"age":30}""", CustomFields::class.java) + assertNotNull(fields) + // GSON deserializes JSON numbers into Map as Double + assertEquals(30.0, fields.fields?.get("age")) + } + + @Test + fun `read - boolean field deserialized correctly`() { + val fields = gson.fromJson("""{"active":true}""", CustomFields::class.java) + assertNotNull(fields) + assertEquals(true, fields.fields?.get("active")) + } + + @Test + fun `read - null field value deserialized as null`() { + val fields = gson.fromJson("""{"optional":null}""", CustomFields::class.java) + assertNotNull(fields) + assertNull(fields.fields?.get("optional")) + } + + @Test + fun `read - multiple fields deserialized correctly`() { + val fields = gson.fromJson( + """{"name":"Alice","score":99.5,"active":false}""", + CustomFields::class.java + ) + assertNotNull(fields) + assertEquals("Alice", fields.fields?.get("name")) + assertEquals(99.5, fields.fields?.get("score")) + assertEquals(false, fields.fields?.get("active")) + } + + @Test + fun `read - empty object produces empty map`() { + val fields = gson.fromJson("""{}""", CustomFields::class.java) + assertNotNull(fields) + assertTrue(fields.fields?.isEmpty() ?: false) + } + + @Test + fun `read - JSON null returns null CustomFields`() { + val fields = gson.fromJson("null", CustomFields::class.java) + assertNull(fields) + } + + // endregion + + // region write + + @Test + fun `write - string field serialized as JSON string`() { + val fields = CustomFields("city" to "Moscow") + val json = gson.toJson(fields) + assertTrue(json.contains(""""city":"Moscow"""")) + } + + @Test + fun `write - null CustomFields serialized as JSON null`() { + val json = gson.toJson(null as CustomFields?) + assertEquals("null", json) + } + + @Test + fun `write - CustomFields with null fields map serialized as JSON null`() { + val fields = CustomFields(fields = null) + val json = gson.toJson(fields) + assertEquals("null", json) + } + + @Test + fun `write - multiple fields serialized correctly`() { + val fields = CustomFields("a" to "1", "b" to 2) + val json = gson.toJson(fields) + assertTrue(json.contains(""""a":"1"""")) + assertTrue(json.contains(""""b":2""")) + } + + @Test + fun `write - null value in fields is dropped by Gson`() { + // CustomerFieldsAdapter.write() calls gson.toJson(value.fields). + // GSON's nullValue() with serializeNulls=false silently drops name+null pairs + // even inside maps, so null field values are absent from the output. + val fields = CustomFields(mapOf("key" to null as Any?)) + val json = gson.toJson(fields) + assertEquals("{}", json) + } + + // endregion + + // region convertTo + + @Test + fun `convertTo - maps fields to typed data class`() { + data class Profile(val name: String?, val age: Double?) + + val fields = CustomFields("name" to "Bob", "age" to 25.0) + val profile = fields.convertTo(Profile::class.java) + + assertNotNull(profile) + assertEquals("Bob", profile!!.name) + assertEquals(25.0, profile.age) + } + + @Test + fun `convertTo - returns null for null fields map`() { + val fields = CustomFields(fields = null) + val result = fields.convertTo(Map::class.java) + assertNull(result) + } + + // endregion + + // region round-trip + + @Test + fun `round-trip - string fields preserved`() { + val original = CustomFields("x" to "hello", "y" to "world") + val json = gson.toJson(original) + val restored = gson.fromJson(json, CustomFields::class.java) + assertNotNull(restored) + assertEquals("hello", restored.fields?.get("x")) + assertEquals("world", restored.fields?.get("y")) + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateOnlyAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateOnlyAdapterTest.kt new file mode 100644 index 00000000..cacbfd93 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateOnlyAdapterTest.kt @@ -0,0 +1,144 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.DateOnly +import com.google.gson.GsonBuilder +import com.google.gson.annotations.SerializedName +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Locale + +class DateOnlyAdapterTest { + + private val gson = GsonBuilder().registerTypeAdapter(DateOnly::class.java, DateOnlyAdapter()).create() + + private data class Holder( + @SerializedName("date") + val date: DateOnly? + ) + + // region read + + @Test + fun `read - parses yyyy-MM-dd format`() { + val holder = gson.fromJson("""{"date":"2023-06-15"}""", Holder::class.java) + assertNotNull(holder.date) + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val expected = formatter.parse("2023-06-15")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - null JSON value returns null`() { + val holder = gson.fromJson("""{"date":null}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - missing field returns null`() { + val holder = gson.fromJson("""{}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - invalid date string returns null without throwing`() { + val holder = gson.fromJson("""{"date":"not-a-date"}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - empty string returns null without throwing`() { + val holder = gson.fromJson("""{"date":""}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - parses start of year`() { + val holder = gson.fromJson("""{"date":"2023-01-01"}""", Holder::class.java) + assertNotNull(holder.date) + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + assertEquals(formatter.parse("2023-01-01")!!.time, holder.date!!.time) + } + + @Test + fun `read - parses end of year`() { + val holder = gson.fromJson("""{"date":"2023-12-31"}""", Holder::class.java) + assertNotNull(holder.date) + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + assertEquals(formatter.parse("2023-12-31")!!.time, holder.date!!.time) + } + + // endregion + + // region write + + @Test + fun `write - null DateOnly field omitted by default Gson serialization`() { + // GSON skips null fields in POJOs by default (serializeNulls not set). + val json = gson.toJson(Holder(null)) + assertEquals("{}", json) + } + + @Test + fun `write - null DateOnly writes JSON null when adapter called directly`() { + val sw = java.io.StringWriter() + val writer = com.google.gson.stream.JsonWriter(sw) + DateOnlyAdapter().write(writer, null) + writer.flush() + assertEquals("null", sw.toString()) + } + + @Test + fun `write - DateOnly serialized as yyyy-MM-dd string`() { + // Pin a known date to avoid locale/TZ ambiguity: use local midnight to avoid rollover. + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val date = formatter.parse("2023-06-15")!! + + val json = gson.toJson(Holder(DateOnly(date.time))) + + assertTrue( + "Serialized DateOnly should match yyyy-MM-dd pattern", json.contains(""""2023-06-15"""") + ) + } + + @Test + fun `write - two DateOnly with same timestamp produce same output`() { + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val time = formatter.parse("2023-06-15")!!.time + assertEquals( + gson.toJson(Holder(DateOnly(time))), gson.toJson(Holder(DateOnly(time))) + ) + } + + // endregion + + // region round-trip + + @Test + fun `round trip - date string preserved after read then write`() { + val originalJson = """{"date":"2023-06-15"}""" + val holder = gson.fromJson(originalJson, Holder::class.java) + assertNotNull(holder.date) + + val serialized = gson.toJson(holder) + assertTrue( + "Round-tripped JSON should still contain the date string", serialized.contains(""""2023-06-15"""") + ) + } + + @Test + fun `read - preserves date independent of UTC offset`() { + // Documents that DateOnly uses local formatter without timezone handling. + // This test pins the expected value using the same local formatter the adapter uses. + val localFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val knownDate = "2023-06-15" + val holder = gson.fromJson("""{"date":"$knownDate"}""", Holder::class.java) + assertNotNull(holder.date) + assertEquals(knownDate, localFormatter.format(holder.date!!)) + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateTimeAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateTimeAdapterTest.kt new file mode 100644 index 00000000..6446cc13 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateTimeAdapterTest.kt @@ -0,0 +1,161 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.DateTime +import com.google.gson.GsonBuilder +import com.google.gson.annotations.SerializedName +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +class DateTimeAdapterTest { + + private val gson = GsonBuilder() + .registerTypeAdapter(DateTime::class.java, DateTimeAdapter()) + .create() + + private data class Holder( + @SerializedName("date") + val date: DateTime? + ) + + // region read + + @Test + fun `read - ISO8601 with positive timezone offset`() { + val holder = gson.fromJson("""{"date":"2023-06-15T10:30:00.000+03:00"}""", Holder::class.java) + assertNotNull(holder.date) + val expected = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + .parse("2023-06-15T10:30:00.000+03:00")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - ISO8601 UTC Z suffix`() { + val holder = gson.fromJson("""{"date":"2023-01-01T00:00:00.000Z"}""", Holder::class.java) + assertNotNull(holder.date) + val expected = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + .parse("2023-01-01T00:00:00.000+00:00")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - ISO8601 without milliseconds`() { + val holder = gson.fromJson("""{"date":"2023-06-15T10:30:00+00:00"}""", Holder::class.java) + assertNotNull(holder.date) + val expected = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US) + .parse("2023-06-15T10:30:00+00:00")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - ISO8601 negative timezone offset`() { + val holder = gson.fromJson("""{"date":"2023-06-15T10:30:00.000-05:00"}""", Holder::class.java) + assertNotNull(holder.date) + val expected = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + .parse("2023-06-15T10:30:00.000-05:00")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - null JSON value returns null DateTime`() { + val holder = gson.fromJson("""{"date":null}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - missing field returns null DateTime`() { + val holder = gson.fromJson("""{}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - invalid date string returns null without throwing`() { + // LoggingExceptionHandler swallows the parse failure + val holder = gson.fromJson("""{"date":"not-a-date"}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - empty string returns null without throwing`() { + val holder = gson.fromJson("""{"date":""}""", Holder::class.java) + assertNull(holder.date) + } + + // endregion + + // region write + + @Test + fun `write - null DateTime field omitted by default Gson serialization`() { + // GSON skips null fields in POJOs by default (serializeNulls not set), + // so the adapter's nullValue() path is not reached via reflection. + val json = gson.toJson(Holder(null)) + assertEquals("{}", json) + } + + @Test + fun `write - null DateTime writes JSON null when adapter called directly`() { + val sw = java.io.StringWriter() + val writer = com.google.gson.stream.JsonWriter(sw) + DateTimeAdapter().write(writer, null) + writer.flush() + assertEquals("null", sw.toString()) + } + + @Test + fun `write - DateTime serialized as non-null string`() { + val json = gson.toJson(Holder(DateTime(0L))) + assertTrue("JSON should contain string date", json.contains(""""date":"""")) + } + + @Test + fun `write - uses dd_MM_yyyy_HH_mm_ss_FFF pattern`() { + // Documents the exact write format so migration doesn't accidentally change it. + // NOTE: FFF in SimpleDateFormat is "Day of week in month", NOT milliseconds. + // This is a known quirk of the current implementation. + val knownTime = 1_700_000_000_000L + val dateTime = DateTime(knownTime) + val formatter = SimpleDateFormat("dd.MM.yyyy HH:mm:ss.FFF", Locale.getDefault()) + val expectedDateString = formatter.format(dateTime) + + val json = gson.toJson(Holder(dateTime)) + assertTrue( + "Serialized date should match 'dd.MM.yyyy HH:mm:ss.FFF' pattern", + json.contains(""""$expectedDateString"""") + ) + } + + @Test + fun `write - two DateTimes with same timestamp produce same output`() { + val time = 1_686_825_000_000L + val json1 = gson.toJson(Holder(DateTime(time))) + val json2 = gson.toJson(Holder(DateTime(time))) + assertEquals(json1, json2) + } + + // endregion + + // region timestamp preservation + + @Test + fun `read - preserves epoch milliseconds from ISO8601 input`() { + // The server sends ISO8601; we must preserve the exact timestamp. + // This is the key regression test for the ISO8601Utils → alternative migration. + val utcSdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).also { + it.timeZone = TimeZone.getTimeZone("UTC") + } + val expectedMillis = utcSdf.parse("2023-06-15T07:30:00.000+00:00")!!.time + + val holder = gson.fromJson("""{"date":"2023-06-15T07:30:00.000+00:00"}""", Holder::class.java) + + assertNotNull(holder.date) + assertEquals(expectedMillis, holder.date!!.time) + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/IdsAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/IdsAdapterTest.kt new file mode 100644 index 00000000..2a80ddad --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/IdsAdapterTest.kt @@ -0,0 +1,132 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.Ids +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [IdsAdapter]. + * + * Key behavior under test: GSON by default parses integer JSON values into Double + * (e.g. 12345 → "12345.0"). The adapter explicitly uses [com.google.gson.stream.JsonReader.nextString] + * on every value so that numeric IDs are read as raw strings without the ".0" suffix. + */ +class IdsAdapterTest { + + // Ids has @JsonAdapter(IdsAdapter::class) so plain Gson() picks it up automatically. + private val gson = Gson() + + // region read + + @Test + fun `read - string ID preserved as-is`() { + val ids = gson.fromJson("""{"mindboxId":"abc123"}""", Ids::class.java) + assertNotNull(ids) + assertEquals("abc123", ids.ids["mindboxId"]) + } + + @Test + fun `read - integer ID read as string without dot-zero suffix`() { + // Critical regression test: without the adapter workaround, GSON would + // deserialise 12345 as the Double 12345.0, producing "12345.0" in the map. + val ids = gson.fromJson("""{"mindboxId":12345}""", Ids::class.java) + assertNotNull(ids) + assertEquals("12345", ids.ids["mindboxId"]) + } + + @Test + fun `read - large integer ID preserved without scientific notation`() { + val ids = gson.fromJson("""{"externalId":9999999999}""", Ids::class.java) + assertNotNull(ids) + assertEquals("9999999999", ids.ids["externalId"]) + } + + @Test + fun `read - multiple IDs of mixed types`() { + val ids = gson.fromJson( + """{"mindboxId":42,"email":"user@example.com","loyaltyId":7}""", + Ids::class.java + ) + assertNotNull(ids) + assertEquals("42", ids.ids["mindboxId"]) + assertEquals("user@example.com", ids.ids["email"]) + assertEquals("7", ids.ids["loyaltyId"]) + } + + @Test + fun `read - empty object produces empty map`() { + val ids = gson.fromJson("""{}""", Ids::class.java) + assertNotNull(ids) + assertTrue(ids.ids.isEmpty()) + } + + @Test + fun `read - JSON null returns null Ids`() { + val ids = gson.fromJson("null", Ids::class.java) + assertNull(ids) + } + + // endregion + + // region write + + @Test + fun `write - string ID serialized as JSON string`() { + val ids = Ids("mindboxId" to "abc123") + val json = gson.toJson(ids) + assertTrue(json.contains(""""mindboxId":"abc123"""")) + } + + @Test + fun `write - null Ids serialized as JSON null`() { + val json = gson.toJson(null as Ids?) + assertEquals("null", json) + } + + @Test + fun `write - multiple IDs serialized correctly`() { + val ids = Ids("mindboxId" to "42", "email" to "user@example.com") + val json = gson.toJson(ids) + assertTrue(json.contains(""""mindboxId":"42"""")) + assertTrue(json.contains(""""email":"user@example.com"""")) + } + + @Test + fun `write - null value in map is dropped by Gson`() { + // GSON's nullValue() with serializeNulls=false silently drops name+null map pairs. + // An Ids entry with a null value is absent from the serialized output. + val ids = Ids(mapOf("mindboxId" to null)) + val json = gson.toJson(ids) + assertEquals("{}", json) + } + + // endregion + + // region round-trip + + @Test + fun `round-trip - integer ID survives serialize then deserialize`() { + // Ids stores everything as String, so we start with a string "42". + val original = Ids("mindboxId" to "42") + val json = gson.toJson(original) + val restored = gson.fromJson(json, Ids::class.java) + assertNotNull(restored) + assertEquals("42", restored.ids["mindboxId"]) + } + + @Test + fun `round-trip - multiple string IDs preserved`() { + // null values are excluded: IdsAdapter.read() hangs on null map values (see bug comment above). + val original = Ids("a" to "1", "b" to "hello") + val json = gson.toJson(original) + val restored = gson.fromJson(json, Ids::class.java) + assertNotNull(restored) + assertEquals(original.ids, restored.ids) + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/ProductListResponseAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/ProductListResponseAdapterTest.kt new file mode 100644 index 00000000..1cb22d04 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/ProductListResponseAdapterTest.kt @@ -0,0 +1,146 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.response.CatalogProductListResponse +import cloud.mindbox.mobile_sdk.models.operation.response.ProductListItemResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [ProductListResponseAdapter]. + * + * The adapter dispatches on the JSON token type: + * - BEGIN_ARRAY → List + * - BEGIN_OBJECT → CatalogProductListResponse + * - NULL → null + */ +class ProductListResponseAdapterTest { + + private val adapter = ProductListResponseAdapter() + + private fun read(json: String): Any? { + val reader = com.google.gson.stream.JsonReader(java.io.StringReader(json)) + return adapter.read(reader) + } + + private fun write(value: Any?): String { + val sw = java.io.StringWriter() + val writer = com.google.gson.stream.JsonWriter(sw) + adapter.write(writer, value) + writer.flush() + return sw.toString() + } + + // region read – array input + + @Test + fun `read - JSON array deserializes to list of ProductListItemResponse`() { + val result = read("""[{"count":2.0,"pricePerItem":99.9},{"count":1.0}]""") + assertNotNull(result) + assertTrue(result is List<*>) + @Suppress("UNCHECKED_CAST") + val list = result as List + assertEquals(2, list.size) + assertEquals(2.0, list[0].count) + assertEquals(99.9, list[0].pricePerItem) + } + + @Test + fun `read - empty JSON array deserializes to empty list`() { + val result = read("[]") + assertNotNull(result) + assertTrue(result is List<*>) + assertTrue((result as List<*>).isEmpty()) + } + + // endregion + + // region read – object input + + @Test + fun `read - JSON object deserializes to CatalogProductListResponse`() { + val result = read("""{"processingStatus":"Success","items":[]}""") + assertNotNull(result) + assertTrue(result is CatalogProductListResponse) + val catalog = result as CatalogProductListResponse + assertNotNull(catalog.items) + assertTrue(catalog.items!!.isEmpty()) + } + + @Test + fun `read - empty JSON object deserializes to CatalogProductListResponse`() { + val result = read("""{}""") + assertNotNull(result) + assertTrue(result is CatalogProductListResponse) + } + + // endregion + + // region read – null input + + @Test + fun `read - JSON null returns null`() { + val result = read("null") + assertNull(result) + } + + // endregion + + // region write + + @Test + fun `write - list serialized as JSON array`() { + val list = listOf(ProductListItemResponse(count = 3.0)) + val json = write(list) + assertTrue(json.startsWith("[")) + assertTrue(json.endsWith("]")) + assertTrue(json.contains(""""count":3.0""")) + } + + @Test + fun `write - CatalogProductListResponse serialized as JSON object`() { + val catalog = CatalogProductListResponse(items = emptyList()) + val json = write(catalog) + assertTrue(json.startsWith("{")) + assertTrue(json.endsWith("}")) + } + + @Test + fun `write - null serialized as JSON null`() { + val json = write(null) + assertEquals("null", json) + } + + // endregion + + // region round-trip + + @Test + fun `round-trip - list of items preserved`() { + val original = listOf( + ProductListItemResponse(count = 1.0, price = 50.0), + ProductListItemResponse(count = 2.0, price = 100.0), + ) + val json = write(original) + val restored = read(json) + + assertTrue(restored is List<*>) + @Suppress("UNCHECKED_CAST") + val list = restored as List + assertEquals(2, list.size) + assertEquals(1.0, list[0].count) + assertEquals(2.0, list[1].count) + } + + @Test + fun `round-trip - CatalogProductListResponse preserved`() { + val original = CatalogProductListResponse(items = emptyList()) + val json = write(original) + val restored = read(json) + assertTrue(restored is CatalogProductListResponse) + } + + // endregion +}