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) + } + } +}