From e746744e0bf3617b76c9c551d617d0946f41b0e1 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 12:36:40 +0100 Subject: [PATCH] Add the E2E control link parser to all four sample apps The E2E suite drives four apps: Swift iOS, Kotlin Android, React Native iOS and React Native Android. Only React Native could read a deep link, and only for a cart. Widen the contract to ://e2e/ with the commands reset, cart and signIn, and port the parser to Swift and Kotlin so one Maestro flow drives every app. Every platform rewrites the scheme to https before parsing. Neither Foundation URLComponents nor java.net.URI accepts the underscores in the Android application id, and the React Native URL polyfill parses a host only for http(s). A test per platform pins that behaviour with both app schemes. The parser stays pure and keeps three outcomes: nil when the link belongs to another feature, a parsed command, or an error that names the bad parameter. A parameter that is present must carry a valid value. Co-Authored-By: Claude Opus 5 (1M context) --- .../androiddemo/e2e/E2EControlLink.kt | 166 ++++++++++++++ .../androiddemo/e2e/E2EControlLinkTest.kt | 153 +++++++++++++ .../app/src/main/AndroidManifest.template.xml | 3 +- .../src/e2e/__tests__/androidManifest.test.ts | 20 ++ .../src/e2e/__tests__/cartBootstrap.test.ts | 104 --------- .../src/e2e/__tests__/controlLink.test.ts | 174 ++++++++++++++ .../sample/src/e2e/cartBootstrap.ts | 103 --------- .../sample/src/e2e/controlLink.ts | 201 +++++++++++++++++ .../sample/src/e2e/useE2ECartBootstrap.ts | 25 ++- .../react-native/scripts/e2e_maestro_android | 2 +- .../react-native/scripts/e2e_maestro_ios | 2 +- .../Sources/E2E/E2EControlLink.swift | 212 ++++++++++++++++++ .../E2E/E2EControlLinkTests.swift | 144 ++++++++++++ 13 files changed, 1090 insertions(+), 219 deletions(-) create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLink.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkTest.kt create mode 100644 platforms/react-native/sample/src/e2e/__tests__/androidManifest.test.ts delete mode 100644 platforms/react-native/sample/src/e2e/__tests__/cartBootstrap.test.ts create mode 100644 platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts delete mode 100644 platforms/react-native/sample/src/e2e/cartBootstrap.ts create mode 100644 platforms/react-native/sample/src/e2e/controlLink.ts create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLink.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLink.kt new file mode 100644 index 000000000..3b4e154b1 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLink.kt @@ -0,0 +1,166 @@ +package com.shopify.checkoutkit.androiddemo.e2e + +import java.net.URI +import java.net.URISyntaxException +import java.net.URLDecoder + +enum class E2EBuyerIdentityMode(val parameterValue: String) { + GUEST("guest"), + HARDCODED("hardcoded"), + CUSTOMER_ACCOUNT("customerAccount"), + ; + + companion object { + fun from(parameterValue: String) = entries.firstOrNull { it.parameterValue == parameterValue } + } +} + +sealed interface E2EControlLink { + data object Reset : E2EControlLink + + data class Cart( + val variantId: String? = null, + val productIndex: Int? = null, + val quantity: Int = 1, + val buyerIdentityMode: E2EBuyerIdentityMode? = null, + ) : E2EControlLink + + data class SignIn(val email: String? = null) : E2EControlLink + + companion object { + const val HOST = "e2e" + + private const val SCHEME_SEPARATOR = "://" + private const val PARSE_ORIGIN_SCHEME = "https://" + + private val RESET_PARAMETERS = emptySet() + private val CART_PARAMETERS = setOf("variantId", "productIndex", "quantity", "buyerIdentityMode") + private val SIGN_IN_PARAMETERS = setOf("email") + + fun parse(url: String): E2EControlLink? { + val separatorIndex = url.indexOf(SCHEME_SEPARATOR) + + if (separatorIndex < 0) { + return null + } + + val authorityAndPath = url.substring(separatorIndex + SCHEME_SEPARATOR.length) + val uri = try { + URI(PARSE_ORIGIN_SCHEME + authorityAndPath) + } catch (error: URISyntaxException) { + return null + } + + if (uri.host != HOST) { + return null + } + + val parameters = parameters(uri.rawQuery) + + return when (uri.path.orEmpty().trim('/')) { + "reset" -> { + rejectUnknownParameters("reset", parameters, RESET_PARAMETERS) + + Reset + } + + "cart" -> { + rejectUnknownParameters("cart", parameters, CART_PARAMETERS) + + cart(parameters) + } + + "signIn" -> { + rejectUnknownParameters("signIn", parameters, SIGN_IN_PARAMETERS) + + SignIn(email = signInEmail(parameters)) + } + + else -> throw IllegalArgumentException("Unsupported e2e command") + } + } + + private fun rejectUnknownParameters( + command: String, + parameters: Map, + allowed: Set, + ) { + val unknown = parameters.keys.filterNot { allowed.contains(it) }.sorted() + + require(unknown.isEmpty()) { "Unknown $command parameters: ${unknown.joinToString(", ")}" } + } + + private fun cart(parameters: Map): Cart { + require(parameters.isNotEmpty()) { "Missing variantId or productIndex" } + + val quantity = quantity(parameters) + val buyerIdentityMode = buyerIdentityMode(parameters) + val variantId = parameters["variantId"] + val productIndexParameter = parameters["productIndex"] + + require(variantId == null || productIndexParameter == null) { + "Use variantId or productIndex, not both" + } + + if (variantId != null) { + require(variantId.isNotEmpty()) { "variantId must not be blank" } + + return Cart(variantId = variantId, quantity = quantity, buyerIdentityMode = buyerIdentityMode) + } + + requireNotNull(productIndexParameter) { "Missing variantId or productIndex" } + + val productIndex = productIndexParameter.toIntOrNull() + + require(productIndex != null && productIndex >= 0) { "productIndex must be a non-negative integer" } + + return Cart(productIndex = productIndex, quantity = quantity, buyerIdentityMode = buyerIdentityMode) + } + + private fun quantity(parameters: Map): Int { + val parameter = parameters["quantity"] ?: return 1 + val quantity = parameter.toIntOrNull() + + require(quantity != null && quantity >= 1) { "quantity must be a positive integer" } + + return quantity + } + + private fun buyerIdentityMode(parameters: Map): E2EBuyerIdentityMode? { + val parameter = parameters["buyerIdentityMode"] ?: return null + + return requireNotNull(E2EBuyerIdentityMode.from(parameter)) { + "buyerIdentityMode must be guest, hardcoded, or customerAccount" + } + } + + private fun signInEmail(parameters: Map): String? { + val email = parameters["email"] ?: return null + + require(email.isNotEmpty()) { "email must not be blank" } + + return email + } + + private fun parameters(rawQuery: String?): Map { + if (rawQuery.isNullOrEmpty()) { + return emptyMap() + } + + return rawQuery + .split("&") + .filter { it.isNotEmpty() } + .associate { pair -> + val separatorIndex = pair.indexOf('=') + + if (separatorIndex < 0) { + decode(pair) to "" + } else { + decode(pair.substring(0, separatorIndex)) to decode(pair.substring(separatorIndex + 1)) + } + } + } + + private fun decode(value: String) = URLDecoder.decode(value, "UTF-8").trim() + } +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkTest.kt new file mode 100644 index 000000000..1e3d1a193 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkTest.kt @@ -0,0 +1,153 @@ +package com.shopify.checkoutkit.androiddemo.e2e + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test + +class E2EControlLinkTest { + private val rejectedNumbers = + listOf("", "1.5", "abc", "1e3", "0x10", "0b11", "0o17", "2147483648", "99999999999999999999") + + @Test + fun `returns null when the link is not a control link`() { + assertThat(E2EControlLink.parse("https://example.com/cart")).isNull() + assertThat(E2EControlLink.parse("com.shopify.checkoutkit.androiddemo://products/1")).isNull() + assertThat(E2EControlLink.parse("not a url")).isNull() + } + + @Test + fun `parses every app scheme`() { + val expected = E2EControlLink.Cart(productIndex = 0) + + assertThat(E2EControlLink.parse("com.shopify.checkoutkit.androiddemo://e2e/cart?productIndex=0")).isEqualTo(expected) + assertThat(E2EControlLink.parse("com.shopify.checkoutkit.swiftdemo://e2e/cart?productIndex=0")).isEqualTo(expected) + } + + @Test + fun `parses the reset command`() { + assertThat(parse("/reset")).isEqualTo(E2EControlLink.Reset) + } + + @Test + fun `rejects unknown parameters`() { + assertRejects("/reset?productIndex=0", "Unknown reset parameters: productIndex") + assertRejects("/cart?productIndex=0&quantitiy=5", "Unknown cart parameters: quantitiy") + assertRejects("/cart?productIndx=0", "Unknown cart parameters: productIndx") + assertRejects("/cart?productIndex=0&foo=1&bar=2", "Unknown cart parameters: bar, foo") + assertRejects("/signIn?emial=shopper@example.com", "Unknown signIn parameters: emial") + } + + @Test + fun `rejects unknown commands`() { + assertRejects("", "Unsupported e2e command") + assertRejects("/", "Unsupported e2e command") + assertRejects("/teleport?productIndex=0", "Unsupported e2e command") + assertRejects("/cart/extra?productIndex=0", "Unsupported e2e command") + } + + @Test + fun `rejects cart commands without a product selector`() { + assertRejects("/cart", "Missing variantId or productIndex") + assertRejects("/cart?", "Missing variantId or productIndex") + assertRejects("/cart?quantity=2", "Missing variantId or productIndex") + } + + @Test + fun `rejects cart commands with both product selectors`() { + assertRejects( + "/cart?variantId=gid://shopify/ProductVariant/1&productIndex=0", + "Use variantId or productIndex, not both", + ) + } + + @Test + fun `rejects a blank variant id`() { + assertRejects("/cart?variantId=", "variantId must not be blank") + assertRejects("/cart?variantId=%20", "variantId must not be blank") + assertRejects("/cart?variantId=%0A", "variantId must not be blank") + } + + @Test + fun `rejects invalid quantities`() { + (rejectedNumbers + listOf("0", "-1")).forEach { quantity -> + assertRejects("/cart?productIndex=0&quantity=$quantity", "quantity must be a positive integer") + } + } + + @Test + fun `rejects invalid product indexes`() { + (rejectedNumbers + "-1").forEach { productIndex -> + assertRejects("/cart?productIndex=$productIndex", "productIndex must be a non-negative integer") + } + } + + @Test + fun `rejects invalid buyer identity modes`() { + listOf("", "member").forEach { buyerIdentityMode -> + assertRejects( + "/cart?productIndex=0&buyerIdentityMode=$buyerIdentityMode", + "buyerIdentityMode must be guest, hardcoded, or customerAccount", + ) + } + } + + @Test + fun `parses a cart command with a variant id`() { + val link = parse("/cart?variantId=gid://shopify/ProductVariant/1&quantity=2&buyerIdentityMode=guest") + + assertThat(link).isEqualTo( + E2EControlLink.Cart( + variantId = "gid://shopify/ProductVariant/1", + quantity = 2, + buyerIdentityMode = E2EBuyerIdentityMode.GUEST, + ), + ) + } + + @Test + fun `parses a cart command with a product index and the default quantity`() { + val link = parse("/cart?productIndex=3&buyerIdentityMode=hardcoded") + + assertThat(link).isEqualTo( + E2EControlLink.Cart(productIndex = 3, quantity = 1, buyerIdentityMode = E2EBuyerIdentityMode.HARDCODED), + ) + } + + @Test + fun `parses a cart command with a trailing slash`() { + assertThat(parse("/cart/?productIndex=3")).isEqualTo(E2EControlLink.Cart(productIndex = 3)) + } + + @Test + fun `parses a sign in command without an email`() { + assertThat(parse("/signIn")).isEqualTo(E2EControlLink.SignIn()) + } + + @Test + fun `parses a sign in command with an email`() { + assertThat(parse("/signIn?email=shopper%2Be2e@example.com")) + .isEqualTo(E2EControlLink.SignIn(email = "shopper+e2e@example.com")) + } + + @Test + fun `decodes a literal plus in a query value as a space`() { + assertThat(parse("/signIn?email=shopper+e2e@example.com")) + .isEqualTo(E2EControlLink.SignIn(email = "shopper e2e@example.com")) + } + + @Test + fun `rejects a blank sign in email`() { + assertRejects("/signIn?email=", "email must not be blank") + assertRejects("/signIn?email=%20", "email must not be blank") + assertRejects("/signIn?email=%0A", "email must not be blank") + } + + private fun parse(path: String) = E2EControlLink.parse("com.shopify.checkoutkit.androiddemo://e2e$path") + + private fun assertRejects(path: String, message: String) { + assertThatThrownBy { parse(path) } + .describedAs(path) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessage(message) + } +} diff --git a/platforms/react-native/sample/android/app/src/main/AndroidManifest.template.xml b/platforms/react-native/sample/android/app/src/main/AndroidManifest.template.xml index e0d538711..66d1050ce 100644 --- a/platforms/react-native/sample/android/app/src/main/AndroidManifest.template.xml +++ b/platforms/react-native/sample/android/app/src/main/AndroidManifest.template.xml @@ -41,7 +41,8 @@ - + + diff --git a/platforms/react-native/sample/src/e2e/__tests__/androidManifest.test.ts b/platforms/react-native/sample/src/e2e/__tests__/androidManifest.test.ts new file mode 100644 index 000000000..6d87b37f4 --- /dev/null +++ b/platforms/react-native/sample/src/e2e/__tests__/androidManifest.test.ts @@ -0,0 +1,20 @@ +import {readFileSync} from 'fs'; +import {join} from 'path'; +import {CONTROL_LINK_HOST} from '../controlLink'; + +const APP_SCHEME = 'com.shopify.checkoutkit.reactnativedemo'; +const TEMPLATE_PATH = join( + __dirname, + '../../../android/app/src/main/AndroidManifest.template.xml', +); + +describe('AndroidManifest.template.xml', () => { + it('registers the control link host that the parser accepts', () => { + const template = readFileSync(TEMPLATE_PATH, 'utf8'); + const filter = new RegExp( + ``, + ); + + expect(template.match(filter)?.[1]).toBe(CONTROL_LINK_HOST); + }); +}); diff --git a/platforms/react-native/sample/src/e2e/__tests__/cartBootstrap.test.ts b/platforms/react-native/sample/src/e2e/__tests__/cartBootstrap.test.ts deleted file mode 100644 index 09811c03d..000000000 --- a/platforms/react-native/sample/src/e2e/__tests__/cartBootstrap.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import {CART_BOOTSTRAP_ROUTE, parseCartBootstrapLink} from '../cartBootstrap'; -import {BuyerIdentityMode} from '../../auth/types'; - -describe('parseCartBootstrapLink', () => { - it('ignores non-cart-bootstrap URLs', () => { - expect(parseCartBootstrapLink('https://example.com/cart')).toBeNull(); - }); - - it('rejects unsupported bootstrap routes', () => { - expect(() => - parseCartBootstrapLink( - 'com.shopify.checkoutkit.reactnativedemo://account?productIndex=0', - ), - ).toThrow('Unsupported cart bootstrap path'); - }); - - it('rejects bootstrap links without a query string', () => { - expect(() => parseCartBootstrapLink(CART_BOOTSTRAP_ROUTE)).toThrow( - 'Missing variantId or productIndex', - ); - }); - - it.each(['0', '-1', '1.5', 'abc'])( - 'rejects invalid quantity %s', - quantity => { - expect(() => - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?productIndex=0&quantity=${quantity}`, - ), - ).toThrow('quantity must be a positive integer'); - }, - ); - - it('rejects links with both variantId and productIndex', () => { - expect(() => - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?variantId=gid://shopify/ProductVariant/1&productIndex=0`, - ), - ).toThrow('Use variantId or productIndex, not both'); - }); - - it('rejects links without variantId or productIndex', () => { - expect(() => parseCartBootstrapLink(`${CART_BOOTSTRAP_ROUTE}?`)).toThrow( - 'Missing variantId or productIndex', - ); - }); - - it.each(['-1', '1.5', 'abc'])( - 'rejects invalid productIndex %s', - productIndex => { - expect(() => - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?productIndex=${productIndex}`, - ), - ).toThrow('productIndex must be a non-negative integer'); - }, - ); - - it.each(['', 'member'])( - 'rejects invalid buyerIdentityMode %s', - buyerIdentityMode => { - expect(() => - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?productIndex=0&buyerIdentityMode=${buyerIdentityMode}`, - ), - ).toThrow( - 'buyerIdentityMode must be guest, hardcoded, or customerAccount', - ); - }, - ); - - it('returns a variantId bootstrap link', () => { - expect( - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?variantId=gid://shopify/ProductVariant/1&quantity=2&buyerIdentityMode=guest`, - ), - ).toEqual({ - variantId: 'gid://shopify/ProductVariant/1', - quantity: 2, - buyerIdentityMode: BuyerIdentityMode.Guest, - }); - }); - - it('returns a productIndex bootstrap link with default quantity', () => { - expect( - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?productIndex=3&buyerIdentityMode=hardcoded`, - ), - ).toEqual({ - productIndex: 3, - quantity: 1, - buyerIdentityMode: BuyerIdentityMode.Hardcoded, - }); - }); - - it('returns a productIndex bootstrap link with a root path', () => { - expect( - parseCartBootstrapLink(`${CART_BOOTSTRAP_ROUTE}/?productIndex=3`), - ).toEqual({ - productIndex: 3, - quantity: 1, - }); - }); -}); diff --git a/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts new file mode 100644 index 000000000..930cb5e9b --- /dev/null +++ b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts @@ -0,0 +1,174 @@ +import {parseControlLink} from '../controlLink'; +import {BuyerIdentityMode} from '../../auth/types'; + +const REJECTED_NUMBERS = [ + '', + '1.5', + 'abc', + '1e3', + '0x10', + '0b11', + '0o17', + '2147483648', + '99999999999999999999', +]; + +function parse(path: string) { + return parseControlLink(`com.shopify.checkoutkit.reactnativedemo://e2e${path}`); +} + +function expectRejection(path: string, message: string) { + expect(() => parse(path)).toThrow(message); +} + +describe('parseControlLink', () => { + it('returns null when the link is not a control link', () => { + expect(parseControlLink('https://example.com/cart')).toBeNull(); + expect( + parseControlLink('com.shopify.checkoutkit.reactnativedemo://products/1'), + ).toBeNull(); + expect(parseControlLink('not a url')).toBeNull(); + }); + + it('parses every app scheme', () => { + const expected = {command: 'cart', productIndex: 0, quantity: 1}; + + expect( + parseControlLink( + 'com.shopify.checkoutkit.reactnativedemo://e2e/cart?productIndex=0', + ), + ).toEqual(expected); + expect( + parseControlLink( + 'com.shopify.checkoutkit.androiddemo://e2e/cart?productIndex=0', + ), + ).toEqual(expected); + }); + + it('parses the reset command', () => { + expect(parse('/reset')).toEqual({command: 'reset'}); + }); + + it.each([ + ['/reset?productIndex=0', 'Unknown reset parameters: productIndex'], + ['/cart?productIndex=0&quantitiy=5', 'Unknown cart parameters: quantitiy'], + ['/cart?productIndx=0', 'Unknown cart parameters: productIndx'], + ['/cart?productIndex=0&foo=1&bar=2', 'Unknown cart parameters: bar, foo'], + ['/signIn?emial=shopper@example.com', 'Unknown signIn parameters: emial'], + ])('rejects unknown parameters in %s', (path, message) => { + expectRejection(path, message); + }); + + it.each(['', '/', '/teleport?productIndex=0', '/cart/extra?productIndex=0'])( + 'rejects the unknown command %s', + path => { + expectRejection(path, 'Unsupported e2e command'); + }, + ); + + it.each(['/cart', '/cart?', '/cart?quantity=2'])( + 'rejects the cart command %s without a product selector', + path => { + expectRejection(path, 'Missing variantId or productIndex'); + }, + ); + + it('rejects cart commands with both product selectors', () => { + expectRejection( + '/cart?variantId=gid://shopify/ProductVariant/1&productIndex=0', + 'Use variantId or productIndex, not both', + ); + }); + + it.each(['/cart?variantId=', '/cart?variantId=%20', '/cart?variantId=%0A'])( + 'rejects the blank variantId in %s', + path => { + expectRejection(path, 'variantId must not be blank'); + }, + ); + + it.each([...REJECTED_NUMBERS, '0', '-1'])( + 'rejects the invalid quantity %s', + quantity => { + expectRejection( + `/cart?productIndex=0&quantity=${quantity}`, + 'quantity must be a positive integer', + ); + }, + ); + + it.each([...REJECTED_NUMBERS, '-1'])( + 'rejects the invalid productIndex %s', + productIndex => { + expectRejection( + `/cart?productIndex=${productIndex}`, + 'productIndex must be a non-negative integer', + ); + }, + ); + + it.each(['', 'member'])( + 'rejects the invalid buyerIdentityMode %s', + buyerIdentityMode => { + expectRejection( + `/cart?productIndex=0&buyerIdentityMode=${buyerIdentityMode}`, + 'buyerIdentityMode must be guest, hardcoded, or customerAccount', + ); + }, + ); + + it('parses a cart command with a variant id', () => { + expect( + parse( + '/cart?variantId=gid://shopify/ProductVariant/1&quantity=2&buyerIdentityMode=guest', + ), + ).toEqual({ + command: 'cart', + variantId: 'gid://shopify/ProductVariant/1', + quantity: 2, + buyerIdentityMode: BuyerIdentityMode.Guest, + }); + }); + + it('parses a cart command with a product index and the default quantity', () => { + expect(parse('/cart?productIndex=3&buyerIdentityMode=hardcoded')).toEqual({ + command: 'cart', + productIndex: 3, + quantity: 1, + buyerIdentityMode: BuyerIdentityMode.Hardcoded, + }); + }); + + it('parses a cart command with a trailing slash', () => { + expect(parse('/cart/?productIndex=3')).toEqual({ + command: 'cart', + productIndex: 3, + quantity: 1, + }); + }); + + it('parses a sign in command without an email', () => { + expect(parse('/signIn')).toEqual({command: 'signIn'}); + }); + + it('parses a sign in command with an email', () => { + expect(parse('/signIn?email=shopper%2Be2e@example.com')).toEqual({ + command: 'signIn', + email: 'shopper+e2e@example.com', + }); + }); + + it('decodes a literal plus in a query value as a space', () => { + expect(parse('/signIn?email=shopper+e2e@example.com')).toEqual({ + command: 'signIn', + email: 'shopper e2e@example.com', + }); + }); + + it.each(['/signIn?email=', '/signIn?email=%20', '/signIn?email=%0A'])( + 'rejects the blank sign in email in %s', + path => { + expectRejection(path, 'email must not be blank'); + }, + ); +}); diff --git a/platforms/react-native/sample/src/e2e/cartBootstrap.ts b/platforms/react-native/sample/src/e2e/cartBootstrap.ts deleted file mode 100644 index 2148770c8..000000000 --- a/platforms/react-native/sample/src/e2e/cartBootstrap.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {BuyerIdentityMode} from '../auth/types'; - -export const CART_BOOTSTRAP_SCHEME = 'com.shopify.checkoutkit.reactnativedemo:'; -const CART_BOOTSTRAP_HOST = 'cart'; -export const CART_BOOTSTRAP_ROUTE = `${CART_BOOTSTRAP_SCHEME}//cart`; -const CART_BOOTSTRAP_PARSE_ORIGIN = `https://${CART_BOOTSTRAP_HOST}`; -const CART_BOOTSTRAP_ROOT_PATH = '/'; - -export type CartBootstrapLink = { - variantId?: string; - productIndex?: number; - quantity: number; - buyerIdentityMode?: BuyerIdentityMode; -}; - -function isBuyerIdentityMode(value: string): value is BuyerIdentityMode { - return Object.values(BuyerIdentityMode).includes(value as BuyerIdentityMode); -} - -export function parseCartBootstrapLink(url: string): CartBootstrapLink | null { - if (!url.startsWith(CART_BOOTSTRAP_SCHEME)) { - return null; - } - - if (!url.startsWith(CART_BOOTSTRAP_ROUTE)) { - throw new Error('Unsupported cart bootstrap path'); - } - - const routeSuffix = url.slice(CART_BOOTSTRAP_ROUTE.length); - - if ( - routeSuffix && - !routeSuffix.startsWith('?') && - !routeSuffix.startsWith('/') - ) { - throw new Error('Unsupported cart bootstrap path'); - } - - let parsedUrl: URL; - try { - // React Native's URL host/path parsing only works for http(s) URLs. - parsedUrl = new URL(`${CART_BOOTSTRAP_PARSE_ORIGIN}${routeSuffix}`); - } catch { - throw new Error('Unsupported cart bootstrap path'); - } - - if ( - parsedUrl.hostname !== CART_BOOTSTRAP_HOST || - parsedUrl.pathname !== CART_BOOTSTRAP_ROOT_PATH - ) { - throw new Error('Unsupported cart bootstrap path'); - } - - if (!parsedUrl.search) { - throw new Error('Missing variantId or productIndex'); - } - - const searchParams = parsedUrl.searchParams; - const variantId = searchParams.get('variantId')?.trim(); - const productIndexParam = searchParams.get('productIndex')?.trim(); - const buyerIdentityModeParam = searchParams.get('buyerIdentityMode')?.trim(); - let buyerIdentityMode: BuyerIdentityMode | undefined; - - const quantityParam = searchParams.get('quantity') ?? '1'; - const quantity = Number(quantityParam); - - if (!Number.isInteger(quantity) || quantity < 1) { - throw new Error('quantity must be a positive integer'); - } - - if (searchParams.has('buyerIdentityMode')) { - if ( - !buyerIdentityModeParam || - !isBuyerIdentityMode(buyerIdentityModeParam) - ) { - throw new Error( - 'buyerIdentityMode must be guest, hardcoded, or customerAccount', - ); - } - - buyerIdentityMode = buyerIdentityModeParam; - } - - if (variantId && productIndexParam) { - throw new Error('Use variantId or productIndex, not both'); - } - - if (variantId) { - return {variantId, quantity, buyerIdentityMode}; - } - - if (!productIndexParam) { - throw new Error('Missing variantId or productIndex'); - } - - const productIndex = Number(productIndexParam); - - if (!Number.isInteger(productIndex) || productIndex < 0) { - throw new Error('productIndex must be a non-negative integer'); - } - - return {productIndex, quantity, buyerIdentityMode}; -} diff --git a/platforms/react-native/sample/src/e2e/controlLink.ts b/platforms/react-native/sample/src/e2e/controlLink.ts new file mode 100644 index 000000000..9761ff91f --- /dev/null +++ b/platforms/react-native/sample/src/e2e/controlLink.ts @@ -0,0 +1,201 @@ +import {BuyerIdentityMode} from '../auth/types'; + +export const CONTROL_LINK_HOST = 'e2e'; +const SCHEME_SEPARATOR = '://'; +const PARSE_ORIGIN_SCHEME = 'https://'; +const DECIMAL_DIGITS = /^\d+$/; +const MAX_SIGNED_32_BIT = 2147483647; +const RESET_PARAMETERS: string[] = []; +const CART_PARAMETERS = [ + 'variantId', + 'productIndex', + 'quantity', + 'buyerIdentityMode', +]; +const SIGN_IN_PARAMETERS = ['email']; + +export type E2EResetCommand = { + command: 'reset'; +}; + +export type E2ECartCommand = { + command: 'cart'; + variantId?: string; + productIndex?: number; + quantity: number; + buyerIdentityMode?: BuyerIdentityMode; +}; + +export type E2ESignInCommand = { + command: 'signIn'; + email?: string; +}; + +export type E2EControlLink = + | E2EResetCommand + | E2ECartCommand + | E2ESignInCommand; + +type Parameters = Map; + +function isBuyerIdentityMode(value: string): value is BuyerIdentityMode { + return Object.values(BuyerIdentityMode).includes(value as BuyerIdentityMode); +} + +function parameterMap(searchParams: URLSearchParams): Parameters { + const parameters: Parameters = new Map(); + + searchParams.forEach((value, name) => { + parameters.set(name, value.trim()); + }); + + return parameters; +} + +function rejectUnknownParameters( + command: string, + parameters: Parameters, + allowed: string[], +): void { + const unknown = Array.from(parameters.keys()) + .filter(name => !allowed.includes(name)) + .sort(); + + if (unknown.length > 0) { + throw new Error(`Unknown ${command} parameters: ${unknown.join(', ')}`); + } +} + +function parseNonNegativeInteger(value: string): number | undefined { + if (!DECIMAL_DIGITS.test(value)) { + return undefined; + } + + const parsed = Number(value); + + return parsed <= MAX_SIGNED_32_BIT ? parsed : undefined; +} + +function parseQuantity(parameters: Parameters): number { + const parameter = parameters.get('quantity'); + + if (parameter === undefined) { + return 1; + } + + const quantity = parseNonNegativeInteger(parameter); + + if (quantity === undefined || quantity < 1) { + throw new Error('quantity must be a positive integer'); + } + + return quantity; +} + +function parseBuyerIdentityMode( + parameters: Parameters, +): BuyerIdentityMode | undefined { + const parameter = parameters.get('buyerIdentityMode'); + + if (parameter === undefined) { + return undefined; + } + + if (!isBuyerIdentityMode(parameter)) { + throw new Error( + 'buyerIdentityMode must be guest, hardcoded, or customerAccount', + ); + } + + return parameter; +} + +function parseCart(parameters: Parameters): E2ECartCommand { + if (parameters.size === 0) { + throw new Error('Missing variantId or productIndex'); + } + + const quantity = parseQuantity(parameters); + const buyerIdentityMode = parseBuyerIdentityMode(parameters); + const variantId = parameters.get('variantId'); + const productIndexParameter = parameters.get('productIndex'); + + if (variantId !== undefined && productIndexParameter !== undefined) { + throw new Error('Use variantId or productIndex, not both'); + } + + if (variantId !== undefined) { + if (variantId === '') { + throw new Error('variantId must not be blank'); + } + + return {command: 'cart', variantId, quantity, buyerIdentityMode}; + } + + if (productIndexParameter === undefined) { + throw new Error('Missing variantId or productIndex'); + } + + const productIndex = parseNonNegativeInteger(productIndexParameter); + + if (productIndex === undefined) { + throw new Error('productIndex must be a non-negative integer'); + } + + return {command: 'cart', productIndex, quantity, buyerIdentityMode}; +} + +function parseSignIn(parameters: Parameters): E2ESignInCommand { + const email = parameters.get('email'); + + if (email === undefined) { + return {command: 'signIn'}; + } + + if (email === '') { + throw new Error('email must not be blank'); + } + + return {command: 'signIn', email}; +} + +export function parseControlLink(url: string): E2EControlLink | null { + const separatorIndex = url.indexOf(SCHEME_SEPARATOR); + + if (separatorIndex < 0) { + return null; + } + + const authorityAndPath = url.slice(separatorIndex + SCHEME_SEPARATOR.length); + let parsedUrl: URL; + + try { + // React Native's URL host and path parsing only works for http(s) URLs. + parsedUrl = new URL(`${PARSE_ORIGIN_SCHEME}${authorityAndPath}`); + } catch { + return null; + } + + if (parsedUrl.hostname !== CONTROL_LINK_HOST) { + return null; + } + + const parameters = parameterMap(parsedUrl.searchParams); + + switch (parsedUrl.pathname.replace(/^\/+|\/+$/g, '')) { + case 'reset': + rejectUnknownParameters('reset', parameters, RESET_PARAMETERS); + + return {command: 'reset'}; + case 'cart': + rejectUnknownParameters('cart', parameters, CART_PARAMETERS); + + return parseCart(parameters); + case 'signIn': + rejectUnknownParameters('signIn', parameters, SIGN_IN_PARAMETERS); + + return parseSignIn(parameters); + default: + throw new Error('Unsupported e2e command'); + } +} diff --git a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts index e75eecd8c..6fa4bddd4 100644 --- a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts +++ b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts @@ -2,7 +2,7 @@ import {useCallback} from 'react'; import {Alert} from 'react-native'; import {useCart} from '../context/Cart'; import useShopify from '../hooks/useShopify'; -import {parseCartBootstrapLink, type CartBootstrapLink} from './cartBootstrap'; +import {parseControlLink, type E2EControlLink} from './controlLink'; type UseE2ECartBootstrapOptions = { onCartReady: () => void; @@ -19,26 +19,33 @@ export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { return useCallback( async (url: string) => { - let cartBootstrapLink: CartBootstrapLink | null = null; + let controlLink: E2EControlLink | null = null; try { - cartBootstrapLink = parseCartBootstrapLink(url); + controlLink = parseControlLink(url); } catch (error) { - Alert.alert('Invalid cart bootstrap link', errorMessage(error)); + Alert.alert('Invalid e2e control link', errorMessage(error)); return true; } - if (!cartBootstrapLink) { + if (!controlLink) { return false; } + if (controlLink.command !== 'cart') { + Alert.alert('Unsupported e2e command', controlLink.command); + return true; + } + + const cartCommand = controlLink; + try { - let {variantId} = cartBootstrapLink; + let {variantId} = cartCommand; if (!variantId) { const {data} = await fetchProducts(); const product = - data?.products.edges[cartBootstrapLink.productIndex ?? 0]?.node; + data?.products.edges[cartCommand.productIndex ?? 0]?.node; variantId = product?.variants.edges[0]?.node.id; } @@ -49,8 +56,8 @@ export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { await seedCart( variantId, - cartBootstrapLink.quantity, - cartBootstrapLink.buyerIdentityMode, + cartCommand.quantity, + cartCommand.buyerIdentityMode, ); onCartReady(); } catch (error) { diff --git a/platforms/react-native/scripts/e2e_maestro_android b/platforms/react-native/scripts/e2e_maestro_android index 3d7325086..4b06704f7 100755 --- a/platforms/react-native/scripts/e2e_maestro_android +++ b/platforms/react-native/scripts/e2e_maestro_android @@ -9,7 +9,7 @@ METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-android-metro.log" METRO_PID="" E2E_ENV_FILE="" APP_ID="com.shopify.checkoutkit.reactnativedemo" -CART_BOOTSTRAP_BASE_LINK="${APP_ID}://cart?productIndex=0&quantity=1" +CART_BOOTSTRAP_BASE_LINK="${APP_ID}://e2e/cart?productIndex=0&quantity=1" MAESTRO_FLOWS=() usage() { diff --git a/platforms/react-native/scripts/e2e_maestro_ios b/platforms/react-native/scripts/e2e_maestro_ios index c717f94fb..2f39cb1ff 100755 --- a/platforms/react-native/scripts/e2e_maestro_ios +++ b/platforms/react-native/scripts/e2e_maestro_ios @@ -9,7 +9,7 @@ METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-ios-metro.log" METRO_PID="" E2E_ENV_FILE="" APP_ID="com.shopify.checkoutkit.reactnativedemo" -CART_BOOTSTRAP_BASE_LINK="${APP_ID}://cart?productIndex=0&quantity=1" +CART_BOOTSTRAP_BASE_LINK="${APP_ID}://e2e/cart?productIndex=0&quantity=1" MAESTRO_FLOWS=() usage() { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift new file mode 100644 index 000000000..984f87bcb --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift @@ -0,0 +1,212 @@ +import Foundation + +enum E2EControlLinkError: LocalizedError, Equatable, Hashable { + case unsupportedCommand + case unknownParameters(command: String, names: [String]) + case missingProductSelector + case ambiguousProductSelector + case blankVariantId + case invalidQuantity + case invalidProductIndex + case invalidBuyerIdentityMode + case blankEmail + + var errorDescription: String? { + switch self { + case .unsupportedCommand: + return "Unsupported e2e command" + case let .unknownParameters(command, names): + return "Unknown \(command) parameters: \(names.joined(separator: ", "))" + case .missingProductSelector: + return "Missing variantId or productIndex" + case .ambiguousProductSelector: + return "Use variantId or productIndex, not both" + case .blankVariantId: + return "variantId must not be blank" + case .invalidQuantity: + return "quantity must be a positive integer" + case .invalidProductIndex: + return "productIndex must be a non-negative integer" + case .invalidBuyerIdentityMode: + return "buyerIdentityMode must be guest, hardcoded, or customerAccount" + case .blankEmail: + return "email must not be blank" + } + } +} + +enum E2EControlLink: Equatable { + case reset + case cart(CartCommand) + case signIn(email: String?) + + struct CartCommand: Equatable { + var variantId: String? + var productIndex: Int? + var quantity: Int = 1 + var buyerIdentityMode: BuyerIdentityMode? + } + + static let host = "e2e" + + private static let schemeSeparator = "://" + private static let parseOriginScheme = "https://" + + private static let resetParameters: Set = [] + private static let cartParameters: Set = ["variantId", "productIndex", "quantity", "buyerIdentityMode"] + private static let signInParameters: Set = ["email"] + + static func parse(_ url: String) throws -> E2EControlLink? { + guard let separator = url.range(of: schemeSeparator) else { + return nil + } + + let authorityAndPath = String(url[separator.upperBound...]) + + guard let components = URLComponents(string: parseOriginScheme + authorityAndPath), + components.host == host + else { + return nil + } + + let parameters = Parameters(percentEncoded: components.percentEncodedQueryItems) + + switch components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) { + case "reset": + try rejectUnknownParameters("reset", parameters, resetParameters) + return .reset + case "cart": + try rejectUnknownParameters("cart", parameters, cartParameters) + return try .cart(cartCommand(from: parameters)) + case "signIn": + try rejectUnknownParameters("signIn", parameters, signInParameters) + return try .signIn(email: signInEmail(from: parameters)) + default: + throw E2EControlLinkError.unsupportedCommand + } + } + + private static func rejectUnknownParameters( + _ command: String, + _ parameters: Parameters, + _ allowed: Set + ) throws { + let unknown = parameters.names.filter { !allowed.contains($0) }.sorted() + + guard unknown.isEmpty else { + throw E2EControlLinkError.unknownParameters(command: command, names: unknown) + } + } + + private static func cartCommand(from parameters: Parameters) throws -> CartCommand { + guard !parameters.isEmpty else { + throw E2EControlLinkError.missingProductSelector + } + + let quantity = try quantity(from: parameters) + let buyerIdentityMode = try buyerIdentityMode(from: parameters) + + if parameters.contains("variantId"), parameters.contains("productIndex") { + throw E2EControlLinkError.ambiguousProductSelector + } + + if let variantId = parameters.value("variantId") { + guard !variantId.isEmpty else { + throw E2EControlLinkError.blankVariantId + } + return CartCommand(variantId: variantId, quantity: quantity, buyerIdentityMode: buyerIdentityMode) + } + + guard let productIndexParameter = parameters.value("productIndex") else { + throw E2EControlLinkError.missingProductSelector + } + + guard let productIndex = nonNegativeInteger(from: productIndexParameter) else { + throw E2EControlLinkError.invalidProductIndex + } + + return CartCommand(productIndex: productIndex, quantity: quantity, buyerIdentityMode: buyerIdentityMode) + } + + private static func quantity(from parameters: Parameters) throws -> Int { + guard let parameter = parameters.value("quantity") else { + return 1 + } + + guard let quantity = nonNegativeInteger(from: parameter), quantity >= 1 else { + throw E2EControlLinkError.invalidQuantity + } + + return quantity + } + + private static func nonNegativeInteger(from value: String) -> Int? { + guard !value.isEmpty, value.allSatisfy({ $0.isASCII && $0.isNumber }) else { + return nil + } + + guard let parsed = Int(value), parsed <= Int(Int32.max) else { + return nil + } + + return parsed + } + + private static func buyerIdentityMode(from parameters: Parameters) throws -> BuyerIdentityMode? { + guard let parameter = parameters.value("buyerIdentityMode") else { + return nil + } + + guard let buyerIdentityMode = BuyerIdentityMode(rawValue: parameter) else { + throw E2EControlLinkError.invalidBuyerIdentityMode + } + + return buyerIdentityMode + } + + private static func signInEmail(from parameters: Parameters) throws -> String? { + guard let email = parameters.value("email") else { + return nil + } + + guard !email.isEmpty else { + throw E2EControlLinkError.blankEmail + } + + return email + } + + private struct Parameters { + private let values: [String: String] + + init(percentEncoded queryItems: [URLQueryItem]?) { + values = (queryItems ?? []).reduce(into: [:]) { result, item in + let value = Self.decode(item.value ?? "") + + result[Self.decode(item.name)] = value.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private static func decode(_ value: String) -> String { + let spaced = value.replacingOccurrences(of: "+", with: " ") + + return spaced.removingPercentEncoding ?? spaced + } + + var isEmpty: Bool { + values.isEmpty + } + + var names: [String] { + Array(values.keys) + } + + func contains(_ name: String) -> Bool { + values[name] != nil + } + + func value(_ name: String) -> String? { + values[name] + } + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift new file mode 100644 index 000000000..1c16651bf --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift @@ -0,0 +1,144 @@ +@testable import CheckoutKitSwiftDemo +import XCTest + +class E2EControlLinkTests: XCTestCase { + private static let rejectedNumbers = [ + "", "1.5", "abc", "1e3", "0x10", "0b11", "0o17", "2147483648", "99999999999999999999" + ] + + func testReturnsNilWhenTheLinkIsNotAControlLink() throws { + XCTAssertNil(try E2EControlLink.parse("https://example.com/cart")) + XCTAssertNil(try E2EControlLink.parse("com.shopify.checkoutkit.swiftdemo://products/1")) + XCTAssertNil(try E2EControlLink.parse("not a url")) + } + + func testParsesEveryAppScheme() throws { + let expected = E2EControlLink.cart(.init(productIndex: 0, quantity: 1)) + + XCTAssertEqual(try E2EControlLink.parse("com.shopify.checkoutkit.swiftdemo://e2e/cart?productIndex=0"), expected) + XCTAssertEqual(try E2EControlLink.parse("com.shopify.checkoutkit.androiddemo://e2e/cart?productIndex=0"), expected) + } + + func testParsesTheResetCommand() throws { + XCTAssertEqual(try parse("/reset"), .reset) + } + + func testRejectsUnknownParameters() { + assertThrows(.unknownParameters(command: "reset", names: ["productIndex"]), "/reset?productIndex=0") + assertThrows(.unknownParameters(command: "cart", names: ["quantitiy"]), "/cart?productIndex=0&quantitiy=5") + assertThrows(.unknownParameters(command: "cart", names: ["productIndx"]), "/cart?productIndx=0") + assertThrows(.unknownParameters(command: "cart", names: ["bar", "foo"]), "/cart?productIndex=0&foo=1&bar=2") + assertThrows(.unknownParameters(command: "signIn", names: ["emial"]), "/signIn?emial=shopper@example.com") + } + + func testRejectsUnknownCommands() { + assertThrows(.unsupportedCommand, "") + assertThrows(.unsupportedCommand, "/") + assertThrows(.unsupportedCommand, "/teleport?productIndex=0") + assertThrows(.unsupportedCommand, "/cart/extra?productIndex=0") + } + + func testRejectsCartCommandsWithoutAProductSelector() { + assertThrows(.missingProductSelector, "/cart") + assertThrows(.missingProductSelector, "/cart?") + assertThrows(.missingProductSelector, "/cart?quantity=2") + } + + func testRejectsCartCommandsWithBothProductSelectors() { + assertThrows(.ambiguousProductSelector, "/cart?variantId=gid://shopify/ProductVariant/1&productIndex=0") + } + + func testRejectsABlankVariantId() { + assertThrows(.blankVariantId, "/cart?variantId=") + assertThrows(.blankVariantId, "/cart?variantId=%20") + assertThrows(.blankVariantId, "/cart?variantId=%0A") + } + + func testRejectsInvalidQuantities() { + for quantity in Self.rejectedNumbers + ["0", "-1"] { + assertThrows(.invalidQuantity, "/cart?productIndex=0&quantity=\(quantity)") + } + } + + func testRejectsInvalidProductIndexes() { + for productIndex in Self.rejectedNumbers + ["-1"] { + assertThrows(.invalidProductIndex, "/cart?productIndex=\(productIndex)") + } + } + + func testRejectsInvalidBuyerIdentityModes() { + for buyerIdentityMode in ["", "member"] { + assertThrows(.invalidBuyerIdentityMode, "/cart?productIndex=0&buyerIdentityMode=\(buyerIdentityMode)") + } + } + + func testParsesACartCommandWithAVariantId() throws { + let link = try parse("/cart?variantId=gid://shopify/ProductVariant/1&quantity=2&buyerIdentityMode=guest") + + XCTAssertEqual(link, .cart(.init(variantId: "gid://shopify/ProductVariant/1", quantity: 2, buyerIdentityMode: .guest))) + } + + func testParsesACartCommandWithAProductIndexAndTheDefaultQuantity() throws { + let link = try parse("/cart?productIndex=3&buyerIdentityMode=hardcoded") + + XCTAssertEqual(link, .cart(.init(productIndex: 3, quantity: 1, buyerIdentityMode: .hardcoded))) + } + + func testParsesACartCommandWithATrailingSlash() throws { + XCTAssertEqual(try parse("/cart/?productIndex=3"), .cart(.init(productIndex: 3, quantity: 1))) + } + + func testParsesASignInCommandWithoutAnEmail() throws { + XCTAssertEqual(try parse("/signIn"), .signIn(email: nil)) + } + + func testParsesASignInCommandWithAnEmail() throws { + XCTAssertEqual(try parse("/signIn?email=shopper%2Be2e@example.com"), .signIn(email: "shopper+e2e@example.com")) + } + + func testDecodesALiteralPlusInAQueryValueAsASpace() throws { + XCTAssertEqual(try parse("/signIn?email=shopper+e2e@example.com"), .signIn(email: "shopper e2e@example.com")) + } + + func testRejectsABlankSignInEmail() { + assertThrows(.blankEmail, "/signIn?email=") + assertThrows(.blankEmail, "/signIn?email=%20") + assertThrows(.blankEmail, "/signIn?email=%0A") + } + + func testErrorMessagesMatchTheOtherPlatforms() { + let messages = [ + E2EControlLinkError.unsupportedCommand: "Unsupported e2e command", + E2EControlLinkError.unknownParameters(command: "reset", names: ["productIndex"]): + "Unknown reset parameters: productIndex", + E2EControlLinkError.unknownParameters(command: "cart", names: ["bar", "foo"]): + "Unknown cart parameters: bar, foo", + E2EControlLinkError.missingProductSelector: "Missing variantId or productIndex", + E2EControlLinkError.ambiguousProductSelector: "Use variantId or productIndex, not both", + E2EControlLinkError.blankVariantId: "variantId must not be blank", + E2EControlLinkError.invalidQuantity: "quantity must be a positive integer", + E2EControlLinkError.invalidProductIndex: "productIndex must be a non-negative integer", + E2EControlLinkError.invalidBuyerIdentityMode: "buyerIdentityMode must be guest, hardcoded, or customerAccount", + E2EControlLinkError.blankEmail: "email must not be blank" + ] + + for (error, message) in messages { + XCTAssertEqual(error.errorDescription, message) + } + } + + private func parse(_ path: String) throws -> E2EControlLink? { + try E2EControlLink.parse("com.shopify.checkoutkit.swiftdemo://e2e\(path)") + } + + private func assertThrows( + _ expected: E2EControlLinkError, + _ path: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertThrowsError(try parse(path), path, file: file, line: line) { error in + XCTAssertEqual(error as? E2EControlLinkError, expected, path, file: file, line: line) + } + } +}