From 52a1cbb3ba319ca3394f45841c226adec6fcd1b1 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 4 Nov 2026 14:52:42 +0100 Subject: [PATCH 1/6] fix(ad-hoc): reuse Checkout 3DS transaction for challenge Checkout's createTransaction() closes the current transaction and creates a new one with fresh SDK transaction ID and ephemeral keys, so performing the challenge on a second transaction did not match the authentication request. * Retain transaction created during authentication and reuse it for challenge. * Track service state explicitly to match PONetcetera3DS2Service. * Release SDK resources when authentication fails. * Close transaction on clean(). --- .../Service/POCheckout3DSService.swift | 154 +++++++++++++++--- .../Service/PONetcetera3DS2Service.swift | 3 +- 2 files changed, 130 insertions(+), 27 deletions(-) diff --git a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift index c5a161d6a..a337c7733 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift @@ -9,6 +9,10 @@ import Checkout3DS /// 3DS2 service implementation that is based on Checkout3DS. +/// +/// 3DS flow is strictly sequential: an authentication, optionally followed by a challenge, and +/// `clean()` at the end. The service does not serialize concurrent calls. Overlapping operations +/// are considered a developer error and are rejected. @MainActor public final class POCheckout3DSService: PO3DS2Service { @@ -17,6 +21,7 @@ public final class POCheckout3DSService: PO3DS2Service { self.errorMapper = DefaultAuthenticationErrorMapper() self.configurationMapper = DefaultConfigurationMapper() self.eventEmitter = ProcessOut.shared.eventEmitter + self.logger = ProcessOut.shared.logger self.delegate = delegate self.environment = environment } @@ -28,20 +33,30 @@ public final class POCheckout3DSService: PO3DS2Service { ) async throws -> PO3DS2AuthenticationRequestParameters { delegate?.checkout3DSService(self, willCreateAuthenticationRequestParametersWith: configuration) do { - let service = try Standalone3DSService.initialize( - with: await serviceConfiguration(with: configuration), environment: environment - ) - self.service = service - guard await delegate?.checkout3DSService(self, shouldContinueWith: await service.warnings) ?? true else { - throw POFailure(code: .Mobile.cancelled) + if Task.isCancelled { + throw POFailure(message: "Authentication was cancelled.", code: .Mobile.cancelled) } - let authenticationRequest = authenticationRequest( - with: try await service.createTransaction().getAuthenticationRequestParameters() - ) - delegate?.checkout3DSService( - self, didCreateAuthenticationRequestParameters: .success(authenticationRequest) - ) - return authenticationRequest + switch state { + case .idle: + break + case .ready: + throw POFailure( + message: "Previous transaction was not cleaned, call clean() first.", code: .Mobile.generic + ) + case .authenticating, .challenging, .cleaning: + throw POFailure(message: "Another operation is already in progress.", code: .Mobile.generic) + } + state = .authenticating + let (context, parameters): (TransactionContext, PO3DS2AuthenticationRequestParameters) + do { + (context, parameters) = try await createTransaction(configuration: configuration) + } catch { + state = .idle + throw error + } + state = .ready(context) + delegate?.checkout3DSService(self, didCreateAuthenticationRequestParameters: .success(parameters)) + return parameters } catch { let failure = failure(with: error) delegate?.checkout3DSService(self, didCreateAuthenticationRequestParameters: .failure(failure)) @@ -52,16 +67,18 @@ public final class POCheckout3DSService: PO3DS2Service { public func performChallenge(with parameters: PO3DS2ChallengeParameters) async throws -> PO3DS2ChallengeResult { delegate?.checkout3DSService(self, willPerformChallengeWith: parameters) do { - guard let transaction = service?.createTransaction() else { - throw POFailure(code: .Mobile.generic) + if Task.isCancelled { + throw POFailure(message: "Challenge was cancelled.", code: .Mobile.cancelled) } - observeDeepLinks() - let authenticationResult = try await transaction.doChallenge( - challengeParameters: challengeParameters(with: parameters) - ) - let challengeResult = PO3DS2ChallengeResult( - transactionStatus: authenticationResult.transactionStatus ?? "N" - ) + guard case .ready(let context) = state, + context.transactionId == parameters.threeDSServerTransactionId else { + throw POFailure(message: "Unable to resolve current transaction.", code: .Mobile.generic) + } + state = .challenging(context) + defer { + state = .ready(context) + } + let challengeResult = try await performChallenge(with: parameters, in: context) delegate?.checkout3DSService(self, didPerformChallenge: .success(challengeResult)) return challengeResult } catch { @@ -72,9 +89,41 @@ public final class POCheckout3DSService: PO3DS2Service { } public func clean() async { - deepLinkObservation = nil - service?.cleanUp() - service = nil + switch state { + case .idle: + break + case .ready(let context): + state = .cleaning + tearDown(service: context.service, transaction: context.transaction) + deepLinkObservation = nil + state = .idle + case .authenticating, .challenging, .cleaning: + logger.warn("Ignoring clean() while another operation is in progress.") + } + } + + // MARK: - Private Nested Types + + private enum State { + + /// No transaction. The only state from which authentication may start. + case idle + + /// Authentication request parameters are being created. + case authenticating + + /// Transaction exists and may be challenged or cleaned. + case ready(TransactionContext) + + /// Challenge is being performed on the transaction. + case challenging(TransactionContext) + + /// Transaction is being closed and SDK resources are being released. + case cleaning + } + + private struct TransactionContext { + let service: ThreeDS2Service, transaction: Transaction, transactionId: String } // MARK: - Private Properties @@ -82,13 +131,66 @@ public final class POCheckout3DSService: PO3DS2Service { private let errorMapper: AuthenticationErrorMapper private let configurationMapper: ConfigurationMapper private let eventEmitter: POEventEmitter + private let logger: POLogger private let environment: Checkout3DS.Environment private let delegate: POCheckout3DSServiceDelegate? - private var service: ThreeDS2Service? + private var state: State = .idle private var deepLinkObservation: AnyObject? + // MARK: - Authentication + + /// Initializes the SDK and creates a transaction. Resources created before a failure are + /// released here, so a failed authentication leaves nothing behind to clean. + /// + /// - NOTE: Transaction must be retained and reused for the challenge. Checkout's + /// `createTransaction()` closes the current transaction and creates a new one with fresh + /// SDK transaction ID and ephemeral keys, which would not match authentication request. + private func createTransaction( + configuration: PO3DS2Configuration + ) async throws -> (TransactionContext, PO3DS2AuthenticationRequestParameters) { + var initializedService: ThreeDS2Service?, createdTransaction: Transaction? + do { + let service = try Standalone3DSService.initialize( + with: await serviceConfiguration(with: configuration), environment: environment + ) + initializedService = service + if let delegate, await !delegate.checkout3DSService(self, shouldContinueWith: await service.warnings) { + throw POFailure(code: .Mobile.cancelled) + } + let transaction = service.createTransaction() + createdTransaction = transaction + let parameters = authenticationRequest(with: try await transaction.getAuthenticationRequestParameters()) + let context = TransactionContext( + service: service, transaction: transaction, transactionId: configuration.directoryServerTransactionId + ) + return (context, parameters) + } catch { + tearDown(service: initializedService, transaction: createdTransaction) + throw error + } + } + + // MARK: - Challenge + + private func performChallenge( + with parameters: PO3DS2ChallengeParameters, in context: TransactionContext + ) async throws -> PO3DS2ChallengeResult { + observeDeepLinks() + let authenticationResult = try await context.transaction.doChallenge( + challengeParameters: challengeParameters(with: parameters) + ) + return PO3DS2ChallengeResult(transactionStatus: authenticationResult.transactionStatus ?? "N") + } + + // MARK: - Clean + + private func tearDown(service: ThreeDS2Service?, transaction: Transaction?) { + transaction?.close() + service?.cleanUp() + } + // MARK: - Mapping private func serviceConfiguration( diff --git a/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift b/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift index 21d73b956..c52c39b8f 100644 --- a/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift +++ b/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift @@ -137,7 +137,8 @@ public actor PONetcetera3DS2Service: PO3DS2Service { // MARK: - Private Properties private let configuration: PONetcetera3DS2ServiceConfiguration - private let eventEmitter: POEventEmitter, logger: POLogger + private let eventEmitter: POEventEmitter + private let logger: POLogger private var state: State = .idle private var deepLinkObservation: AnyObject? From 020900f6b1a6658766cfcaa5aef56730336501ad Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 4 Nov 2026 15:22:18 +0100 Subject: [PATCH 2/6] test(ad-hoc): cover Checkout 3DS service * Add internal initializer to inject Checkout 3DS service provider. * Port Netcetera 3DS service tests to Checkout 3DS service. * Verify that challenge reuses authentication transaction. * Rely on Checkout cleanUp() to close current transaction. * Disable type_body_length for PONetcetera3DS2Service. --- .../Service/POCheckout3DSService.swift | 52 +++-- .../Service/PONetcetera3DS2Service.swift | 4 + .../Sources/Checkout3DSServiceTests.swift | 15 -- .../Service/Checkout3DSServiceTests.swift | 183 ++++++++++++++++++ .../Sources/Mocks/MockThreeDS2Service.swift | 78 ++++++++ .../Checkout3DSServiceTransactionTests.swift | 86 ++++++++ 6 files changed, 387 insertions(+), 31 deletions(-) delete mode 100644 Tests/ProcessOutCheckout3DSTests/Sources/Checkout3DSServiceTests.swift create mode 100644 Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift create mode 100644 Tests/ProcessOutCheckout3DSTests/Sources/Mocks/MockThreeDS2Service.swift create mode 100644 Tests/ProcessOutCheckout3DSTests/Sources/Unit/Service/Checkout3DSServiceTransactionTests.swift diff --git a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift index a337c7733..dd56074ce 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift @@ -17,13 +17,15 @@ import Checkout3DS public final class POCheckout3DSService: PO3DS2Service { /// Creates service instance. - public nonisolated init(delegate: POCheckout3DSServiceDelegate? = nil, environment: Environment = .production) { - self.errorMapper = DefaultAuthenticationErrorMapper() - self.configurationMapper = DefaultConfigurationMapper() - self.eventEmitter = ProcessOut.shared.eventEmitter - self.logger = ProcessOut.shared.logger - self.delegate = delegate - self.environment = environment + public nonisolated convenience init( + delegate: POCheckout3DSServiceDelegate? = nil, environment: Environment = .production + ) { + self.init( + delegate: delegate, + environment: environment, + eventEmitter: ProcessOut.shared.eventEmitter, + logger: ProcessOut.shared.logger + ) } // MARK: - PO3DS2Service @@ -94,7 +96,7 @@ public final class POCheckout3DSService: PO3DS2Service { break case .ready(let context): state = .cleaning - tearDown(service: context.service, transaction: context.transaction) + tearDown(service: context.service) deepLinkObservation = nil state = .idle case .authenticating, .challenging, .cleaning: @@ -102,6 +104,26 @@ public final class POCheckout3DSService: PO3DS2Service { } } + // MARK: - + + typealias ServiceProvider = @MainActor (ThreeDS2ServiceConfiguration, Environment) throws -> ThreeDS2Service + + nonisolated init( + delegate: POCheckout3DSServiceDelegate? = nil, + environment: Environment = .production, + eventEmitter: POEventEmitter, + logger: POLogger, + serviceProvider: @escaping ServiceProvider = { try Standalone3DSService.initialize(with: $0, environment: $1) } + ) { + self.errorMapper = DefaultAuthenticationErrorMapper() + self.configurationMapper = DefaultConfigurationMapper() + self.eventEmitter = eventEmitter + self.logger = logger + self.delegate = delegate + self.environment = environment + self.serviceProvider = serviceProvider + } + // MARK: - Private Nested Types private enum State { @@ -135,6 +157,7 @@ public final class POCheckout3DSService: PO3DS2Service { private let environment: Checkout3DS.Environment private let delegate: POCheckout3DSServiceDelegate? + private let serviceProvider: ServiceProvider private var state: State = .idle private var deepLinkObservation: AnyObject? @@ -150,24 +173,21 @@ public final class POCheckout3DSService: PO3DS2Service { private func createTransaction( configuration: PO3DS2Configuration ) async throws -> (TransactionContext, PO3DS2AuthenticationRequestParameters) { - var initializedService: ThreeDS2Service?, createdTransaction: Transaction? + var initializedService: ThreeDS2Service? do { - let service = try Standalone3DSService.initialize( - with: await serviceConfiguration(with: configuration), environment: environment - ) + let service = try serviceProvider(await serviceConfiguration(with: configuration), environment) initializedService = service if let delegate, await !delegate.checkout3DSService(self, shouldContinueWith: await service.warnings) { throw POFailure(code: .Mobile.cancelled) } let transaction = service.createTransaction() - createdTransaction = transaction let parameters = authenticationRequest(with: try await transaction.getAuthenticationRequestParameters()) let context = TransactionContext( service: service, transaction: transaction, transactionId: configuration.directoryServerTransactionId ) return (context, parameters) } catch { - tearDown(service: initializedService, transaction: createdTransaction) + tearDown(service: initializedService) throw error } } @@ -186,8 +206,8 @@ public final class POCheckout3DSService: PO3DS2Service { // MARK: - Clean - private func tearDown(service: ThreeDS2Service?, transaction: Transaction?) { - transaction?.close() + /// - NOTE: Checkout's `cleanUp()` closes the current transaction, so transaction is not closed explicitly. + private func tearDown(service: ThreeDS2Service?) { service?.cleanUp() } diff --git a/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift b/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift index c52c39b8f..f55e65130 100644 --- a/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift +++ b/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift @@ -5,6 +5,8 @@ // Created by Andrii Vysotskyi on 22.04.2025. // +// swiftlint:disable type_body_length + @_spi(PO) import ProcessOut @_spi(PO) import NetceteraShim import ThreeDS_SDK @@ -328,3 +330,5 @@ public actor PONetcetera3DS2Service: PO3DS2Service { } } } + +// swiftlint:enable type_body_length diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Checkout3DSServiceTests.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Checkout3DSServiceTests.swift deleted file mode 100644 index 48243d281..000000000 --- a/Tests/ProcessOutCheckout3DSTests/Sources/Checkout3DSServiceTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Checkout3DSServiceTests.swift -// ProcessOutCheckout3DSTests -// -// Created by Andrii Vysotskyi on 17.04.2023. -// - -import Testing -import ProcessOutCheckout3DS - -struct Checkout3DSServiceTests { - - @Test - func example() throws { } -} diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift new file mode 100644 index 000000000..08f7325c2 --- /dev/null +++ b/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift @@ -0,0 +1,183 @@ +// +// Checkout3DSServiceTests.swift +// ProcessOutCheckout3DSTests +// +// Created by Andrii Vysotskyi on 17.04.2023. +// + +import Foundation +import Testing +@testable import ProcessOutCheckout3DS +@_spi(PO) @testable import ProcessOut + +@MainActor +@Suite(.serialized) +struct Checkout3DSServiceTests { + + init() async { + processOut = await .init(configuration: .init(projectId: "proj_test")) + } + + // MARK: - + + @Test + func authenticationRequestParameters_whenConfigurationIsInvalid_fails() async throws { + // Given + let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) + let configuration = PO3DS2Configuration( + directoryServerId: "", + directoryServerPublicKey: "", + directoryServerRootCertificates: [], + directoryServerTransactionId: "", + messageVersion: "" + ) + + // When + await withKnownIssue { + _ = try await sut.authenticationRequestParameters(configuration: configuration) + } + + // Cleanup + await sut.clean() + } + + @Test + func authenticationRequestParameters_whenConfigurationIsValid_succeeds() async throws { + // Given + let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) + + // When + let requestParameters = try await sut.authenticationRequestParameters( + configuration: Self.validConfiguration() + ) + + // Then + #expect(!requestParameters.deviceData.isEmpty) + + // Cleanup + await sut.clean() + } + + // MARK: - State + + @Test + func authenticationRequestParameters_whenAnotherAuthenticationIsInProgress_fails() async throws { + // Given + let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) + _ = try await sut.authenticationRequestParameters(configuration: Self.validConfiguration()) + + // When + await withKnownIssue { + _ = try await sut.authenticationRequestParameters(configuration: Self.validConfiguration()) + } + + // Cleanup + await sut.clean() + } + + @Test + func authenticationRequestParameters_afterFailure_succeeds() async throws { + // Given + let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) + let invalidConfiguration = PO3DS2Configuration( + directoryServerId: "", + directoryServerPublicKey: "", + directoryServerRootCertificates: [], + directoryServerTransactionId: "", + messageVersion: "" + ) + await withKnownIssue { + _ = try await sut.authenticationRequestParameters(configuration: invalidConfiguration) + } + + // When + let requestParameters = try await sut.authenticationRequestParameters( + configuration: Self.validConfiguration() + ) + + // Then + #expect(!requestParameters.deviceData.isEmpty) + + // Cleanup + await sut.clean() + } + + @Test + func authenticationRequestParameters_afterClean_succeeds() async throws { + // Given + let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) + _ = try await sut.authenticationRequestParameters(configuration: Self.validConfiguration()) + await sut.clean() + + // When + let requestParameters = try await sut.authenticationRequestParameters( + configuration: Self.validConfiguration() + ) + + // Then + #expect(!requestParameters.deviceData.isEmpty) + + // Cleanup + await sut.clean() + } + + @Test + func performChallenge_whenThereIsNoTransaction_fails() async throws { + // Given + let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) + let parameters = PO3DS2ChallengeParameters( + acsTransactionId: "", acsReferenceNumber: "", acsSignedContent: "", threeDSServerTransactionId: "" + ) + + // When + await withKnownIssue { + _ = try await sut.performChallenge(with: parameters) + } + } + + @Test + func performChallenge_whenTransactionIdDoesNotMatch_fails() async throws { + // Given + let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) + _ = try await sut.authenticationRequestParameters(configuration: Self.validConfiguration()) + let parameters = PO3DS2ChallengeParameters( + acsTransactionId: "", acsReferenceNumber: "", acsSignedContent: "", threeDSServerTransactionId: "other" + ) + + // When + await withKnownIssue { + _ = try await sut.performChallenge(with: parameters) + } + + // Cleanup + await sut.clean() + } + + // MARK: - Private Methods + + private static func validConfiguration() -> PO3DS2Configuration { + PO3DS2Configuration( + directoryServerId: "0000123456", + directoryServerPublicKey: + """ + ewogICJrdHkiOiAiUlNBIiwKICAia2lkIjogImU0ODA1NGNkLTkxNDgtNGE3ZS1h\ + MjE5LWYyMTA1NzEyZmZkMyIsCiAgIm4iOiAiejZjWDFIMDRrTHRqcnJ2elIxVndz\ + OUtMTmF0ZkVOQ0JkMDliLUx4eDlKbENTMjRQaFZwbnBmU1ZrTThTYmNlWW9sLXJB\ + UUZTanYtVVpsTEFiUGY1R2lCNG9aSUV4XzBMc2V1WlNRZHNQNDVjM3FsVXc3eXFY\ + RmZDUVVqbUl0NEtvWndwOWNGdG1rRDFSemg1OFo0cjAzTnB6OVJ4X2dZQWk3bXlT\ + WGhSZ3IxYTBla3h6QjdoTDZDckRoNGRyTFJ3dEhfTHN3ZzBpRzBnUmRVVEdrZXNp\ + TU14RnFRa3ZDQ0loQUIxVFEzNnctUHQtYk8yWXFFUDMteWIwd1dicU9CQ0lseGRH\ + UXh2V0gydkJsTkNCZDhrMjNVYUZiYlYzVE9rWVQtdGhkOXByM0RuQ3JXWUdpWTdB\ + NVF3MUdDS0FCd3QtOWF0OHZOQnQ4Q2Q4WXNXeFk0a3JRIiwKICAiZSI6ICJBUUFC\ + IiwKfQ + """, + directoryServerRootCertificates: [], + directoryServerTransactionId: UUID().uuidString, + messageVersion: "2.2.0" + ) + } + + // MARK: - Private Properties + + private let processOut: ProcessOut +} diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Mocks/MockThreeDS2Service.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Mocks/MockThreeDS2Service.swift new file mode 100644 index 000000000..39d2262b8 --- /dev/null +++ b/Tests/ProcessOutCheckout3DSTests/Sources/Mocks/MockThreeDS2Service.swift @@ -0,0 +1,78 @@ +// +// MockThreeDS2Service.swift +// ProcessOutCheckout3DSTests +// +// Created by Andrii Vysotskyi on 04.11.2026. +// + +import Checkout3DS + +@MainActor +final class MockThreeDS2Service: ThreeDS2Service { + + /// Transactions created by service in order of creation. + private(set) var createdTransactions: [MockTransaction] = [] + + private(set) var cleanUpCallsCount = 0 + + // MARK: - ThreeDS2Service + + nonisolated func createTransaction() -> any Transaction { + MainActor.assumeIsolated { + let transaction = MockTransaction() + createdTransactions.append(transaction) + return transaction + } + } + + nonisolated func getWarnings() -> Set { + [] + } + + nonisolated func cleanUp() { + MainActor.assumeIsolated { + cleanUpCallsCount += 1 + } + } +} + +@MainActor +final class MockTransaction: Transaction { + + private(set) var doChallengeCallsCount = 0 + + // MARK: - Transaction + + nonisolated func getAuthenticationRequestParameters( + completion: @escaping (Result) -> Void + ) { + let parameters = AuthenticationRequestParameters( + deviceData: "device-data", + sdkTransactionID: "sdk-transaction-id", + sdkAppID: "sdk-app-id", + sdkReferenceNumber: "sdk-reference-number", + sdkEphemeralPublicKey: "sdk-ephemeral-public-key", + mssageVersion: "2.2.0" + ) + completion(.success(parameters)) + } + + nonisolated func doChallenge( + challengeParameters: ChallengeParameters, + completion: @escaping (Result) -> Void + ) { + MainActor.assumeIsolated { + doChallengeCallsCount += 1 + } + // AuthenticationResult can't be constructed outside of Checkout3DS. + completion(.failure(.challengeCancelled)) + } + + nonisolated func setProtocolErrorListener(_ listener: ((ProtocolErrorEvent) -> Void)?) { + // Ignored + } + + nonisolated func close() { + // Ignored + } +} diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Unit/Service/Checkout3DSServiceTransactionTests.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Unit/Service/Checkout3DSServiceTransactionTests.swift new file mode 100644 index 000000000..fca906ddb --- /dev/null +++ b/Tests/ProcessOutCheckout3DSTests/Sources/Unit/Service/Checkout3DSServiceTransactionTests.swift @@ -0,0 +1,86 @@ +// +// Checkout3DSServiceTransactionTests.swift +// ProcessOutCheckout3DSTests +// +// Created by Andrii Vysotskyi on 04.11.2026. +// + +import Foundation +import Testing +@testable import ProcessOutCheckout3DS +@_spi(PO) @testable import ProcessOut + +@MainActor +struct Checkout3DSServiceTransactionTests { + + init() async { + processOut = await .init(configuration: .init(projectId: "proj_test")) + let threeDSService = MockThreeDS2Service() + self.threeDSService = threeDSService + sut = POCheckout3DSService( + eventEmitter: processOut.eventEmitter, + logger: processOut.logger, + serviceProvider: { _, _ in threeDSService } + ) + } + + // MARK: - + + @Test + func performChallenge_reusesAuthenticationTransaction() async throws { + // Given + let configuration = Self.configuration() + _ = try await sut.authenticationRequestParameters(configuration: configuration) + + // When + _ = try? await sut.performChallenge( + with: Self.challengeParameters(transactionId: configuration.directoryServerTransactionId) + ) + + // Then + #expect(threeDSService.createdTransactions.count == 1) + #expect(threeDSService.createdTransactions.first?.doChallengeCallsCount == 1) + + // Cleanup + await sut.clean() + } + + @Test + func clean_cleansUpService() async throws { + // Given + _ = try await sut.authenticationRequestParameters(configuration: Self.configuration()) + + // When + await sut.clean() + + // Then + #expect(threeDSService.cleanUpCallsCount == 1) + } + + // MARK: - Private Methods + + private static func configuration() -> PO3DS2Configuration { + PO3DS2Configuration( + directoryServerId: "A000000003", + directoryServerPublicKey: "", + directoryServerRootCertificates: [], + directoryServerTransactionId: UUID().uuidString, + messageVersion: "2.2.0" + ) + } + + private static func challengeParameters(transactionId: String) -> PO3DS2ChallengeParameters { + PO3DS2ChallengeParameters( + acsTransactionId: "", + acsReferenceNumber: "", + acsSignedContent: "", + threeDSServerTransactionId: transactionId + ) + } + + // MARK: - Private Properties + + private let processOut: ProcessOut + private let threeDSService: MockThreeDS2Service + private let sut: POCheckout3DSService +} From 7efac5c50c41c00a2b1dab6bc0d9b9175d1ebcd9 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 4 Nov 2026 15:46:18 +0100 Subject: [PATCH 3/6] test(ad-hoc): test Checkout 3DS service via public API only * Remove internal initializer and service provider seam. * Remove mocked transaction reuse tests. * Decode request fixtures instead of using internal initializers. --- .../Service/POCheckout3DSService.swift | 41 ++---- .../Service/Checkout3DSServiceTests.swift | 130 ++++++++++-------- .../Sources/Mocks/MockThreeDS2Service.swift | 78 ----------- .../Checkout3DSServiceTransactionTests.swift | 86 ------------ 4 files changed, 81 insertions(+), 254 deletions(-) delete mode 100644 Tests/ProcessOutCheckout3DSTests/Sources/Mocks/MockThreeDS2Service.swift delete mode 100644 Tests/ProcessOutCheckout3DSTests/Sources/Unit/Service/Checkout3DSServiceTransactionTests.swift diff --git a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift index dd56074ce..2548c0726 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift @@ -17,15 +17,13 @@ import Checkout3DS public final class POCheckout3DSService: PO3DS2Service { /// Creates service instance. - public nonisolated convenience init( - delegate: POCheckout3DSServiceDelegate? = nil, environment: Environment = .production - ) { - self.init( - delegate: delegate, - environment: environment, - eventEmitter: ProcessOut.shared.eventEmitter, - logger: ProcessOut.shared.logger - ) + public nonisolated init(delegate: POCheckout3DSServiceDelegate? = nil, environment: Environment = .production) { + self.errorMapper = DefaultAuthenticationErrorMapper() + self.configurationMapper = DefaultConfigurationMapper() + self.eventEmitter = ProcessOut.shared.eventEmitter + self.logger = ProcessOut.shared.logger + self.delegate = delegate + self.environment = environment } // MARK: - PO3DS2Service @@ -104,26 +102,6 @@ public final class POCheckout3DSService: PO3DS2Service { } } - // MARK: - - - typealias ServiceProvider = @MainActor (ThreeDS2ServiceConfiguration, Environment) throws -> ThreeDS2Service - - nonisolated init( - delegate: POCheckout3DSServiceDelegate? = nil, - environment: Environment = .production, - eventEmitter: POEventEmitter, - logger: POLogger, - serviceProvider: @escaping ServiceProvider = { try Standalone3DSService.initialize(with: $0, environment: $1) } - ) { - self.errorMapper = DefaultAuthenticationErrorMapper() - self.configurationMapper = DefaultConfigurationMapper() - self.eventEmitter = eventEmitter - self.logger = logger - self.delegate = delegate - self.environment = environment - self.serviceProvider = serviceProvider - } - // MARK: - Private Nested Types private enum State { @@ -157,7 +135,6 @@ public final class POCheckout3DSService: PO3DS2Service { private let environment: Checkout3DS.Environment private let delegate: POCheckout3DSServiceDelegate? - private let serviceProvider: ServiceProvider private var state: State = .idle private var deepLinkObservation: AnyObject? @@ -175,7 +152,9 @@ public final class POCheckout3DSService: PO3DS2Service { ) async throws -> (TransactionContext, PO3DS2AuthenticationRequestParameters) { var initializedService: ThreeDS2Service? do { - let service = try serviceProvider(await serviceConfiguration(with: configuration), environment) + let service = try Standalone3DSService.initialize( + with: await serviceConfiguration(with: configuration), environment: environment + ) initializedService = service if let delegate, await !delegate.checkout3DSService(self, shouldContinueWith: await service.warnings) { throw POFailure(code: .Mobile.cancelled) diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift index 08f7325c2..d4b3ac741 100644 --- a/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift +++ b/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift @@ -7,15 +7,15 @@ import Foundation import Testing -@testable import ProcessOutCheckout3DS -@_spi(PO) @testable import ProcessOut +import ProcessOut +import ProcessOutCheckout3DS @MainActor @Suite(.serialized) struct Checkout3DSServiceTests { - init() async { - processOut = await .init(configuration: .init(projectId: "proj_test")) + init() { + ProcessOut.configure(configuration: .init(projectId: "proj_test")) } // MARK: - @@ -23,14 +23,8 @@ struct Checkout3DSServiceTests { @Test func authenticationRequestParameters_whenConfigurationIsInvalid_fails() async throws { // Given - let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) - let configuration = PO3DS2Configuration( - directoryServerId: "", - directoryServerPublicKey: "", - directoryServerRootCertificates: [], - directoryServerTransactionId: "", - messageVersion: "" - ) + let sut = POCheckout3DSService() + let configuration = try Self.invalidConfiguration() // When await withKnownIssue { @@ -44,11 +38,11 @@ struct Checkout3DSServiceTests { @Test func authenticationRequestParameters_whenConfigurationIsValid_succeeds() async throws { // Given - let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) + let sut = POCheckout3DSService() // When let requestParameters = try await sut.authenticationRequestParameters( - configuration: Self.validConfiguration() + configuration: try Self.validConfiguration() ) // Then @@ -63,12 +57,12 @@ struct Checkout3DSServiceTests { @Test func authenticationRequestParameters_whenAnotherAuthenticationIsInProgress_fails() async throws { // Given - let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) - _ = try await sut.authenticationRequestParameters(configuration: Self.validConfiguration()) + let sut = POCheckout3DSService() + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) // When await withKnownIssue { - _ = try await sut.authenticationRequestParameters(configuration: Self.validConfiguration()) + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) } // Cleanup @@ -78,21 +72,15 @@ struct Checkout3DSServiceTests { @Test func authenticationRequestParameters_afterFailure_succeeds() async throws { // Given - let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) - let invalidConfiguration = PO3DS2Configuration( - directoryServerId: "", - directoryServerPublicKey: "", - directoryServerRootCertificates: [], - directoryServerTransactionId: "", - messageVersion: "" - ) + let sut = POCheckout3DSService() + let invalidConfiguration = try Self.invalidConfiguration() await withKnownIssue { _ = try await sut.authenticationRequestParameters(configuration: invalidConfiguration) } // When let requestParameters = try await sut.authenticationRequestParameters( - configuration: Self.validConfiguration() + configuration: try Self.validConfiguration() ) // Then @@ -105,13 +93,13 @@ struct Checkout3DSServiceTests { @Test func authenticationRequestParameters_afterClean_succeeds() async throws { // Given - let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) - _ = try await sut.authenticationRequestParameters(configuration: Self.validConfiguration()) + let sut = POCheckout3DSService() + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) await sut.clean() // When let requestParameters = try await sut.authenticationRequestParameters( - configuration: Self.validConfiguration() + configuration: try Self.validConfiguration() ) // Then @@ -124,10 +112,8 @@ struct Checkout3DSServiceTests { @Test func performChallenge_whenThereIsNoTransaction_fails() async throws { // Given - let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) - let parameters = PO3DS2ChallengeParameters( - acsTransactionId: "", acsReferenceNumber: "", acsSignedContent: "", threeDSServerTransactionId: "" - ) + let sut = POCheckout3DSService() + let parameters = try Self.challengeParameters(transactionId: "") // When await withKnownIssue { @@ -138,11 +124,9 @@ struct Checkout3DSServiceTests { @Test func performChallenge_whenTransactionIdDoesNotMatch_fails() async throws { // Given - let sut = POCheckout3DSService(eventEmitter: processOut.eventEmitter, logger: processOut.logger) - _ = try await sut.authenticationRequestParameters(configuration: Self.validConfiguration()) - let parameters = PO3DS2ChallengeParameters( - acsTransactionId: "", acsReferenceNumber: "", acsSignedContent: "", threeDSServerTransactionId: "other" - ) + let sut = POCheckout3DSService() + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) + let parameters = try Self.challengeParameters(transactionId: "other") // When await withKnownIssue { @@ -155,29 +139,57 @@ struct Checkout3DSServiceTests { // MARK: - Private Methods - private static func validConfiguration() -> PO3DS2Configuration { - PO3DS2Configuration( - directoryServerId: "0000123456", - directoryServerPublicKey: - """ - ewogICJrdHkiOiAiUlNBIiwKICAia2lkIjogImU0ODA1NGNkLTkxNDgtNGE3ZS1h\ - MjE5LWYyMTA1NzEyZmZkMyIsCiAgIm4iOiAiejZjWDFIMDRrTHRqcnJ2elIxVndz\ - OUtMTmF0ZkVOQ0JkMDliLUx4eDlKbENTMjRQaFZwbnBmU1ZrTThTYmNlWW9sLXJB\ - UUZTanYtVVpsTEFiUGY1R2lCNG9aSUV4XzBMc2V1WlNRZHNQNDVjM3FsVXc3eXFY\ - RmZDUVVqbUl0NEtvWndwOWNGdG1rRDFSemg1OFo0cjAzTnB6OVJ4X2dZQWk3bXlT\ - WGhSZ3IxYTBla3h6QjdoTDZDckRoNGRyTFJ3dEhfTHN3ZzBpRzBnUmRVVEdrZXNp\ - TU14RnFRa3ZDQ0loQUIxVFEzNnctUHQtYk8yWXFFUDMteWIwd1dicU9CQ0lseGRH\ - UXh2V0gydkJsTkNCZDhrMjNVYUZiYlYzVE9rWVQtdGhkOXByM0RuQ3JXWUdpWTdB\ - NVF3MUdDS0FCd3QtOWF0OHZOQnQ4Q2Q4WXNXeFk0a3JRIiwKICAiZSI6ICJBUUFC\ - IiwKfQ - """, - directoryServerRootCertificates: [], - directoryServerTransactionId: UUID().uuidString, - messageVersion: "2.2.0" + private static func validConfiguration() throws -> PO3DS2Configuration { + try decode( + [ + "directoryServerID": "0000123456", + "directoryServerPublicKey": + """ + ewogICJrdHkiOiAiUlNBIiwKICAia2lkIjogImU0ODA1NGNkLTkxNDgtNGE3ZS1h\ + MjE5LWYyMTA1NzEyZmZkMyIsCiAgIm4iOiAiejZjWDFIMDRrTHRqcnJ2elIxVndz\ + OUtMTmF0ZkVOQ0JkMDliLUx4eDlKbENTMjRQaFZwbnBmU1ZrTThTYmNlWW9sLXJB\ + UUZTanYtVVpsTEFiUGY1R2lCNG9aSUV4XzBMc2V1WlNRZHNQNDVjM3FsVXc3eXFY\ + RmZDUVVqbUl0NEtvWndwOWNGdG1rRDFSemg1OFo0cjAzTnB6OVJ4X2dZQWk3bXlT\ + WGhSZ3IxYTBla3h6QjdoTDZDckRoNGRyTFJ3dEhfTHN3ZzBpRzBnUmRVVEdrZXNp\ + TU14RnFRa3ZDQ0loQUIxVFEzNnctUHQtYk8yWXFFUDMteWIwd1dicU9CQ0lseGRH\ + UXh2V0gydkJsTkNCZDhrMjNVYUZiYlYzVE9rWVQtdGhkOXByM0RuQ3JXWUdpWTdB\ + NVF3MUdDS0FCd3QtOWF0OHZOQnQ4Q2Q4WXNXeFk0a3JRIiwKICAiZSI6ICJBUUFC\ + IiwKfQ + """, + "directoryServerRootCAs": [], + "threeDSServerTransID": UUID().uuidString, + "messageVersion": "2.2.0" + ] + ) + } + + private static func invalidConfiguration() throws -> PO3DS2Configuration { + try decode( + [ + "directoryServerID": "", + "directoryServerPublicKey": "", + "directoryServerRootCAs": [], + "threeDSServerTransID": "", + "messageVersion": "" + ] ) } - // MARK: - Private Properties + private static func challengeParameters(transactionId: String) throws -> PO3DS2ChallengeParameters { + try decode( + [ + "acsTransID": "", + "acsReferenceNumber": "", + "acsSignedContent": "", + "threeDSServerTransID": transactionId + ] + ) + } - private let processOut: ProcessOut + /// Request types have no public initializers, so fixtures are decoded the same way the SDK + /// receives them from the API. + private static func decode(_ object: [String: Any]) throws -> T { + let data = try JSONSerialization.data(withJSONObject: object) + return try JSONDecoder().decode(T.self, from: data) + } } diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Mocks/MockThreeDS2Service.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Mocks/MockThreeDS2Service.swift deleted file mode 100644 index 39d2262b8..000000000 --- a/Tests/ProcessOutCheckout3DSTests/Sources/Mocks/MockThreeDS2Service.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// MockThreeDS2Service.swift -// ProcessOutCheckout3DSTests -// -// Created by Andrii Vysotskyi on 04.11.2026. -// - -import Checkout3DS - -@MainActor -final class MockThreeDS2Service: ThreeDS2Service { - - /// Transactions created by service in order of creation. - private(set) var createdTransactions: [MockTransaction] = [] - - private(set) var cleanUpCallsCount = 0 - - // MARK: - ThreeDS2Service - - nonisolated func createTransaction() -> any Transaction { - MainActor.assumeIsolated { - let transaction = MockTransaction() - createdTransactions.append(transaction) - return transaction - } - } - - nonisolated func getWarnings() -> Set { - [] - } - - nonisolated func cleanUp() { - MainActor.assumeIsolated { - cleanUpCallsCount += 1 - } - } -} - -@MainActor -final class MockTransaction: Transaction { - - private(set) var doChallengeCallsCount = 0 - - // MARK: - Transaction - - nonisolated func getAuthenticationRequestParameters( - completion: @escaping (Result) -> Void - ) { - let parameters = AuthenticationRequestParameters( - deviceData: "device-data", - sdkTransactionID: "sdk-transaction-id", - sdkAppID: "sdk-app-id", - sdkReferenceNumber: "sdk-reference-number", - sdkEphemeralPublicKey: "sdk-ephemeral-public-key", - mssageVersion: "2.2.0" - ) - completion(.success(parameters)) - } - - nonisolated func doChallenge( - challengeParameters: ChallengeParameters, - completion: @escaping (Result) -> Void - ) { - MainActor.assumeIsolated { - doChallengeCallsCount += 1 - } - // AuthenticationResult can't be constructed outside of Checkout3DS. - completion(.failure(.challengeCancelled)) - } - - nonisolated func setProtocolErrorListener(_ listener: ((ProtocolErrorEvent) -> Void)?) { - // Ignored - } - - nonisolated func close() { - // Ignored - } -} diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Unit/Service/Checkout3DSServiceTransactionTests.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Unit/Service/Checkout3DSServiceTransactionTests.swift deleted file mode 100644 index fca906ddb..000000000 --- a/Tests/ProcessOutCheckout3DSTests/Sources/Unit/Service/Checkout3DSServiceTransactionTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// Checkout3DSServiceTransactionTests.swift -// ProcessOutCheckout3DSTests -// -// Created by Andrii Vysotskyi on 04.11.2026. -// - -import Foundation -import Testing -@testable import ProcessOutCheckout3DS -@_spi(PO) @testable import ProcessOut - -@MainActor -struct Checkout3DSServiceTransactionTests { - - init() async { - processOut = await .init(configuration: .init(projectId: "proj_test")) - let threeDSService = MockThreeDS2Service() - self.threeDSService = threeDSService - sut = POCheckout3DSService( - eventEmitter: processOut.eventEmitter, - logger: processOut.logger, - serviceProvider: { _, _ in threeDSService } - ) - } - - // MARK: - - - @Test - func performChallenge_reusesAuthenticationTransaction() async throws { - // Given - let configuration = Self.configuration() - _ = try await sut.authenticationRequestParameters(configuration: configuration) - - // When - _ = try? await sut.performChallenge( - with: Self.challengeParameters(transactionId: configuration.directoryServerTransactionId) - ) - - // Then - #expect(threeDSService.createdTransactions.count == 1) - #expect(threeDSService.createdTransactions.first?.doChallengeCallsCount == 1) - - // Cleanup - await sut.clean() - } - - @Test - func clean_cleansUpService() async throws { - // Given - _ = try await sut.authenticationRequestParameters(configuration: Self.configuration()) - - // When - await sut.clean() - - // Then - #expect(threeDSService.cleanUpCallsCount == 1) - } - - // MARK: - Private Methods - - private static func configuration() -> PO3DS2Configuration { - PO3DS2Configuration( - directoryServerId: "A000000003", - directoryServerPublicKey: "", - directoryServerRootCertificates: [], - directoryServerTransactionId: UUID().uuidString, - messageVersion: "2.2.0" - ) - } - - private static func challengeParameters(transactionId: String) -> PO3DS2ChallengeParameters { - PO3DS2ChallengeParameters( - acsTransactionId: "", - acsReferenceNumber: "", - acsSignedContent: "", - threeDSServerTransactionId: transactionId - ) - } - - // MARK: - Private Properties - - private let processOut: ProcessOut - private let threeDSService: MockThreeDS2Service - private let sut: POCheckout3DSService -} From aa8f4197d1fe5c705481c4939461479d3953679b Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 4 Nov 2026 16:09:33 +0100 Subject: [PATCH 4/6] chore(ad-hoc): run Checkout 3DS tests in CI --- Scripts/Tests/Run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Scripts/Tests/Run.sh b/Scripts/Tests/Run.sh index 35958ed39..ff899332e 100755 --- a/Scripts/Tests/Run.sh +++ b/Scripts/Tests/Run.sh @@ -6,7 +6,7 @@ PROJECT='ProcessOut.xcodeproj' DESTINATION=$(./Scripts/Tests/Destination.swift) # Run Tests -for PRODUCT in "ProcessOut" "ProcessOutUI" "ProcessOutNetcetera3DS"; do +for PRODUCT in "ProcessOut" "ProcessOutUI" "ProcessOutCheckout3DS" "ProcessOutNetcetera3DS"; do xcodebuild clean test \ -destination "$DESTINATION" \ -project $PROJECT \ From 64d1076a1d40ca40db4edb3dfae65620a8a124ff Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 4 Nov 2026 16:12:32 +0100 Subject: [PATCH 5/6] fix(ad-hoc): close Checkout 3DS transaction explicitly * Close transaction before cleaning up service instead of relying on cleanUp(). * Remove unreachable cleaning state since teardown is synchronous. --- .../Service/POCheckout3DSService.swift | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift index 2548c0726..35d7ed1c6 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift @@ -43,7 +43,7 @@ public final class POCheckout3DSService: PO3DS2Service { throw POFailure( message: "Previous transaction was not cleaned, call clean() first.", code: .Mobile.generic ) - case .authenticating, .challenging, .cleaning: + case .authenticating, .challenging: throw POFailure(message: "Another operation is already in progress.", code: .Mobile.generic) } state = .authenticating @@ -93,11 +93,10 @@ public final class POCheckout3DSService: PO3DS2Service { case .idle: break case .ready(let context): - state = .cleaning - tearDown(service: context.service) + tearDown(service: context.service, transaction: context.transaction) deepLinkObservation = nil state = .idle - case .authenticating, .challenging, .cleaning: + case .authenticating, .challenging: logger.warn("Ignoring clean() while another operation is in progress.") } } @@ -117,9 +116,6 @@ public final class POCheckout3DSService: PO3DS2Service { /// Challenge is being performed on the transaction. case challenging(TransactionContext) - - /// Transaction is being closed and SDK resources are being released. - case cleaning } private struct TransactionContext { @@ -150,7 +146,7 @@ public final class POCheckout3DSService: PO3DS2Service { private func createTransaction( configuration: PO3DS2Configuration ) async throws -> (TransactionContext, PO3DS2AuthenticationRequestParameters) { - var initializedService: ThreeDS2Service? + var initializedService: ThreeDS2Service?, createdTransaction: Transaction? do { let service = try Standalone3DSService.initialize( with: await serviceConfiguration(with: configuration), environment: environment @@ -160,13 +156,14 @@ public final class POCheckout3DSService: PO3DS2Service { throw POFailure(code: .Mobile.cancelled) } let transaction = service.createTransaction() + createdTransaction = transaction let parameters = authenticationRequest(with: try await transaction.getAuthenticationRequestParameters()) let context = TransactionContext( service: service, transaction: transaction, transactionId: configuration.directoryServerTransactionId ) return (context, parameters) } catch { - tearDown(service: initializedService) + tearDown(service: initializedService, transaction: createdTransaction) throw error } } @@ -185,8 +182,8 @@ public final class POCheckout3DSService: PO3DS2Service { // MARK: - Clean - /// - NOTE: Checkout's `cleanUp()` closes the current transaction, so transaction is not closed explicitly. - private func tearDown(service: ThreeDS2Service?) { + private func tearDown(service: ThreeDS2Service?, transaction: Transaction?) { + transaction?.close() service?.cleanUp() } From 9bff2bcc2807d1786c5765d448f138464af283e2 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Thu, 5 Nov 2026 13:37:59 +0100 Subject: [PATCH 6/6] test(ad-hoc): assert specific Checkout 3DS state failures * Test concurrent authentication while the first call is in flight. * Rename not-cleaned transaction test to match the state it covers. * Match state rejection failures by message instead of accepting any error. --- .../Service/Checkout3DSServiceTests.swift | 76 ++++++++++++++++++- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift index d4b3ac741..d4c9e34af 100644 --- a/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift +++ b/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift @@ -9,6 +9,7 @@ import Foundation import Testing import ProcessOut import ProcessOutCheckout3DS +import Checkout3DS @MainActor @Suite(.serialized) @@ -55,16 +56,45 @@ struct Checkout3DSServiceTests { // MARK: - State @Test - func authenticationRequestParameters_whenAnotherAuthenticationIsInProgress_fails() async throws { + func authenticationRequestParameters_whenPreviousTransactionIsNotCleaned_fails() async throws { // Given let sut = POCheckout3DSService() _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) // When - await withKnownIssue { + let failure = await #expect(throws: POFailure.self) { + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) + } + + // Then + #expect(failure?.message == "Previous transaction was not cleaned, call clean() first.") + + // Cleanup + await sut.clean() + } + + @Test + func authenticationRequestParameters_whenAnotherAuthenticationIsInProgress_fails() async throws { + // Given + let delegate = SuspendingDelegate() + let sut = POCheckout3DSService(delegate: delegate) + let configuration = try Self.validConfiguration() + let authentication = Task { + try await sut.authenticationRequestParameters(configuration: configuration) + } + await delegate.waitForSuspension() + + // When + let failure = await #expect(throws: POFailure.self) { _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) } + // Then + #expect(failure?.message == "Another operation is already in progress.") + delegate.resume() + let requestParameters = try await authentication.value + #expect(!requestParameters.deviceData.isEmpty) + // Cleanup await sut.clean() } @@ -116,9 +146,12 @@ struct Checkout3DSServiceTests { let parameters = try Self.challengeParameters(transactionId: "") // When - await withKnownIssue { + let failure = await #expect(throws: POFailure.self) { _ = try await sut.performChallenge(with: parameters) } + + // Then + #expect(failure?.message == "Unable to resolve current transaction.") } @Test @@ -129,10 +162,13 @@ struct Checkout3DSServiceTests { let parameters = try Self.challengeParameters(transactionId: "other") // When - await withKnownIssue { + let failure = await #expect(throws: POFailure.self) { _ = try await sut.performChallenge(with: parameters) } + // Then + #expect(failure?.message == "Unable to resolve current transaction.") + // Cleanup await sut.clean() } @@ -193,3 +229,35 @@ struct Checkout3DSServiceTests { return try JSONDecoder().decode(T.self, from: data) } } + +/// Suspends authentication when asked whether to continue, until resumed by the test. +@MainActor +private final class SuspendingDelegate: POCheckout3DSServiceDelegate { + + func waitForSuspension() async { + guard continuation == nil else { + return + } + await withCheckedContinuation { suspensionContinuation = $0 } + } + + func resume() { + continuation?.resume(returning: true) + continuation = nil + } + + // MARK: - POCheckout3DSServiceDelegate + + func checkout3DSService(_ service: POCheckout3DSService, shouldContinueWith warnings: Set) async -> Bool { + await withCheckedContinuation { continuation in + self.continuation = continuation + suspensionContinuation?.resume() + suspensionContinuation = nil + } + } + + // MARK: - Private Properties + + private var continuation: CheckedContinuation? + private var suspensionContinuation: CheckedContinuation? +}