From 03c47ddfe6af6ee3d708b7dfa39ce009b5486c3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 17:05:20 +0000 Subject: [PATCH] Fix applyCode typed redeem errors for 0.3.1 Check HTTP status on every request, map redeem failures to TapAppLinkRedeemException, send X-TapAppLink-SDK-Version, and document UI states for each result. applyCode now delivers Result. Co-authored-by: Kenny Ye --- CHANGELOG.md | 6 + README.md | 68 +++++++- build.gradle.kts | 6 +- gradle.properties | 2 +- .../java/com/tapapplink/sdk/TapAppLink.kt | 91 +++++++---- .../java/com/tapapplink/sdk/TapAppLinkHttp.kt | 95 +++++++++++ .../sdk/TapAppLinkRedeemException.kt | 33 ++++ .../tapapplink/sdk/TapAppLinkApplyCodeTest.kt | 149 ++++++++++++++++++ .../sdk/TapAppLinkRedeemExceptionTest.kt | 128 +++++++++++++++ 9 files changed, 541 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/tapapplink/sdk/TapAppLinkHttp.kt create mode 100644 src/main/java/com/tapapplink/sdk/TapAppLinkRedeemException.kt create mode 100644 src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt create mode 100644 src/test/java/com/tapapplink/sdk/TapAppLinkRedeemExceptionTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e6aa416..9c8b057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.3.1 + +- **Fixed:** Android and React Native 0.3.0 could return an error from `applyCode()` as if it were a normal result, so an app could show a code as applied when it wasn't. Upgrade to 0.3.1, which raises a typed error for unknown, inactive and wrong-environment codes. iOS and Flutter 0.3.0 threw a generic error, and 0.3.1 makes it typed. +- Send `X-TapAppLink-SDK-Version` on every request so the server can return distinct 410/400 statuses while remaining compatible with legacy 404 bodies. +- `applyCode` callback is now `(Result) -> Unit` and failures are `TapAppLinkRedeemException` (`UnknownCode`, `InactiveCode`, `WrongEnvironment`, `Network`, `Other`). + ## 0.3.0 - Persist install id, tracked flag, attribution id and offer in SharedPreferences so cold launches do not re-post `/ingestInstall`. diff --git a/README.md b/README.md index f3e2578..78839f6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ dependencyResolutionManagement { Then in the app module: ```kotlin -implementation("com.github.tapapplink:tapapplink-android:0.3.0") +implementation("com.github.tapapplink:tapapplink-android:0.3.1") ``` `./gradlew` needs JDK 17 or 21. JDK 25 is not supported by this Android Gradle Plugin. @@ -44,9 +44,71 @@ TapAppLink.trackInstall(context) { result -> TapAppLink.trackInstall(context, installReferrer) { } TapAppLink.setAppUserId(Purchases.sharedInstance.appUserID) { } -TapAppLink.applyCode("SARAH10") { } ``` +### Apply a discount code + +Show success only on a real success result. Map each outcome to UI like this: + +```kotlin +TapAppLink.applyCode(code) { result -> + result.onSuccess { body -> + val offer = TapAppLink.getOffer() + val offerLine = offer?.let { "${it.creatorName}'s offer" } + if (body.optBoolean("alreadyAttributed")) { + // "You're all set" — hide the code; show offerLine if any. + showStatus(title = "You're all set", codeVisible = false, hint = offerLine) + } else { + // "Code applied" — show the code; show offerLine if any. + showStatus(title = "Code applied", codeVisible = true, code = code, hint = offerLine) + } + }.onFailure { error -> + when (error) { + is TapAppLinkRedeemException.UnknownCode -> { + showStatus( + title = "We don't recognise that code. Check it and try again.", + hint = "Codes aren't case sensitive.", + ) + } + is TapAppLinkRedeemException.InactiveCode -> { + showStatus( + title = "This code is no longer active.", + hint = "You can still subscribe at the regular price.", + ) + } + is TapAppLinkRedeemException.WrongEnvironment -> { + // Same customer copy as unknownCode. Never say "environment" to customers. + Log.w(TAG, TapAppLinkRedeemException.WrongEnvironment.DEVELOPER_WARNING) + showStatus( + title = "We don't recognise that code. Check it and try again.", + hint = "Codes aren't case sensitive.", + ) + } + is TapAppLinkRedeemException.Network -> { + showStatus( + title = "We couldn't check your code. Check your connection and try again.", + ) + } + is TapAppLinkRedeemException.Other -> { + Log.w(TAG, "applyCode failed status=${error.status} message=${error.message}") + showStatus( + title = "We couldn't check your code. Check your connection and try again.", + ) + } + else -> { + Log.w(TAG, "applyCode failed", error) + showStatus( + title = "We couldn't check your code. Check your connection and try again.", + ) + } + } + } +} +``` + +`TapAppLinkRedeemException.WrongEnvironment.DEVELOPER_WARNING` is exactly: +`This code belongs to the other environment (Sandbox or Production). Check your API key.` + `trackInstall()` is safe on every launch. It stores an `installId`, the tracked flag, attribution id and offer in SharedPreferences, and only posts `/ingestInstall` once per install. `setAppUserId` / identify send the stored `attributionId`. Call `resetForTesting(context)` in debug builds before repeating a match test on the same device. @@ -57,7 +119,7 @@ Purchases are attributed through billing webhooks. Leave out a client `trackPurc GitHub Actions runs on every pull request and push to `main`: Gradle assemble, unit tests, ktlint, and Android Lint. -Pushing a semver tag (`0.3.0`, `v0.3.0`, or a prerelease suffix) runs the same checks, creates a GitHub Release, and requests a JitPack build for that tag. `jitpack.yml` pins OpenJDK 17 for JitPack. +Pushing a semver tag (`0.3.1`, `v0.3.1`, or a prerelease suffix) runs the same checks, creates a GitHub Release, and requests a JitPack build for that tag. `jitpack.yml` pins OpenJDK 17 for JitPack. ### Local checks diff --git a/build.gradle.kts b/build.gradle.kts index 0f312cd..b85f766 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.tapapplink" -version = "0.3.0" +version = "0.3.1" android { namespace = "com.tapapplink.sdk" @@ -43,6 +43,8 @@ ktlint { dependencies { implementation("com.android.installreferrer:installreferrer:2.2") testImplementation("junit:junit:4.13.2") + // Real org.json for JVM unit tests (android.jar stubs JSONObject). + testImplementation("org.json:json:20240303") } afterEvaluate { @@ -52,7 +54,7 @@ afterEvaluate { from(components["release"]) groupId = "com.tapapplink" artifactId = "sdk" - version = "0.3.0" + version = "0.3.1" pom { name.set("Tap App Link Android SDK") description.set("Tap App Link attribution SDK for Android") diff --git a/gradle.properties b/gradle.properties index 3b4286a..47d5eb2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true kotlin.code.style=official GROUP=com.tapapplink POM_ARTIFACT_ID=sdk -VERSION_NAME=0.3.0 +VERSION_NAME=0.3.1 diff --git a/src/main/java/com/tapapplink/sdk/TapAppLink.kt b/src/main/java/com/tapapplink/sdk/TapAppLink.kt index 073116f..b0bf4a7 100644 --- a/src/main/java/com/tapapplink/sdk/TapAppLink.kt +++ b/src/main/java/com/tapapplink/sdk/TapAppLink.kt @@ -3,8 +3,6 @@ package com.tapapplink.sdk import android.content.Context import android.util.Log import org.json.JSONObject -import java.net.HttpURLConnection -import java.net.URL import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -32,10 +30,14 @@ data class TapAppLinkOffer( object TapAppLink { private const val TAG = "TapAppLink" + /** Sent on every request as `X-TapAppLink-SDK-Version`. */ + const val SDK_VERSION = "0.3.1" + private var config: TapAppLinkConfig? = null private var store: TapAppLinkStore? = null private var lastAppUserId: String? = null private var referrerReader: ReferrerReader = PlayInstallReferrerReader() + private var httpClient: TapAppLinkHttpClient = DefaultTapAppLinkHttpClient private val io = Executors.newSingleThreadExecutor() @JvmStatic @@ -80,30 +82,58 @@ object TapAppLink { .put("firstOpenAt", isoNow()) referrer?.takeIf { it.isNotBlank() }?.let { body.put("installReferrer", it) } debugLog("trackInstall posting installId=$installId hasReferrer=${body.has("installReferrer")}") - val result = post(cfg, "/ingestInstall", body) - persisted.tracked = true - cacheFromResult(persisted, result) - debugLog("trackInstall response=$result; ${persisted.debugSnapshot()}") - callback(result) + try { + val result = post(cfg, "/ingestInstall", body) + persisted.tracked = true + cacheFromResult(persisted, result) + debugLog("trackInstall response=$result; ${persisted.debugSnapshot()}") + callback(result) + } catch (error: Exception) { + debugLog("trackInstall failed: ${error.message}") + } } } @JvmStatic fun setAppUserId(appUserId: String, callback: (JSONObject) -> Unit) { lastAppUserId = appUserId - io.execute { callback(identify(appUserId)) } + io.execute { + try { + callback(identify(appUserId)) + } catch (error: Exception) { + debugLog("setAppUserId failed: ${error.message}") + } + } } + /** + * Redeems a discount code via `/redeemCode`. + * + * On success, [callback] receives [Result.success] with the JSON body + * (including `alreadyAttributed` when the install was already linked). + * On failure, [callback] receives [Result.failure] with a + * [TapAppLinkRedeemException] case. + * + * Signature note: the callback changed from `(JSONObject) -> Unit` in 0.3.0 + * to `(Result) -> Unit` so errors are typed instead of returned + * as a fake success body. + */ @JvmStatic - fun applyCode(code: String, callback: (JSONObject) -> Unit) { + fun applyCode(code: String, callback: (Result) -> Unit) { io.execute { - val cfg = requireConfig() - val body = JSONObject().put("code", code).put("platform", "ANDROID") - lastAppUserId?.let { body.put("appUserId", it) } - store?.attributionId?.let { body.put("attributionId", it) } - val result = post(cfg, "/redeemCode", body) - store?.let { cacheFromResult(it, result) } - callback(result) + callback( + runCatching { + val cfg = requireConfig() + val body = JSONObject().put("code", code).put("platform", "ANDROID") + lastAppUserId?.let { body.put("appUserId", it) } + store?.attributionId?.let { body.put("attributionId", it) } + val result = post(cfg, "/redeemCode", body) + store?.let { cacheFromResult(it, result) } + result + }.recoverCatching { error -> + throw mapNetworkFailure(error) + }, + ) } } @@ -145,6 +175,7 @@ object TapAppLink { store?.clear() } store = null + httpClient = DefaultTapAppLinkHttpClient debugLog("resetForTesting cleared local state") } @@ -156,6 +187,10 @@ object TapAppLink { store = next } + internal fun setHttpClientForTesting(client: TapAppLinkHttpClient) { + httpClient = client + } + private fun ensureStore(context: Context): TapAppLinkStore { val existing = store if (existing != null) return existing @@ -211,21 +246,15 @@ object TapAppLink { val base = (cfg.ingestUrl ?: "https://us-central1-tapapplink.cloudfunctions.net") .trimEnd('/') val url = "$base$path" - debugLog("POST $url body=$body auth=${redactKey(cfg.publicKey)}") - val connection = (URL(url).openConnection() as HttpURLConnection).apply { - requestMethod = "POST" - setRequestProperty("Authorization", "Bearer ${cfg.publicKey}") - setRequestProperty("Content-Type", "application/json") - doOutput = true - connectTimeout = 15_000 - readTimeout = 15_000 - } - connection.outputStream.use { it.write(body.toString().toByteArray()) } - val stream = - if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream - val text = stream?.bufferedReader()?.readText().orEmpty() - debugLog("POST $path status=${connection.responseCode} response=$text") - return if (text.isBlank()) JSONObject() else JSONObject(text) + val headers = mapOf( + "Authorization" to "Bearer ${cfg.publicKey}", + "Content-Type" to "application/json", + "X-TapAppLink-SDK-Version" to SDK_VERSION, + ) + debugLog("POST $url body=$body auth=${redactKey(cfg.publicKey)} sdk=$SDK_VERSION") + val response = httpClient.post(url, headers, body.toString()) + debugLog("POST $path status=${response.status} response=${response.body}") + return requireSuccessBody(path, response) } private fun debugLog(message: String) { diff --git a/src/main/java/com/tapapplink/sdk/TapAppLinkHttp.kt b/src/main/java/com/tapapplink/sdk/TapAppLinkHttp.kt new file mode 100644 index 0000000..20f8a72 --- /dev/null +++ b/src/main/java/com/tapapplink/sdk/TapAppLinkHttp.kt @@ -0,0 +1,95 @@ +package com.tapapplink.sdk + +import org.json.JSONObject +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +internal data class TapAppLinkHttpResponse( + val status: Int, + val body: String, +) + +internal fun interface TapAppLinkHttpClient { + fun post( + url: String, + headers: Map, + body: String, + ): TapAppLinkHttpResponse +} + +internal object DefaultTapAppLinkHttpClient : TapAppLinkHttpClient { + override fun post( + url: String, + headers: Map, + body: String, + ): TapAppLinkHttpResponse { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + headers.forEach { (key, value) -> setRequestProperty(key, value) } + doOutput = true + connectTimeout = 15_000 + readTimeout = 15_000 + } + connection.outputStream.use { it.write(body.toByteArray()) } + val status = connection.responseCode + val stream = + if (status in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader()?.readText().orEmpty() + return TapAppLinkHttpResponse(status = status, body = text) + } +} + +/** + * Parses a non-2xx redeem response into a typed [TapAppLinkRedeemException]. + * + * Prefers the JSON `error` field, then falls back to HTTP status. + */ +internal fun mapRedeemFailure( + status: Int, + bodyText: String, +): TapAppLinkRedeemException { + val body = bodyText.trim().takeIf { it.isNotEmpty() }?.let { + runCatching { JSONObject(it) }.getOrNull() + } + val errorCode = body?.optString("error")?.takeIf { it.isNotBlank() } + val message = body?.optString("message")?.takeIf { it.isNotBlank() } + ?: bodyText.takeIf { it.isNotBlank() } + ?: "HTTP $status" + + // Prefer the body `error` field; fall back to status (404 unknown, 410 inactive). + // A 400 with `wrong_environment` is handled by the body field above. + return when (errorCode) { + "unknown_code" -> TapAppLinkRedeemException.UnknownCode() + "inactive_code" -> TapAppLinkRedeemException.InactiveCode() + "wrong_environment" -> TapAppLinkRedeemException.WrongEnvironment() + else -> when (status) { + 404 -> TapAppLinkRedeemException.UnknownCode() + 410 -> TapAppLinkRedeemException.InactiveCode() + else -> TapAppLinkRedeemException.Other(status, message) + } + } +} + +internal fun mapNetworkFailure(cause: Throwable): TapAppLinkRedeemException { + // Timeouts, DNS failures, and other IO errors all surface as Network. + if (cause is TapAppLinkRedeemException) return cause + return TapAppLinkRedeemException.Network(cause) +} + +/** + * Throws when [response] is not 2xx. Never returns an error body as success. + */ +internal fun requireSuccessBody( + path: String, + response: TapAppLinkHttpResponse, +): JSONObject { + if (response.status !in 200..299) { + if (path == "/redeemCode") { + throw mapRedeemFailure(response.status, response.body) + } + val message = response.body.trim().ifEmpty { "HTTP ${response.status}" } + throw IOException("TapAppLink request $path failed with HTTP ${response.status}: $message") + } + return if (response.body.isBlank()) JSONObject() else JSONObject(response.body) +} diff --git a/src/main/java/com/tapapplink/sdk/TapAppLinkRedeemException.kt b/src/main/java/com/tapapplink/sdk/TapAppLinkRedeemException.kt new file mode 100644 index 0000000..9356508 --- /dev/null +++ b/src/main/java/com/tapapplink/sdk/TapAppLinkRedeemException.kt @@ -0,0 +1,33 @@ +package com.tapapplink.sdk + +/** + * Typed failure from [TapAppLink.applyCode]. + * + * Map server `error` values (`unknown_code`, `inactive_code`, `wrong_environment`) + * and HTTP status fallbacks (404, 410, 400) into these cases. + */ +sealed class TapAppLinkRedeemException( + message: String? = null, + cause: Throwable? = null, +) : Exception(message, cause) { + class UnknownCode : TapAppLinkRedeemException("unknown_code") + + class InactiveCode : TapAppLinkRedeemException("inactive_code") + + class WrongEnvironment : TapAppLinkRedeemException("wrong_environment") { + companion object { + /** Developer-only warning. Never show this (or the word "environment") to customers. */ + const val DEVELOPER_WARNING = + "This code belongs to the other environment (Sandbox or Production). Check your API key." + } + } + + class Network( + cause: Throwable? = null, + ) : TapAppLinkRedeemException("network", cause) + + class Other( + val status: Int, + override val message: String?, + ) : TapAppLinkRedeemException(message) +} diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt new file mode 100644 index 0000000..74b0092 --- /dev/null +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt @@ -0,0 +1,149 @@ +package com.tapapplink.sdk + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class TapAppLinkApplyCodeTest { + @Before + fun setUp() { + TapAppLink.resetForTesting() + TapAppLink.setStoreForTesting(TapAppLinkStore(InMemoryKeyValueStore())) + TapAppLink.configure( + TapAppLinkConfig( + publicKey = "etk_test_key_123456", + environment = TapAppLinkEnvironment.SANDBOX, + ingestUrl = "https://example.test", + ), + ) + } + + @Test + fun applyCodeSuccessCachesOfferAndAttribution() { + val seenHeaders = AtomicReference>() + TapAppLink.setHttpClientForTesting { _, headers, _ -> + seenHeaders.set(headers) + TapAppLinkHttpResponse( + 200, + """{"attributionId":"attr_9","alreadyAttributed":false,"offer":{"creatorName":"Sarah","promoCode":"SARAH10","billingOfferId":"offer_1"}}""", + ) + } + + val result = awaitApply("SARAH10") + assertTrue(result.isSuccess) + val body = result.getOrThrow() + assertEquals("attr_9", body.getString("attributionId")) + assertFalse(body.getBoolean("alreadyAttributed")) + assertEquals("attr_9", TapAppLink.getAttributionId()) + assertEquals("Sarah", TapAppLink.getOffer()?.creatorName) + assertEquals("0.3.1", seenHeaders.get()["X-TapAppLink-SDK-Version"]) + assertEquals(TapAppLink.SDK_VERSION, seenHeaders.get()["X-TapAppLink-SDK-Version"]) + } + + @Test + fun applyCodeAlreadyAttributedSuccess() { + TapAppLink.setHttpClientForTesting { _, _, _ -> + TapAppLinkHttpResponse( + 200, + """{"attributionId":"attr_1","alreadyAttributed":true,"offer":{"creatorName":"Alex","promoCode":null,"billingOfferId":"b1"}}""", + ) + } + + val result = awaitApply("ALEX") + assertTrue(result.isSuccess) + assertTrue(result.getOrThrow().getBoolean("alreadyAttributed")) + } + + @Test + fun applyCodeUnknownCodeFromBody() { + TapAppLink.setHttpClientForTesting { _, _, _ -> + TapAppLinkHttpResponse(404, """{"error":"unknown_code"}""") + } + val result = awaitApply("NOPE") + assertTrue(result.exceptionOrNull() is TapAppLinkRedeemException.UnknownCode) + } + + @Test + fun applyCodeInactiveCodeFrom410() { + TapAppLink.setHttpClientForTesting { _, _, _ -> + TapAppLinkHttpResponse(410, """{"error":"inactive_code"}""") + } + val result = awaitApply("OLD") + assertTrue(result.exceptionOrNull() is TapAppLinkRedeemException.InactiveCode) + } + + @Test + fun applyCodeWrongEnvironmentFrom400() { + TapAppLink.setHttpClientForTesting { _, _, _ -> + TapAppLinkHttpResponse(400, """{"error":"wrong_environment"}""") + } + val result = awaitApply("LIVEONLY") + assertTrue(result.exceptionOrNull() is TapAppLinkRedeemException.WrongEnvironment) + } + + @Test + fun applyCodeLegacy404WithBodyError() { + TapAppLink.setHttpClientForTesting { _, _, _ -> + TapAppLinkHttpResponse(404, """{"error":"inactive_code","message":"gone"}""") + } + val result = awaitApply("LEGACY") + assertTrue(result.exceptionOrNull() is TapAppLinkRedeemException.InactiveCode) + } + + @Test + fun applyCodeLegacy404WithoutBodyErrorIsUnknown() { + TapAppLink.setHttpClientForTesting { _, _, _ -> + TapAppLinkHttpResponse(404, """{"message":"missing"}""") + } + val result = awaitApply("MISSING") + assertTrue(result.exceptionOrNull() is TapAppLinkRedeemException.UnknownCode) + } + + @Test + fun applyCodeNetworkFailure() { + TapAppLink.setHttpClientForTesting { _, _, _ -> + throw IOException("offline") + } + val result = awaitApply("SARAH10") + assertTrue(result.exceptionOrNull() is TapAppLinkRedeemException.Network) + } + + @Test + fun applyCodeOtherFailure() { + TapAppLink.setHttpClientForTesting { _, _, _ -> + TapAppLinkHttpResponse(502, """{"message":"bad gateway"}""") + } + val result = awaitApply("SARAH10") + val error = result.exceptionOrNull() + assertTrue(error is TapAppLinkRedeemException.Other) + assertEquals(502, (error as TapAppLinkRedeemException.Other).status) + } + + @Test + fun nonRedeemNon2xxDoesNotReturnBodyAsSuccess() { + try { + requireSuccessBody("/ingestInstall", TapAppLinkHttpResponse(404, """{"error":"unknown_code"}""")) + throw AssertionError("expected failure") + } catch (error: IOException) { + assertTrue(error.message!!.contains("404")) + } + } + + private fun awaitApply(code: String): Result { + val latch = CountDownLatch(1) + val held = AtomicReference>() + TapAppLink.applyCode(code) { result -> + held.set(result) + latch.countDown() + } + assertTrue("timed out waiting for applyCode", latch.await(5, TimeUnit.SECONDS)) + return held.get() + } +} diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemExceptionTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemExceptionTest.kt new file mode 100644 index 0000000..4feb3a5 --- /dev/null +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemExceptionTest.kt @@ -0,0 +1,128 @@ +package com.tapapplink.sdk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +class TapAppLinkRedeemExceptionTest { + @Test + fun mapsErrorFieldUnknownCode() { + val error = mapRedeemFailure(404, """{"error":"unknown_code"}""") + assertTrue(error is TapAppLinkRedeemException.UnknownCode) + } + + @Test + fun mapsErrorFieldInactiveCode() { + val error = mapRedeemFailure(410, """{"error":"inactive_code"}""") + assertTrue(error is TapAppLinkRedeemException.InactiveCode) + } + + @Test + fun mapsErrorFieldWrongEnvironment() { + val error = mapRedeemFailure(400, """{"error":"wrong_environment"}""") + assertTrue(error is TapAppLinkRedeemException.WrongEnvironment) + } + + @Test + fun prefersErrorFieldOverStatusOnLegacy404() { + val inactive = mapRedeemFailure(404, """{"error":"inactive_code"}""") + assertTrue(inactive is TapAppLinkRedeemException.InactiveCode) + + val wrong = mapRedeemFailure(404, """{"error":"wrong_environment"}""") + assertTrue(wrong is TapAppLinkRedeemException.WrongEnvironment) + } + + @Test + fun legacy404WithoutErrorFieldIsUnknownCode() { + val error = mapRedeemFailure(404, """{"message":"not found"}""") + assertTrue(error is TapAppLinkRedeemException.UnknownCode) + } + + @Test + fun status410FallbackIsInactiveCode() { + val error = mapRedeemFailure(410, "{}") + assertTrue(error is TapAppLinkRedeemException.InactiveCode) + } + + @Test + fun status400WithWrongEnvironmentIsWrongEnvironment() { + val error = mapRedeemFailure(400, """{"error":"wrong_environment","message":"sandbox"}""") + assertTrue(error is TapAppLinkRedeemException.WrongEnvironment) + } + + @Test + fun status400WithoutKnownErrorIsOther() { + val error = mapRedeemFailure(400, """{"message":"bad request"}""") + assertTrue(error is TapAppLinkRedeemException.Other) + val other = error as TapAppLinkRedeemException.Other + assertEquals(400, other.status) + assertEquals("bad request", other.message) + } + + @Test + fun otherStatusesBecomeOther() { + val error = mapRedeemFailure(500, """{"message":"boom"}""") + assertTrue(error is TapAppLinkRedeemException.Other) + assertEquals(500, (error as TapAppLinkRedeemException.Other).status) + } + + @Test + fun emptyBodyUsesHttpStatusMessageForOther() { + val error = mapRedeemFailure(503, "") + assertTrue(error is TapAppLinkRedeemException.Other) + assertEquals("HTTP 503", error.message) + } + + @Test + fun wrongEnvironmentDeveloperWarningExactText() { + assertEquals( + "This code belongs to the other environment (Sandbox or Production). Check your API key.", + TapAppLinkRedeemException.WrongEnvironment.DEVELOPER_WARNING, + ) + } + + @Test + fun mapNetworkFailureWrapsTimeoutsAndIo() { + assertTrue(mapNetworkFailure(SocketTimeoutException("t")) is TapAppLinkRedeemException.Network) + assertTrue(mapNetworkFailure(UnknownHostException("h")) is TapAppLinkRedeemException.Network) + assertTrue(mapNetworkFailure(IOException("io")) is TapAppLinkRedeemException.Network) + } + + @Test + fun mapNetworkFailurePassesThroughRedeemExceptions() { + val original = TapAppLinkRedeemException.UnknownCode() + assertTrue(mapNetworkFailure(original) === original) + } + + @Test + fun requireSuccessBodyReturnsJsonOn2xx() { + val json = requireSuccessBody("/ingestInstall", TapAppLinkHttpResponse(200, """{"ok":true}""")) + assertTrue(json.getBoolean("ok")) + } + + @Test + fun requireSuccessBodyNeverReturnsErrorBodyAsSuccess() { + try { + requireSuccessBody("/ingestInstall", TapAppLinkHttpResponse(500, """{"error":"nope"}""")) + throw AssertionError("expected IOException") + } catch (error: IOException) { + assertTrue(error.message!!.contains("500")) + } + } + + @Test + fun requireSuccessBodyMapsRedeemFailures() { + try { + requireSuccessBody( + "/redeemCode", + TapAppLinkHttpResponse(404, """{"error":"unknown_code"}"""), + ) + throw AssertionError("expected UnknownCode") + } catch (error: TapAppLinkRedeemException.UnknownCode) { + // expected + } + } +}