diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift index dfd71f1e..36851cb2 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift @@ -162,10 +162,6 @@ extension CartViewController: CheckoutDelegate { dismiss(animated: true) } - func checkoutDidFail(errors: [ShopifyCheckoutSheetKit.CheckoutError]) { - print(#function, errors) - } - func checkoutDidClickContactLink(url: URL) { if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) @@ -173,15 +169,44 @@ extension CartViewController: CheckoutDelegate { } func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) { - switch error { - case .sdkError(let underlying): - print(#function, underlying) - forceCloseCheckout("Checkout Unavailable") - case .checkoutExpired(let message): forceCloseCheckout(message) - case .checkoutUnavailable(let message): forceCloseCheckout(message) - case .checkoutLiquidNotMigrated(let message): - print(#function, message) - forceCloseCheckout("Checkout Unavailable") + var errorMessage: String = "" + + /// Internal Checkout SDK error + if case .sdkError(let underlying, _) = error { + errorMessage = "\(underlying.localizedDescription)" + } + + /// Checkout unavailable error + if case .checkoutUnavailable(let message, let code, _) = error { + errorMessage = message + handleCheckoutUnavailable(message, code) + } + + /// Storefront configuration error + if case .configurationError(let message, _, _) = error { + errorMessage = message + } + + /// Checkout has expired, re-create cart to fetch a new checkout URL + if case .checkoutExpired(let message, _, _) = error { + errorMessage = message + } + + /// Unauthorized checkout + if case .authenticationError(let message, _, _) = error { + errorMessage = message + } + + print(#function, error) + forceCloseCheckout(errorMessage) + } + + private func handleCheckoutUnavailable(_ message: String, _ code: CheckoutUnavailable) { + switch code { + case .clientError(let clientErrorCode): + print("[CheckoutUnavailable] (checkoutError)", message, clientErrorCode) + case .httpError(let statusCode): + print("[CheckoutUnavailable] (httpError)", statusCode) } } @@ -196,8 +221,7 @@ extension CartViewController: CheckoutDelegate { } } - private func forceCloseCheckout(_ message: String) { - print(#function, message) + private func forceCloseCheckout(_ message: String = "Checkout unavailable") { dismiss(animated: true) resetCart() self.showAlert(message: message) diff --git a/Samples/Samples.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Samples/Samples.xcworkspace/xcshareddata/swiftpm/Package.resolved index 47823df4..ba826584 100644 --- a/Samples/Samples.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Samples/Samples.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Shopify/mobile-buy-sdk-ios", "state" : { - "revision" : "e6e85dcf8f9eb95baaa8336ad3d7967ea8c36ade", - "version" : "11.1.0" + "revision" : "3a6ecdce6b9e8f356078fbdc2b738ac32cd18153", + "version" : "11.3.0" } }, { diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift index 58e9308b..1407ed67 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift @@ -29,7 +29,7 @@ enum BridgeError: Swift.Error { } enum CheckoutBridge { - static let schemaVersion = "8.0" + static let schemaVersion = "8.1" static let messageHandler = "mobileCheckoutSdk" internal static var logger: ProductionLogger = InternalLogger() @@ -82,11 +82,22 @@ enum CheckoutBridge { extension CheckoutBridge { enum WebEvent: Decodable { + /// Error types + case authenticationError(message: String?, code: CheckoutErrorCode) + case checkoutExpired(message: String?, code: CheckoutErrorCode) + case checkoutUnavailable(message: String?, code: CheckoutErrorCode) + case configurationError(message: String?, code: CheckoutErrorCode) + + /// Success case checkoutComplete(event: CheckoutCompletedEvent) - case checkoutExpired - case checkoutUnavailable + + /// Presentational case checkoutModalToggled(modalVisible: Bool) + + /// Eventing case webPixels(event: PixelEvent?) + + /// Generic case unsupported(String) enum CodingKeys: String, CodingKey { @@ -94,6 +105,7 @@ extension CheckoutBridge { case body } + // swiftlint:disable cyclomatic_complexity init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -110,8 +122,24 @@ extension CheckoutBridge { self = .checkoutComplete(event: emptyCheckoutCompletedEvent) } case "error": - // needs to support .checkoutUnavailable by parsing error payload on body - self = .checkoutExpired + let errorDecoder = CheckoutErrorEventDecoder() + let error = errorDecoder.decode(from: container, using: decoder) + let code = CheckoutErrorCode.from(error.code) + + switch error.group { + case .configuration: + if code == .customerAccountRequired { + self = .authenticationError(message: error.reason, code: .customerAccountRequired) + } else { + self = .configurationError(message: error.reason, code: code) + } + case .unrecoverable: + self = .checkoutUnavailable(message: error.reason, code: code) + case .expired: + self = .checkoutExpired(message: error.reason, code: CheckoutErrorCode.from(error.code)) + default: + self = .unsupported(name) + } case "checkoutBlockingEvent": let modalVisible = try container.decode(String.self, forKey: .body) self = .checkoutModalToggled(modalVisible: Bool(modalVisible)!) @@ -123,6 +151,7 @@ extension CheckoutBridge { self = .unsupported(name) } } + // swiftlint:enable cyclomatic_complexity } } diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutError.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutError.swift index 026dcd7e..2ecddf34 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutError.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutError.swift @@ -21,23 +21,106 @@ 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 + +public enum CheckoutErrorCode: String, Codable { + case customerAccountRequired = "customer_account_required" + case storefrontPasswordRequired = "storefront_password_required" + case checkoutLiquidNotMigrated = "checkout_liquid_not_migrated" + case cartExpired = "cart_expired" + case cartCompleted = "cart_completed" + case invalidCart = "invalid_cart" + case unknown = "unknown" + + public static func from(_ code: String?) -> CheckoutErrorCode { + let fallback = CheckoutErrorCode.unknown + + guard let errorCode = code else { + return fallback + } + + return CheckoutErrorCode(rawValue: errorCode) ?? fallback + } +} + +public enum CheckoutUnavailable { + case clientError(code: CheckoutErrorCode) + case httpError(statusCode: Int) +} + /// A type representing Shopify Checkout specific errors. +/// "recoverable" indicates that though the request has failed, it should be retried in a fallback browser experience. public enum CheckoutError: Swift.Error { + /// Issued when checkout has encountered an authentication error. + /// For example; the stonrefront is configured to enforce customer account login. + case authenticationError(message: String, code: CheckoutErrorCode, recoverable: Bool = false) + /// Issued when an internal error within Shopify Checkout SDK /// In event of an sdkError you could use the stacktrace to inform you of how to proceed, /// if the issue persists, it is recommended to open a bug report in http://github.com/Shopify/checkout-sheet-kit-swift - case sdkError(underlying: Swift.Error) + case sdkError(underlying: Swift.Error, recoverable: Bool = true) /// Issued when the provided checkout URL results in an error related to shop being on checkout.liquid. /// The SDK only supports stores migrated for extensibility. - case checkoutLiquidNotMigrated(message: String) + case configurationError(message: String, code: CheckoutErrorCode, recoverable: Bool = false) /// Issued when checkout has encountered a unrecoverable error (for example server side error) /// if the issue persists, it is recommended to open a bug report in http://github.com/Shopify/checkout-sheet-kit-swift - case checkoutUnavailable(message: String) + case checkoutUnavailable(message: String, code: CheckoutUnavailable, recoverable: Bool) /// Issued when checkout is no longer available and will no longer be available with the checkout url supplied. /// This may happen when the user has paused on checkout for a long period (hours) and then attempted to proceed again with the same checkout url /// In event of checkoutExpired, a new checkout url will need to be generated - case checkoutExpired(message: String) + case checkoutExpired(message: String, code: CheckoutErrorCode, recoverable: Bool = false) +} + +internal enum CheckoutErrorGroup: String, Codable { + /// An authentication error + case authentication + /// A shop configuration error + case configuration + /// A terminal checkout error which cannot be handled + case unrecoverable + /// A checkout-related error, such as failure to receive a receipt or progress through checkout + case checkout + /// The checkout session has expired and is no longer available + case expired + /// The error sent by checkout is not supported + case unsupported +} + +internal struct CheckoutErrorEvent: Codable { + public let group: CheckoutErrorGroup + public let code: String? + public let flowType: String? + public let reason: String? + public let type: String? + + public init(group: CheckoutErrorGroup, code: String? = nil, flowType: String? = nil, reason: String? = nil, type: String? = nil) { + self.group = group + self.code = code + self.flowType = flowType + self.reason = reason + self.type = type + } +} + +internal class CheckoutErrorEventDecoder { + func decode(from container: KeyedDecodingContainer, using decoder: Decoder) -> CheckoutErrorEvent { + + do { + let messageBody = try container.decode(String.self, forKey: .body) + + /// Failure to decode will trigger the catch block + let data = messageBody.data(using: .utf8) + + let events = try JSONDecoder().decode([CheckoutErrorEvent].self, from: data!) + + /// Failure to find an event in the payload array will trigger the catch block + return events.first! + } catch { + CheckoutBridge.logger.logError(error, "Error decoding checkout error event") + return CheckoutErrorEvent(group: .unsupported, reason: "Decoded error could not be parsed.") + } + } } diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift index 96fcf6ba..44ea31b0 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift @@ -34,6 +34,9 @@ protocol CheckoutWebViewDelegate: AnyObject { func checkoutViewDidEmitWebPixelEvent(event: PixelEvent) } +private let deprecatedReasonHeader = "X-Shopify-API-Deprecated-Reason" +private let checkoutLiquidNotSupportedReason = "checkout_liquid_not_supported" + class CheckoutWebView: WKWebView { private static var cache: CacheEntry? @@ -151,14 +154,41 @@ extension CheckoutWebView: WKScriptMessageHandler { func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { do { switch try CheckoutBridge.decode(message) { + /// Error: authentication error + case .authenticationError(let message, let code): + viewDelegate?.checkoutViewDidFailWithError( + error: .authenticationError( + message: message ?? "Unauthorized", + code: code, + recoverable: false + ) + ) + /// Completed event case let .checkoutComplete(checkoutCompletedEvent): - CheckoutWebView.cache = nil viewDelegate?.checkoutViewDidCompleteCheckout(event: checkoutCompletedEvent) - case .checkoutUnavailable: - CheckoutWebView.cache = nil - viewDelegate?.checkoutViewDidFailWithError(error: .checkoutUnavailable(message: "Checkout unavailable.")) + /// Error: Checkout unavailable + case .checkoutUnavailable(let message, let code): + viewDelegate?.checkoutViewDidFailWithError( + error: .checkoutUnavailable( + message: message ?? "Checkout unavailable.", + code: CheckoutUnavailable.clientError(code: code), + recoverable: true + ) + ) + /// Error: Storefront not configured properly + case .configurationError(let message, let code): + viewDelegate?.checkoutViewDidFailWithError(error: .checkoutUnavailable( + message: message ?? "Storefront configuration error.", + code: CheckoutUnavailable.clientError(code: code), + recoverable: false + )) + /// Error: Checkout expired + case .checkoutExpired(let message, let code): + viewDelegate?.checkoutViewDidFailWithError(error: .checkoutExpired(message: message ?? "Checkout has expired.", code: code)) + /// Checkout modal toggled case let .checkoutModalToggled(modalVisible): viewDelegate?.checkoutViewDidToggleModal(modalVisible: modalVisible) + /// Checkout web pixel event case let .webPixels(event): if let nonOptionalEvent = event { viewDelegate?.checkoutViewDidEmitWebPixelEvent(event: nonOptionalEvent) @@ -200,17 +230,46 @@ extension CheckoutWebView: WKNavigationDelegate { } func handleResponse(_ response: HTTPURLResponse) -> WKNavigationResponsePolicy { - if isCheckout(url: response.url) && response.statusCode >= 400 { - CheckoutWebView.cache = nil - switch response.statusCode { - case 410: - viewDelegate?.checkoutViewDidFailWithError(error: .checkoutExpired(message: "Checkout has expired")) + let headers = response.allHeaderFields + let statusCode = response.statusCode + let errorMessageForStatusCode = HTTPURLResponse.localizedString( + forStatusCode: statusCode + ) + + guard isCheckout(url: response.url) else { + return .allow + } + + if statusCode >= 400 { + /// Invalidate cache for any sort of error + CheckoutWebView.invalidate() + + switch statusCode { case 404: - viewDelegate?.checkoutViewDidFailWithError(error: .checkoutLiquidNotMigrated(message: "The checkout url provided has resulted in an error. The store is still using checkout.liquid, whereas the checkout SDK only supports checkout with extensibility.")) - case 500: - viewDelegate?.checkoutViewDidFailWithError(error: .checkoutUnavailable(message: "Checkout unavailable due to error")) + if let reason = headers[deprecatedReasonHeader] as? String, reason.lowercased() == checkoutLiquidNotSupportedReason { + viewDelegate?.checkoutViewDidFailWithError(error: .configurationError(message: "Storefronts using checkout.liquid are not supported. Please upgrade to Checkout Extensibility.", code: CheckoutErrorCode.checkoutLiquidNotMigrated, recoverable: false)) + } else { + viewDelegate?.checkoutViewDidFailWithError(error: .checkoutUnavailable( + message: errorMessageForStatusCode, + code: CheckoutUnavailable.httpError(statusCode: statusCode), + recoverable: false + )) + } + case 410: + viewDelegate?.checkoutViewDidFailWithError(error: .checkoutExpired(message: "Checkout has expired.", code: CheckoutErrorCode.cartExpired)) + case 500...599: + viewDelegate?.checkoutViewDidFailWithError(error: .checkoutUnavailable( + message: errorMessageForStatusCode, + code: CheckoutUnavailable.httpError(statusCode: statusCode), + recoverable: true + )) default: - () + viewDelegate?.checkoutViewDidFailWithError( + error: .checkoutUnavailable( + message: errorMessageForStatusCode, + code: CheckoutUnavailable.httpError(statusCode: statusCode), + recoverable: false + )) } return .cancel @@ -224,6 +283,7 @@ extension CheckoutWebView: WKNavigationDelegate { viewDelegate?.checkoutViewDidStartNavigation() } + /// No need to emit checkoutDidFail error here as it has been handled in handleResponse already func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { timer = nil } @@ -245,7 +305,6 @@ extension CheckoutWebView: WKNavigationDelegate { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { timer = nil - CheckoutWebView.cache = nil viewDelegate?.checkoutViewDidFailWithError(error: .sdkError(underlying: error)) } diff --git a/Tests/ShopifyCheckoutSheetKitTests/CheckoutBridgeTests.swift b/Tests/ShopifyCheckoutSheetKitTests/CheckoutBridgeTests.swift index bd86760e..0579f324 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/CheckoutBridgeTests.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/CheckoutBridgeTests.swift @@ -25,6 +25,7 @@ import XCTest import WebKit @testable import ShopifyCheckoutSheetKit +// swiftlint:disable type_body_length class CheckoutBridgeTests: XCTestCase { class WKScriptMessageMock: WKScriptMessage { private let _mockBody: Any @@ -59,9 +60,7 @@ class CheckoutBridgeTests: XCTestCase { } func testDecodeHandlesUnsupportedEventsGracefully() throws { - let mock = WKScriptMessageMock(body: """ - { "name": "unknown_event", "body": "" } - """) + let mock = createEventPayload(name: "unknown", "{}") let result = try CheckoutBridge.decode(mock) @@ -71,18 +70,10 @@ class CheckoutBridgeTests: XCTestCase { } func testDecodeSupportsCheckoutCompletedEvent() throws { - let body = "{\"orderDetails\":{\"id\":\"gid://shopify/OrderIdentity/8\",\"cart\":{\"lines\":[{\"quantity\":1,\"title\":\"Awesome Plastic Shoes\",\"price\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"merchandiseId\":\"gid://shopify/ProductVariant/1\",\"productId\":\"gid://shopify/Product/1\"}],\"price\":{\"total\":{\"amount\":109.89,\"currencyCode\":\"CAD\"},\"subtotal\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"taxes\":{\"amount\":0,\"currencyCode\":\"CAD\"},\"shipping\":{\"amount\":21.9,\"currencyCode\":\"CAD\"}},\"token\": \"fake-token\"},\"billingAddress\":{\"city\":\"Calgary\",\"countryCode\":\"CA\",\"postalCode\":\"T1X 0L3\",\"address1\":\"The Cloak & Dagger\",\"address2\":\"1st Street Southeast\",\"firstName\":\"Test\",\"lastName\":\"McTest\",\"name\":\"Test\",\"zoneCode\":\"AB\",\"coordinates\":{\"latitude\":45.416311,\"longitude\":-75.68683}},\"paymentMethods\":[{\"type\":\"direct\",\"details\":{\"amount\":\"109.89\",\"currency\":\"CAD\",\"brand\":\"BOGUS\",\"lastFourDigits\":\"1\"}}],\"deliveries\":[{\"method\":\"SHIPPING\",\"details\":{\"location\":{\"city\":\"Calgary\",\"countryCode\":\"CA\",\"postalCode\":\"T1X 0L3\",\"address1\":\"The Cloak & Dagger\",\"address2\":\"1st Street Southeast\",\"firstName\":\"Test\",\"lastName\":\"McTest\",\"name\":\"Test\",\"zoneCode\":\"AB\",\"coordinates\":{\"latitude\":45.416311,\"longitude\":-75.68683}}}}]},\"orderId\":\"gid://shopify/OrderIdentity/19\"}" - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "") + let payload = "{\"orderDetails\":{\"id\":\"gid://shopify/OrderIdentity/8\",\"cart\":{\"lines\":[{\"quantity\":1,\"title\":\"Awesome Plastic Shoes\",\"price\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"merchandiseId\":\"gid://shopify/ProductVariant/1\",\"productId\":\"gid://shopify/Product/1\"}],\"price\":{\"total\":{\"amount\":109.89,\"currencyCode\":\"CAD\"},\"subtotal\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"taxes\":{\"amount\":0,\"currencyCode\":\"CAD\"},\"shipping\":{\"amount\":21.9,\"currencyCode\":\"CAD\"}},\"token\": \"fake-token\"},\"billingAddress\":{\"city\":\"Calgary\",\"countryCode\":\"CA\",\"postalCode\":\"T1X 0L3\",\"address1\":\"The Cloak & Dagger\",\"address2\":\"1st Street Southeast\",\"firstName\":\"Test\",\"lastName\":\"McTest\",\"name\":\"Test\",\"zoneCode\":\"AB\",\"coordinates\":{\"latitude\":45.416311,\"longitude\":-75.68683}},\"paymentMethods\":[{\"type\":\"direct\",\"details\":{\"amount\":\"109.89\",\"currency\":\"CAD\",\"brand\":\"BOGUS\",\"lastFourDigits\":\"1\"}}],\"deliveries\":[{\"method\":\"SHIPPING\",\"details\":{\"location\":{\"city\":\"Calgary\",\"countryCode\":\"CA\",\"postalCode\":\"T1X 0L3\",\"address1\":\"The Cloak & Dagger\",\"address2\":\"1st Street Southeast\",\"firstName\":\"Test\",\"lastName\":\"McTest\",\"name\":\"Test\",\"zoneCode\":\"AB\",\"coordinates\":{\"latitude\":45.416311,\"longitude\":-75.68683}}}}]},\"orderId\":\"gid://shopify/OrderIdentity/19\"}" - let mock = WKScriptMessageMock(body: """ - { - "name": "completed", - "body": "\(body)" - } - """) - - let result = try CheckoutBridge.decode(mock) + let event = createEventPayload(name: "completed", payload) + let result = try CheckoutBridge.decode(event) guard case .checkoutComplete(let event) = result else { XCTFail("Expected .checkoutComplete, got \(result)") @@ -98,13 +89,10 @@ class CheckoutBridgeTests: XCTestCase { func testFailedDecodeReturnsEmptyEvent() throws { /// Missing orderId, taxes, billingAddress - let body = "{\"orderDetails\":{\"cart\":{\"lines\":[{\"quantity\":1,\"title\":\"Awesome Plastic Shoes\",\"price\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"merchandiseId\":\"gid://shopify/ProductVariant/1\",\"productId\":\"gid://shopify/Product/1\"}],\"price\":{\"total\":{\"amount\":109.89,\"currencyCode\":\"CAD\"},\"subtotal\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"shipping\":{\"amount\":21.9,\"currencyCode\":\"CAD\"}},\"token\":\"fake-token\"},\"paymentMethods\":[{\"type\":\"direct\",\"details\":{\"amount\":\"109.89\",\"currency\":\"CAD\",\"brand\":\"BOGUS\",\"lastFourDigits\":\"1\"}}],\"deliveries\":[{\"method\":\"SHIPPING\",\"details\":{\"location\":{\"city\":\"Calgary\",\"countryCode\":\"CA\",\"postalCode\":\"T1X 0L3\",\"address1\":\"The Cloak & Dagger\",\"address2\":\"1st Street Southeast\",\"firstName\":\"Test\",\"lastName\":\"McTest\",\"name\":\"Test\",\"zoneCode\":\"AB\",\"coordinates\":{\"latitude\":45.416311,\"longitude\":-75.68683}}}}]},\"orderId\":\"gid://shopify/OrderIdentity/19\",\"cart\":{\"lines\":[{\"quantity\":1,\"title\":\"Awesome Plastic Shoes\",\"price\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"merchandiseId\":\"gid://shopify/ProductVariant/1\",\"productId\":\"gid://shopify/Product/1\"}],\"price\":{\"total\":{\"amount\":109.89,\"currencyCode\":\"CAD\"},\"subtotal\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"taxes\":{\"amount\":0,\"currencyCode\":\"CAD\"},\"shipping\":{\"amount\":21.9,\"currencyCode\":\"CAD\"}}}}" - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "") - - let mock = WKScriptMessageMock(body: "{\"name\":\"completed\",\"body\": \"\(body)\"}") + let payload = "{\"orderDetails\":{\"cart\":{\"lines\":[{\"quantity\":1,\"title\":\"Awesome Plastic Shoes\",\"price\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"merchandiseId\":\"gid://shopify/ProductVariant/1\",\"productId\":\"gid://shopify/Product/1\"}],\"price\":{\"total\":{\"amount\":109.89,\"currencyCode\":\"CAD\"},\"subtotal\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"shipping\":{\"amount\":21.9,\"currencyCode\":\"CAD\"}},\"token\":\"fake-token\"},\"paymentMethods\":[{\"type\":\"direct\",\"details\":{\"amount\":\"109.89\",\"currency\":\"CAD\",\"brand\":\"BOGUS\",\"lastFourDigits\":\"1\"}}],\"deliveries\":[{\"method\":\"SHIPPING\",\"details\":{\"location\":{\"city\":\"Calgary\",\"countryCode\":\"CA\",\"postalCode\":\"T1X 0L3\",\"address1\":\"The Cloak & Dagger\",\"address2\":\"1st Street Southeast\",\"firstName\":\"Test\",\"lastName\":\"McTest\",\"name\":\"Test\",\"zoneCode\":\"AB\",\"coordinates\":{\"latitude\":45.416311,\"longitude\":-75.68683}}}}]},\"orderId\":\"gid://shopify/OrderIdentity/19\",\"cart\":{\"lines\":[{\"quantity\":1,\"title\":\"Awesome Plastic Shoes\",\"price\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"merchandiseId\":\"gid://shopify/ProductVariant/1\",\"productId\":\"gid://shopify/Product/1\"}],\"price\":{\"total\":{\"amount\":109.89,\"currencyCode\":\"CAD\"},\"subtotal\":{\"amount\":87.99,\"currencyCode\":\"CAD\"},\"taxes\":{\"amount\":0,\"currencyCode\":\"CAD\"},\"shipping\":{\"amount\":21.9,\"currencyCode\":\"CAD\"}}}}" - let result = try CheckoutBridge.decode(mock) + let event = createEventPayload(name: "completed", payload) + let result = try CheckoutBridge.decode(event) guard case .checkoutComplete(let event) = result else { XCTFail("Expected .checkoutComplete, got \(result)") @@ -114,29 +102,77 @@ class CheckoutBridgeTests: XCTestCase { XCTAssertEqual(event.orderDetails.id, "") } - func testDecodeSupportsCheckoutUnavailableEvent() throws { - let mock = WKScriptMessageMock(body: """ - { - "name": "error" + func testDecodeSupportsCheckoutExpiredEvent() throws { + let event = createErrorEventPayload("[{\"group\":\"expired\",\"type\": \"invalidCart\",\"reason\": \"Cart is invalid\", \"flowType\": \"regular\", \"code\": \"null\"}]") + let result = try CheckoutBridge.decode(event) + + guard case CheckoutBridge.WebEvent.checkoutExpired = result else { + return XCTFail("expected .checkoutExpired error, got \(result)") + } } - """) - let result = try CheckoutBridge.decode(mock) + func testDecodesBarebonesErrorEvent() throws { + let event = createErrorEventPayload("[{\"group\":\"expired\"}]") + let result = try CheckoutBridge.decode(event) guard case CheckoutBridge.WebEvent.checkoutExpired = result else { - return XCTFail("expected CheckoutScriptMessage.checkoutExpired, got \(result)") + return XCTFail("expected .checkoutExpired error, got \(result)") } } - func testDecodeSupportsCheckoutBlockingEvent() throws { - let mock = WKScriptMessageMock(body: """ - { - "name": "checkoutBlockingEvent", - "body": "true" + func testDecodeSupportsUnrecoverableErrorEvent() throws { + let event = createErrorEventPayload("[{\"group\":\"unrecoverable\",\"reason\": \"Checkout crashed\", \"code\": \"sdk_not_enabled\"}]") + + let result = try CheckoutBridge.decode(event) + + guard case CheckoutBridge.WebEvent.checkoutUnavailable = result else { + return XCTFail("expected .checkoutUnavailable error, got \(result)") + } } - """) - let result = try CheckoutBridge.decode(mock) + func testDecodeSupportsConfigurationErrorEvent() throws { + let event = createErrorEventPayload("[{\"group\":\"configuration\",\"code\":\"storefront_password_required\",\"reason\": \"Storefront password required\"}]") + + let result = try CheckoutBridge.decode(event) + + guard case CheckoutBridge.WebEvent.configurationError = result else { + return XCTFail("expected .configurationError error, got \(result)") + } + } + + func testDecodeSupportsAuthConfigurationErrorEvent() throws { + let event = createErrorEventPayload("[{\"group\":\"configuration\",\"code\":\"customer_account_required\",\"reason\": \"Customer Account required\"}]") + + let result = try CheckoutBridge.decode(event) + + guard case CheckoutBridge.WebEvent.authenticationError = result else { + return XCTFail("expected .authenticationError error, got \(result)") + } + } + + func testDecodeSupportsUnsupportedConfigurationErrorEvent() throws { + let event = createErrorEventPayload("[{\"group\":\"configuration\",\"code\":\"unsupported\",\"reason\": \"Unsupported\"}]") + + let result = try CheckoutBridge.decode(event) + + guard case CheckoutBridge.WebEvent.configurationError = result else { + return XCTFail("expected .configurationError error, got \(result)") + } + } + + func testDecodeFailsSilentlyWhenErrorIsUnsupported() throws { + let event = createErrorEventPayload("[{\"group\":\"checkout\",\"reason\": \"violation\"}]") + let result = try CheckoutBridge.decode(event) + + guard case CheckoutBridge.WebEvent.unsupported = result else { + return XCTFail("expected .unsupported event, got \(result)") + } + } + + func testDecodeSupportsCheckoutBlockingEvent() throws { + let event = createEventPayload(name: "checkoutBlockingEvent", "true") + + let result = try CheckoutBridge.decode(event) guard case CheckoutBridge.WebEvent.checkoutModalToggled = result else { return XCTFail("expected CheckoutScriptMessage.checkoutModalToggled, got \(result)") @@ -144,18 +180,9 @@ class CheckoutBridgeTests: XCTestCase { } func testDecodeSupportsStandardWebPixelsEvent() throws { - let body = "{\"name\": \"page_viewed\",\"event\": {\"id\": \"123\",\"name\": \"page_viewed\",\"type\":\"standard\",\"timestamp\": \"2024-01-04T09:48:53.358Z\",\"data\": {}, \"context\": {}}}" - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "") + let event = createEventPayload(name: "webPixels", "{\"name\": \"page_viewed\",\"event\": {\"id\": \"123\",\"name\": \"page_viewed\",\"type\":\"standard\",\"timestamp\": \"2024-01-04T09:48:53.358Z\",\"data\": {}, \"context\": {}}}") - let mock = WKScriptMessageMock(body: """ - { - "name": "webPixels", - "body": "\(body)" - } - """) - - let result = try CheckoutBridge.decode(mock) + let result = try CheckoutBridge.decode(event) guard case .webPixels(let pixelEvent) = result, case .standardEvent(let pageViewedEvent) = pixelEvent else { XCTFail("Expected .webPixels(.pageViewed), got \(result)") @@ -317,6 +344,20 @@ class CheckoutBridgeTests: XCTestCase { } """ } + + private func createPayload(_ jsonString: String) -> String { + return jsonString + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "") + } + + private func createErrorEventPayload(_ jsonString: String) -> CheckoutBridgeTests.WKScriptMessageMock { + return WKScriptMessageMock(body: "{\"name\": \"error\",\"body\": \"\(createPayload(jsonString))\"}") + } + + private func createEventPayload(name: String, _ jsonString: String) -> CheckoutBridgeTests.WKScriptMessageMock { + return WKScriptMessageMock(body: "{\"name\": \"\(name)\",\"body\": \"\(createPayload(jsonString))\"}") + } } struct MyCustomData: Codable { @@ -327,3 +368,5 @@ struct MyCustomDataWrapper: Codable { let attr: String let attr2: [Int] } + +// swiftlint:enable type_body_length diff --git a/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift b/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift index 585acbf5..8e53112a 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift @@ -74,7 +74,7 @@ class CheckoutViewDelegateTests: XCTestCase { let two = CheckoutWebView.for(checkout: checkoutURL) XCTAssertEqual(one, two) - viewController.checkoutViewDidFailWithError(error: .checkoutUnavailable(message: "error")) + viewController.checkoutViewDidFailWithError(error: .checkoutUnavailable(message: "error", code: CheckoutUnavailable.httpError(statusCode: 500), recoverable: false)) let three = CheckoutWebView.for(checkout: checkoutURL) XCTAssertNotEqual(two, three) @@ -85,7 +85,7 @@ class CheckoutViewDelegateTests: XCTestCase { _ = CheckoutWebView.for(checkout: checkoutURL) - viewController.checkoutViewDidFailWithError(error: .checkoutUnavailable(message: "error")) + viewController.checkoutViewDidFailWithError(error: .checkoutUnavailable(message: "error", code: CheckoutUnavailable.httpError(statusCode: 500), recoverable: false)) XCTAssertEqual(false, CheckoutWebView.preloadingActivatedByClient) } diff --git a/Tests/ShopifyCheckoutSheetKitTests/CheckoutWebViewTests.swift b/Tests/ShopifyCheckoutSheetKitTests/CheckoutWebViewTests.swift index 1a29983f..4a813a48 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/CheckoutWebViewTests.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/CheckoutWebViewTests.swift @@ -103,6 +103,78 @@ class CheckoutWebViewTests: XCTestCase { wait(for: [didClickLinkExpectation], timeout: 1) } + func test403responseOnCheckoutURLCodeDelegation() { + view.load(checkout: URL(string: "http://shopify1.shopify.com/checkouts/cn/123")!) + let link = view.url! + let didFailWithErrorExpectation = expectation(description: "checkoutViewDidFailWithError was called") + + mockDelegate.didFailWithErrorExpectation = didFailWithErrorExpectation + view.viewDelegate = mockDelegate + + let urlResponse = HTTPURLResponse(url: link, statusCode: 403, httpVersion: nil, headerFields: nil)! + + let policy = view.handleResponse(urlResponse) + XCTAssertEqual(policy, .cancel) + + waitForExpectations(timeout: 5) { _ in + switch self.mockDelegate.errorReceived { + case .some(.checkoutUnavailable(let message, _, let recoverable)): + XCTAssertEqual(message, "forbidden") + XCTAssertFalse(recoverable) + default: + XCTFail("Unhandled error case received") + } + } + } + + func test404responseOnCheckoutURLCodeDelegation() { + view.load(checkout: URL(string: "http://shopify1.shopify.com/checkouts/cn/123")!) + let link = view.url! + let didFailWithErrorExpectation = expectation(description: "checkoutViewDidFailWithError was called") + + mockDelegate.didFailWithErrorExpectation = didFailWithErrorExpectation + view.viewDelegate = mockDelegate + + let urlResponse = HTTPURLResponse(url: link, statusCode: 404, httpVersion: nil, headerFields: nil)! + + let policy = view.handleResponse(urlResponse) + XCTAssertEqual(policy, .cancel) + + waitForExpectations(timeout: 5) { _ in + switch self.mockDelegate.errorReceived { + case .some(.checkoutUnavailable(let message, _, let recoverable)): + XCTAssertEqual(message, "not found") + XCTAssertFalse(recoverable) + default: + XCTFail("Unhandled error case received") + } + } + } + + func testTreat404WithDeprecationHeader() { + view.load(checkout: URL(string: "http://shopify1.shopify.com/checkouts/cn/123")!) + let link = view.url! + let didFailWithErrorExpectation = expectation(description: "checkoutViewDidFailWithError was called") + + mockDelegate.didFailWithErrorExpectation = didFailWithErrorExpectation + view.viewDelegate = mockDelegate + + let urlResponse = HTTPURLResponse(url: link, statusCode: 404, httpVersion: nil, headerFields: ["X-Shopify-API-Deprecated-Reason": "checkout_liquid_not_supported"])! + + let policy = view.handleResponse(urlResponse) + XCTAssertEqual(policy, .cancel) + + waitForExpectations(timeout: 5) { _ in + switch self.mockDelegate.errorReceived { + case .some(.configurationError(let message, _, let recoverable)): + XCTAssertEqual(message, "Storefronts using checkout.liquid are not supported. Please upgrade to Checkout Extensibility.") + XCTAssertFalse(recoverable) + default: + XCTFail("Unhandled error case received") + } + } + } + func test410responseOnCheckoutURLCodeDelegation() { view.load(checkout: URL(string: "http://shopify1.shopify.com/checkouts/cn/123")!) let link = view.url! @@ -116,9 +188,55 @@ class CheckoutWebViewTests: XCTestCase { let policy = view.handleResponse(urlResponse) XCTAssertEqual(policy, .cancel) - waitForExpectations(timeout: 5, handler: nil) + waitForExpectations(timeout: 5) { _ in + switch self.mockDelegate.errorReceived { + case .some(.checkoutExpired(let message, _, let recoverable)): + XCTAssertEqual(message, "Checkout has expired.") + XCTAssertFalse(recoverable) + default: + XCTFail("Unhandled error case received") + } + } } + func testTreat5XXReponsesAsRecoverable() { + view.load(checkout: URL(string: "http://shopify1.shopify.com/checkouts/cn/123")!) + let link = view.url! + view.viewDelegate = mockDelegate + + for statusCode in 500...510 { + let didFailWithErrorExpectation = expectation(description: "checkoutViewDidFailWithError was called for status code \(statusCode)") + mockDelegate.didFailWithErrorExpectation = didFailWithErrorExpectation + + let urlResponse = HTTPURLResponse(url: link, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + + let policy = view.handleResponse(urlResponse) + XCTAssertEqual(policy, .cancel, "Policy should be .cancel for status code \(statusCode)") + + waitForExpectations(timeout: 3) { error in + if error != nil { + XCTFail("Test timed out for status code \(statusCode)") + } + + guard let receivedError = self.mockDelegate.errorReceived else { + XCTFail("Expected to receive a `CheckoutError` for status code \(statusCode)") + return + } + + switch receivedError { + case .checkoutUnavailable(_, _, let recoverable): + XCTAssertTrue(recoverable, "Error should be recoverable for status code \(statusCode)") + default: + XCTFail("Received incorrect `CheckoutError` case for status code \(statusCode)") + } + } + + // Reset the delegate's expectations and error received state before the next iteration + mockDelegate.didFailWithErrorExpectation = nil + mockDelegate.errorReceived = nil + } + } + func testNormalresponseOnNonCheckoutURLCodeDelegation() { let link = URL(string: "http://shopify.com/resource_url")! let didFailWithErrorExpectation = expectation(description: "checkoutViewDidFailWithError was not called") diff --git a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutDelegate.swift b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutDelegate.swift index c77bb99e..df950f25 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutDelegate.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutDelegate.swift @@ -29,8 +29,6 @@ class ExampleDelegate: CheckoutDelegate { func checkoutDidCancel() {} - func checkoutDidFail(errors: [ShopifyCheckoutSheetKit.CheckoutError]) {} - func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) {} func checkoutDidClickContactLink(url: URL) {} diff --git a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift index d6b1192a..c47dee1f 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift @@ -25,6 +25,8 @@ import XCTest @testable import ShopifyCheckoutSheetKit class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate { + var errorReceived: CheckoutError? + var didStartNavigationExpectation: XCTestExpectation? var didFinishNavigationExpectation: XCTestExpectation? @@ -63,7 +65,8 @@ class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate { didClickLinkExpectation?.fulfill() } - func checkoutViewDidFailWithError(error: CheckoutError) { + func checkoutViewDidFailWithError(error: CheckoutError) { + errorReceived = error didFailWithErrorExpectation?.fulfill() } diff --git a/Tests/ShopifyCheckoutSheetKitTests/SwiftUITests.swift b/Tests/ShopifyCheckoutSheetKitTests/SwiftUITests.swift index fa91da1b..ef59a0d7 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/SwiftUITests.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/SwiftUITests.swift @@ -80,13 +80,13 @@ class CheckoutSheetTests: XCTestCase { func testOnFail() { var actionCalled = false var actionData: CheckoutError? - let error: CheckoutError = .checkoutUnavailable(message: "error") + let error: CheckoutError = .checkoutUnavailable(message: "error", code: CheckoutUnavailable.httpError(statusCode: 500), recoverable: false) - checkoutSheet.onFail { failure in + checkoutSheet.onFail { (failure) in actionCalled = true actionData = failure - } + checkoutSheet.delegate.checkoutDidFail(error: error) XCTAssertTrue(actionCalled) XCTAssertNotNil(actionData)