diff --git a/.swiftformat b/.swiftformat index 2c7479e32..4c4c57c16 100644 --- a/.swiftformat +++ b/.swiftformat @@ -6,6 +6,6 @@ --allman false --semicolons inline --trimwhitespace always ---disable redundantReturn,hoistAwait,preferKeyPath,redundantInternal,redundantPublic ---swiftversion 5.7.1 +--disable redundantReturn,hoistAwait,preferKeyPath,redundantInternal,conditionalAssignment +--swiftversion 5.9 --extensionacl on-declarations diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift index 46805358f..9f221ee09 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift @@ -84,16 +84,51 @@ enum CheckoutBridge: CheckoutBridgeProtocol { } static func sendMessage(_ webView: WKWebView, messageName: String, messageBody: String?) { - let dispatchMessageBody: String - if let body = messageBody { - dispatchMessageBody = "'\(messageName)', \(body)" + let dispatchMessageBody = if let body = messageBody { + "'\(messageName)', \(body)" } else { - dispatchMessageBody = "'\(messageName)'" + "'\(messageName)'" } let script = dispatchMessageTemplate(body: dispatchMessageBody) webView.evaluateJavaScript(script) } + /// Build embed query parameter string for embedded checkout + static func embedParams(authToken: String? = nil) -> String { + var params: [String] = [] + + // Authentication token (required for embedded checkout) + if let token = authToken { + params.append("authentication=\(token)") + } + + // Protocol version (always 2025-04 for embedded checkout) + params.append("protocol=2025-04") + + // Color scheme + let colorScheme = ShopifyCheckoutSheetKit.configuration.colorScheme + let normalizedColorScheme: String + switch colorScheme { + case .automatic: + normalizedColorScheme = "automatic" + case .light: + normalizedColorScheme = "light" + case .dark: + normalizedColorScheme = "dark" + case .web: + normalizedColorScheme = "web" + } + params.append("color-scheme=\(normalizedColorScheme)") + + // Library identification + params.append("library=CheckoutKit/4.0.0") + + // Platform identification (valid: ReactNative, Swift, Android, Web) + params.append("platform=Swift") + + return params.joined(separator: ",") + } + static func decode(_ message: WKScriptMessage) throws -> WebEvent { guard let body = message.body as? String, let data = body.data(using: .utf8) else { throw BridgeError.invalidBridgeEvent() diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutDelegate.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutDelegate.swift index 5fd1139b2..316ccedd2 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutDelegate.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutDelegate.swift @@ -44,6 +44,20 @@ public protocol CheckoutDelegate: AnyObject { /// Tells te delegate that a Web Pixel event was emitted func checkoutDidEmitWebPixelEvent(event: PixelEvent) + + // MARK: - Embedded Checkout Events (Schema 2025-04) + + /// Tells the delegate that the embedded checkout state has changed + func checkoutDidChangeState(state: CheckoutStatePayload) + + /// Tells the delegate that the embedded checkout successfully completed with order details + func checkoutDidComplete(payload: CheckoutCompletePayload) + + /// Tells the delegate that the embedded checkout encountered one or more errors + func checkoutDidFail(errors: [ErrorPayload]) + + /// Tells the delegate that an embedded checkout web pixel event was emitted + func checkoutDidEmitWebPixelEvent(payload: WebPixelsPayload) } extension CheckoutDelegate { @@ -68,4 +82,22 @@ extension CheckoutDelegate { UIApplication.shared.open(url) } } + + // MARK: - Embedded Checkout Default Implementations + + public func checkoutDidChangeState(state: CheckoutStatePayload) { + /// No-op by default + } + + public func checkoutDidComplete(payload: CheckoutCompletePayload) { + /// No-op by default + } + + public func checkoutDidFail(errors: [ErrorPayload]) { + /// No-op by default + } + + public func checkoutDidEmitWebPixelEvent(payload: WebPixelsPayload) { + /// No-op by default + } } diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift index fbdeef1eb..5e54692fe 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift @@ -33,6 +33,12 @@ protocol CheckoutWebViewDelegate: AnyObject { func checkoutViewDidFailWithError(error: CheckoutError) func checkoutViewDidToggleModal(modalVisible: Bool) func checkoutViewDidEmitWebPixelEvent(event: PixelEvent) + + // MARK: - Embedded Checkout Events (Schema 2025-04) + func checkoutViewDidChangeState(state: CheckoutStatePayload) + func checkoutViewDidComplete(payload: CheckoutCompletePayload) + func checkoutViewDidFail(errors: [ErrorPayload]) + func checkoutViewDidEmitWebPixelEvent(payload: WebPixelsPayload) } private let deprecatedReasonHeader = "x-shopify-api-deprecated-reason" @@ -130,6 +136,8 @@ class CheckoutWebView: WKWebView { var isPreloadRequest: Bool = false private var entryPoint: MetaData.EntryPoint? + + var checkoutOptions: CheckoutOptions? // MARK: Initializers @@ -232,7 +240,10 @@ class CheckoutWebView: WKWebView { func load(checkout url: URL, isPreload: Bool = false) { OSLogger.shared.info("Loading checkout URL: \(url.absoluteString), isPreload: \(isPreload)") - var request = URLRequest(url: url) + + // Build URL with embed query parameter if needed + let finalURL = buildCheckoutURL(from: url, options: checkoutOptions) + var request = URLRequest(url: finalURL) if isPreload, isPreloadingAvailable { isPreloadRequest = true @@ -241,6 +252,33 @@ class CheckoutWebView: WKWebView { load(request) } + + private func buildCheckoutURL(from originalURL: URL, options: CheckoutOptions?) -> URL { + // Get authentication token if provided + var authToken: String? + if let appAuth = options?.appAuthentication { + switch appAuth { + case .token(let token): + authToken = token + } + } + + // Only add embed parameter if we have options (indicating embedded checkout) + guard options != nil else { + return originalURL + } + + // Get embed params string with authentication token + let embedValue = CheckoutBridge.embedParams(authToken: authToken) + + // Add the embed parameter to the URL + var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: false) + var queryItems = components?.queryItems ?? [] + queryItems.append(URLQueryItem(name: "embed", value: embedValue)) + components?.queryItems = queryItems + + return components?.url ?? originalURL + } private func dispatchPresentedMessage(_ checkoutDidLoad: Bool, _ checkoutDidPresent: Bool) { if checkoutDidLoad, checkoutDidPresent, isBridgeAttached { @@ -253,11 +291,52 @@ class CheckoutWebView: WKWebView { extension CheckoutWebView: WKScriptMessageHandler { func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? String else { + OSLogger.shared.error("Invalid script message body") + return + } + + // Try to decode as JSON first for embedded checkout events + if let eventData = try? JSONSerialization.jsonObject(with: Data(body.utf8), options: []) as? [String: Any], + let eventName = eventData["name"] as? String { + + switch eventName { + case "stateChange": + OSLogger.shared.info("Embedded checkout state change event received") + handleStateChangeEvent(eventData) + return + case "completed": + OSLogger.shared.info("Embedded checkout completed event received") + if handleEmbeddedCompletedEvent(eventData) { + return + } + // Fall through to legacy handling if embedded handling fails + case "error": + OSLogger.shared.error("Embedded checkout error event received") + if handleEmbeddedErrorEvent(eventData) { + return + } + // Fall through to legacy handling if embedded handling fails + case "webPixels": + OSLogger.shared.info("Embedded checkout web pixels event received") + handleEmbeddedWebPixelEvent(eventData) + return + case "checkoutBlockingEvent": + if let modalVisible = eventData["body"] as? String, let visible = Bool(modalVisible) { + viewDelegate?.checkoutViewDidToggleModal(modalVisible: visible) + } + return + default: + break + } + } + + // Fall back to legacy CheckoutBridge decoding for existing events do { switch try CheckoutBridge.decode(message) { - /// Completed event + /// Completed event case let .checkoutComplete(checkoutCompletedEvent): - OSLogger.shared.info("Checkout completed event received") + OSLogger.shared.info("Legacy checkout completed event received") viewDelegate?.checkoutViewDidCompleteCheckout(event: checkoutCompletedEvent) /// Error: Checkout unavailable case let .checkoutUnavailable(message, code): @@ -290,13 +369,105 @@ extension CheckoutWebView: WKScriptMessageHandler { viewDelegate?.checkoutViewDidEmitWebPixelEvent(event: nonOptionalEvent) } default: - () + OSLogger.shared.debug("Unsupported legacy event") } } catch { OSLogger.shared.error("Error decoding bridge script message: \(error.localizedDescription)") viewDelegate?.checkoutViewDidFailWithError(error: .sdkError(underlying: error)) } } + + // MARK: - Embedded Checkout Event Handlers + + private func handleStateChangeEvent(_ event: [String: Any]) { + guard let statePayload = decodeStateChangePayload(from: event) else { + OSLogger.shared.error("Failed to decode state change payload") + return + } + viewDelegate?.checkoutViewDidChangeState(state: statePayload) + } + + private func handleEmbeddedCompletedEvent(_ event: [String: Any]) -> Bool { + guard let embeddedPayload = decodeEmbeddedCompletePayload(from: event) else { + return false + } + viewDelegate?.checkoutViewDidComplete(payload: embeddedPayload) + return true + } + + private func handleEmbeddedErrorEvent(_ event: [String: Any]) -> Bool { + guard let embeddedErrors = decodeEmbeddedErrorPayload(from: event) else { + return false + } + viewDelegate?.checkoutViewDidFail(errors: embeddedErrors) + return true + } + + private func handleEmbeddedWebPixelEvent(_ event: [String: Any]) { + guard let embeddedPayload = decodeEmbeddedWebPixelPayload(from: event) else { + OSLogger.shared.error("Failed to decode embedded checkout web pixel payload") + return + } + viewDelegate?.checkoutViewDidEmitWebPixelEvent(payload: embeddedPayload) + } + + // MARK: - Embedded Checkout Decoders + + private func decodeEmbeddedCompletePayload(from event: [String: Any]) -> CheckoutCompletePayload? { + guard let messageBody = event["body"] as? String, + let data = messageBody.data(using: .utf8) else { + return nil + } + + do { + return try JSONDecoder().decode(CheckoutCompletePayload.self, from: data) + } catch { + OSLogger.shared.debug("Failed to decode as embedded complete payload: \(error)") + return nil + } + } + + private func decodeEmbeddedWebPixelPayload(from event: [String: Any]) -> WebPixelsPayload? { + guard let messageBody = event["body"] as? String, + let data = messageBody.data(using: .utf8) else { + return nil + } + + do { + return try JSONDecoder().decode(WebPixelsPayload.self, from: data) + } catch { + OSLogger.shared.debug("Failed to decode as embedded web pixel payload: \(error)") + return nil + } + } + + private func decodeStateChangePayload(from event: [String: Any]) -> CheckoutStatePayload? { + guard let messageBody = event["body"] as? String, + let data = messageBody.data(using: .utf8) else { + return nil + } + + do { + return try JSONDecoder().decode(CheckoutStatePayload.self, from: data) + } catch { + OSLogger.shared.error("Failed to decode state change payload: \(error)") + return nil + } + } + + private func decodeEmbeddedErrorPayload(from event: [String: Any]) -> [ErrorPayload]? { + guard let messageBody = event["body"] as? String, + let data = messageBody.data(using: .utf8) else { + return nil + } + + do { + return try JSONDecoder().decode([ErrorPayload].self, from: data) + } catch { + OSLogger.shared.debug("Failed to decode as embedded error payload: \(error)") + return nil + } + } } extension CheckoutWebView: WKNavigationDelegate { diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift index bd845b5f9..63f43c833 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift @@ -62,7 +62,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl // MARK: Initializers - public init(checkoutURL url: URL, delegate: CheckoutDelegate? = nil, entryPoint: MetaData.EntryPoint? = nil) { + public init(checkoutURL url: URL, delegate: CheckoutDelegate? = nil, entryPoint: MetaData.EntryPoint? = nil, options: CheckoutOptions? = nil) { checkoutURL = url self.delegate = delegate @@ -71,6 +71,9 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl checkoutView.scrollView.contentInsetAdjustmentBehavior = .never self.checkoutView = checkoutView + // Set checkout options for embedded checkout support + checkoutView.checkoutOptions = options + super.init(nibName: nil, bundle: nil) title = ShopifyCheckoutSheetKit.configuration.title @@ -246,6 +249,39 @@ extension CheckoutWebViewController: CheckoutWebViewDelegate { func checkoutViewDidEmitWebPixelEvent(event: PixelEvent) { delegate?.checkoutDidEmitWebPixelEvent(event: event) } + + // MARK: - Embedded Checkout Delegate Methods + + func checkoutViewDidChangeState(state: CheckoutStatePayload) { + delegate?.checkoutDidChangeState(state: state) + } + + func checkoutViewDidComplete(payload: CheckoutCompletePayload) { + ConfettiCannon.fire(in: view) + CheckoutWebView.invalidate(disconnect: false) + delegate?.checkoutDidComplete(payload: payload) + } + + func checkoutViewDidFail(errors: [ErrorPayload]) { + CheckoutWebView.invalidate() + delegate?.checkoutDidFail(errors: errors) + + // For embedded checkout errors, attempt recovery based on error group and URL type + let shouldAttemptRecovery = errors.contains { error in + // Only recover from specific recoverable network errors (5xx server errors) + return error.group == "network" && error.type == "server_error" && (error.code?.hasPrefix("5") == true) + } && !CheckoutURL(from: checkoutURL).isMultipassURL() + + if shouldAttemptRecovery { + presentFallbackViewController(url: checkoutURL) + } else { + dismiss(animated: true) + } + } + + func checkoutViewDidEmitWebPixelEvent(payload: WebPixelsPayload) { + delegate?.checkoutDidEmitWebPixelEvent(payload: payload) + } private func canRecoverFromError(_: CheckoutError) -> Bool { /// Reuse of multipass tokens will cause 422 errors. A new token must be generated diff --git a/Sources/ShopifyCheckoutSheetKit/EmbeddedCheckoutModels.swift b/Sources/ShopifyCheckoutSheetKit/EmbeddedCheckoutModels.swift new file mode 100644 index 000000000..371b425b0 --- /dev/null +++ b/Sources/ShopifyCheckoutSheetKit/EmbeddedCheckoutModels.swift @@ -0,0 +1,273 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation + +// MARK: - Main Payload Types + +/// Payload for embedded checkout state change events (Schema 2025-04) +public struct CheckoutStatePayload: Codable { + public let flowType: FlowType + public let cart: EmbeddedCartInfo? + public let buyer: EmbeddedBuyerInfo? + public let delivery: EmbeddedDeliveryInfo? + public let payment: EmbeddedPaymentMethod? +} + +/// Payload for embedded checkout completion events (Schema 2025-04) +public struct CheckoutCompletePayload: Codable { + public let flowType: FlowType + public let orderID: String? + public let cart: EmbeddedCartInfo? + public let buyer: EmbeddedBuyerInfo? + public let delivery: EmbeddedDeliveryInfo? + public let payment: EmbeddedPaymentMethod? + + public init(orderID: String?) { + flowType = .embedded + self.orderID = orderID + cart = nil + buyer = nil + delivery = nil + payment = nil + } +} + +/// Payload for embedded checkout web pixel events (Schema 2025-04) +public struct WebPixelsPayload: Codable { + public let flowType: FlowType + public let type: PixelEventType + public let name: String + public let timestamp: String + public let data: AnyCodable? +} + +// MARK: - Supporting Data Types + +/// Information about the shopping cart +public struct EmbeddedCartInfo: Codable { + public let token: String? + public let lines: [EmbeddedCartLine]? + public let totalAmount: EmbeddedMoneyAmount? + public let subtotalAmount: EmbeddedMoneyAmount? + public let taxAmount: EmbeddedMoneyAmount? + public let shippingAmount: EmbeddedMoneyAmount? +} + +/// Individual cart line item +public struct EmbeddedCartLine: Codable { + public let id: String + public let quantity: Int + public let merchandise: EmbeddedMerchandise + public let totalAmount: EmbeddedMoneyAmount? +} + +/// Product merchandise information +public struct EmbeddedMerchandise: Codable { + public let id: String + public let title: String? + public let image: EmbeddedImageInfo? + public let product: EmbeddedProductInfo? +} + +/// Product information +public struct EmbeddedProductInfo: Codable { + public let id: String + public let title: String? + public let vendor: String? + public let type: String? +} + +/// Image information +public struct EmbeddedImageInfo: Codable { + public let url: String? + public let altText: String? +} + +/// Money amount with currency +public struct EmbeddedMoneyAmount: Codable { + public let amount: String + public let currencyCode: String +} + +/// Buyer information +public struct EmbeddedBuyerInfo: Codable { + public let email: String? + public let phone: String? + public let acceptsMarketing: Bool? + public let firstName: String? + public let lastName: String? +} + +/// Delivery information +public struct EmbeddedDeliveryInfo: Codable { + public let address: EmbeddedAddressInfo? + public let method: DeliveryMethodType? + public let instructions: String? +} + +/// Address information +public struct EmbeddedAddressInfo: Codable { + public let address1: String? + public let address2: String? + public let city: String? + public let company: String? + public let country: String? + public let countryCode: String? + public let firstName: String? + public let lastName: String? + public let phone: String? + public let province: String? + public let provinceCode: String? + public let zip: String? +} + +/// Payment method information +public struct EmbeddedPaymentMethod: Codable { + public let type: String? + public let details: AnyCodable? +} + +// MARK: - Enums + +/// Flow type for embedded checkout (Schema 2025-04) +public enum FlowType: String, Codable { + case embedded + case checkout +} + +/// Delivery method types +public enum DeliveryMethodType: String, Codable { + case shipping + case pickup + case delivery +} + +/// Web pixel event types +public enum PixelEventType: String, Codable { + case standard + case custom + case advancedDom = "advanced-dom" +} + +// MARK: - Error Models + +public struct ErrorPayload: Codable { + public let flowType: FlowType + public let group: String + public let type: String + public let code: String? + public let reason: String? +} + +public struct AuthenticationErrorPayload: Codable { + public let group: String = "authentication" + public let code: String + public let reason: String? +} + +public struct KillswitchErrorPayload: Codable { + public let group: String = "killswitch" + public let reason: String? +} + +// MARK: - CheckoutOptions + +/// Options for configuring embedded checkout behavior +public struct CheckoutOptions { + /// Authentication configuration for embedded checkout + public let appAuthentication: AppAuthentication? + + public init(appAuthentication: AppAuthentication? = nil) { + self.appAuthentication = appAuthentication + } +} + +/// Authentication methods for embedded checkout +public enum AppAuthentication { + /// Token-based authentication for partner access + case token(String) +} + +// MARK: - Helper Types + +/// A type-erased wrapper for any Codable value, used for handling arbitrary JSON values +public struct AnyCodable: Codable { + public let value: Any + + public init(_ value: (some Any)?) { + self.value = value ?? () + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self.init(()) + } else if let bool = try? container.decode(Bool.self) { + self.init(bool) + } else if let int = try? container.decode(Int.self) { + self.init(int) + } else if let double = try? container.decode(Double.self) { + self.init(double) + } else if let string = try? container.decode(String.self) { + self.init(string) + } else if let array = try? container.decode([AnyCodable].self) { + self.init(array.map { $0.value }) + } else if let dictionary = try? container.decode([String: AnyCodable].self) { + self.init(Dictionary(uniqueKeysWithValues: dictionary.map { key, value in + (key, value.value) + })) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + case is Void: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + let anyArray = array.map(AnyCodable.init) + try container.encode(anyArray) + case let dictionary as [String: Any]: + let anyDictionary = Dictionary(uniqueKeysWithValues: dictionary.map { key, value in + (key, AnyCodable(value)) + }) + try container.encode(anyDictionary) + default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded") + throw EncodingError.invalidValue(value, context) + } + } +} \ No newline at end of file diff --git a/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift b/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift index 5e24074e2..f488d7121 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift @@ -38,7 +38,7 @@ class CheckoutViewDelegateTests: XCTestCase { $0.title = customTitle ?? "Checkout" } viewController = MockCheckoutWebViewController( - checkoutURL: checkoutURL, delegate: delegate + checkoutURL: checkoutURL, delegate: delegate, entryPoint: nil, options: nil ) navigationController = UINavigationController(rootViewController: viewController) @@ -209,7 +209,7 @@ class CheckoutViewDelegateTests: XCTestCase { func testCloseButtonUsesSystemDefaultWhenTintColorIsNil() { ShopifyCheckoutSheetKit.configuration.closeButtonTintColor = nil - let controller = MockCheckoutWebViewController(checkoutURL: checkoutURL, delegate: delegate) + let controller = MockCheckoutWebViewController(checkoutURL: checkoutURL, delegate: delegate, entryPoint: nil, options: nil) let closeButton = controller.navigationItem.rightBarButtonItem XCTAssertNotNil(closeButton) @@ -220,7 +220,7 @@ class CheckoutViewDelegateTests: XCTestCase { func testCloseButtonUsesCustomImageAndTintWhenColorIsSet() { let customColor = UIColor.red ShopifyCheckoutSheetKit.configuration.closeButtonTintColor = customColor - let controller = MockCheckoutWebViewController(checkoutURL: checkoutURL, delegate: delegate) + let controller = MockCheckoutWebViewController(checkoutURL: checkoutURL, delegate: delegate, entryPoint: nil, options: nil) let closeButton = controller.navigationItem.rightBarButtonItem XCTAssertNotNil(closeButton) @@ -231,7 +231,7 @@ class CheckoutViewDelegateTests: XCTestCase { func testCloseButtonImageIsXMarkCircleFill() { ShopifyCheckoutSheetKit.configuration.closeButtonTintColor = .blue - let controller = MockCheckoutWebViewController(checkoutURL: checkoutURL, delegate: delegate) + let controller = MockCheckoutWebViewController(checkoutURL: checkoutURL, delegate: delegate, entryPoint: nil, options: nil) let closeButton = controller.navigationItem.rightBarButtonItem let expectedImage = UIImage(systemName: "xmark.circle.fill") @@ -249,6 +249,10 @@ protocol Dismissible: AnyObject { extension CheckoutWebViewController: Dismissible {} +func createEmptyCheckoutCompletedPayload() -> CheckoutCompletePayload { + return CheckoutCompletePayload(orderID: "test_order_123") +} + class MockCheckoutWebViewController: CheckoutWebViewController { private(set) var dismissCalled = false diff --git a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift index 31777b5de..46db65888 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift @@ -84,4 +84,36 @@ class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate { completedEventReceived = event didEmitCheckoutCompletedEventExpectation?.fulfill() } + + // MARK: - Embedded Checkout Delegate Methods + + var stateChangePayloadReceived: CheckoutStatePayload? + var completePayloadReceived: CheckoutCompletePayload? + var errorsReceived: [ErrorPayload]? + var webPixelsPayloadReceived: WebPixelsPayload? + + var didChangeStateExpectation: XCTestExpectation? + var didCompleteEmbeddedCheckoutExpectation: XCTestExpectation? + var didFailEmbeddedCheckoutExpectation: XCTestExpectation? + var didEmitEmbeddedWebPixelEventExpectation: XCTestExpectation? + + func checkoutViewDidChangeState(state: CheckoutStatePayload) { + stateChangePayloadReceived = state + didChangeStateExpectation?.fulfill() + } + + func checkoutViewDidComplete(payload: CheckoutCompletePayload) { + completePayloadReceived = payload + didCompleteEmbeddedCheckoutExpectation?.fulfill() + } + + func checkoutViewDidFail(errors: [ErrorPayload]) { + errorsReceived = errors + didFailEmbeddedCheckoutExpectation?.fulfill() + } + + func checkoutViewDidEmitWebPixelEvent(payload: WebPixelsPayload) { + webPixelsPayloadReceived = payload + didEmitEmbeddedWebPixelEventExpectation?.fulfill() + } }