Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Foundation
@testable import ShopifyAcceleratedCheckouts
import XCTest

@available(iOS 17.0, *)
final class StorefrontAPICodingTests: XCTestCase {
private func decode<T: Decodable>(_ 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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading