From 99251679ca62ad0b949fc773b0fd52c2bf49dc5a Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 15 May 2026 13:24:52 +0100 Subject: [PATCH] feat: add first protocol event --- .gitignore | 1 + dev.yml | 22 ++ .../android/build.gradle | 20 ++ .../android/gradle.properties | 5 + .../checkoutkit/CasingTransform.kt | 89 +++++ .../checkoutkit/DispatchEnvelope.kt | 31 ++ .../reactnative/checkoutkit/ProtocolRelay.kt | 62 ++++ .../checkoutkit/ShopifyCheckoutKitModule.java | 22 +- .../checkoutkit/CasingTransformTest.kt | 328 ++++++++++++++++++ .../checkoutkit/ProtocolRelayTest.kt | 142 ++++++++ .../ios/CasingTransform.swift | 97 ++++++ .../ios/CheckoutProtocolBridge.swift | 28 ++ .../ios/DispatchEnvelope.swift | 29 ++ .../ios/Package.swift | 28 ++ .../ios/ProtocolRelay.swift | 61 ++++ .../ios/ShopifyCheckoutKit.mm | 1 + .../ios/ShopifyCheckoutKit.swift | 20 +- .../ios/Tests/CasingTransformTests.swift | 254 ++++++++++++++ .../ios/Tests/ProtocolRelayTests.swift | 131 +++++++ .../checkout-kit-react-native/src/index.ts | 34 +- .../checkout-kit-react-native/src/protocol.ts | 38 ++ .../src/specs/NativeShopifyCheckoutKit.ts | 1 + .../tests/context.test.tsx | 2 + .../tests/index.test.ts | 89 ++++- .../tests/protocol.test.ts | 38 ++ 25 files changed, 1557 insertions(+), 16 deletions(-) create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CasingTransform.kt create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEnvelope.kt create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CasingTransformTest.kt create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CasingTransform.swift create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutProtocolBridge.swift create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/DispatchEnvelope.swift create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Package.swift create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/CasingTransformTests.swift create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts diff --git a/.gitignore b/.gitignore index ed7805a1..e20f678a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ apollo-ios-cli # Android / Gradle .gradle/ +.kotlin/ build/ captures/ .externalNativeBuild diff --git a/dev.yml b/dev.yml index b651cd85..827371c9 100644 --- a/dev.yml +++ b/dev.yml @@ -264,6 +264,28 @@ commands: build: desc: Build the @shopify/checkout-kit-react-native module run: cd platforms/react-native && pnpm module build + test: + desc: Run React Native module tests (JS + iOS + Android) + long_desc: | + Runs unit tests across all three React Native targets: + - JS: Jest tests in `platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/` + - iOS: Swift Package tests at `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/` + - Android: Gradle JVM tests for `:shopify_checkout-kit-react-native` (requires a local Maven publish of `:lib`) + run: | + set -e + cd platforms/react-native && pnpm test + cd modules/@shopify/checkout-kit-react-native/ios && swift test + cd ../../../../sample/android && USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test + subcommands: + js: + desc: Run JS unit tests via jest + run: cd platforms/react-native && pnpm test + ios: + desc: Run native iOS unit tests (Swift Package at modules/.../ios) + run: cd platforms/react-native/modules/@shopify/checkout-kit-react-native/ios && swift test + android: + desc: Run native Android unit tests for the RN module (uses local Maven publish of :lib) + run: cd platforms/react-native/sample/android && USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test lint: desc: Run all React Native lint checks (Swift, module, sample) aliases: [style] diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/build.gradle b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/build.gradle index cc2dc2a3..588c0666 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/build.gradle +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/build.gradle @@ -1,4 +1,6 @@ buildscript { + ext.kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "2.1.20" + repositories { google() mavenCentral() @@ -6,11 +8,15 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:8.11.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" } } apply plugin: "com.android.library" apply plugin: "com.facebook.react" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "org.jetbrains.kotlin.plugin.serialization" def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties[name]).toInteger() @@ -73,8 +79,17 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + kotlinOptions { + jvmTarget = "1.8" + } + + testOptions { + unitTests.includeAndroidResources = true + } } + repositories { mavenLocal() mavenCentral() @@ -97,6 +112,11 @@ dependencies { implementation(shopifySdkArtifact) implementation("com.fasterxml.jackson.core:jackson-databind:2.12.5") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") debugImplementation(shopifySdkArtifact) + + testImplementation "junit:junit:4.13.2" + testImplementation "org.assertj:assertj-core:3.27.7" + testImplementation "org.robolectric:robolectric:4.16.1" } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/gradle.properties b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/gradle.properties index 08a3c77a..08703c8d 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/gradle.properties +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/gradle.properties @@ -3,3 +3,8 @@ targetSdkVersion=35 compileSdkVersion=36 ndkVersion=23.1.7779620 buildToolsVersion = "35.0.0" + +# Opt out of the React Native Gradle plugin's JdkConfiguratorUtils, which otherwise +# silently rewrites compileOptions to 17 and pins the Kotlin JVM toolchain to 17 for +# every com.android.library it sees. We mirror :lib's pinned JVM 1.8 contract instead. +react.internal.disableJavaVersionAlignment=true diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CasingTransform.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CasingTransform.kt new file mode 100644 index 00000000..26726cd8 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CasingTransform.kt @@ -0,0 +1,89 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Bridges typed snake_case payloads (per @SerialName annotations on the native models) + * with camelCase JSON expected by JavaScript consumers. + */ +internal object CasingTransform { + + private const val CAMEL_TO_SNAKE_BUFFER_PADDING: Int = 4 + + internal val json: Json = Json { ignoreUnknownKeys = true } + + fun snakeToCamel(s: String): String { + if (s.isEmpty() || !s.contains('_')) return s + val builder = StringBuilder(s.length) + var upperNext = false + for (ch in s) { + if (ch == '_') { + upperNext = true + } else if (upperNext) { + builder.append(ch.uppercaseChar()) + upperNext = false + } else { + builder.append(ch) + } + } + return builder.toString() + } + + fun camelToSnake(s: String): String { + if (s.isEmpty()) return s + val builder = StringBuilder(s.length + CAMEL_TO_SNAKE_BUFFER_PADDING) + for (ch in s) { + if (ch.isUpperCase()) { + builder.append('_').append(ch.lowercaseChar()) + } else { + builder.append(ch) + } + } + return builder.toString() + } + + fun transformKeys(element: JsonElement, fn: (String) -> String): JsonElement = when (element) { + is JsonObject -> JsonObject(element.entries.associate { (key, value) -> fn(key) to transformKeys(value, fn) }) + is JsonArray -> JsonArray(element.map { transformKeys(it, fn) }) + else -> element + } + + inline fun encodeForJS(payload: T): String { + val element = json.encodeToJsonElement(payload) + val transformed = transformKeys(element, ::snakeToCamel) + return json.encodeToString(JsonElement.serializer(), transformed) + } + + inline fun decodeFromJS(json: String): T { + val element = Json.parseToJsonElement(json) + val transformed = transformKeys(element, ::camelToSnake) + return CasingTransform.json.decodeFromJsonElement(transformed) + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEnvelope.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEnvelope.kt new file mode 100644 index 00000000..45c85910 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEnvelope.kt @@ -0,0 +1,31 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import kotlinx.serialization.Serializable + +@Serializable +internal data class DispatchEnvelope

