diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/AppDelegate.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/AppDelegate.swift index dd51fdfc..587cba86 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/AppDelegate.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/AppDelegate.swift @@ -48,7 +48,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ShopifyCheckoutKit.configure { $0.colorScheme = .automatic $0.tintColor = ColorPalette.primaryColor - $0.preloading.enabled = true + $0.preloading.enabled = false $0.logger = FileLogger("log.txt") $0.logLevel = checkoutKitLogLevel } diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolBridge.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolBridge.swift deleted file mode 100644 index 442069cd..00000000 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolBridge.swift +++ /dev/null @@ -1,27 +0,0 @@ -/* - 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 ShopifyCheckoutKit -import ShopifyCheckoutProtocol - -extension CheckoutProtocol.Client: @retroactive CheckoutCommunicationProtocol {} diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift index b7b8558d..171b5951 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift @@ -21,27 +21,69 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import SafariServices import ShopifyCheckoutProtocol +import UIKit -enum CheckoutProtocolClient { - static let shared = CheckoutProtocol.Client() - .on(CheckoutProtocol.start) { checkout in - print("[UCP] ec.start: \(checkout.id)") - } - .on(CheckoutProtocol.complete) { checkout in - print("[UCP] ec.complete: \(checkout.order?.id ?? "unknown")") - CartManager.shared.resetCart() - } - .on(CheckoutProtocol.lineItemsChange) { checkout in - print("[UCP] ec.line_items.change: \(checkout.id)") - } - .on(CheckoutProtocol.messagesChange) { checkout in - print("[UCP] ec.messages.change: \(checkout.id)") - } - .on(CheckoutProtocol.totalsChange) { checkout in - print("[UCP] ec.totals.change: \(checkout.id)") +extension CheckoutProtocol.Client { + @MainActor + static func with(windowOpen: WindowOpenHandlerOption) -> CheckoutProtocol.Client { + let base = CheckoutProtocol.Client() + .on(CheckoutProtocol.start) { checkout in + print("[UCP] ec.start: \(checkout.id)") + } + .on(CheckoutProtocol.complete) { checkout in + print("[UCP] ec.complete: \(checkout.order?.id ?? "unknown")") + CartManager.shared.resetCart() + } + .on(CheckoutProtocol.lineItemsChange) { checkout in + print("[UCP] ec.line_items.change: \(checkout.id)") + } + .on(CheckoutProtocol.messagesChange) { checkout in + print("[UCP] ec.messages.change: \(checkout.id)") + } + .on(CheckoutProtocol.totalsChange) { checkout in + print("[UCP] ec.totals.change: \(checkout.id)") + } + .on(CheckoutProtocol.error) { error in + print("[UCP] ec.error: \(error.messages.first?.content ?? "(no message)")") + } + + switch windowOpen { + case .default: + return base + case .safariViewController: + return base.on(CheckoutProtocol.windowOpen) { request in + let scheme = request.url.scheme?.lowercased() + + print("[UCP] ec.window_open (\(scheme ?? ""))") + + guard scheme == "http" || scheme == "https" else { + return .rejected(reason: "unsupported URL scheme") + } + + guard let presenter = UIApplication.shared.foregroundActiveWindow?.topMostViewController() else { + return .rejected(reason: "no presenter available") + } + + let safari = SFSafariViewController(url: request.url) + presenter.present(safari, animated: true) + return .success + } } - .on(CheckoutProtocol.error) { error in - print("[UCP] ec.error: \(error.messages.first?.content ?? "(no message)")") + } +} + +extension UIApplication { + fileprivate var foregroundActiveWindow: UIWindow? { + let activeScenes = connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + + if #available(iOS 15.0, *) { + return activeScenes.compactMap(\.keyWindow).first + } else { + return activeScenes.flatMap(\.windows).first { $0.isKeyWindow } } + } } diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Localizable.xcstrings b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Localizable.xcstrings index e6b4e3e0..77e4a912 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Localizable.xcstrings +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Localizable.xcstrings @@ -186,6 +186,10 @@ }, "Version" : { + }, + "Window open handler" : { + "comment" : "A label for the selection of the window open handler in the settings.", + "isCommentAutoGenerated" : true }, "Your cart is empty." : { diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift index 21671a0b..82dfa7dc 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift @@ -37,11 +37,16 @@ struct CartView: View { @ObservedObject var cartManager: CartManager = .shared - private let client = CheckoutProtocolClient.shared - @AppStorage(AppStorageKeys.applePayStyle.rawValue) var applePayStyle: ApplePayStyleOption = .automatic + @AppStorage(AppStorageKeys.windowOpenHandler.rawValue) + var windowOpenHandler: WindowOpenHandlerOption = .default + + private var client: CheckoutProtocol.Client { + .with(windowOpen: windowOpenHandler) + } + var body: some View { if let lines = cartManager.cart?.lines.nodes { ZStack(alignment: .bottom) { diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/SettingsView.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/SettingsView.swift index 4fe8e631..7ec6554e 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/SettingsView.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/SettingsView.swift @@ -31,6 +31,19 @@ enum AppStorageKeys: String { case checkoutKitLogLevel case buyerIdentityMode case applePayStyle + case windowOpenHandler +} + +enum WindowOpenHandlerOption: String, CaseIterable { + case `default` + case safariViewController + + var title: String { + switch self { + case .default: return "Default (UIApplication.open)" + case .safariViewController: return "SFSafariViewController" + } + } } struct SettingsView: View { @@ -48,6 +61,9 @@ struct SettingsView: View { @AppStorage(AppStorageKeys.applePayStyle.rawValue) var applePayStyle: ApplePayStyleOption = .automatic + @AppStorage(AppStorageKeys.windowOpenHandler.rawValue) + var windowOpenHandler: WindowOpenHandlerOption = .default + @State private var preloadingEnabled = ShopifyCheckoutKit.configuration.preloading.enabled @State private var logs: [String?] = LogReader.shared.readLogs() ?? [] @State private var selectedColorScheme = ShopifyCheckoutKit.configuration.colorScheme @@ -61,6 +77,13 @@ struct SettingsView: View { .onChange(of: preloadingEnabled) { newValue in ShopifyCheckoutKit.configuration.preloading.enabled = newValue } + + Picker("Window open handler", selection: $windowOpenHandler) { + ForEach(WindowOpenHandlerOption.allCases, id: \.self) { option in + Text(option.title).tag(option) + } + } + .pickerStyle(.menu) } Section( diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift index bc47f183..8b9e267a 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift @@ -21,8 +21,13 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +#if !COCOAPODS + import ShopifyCheckoutProtocol +#endif import Foundation public protocol CheckoutCommunicationProtocol: Sendable { func process(_ message: String) async -> String? } + +extension CheckoutProtocol.Client: CheckoutCommunicationProtocol {} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 672073e2..fcba9431 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -30,7 +30,6 @@ import WebKit protocol CheckoutWebViewDelegate: AnyObject { func checkoutViewDidStartNavigation() func checkoutViewDidFinishNavigation() - func checkoutViewDidClickLink(url: URL) func checkoutViewDidFailWithError(error: CheckoutError) } @@ -254,29 +253,53 @@ extension CheckoutWebView: WKScriptMessageHandler { return } - guard let client else { - return - } - Task { - if let response = await client.process(body) { + if let response = await client?.process(body) { + checkoutBridge.sendResponse(self, messageBody: response) + return + } + + if let response = await CheckoutWebView.defaultsClient.process(body) { checkoutBridge.sendResponse(self, messageBody: response) } } } + + /// Kit-owned client that handles delegations the consumer did not register. + /// Today the only default is `window.open`, which falls back to + /// `UIApplication.shared.open(...)` after a `canOpenURL` check. + static let defaultsClient = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { request in + guard UIApplication.shared.canOpenURL(request.url) else { + return .rejected(reason: "canOpenURL returned false") + } + UIApplication.shared.open(request.url) + return .success + } } extension CheckoutWebView: WKNavigationDelegate { func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + // Handle rare cases where the url is nil guard let url = action.request.url else { decisionHandler(.allow) return } - if isExternalLink(action) || CheckoutURL(from: url).isDeepLink() { - OSLogger.shared.debug("External or deep link clicked: \(url.absoluteString) - request intercepted") - viewDelegate?.checkoutViewDidClickLink(url: removeExternalParam(url)) - decisionHandler(.cancel) + // Handle non-HTTP links triggered on external surfaces by opening them with UIApplication + // Scenarios include: + // - mailto:, tel: etc + // - Deep links on offsite payment sites + // + if CheckoutURL(from: url).isDeepLink() { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + OSLogger.shared.debug("Deep link intercepted: \(url.absoluteString) - allowed") + return decisionHandler(.allow) + } else { + OSLogger.shared.debug("Deep link intercepted: \(url.absoluteString) - rejected") + return decisionHandler(.cancel) + } return } @@ -401,27 +424,6 @@ extension CheckoutWebView: WKNavigationDelegate { ) } - private func isExternalLink(_ action: WKNavigationAction) -> Bool { - if action.navigationType == .linkActivated && action.targetFrame == nil { - return true - } - - guard let url = action.request.url else { return false } - guard let url = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } - - guard let openExternally = url.queryItems?.first(where: { $0.name == "open_externally" })?.value else { return false } - - return openExternally.lowercased() == "true" || openExternally == "1" - } - - private func removeExternalParam(_ url: URL) -> URL { - guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url - } - urlComponents.queryItems = urlComponents.queryItems?.filter { !($0.name == "open_externally") } - return urlComponents.url ?? url - } - private func isCheckout(url: URL?) -> Bool { return self.url == url } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index a76f11d3..547bd73c 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -247,12 +247,6 @@ extension CheckoutWebViewController: CheckoutWebViewDelegate { return isRecoverableError() && isWithinRetryLimit && error.isRecoverable } - func checkoutViewDidClickLink(url: URL) { - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } - private func isRecoverableError() -> Bool { return !CheckoutURL(from: checkoutURL).isMultipassURL() } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 7f1a6ae8..1a30425a 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -34,6 +34,7 @@ class CheckoutWebViewTests: XCTestCase { override func setUp() { ShopifyCheckoutKit.configuration.preloading.enabled = true + CheckoutWebView.invalidate() view = CheckoutWebView.for(checkout: url) mockDelegate = MockCheckoutWebViewDelegate() view.viewDelegate = mockDelegate @@ -41,6 +42,12 @@ class CheckoutWebViewTests: XCTestCase { MockCheckoutBridge.reset() } + override func tearDown() { + view.viewDelegate = nil + CheckoutWebView.invalidate() + super.tearDown() + } + private func createRecoveryAgent() -> CheckoutWebView { recovery = CheckoutWebView.for(checkout: url, recovery: true) mockDelegate = MockCheckoutWebViewDelegate() @@ -68,88 +75,40 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertFalse(recovery.isOpaque) } - func testEmailContactLinkDelegation() throws { - let link = try XCTUnwrap(URL(string: "mailto:contact@shopify.com")) - - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate - - view.webView(view, decidePolicyFor: MockNavigationAction(url: link)) { policy in - XCTAssertEqual(policy, .cancel) - } - - wait(for: [didClickLinkExpectation], timeout: 1) - } - - func testPhoneContactLinkDelegation() throws { - let link = try XCTUnwrap(URL(string: "tel:1234567890")) - - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate - - view.webView(view, decidePolicyFor: MockNavigationAction(url: link)) { policy in - XCTAssertEqual(policy, .cancel) - } - - wait(for: [didClickLinkExpectation], timeout: 1) - } - - func testURLLinkDelegation() throws { + func testHTTPSLinkIsAllowed() throws { let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users")) - - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate + let received = expectation(description: "policy decided") view.webView(view, decidePolicyFor: MockExternalNavigationAction(url: link)) { policy in - XCTAssertEqual(policy, .cancel) + XCTAssertEqual(policy, .allow) + received.fulfill() } - wait(for: [didClickLinkExpectation], timeout: 1) + wait(for: [received], timeout: 2.0) } - func testCheckoutDidClickLinkWasCalledForDeepLink() throws { - let link = try XCTUnwrap(URL(string: "shopify://app/privacy")) - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate + func testDeepLinkIsCancelledWhenUIApplicationCannotOpen() throws { + let link = try XCTUnwrap(URL(string: "unhandled-scheme://nowhere")) + let received = expectation(description: "policy decided") view.webView(view, decidePolicyFor: MockExternalNavigationAction(url: link)) { policy in - XCTAssertEqual(policy, .cancel) + XCTAssertEqual(policy, .cancel, "Schemes that canOpenURL refuses should be cancelled") + received.fulfill() } - wait(for: [didClickLinkExpectation], timeout: 1) + wait(for: [received], timeout: 2.0) } - func testURLLinkDelegationWithExternalParam() throws { - let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users?open_externally=true")) + func testHTTPSubframeRequestIsAllowed() throws { + let link = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")) + let received = expectation(description: "policy decided") - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate - - view.webView(view, decidePolicyFor: MockExternalNavigationAction(url: link, navigationType: .other)) { policy in - XCTAssertEqual(policy, .cancel) + view.webView(view, decidePolicyFor: MockNavigationAction(url: link)) { policy in + XCTAssertEqual(policy, .allow) + received.fulfill() } - wait(for: [didClickLinkExpectation], timeout: 1) + wait(for: [received], timeout: 2.0) } func test403responseOnCheckoutURLCodeDelegation() throws { @@ -498,6 +457,64 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) } + + @MainActor + func testWindowOpenRequestUsesConsumerOverride() async throws { + let id = "req-window-1" + let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"\#(id)","params":{"url":"https://example.com/terms"}}"# + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + view.client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { _ in + .rejected(reason: "consumer override") + } + let message = MockScriptMessage(body: body) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 2.0) + + let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) + let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + XCTAssertEqual(parsed["id"] as? String, id) + let resultBody = try XCTUnwrap(parsed["result"] as? [String: Any]) + let ucp = try XCTUnwrap(resultBody["ucp"] as? [String: Any]) + XCTAssertEqual(ucp["status"] as? String, "error") + let messages = try XCTUnwrap(resultBody["messages"] as? [[String: Any]]) + XCTAssertEqual(messages.first?["content"] as? String, "consumer override") + XCTAssertEqual(messages.first?["code"] as? String, "window_open_rejected_error") + } + + @MainActor + func testDefaultsClientRejectsUnopenableScheme() async throws { + let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"req-window-1","params":{"url":"unhandled-scheme://nowhere"}}"# + + let raw = await CheckoutWebView.defaultsClient.process(body) + let response = try XCTUnwrap(raw) + let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + XCTAssertEqual(parsed["id"] as? String, "req-window-1") + let resultBody = try XCTUnwrap(parsed["result"] as? [String: Any]) + let ucp = try XCTUnwrap(resultBody["ucp"] as? [String: Any]) + XCTAssertEqual( + ucp["status"] as? String, + "error", + "Default handler should reject schemes that canOpenURL refuses" + ) + } + + @MainActor + func testWindowOpenRequestIgnoresMalformedBody() async { + view.client = nil + let notFired = expectation(description: "sendResponse must not fire") + notFired.isInverted = true + MockCheckoutBridge.sendResponseExpectation = notFired + let message = MockScriptMessage(body: #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"r","params":{}}"#) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [notFired], timeout: 1.0) + XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) + } } class LoadedRequestObservableWebView: CheckoutWebView { @@ -519,12 +536,14 @@ class MockCheckoutBridge: CheckoutBridgeProtocol { static var sendMessageCalled = false static var sendResponseCalled = false static var lastResponseBody: String? + static var sendResponseExpectation: XCTestExpectation? static func reset() { instrumentCalled = false sendMessageCalled = false sendResponseCalled = false lastResponseBody = nil + sendResponseExpectation = nil } static func instrument(_: WKWebView, _: InstrumentationPayload) { @@ -538,5 +557,6 @@ class MockCheckoutBridge: CheckoutBridgeProtocol { static func sendResponse(_: WKWebView, messageBody: String) { sendResponseCalled = true lastResponseBody = messageBody + sendResponseExpectation?.fulfill() } } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutWebViewDelegate.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutWebViewDelegate.swift index cb8b0c94..85a29645 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutWebViewDelegate.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutWebViewDelegate.swift @@ -29,7 +29,6 @@ class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate { var didStartNavigationExpectation: XCTestExpectation? var didFinishNavigationExpectation: XCTestExpectation? - var didClickLinkExpectation: XCTestExpectation? var didFailWithErrorExpectation: XCTestExpectation? func checkoutViewDidStartNavigation() { @@ -40,10 +39,6 @@ class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate { didFinishNavigationExpectation?.fulfill() } - func checkoutViewDidClickLink(url _: URL) { - didClickLinkExpectation?.fulfill() - } - func checkoutViewDidFailWithError(error: CheckoutError) { errorReceived = error didFailWithErrorExpectation?.fulfill() diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift index f3560720..92a2f518 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift @@ -26,14 +26,21 @@ import Foundation extension CheckoutProtocol { /// Returns the given checkout URL with the query parameters required to /// initiate the Embedded Checkout Protocol handshake (`ec_version`, - /// `ec_color_scheme`). - public static func url(for url: URL, colorScheme: String) -> URL { + /// `ec_color_scheme`, `ec_delegate`). + public static func url( + for url: URL, + colorScheme: String, + delegations: [String] = defaultDelegations + ) -> URL { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } var queryItems = components.queryItems ?? [] queryItems.append(URLQueryItem(name: "ec_version", value: specVersion)) queryItems.append(URLQueryItem(name: "ec_color_scheme", value: colorScheme)) + if !delegations.isEmpty { + queryItems.append(URLQueryItem(name: "ec_delegate", value: delegations.joined(separator: ","))) + } components.queryItems = queryItems return components.url ?? url } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift index 61a85501..601de446 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift @@ -24,6 +24,8 @@ public enum CheckoutProtocol { public static let specVersion = "2026-04-08" + public static let defaultDelegations: [String] = ["window.open"] + static let buyerChange = NotificationDescriptor(method: "ec.buyer.change") public static let complete = NotificationDescriptor(method: "ec.complete") public static let error = NotificationDescriptor(method: "ec.error") diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift index 7088c6d2..8a2b95ff 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift @@ -27,6 +27,7 @@ extension CheckoutProtocol { public struct Client: Sendable, MutableCopyable { private var notificationHandlers: [String: @MainActor @Sendable (any EventPayload) -> Void] private var delegationEntries: [String: DelegationEntry] + var delegations: [String] { delegationEntries.values.map(\.delegation) } @@ -52,13 +53,13 @@ extension CheckoutProtocol { @discardableResult public func on( _ descriptor: DelegationDescriptor, - perform: @escaping @MainActor (P) async -> R + perform: @escaping @MainActor @Sendable (P) async -> R ) -> Client { return copy { $0.delegationEntries[descriptor.method] = DelegationEntry( delegation: descriptor.delegation, - handler: { id, checkout in - guard let payload = checkout as? P else { return nil } + handler: { id, params in + guard let payload = descriptor.decode(params) else { return nil } let result = await perform(payload) return CheckoutProtocol.encodeResponse(id: id, result: result) } @@ -70,16 +71,17 @@ extension CheckoutProtocol { let decoded = CheckoutProtocol.decode(jsonRpc: message) switch decoded { - case let .ready(id, _): - return CheckoutProtocol.encodeReadyResponse(id: id) + case let .ready(id, requested): + let accepted = requested.filter(Set(delegations).contains) + return CheckoutProtocol.encodeReadyResponse(id: id, acceptedDelegations: accepted) case let .notification(method, payload): await notificationHandlers[method]?(payload) return nil - case let .request(id, method, checkout): + case let .request(id, method, params): if let entry = delegationEntries[method] { - return await entry.handler(id, checkout) + return await entry.handler(id, params) } return nil @@ -91,6 +93,6 @@ extension CheckoutProtocol { struct DelegationEntry { let delegation: String - let handler: @MainActor @Sendable (String, Checkout) async -> String? + let handler: @MainActor @Sendable (String, Data) async -> String? } } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift index 8cf038d3..da475f8e 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift @@ -25,11 +25,15 @@ import Foundation extension CheckoutProtocol { /// Returns an `ec.ready` response if the given message is an `ec.ready` request, - /// otherwise `nil`. Lets the kit acknowledge the handshake without surfacing it - /// to consumers. - public static func acknowledgeReady(_ message: String) -> String? { - guard case let .ready(id, _) = decode(jsonRpc: message) else { return nil } - return encodeReadyResponse(id: id) + /// otherwise `nil`. The response echoes the intersection of the merchant's + /// requested delegations with `supportedDelegations` under a `delegate` array. + public static func acknowledgeReady( + _ message: String, + supportedDelegations: [String] = CheckoutProtocol.defaultDelegations + ) -> String? { + guard case let .ready(id, requested) = decode(jsonRpc: message) else { return nil } + let accepted = requested.filter(Set(supportedDelegations).contains) + return encodeReadyResponse(id: id, acceptedDelegations: accepted) } } @@ -52,15 +56,30 @@ extension CheckoutProtocol { return .notification(method: request.method, payload: error) } - guard let checkout = request.params?.checkout else { - return .unknown(method: request.method, rawParams: jsonRpc) + if let id = request.id { + return .request( + id: id, + method: request.method, + params: extractParamsData(from: data) + ) } - if let id = request.id { - return .request(id: id, method: request.method, checkout: checkout) + if let checkout = request.params?.checkout { + return .notification(method: request.method, payload: checkout) } - return .notification(method: request.method, payload: checkout) + return .unknown(method: request.method, rawParams: jsonRpc) + } + + private static func extractParamsData(from envelope: Data) -> Data { + guard + let object = try? JSONSerialization.jsonObject(with: envelope) as? [String: Any], + let params = object["params"], + let data = try? JSONSerialization.data(withJSONObject: params) + else { + return Data("{}".utf8) + } + return data } static func encodeResponse(id: String, result: some Encodable) -> String { @@ -71,8 +90,11 @@ extension CheckoutProtocol { return String(data: data, encoding: .utf8) ?? "{}" } - static func encodeReadyResponse(id: String) -> String { - let result = ReadyResult(ucp: UCPSuccess(version: specVersion)) + static func encodeReadyResponse(id: String, acceptedDelegations: [String]) -> String { + let result = UCPSuccessResult( + ucp: UCPSuccess(version: specVersion), + delegate: acceptedDelegations.isEmpty ? nil : acceptedDelegations + ) return encodeResponse(id: id, result: result) } } @@ -83,11 +105,22 @@ private struct JSONRPCResponse: Encodable { let result: R } -private struct ReadyResult: Encodable { +struct UCPSuccessResult: Encodable { let ucp: UCPSuccess + let delegate: [String]? + + init(ucp: UCPSuccess, delegate: [String]? = nil) { + self.ucp = ucp + self.delegate = delegate + } } -private struct UCPSuccess: Encodable { +struct UCPSuccess: Encodable { let version: String let status = "success" } + +struct UCPError: Encodable { + let version: String + let status = "error" +} diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift index b1da61fe..7684b384 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift @@ -21,6 +21,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import Foundation + /// Marker protocol that constrains which types can be used as descriptor payloads. /// This must be a protocol (not a typealias for `Decodable & Sendable`) so that /// conformance is explicit — preventing arbitrary types like `[String]` or `Int` @@ -43,4 +45,15 @@ public struct NotificationDescriptor: Sendable { public struct DelegationDescriptor: Sendable { public let method: String public let delegation: String + let decode: @Sendable (Data) -> Payload? + + public init( + method: String, + delegation: String, + decode: @escaping @Sendable (Data) -> Payload? + ) { + self.method = method + self.delegation = delegation + self.decode = decode + } } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift index e01a9d66..b1b469a5 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift @@ -34,4 +34,5 @@ struct JSONRPCParams: Decodable { let checkout: Checkout? let delegate: [String]? let error: ErrorResponse? + let url: String? } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift index c20eb3b2..3bebdf8a 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift @@ -21,9 +21,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import Foundation + enum UCPMessage { case notification(method: String, payload: any EventPayload & Sendable) - case request(id: String, method: String, checkout: Checkout) + case request(id: String, method: String, params: Data) case ready(id: String, delegations: [String]) case unknown(method: String, rawParams: String) } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift new file mode 100644 index 00000000..f214339e --- /dev/null +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift @@ -0,0 +1,97 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation + +public struct WindowOpenRequest: EventPayload { + public let url: URL + + public init(url: URL) { + self.url = url + } + + private enum CodingKeys: String, CodingKey { + case url + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let raw = try container.decode(String.self, forKey: .url) + guard !raw.isEmpty, let parsed = URL(string: raw) else { + throw DecodingError.dataCorruptedError( + forKey: .url, + in: container, + debugDescription: "Invalid URL string: '\(raw)'" + ) + } + url = parsed + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(url.absoluteString, forKey: .url) + } +} + +public enum WindowOpenResult: ResponsePayload { + case success + case rejected(reason: String?) + + public func encode(to encoder: Encoder) throws { + switch self { + case .success: + try UCPSuccessResult( + ucp: UCPSuccess(version: CheckoutProtocol.specVersion) + ).encode(to: encoder) + case let .rejected(reason): + try WindowOpenRejectedBody( + ucp: UCPError(version: CheckoutProtocol.specVersion), + messages: [ + WindowOpenRejectedMessage(content: reason ?? "Window open rejected") + ] + ).encode(to: encoder) + } + } +} + +extension CheckoutProtocol { + public static let windowOpen = DelegationDescriptor( + method: "ec.window.open_request", + delegation: "window.open", + decode: { params in + try? JSONDecoder().decode(WindowOpenRequest.self, from: params) + } + ) +} + +private struct WindowOpenRejectedBody: Encodable { + let ucp: UCPError + let messages: [WindowOpenRejectedMessage] +} + +private struct WindowOpenRejectedMessage: Encodable { + let type = "error" + let code = "window_open_rejected_error" + let content: String + let severity = "unrecoverable" +} diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift new file mode 100644 index 00000000..76a0b5dc --- /dev/null +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift @@ -0,0 +1,79 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +@testable import ShopifyCheckoutProtocol +import Testing + +@Suite("CheckoutProtocol URL Tests") +struct CheckoutProtocolURLTests { + @Test func appendsVersionAndColorScheme() throws { + let input = try #require(URL(string: "https://shop.example.com/checkout")) + let result = CheckoutProtocol.url(for: input, colorScheme: "automatic") + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(items.contains(URLQueryItem(name: "ec_version", value: CheckoutProtocol.specVersion))) + #expect(items.contains(URLQueryItem(name: "ec_color_scheme", value: "automatic"))) + } + + @Test func appendsDefaultDelegate() throws { + let input = try #require(URL(string: "https://shop.example.com/checkout")) + let result = CheckoutProtocol.url(for: input, colorScheme: "light") + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(items.contains(URLQueryItem(name: "ec_delegate", value: "window.open"))) + } + + @Test func joinsMultipleDelegationsWithComma() throws { + let input = try #require(URL(string: "https://shop.example.com/checkout")) + let result = CheckoutProtocol.url( + for: input, + colorScheme: "light", + delegations: ["window.open", "payment.credential"] + ) + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(items.contains(URLQueryItem(name: "ec_delegate", value: "window.open,payment.credential"))) + } + + @Test func omitsDelegateWhenEmpty() throws { + let input = try #require(URL(string: "https://shop.example.com/checkout")) + let result = CheckoutProtocol.url(for: input, colorScheme: "light", delegations: []) + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(!items.contains(where: { $0.name == "ec_delegate" })) + } + + @Test func preservesExistingQueryItems() throws { + let input = try #require(URL(string: "https://shop.example.com/checkout?cart=abc")) + let result = CheckoutProtocol.url(for: input, colorScheme: "light") + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(items.contains(URLQueryItem(name: "cart", value: "abc"))) + } +} diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift index 255a9314..5e7ec0f6 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift @@ -95,6 +95,90 @@ struct ClientTests { #expect(response == nil) } + @Test @MainActor func windowOpenRequestDispatchesToRegisteredHandler() async throws { + let request = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com/terms"}} + """# + + var receivedURL: URL? + let client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { payload in + receivedURL = payload.url + return .success + } + + let response = try #require(await client.process(request)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + #expect(parsed["id"] as? String == "req-window-1") + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "success") + #expect(receivedURL == URL(string: "https://example.com/terms")) + } + + @Test @MainActor func windowOpenRequestEncodesRejectedResult() async throws { + let request = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} + """# + + let client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { _ in + .rejected(reason: "no presenter available") + } + + let response = try #require(await client.process(request)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "error") + + let messages = try #require(result["messages"] as? [[String: Any]]) + #expect(messages[0]["content"] as? String == "no presenter available") + } + + @Test @MainActor func windowOpenRequestReturnsNilWhenHandlerNotRegistered() async { + let client = CheckoutProtocol.Client() + let request = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} + """# + + let response = await client.process(request) + #expect(response == nil) + } + + @Test @MainActor func windowOpenRequestLastHandlerWins() async throws { + let request = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} + """# + + let client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { _ in .rejected(reason: "first") } + .on(CheckoutProtocol.windowOpen) { _ in .success } + + let response = try #require(await client.process(request)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "success") + } + + @Test @MainActor func windowOpenRequestAdvertisesDelegationInReadyResponse() async throws { + let ready = #""" + {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["window.open"]}} + """# + + let client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { _ in .success } + + let response = try #require(await client.process(ready)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + let result = try #require(parsed["result"] as? [String: Any]) + let delegate = try #require(result["delegate"] as? [String]) + #expect(delegate == ["window.open"]) + } + @Test @MainActor func readyReturnsResponse() async throws { let client = CheckoutProtocol.Client() diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift index 58d7ce53..6c369774 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift @@ -62,19 +62,50 @@ struct CodecDecodeTests { #expect(error.messages.first?.content == "Boom.") } - @Test func decodesRequest() throws { + @Test func decodesRequestCarriesRawParams() throws { let json = try fixtureString("request") let message = CheckoutProtocol.decode(jsonRpc: json) - guard case let .request(id, method, checkout) = message else { + guard case let .request(id, method, params) = message else { Issue.record("Expected .request, got \(message)") return } #expect(id == "req-456") #expect(method == "ec.payment.credential_request") - #expect(checkout.id == "checkout-789") - #expect(checkout.currency == "CAD") + + let parsed = try #require( + JSONSerialization.jsonObject(with: params) as? [String: Any] + ) + let checkout = try #require(parsed["checkout"] as? [String: Any]) + #expect(checkout["id"] as? String == "checkout-789") + #expect(checkout["currency"] as? String == "CAD") + } + + @Test func decodesWindowOpenRequest() throws { + let json = try fixtureString("window_open_request") + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .request(id, method, params) = message else { + Issue.record("Expected .request, got \(message)") + return + } + + #expect(id == "req-window-1") + #expect(method == "ec.window.open_request") + + let payload = try #require(CheckoutProtocol.windowOpen.decode(params)) + #expect(payload.url == URL(string: "https://example.com/terms")) + } + + @Test func windowOpenDescriptorRejectsEmptyURL() { + let params = Data(#"{"url":""}"#.utf8) + #expect(CheckoutProtocol.windowOpen.decode(params) == nil) + } + + @Test func windowOpenDescriptorRejectsMissingURL() { + let params = Data("{}".utf8) + #expect(CheckoutProtocol.windowOpen.decode(params) == nil) } @Test func decodesUnknownMethod() { diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift index 6a6199a3..2fd94db7 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift @@ -51,7 +51,7 @@ struct CodecEncodeTests { } @Test func encodesReadyResponseWithResultEnvelope() throws { - let json = CheckoutProtocol.encodeReadyResponse(id: "ready-1") + let json = CheckoutProtocol.encodeReadyResponse(id: "ready-1", acceptedDelegations: []) let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) #expect(parsed["jsonrpc"] as? String == "2.0") @@ -63,6 +63,16 @@ struct CodecEncodeTests { let ucp = try #require(result["ucp"] as? [String: Any]) #expect(ucp["version"] as? String == CheckoutProtocol.specVersion) #expect(ucp["status"] as? String == "success") + #expect(result["delegate"] == nil, "Empty delegate list must be omitted") + } + + @Test func encodesReadyResponseEchoesAcceptedDelegations() throws { + let json = CheckoutProtocol.encodeReadyResponse(id: "ready-1", acceptedDelegations: ["window.open"]) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + let delegate = try #require(result["delegate"] as? [String]) + #expect(delegate == ["window.open"]) } @Test func acknowledgeReadyReturnsResponseForReadyMessage() throws { @@ -80,6 +90,31 @@ struct CodecEncodeTests { #expect(ucp["status"] as? String == "success") } + @Test func acknowledgeReadyEchoesIntersectionWithSupportedDelegations() throws { + let message = #""" + {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["payment.credential","window.open","fulfillment.address_change"]}} + """# + + let response = try #require(CheckoutProtocol.acknowledgeReady(message, supportedDelegations: ["window.open"])) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + let delegate = try #require(result["delegate"] as? [String]) + #expect(delegate == ["window.open"]) + } + + @Test func acknowledgeReadyOmitsDelegateWhenNoIntersection() throws { + let message = #""" + {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["payment.credential"]}} + """# + + let response = try #require(CheckoutProtocol.acknowledgeReady(message, supportedDelegations: ["window.open"])) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + #expect(result["delegate"] == nil) + } + @Test func acknowledgeReadyReturnsNilForNonReadyMessage() { let message = #""" {"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{"id":"c"}}} @@ -91,4 +126,48 @@ struct CodecEncodeTests { @Test func acknowledgeReadyReturnsNilForMalformedJSON() { #expect(CheckoutProtocol.acknowledgeReady("not json") == nil) } + + @Test func windowOpenResultEncodesSuccessBody() throws { + let json = CheckoutProtocol.encodeResponse(id: "req-window-1", result: WindowOpenResult.success) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + #expect(parsed["jsonrpc"] as? String == "2.0") + #expect(parsed["id"] as? String == "req-window-1") + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "success") + #expect(ucp["version"] as? String == CheckoutProtocol.specVersion) + } + + @Test func windowOpenResultEncodesRejectedBody() throws { + let json = CheckoutProtocol.encodeResponse( + id: "req-window-1", + result: WindowOpenResult.rejected(reason: "canOpenURL returned false") + ) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + #expect(parsed["id"] as? String == "req-window-1") + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "error") + + let messages = try #require(result["messages"] as? [[String: Any]]) + #expect(messages.count == 1) + #expect(messages[0]["type"] as? String == "error") + #expect(messages[0]["code"] as? String == "window_open_rejected_error") + #expect(messages[0]["severity"] as? String == "unrecoverable") + #expect(messages[0]["content"] as? String == "canOpenURL returned false") + } + + @Test func windowOpenResultEncodesRejectedWithNilReason() throws { + let json = CheckoutProtocol.encodeResponse( + id: "req-window-1", + result: WindowOpenResult.rejected(reason: nil) + ) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + let messages = try #require(result["messages"] as? [[String: Any]]) + #expect(messages[0]["content"] as? String != "", "Content is required per message_error schema") + } } diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/Fixtures/window_open_request.json b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/Fixtures/window_open_request.json new file mode 100644 index 00000000..b2d70a74 --- /dev/null +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/Fixtures/window_open_request.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "ec.window.open_request", + "id": "req-window-1", + "params": { + "url": "https://example.com/terms" + } +}