diff --git a/OneSignalSDK/onesignal/core/api/core.api b/OneSignalSDK/onesignal/core/api/core.api index ccdf99ba5..3b8325644 100644 --- a/OneSignalSDK/onesignal/core/api/core.api +++ b/OneSignalSDK/onesignal/core/api/core.api @@ -13,6 +13,26 @@ public final class com/onesignal/ContinueResult { public final fun isSuccess ()Z } +public final class com/onesignal/ErrorCode : java/lang/Enum { + public static final field BACKEND_ERROR Lcom/onesignal/ErrorCode; + public static final field INVALID_ARGUMENT Lcom/onesignal/ErrorCode; + public static final field NOT_INITIALIZED Lcom/onesignal/ErrorCode; + public static final field STORAGE_LOCKED Lcom/onesignal/ErrorCode; + public static final field UNKNOWN Lcom/onesignal/ErrorCode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getSource ()Lcom/onesignal/ErrorSource; + public static fun valueOf (Ljava/lang/String;)Lcom/onesignal/ErrorCode; + public static fun values ()[Lcom/onesignal/ErrorCode; +} + +public final class com/onesignal/ErrorSource : java/lang/Enum { + public static final field BACKEND Lcom/onesignal/ErrorSource; + public static final field CLIENT Lcom/onesignal/ErrorSource; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/onesignal/ErrorSource; + public static fun values ()[Lcom/onesignal/ErrorSource; +} + public abstract interface class com/onesignal/IOneSignal { public abstract fun addUserJwtInvalidatedListener (Lcom/onesignal/IUserJwtInvalidatedListener;)V public abstract fun getConsentGiven ()Z @@ -64,6 +84,23 @@ public abstract interface class com/onesignal/IUserJwtInvalidatedListener { public abstract fun onUserJwtInvalidated (Lcom/onesignal/UserJwtInvalidatedEvent;)V } +public final class com/onesignal/InitData : com/onesignal/OneSignalResultData { + public fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/LoginData : com/onesignal/OneSignalResultData { + public final fun getExternalId ()Ljava/lang/String; + public final fun getOnesignalId ()Ljava/lang/String; + public fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/LogoutData : com/onesignal/OneSignalResultData { + public fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + public final class com/onesignal/OneSignal { public static final field INSTANCE Lcom/onesignal/OneSignal; public static final fun addUserJwtInvalidatedListener (Lcom/onesignal/IUserJwtInvalidatedListener;)V @@ -109,12 +146,51 @@ public final class com/onesignal/OneSignal { public static final fun updateUserJwtSuspend (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class com/onesignal/OneSignalError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getError ()Ljava/util/List; + public final fun getFirst ()Lcom/onesignal/OneSignalError$Detail; + public final fun toList ()Ljava/util/List; + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/OneSignalError$Detail { + public final fun getBackendCode ()Ljava/lang/Integer; + public final fun getCode ()Lcom/onesignal/ErrorCode; + public final fun getMessage ()Ljava/lang/String; + public final fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/OneSignalException : java/lang/Exception { + public final fun getError ()Lcom/onesignal/OneSignalError; +} + +public final class com/onesignal/OneSignalResult { + public final fun getData ()Lcom/onesignal/OneSignalResultData; + public final fun getError ()Lcom/onesignal/OneSignalError; + public final fun getOrNull ()Lcom/onesignal/OneSignalResultData; + public final fun getOrThrow ()Lcom/onesignal/OneSignalResultData; + public final fun isSuccess ()Z + public final fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/onesignal/OneSignalResultData { + public abstract fun toMap ()Ljava/util/Map; +} + public final class com/onesignal/SyncJobService : android/app/job/JobService { public fun ()V public fun onStartJob (Landroid/app/job/JobParameters;)Z public fun onStopJob (Landroid/app/job/JobParameters;)Z } +public final class com/onesignal/UpdateUserJwtData : com/onesignal/OneSignalResultData { + public fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + public final class com/onesignal/UserJwtInvalidatedEvent { public fun (Ljava/lang/String;)V public final fun getExternalId ()Ljava/lang/String; diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt new file mode 100644 index 000000000..58349fa1a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt @@ -0,0 +1,160 @@ +package com.onesignal + +/** Whether the SDK produced a failure locally or OneSignal's backend returned it. */ +enum class ErrorSource { + CLIENT, + BACKEND, +} + +/** + * The catalog of failure codes shared by every OneSignal SDK. + * + * An enum rather than a sealed hierarchy so that Java callers get a native `switch` and the + * wrapper bridges get a trivial name-to-string marshal. The backend half of the catalog is + * deliberately *not* modelled here — see [BACKEND_ERROR]. + */ +enum class ErrorCode(val source: ErrorSource) { + /** [IOneSignal.initWithContextSuspend] has not been called. */ + NOT_INITIALIZED(ErrorSource.CLIENT), + + /** + * Device storage was locked, so the SDK could not read or write its own preferences. + * Transient: the same call generally succeeds once the device is unlocked. + */ + STORAGE_LOCKED(ErrorSource.CLIENT), + + /** A caller-supplied argument failed validation before any request was made. */ + INVALID_ARGUMENT(ErrorSource.CLIENT), + + /** OneSignal rejected the request. The catalog code is on [OneSignalError.Detail.backendCode]. */ + BACKEND_ERROR(ErrorSource.BACKEND), + + /** No more specific code applies. Callers should surface [OneSignalError.Detail.message]. */ + UNKNOWN(ErrorSource.CLIENT), +} + +/** + * Describes why a OneSignal call failed. + * + * One request can fail for several reasons at once, so [error] is a list of [Detail]. Everything + * the SDK raises locally has exactly one reason, which [first] reads without the indexing + * ceremony. + * + * On the wire this is the list itself, sitting under the envelope's `error` key: + * + * ```json + * { "success": false, "data": null, + * "error": [ { "code": "STORAGE_LOCKED", "source": "CLIENT", "backendCode": null, "message": "..." } ] } + * ``` + */ +class OneSignalError internal constructor( + error: List, + /** + * The throwable behind the failure, when there was one. + * + * Deliberately absent from [toList]: a stack trace cannot cross the wrapper bridges, and the + * wire schema has to stay identical across every SDK. This exists so that native Kotlin and + * Java callers do not lose the stack when the suspend APIs report a failure instead of + * throwing it. + */ + val cause: Throwable? = null, +) { + /** + * Why the call failed. Never empty. + * + * Copied rather than aliased so that a caller holding the original list cannot empty it + * afterwards and leave [first] throwing. + */ + val error: List = error.toList() + + init { + // [first] is documented as always safe to read, and the wire projection of an empty error + // would claim failure while explaining nothing. Both factories guard this; the check is + // here so a future caller of the constructor cannot quietly break the invariant. + require(this.error.isNotEmpty()) { "OneSignalError requires at least one Detail." } + } + + /** + * A single reason a call failed. + * + * Nested rather than top-level so the name cannot collide with `kotlin.Error`, which is + * auto-imported everywhere, or shadow `java.lang.Error` in a Java file that imports it. + */ + class Detail internal constructor( + /** A stable code, safe to branch on. Never localized. */ + val code: ErrorCode, + /** + * The backend's catalog code, present only when [code] is [ErrorCode.BACKEND_ERROR]. + * + * Left as a raw number on purpose: the backend adds codes on its own schedule, and an SDK + * release must not be the thing that unblocks recognizing one. + */ + val backendCode: Int? = null, + /** A human-readable description intended for logs and diagnostics, not for end users. */ + val message: String? = null, + ) { + /** Projects this reason onto the cross-SDK wire shape consumed by the wrapper bridges. */ + fun toMap(): Map = + mapOf( + KEY_CODE to code.name, + KEY_SOURCE to code.source.name, + KEY_BACKEND_CODE to backendCode, + KEY_MESSAGE to message, + ) + + override fun toString(): String = "Detail(code=$code, backendCode=$backendCode, message=$message)" + + internal companion object { + // Private because `const val` in an internal companion still compiles to a public + // static field, which would leak the wire keys into the customer-facing API surface. + private const val KEY_CODE = "code" + private const val KEY_SOURCE = "source" + private const val KEY_BACKEND_CODE = "backendCode" + private const val KEY_MESSAGE = "message" + + /** + * Rebuilds a reason from its wire shape. + * + * An unrecognized code degrades to [ErrorCode.UNKNOWN] rather than throwing, so a + * wrapper built against an older SDK survives a newer producer emitting a code it has + * never heard of. The original text is preserved on [message] either way. + */ + fun fromMap(map: Map): Detail = + Detail( + code = codeOf(map[KEY_CODE] as? String), + backendCode = (map[KEY_BACKEND_CODE] as? Number)?.toInt(), + message = map[KEY_MESSAGE] as? String, + ) + + private fun codeOf(name: String?): ErrorCode = ErrorCode.entries.firstOrNull { it.name == name } ?: ErrorCode.UNKNOWN + } + } + + /** The first reason, which is the only one for every failure the SDK raises locally. */ + val first: Detail + get() = error.first() + + /** Projects this error onto the cross-SDK wire shape consumed by the wrapper bridges. */ + fun toList(): List> = error.map { it.toMap() } + + override fun toString(): String = "OneSignalError(error=$error)" + + internal companion object { + /** Builds a single-reason error, which is the shape of everything the SDK raises locally. */ + fun of( + code: ErrorCode, + message: String? = null, + backendCode: Int? = null, + cause: Throwable? = null, + ): OneSignalError = OneSignalError(listOf(Detail(code, backendCode, message)), cause) + + /** + * Rebuilds an error from its wire shape. A payload carrying no recognizable reason still + * yields a usable error rather than an empty list, so [first] is always safe. + */ + fun fromList(reasons: List>): OneSignalError = + OneSignalError( + reasons.map { Detail.fromMap(it) }.takeIf { it.isNotEmpty() } ?: listOf(Detail(ErrorCode.UNKNOWN)), + ) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt new file mode 100644 index 000000000..deed236e5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt @@ -0,0 +1,113 @@ +package com.onesignal + +/** + * The outcome of an asynchronous OneSignal call: either a [data] payload or an [error], never both + * and never neither. + * + * Every SDK returns the same envelope, so a wrapper can handle results uniformly regardless of the + * platform underneath it: + * + * ```json + * { "success": true, "data": { }, "error": null } + * { "success": false, "data": null, "error": [ { "code": "STORAGE_LOCKED", ... } ] } + * ``` + * + * The presence of [error] is what defines the outcome; [isSuccess] and the wire-level `success` + * flag are both derived from it, so the two can never disagree. + * + * From Kotlin: + * ```kotlin + * val result = OneSignal.login("user-123") + * if (result.isSuccess) println(result.data?.onesignalId) else println(result.error?.first?.code) + * ``` + * + * From Java the generated accessors read naturally: + * ```java + * if (result.isSuccess()) { result.getData(); } else { result.getError(); } + * ``` + */ +class OneSignalResult internal constructor( + /** The payload on success, `null` on failure. */ + val data: T?, + /** The failure detail on failure, `null` on success. */ + val error: OneSignalError?, +) { + /** `true` when the call completed successfully. Equivalent to `error == null`. */ + val isSuccess: Boolean + get() = error == null + + /** Kotlin-idiomatic alias for [data]. */ + fun getOrNull(): T? = data + + /** + * Returns the payload, or throws [OneSignalException] when the call failed. Use this only where + * a failure genuinely cannot be handled locally. + */ + fun getOrThrow(): T = data ?: throw OneSignalException(error ?: unexpectedMissingError()) + + /** Projects the envelope onto the cross-SDK wire shape consumed by the wrapper bridges. */ + fun toMap(): Map = + mapOf( + KEY_SUCCESS to isSuccess, + KEY_DATA to data?.toMap(), + KEY_ERROR to error?.toList(), + ) + + override fun toString(): String = if (isSuccess) "OneSignalResult(success, data=$data)" else "OneSignalResult(failure, error=$error)" + + private fun unexpectedMissingError() = OneSignalError.of(ErrorCode.UNKNOWN, "Result carried neither data nor error.") + + internal companion object { + // Private because `const val` in an internal companion still compiles to a public static + // field, which would leak the wire keys into the customer-facing API surface. + private const val KEY_SUCCESS = "success" + private const val KEY_DATA = "data" + private const val KEY_ERROR = "error" + + fun success(data: T): OneSignalResult = OneSignalResult(data, null) + + fun failure(error: OneSignalError): OneSignalResult = OneSignalResult(null, error) + + fun failure( + code: ErrorCode, + message: String? = null, + backendCode: Int? = null, + cause: Throwable? = null, + ): OneSignalResult = failure(OneSignalError.of(code, message, backendCode, cause)) + + /** + * Rebuilds an envelope from its wire shape, delegating payload parsing to [dataParser]. + * + * The incoming `success` flag is deliberately ignored: [error] is the single source of + * truth, which keeps a malformed producer from yielding a result that claims success while + * carrying an error. Unrecognized keys are ignored so a newer producer can add fields + * without breaking an older consumer. + */ + @Suppress("UNCHECKED_CAST") + fun fromMap( + map: Map, + dataParser: (Map) -> T, + ): OneSignalResult { + val reasons = map[KEY_ERROR] as? List> + if (reasons != null) { + return failure(OneSignalError.fromList(reasons)) + } + + val dataMap = map[KEY_DATA] as? Map ?: emptyMap() + return success(dataParser(dataMap)) + } + } +} + +/** Thrown by [OneSignalResult.getOrThrow] when the underlying call failed. */ +class OneSignalException internal constructor( + /** The failure detail that caused this exception. */ + val error: OneSignalError, +) : Exception(describe(error), error.cause) + +// A Detail carries no message when the code says everything, so appending a bare "null" to the +// exception text would only add noise to the stack trace. +private fun describe(error: OneSignalError): String = + error.error.joinToString("; ") { detail -> + if (detail.message == null) detail.code.name else "${detail.code}: ${detail.message}" + } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt new file mode 100644 index 000000000..73a9b5032 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt @@ -0,0 +1,92 @@ +package com.onesignal + +/** + * Implemented by every payload that can travel inside a [OneSignalResult]. + * + * The contract exists so the envelope can be projected onto the wire shape without knowing which + * payload it is carrying. It is not intended to be implemented outside the SDK. + */ +interface OneSignalResultData { + /** Projects this payload onto the cross-SDK wire shape consumed by the wrapper bridges. */ + fun toMap(): Map +} + +/* + * The payloads themselves. + * + * Several of these start with no fields. That is intentional, and it is why none of them is a + * `data class` or an `object`: a regular class with an internal constructor can gain nullable or + * defaulted fields later without changing any signature, without a singleton blocking per-call + * state, and without silently altering generated equals/hashCode/toString/copy behavior that a + * customer may have come to depend on. + * + * Field names here are also the wire keys, so they are additive-only: never renamed, never removed. + */ + +/** The payload returned by a successful login. */ +class LoginData internal constructor( + /** The OneSignal ID the external ID is now associated with. */ + val onesignalId: String, + /** The external ID that was logged in. */ + val externalId: String, +) : OneSignalResultData { + override fun toMap(): Map = + mapOf( + KEY_ONESIGNAL_ID to onesignalId, + KEY_EXTERNAL_ID to externalId, + ) + + override fun toString(): String = "LoginData(onesignalId=$onesignalId, externalId=$externalId)" + + internal companion object { + // Private because `const val` in an internal companion still compiles to a public static + // field, which would leak the wire keys into the customer-facing API surface. + private const val KEY_ONESIGNAL_ID = "onesignalId" + private const val KEY_EXTERNAL_ID = "externalId" + + fun fromMap(map: Map): LoginData = + LoginData( + onesignalId = map[KEY_ONESIGNAL_ID] as? String ?: "", + externalId = map[KEY_EXTERNAL_ID] as? String ?: "", + ) + } +} + +/** The payload returned by a successful logout. Carries no fields yet. */ +class LogoutData internal constructor() : OneSignalResultData { + override fun toMap(): Map = emptyMap() + + override fun toString(): String = "LogoutData()" + + internal companion object { + fun fromMap( + @Suppress("UNUSED_PARAMETER") map: Map, + ): LogoutData = LogoutData() + } +} + +/** The payload returned by a successful user JWT update. Carries no fields yet. */ +class UpdateUserJwtData internal constructor() : OneSignalResultData { + override fun toMap(): Map = emptyMap() + + override fun toString(): String = "UpdateUserJwtData()" + + internal companion object { + fun fromMap( + @Suppress("UNUSED_PARAMETER") map: Map, + ): UpdateUserJwtData = UpdateUserJwtData() + } +} + +/** The payload returned by a successful initialization. Carries no fields yet. */ +class InitData internal constructor() : OneSignalResultData { + override fun toMap(): Map = emptyMap() + + override fun toString(): String = "InitData()" + + internal companion object { + fun fromMap( + @Suppress("UNUSED_PARAMETER") map: Map, + ): InitData = InitData() + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt new file mode 100644 index 000000000..3725a7324 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt @@ -0,0 +1,247 @@ +package com.onesignal + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class OneSignalResultTests : FunSpec({ + + test("success carries data and no error") { + val result = OneSignalResult.success(LoginData("os-1", "ext-1")) + + result.isSuccess.shouldBeTrue() + result.error.shouldBeNull() + result.data.shouldNotBeNull() + result.data!!.onesignalId shouldBe "os-1" + result.getOrNull().shouldNotBeNull() + result.getOrThrow().externalId shouldBe "ext-1" + } + + test("failure carries error and no data") { + val result = OneSignalResult.failure(ErrorCode.INVALID_ARGUMENT, "no app ID") + + result.isSuccess.shouldBeFalse() + result.data.shouldBeNull() + result.getOrNull().shouldBeNull() + result.error.shouldNotBeNull() + result.error!!.first.code shouldBe ErrorCode.INVALID_ARGUMENT + result.error!!.first.message shouldBe "no app ID" + } + + test("a client code is distinguishable from a backend one without inspecting the message") { + val client = OneSignalResult.failure(ErrorCode.STORAGE_LOCKED, "device locked") + val backend = OneSignalResult.failure(ErrorCode.BACKEND_ERROR, "Invalid API Key", backendCode = 100) + + client.error!!.first.code.source shouldBe ErrorSource.CLIENT + client.error!!.first.backendCode.shouldBeNull() + + backend.error!!.first.code.source shouldBe ErrorSource.BACKEND + backend.error!!.first.backendCode shouldBe 100 + } + + test("an error can carry several reasons at once") { + val error = + OneSignalError( + listOf( + OneSignalError.Detail(ErrorCode.BACKEND_ERROR, 100, "Invalid API Key"), + OneSignalError.Detail(ErrorCode.BACKEND_ERROR, 144, "Invalid external ID"), + ), + ) + + error.error.size shouldBe 2 + error.first.backendCode shouldBe 100 + error.toList().map { it["backendCode"] } shouldBe listOf(100, 144) + } + + // first is documented as always safe to read, so the constructor has to refuse the one input + // that would make it throw. + test("an error cannot be built with no reasons") { + shouldThrow { OneSignalError(emptyList()) } + } + + test("a reason with no message keeps the exception text free of a bare null") { + val result = OneSignalResult.failure(ErrorCode.STORAGE_LOCKED) + + val thrown = + runCatching { result.getOrThrow() } + .exceptionOrNull() + .shouldBeInstanceOf() + + thrown.message shouldBe "STORAGE_LOCKED" + } + + test("getOrThrow surfaces the error and keeps the cause attached") { + val boom = IllegalStateException("boom") + val result = OneSignalResult.failure(ErrorCode.UNKNOWN, "offline", cause = boom) + + val thrown = + runCatching { result.getOrThrow() } + .exceptionOrNull() + .shouldBeInstanceOf() + + thrown.error.first.code shouldBe ErrorCode.UNKNOWN + thrown.message shouldBe "UNKNOWN: offline" + thrown.cause shouldBe boom + } + + test("the cause stays off the wire so every SDK serializes the same shape") { + val error = OneSignalError.of(ErrorCode.UNKNOWN, "boom", cause = IllegalStateException("boom")) + + error.cause.shouldNotBeNull() + error.toList().single().keys shouldBe setOf("code", "source", "backendCode", "message") + } + + test("success projects onto the wire envelope") { + val map = OneSignalResult.success(LoginData("os-1", "ext-1")).toMap() + + map shouldBe + mapOf( + "success" to true, + "data" to mapOf("onesignalId" to "os-1", "externalId" to "ext-1"), + "error" to null, + ) + } + + test("failure projects onto the wire envelope") { + val map = OneSignalResult.failure(ErrorCode.STORAGE_LOCKED, "device locked").toMap() + + map shouldBe + mapOf( + "success" to false, + "data" to null, + "error" to + listOf( + mapOf( + "code" to "STORAGE_LOCKED", + "source" to "CLIENT", + "backendCode" to null, + "message" to "device locked", + ), + ), + ) + } + + test("an empty payload still serializes as a present, empty data object") { + val map = OneSignalResult.success(InitData()).toMap() + + map["success"] shouldBe true + map["error"].shouldBeNull() + map.containsKey("data").shouldBeTrue() + map["data"] shouldBe emptyMap() + } + + test("success round-trips through the wire shape") { + val original = OneSignalResult.success(LoginData("os-1", "ext-1")) + + val restored = OneSignalResult.fromMap(original.toMap(), LoginData::fromMap) + + restored.isSuccess.shouldBeTrue() + restored.toMap() shouldBe original.toMap() + } + + test("failure round-trips through the wire shape") { + val original = OneSignalResult.failure(ErrorCode.BACKEND_ERROR, "already linked", backendCode = 409) + + val restored = OneSignalResult.fromMap(original.toMap(), LoginData::fromMap) + + restored.isSuccess.shouldBeFalse() + restored.toMap() shouldBe original.toMap() + } + + test("an empty payload round-trips through the wire shape") { + val original = OneSignalResult.success(InitData()) + + val restored = OneSignalResult.fromMap(original.toMap(), InitData::fromMap) + + restored.isSuccess.shouldBeTrue() + restored.toMap() shouldBe original.toMap() + } + + test("unknown envelope and payload fields are ignored rather than throwing") { + val fromNewerProducer = + mapOf( + "success" to true, + "data" to + mapOf( + "onesignalId" to "os-1", + "externalId" to "ext-1", + "subscriptionId" to "sub-9", + ), + "error" to null, + "traceId" to "abc-123", + ) + + val restored = OneSignalResult.fromMap(fromNewerProducer, LoginData::fromMap) + + restored.isSuccess.shouldBeTrue() + restored.data!!.onesignalId shouldBe "os-1" + restored.toMap() shouldBe + mapOf( + "success" to true, + "data" to mapOf("onesignalId" to "os-1", "externalId" to "ext-1"), + "error" to null, + ) + } + + // The enum is closed, so a wrapper running against a newer producer will eventually meet a + // code it cannot name. It has to degrade rather than throw out of valueOf. + test("an unrecognized code degrades to UNKNOWN with the message preserved") { + val fromNewerProducer = + mapOf( + "success" to false, + "data" to null, + "error" to listOf(mapOf("code" to "RATE_LIMITED", "message" to "slow down", "retryAfterSeconds" to 30)), + ) + + val restored = OneSignalResult.fromMap(fromNewerProducer, LoginData::fromMap) + + restored.isSuccess.shouldBeFalse() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + restored.error!!.first.message shouldBe "slow down" + } + + test("a malformed error missing its code degrades to unknown instead of throwing") { + val restored = + OneSignalResult.fromMap( + mapOf("success" to false, "data" to null, "error" to listOf(mapOf("message" to "something broke"))), + LoginData::fromMap, + ) + + restored.isSuccess.shouldBeFalse() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + restored.error!!.first.message shouldBe "something broke" + } + + // first is documented as always safe to read, so an error list that arrives empty still has + // to produce one reason rather than blowing up at the call site. + test("an empty reason list still yields a readable error") { + val restored = + OneSignalResult.fromMap( + mapOf("success" to false, "data" to null, "error" to emptyList>()), + LoginData::fromMap, + ) + + restored.isSuccess.shouldBeFalse() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + } + + test("error presence wins over a contradictory success flag") { + val contradictory = + mapOf( + "success" to true, + "data" to mapOf("onesignalId" to "os-1", "externalId" to "ext-1"), + "error" to listOf(mapOf("code" to "STORAGE_LOCKED", "message" to "device locked")), + ) + + val restored = OneSignalResult.fromMap(contradictory, LoginData::fromMap) + + restored.isSuccess.shouldBeFalse() + restored.data.shouldBeNull() + restored.error!!.first.code shouldBe ErrorCode.STORAGE_LOCKED + } +})