( + val type: String, + val payload: P, +) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt new file mode 100644 index 00000000..a44ef04a --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt @@ -0,0 +1,62 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import com.shopify.checkoutkit.CheckoutProtocol + +fun interface DispatchCallback { + fun invoke(json: String) +} + +object ProtocolRelay { + + @JvmStatic + fun makeClient( + subscribedMethods: List, + dispatch: DispatchCallback, + ): CheckoutProtocol.Client { + var client = CheckoutProtocol.Client() + for (method in subscribedMethods) { + when (method) { + CheckoutProtocol.start.method -> { + client = client.on(CheckoutProtocol.start) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } + } + } + return client + } + + private inline fun forwardEnvelope( + type: String, + payload: P, + dispatch: DispatchCallback, + ) { + try { + dispatch.invoke(CasingTransform.encodeForJS(DispatchEnvelope(type, payload))) + } catch (e: Throwable) { + // dispatch failures are swallowed — there is no native consumer for them + } + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java index c5b95e1f..fabf6bfb 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java @@ -39,7 +39,9 @@ of this software and associated documentation files (the "Software"), to deal import com.shopify.checkoutkit.NativeShopifyCheckoutKitSpec; import com.shopify.checkoutkit.*; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -82,13 +84,29 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL, @Nullable Callback dispatch) { + public void present(String checkoutURL, ReadableArray subscribedMethods, @Nullable Callback dispatch) { Activity currentActivity = getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext, dispatch); + + List methods = new ArrayList<>(); + for (int i = 0; i < subscribedMethods.size(); i++) { + String method = subscribedMethods.getString(i); + if (method != null) { + methods.add(method); + } + } + CheckoutProtocol.Client client = ProtocolRelay.makeClient( + methods, + json -> { + if (dispatch != null) { + dispatch.invoke(json); + } + }); + currentActivity.runOnUiThread(() -> { checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, - checkoutEventProcessor); + checkoutEventProcessor, client); }); } } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CasingTransformTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CasingTransformTest.kt new file mode 100644 index 00000000..e926be4f --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CasingTransformTest.kt @@ -0,0 +1,328 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import com.shopify.checkoutkit.Checkout +import com.shopify.checkoutkit.CheckoutLineItem +import com.shopify.checkoutkit.CheckoutStatus +import com.shopify.checkoutkit.ItemClass +import com.shopify.checkoutkit.UCPCheckoutResponseSchema +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CasingTransformTest { + + // region snakeToCamel + + @Test + fun `snakeToCamel converts continue_url to lower camel`() { + assertThat(CasingTransform.snakeToCamel("continue_url")).isEqualTo("continueUrl") + } + + @Test + fun `snakeToCamel converts line_items to lower camel`() { + assertThat(CasingTransform.snakeToCamel("line_items")).isEqualTo("lineItems") + } + + @Test + fun `snakeToCamel leaves single-word keys unchanged`() { + assertThat(CasingTransform.snakeToCamel("foo")).isEqualTo("foo") + } + + @Test + fun `snakeToCamel returns empty string unchanged`() { + assertThat(CasingTransform.snakeToCamel("")).isEqualTo("") + } + + @Test + fun `snakeToCamel converts oauth_2_0_access_token`() { + assertThat(CasingTransform.snakeToCamel("oauth_2_0_access_token")).isEqualTo("oauth20AccessToken") + } + + @Test + fun `snakeToCamel converts http_request_finish`() { + assertThat(CasingTransform.snakeToCamel("http_request_finish")).isEqualTo("httpRequestFinish") + } + + @Test + fun `snakeToCamel converts accelerated_checkouts_apple_pay_configuration`() { + assertThat(CasingTransform.snakeToCamel("accelerated_checkouts_apple_pay_configuration")) + .isEqualTo("acceleratedCheckoutsApplePayConfiguration") + } + + @Test + fun `snakeToCamel converts iso_8601_timestamp`() { + assertThat(CasingTransform.snakeToCamel("iso_8601_timestamp")).isEqualTo("iso8601Timestamp") + } + + @Test + fun `snakeToCamel converts x_forwarded_for_header`() { + assertThat(CasingTransform.snakeToCamel("x_forwarded_for_header")).isEqualTo("xForwardedForHeader") + } + + @Test + fun `snakeToCamel passes through already-camel input unchanged`() { + assertThat(CasingTransform.snakeToCamel("alreadyCamel")).isEqualTo("alreadyCamel") + } + + @Test + fun `snakeToCamel preserves embedded digits as non-letter characters`() { + assertThat(CasingTransform.snakeToCamel("field_v2")).isEqualTo("fieldV2") + } + + // endregion + + // region camelToSnake + + @Test + fun `camelToSnake converts continueUrl to snake`() { + assertThat(CasingTransform.camelToSnake("continueUrl")).isEqualTo("continue_url") + } + + @Test + fun `camelToSnake converts lineItems to snake`() { + assertThat(CasingTransform.camelToSnake("lineItems")).isEqualTo("line_items") + } + + @Test + fun `camelToSnake leaves single-word keys unchanged`() { + assertThat(CasingTransform.camelToSnake("foo")).isEqualTo("foo") + } + + @Test + fun `camelToSnake converts acceleratedCheckoutsApplePayConfiguration`() { + assertThat(CasingTransform.camelToSnake("acceleratedCheckoutsApplePayConfiguration")) + .isEqualTo("accelerated_checkouts_apple_pay_configuration") + } + + @Test + fun `camelToSnake converts httpRequestFinish`() { + assertThat(CasingTransform.camelToSnake("httpRequestFinish")).isEqualTo("http_request_finish") + } + + @Test + fun `camelToSnake splits each uppercase letter in consecutive-uppercase runs`() { + assertThat(CasingTransform.camelToSnake("imageURL")).isEqualTo("image_u_r_l") + } + + // endregion + + // region round-trip + + @Test + fun `snakeToCamel round-trips typical wire keys through camelToSnake`() { + val keys = listOf( + "continue_url", + "line_items", + "http_request_finish", + "x_forwarded_for_header", + "accelerated_checkouts_apple_pay_configuration", + ) + keys.forEach { key -> + val camel = CasingTransform.snakeToCamel(key) + assertThat(CasingTransform.camelToSnake(camel)).isEqualTo(key) + } + } + + // endregion + + // region transformKeys + + @Test + fun `transformKeys recursively transforms keys in nested objects and arrays`() { + val input = buildJsonObject { + put("outer_key", JsonPrimitive("v")) + put( + "nested_object", + buildJsonObject { + put("inner_key", JsonPrimitive(1)) + } + ) + put( + "list_of_objects", + buildJsonArray { + add( + buildJsonObject { + put("array_item_key", JsonPrimitive("a")) + } + ) + add( + buildJsonObject { + put("array_item_key", JsonPrimitive("b")) + } + ) + } + ) + } + + val transformed = CasingTransform.transformKeys(input, CasingTransform::snakeToCamel) as JsonObject + + assertThat(transformed.keys).containsExactlyInAnyOrder("outerKey", "nestedObject", "listOfObjects") + val nested = transformed["nestedObject"] as JsonObject + assertThat(nested.keys).containsExactly("innerKey") + val list = transformed["listOfObjects"] as JsonArray + assertThat(list).hasSize(2) + list.forEach { element -> + assertThat((element as JsonObject).keys).containsExactly("arrayItemKey") + } + } + + @Test + fun `transformKeys passes through JsonPrimitive unchanged`() { + val primitive = JsonPrimitive("hello_there") + val result = CasingTransform.transformKeys(primitive, CasingTransform::snakeToCamel) + assertThat(result).isSameAs(primitive) + assertThat((result as JsonPrimitive).content).isEqualTo("hello_there") + } + + @Test + fun `transformKeys passes through JsonNull unchanged`() { + val result = CasingTransform.transformKeys(JsonNull, CasingTransform::snakeToCamel) + assertThat(result).isEqualTo(JsonNull) + } + + // endregion + + // region encodeForJS round-trip + + @Test + fun `encodeForJS produces camelCase keys for a Checkout payload`() { + val checkout = sampleCheckout() + + val jsonString = CasingTransform.encodeForJS(checkout) + val parsed = Json.parseToJsonElement(jsonString).jsonObject + + assertThat(parsed.keys).contains("continueUrl", "lineItems", "expiresAt") + assertThat(parsed.keys).doesNotContain("continue_url", "line_items", "expires_at") + + val ucp = parsed["ucp"]!!.jsonObject + assertThat(ucp.keys).contains("paymentHandlers") + assertThat(ucp.keys).doesNotContain("payment_handlers") + } + + @Test + fun `encodeForJS transforms keys inside list elements`() { + val checkout = sampleCheckout( + lineItems = listOf( + CheckoutLineItem( + id = "li1", + item = ItemClass( + id = "i1", + title = "Widget", + price = 100, + imageURL = "https://example.com/img.png", + ), + quantity = 1, + totals = emptyList(), + ) + ) + ) + + val jsonString = CasingTransform.encodeForJS(checkout) + val parsed = Json.parseToJsonElement(jsonString).jsonObject + val lineItem = parsed["lineItems"]!!.jsonArray[0].jsonObject + val item = lineItem["item"]!!.jsonObject + + assertThat(item.keys).contains("imageUrl") + assertThat(item.keys).doesNotContain("image_url") + } + + // endregion + + // region decodeFromJS reverse round-trip + + @Test + fun `decodeFromJS decodes camelCase JSON back into a Checkout`() { + val camelJson = """ + { + "id":"chk1", + "currency":"USD", + "status":"incomplete", + "continueUrl":"https://example.com/continue", + "expiresAt":"2026-12-31T23:59:59Z", + "lineItems":[ + {"id":"li1","item":{"id":"i1","title":"Widget","price":100,"imageUrl":"https://example.com/img.png"},"quantity":1,"totals":[]} + ], + "links":[], + "totals":[], + "ucp":{"paymentHandlers":{},"version":"1.0"} + } + """.trimIndent() + + val checkout = CasingTransform.decodeFromJS(camelJson) + + assertThat(checkout.id).isEqualTo("chk1") + assertThat(checkout.currency).isEqualTo("USD") + assertThat(checkout.continueURL).isEqualTo("https://example.com/continue") + assertThat(checkout.expiresAt).isEqualTo("2026-12-31T23:59:59Z") + assertThat(checkout.lineItems).hasSize(1) + assertThat(checkout.lineItems[0].item.imageURL).isEqualTo("https://example.com/img.png") + assertThat(checkout.ucp.paymentHandlers).isEmpty() + } + + @Test + fun `encode then decode round-trips Checkout instance`() { + val original = sampleCheckout() + val encoded = CasingTransform.encodeForJS(original) + val decoded = CasingTransform.decodeFromJS(encoded) + + assertThat(decoded.id).isEqualTo(original.id) + assertThat(decoded.currency).isEqualTo(original.currency) + assertThat(decoded.continueURL).isEqualTo(original.continueURL) + assertThat(decoded.expiresAt).isEqualTo(original.expiresAt) + } + + // endregion + + // region helpers + + private fun sampleCheckout( + id: String = "chk1", + currency: String = "USD", + lineItems: List = emptyList(), + ): Checkout = Checkout( + id = id, + currency = currency, + status = CheckoutStatus.Incomplete, + continueURL = "https://example.com/continue", + expiresAt = "2026-12-31T23:59:59Z", + lineItems = lineItems, + links = emptyList(), + totals = emptyList(), + ucp = UCPCheckoutResponseSchema( + paymentHandlers = emptyMap(), + version = "1.0", + ), + ) + + // endregion +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt new file mode 100644 index 00000000..f5156359 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt @@ -0,0 +1,142 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import android.os.Looper +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class ProtocolRelayTest { + + @Test + fun `envelope encodes type and camelCase payload`() { + val payload = SnakePayload(continueUrl = "https://example.com", lineItems = emptyList()) + val envelope = DispatchEnvelope(type = "ec.start", payload = payload) + + val json = CasingTransform.encodeForJS(envelope) + + val parsed = Json.parseToJsonElement(json).jsonObject + assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.start") + + val payloadObj = parsed["payload"]!!.jsonObject + assertThat(payloadObj["continueUrl"]?.jsonPrimitive?.content).isEqualTo("https://example.com") + assertThat(payloadObj).containsKey("lineItems") + assertThat(payloadObj).doesNotContainKey("continue_url") + assertThat(payloadObj).doesNotContainKey("line_items") + } + + @Test + fun `relay dispatches envelope on ec start`() { + var captured: String? = null + val client = ProtocolRelay.makeClient( + listOf("ec.start"), + DispatchCallback { json -> captured = json }, + ) + + client.process(ecStartNotificationFixture) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val json = captured + assertThat(json).isNotNull() + val parsed = Json.parseToJsonElement(json!!).jsonObject + assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.start") + + val payload = parsed["payload"]!!.jsonObject + assertThat(payload["id"]?.jsonPrimitive?.content).isEqualTo("checkout-123") + assertThat(payload["currency"]?.jsonPrimitive?.content).isEqualTo("USD") + + val lineItems = payload["lineItems"]!!.jsonArray + assertThat(lineItems).hasSize(1) + val firstItem = lineItems[0].jsonObject["item"]!!.jsonObject + assertThat(firstItem["imageUrl"]?.jsonPrimitive?.content).isEqualTo("https://example.com/image.png") + } + + @Test + fun `relay ignores methods not in subscribed list`() { + var captured: String? = null + val client = ProtocolRelay.makeClient( + emptyList(), + DispatchCallback { json -> captured = json }, + ) + + client.process(ecStartNotificationFixture) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(captured).isNull() + } +} + +@Serializable +private data class SnakePayload( + @SerialName("continue_url") val continueUrl: String, + @SerialName("line_items") val lineItems: List, +) + +private val ecStartNotificationFixture = """ +{ + "jsonrpc": "2.0", + "method": "ec.start", + "params": { + "checkout": { + "ucp": { + "version": "2026-04-08", + "payment_handlers": {} + }, + "id": "checkout-123", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li-1", + "quantity": 1, + "item": { + "id": "product-1", + "title": "Test Product", + "price": 2999, + "image_url": "https://example.com/image.png" + }, + "totals": [ + {"type": "subtotal", "amount": 2999} + ] + } + ], + "totals": [ + {"type": "total", "amount": 2999} + ], + "links": [ + {"type": "privacy_policy", "url": "https://example.com/privacy"} + ] + } + } +} +""".trimIndent() diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CasingTransform.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CasingTransform.swift new file mode 100644 index 00000000..6eeb36a6 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CasingTransform.swift @@ -0,0 +1,97 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation + +enum CasingTransform { + static func snakeToCamel(_ s: String) -> String { + guard !s.isEmpty else { return s } + let parts = s.split(separator: "_", omittingEmptySubsequences: false) + guard let first = parts.first else { return s } + let head = String(first) + let tail = parts.dropFirst().map { part -> String in + guard let initial = part.first else { return "" } + return initial.uppercased() + part.dropFirst() + } + return ([head] + tail).joined() + } + + static func camelToSnake(_ s: String) -> String { + guard !s.isEmpty else { return s } + var result = "" + for character in s { + if character.isUppercase { + if !result.isEmpty { + result.append("_") + } + result.append(character.lowercased()) + } else { + result.append(character) + } + } + return result + } + + static func transformKeys(_ value: Any, _ fn: (String) -> String) -> Any { + if let dict = value as? [String: Any] { + var transformed: [String: Any] = [:] + for (key, item) in dict { + transformed[fn(key)] = transformKeys(item, fn) + } + return transformed + } + if let array = value as? [Any] { + return array.map { transformKeys($0, fn) } + } + return value + } + + static func encodeForJS(_ payload: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(payload) + let object = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + let transformed = transformKeys(object, snakeToCamel) + let outputData = try JSONSerialization.data(withJSONObject: transformed, options: [.fragmentsAllowed]) + guard let string = String(data: outputData, encoding: .utf8) else { + throw CasingTransformError.invalidUTF8 + } + return string + } + + static func decodeFromJS(_ json: String, as type: T.Type) throws -> T { + guard let data = json.data(using: .utf8) else { + throw CasingTransformError.invalidUTF8 + } + let object = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + let transformed = transformKeys(object, camelToSnake) + let snakeData = try JSONSerialization.data(withJSONObject: transformed, options: [.fragmentsAllowed]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(type, from: snakeData) + } +} + +enum CasingTransformError: Error { + case invalidUTF8 +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutProtocolBridge.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutProtocolBridge.swift new file mode 100644 index 00000000..d86e5f08 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutProtocolBridge.swift @@ -0,0 +1,28 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +#if COCOAPODS + import ShopifyCheckoutKit + + extension CheckoutProtocol.Client: @retroactive CheckoutCommunicationProtocol {} +#endif diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/DispatchEnvelope.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/DispatchEnvelope.swift new file mode 100644 index 00000000..2e80d8af --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/DispatchEnvelope.swift @@ -0,0 +1,29 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation + +struct DispatchEnvelope: Encodable { + let type: String + let payload: Payload +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Package.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Package.swift new file mode 100644 index 00000000..ba604e69 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "RNShopifyCheckoutKitCasingTransform", + platforms: [.iOS(.v13), .macOS(.v10_15)], + products: [ + .library(name: "RNShopifyCheckoutKitCasingTransform", targets: ["RNShopifyCheckoutKitCasingTransform"]) + ], + dependencies: [ + .package(path: "../../../../../../protocol/languages/swift") + ], + targets: [ + .target( + name: "RNShopifyCheckoutKitCasingTransform", + dependencies: [ + .product(name: "ShopifyCheckoutProtocol", package: "swift") + ], + path: ".", + sources: ["CasingTransform.swift", "DispatchEnvelope.swift", "ProtocolRelay.swift"] + ), + .testTarget( + name: "RNShopifyCheckoutKitCasingTransformTests", + dependencies: ["RNShopifyCheckoutKitCasingTransform"], + path: "Tests" + ) + ] +) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift new file mode 100644 index 00000000..68ac74f5 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift @@ -0,0 +1,61 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +#if COCOAPODS + import ShopifyCheckoutKit +#else + import ShopifyCheckoutProtocol +#endif + +func makeRelayClient( + subscribedMethods: [String], + dispatch: @escaping @MainActor @Sendable (String) -> Void +) -> CheckoutProtocol.Client { + var client = CheckoutProtocol.Client() + + for method in subscribedMethods { + switch method { + case CheckoutProtocol.start.method: + client = client.on(CheckoutProtocol.start) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } + default: + continue + } + } + + return client +} + +@MainActor +private func forwardEnvelope( + type: String, + payload: P, + dispatch: @MainActor @Sendable (String) -> Void +) { + guard let json = try? CasingTransform.encodeForJS(DispatchEnvelope(type: type, payload: payload)) else { + return + } + dispatch(json) +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm index 2f8f2f45..90d9406e 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm @@ -41,6 +41,7 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) RCT_EXTERN_METHOD(present:(NSString *)checkoutURL + subscribedMethods:(NSArray *)subscribedMethods dispatch:(RCTResponseSenderBlock)dispatch) @end diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift index ee90efca..19489747 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -102,15 +102,23 @@ class RCTShopifyCheckoutKit: NSObject { invalidate() } - @objc func present(_ checkoutURL: String, dispatch: RCTResponseSenderBlock?) { + @objc func present(_ checkoutURL: String, subscribedMethods: [String], dispatch: RCTResponseSenderBlock?) { pendingDispatchCallback = dispatch DispatchQueue.main.async { - if let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() { - let view = CheckoutViewController(checkout: url) - viewController.present(view, animated: true) - self.checkoutSheet = view - } + guard let url = URL(string: checkoutURL), + let viewController = self.getCurrentViewController() else { return } + + let client = makeRelayClient( + subscribedMethods: subscribedMethods, + dispatch: { [weak self] json in + self?.pendingDispatchCallback?([json]) + } + ) + + let view = CheckoutViewController(checkout: url, client: client) + viewController.present(view, animated: true) + self.checkoutSheet = view } } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/CasingTransformTests.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/CasingTransformTests.swift new file mode 100644 index 00000000..e0da1a1e --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/CasingTransformTests.swift @@ -0,0 +1,254 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +@testable import RNShopifyCheckoutKitCasingTransform +import Testing + +@Suite("Casing Transform Tests") +struct CasingTransformTests { + @Test func snakeToCamelConvertsSingleUnderscore() { + #expect(CasingTransform.snakeToCamel("continue_url") == "continueUrl") + } + + @Test func snakeToCamelConvertsLineItems() { + #expect(CasingTransform.snakeToCamel("line_items") == "lineItems") + } + + @Test func snakeToCamelConvertsImageUrl() { + #expect(CasingTransform.snakeToCamel("image_url") == "imageUrl") + } + + @Test func snakeToCamelLeavesNonSnakeUnchanged() { + #expect(CasingTransform.snakeToCamel("foo") == "foo") + } + + @Test func snakeToCamelHandlesEmptyString() { + #expect(CasingTransform.snakeToCamel("") == "") + } + + @Test func snakeToCamelConvertsMultipleUnderscores() { + #expect(CasingTransform.snakeToCamel("a_b_c") == "aBC") + } + + @Test func snakeToCamelConvertsLongMultiSegmentField() { + #expect(CasingTransform.snakeToCamel("accelerated_checkouts_apple_pay_configuration") == "acceleratedCheckoutsApplePayConfiguration") + } + + @Test func snakeToCamelConvertsOAuthAccessToken() { + #expect(CasingTransform.snakeToCamel("oauth_2_0_access_token") == "oauth20AccessToken") + } + + @Test func snakeToCamelConvertsHttpRequestFinish() { + #expect(CasingTransform.snakeToCamel("http_request_finish") == "httpRequestFinish") + } + + @Test func snakeToCamelConvertsIso8601Timestamp() { + #expect(CasingTransform.snakeToCamel("iso_8601_timestamp") == "iso8601Timestamp") + } + + @Test func snakeToCamelConvertsXForwardedForHeader() { + #expect(CasingTransform.snakeToCamel("x_forwarded_for_header") == "xForwardedForHeader") + } + + @Test func snakeToCamelLeavesAlreadyCamelInputUnchanged() { + #expect(CasingTransform.snakeToCamel("alreadyCamel") == "alreadyCamel") + } + + @Test func snakeToCamelTreatsNumbersAsNonSpecialCharacters() { + #expect(CasingTransform.snakeToCamel("field_v2") == "fieldV2") + } + + @Test func camelToSnakeConvertsContinueUrl() { + #expect(CasingTransform.camelToSnake("continueUrl") == "continue_url") + } + + @Test func camelToSnakeConvertsLineItems() { + #expect(CasingTransform.camelToSnake("lineItems") == "line_items") + } + + @Test func camelToSnakeLeavesLowercaseUnchanged() { + #expect(CasingTransform.camelToSnake("foo") == "foo") + } + + @Test func camelToSnakeHandlesEmptyString() { + #expect(CasingTransform.camelToSnake("") == "") + } + + @Test func camelToSnakeConvertsAcceleratedCheckoutsApplePayConfiguration() { + #expect(CasingTransform.camelToSnake("acceleratedCheckoutsApplePayConfiguration") == "accelerated_checkouts_apple_pay_configuration") + } + + @Test func camelToSnakeConvertsHttpRequestFinish() { + #expect(CasingTransform.camelToSnake("httpRequestFinish") == "http_request_finish") + } + + @Test func camelToSnakeConvertsXForwardedForHeader() { + #expect(CasingTransform.camelToSnake("xForwardedForHeader") == "x_forwarded_for_header") + } + + @Test func camelToSnakeSplitsEachConsecutiveUppercaseCharacter() { + #expect(CasingTransform.camelToSnake("imageURL") == "image_u_r_l") + } + + @Test func snakeToCamelRoundTripsTypicalWireKeys() { + let keys = [ + "continue_url", + "line_items", + "image_url", + "http_request_finish", + "accelerated_checkouts_apple_pay_configuration", + "x_forwarded_for_header" + ] + for key in keys { + #expect(CasingTransform.camelToSnake(CasingTransform.snakeToCamel(key)) == key) + } + } + + @Test func transformKeysRecursesNestedDictionariesAndArrays() throws { + let input: [String: Any] = [ + "outer_key": [ + "inner_key": "value", + "nested_list": [ + ["item_id": "1"], + ["item_id": "2"] + ] + ] + ] + + let result = try #require( + CasingTransform.transformKeys(input, CasingTransform.snakeToCamel) as? [String: Any] + ) + + let outer = try #require(result["outerKey"] as? [String: Any]) + #expect(outer["innerKey"] as? String == "value") + let list = try #require(outer["nestedList"] as? [[String: Any]]) + #expect(list[0]["itemId"] as? String == "1") + #expect(list[1]["itemId"] as? String == "2") + } + + @Test func transformKeysPassesThroughPrimitives() { + #expect(CasingTransform.transformKeys("text", CasingTransform.snakeToCamel) as? String == "text") + #expect(CasingTransform.transformKeys(42, CasingTransform.snakeToCamel) as? Int == 42) + #expect(CasingTransform.transformKeys(true, CasingTransform.snakeToCamel) as? Bool == true) + #expect(CasingTransform.transformKeys(NSNull(), CasingTransform.snakeToCamel) is NSNull) + } + + @Test func encodeForJSConvertsTopLevelKeysToCamelCase() throws { + let payload = makePayload() + let json = try CasingTransform.encodeForJS(payload) + + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + #expect(parsed["continueUrl"] != nil) + #expect(parsed["continue_url"] == nil) + #expect(parsed["lineItems"] != nil) + #expect(parsed["line_items"] == nil) + #expect(parsed["expiresAt"] != nil) + #expect(parsed["expires_at"] == nil) + } + + @Test func encodeForJSConvertsNestedKeysToCamelCase() throws { + let payload = makePayload() + let json = try CasingTransform.encodeForJS(payload) + + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + let ucp = try #require(parsed["ucp"] as? [String: Any]) + #expect(ucp["paymentHandlers"] != nil) + #expect(ucp["payment_handlers"] == nil) + } + + @Test func decodeFromJSAcceptsCamelCaseInputAndDecodesIntoTypedModel() throws { + let camelJSON = #""" + { + "id": "chk_1", + "currency": "USD", + "continueUrl": "https://example.com/continue", + "expiresAt": "2023-11-14T22:13:20Z", + "lineItems": [], + "ucp": { + "version": "2026-04-08", + "paymentHandlers": {} + } + } + """# + + let payload = try CasingTransform.decodeFromJS(camelJSON, as: TestPayload.self) + + #expect(payload.id == "chk_1") + #expect(payload.currency == "USD") + #expect(payload.continueURL == "https://example.com/continue") + #expect(payload.lineItems.isEmpty) + #expect(payload.ucp.version == "2026-04-08") + #expect(payload.ucp.paymentHandlers.isEmpty) + } + + private func makePayload() -> TestPayload { + TestPayload( + id: "chk_1", + currency: "USD", + continueURL: "https://example.com/continue", + expiresAt: Date(timeIntervalSince1970: 1_700_000_000), + lineItems: [], + ucp: TestUCP(version: "2026-04-08", paymentHandlers: [:]) + ) + } +} + +private struct TestPayload: Codable { + let id: String + let currency: String + let continueURL: String + let expiresAt: Date + let lineItems: [TestLineItem] + let ucp: TestUCP + + enum CodingKeys: String, CodingKey { + case id + case currency + case continueURL = "continue_url" + case expiresAt = "expires_at" + case lineItems = "line_items" + case ucp + } +} + +private struct TestLineItem: Codable { + let id: String + let imageURL: String + + enum CodingKeys: String, CodingKey { + case id + case imageURL = "image_url" + } +} + +private struct TestUCP: Codable { + let version: String + let paymentHandlers: [String: String] + + enum CodingKeys: String, CodingKey { + case version + case paymentHandlers = "payment_handlers" + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift new file mode 100644 index 00000000..eec25bde --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift @@ -0,0 +1,131 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +@testable import RNShopifyCheckoutKitCasingTransform +import ShopifyCheckoutProtocol +import Testing + +@Suite("Protocol Relay Tests") +struct ProtocolRelayTests { + @Test func envelopeEncodesTypeAndCamelCasePayload() throws { + let payload = SnakePayload(continueURL: "https://example.com", lineItems: []) + let envelope = DispatchEnvelope(type: "ec.start", payload: payload) + let json = try CasingTransform.encodeForJS(envelope) + + let parsed = try #require( + JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] + ) + #expect(parsed["type"] as? String == "ec.start") + + let payloadDict = try #require(parsed["payload"] as? [String: Any]) + #expect(payloadDict["continueUrl"] as? String == "https://example.com") + #expect(payloadDict["lineItems"] as? [Any] != nil) + #expect(payloadDict["continue_url"] == nil) + #expect(payloadDict["line_items"] == nil) + } + + @MainActor + @Test func relayDispatchesEnvelopeOnEcStart() async throws { + var captured: String? + let client = makeRelayClient( + subscribedMethods: ["ec.start"], + dispatch: { json in captured = json } + ) + + _ = await client.process(ecStartNotificationFixture) + + let json = try #require(captured) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + #expect(parsed["type"] as? String == "ec.start") + let payload = try #require(parsed["payload"] as? [String: Any]) + #expect(payload["id"] as? String == "checkout-123") + #expect(payload["currency"] as? String == "USD") + let lineItems = try #require(payload["lineItems"] as? [[String: Any]]) + #expect(lineItems.count == 1) + let firstItem = try #require(lineItems.first?["item"] as? [String: Any]) + #expect(firstItem["imageUrl"] as? String == "https://example.com/image.png") + } + + @MainActor + @Test func relayIgnoresMethodsNotInSubscribedList() async throws { + var captured: String? + let client = makeRelayClient( + subscribedMethods: [], + dispatch: { json in captured = json } + ) + + _ = await client.process(ecStartNotificationFixture) + + #expect(captured == nil) + } +} + +private struct SnakePayload: Codable { + let continueURL: String + let lineItems: [String] + + enum CodingKeys: String, CodingKey { + case continueURL = "continue_url" + case lineItems = "line_items" + } +} + +private let ecStartNotificationFixture = #""" +{ + "jsonrpc": "2.0", + "method": "ec.start", + "params": { + "checkout": { + "ucp": { + "version": "2026-04-08", + "payment_handlers": {} + }, + "id": "checkout-123", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li-1", + "quantity": 1, + "item": { + "id": "product-1", + "title": "Test Product", + "price": 2999, + "image_url": "https://example.com/image.png" + }, + "totals": [ + {"type": "subtotal", "amount": 2999} + ] + } + ], + "totals": [ + {"type": "total", "amount": 2999} + ], + "links": [ + {"type": "privacy_policy", "url": "https://example.com/privacy"} + ] + } + } +} +"""# diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index ad79fa29..a9b839fa 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts @@ -51,6 +51,11 @@ import type { AcceleratedCheckoutButtonsProps, RenderStateChangeEvent, } from './components/AcceleratedCheckoutButtons'; +import {CheckoutProtocol} from './protocol'; +import type { + CheckoutProtocolPayloads, + ProtocolHandlers, +} from './protocol'; const defaultFeatures: Features = { handleGeolocationRequests: true, @@ -130,8 +135,14 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * @param checkoutUrl The URL of the checkout to display * @param callbacks Optional per-call SDK callbacks */ - public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { - RNShopifyCheckoutKit.present(checkoutUrl, this.buildDispatcher(callbacks)); + public present( + checkoutUrl: string, + callbacks?: PresentCallbacks, + protocol?: ProtocolHandlers, + ): void { + const dispatcher = this.buildDispatcher(callbacks, protocol); + const subscribedMethods = Object.keys(protocol ?? {}); + RNShopifyCheckoutKit.present(checkoutUrl, subscribedMethods, dispatcher); } /** @@ -306,12 +317,16 @@ class ShopifyCheckout implements ShopifyCheckoutKit { */ private buildDispatcher( callbacks: PresentCallbacks | undefined, + protocol: ProtocolHandlers | undefined, ): ((envelopeJson: string) => void) | null { const needsDefaultGeolocation = Platform.OS === 'android' && this.featureEnabled('handleGeolocationRequests'); - if (!callbacks && !needsDefaultGeolocation) { + const hasProtocolHandlers = + protocol != null && Object.keys(protocol).length > 0; + + if (!callbacks && !needsDefaultGeolocation && !hasProtocolHandlers) { return null; } @@ -349,8 +364,16 @@ class ShopifyCheckout implements ShopifyCheckoutKit { this.handleDefaultGeolocationRequest(); } return; - default: + default: { + const method = envelope.type as keyof CheckoutProtocolPayloads; + const handler = protocol?.[method]; + if (handler) { + handler( + envelope.payload as CheckoutProtocolPayloads[typeof method], + ); + } return; + } } }; } @@ -467,6 +490,7 @@ export { ApplePayContactField, ApplePayLabel, ApplePayStyle, + CheckoutProtocol, ColorScheme, LogLevel, ShopifyCheckout, @@ -491,10 +515,12 @@ export type { AcceleratedCheckoutButtonsProps, AcceleratedCheckoutConfiguration, CheckoutException, + CheckoutProtocolPayloads, Configuration, Features, GeolocationRequestEvent, PresentCallbacks, + ProtocolHandlers, RenderStateChangeEvent, }; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts new file mode 100644 index 00000000..50617155 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts @@ -0,0 +1,38 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import type {Checkout} from '@shopify/checkout-kit-protocol'; + +export const CheckoutProtocol = { + start: 'ec.start', +} as const; + +export interface CheckoutProtocolPayloads { + 'ec.start': Checkout; +} + +export type ProtocolHandlers = Partial<{ + [K in keyof CheckoutProtocolPayloads]: ( + payload: CheckoutProtocolPayloads[K], + ) => void; +}>; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts index 914f912f..92a9dc38 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts @@ -74,6 +74,7 @@ type ConfigurationResultSpec = { export interface Spec extends TurboModule { present( checkoutUrl: string, + subscribedMethods: string[], dispatch: ((envelopeJson: string) => void) | null, ): void; preload(checkoutUrl: string): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx index 3451a6e4..793b4201 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx @@ -172,6 +172,7 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, + [], null, ); }); @@ -198,6 +199,7 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, + [], expect.any(Function), ); }); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts index 9d993b12..c1f14ac7 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts @@ -16,6 +16,7 @@ import { LogLevel, ColorScheme, CheckoutNativeErrorType, + CheckoutProtocol, type Configuration, type AcceleratedCheckoutConfiguration, } from '../src'; @@ -68,7 +69,7 @@ type Dispatch = (envelopeJson: string) => void; function lastDispatch(): Dispatch { const dispatch = NativeModule.present.mock.calls[ NativeModule.present.mock.calls.length - 1 - ][1] as Dispatch | null; + ][2] as Dispatch | null; if (!dispatch) { throw new Error( 'Expected the last present() call to receive a non-null dispatcher', @@ -153,7 +154,7 @@ describe('ShopifyCheckoutKit', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); expect(NativeModule.present).toHaveBeenCalledTimes(1); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, [], null); }); it('calls `present` with a dispatcher when callbacks are provided', () => { @@ -161,6 +162,7 @@ describe('ShopifyCheckoutKit', () => { instance.present(checkoutUrl, {onClose: jest.fn()}); expect(NativeModule.present).toHaveBeenCalledWith( checkoutUrl, + [], expect.any(Function), ); }); @@ -286,6 +288,84 @@ describe('ShopifyCheckoutKit', () => { }); }); + describe('protocol handlers', () => { + const startPayload = { + id: 'chk_123', + currency: 'USD', + lineItems: [], + links: [], + status: 'active', + totals: [], + ucp: {}, + }; + + it('routes envelope.type via the protocol handler map', () => { + const instance = new ShopifyCheckout(); + const onStart = jest.fn(); + instance.present(checkoutUrl, undefined, { + [CheckoutProtocol.start]: onStart, + }); + lastDispatch()( + JSON.stringify({type: CheckoutProtocol.start, payload: startPayload}), + ); + expect(onStart).toHaveBeenCalledTimes(1); + expect(onStart).toHaveBeenCalledWith(startPayload); + expect(onStart.mock.calls[0][0].id).toBe('chk_123'); + }); + + it('passes subscribedMethods to native present()', () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl, undefined, { + [CheckoutProtocol.start]: jest.fn(), + }); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + [CheckoutProtocol.start], + expect.any(Function), + ); + }); + + it('still routes existing close/fail/geolocationRequest cases alongside protocol handlers', () => { + Platform.OS = 'ios'; + const instance = new ShopifyCheckout(); + const onClose = jest.fn(); + const onFail = jest.fn(); + const onGeolocationRequest = jest.fn(); + const onStart = jest.fn(); + instance.present( + checkoutUrl, + {onClose, onFail, onGeolocationRequest}, + {[CheckoutProtocol.start]: onStart}, + ); + const dispatch = lastDispatch(); + dispatch(JSON.stringify({type: 'close'})); + dispatch( + JSON.stringify({ + type: 'fail', + payload: { + __typename: CheckoutNativeErrorType.InternalError, + message: 'boom', + code: CheckoutErrorCode.unknown, + recoverable: true, + }, + }), + ); + dispatch( + JSON.stringify({ + type: 'geolocationRequest', + payload: {origin: 'https://shopify.com'}, + }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onFail).toHaveBeenCalledTimes(1); + expect(onFail.mock.calls[0][0]).toBeInstanceOf(InternalError); + expect(onGeolocationRequest).toHaveBeenCalledWith({ + origin: 'https://shopify.com', + }); + expect(onStart).not.toHaveBeenCalled(); + }); + }); + describe('envelope parsing', () => { it('logs a LifecycleEventParseError when the envelope is invalid JSON', () => { const instance = new ShopifyCheckout(); @@ -363,6 +443,7 @@ describe('ShopifyCheckoutKit', () => { instance.present(checkoutUrl); expect(NativeModule.present).toHaveBeenCalledWith( checkoutUrl, + [], expect.any(Function), ); }); @@ -372,7 +453,7 @@ describe('ShopifyCheckoutKit', () => { handleGeolocationRequests: false, }); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, [], null); }); it('handles geolocation permission grant correctly', async () => { @@ -472,7 +553,7 @@ describe('ShopifyCheckoutKit', () => { it('passes a null dispatcher by default — no default geolocation handling on iOS', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, [], null); }); it('does not run the default geolocation handler on iOS even if dispatcher fires', async () => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts new file mode 100644 index 00000000..c7e44a8f --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts @@ -0,0 +1,38 @@ +import {CheckoutProtocol, type ProtocolHandlers} from '../src'; +import type {Checkout} from '@shopify/checkout-kit-protocol'; + +describe('CheckoutProtocol', () => { + describe('runtime values', () => { + it('exposes ec.start as the literal method string', () => { + expect(CheckoutProtocol.start).toBe('ec.start'); + }); + }); + + describe('ProtocolHandlers typing', () => { + it('accepts a handler keyed by CheckoutProtocol.start', () => { + const handlers: ProtocolHandlers = { + [CheckoutProtocol.start]: chk => { + expect(typeof chk.id).toBe('string'); + }, + }; + + expect(typeof handlers[CheckoutProtocol.start]).toBe('function'); + }); + + it('infers Checkout as the start handler payload type', () => { + type StartHandler = NonNullable; + type StartParam = Parameters[0]; + + const _typeCheck: Checkout extends StartParam ? true : false = true; + const _reverseCheck: StartParam extends Checkout ? true : false = true; + + expect(_typeCheck).toBe(true); + expect(_reverseCheck).toBe(true); + }); + + it('accepts an empty handlers map', () => { + const empty: ProtocolHandlers = {}; + expect(empty).toEqual({}); + }); + }); +});