From c59fb44c8bc8bec57f639041e06eabe36b404cc7 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 6 Oct 2026 22:40:41 +0100 Subject: [PATCH] Cover accelerated checkout validation discounts and response handling --- .../StorefrontAPICodingTests.swift | 102 ++++++++++++++++ .../ErrorHandlerAddressValidationTests.swift | 115 ++++++++++++++++++ .../PassKitDiscountSummaryTests.swift | 81 ++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPICodingTests.swift create mode 100644 platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandlerAddressValidationTests.swift create mode 100644 platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/PassKitDiscountSummaryTests.swift diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPICodingTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPICodingTests.swift new file mode 100644 index 000000000..2e341d516 --- /dev/null +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPICodingTests.swift @@ -0,0 +1,102 @@ +import Foundation +@testable import ShopifyAcceleratedCheckouts +import XCTest + +@available(iOS 17.0, *) +final class StorefrontAPICodingTests: XCTestCase { + private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(type, from: Data(json.utf8)) + } + + private func object(_ value: some Encodable) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as? [String: Any]) + } + + func testMoneyAcceptsStringAndNumericAmountsAndEncodesDecimalStrings() throws { + for amount in ["\"12.50\"", "12.50"] { + let money = try decode(StorefrontAPI.MoneyV2.self, "{\"amount\":\(amount),\"currencyCode\":\"USD\"}") + XCTAssertEqual(money.amount, Decimal(string: "12.5")) + XCTAssertEqual(money.currencyCode, "USD") + let encoded = try object(money) + XCTAssertEqual(encoded["amount"] as? String, "12.5") + XCTAssertEqual(encoded["currencyCode"] as? String, "USD") + } + XCTAssertThrowsError(try decode(StorefrontAPI.MoneyV2.self, #"{"amount":"invalid","currencyCode":"USD"}"#)) + } + + func testDiscountVariantsDecodeTheirAmountsAndEncodeTheirPayloads() throws { + let application = #""" + {"targetSelection":"ALL","targetType":"LINE_ITEM","value":{"__typename":"PricingPercentageValue","percentage":10}} + """# + for name in ["CartAutomaticDiscountAllocation", "CartCodeDiscountAllocation", "CartCustomDiscountAllocation"] { + let json = """ + {"__typename":"\(name)","code":"SAVE","discountApplication":\(application),"targetType":"LINE_ITEM", + "discountedAmount":{"amount":"2.50","currencyCode":"USD"}} + """ + let allocation = try decode(StorefrontAPI.CartDiscountAllocation.self, json) + let encoded = try object(allocation) + let money = try XCTUnwrap(encoded["discountedAmount"] as? [String: Any]) + XCTAssertEqual(money["amount"] as? String, "2.5") + XCTAssertEqual(money["currencyCode"] as? String, "USD") + switch (name, allocation) { + case ("CartAutomaticDiscountAllocation", .automatic): XCTAssertNil(encoded["code"]) + case ("CartCodeDiscountAllocation", .code): XCTAssertEqual(encoded["code"] as? String, "SAVE") + case ("CartCustomDiscountAllocation", .custom): XCTAssertNil(encoded["code"]) + default: XCTFail("Decoded the wrong discount variant for \(name)") + } + } + XCTAssertThrowsError(try decode(StorefrontAPI.CartDiscountAllocation.self, #"{"__typename":"Unknown"}"#)) + let fixed = try decode(StorefrontAPI.PricingValue.self, #"{"__typename":"MoneyV2","amount":"5","currencyCode":"CAD"}"#) + guard case let .fixedAmount(money) = fixed else { return XCTFail("Expected a fixed discount") } + XCTAssertEqual(money.amount, 5) + XCTAssertEqual(try object(fixed)["currencyCode"] as? String, "CAD") + } + + func testPreparationVariantsRetainTheirPayloadWhenEncoded() throws { + let ready = try decode(StorefrontAPI.CartPrepareForCompletionResult.self, #"{"__typename":"CartStatusReady","checkoutURL":"https://example.com/checkout"}"#) + guard case .ready = ready else { return XCTFail("Expected ready") } + XCTAssertEqual(try object(ready)["checkoutURL"] as? String, "https://example.com/checkout") + let notReady = try decode(StorefrontAPI.CartPrepareForCompletionResult.self, #"{"__typename":"CartStatusNotReady","errors":[]}"#) + guard case .notReady = notReady else { return XCTFail("Expected not ready") } + XCTAssertEqual(try (object(notReady)["errors"] as? [Any])?.count, 0) + let throttled = try decode(StorefrontAPI.CartPrepareForCompletionResult.self, #"{"__typename":"CartThrottled","pollAfter":"2026-01-01T00:00:00Z"}"#) + guard case .throttled = throttled else { return XCTFail("Expected throttled") } + XCTAssertNotNil(try object(throttled)["pollAfter"]) + } + + func testSubmissionVariantsRetainTheirPayloadWhenEncoded() throws { + let success = try decode(StorefrontAPI.CartSubmitForCompletionResult.self, #"{"__typename":"SubmitSuccess","redirectUrl":"https://example.com/complete"}"#) + guard case .success = success else { return XCTFail("Expected success") } + XCTAssertEqual(try object(success)["redirectUrl"] as? String, "https://example.com/complete") + let failed = try decode(StorefrontAPI.CartSubmitForCompletionResult.self, #"{"__typename":"SubmitFailed","errors":[]}"#) + guard case .failed = failed else { return XCTFail("Expected failed") } + XCTAssertEqual(try (object(failed)["errors"] as? [Any])?.count, 0) + let accepted = try decode(StorefrontAPI.CartSubmitForCompletionResult.self, #"{"__typename":"SubmitAlreadyAccepted","attemptId":"attempt"}"#) + guard case .alreadyAccepted = accepted else { return XCTFail("Expected already accepted") } + XCTAssertEqual(try object(accepted)["attemptId"] as? String, "attempt") + let throttled = try decode(StorefrontAPI.CartSubmitForCompletionResult.self, #"{"__typename":"SubmitThrottled","pollAfter":"2026-01-01T00:00:00Z"}"#) + guard case .throttled = throttled else { return XCTFail("Expected throttled") } + XCTAssertNotNil(try object(throttled)["pollAfter"]) + } + + func testStorefrontErrorsExplainTheFailureAndRecovery() { + let payload = StorefrontAPI.CartApiPayload.cartPrepareForCompletion(.init(result: nil, userErrors: [])) + let cases: [(StorefrontAPI.Errors, String, String)] = [ + (.payload(propertyName: "cart"), "Request Payload failed to unwrap property: cart", "Check the previous request had a property named: cart"), + (.notImplemented, "NOT_IMPLEMENTED", "Check the implementation of the method"), + (.invariant(message: "Missing cart"), "Missing cart", ""), + (.response(requestName: "prepare", message: "Invalid", payload: payload), "Request: prepare Failed. Message: Invalid", "Check the API payload for more details: prepare"), + (.nilCart(requestName: "prepare"), "Request: prepare failed. Cart is nil", "Check the API payload for more details: prepare"), + (.currencyChanged, "The currency has changed since the cart was created", "The currency has changed since the cart was created"), + (.warning(type: .outOfStock, cart: nil), "Request failed with outOfStock warning.", "Address the outOfStock warning and try again") + ] + for (error, reason, recovery) in cases { + XCTAssertEqual(error.failureReason, reason) + XCTAssertEqual(error.recoverySuggestion, recovery) + } + let error = StorefrontAPI.Errors.userError(userErrors: [.init(code: .invalid, message: "Invalid email", field: ["email"])], cart: nil) + XCTAssertEqual(error.failureReason, "Request failed with 1 userErrors.") + XCTAssertTrue(error.recoverySuggestion?.contains("Invalid email") == true) + XCTAssertTrue(error.recoverySuggestion?.contains("email") == true) + } +} diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandlerAddressValidationTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandlerAddressValidationTests.swift new file mode 100644 index 000000000..9f7d4f993 --- /dev/null +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandlerAddressValidationTests.swift @@ -0,0 +1,115 @@ +import Contacts +import PassKit +@testable import ShopifyAcceleratedCheckouts +import XCTest + +@available(iOS 17.0, *) +final class ErrorHandlerAddressValidationTests: XCTestCase { + func testAddressValidationIdentifiesTheEditableFieldAndLocalizedMessage() throws { + let rules: [(StorefrontAPI.CartErrorCode, String, [String])] = [ + (.addressFieldContainsEmojis, "emojis", ["firstName", "lastName", "address1", "address2", "city", "zip"]), + (.addressFieldContainsHtmlTags, "html_tags", ["firstName", "lastName", "address1", "address2", "city"]), + (.addressFieldContainsUrl, "url", ["firstName", "lastName"]), + (.addressFieldDoesNotMatchExpectedPattern, "invalid", ["firstName", "lastName", "phone"]), + (.addressFieldIsRequired, "missing", ["firstName", "lastName", "address1", "address2", "city", "zip", "phone"]), + (.addressFieldIsTooLong, "too_long", ["firstName", "lastName", "address1", "address2", "city"]) + ] + let suffixes = ["firstName": "first_name", "lastName": "last_name", "zip": "postal_code"] + let postalKeys = [ + "address1": CNPostalAddressStreetKey, + "address2": CNPostalAddressStreetKey, + "city": CNPostalAddressCityKey, + "zip": CNPostalAddressPostalCodeKey + ] + for (code, category, fields) in rules { + for field in fields { + let action = ErrorHandler.map( + errors: [.init(code: code, message: "Invalid field", field: ["addresses", "0", "address", "deliveryAddress", field])], + shippingCountry: "US", cart: .testCart + ) + guard case let .showError(errors) = action else { + XCTFail("Expected an editable error for \(code), \(field)") + continue + } + XCTAssertEqual(errors.count, 1) + let error = try XCTUnwrap(errors.first) as NSError + XCTAssertEqual(error.domain, PKPaymentErrorDomain) + XCTAssertEqual(error.localizedDescription, "errors.\(category).\(suffixes[field] ?? field)".localizedString) + if let postalKey = postalKeys[field] { + XCTAssertEqual(error.userInfo[PKPaymentErrorKey.postalAddressUserInfoKey.rawValue] as? String, postalKey) + } else { + let contact: PKContactField = field == "phone" ? .phoneNumber : .name + XCTAssertEqual(error.userInfo[PKPaymentErrorKey.contactFieldUserInfoKey.rawValue] as? PKContactField, contact) + } + } + } + } + + func testUnsupportedAddressFieldsFallBackToTheCheckoutURL() { + let codes: [StorefrontAPI.CartErrorCode] = [ + .addressFieldContainsEmojis, + .addressFieldContainsHtmlTags, + .addressFieldContainsUrl, + .addressFieldDoesNotMatchExpectedPattern, + .addressFieldIsRequired, + .addressFieldIsTooLong + ] + for code in codes { + assertInterrupt(code, field: ["unsupported"], reason: .unhandled) + } + } + + func testNonEditableErrorsPreserveTheCheckoutURLAndReason() { + let unhandled: [StorefrontAPI.CartErrorCode] = [ + .invalidMerchandiseLine, .giftCardRecipientInvalid, .invalidPaymentEmptyCart, + .invalidIncrement, .invalidMetafields, .onlyOneDeliveryAddressCanBeSelected, + .paymentsCreditCardBaseExpired, .pendingDeliveryGroups, .invalidCompanyLocation, + .invalidDeliveryAddressId, .variantRequiresSellingPlan, .cartTooLarge, .notApplicable, + .insufficientBalance, .deliveryAddressSizeExceeded, .unknownValue + ] + for code in unhandled { + assertInterrupt(code, reason: .unhandled) + } + for code: StorefrontAPI.CartErrorCode in [.merchandiseLineTransformersRunError, .validationCustom, .paymentMethodUnavailable] { + assertInterrupt(code, reason: .other) + } + assertInterrupt(.tooManyLineItems, reason: .outOfStock) + assertInterrupt(.notEnoughStock, reason: .notEnoughStock) + assertInterrupt(.invalid, field: ["input", "lines", "0", "quantity"], reason: .outOfStock) + assertInterrupt(.invalid, field: ["unknown"], reason: .unhandled) + assertInterrupt(.invalidPayment, field: ["amount"], reason: .other) + assertInterrupt(.invalidPayment, field: ["payment", "amount"], reason: .other) + assertInterrupt(.invalidPayment, field: ["unknown"], reason: .unhandled) + assertInterrupt(.paymentMethodNotSupported, field: ["payment", "walletPaymentMethod", "applePayWalletContent"], reason: .other) + assertInterrupt(.paymentMethodNotSupported, field: ["unknown"], reason: .unhandled) + } + + func testPostalErrorsPointToTheCorrectCountrySpecificField() throws { + let cases: [(StorefrontAPI.CartErrorCode, String, String)] = [ + (.invalidZipCodeForCountry, "US", CNPostalAddressPostalCodeKey), + (.invalidZipCodeForProvince, "CA", CNPostalAddressPostalCodeKey), + (.provinceNotFound, "AE", CNPostalAddressSubLocalityKey), + (.provinceNotFound, "CA", CNPostalAddressSubAdministrativeAreaKey) + ] + for (code, country, key) in cases { + let action = ErrorHandler.map(errors: [.init(code: code, message: "Invalid address", field: nil)], shippingCountry: country, cart: nil) + guard case let .showError(errors) = action else { return XCTFail("Expected address correction for \(code)") } + let error = try XCTUnwrap(errors.first) as NSError + XCTAssertEqual(error.userInfo[PKPaymentErrorKey.postalAddressUserInfoKey.rawValue] as? String, key) + } + } + + private func assertInterrupt( + _ code: StorefrontAPI.CartErrorCode, + field: [String]? = nil, + reason: ErrorHandler.InterruptReason, + file: StaticString = #filePath, + line: UInt = #line + ) { + let cart = StorefrontAPI.Cart.testCart + let result = ErrorHandler.map(errors: [.init(code: code, message: "Cannot complete", field: field)], shippingCountry: "US", cart: cart) + guard case let .interrupt(actualReason, url) = result else { return XCTFail("Expected fallback for \(code)", file: file, line: line) } + XCTAssertEqual(actualReason, reason, file: file, line: line) + XCTAssertEqual(url, cart.checkoutUrl.url, file: file, line: line) + } +} diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/PassKitDiscountSummaryTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/PassKitDiscountSummaryTests.swift new file mode 100644 index 000000000..f2769bb8b --- /dev/null +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/PassKitDiscountSummaryTests.swift @@ -0,0 +1,81 @@ +import PassKit +@testable import ShopifyAcceleratedCheckouts +import XCTest + +@available(iOS 17.0, *) +final class PassKitDiscountSummaryTests: XCTestCase { + private func money(_ amount: Decimal) -> StorefrontAPI.MoneyV2 { + .init(amount: amount, currencyCode: "USD") + } + + private func cart() -> StorefrontAPI.Cart { + let application = StorefrontAPI.CartDiscountApplication(targetSelection: .all, targetType: .lineItem, value: .percentage(.init(percentage: 10))) + let automatic = StorefrontAPI.CartDiscountAllocation.automatic(.init(discountApplication: application, discountedAmount: money(2), targetType: .lineItem)) + let custom = StorefrontAPI.CartDiscountAllocation.custom(.init(discountApplication: application, discountedAmount: money(1), targetType: .lineItem)) + let lineCode = StorefrontAPI.CartDiscountAllocation.code(.init(code: "SAVE", discountApplication: application, discountedAmount: money(3), targetType: .lineItem)) + let cartCode = StorefrontAPI.CartDiscountAllocation.code(.init(code: "SAVE", discountApplication: application, discountedAmount: money(4), targetType: .lineItem)) + let deliveryTypes: [(StorefrontAPI.CartDeliveryGroupType, Decimal)] = [(.oneTimePurchase, 5), (.subscription, 7)] + let groups: [StorefrontAPI.CartDeliveryGroup] = deliveryTypes.map { type, price in + let option = StorefrontAPI.CartDeliveryOption( + handle: "\(price)", + title: "Shipping", + code: nil, + deliveryMethodType: .shipping, + description: nil, + estimatedCost: money(price) + ) + return .init(id: .init("group-\(price)"), groupType: type, deliveryOptions: [option], selectedDeliveryOption: option) + } + return .init( + id: .init("cart"), + checkoutUrl: .init(URL(string: "https://example.com/checkout")!), + totalQuantity: 1, + buyerIdentity: nil, + deliveryGroups: .init(nodes: groups), + delivery: nil, + lines: .init(nodes: [.init( + id: .init("line"), + quantity: 1, + merchandise: nil, + cost: .init(totalAmount: money(94), subtotalAmount: money(100)), + discountAllocations: [automatic, custom, lineCode] + )]), + cost: .init(totalAmount: money(115), subtotalAmount: money(100), totalTaxAmount: money(10), totalDutyAmount: money(3)), + discountCodes: [.init(code: "SAVE", applicable: true), .init(code: "FREESHIP", applicable: true), .init(code: "EXPIRED", applicable: false)], + discountAllocations: [cartCode] + ) + } + + func testDiscountAllocationsAvoidDuplicatingCodesAndPreserveCurrency() throws { + let allocations = try PassKitFactory.shared.createDiscountAllocations(cart: cart()) + XCTAssertEqual(allocations.count, 5) + XCTAssertTrue(allocations.allSatisfy { $0.currencyCode == "USD" }) + XCTAssertEqual(allocations.filter { $0.code == "SAVE" }.map(\.amount).reduce(0, +), 7) + XCTAssertEqual(allocations.filter { $0.code == nil }.map(\.amount).reduce(0, +), 3) + XCTAssertEqual(allocations.filter { $0.code == "FREESHIP" }.map(\.amount), [0]) + XCTAssertFalse(allocations.contains { $0.code == "EXPIRED" }) + } + + func testSummaryGroupsDiscountsAndSeparatesSubscriptionShipping() { + let items = PassKitFactory.shared.mapToApplePayLineItems(cart: cart(), merchantName: "Example") + let amounts = Dictionary(uniqueKeysWithValues: items.map { ($0.label, $0.amount.decimalValue) }) + XCTAssertEqual(items.count, 8) + XCTAssertEqual(amounts["order_summary.subtotal".localizedString], 100) + XCTAssertEqual(amounts["order_summary.shipping_one_time_purchase".localizedString], 5) + XCTAssertEqual(amounts["order_summary.shipping_subscription".localizedString], 7) + XCTAssertEqual(amounts["order_summary.duties".localizedString], 3) + XCTAssertEqual(amounts["order_summary.taxes".localizedString], 10) + XCTAssertEqual(amounts["SAVE"], -7) + XCTAssertEqual(amounts["order_summary.discount".localizedString], -3) + XCTAssertNil(amounts["FREESHIP"]) + XCTAssertEqual(items.last?.label, "Example") + XCTAssertEqual(items.last?.amount.decimalValue, 115) + XCTAssertEqual(items.dropLast().map { $0.amount.decimalValue }.reduce(0, +), 115) + XCTAssertTrue(items.allSatisfy { $0.type == .final }) + } + + func testMissingCartCannotProducePaymentItemsOrDiscounts() { + XCTAssertTrue(PassKitFactory.shared.mapToApplePayLineItems(cart: nil, merchantName: "Example").isEmpty) + XCTAssertThrowsError(try PassKitFactory.shared.createDiscountAllocations(cart: nil)) + } +}