diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a96a671..cd2dc5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: xcodebuild -showsdks - name: Lint Swift formatting - run: xcrun swift-format lint --recursive --strict Sources Tests Package.swift + run: xcrun swift-format lint --recursive --strict Sources Tests Tools/TestApp Package.swift - name: Run Swift package tests run: swift test --build-system swiftbuild --no-parallel @@ -98,42 +98,38 @@ jobs: ios-tests: name: Package Tests (iOS Simulator) runs-on: macos-26 - timeout-minutes: 20 + timeout-minutes: 10 env: - XCODE_MAJOR: "26" + XCODE_VERSION: "26.5" steps: - name: Check out repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Resolve latest stable Xcode + - name: Select Xcode run: | - xcode_app="$(ruby <<'RUBY' - xcode_major = ENV.fetch("XCODE_MAJOR") - candidates = Dir["/Applications/Xcode_#{xcode_major}*.app"].select { |path| File.directory?(path) } - candidates = candidates.reject do |path| - labels = [path, File.realpath(path)].map { |candidate| File.basename(candidate).downcase } - labels.any? { |label| label.match?(/beta|release[ _-]?candidate|(?:^|[_. -])rc(?:$|[_. -])/) } - end - selected = candidates.max_by do |path| - File.basename(path)[/Xcode_(\d+(?:\.\d+)*)/, 1].to_s.split(".").map(&:to_i) - end - abort("No stable Xcode #{xcode_major} installation found") unless selected - puts selected - RUBY - )" + xcode_app="/Applications/Xcode_${XCODE_VERSION}.app" + if [[ ! -d "${xcode_app}" ]]; then + echo "Xcode ${XCODE_VERSION} is not installed on the runner." >&2 + exit 1 + fi echo "DEVELOPER_DIR=${xcode_app}/Contents/Developer" >> "$GITHUB_ENV" - echo "Resolved Xcode: ${xcode_app}" + echo "Selected Xcode: ${xcode_app}" + + - name: Show Xcode environment + run: | + xcodebuild -version + swift --version - name: Select iOS Simulator run: | simulator_id="$(xcrun simctl list devices available --json | ruby -rjson -e ' - runtimes = JSON.parse(STDIN.read).fetch("devices").select { |name, _| name.include?(".iOS-") } - _, devices = runtimes.max_by { |name, _| name.scan(/\d+/).map(&:to_i) } - abort("No available iOS runtime found") unless devices + runtimes = JSON.parse(STDIN.read).fetch("devices") + _, devices = runtimes.find { |name, _| name.end_with?(".iOS-26-2") } + abort("The iOS 26.2 Simulator runtime is required for StoreKit tests") unless devices device = devices.find { |candidate| candidate["isAvailable"] && candidate["name"].start_with?("iPhone") } - abort("No available iPhone Simulator found") unless device + abort("No available iPhone with iOS 26.2 found") unless device puts device.fetch("udid") ')" echo "SIMULATOR_ID=${simulator_id}" >> "$GITHUB_ENV" @@ -145,3 +141,13 @@ jobs: -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO + + - name: Run StoreKit integration tests + run: | + xcodebuild test \ + -workspace Tools/TestApp/StoreTransactionKitTestApp.xcworkspace \ + -scheme StoreTransactionKitIntegrationTests \ + -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -parallel-testing-enabled NO \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO diff --git a/Fixtures/ExternalConsumer/Sources/Consumer/main.swift b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift similarity index 93% rename from Fixtures/ExternalConsumer/Sources/Consumer/main.swift rename to Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift index 5775fb9..25e993c 100644 --- a/Fixtures/ExternalConsumer/Sources/Consumer/main.swift +++ b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift @@ -8,7 +8,7 @@ private enum EntitlementID: String, Hashable, Sendable { @MainActor struct Consumer { static func main() async throws { - let store = Store( + let store = TransactionStore( handleTransaction: { transaction in print("Handle transaction \(transaction.id)") }, diff --git a/README.md b/README.md index 3e3fdcc..5dd4bbf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ durable processing, and `Transaction.finish()` into one process-owned, observable store. It supports iOS 18.4 and later and macOS 15.4 and later. -## Create a Store +## Create a TransactionStore Define the entitlement identifiers in the app. A string-backed enum keeps StoreKit product identifiers typed without requiring a framework protocol. @@ -20,10 +20,20 @@ enum SubscriptionID: String, Hashable, Sendable { actor PurchaseLedger { func apply(_ transaction: StoreTransactionSnapshot) async throws { - try await database.commitPurchase( - transactionID: transaction.id, - productID: transaction.productID - ) + if let revocationDate = transaction.revocationDate { + try await database.revokePurchase( + transactionID: transaction.id, + productID: transaction.productID, + signedDate: transaction.signedDate, + revocationDate: revocationDate + ) + } else { + try await database.commitPurchase( + transactionID: transaction.id, + productID: transaction.productID, + signedDate: transaction.signedDate + ) + } } } @@ -37,8 +47,8 @@ actor StoreDiagnostics { func makeStore( ledger: PurchaseLedger, diagnostics: StoreDiagnostics -) -> Store { - Store( +) -> TransactionStore { + TransactionStore( handleTransaction: { transaction in try await ledger.apply(transaction) }, @@ -49,15 +59,34 @@ func makeStore( } ``` -`Store` is `@MainActor` and `@Observable`. It starts `Transaction.unfinished` -and `Transaction.updates` monitoring during initialization. Retain one instance -in the application's process-lifetime composition. +`TransactionStore` is `@MainActor` and `@Observable`. It monitors +`Transaction.unfinished`, `Transaction.updates`, and subscription status +changes during initialization. Retain one instance in the application's +process-lifetime composition. + +Startup and each entitlement refresh reconcile `Transaction.unfinished` +before publishing entitlement state. Every verified delivery is durably +handled, including consumables. If the handler fails, startup or the refresh +fails, the transaction remains unfinished, and a later refresh retries it. +An unverified unfinished delivery is sent to `reportFailure` with source +`.unfinished`. `activeEntitlements` contains the app-defined identifiers represented by StoreKit's current entitlements. It is `nil` while the initial entitlement query is unresolved and becomes a non-`nil` empty set when no known -subscription is active. `entitlements` contains the complete verified -snapshot, including product identifiers outside `SubscriptionID`. +identifier matches a current entitlement. A subscription superseded by an +upgrade is excluded from this set. `entitlements` contains the complete +verified snapshot, including superseded transactions and product identifiers +outside `SubscriptionID`. Use `StoreTransactionSnapshot.subscriptionGroupID` +when the app grants access at subscription-group rather than product-tier +granularity. A current-entitlement element that StoreKit can't verify is +omitted from the projection and delivered to `reportFailure` with source +`.currentEntitlementVerification`. + +For renewal dates, grace periods, billing retry, and expiration messaging, +read `Product.SubscriptionInfo.Status` directly. `TransactionStore` owns the +durable transaction path and current-entitlement projection, not subscription +status presentation. ## Use SubscriptionStoreView @@ -75,14 +104,29 @@ import StoreKit import SwiftUI struct PremiumStoreView: View { - @Environment(Store.self) private var store + @Environment(TransactionStore.self) private var store + @State private var refreshError: (any Error)? var body: some View { VStack { if let activeEntitlements = store.activeEntitlements { - if activeEntitlements.contains(.yearly) { + if activeEntitlements.contains(.monthly) + || activeEntitlements.contains(.yearly) + { Label("Premium active", systemImage: "checkmark.seal.fill") } + } else if let error = refreshError ?? store.startupError { + Text(error.localizedDescription) + Button("Retry") { + Task { + do { + try await store.refreshEntitlements() + refreshError = nil + } catch { + refreshError = error + } + } + } } else { ProgressView() } @@ -98,21 +142,38 @@ struct PremiumStoreView: View { No `onInAppPurchaseCompletion` modifier is needed here. By default, successful StoreKit view purchases are delivered through `Transaction.updates`, which the store already monitors. A non-`nil` completion action replaces that default; -if you add one, pass its successful `Product.PurchaseResult` to -`store.process(_:)`. - -`PurchaseLedger.apply(_:)` must be idempotent because StoreKit delivery is at -least once. It must return only after the business effect is durable. The -store calls `finish()` after that return. +if you add one, pass each `.success` value (the `Product.PurchaseResult`) to +`store.process(_:)`. The action also replaces StoreKit's default failure alert, +so it must own `.failure` presentation or diagnostics. + +StoreTransactionKit exposes an at-least-once handler-delivery contract, so +`PurchaseLedger.apply(_:)` must be idempotent. It must return only after the +business effect is durable. The store calls `finish()` after that return. Treat +purchase and revocation revisions as distinct durable business events; +transaction ID alone is not a sufficient idempotency key for both. The handler +must not call methods on the same store, including through an awaited detached +task, because that creates a dependency cycle with the transaction being +handled. + +Apps that support promoted purchases or applicable win-back flows own +`PurchaseIntent.intents`: complete each intent's product purchase and pass its +result to `store.process(_:)`. Call `store.restorePurchases()` only from an explicit user action because -`AppStore.sync()` may present authentication UI. +`AppStore.sync()` presents authentication UI. Treat `StoreKitError.userCancelled` +as a normal user outcome rather than a diagnostic failure. For product merchandising and UI composition, use Apple's [Getting started with In-App Purchase using StoreKit views](https://developer.apple.com/documentation/storekit/getting-started-with-in-app-purchases-using-storekit-views) and [Implementing a store in your app using the StoreKit API](https://developer.apple.com/documentation/storekit/implementing-a-store-in-your-app-using-the-storekit-api). +## Testing + +The app-hosted StoreKit integration suite runs with `xcodebuild`. See +[Tools/TestApp/README.md](Tools/TestApp/README.md) for the scenarios and +command. + ## License StoreTransactionKit is available under the MIT License. diff --git a/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift b/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift index ed15273..1c5f7f7 100644 --- a/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift +++ b/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift @@ -53,8 +53,9 @@ package actor FailureReporterDispatcher { private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task { - await drainQueue() + worker = Task.detached { [weak self] in + guard let self else { return } + await self.drainQueue() } } diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift new file mode 100644 index 0000000..0501211 --- /dev/null +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -0,0 +1,240 @@ +import Foundation +import StoreKit + +package final class CurrentEntitlementReconciler: Sendable { + struct AcceptedTransaction: Sendable { + let snapshot: StoreTransactionSnapshot + let acceptance: ProcessingAcceptance + + init( + snapshot: StoreTransactionSnapshot, + acceptance: ProcessingAcceptance + ) { + self.snapshot = snapshot + self.acceptance = acceptance + } + } + + private struct UnfinishedBatch: Sendable { + let revisions: Set + let acceptedTransactions: [AcceptedTransaction] + let verificationFailures: [UnfinishedVerificationFailure] + } + + private struct UnfinishedVerificationFailure: Sendable { + let revision: Data + let error: any Error + } + + private let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult + private let queryUnfinished: @Sendable () async -> [StoreTransactionDelivery] + private let core: TransactionProcessingCore + private let failures: FailureReporterDispatcher + + package init( + query: + @escaping @Sendable () async throws + -> CurrentEntitlementQueryResult, + queryUnfinished: + @escaping @Sendable () async -> [StoreTransactionDelivery], + core: TransactionProcessingCore, + failures: FailureReporterDispatcher + ) { + self.currentEntitlements = query + self.queryUnfinished = queryUnfinished + self.core = core + self.failures = failures + } + + package func query( + retryFailedTransactions: Bool + ) async throws -> [StoreTransactionSnapshot] { + if retryFailedTransactions { + await core.beginRetryAttempt() + } + var reconciledRevisions: Set = [] + var batch = await unfinishedBatch( + excluding: reconciledRevisions + ) + var observedUnfinishedVerificationRevisions: Set = [] + var observedUnfinishedVerificationFailures: [any Error] = [] + collectUnfinishedVerificationFailures( + batch.verificationFailures, + observedRevisions: &observedUnfinishedVerificationRevisions, + observedFailures: &observedUnfinishedVerificationFailures + ) + var precedingCurrentVerificationFailures: [StoreTransactionVerificationError] = [] + + while true { + while !batch.acceptedTransactions.isEmpty { + do { + try await drain(batch.acceptedTransactions) + } catch { + await reportVerificationFailures( + unfinished: observedUnfinishedVerificationFailures, + currentEntitlements: + precedingCurrentVerificationFailures + ) + throw error + } + reconciledRevisions.formUnion(batch.revisions) + batch = await unfinishedBatch( + excluding: reconciledRevisions + ) + collectUnfinishedVerificationFailures( + batch.verificationFailures, + observedRevisions: + &observedUnfinishedVerificationRevisions, + observedFailures: + &observedUnfinishedVerificationFailures + ) + } + + let result: CurrentEntitlementQueryResult + do { + result = try await currentEntitlements() + } catch { + await reportUnfinishedVerificationFailures( + observedUnfinishedVerificationFailures + ) + throw error + } + + let postQueryBatch = await unfinishedBatch( + excluding: reconciledRevisions + ) + collectUnfinishedVerificationFailures( + postQueryBatch.verificationFailures, + observedRevisions: + &observedUnfinishedVerificationRevisions, + observedFailures: + &observedUnfinishedVerificationFailures + ) + guard !postQueryBatch.acceptedTransactions.isEmpty else { + await reportVerificationFailures( + unfinished: observedUnfinishedVerificationFailures, + currentEntitlements: result.verificationFailures + ) + return result.snapshots + } + batch = postQueryBatch + precedingCurrentVerificationFailures = + result.verificationFailures + } + } + + private func unfinishedBatch( + excluding reconciledRevisions: Set + ) async -> UnfinishedBatch { + var revisions: Set = [] + var acceptedTransactions: [AcceptedTransaction] = [] + var verificationFailures: [UnfinishedVerificationFailure] = [] + + for delivery in await queryUnfinished() { + switch delivery { + case .verified(let envelope): + guard + !reconciledRevisions.contains(envelope.revision), + revisions.insert(envelope.revision).inserted + else { + continue + } + acceptedTransactions.append( + AcceptedTransaction( + snapshot: envelope.value, + acceptance: await core.accept(envelope) + )) + case .unverified(let revision, let error): + verificationFailures.append( + UnfinishedVerificationFailure( + revision: revision, + error: error + )) + } + } + + return UnfinishedBatch( + revisions: revisions, + acceptedTransactions: acceptedTransactions, + verificationFailures: verificationFailures + ) + } + + private func collectUnfinishedVerificationFailures( + _ verificationFailures: [UnfinishedVerificationFailure], + observedRevisions: inout Set, + observedFailures: inout [any Error] + ) { + for failure in verificationFailures + where observedRevisions.insert(failure.revision).inserted { + observedFailures.append(failure.error) + } + } + + private func reportVerificationFailures( + unfinished: [any Error], + currentEntitlements: [StoreTransactionVerificationError] + ) async { + await reportUnfinishedVerificationFailures(unfinished) + await reportCurrentEntitlementVerificationFailures( + currentEntitlements + ) + } + + private func reportUnfinishedVerificationFailures( + _ verificationFailures: [any Error] + ) async { + for failure in verificationFailures { + await failures.enqueue( + StoreTransactionBackgroundFailure( + source: .unfinished, + transactionID: nil, + productID: nil, + underlyingError: failure + ) + ) + } + } + + func drain(_ transactions: [AcceptedTransaction]) async throws { + var firstError: (any Error)? + for transaction in transactions { + do { + _ = try await transaction.acceptance.receipt.terminalValue() + } catch { + if transaction.acceptance.role == .owner { + let failure = StoreTransactionBackgroundFailure( + source: .unfinished, + transactionID: transaction.snapshot.id, + productID: transaction.snapshot.productID, + underlyingError: error + ) + await failures.enqueue(failure) + } + if firstError == nil { + firstError = StoreTransactionFailureWithReportingOwner( + underlyingError: error + ) + } + } + } + if let firstError { + throw firstError + } + } + + private func reportCurrentEntitlementVerificationFailures( + _ verificationFailures: [StoreTransactionVerificationError] + ) async { + for failure in verificationFailures { + await failures.enqueue( + StoreTransactionBackgroundFailure( + source: .currentEntitlementVerification, + transactionID: nil, + productID: nil, + underlyingError: failure + ) + ) + } + } +} diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index 42eea46..1fac461 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -1,42 +1,97 @@ import Foundation +package struct EntitlementRefreshReservation: Sendable { + package enum Role: Equatable, Sendable { + case owner + case observer + } + + package let receipt: ProcessingReceipt + package let role: Role + package let token: UInt64 + package let reportingAuthority: DirectOperationReportingAuthority +} + +package struct EntitlementRefreshSuccess: Sendable { + package let token: UInt64 + package let entitlements: StoreEntitlements +} + package actor EntitlementRefreshCoordinator { - private struct Reservation: Sendable { + private struct PendingReservation: Sendable { let token: UInt64 + let retryFailedTransactions: Bool let receipt: ProcessingReceipt + let reportingAuthority: DirectOperationReportingAuthority } private let sessionID: UUID - private let query: @Sendable () async throws -> [StoreTransactionSnapshot] + private let query: @Sendable (Bool) async throws -> [StoreTransactionSnapshot] private let didChange: @Sendable (StoreEntitlements) async -> Void + private let didSucceed: @Sendable (EntitlementRefreshSuccess) async -> Void private var nextToken: UInt64 = 0 private var current: StoreEntitlements? - private var pending: [Reservation] = [] + private var pending: [PendingReservation] = [] private var worker: Task? private var acceptsReservations = true package init( sessionID: UUID = UUID(), query: - @escaping @Sendable () async throws + @escaping @Sendable (Bool) async throws -> [StoreTransactionSnapshot], - didChange: @escaping @Sendable (StoreEntitlements) async -> Void + didChange: @escaping @Sendable (StoreEntitlements) async -> Void, + didSucceed: + @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in } ) { self.sessionID = sessionID self.query = query self.didChange = didChange + self.didSucceed = didSucceed } - package func reserve() -> ProcessingReceipt { + package func reserve( + retryFailedTransactions: Bool = true + ) -> EntitlementRefreshReservation { guard acceptsReservations else { - return .failed(StoreTransactionInternalError.entitlementRefreshClosed) + return EntitlementRefreshReservation( + receipt: .failed( + StoreTransactionInternalError.entitlementRefreshClosed + ), + role: .owner, + token: 0, + reportingAuthority: DirectOperationReportingAuthority() + ) } precondition(nextToken < .max) nextToken += 1 + let role: EntitlementRefreshReservation.Role + let reportingAuthority: DirectOperationReportingAuthority + if let preceding = pending.last, + preceding.retryFailedTransactions == retryFailedTransactions + { + role = .observer + reportingAuthority = preceding.reportingAuthority + } else { + role = .owner + reportingAuthority = DirectOperationReportingAuthority() + } let receipt = ProcessingReceipt() - pending.append(Reservation(token: nextToken, receipt: receipt)) + pending.append( + PendingReservation( + token: nextToken, + retryFailedTransactions: retryFailedTransactions, + receipt: receipt, + reportingAuthority: reportingAuthority + ) + ) startWorkerIfNeeded() - return receipt + return EntitlementRefreshReservation( + receipt: receipt, + role: role, + token: nextToken, + reportingAuthority: reportingAuthority + ) } package func sealAndDrain() async { @@ -48,17 +103,25 @@ package actor EntitlementRefreshCoordinator { private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task { - await runQueries() + worker = Task.detached { [weak self] in + guard let self else { return } + await self.runQueries() } } private func runQueries() async { while !pending.isEmpty { - let reservations = pending - pending.removeAll(keepingCapacity: true) + let retryFailedTransactions = + pending[0].retryFailedTransactions + let end = + pending.firstIndex { + $0.retryFailedTransactions != retryFailedTransactions + } ?? pending.endIndex + let reservations = Array(pending[..: Sendable { private typealias ReceiptResult = Result + private final class Waiter: Sendable { + private enum State { + case idle + case waiting(CheckedContinuation) + case cancelled + case terminal(ReceiptResult) + } + + private let state = Mutex(.idle) + + func value() async -> ReceiptResult { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> ReceiptResult? in + switch state { + case .idle: + state = .waiting(continuation) + return nil + case .cancelled: + return .failure(ProcessingReceiptWaiterCancellation()) + case .terminal(let result): + return result + case .waiting: + preconditionFailure("A processing receipt waiter was awaited more than once.") + } + } + if let immediate { + continuation.resume(returning: immediate) + } + } + } + + func cancel() { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .idle: + state = .cancelled + return nil + case .waiting(let continuation): + state = .cancelled + return continuation + case .cancelled, .terminal: + return nil + } + } + continuation?.resume( + returning: .failure(ProcessingReceiptWaiterCancellation()) + ) + } + + func complete(_ result: ReceiptResult) { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .idle: + state = .terminal(result) + return nil + case .waiting(let continuation): + state = .terminal(result) + return continuation + case .cancelled: + return nil + case .terminal: + preconditionFailure("A processing receipt waiter completed more than once.") + } + } + continuation?.resume(returning: result) + } + } + private enum State { - case pending([UUID: CheckedContinuation]) + case pending([UUID: Waiter]) + case terminal(ReceiptResult) + } + + private enum Registration { + case waiter(id: UUID, waiter: Waiter) case terminal(ReceiptResult) } @@ -26,61 +101,31 @@ package final class ProcessingReceipt: Sendable { } package func value() async throws -> Value { - let waiterID = UUID() - let cancellation = Mutex(false) + guard !Task.isCancelled else { + throw ProcessingReceiptWaiterCancellation() + } - let result = await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - let wasCancelled = cancellation.withLock { $0 } - let immediate = state.withLock { state -> ReceiptResult? in - if wasCancelled { - return .failure(CancellationError()) - } - switch state { - case .pending(var waiters): - waiters[waiterID] = continuation - state = .pending(waiters) - return nil - case .terminal(let result): - return result - } - } - if let immediate { - continuation.resume(returning: immediate) - } + switch registerWaiter() { + case .terminal(let result): + return try result.get() + case .waiter(let id, let waiter): + let result = await withTaskCancellationHandler { + await waiter.value() + } onCancel: { + waiter.cancel() + self.removeWaiter(id) } - } onCancel: { - cancellation.withLock { $0 = true } - let continuation = state.withLock { state -> CheckedContinuation? in - guard case .pending(var waiters) = state else { return nil } - let continuation = waiters.removeValue(forKey: waiterID) - state = .pending(waiters) - return continuation - } - continuation?.resume(returning: .failure(CancellationError())) + return try result.get() } - - return try result.get() } package func terminalValue() async throws -> Value { - let result = await withCheckedContinuation { continuation in - let waiterID = UUID() - let immediate = state.withLock { state -> ReceiptResult? in - switch state { - case .pending(var waiters): - waiters[waiterID] = continuation - state = .pending(waiters) - return nil - case .terminal(let result): - return result - } - } - if let immediate { - continuation.resume(returning: immediate) - } + switch registerWaiter() { + case .terminal(let result): + return try result.get() + case .waiter(_, let waiter): + return try await waiter.value().get() } - return try result.get() } package func succeed(_ value: Value) { @@ -92,15 +137,38 @@ package final class ProcessingReceipt: Sendable { } private func complete(_ result: ReceiptResult) { - let continuations = state.withLock { state -> [CheckedContinuation] in + let waiters = state.withLock { state -> [Waiter] in guard case .pending(let waiters) = state else { preconditionFailure("A processing receipt completed more than once.") } state = .terminal(result) return Array(waiters.values) } - for continuation in continuations { - continuation.resume(returning: result) + for waiter in waiters { + waiter.complete(result) + } + } + + private func registerWaiter() -> Registration { + state.withLock { state in + switch state { + case .pending(var waiters): + let id = UUID() + let waiter = Waiter() + waiters[id] = waiter + state = .pending(waiters) + return .waiter(id: id, waiter: waiter) + case .terminal(let result): + return .terminal(result) + } + } + } + + private func removeWaiter(_ id: UUID) { + state.withLock { state in + guard case .pending(var waiters) = state else { return } + waiters.removeValue(forKey: id) + state = .pending(waiters) } } } diff --git a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift index 3a0cd61..00b9b35 100644 --- a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift +++ b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift @@ -1,18 +1,38 @@ import Foundation +package struct ProcessingAcceptance: Sendable { + package enum Role: Equatable, Sendable { + case owner + case inFlightObserver + case failedObserver + case completedObserver + } + + package let receipt: ProcessingReceipt + package let role: Role + package let reportingAuthority: DirectOperationReportingAuthority +} + package actor TransactionProcessingCore { + private struct Attempt: Sendable { + let receipt: ProcessingReceipt + let reportingAuthority: DirectOperationReportingAuthority + } + private struct QueuedOperation: Sendable { let envelope: ProcessingEnvelope - let receipt: ProcessingReceipt + let attempt: Attempt } private let sessionID: UUID private let handle: @Sendable (Value) async throws -> Void private var queue: [QueuedOperation] = [] - private var inFlight: [Data: ProcessingReceipt] = [:] + private var inFlight: [Data: Attempt] = [:] + private var failed: [Data: Attempt] = [:] private var completed = CompletedRevisionCache() private var worker: Task? private var acceptsInput = true + private var initialAttemptCompleted = false package init( sessionID: UUID = UUID(), @@ -24,22 +44,68 @@ package actor TransactionProcessingCore { package func accept( _ envelope: ProcessingEnvelope - ) -> ProcessingReceipt { + ) -> ProcessingAcceptance { guard acceptsInput else { - return .failed(StoreTransactionInternalError.inputClosed) + return ProcessingAcceptance( + receipt: .failed(StoreTransactionInternalError.inputClosed), + role: .owner, + reportingAuthority: DirectOperationReportingAuthority() + ) } if completed.contains(envelope.revision) { - return .succeeded(envelope.value) + return ProcessingAcceptance( + receipt: .succeeded(envelope.value), + role: .completedObserver, + reportingAuthority: DirectOperationReportingAuthority() + ) + } + if let attempt = inFlight[envelope.revision] { + return ProcessingAcceptance( + receipt: attempt.receipt, + role: .inFlightObserver, + reportingAuthority: attempt.reportingAuthority + ) } - if let receipt = inFlight[envelope.revision] { - return receipt + if let attempt = failed[envelope.revision] { + return ProcessingAcceptance( + receipt: attempt.receipt, + role: .failedObserver, + reportingAuthority: attempt.reportingAuthority + ) } - let receipt = ProcessingReceipt() - inFlight[envelope.revision] = receipt - queue.append(QueuedOperation(envelope: envelope, receipt: receipt)) + let attempt = Attempt( + receipt: ProcessingReceipt(), + reportingAuthority: DirectOperationReportingAuthority() + ) + inFlight[envelope.revision] = attempt + queue.append(QueuedOperation(envelope: envelope, attempt: attempt)) startWorkerIfNeeded() - return receipt + return ProcessingAcceptance( + receipt: attempt.receipt, + role: .owner, + reportingAuthority: attempt.reportingAuthority + ) + } + + package func retryFailedTransactionsInNewAttempt() -> Bool { + initialAttemptCompleted + } + + package func beginTransactionAttempt() -> Bool { + guard initialAttemptCompleted else { + return false + } + failed.removeAll(keepingCapacity: true) + return true + } + + package func beginRetryAttempt() { + failed.removeAll(keepingCapacity: true) + } + + package func completeInitialAttempt() { + initialAttemptCompleted = true } package func finishInputAndDrain() async { @@ -48,12 +114,14 @@ package actor TransactionProcessingCore { await activeWorker?.value precondition(queue.isEmpty) precondition(inFlight.isEmpty) + failed.removeAll(keepingCapacity: false) } private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task { - await drainQueue() + worker = Task.detached { [weak self] in + guard let self else { return } + await self.drainQueue() } } @@ -72,10 +140,11 @@ package actor TransactionProcessingCore { await operation.envelope.finish() completed.insert(operation.envelope.revision) inFlight.removeValue(forKey: operation.envelope.revision) - operation.receipt.succeed(operation.envelope.value) + operation.attempt.receipt.succeed(operation.envelope.value) } catch { inFlight.removeValue(forKey: operation.envelope.revision) - operation.receipt.fail(error) + failed[operation.envelope.revision] = operation.attempt + operation.attempt.receipt.fail(error) } } worker = nil diff --git a/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift b/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift new file mode 100644 index 0000000..7914b75 --- /dev/null +++ b/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift @@ -0,0 +1,162 @@ +import Foundation +import Synchronization + +package final class DirectOperationReportingAuthority: Sendable { + private enum ParticipantState: Equatable { + case attached + case abandoned + } + + private struct State { + var participants: [UUID: ParticipantState] = [:] + var delivered = false + var report: StoreTransactionBackgroundFailure? + var claimed = false + } + + private let state = Mutex(State()) + + package init() {} + + fileprivate func attach(abandoned: Bool) -> UUID { + state.withLock { state in + let id = UUID() + state.participants[id] = abandoned ? .abandoned : .attached + return id + } + } + + fileprivate func succeed(participant id: UUID) { + state.withLock { state in + _ = state.participants.removeValue(forKey: id) + } + } + + fileprivate func fail( + participant id: UUID, + report: StoreTransactionBackgroundFailure? + ) -> StoreTransactionBackgroundFailure? { + state.withLock { state in + guard state.participants[id] != nil else { return nil } + if state.report == nil { + state.report = report + } + return claimReportIfAbandoned(state: &state) + } + } + + fileprivate func abandon( + participant id: UUID + ) -> StoreTransactionBackgroundFailure? { + state.withLock { state in + guard state.participants[id] != nil else { return nil } + state.participants[id] = .abandoned + return claimReportIfAbandoned(state: &state) + } + } + + fileprivate func deliver(participant id: UUID) { + state.withLock { state in + guard state.participants.removeValue(forKey: id) != nil else { + return + } + state.delivered = true + } + } + + private func claimReportIfAbandoned( + state: inout State + ) -> StoreTransactionBackgroundFailure? { + guard !state.claimed, !state.delivered, let report = state.report else { + return nil + } + guard state.participants.values.allSatisfy({ $0 == .abandoned }) else { + return nil + } + state.claimed = true + return report + } +} + +package final class DirectOperationObservation: Sendable { + package struct Binding: Sendable { + fileprivate let participantID: UUID + fileprivate let authority: DirectOperationReportingAuthority + } + + private enum Disposition: Equatable { + case attached + case abandoned + case delivered + } + + private struct State { + var disposition: Disposition = .attached + var binding: Binding? + } + + private let state = Mutex(State()) + + package init() {} + + package func bind( + to authority: DirectOperationReportingAuthority + ) -> Binding { + state.withLock { state in + precondition(state.binding == nil) + precondition(state.disposition != .delivered) + let binding = Binding( + participantID: authority.attach( + abandoned: state.disposition == .abandoned + ), + authority: authority + ) + state.binding = binding + return binding + } + } + + package func succeed(_ binding: Binding) { + let active = state.withLock { state -> Binding in + precondition(state.binding?.participantID == binding.participantID) + state.binding = nil + return binding + } + active.authority.succeed(participant: active.participantID) + } + + package func fail( + _ binding: Binding, + report: StoreTransactionBackgroundFailure? + ) -> StoreTransactionBackgroundFailure? { + let active = state.withLock { state -> Binding in + precondition(state.binding?.participantID == binding.participantID) + return binding + } + return active.authority.fail( + participant: active.participantID, + report: report + ) + } + + package func abandon() -> StoreTransactionBackgroundFailure? { + let active = state.withLock { state -> Binding? in + guard state.disposition != .delivered else { return nil } + state.disposition = .abandoned + return state.binding + } + guard let active else { return nil } + return active.authority.abandon(participant: active.participantID) + } + + package func deliver() { + let active = state.withLock { state -> Binding? in + guard state.disposition != .delivered else { return nil } + state.disposition = .delivered + defer { state.binding = nil } + return state.binding + } + guard let active else { return } + active.authority.deliver(participant: active.participantID) + } +} diff --git a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift index fc199cf..90b9dc9 100644 --- a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift +++ b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift @@ -1,7 +1,38 @@ +package struct RestoreReservation: Sendable { + package enum Role: Equatable, Sendable { + case owner + case observer + } + + package let receipt: ProcessingReceipt + package let role: Role + package let reportingAuthority: DirectOperationReportingAuthority +} + +package struct RestoreCoordinatorFailure: Error { + package let underlyingError: any Error + package let reportsWhenAbandoned: Bool + package let reportingAuthority: DirectOperationReportingAuthority + + package init( + propagating error: any Error, + reportsWhenAbandoned: Bool, + reportingAuthority: DirectOperationReportingAuthority + ) { + let propagation = StoreTransactionFailurePropagation(error) + self.underlyingError = propagation.underlyingError + self.reportsWhenAbandoned = + reportsWhenAbandoned && !propagation.hasReportingOwner + self.reportingAuthority = reportingAuthority + } +} + package actor RestoreCoordinator { private struct InFlight: Sendable { let id: UInt64 - let task: Task + let receipt: ProcessingReceipt + let reportingAuthority: DirectOperationReportingAuthority + let task: Task } private let synchronize: @Sendable () async throws -> Void @@ -17,36 +48,81 @@ package actor RestoreCoordinator { self.entitlements = entitlements } - package func restore() async throws -> StoreEntitlements { + package func reserve( + retryFailedTransactions: Bool = true + ) -> RestoreReservation { if let inFlight { - return try await inFlight.task.value + return RestoreReservation( + receipt: inFlight.receipt, + role: .observer, + reportingAuthority: inFlight.reportingAuthority + ) } precondition(nextID < .max) nextID += 1 let id = nextID + let receipt = ProcessingReceipt() + let reportingAuthority = DirectOperationReportingAuthority() let synchronize = synchronize let entitlements = entitlements - let task = Task { - try await synchronize() - let receipt = await entitlements.reserve() - return try await receipt.terminalValue() - } - inFlight = InFlight(id: id, task: task) - - do { - let value = try await task.value - clearInFlight(id: id) - return value - } catch { - clearInFlight(id: id) - throw error + let task = Task.detached { [weak self] in + let result: Result + do { + try await synchronize() + } catch { + await self?.complete( + id: id, + result: .failure( + RestoreCoordinatorFailure( + propagating: error, + reportsWhenAbandoned: true, + reportingAuthority: reportingAuthority + )) + ) + return + } + + let refresh = await entitlements.reserve( + retryFailedTransactions: retryFailedTransactions + ) + do { + result = .success(try await refresh.receipt.terminalValue()) + } catch { + result = .failure( + RestoreCoordinatorFailure( + propagating: error, + reportsWhenAbandoned: refresh.role == .owner, + reportingAuthority: refresh.reportingAuthority + )) + } + await self?.complete(id: id, result: result) } + inFlight = InFlight( + id: id, + receipt: receipt, + reportingAuthority: reportingAuthority, + task: task + ) + return RestoreReservation( + receipt: receipt, + role: .owner, + reportingAuthority: reportingAuthority + ) } - private func clearInFlight(id: UInt64) { - guard inFlight?.id == id else { return } + private func complete( + id: UInt64, + result: Result + ) { + guard let active = inFlight, active.id == id else { return } inFlight = nil + switch result { + case .success(let value): + active.receipt.succeed(value) + case .failure(let error): + active.receipt.fail(error) + } } isolated deinit { diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift index fd6d75d..0f89ef7 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift @@ -17,12 +17,18 @@ package final class StoreTransactionPipeline: Sendable { _ delivery: StoreTransactionDelivery ) async throws -> ( snapshot: StoreTransactionSnapshot, - receipt: ProcessingReceipt + acceptance: ProcessingAcceptance, + retryFailedTransactions: Bool ) { switch delivery { case .verified(let envelope): - return (envelope.value, await core.accept(envelope)) - case .unverified(let error): + let retryFailedTransactions = await core.beginTransactionAttempt() + return ( + envelope.value, + await core.accept(envelope), + retryFailedTransactions + ) + case .unverified(_, let error): throw error } } @@ -32,11 +38,13 @@ package final class StoreTransactionPipeline: Sendable { source: StoreTransactionBackgroundFailure.Source ) async { let snapshot: StoreTransactionSnapshot? - let receipt: ProcessingReceipt + let acceptance: ProcessingAcceptance + let retryFailedTransactions: Bool do { let accepted = try await accept(delivery) snapshot = accepted.snapshot - receipt = accepted.receipt + acceptance = accepted.acceptance + retryFailedTransactions = accepted.retryFailedTransactions } catch { await failures.enqueue( StoreTransactionBackgroundFailure( @@ -48,9 +56,24 @@ package final class StoreTransactionPipeline: Sendable { return } + await processAcceptedBackground( + snapshot: snapshot, + acceptance: acceptance, + retryFailedTransactions: retryFailedTransactions, + source: source + ) + } + + package func processAcceptedBackground( + snapshot: StoreTransactionSnapshot?, + acceptance: ProcessingAcceptance, + retryFailedTransactions: Bool = true, + source: StoreTransactionBackgroundFailure.Source + ) async { do { - _ = try await receipt.terminalValue() + _ = try await acceptance.receipt.terminalValue() } catch { + guard case .owner = acceptance.role else { return } await failures.enqueue( StoreTransactionBackgroundFailure( source: source, @@ -61,36 +84,46 @@ package final class StoreTransactionPipeline: Sendable { return } + if case .inFlightObserver = acceptance.role { + return + } + let refresh = await entitlements.reserve( + retryFailedTransactions: retryFailedTransactions + ) do { - let refresh = await entitlements.reserve() - _ = try await refresh.terminalValue() + _ = try await refresh.receipt.terminalValue() } catch { + let propagation = StoreTransactionFailurePropagation(error) + guard !propagation.hasReportingOwner else { return } + guard refresh.role == .owner else { return } await failures.enqueue( StoreTransactionBackgroundFailure( source: .entitlementRefresh, transactionID: snapshot?.id, productID: snapshot?.productID, - underlyingError: error + underlyingError: propagation.underlyingError )) } } - package func reportAbandoned( - operation: StoreTransactionOperation, - snapshot: StoreTransactionSnapshot?, - receipt: ProcessingReceipt - ) async { + package func refreshEntitlements() async { + let retryFailedTransactions = + await core.retryFailedTransactionsInNewAttempt() + let refresh = await entitlements.reserve( + retryFailedTransactions: retryFailedTransactions + ) do { - _ = try await receipt.terminalValue() - let refresh = await entitlements.reserve() - _ = try await refresh.terminalValue() + _ = try await refresh.receipt.terminalValue() } catch { + let propagation = StoreTransactionFailurePropagation(error) + guard !propagation.hasReportingOwner else { return } + guard refresh.role == .owner else { return } await failures.enqueue( StoreTransactionBackgroundFailure( - source: .abandonedDirectOperation(operation), - transactionID: snapshot?.id, - productID: snapshot?.productID, - underlyingError: error + source: .entitlementRefresh, + transactionID: nil, + productID: nil, + underlyingError: propagation.underlyingError )) } } diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index c594dda..d7438c8 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -1,5 +1,9 @@ import StoreKit +private struct DirectOperationFailure: Error { + let underlyingError: any Error +} + package final class StoreTransactionRuntime: Sendable { private let source: StoreTransactionSource private let core: TransactionProcessingCore @@ -9,10 +13,11 @@ package final class StoreTransactionRuntime: Sendable { private let restoreCoordinator: RestoreCoordinator private let operations = FiniteOperationRegistry() private let readinessLease: FiniteOperationLease + private let subscriptionStatusReadiness: ProcessingReceipt private let producerCancellation = TaskCancellationBag() private let finiteTasks = TaskCompletionBag() private let updatesTask: Task - private let unfinishedTask: Task + private let subscriptionStatusTask: Task package init( sessionID: UUID, @@ -21,6 +26,8 @@ package final class StoreTransactionRuntime: Sendable { @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, entitlementsDidChange: @escaping @Sendable (StoreEntitlements) async -> Void, + entitlementRefreshDidSucceed: + @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in }, reportFailure: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void ) { @@ -30,14 +37,25 @@ package final class StoreTransactionRuntime: Sendable { sessionID: sessionID, handle: handleTransaction ) - let entitlements = EntitlementRefreshCoordinator( + let failures = FailureReporterDispatcher( sessionID: sessionID, + report: reportFailure + ) + let currentEntitlements = CurrentEntitlementReconciler( query: source.currentEntitlements, - didChange: entitlementsDidChange + queryUnfinished: source.queryUnfinished, + core: core, + failures: failures ) - let failures = FailureReporterDispatcher( + let entitlements = EntitlementRefreshCoordinator( sessionID: sessionID, - report: reportFailure + query: { retryFailedTransactions in + try await currentEntitlements.query( + retryFailedTransactions: retryFailedTransactions + ) + }, + didChange: entitlementsDidChange, + didSucceed: entitlementRefreshDidSucceed ) let pipeline = StoreTransactionPipeline( core: core, @@ -53,19 +71,30 @@ package final class StoreTransactionRuntime: Sendable { entitlements: entitlements ) self.readinessLease = operations.begin()! + let subscriptionStatusReadiness = ProcessingReceipt() + self.subscriptionStatusReadiness = subscriptionStatusReadiness - self.updatesTask = Task { + self.updatesTask = Task.detached { await source.runUpdates { delivery in await pipeline.processBackground(delivery, source: .updates) } } - self.unfinishedTask = Task { - await source.runUnfinished { delivery in - await pipeline.processBackground(delivery, source: .unfinished) + self.subscriptionStatusTask = Task.detached { + await source.runSubscriptionStatusUpdates { + do { + _ = try await subscriptionStatusReadiness.value() + } catch is ProcessingReceiptWaiterCancellation { + return + } catch { + preconditionFailure( + "Subscription status readiness cannot fail: \(error)" + ) + } + await pipeline.refreshEntitlements() } } producerCancellation.insert(updatesTask) - producerCancellation.insert(unfinishedTask) + producerCancellation.insert(subscriptionStatusTask) } package func beginOperation() -> FiniteOperationLeases? { @@ -73,12 +102,48 @@ package final class StoreTransactionRuntime: Sendable { } package func readiness() async throws -> StoreTransactionReadiness { - defer { readinessLease.end() } - await unfinishedTask.value - let receipt = await entitlements.reserve() - return StoreTransactionReadiness( - entitlements: try await receipt.value() + let reservation = await entitlements.reserve( + retryFailedTransactions: false ) + let completion = ProcessingReceipt() + let core = core + let subscriptionStatusReadiness = subscriptionStatusReadiness + let readinessLease = readinessLease + let task = Task { + let result: Result + do { + result = .success( + StoreTransactionReadiness( + entitlements: try await reservation.receipt.terminalValue(), + refreshToken: reservation.token + )) + } catch let owned as StoreTransactionFailureWithReportingOwner { + result = .failure(owned.underlyingError) + } catch { + result = .failure(error) + } + await core.completeInitialAttempt() + subscriptionStatusReadiness.succeed(()) + readinessLease.end() + switch result { + case .success(let readiness): + completion.succeed(readiness) + case .failure(let error): + completion.fail( + StoreTransactionReadinessFailure( + refreshToken: reservation.token, + underlyingError: error + ) + ) + } + } + finiteTasks.insert(task) + + do { + return try await completion.value() + } catch is ProcessingReceiptWaiterCancellation { + throw CancellationError() + } } package func process( @@ -87,40 +152,10 @@ package final class StoreTransactionRuntime: Sendable { ) async throws -> StorePurchaseOutcome { switch result { case .success(let verificationResult): - let accepted: - ( - snapshot: StoreTransactionSnapshot, - receipt: ProcessingReceipt - ) - do { - accepted = try await pipeline.accept( - source.purchaseDelivery(verificationResult) - ) - } catch { - leases.work.end() - leases.observer.end() - throw error - } - let operationReceipt = ProcessingReceipt() - let entitlements = entitlements - let task = Task { - defer { leases.work.end() } - do { - let snapshot = try await accepted.receipt.terminalValue() - let refresh = await entitlements.reserve() - _ = try await refresh.terminalValue() - operationReceipt.succeed(snapshot) - } catch { - operationReceipt.fail(error) - } - } - finiteTasks.insert(task) - return try await outcome( - receipt: operationReceipt, - operation: .processPurchase, - snapshot: accepted.snapshot, - observerLease: leases.observer - ) { .completed($0) } + return try await process( + source.purchaseDelivery(verificationResult), + leases: leases + ) case .pending: do { try Task.checkCancellation() @@ -150,25 +185,119 @@ package final class StoreTransactionRuntime: Sendable { } } + package func process( + _ delivery: StoreTransactionDelivery, + leases: FiniteOperationLeases + ) async throws -> StorePurchaseOutcome { + let accepted: + ( + snapshot: StoreTransactionSnapshot, + acceptance: ProcessingAcceptance, + retryFailedTransactions: Bool + ) + do { + accepted = try await pipeline.accept(delivery) + } catch { + leases.work.end() + leases.observer.end() + throw error + } + let observation = DirectOperationObservation() + let transactionBinding = observation.bind( + to: accepted.acceptance.reportingAuthority + ) + let operationReceipt = ProcessingReceipt() + let entitlements = entitlements + let task = Task { + defer { leases.work.end() } + let snapshot: StoreTransactionSnapshot + do { + snapshot = try await accepted.acceptance.receipt + .terminalValue() + } catch { + operationReceipt.fail( + await directFailure( + observation: observation, + binding: transactionBinding, + propagating: error, + reportsWhenAbandoned: + accepted.acceptance.role == .owner, + operation: .processPurchase, + snapshot: accepted.snapshot + ) + ) + return + } + observation.succeed(transactionBinding) + + let refresh = await entitlements.reserve( + retryFailedTransactions: + accepted.retryFailedTransactions + ) + let refreshBinding = observation.bind( + to: refresh.reportingAuthority + ) + do { + _ = try await refresh.receipt.terminalValue() + observation.succeed(refreshBinding) + operationReceipt.succeed(snapshot) + } catch { + operationReceipt.fail( + await directFailure( + observation: observation, + binding: refreshBinding, + propagating: error, + reportsWhenAbandoned: refresh.role == .owner, + operation: .processPurchase, + snapshot: accepted.snapshot + ) + ) + } + } + finiteTasks.insert(task) + return try await outcome( + receipt: operationReceipt, + observation: observation, + observerLease: leases.observer + ) { .completed($0) } + } + package func currentEntitlements( leases: FiniteOperationLeases ) async throws -> StoreEntitlements { + let retryFailedTransactions = + await core.retryFailedTransactionsInNewAttempt() + let refresh = await entitlements.reserve( + retryFailedTransactions: retryFailedTransactions + ) + let observation = DirectOperationObservation() + let binding = observation.bind(to: refresh.reportingAuthority) let operationReceipt = ProcessingReceipt() - let entitlements = entitlements let task = Task { defer { leases.work.end() } do { - let refresh = await entitlements.reserve() - operationReceipt.succeed(try await refresh.terminalValue()) + let value = try await refresh.receipt.terminalValue() + observation.succeed(binding) + operationReceipt.succeed( + value + ) } catch { - operationReceipt.fail(error) + operationReceipt.fail( + await directFailure( + observation: observation, + binding: binding, + propagating: error, + reportsWhenAbandoned: refresh.role == .owner, + operation: .currentEntitlements, + snapshot: nil + ) + ) } } finiteTasks.insert(task) return try await outcome( receipt: operationReceipt, - operation: .currentEntitlements, - snapshot: nil, + observation: observation, observerLease: leases.observer ) { $0 } } @@ -177,6 +306,10 @@ package final class StoreTransactionRuntime: Sendable { for productID: Product.ID, leases: FiniteOperationLeases ) async throws -> [StoreTransactionSnapshot] { + let observation = DirectOperationObservation() + let binding = observation.bind( + to: DirectOperationReportingAuthority() + ) let operationReceipt = ProcessingReceipt<[StoreTransactionSnapshot]>() let source = source let task = Task { @@ -184,16 +317,25 @@ package final class StoreTransactionRuntime: Sendable { do { let snapshots = try await source.history(productID) .sorted(by: Self.historyOrder) + observation.succeed(binding) operationReceipt.succeed(snapshots) } catch { - operationReceipt.fail(error) + operationReceipt.fail( + await directFailure( + observation: observation, + binding: binding, + propagating: error, + reportsWhenAbandoned: true, + operation: .history, + snapshot: nil + ) + ) } } finiteTasks.insert(task) return try await outcome( receipt: operationReceipt, - operation: .history, - snapshot: nil, + observation: observation, observerLease: leases.observer ) { $0 } } @@ -201,23 +343,56 @@ package final class StoreTransactionRuntime: Sendable { package func restorePurchases( leases: FiniteOperationLeases ) async throws -> StoreEntitlements { + let retryFailedTransactions = + await core.retryFailedTransactionsInNewAttempt() + let restore = await restoreCoordinator.reserve( + retryFailedTransactions: retryFailedTransactions + ) + let observation = DirectOperationObservation() + let restoreBinding = observation.bind( + to: restore.reportingAuthority + ) let operationReceipt = ProcessingReceipt() - let restoreCoordinator = restoreCoordinator let task = Task { defer { leases.work.end() } do { + let value = try await restore.receipt.terminalValue() + observation.succeed(restoreBinding) operationReceipt.succeed( - try await restoreCoordinator.restore() + value + ) + } catch let failure as RestoreCoordinatorFailure { + let failureBinding: DirectOperationObservation.Binding + if failure.reportingAuthority === restore.reportingAuthority { + failureBinding = restoreBinding + } else { + observation.succeed(restoreBinding) + failureBinding = observation.bind( + to: failure.reportingAuthority + ) + } + operationReceipt.fail( + await directFailure( + observation: observation, + binding: failureBinding, + propagating: failure.underlyingError, + reportsWhenAbandoned: + restore.role == .owner + && failure.reportsWhenAbandoned, + operation: .restorePurchases, + snapshot: nil + ) ) } catch { - operationReceipt.fail(error) + preconditionFailure( + "RestoreCoordinator exposed an unclassified failure: \(error)" + ) } } finiteTasks.insert(task) return try await outcome( receipt: operationReceipt, - operation: .restorePurchases, - snapshot: nil, + observation: observation, observerLease: leases.observer ) { $0 } } @@ -225,11 +400,11 @@ package final class StoreTransactionRuntime: Sendable { package func close() async { producerCancellation.cancel() await updatesTask.value - await unfinishedTask.value + await subscriptionStatusTask.value await operations.stopAdmissionAndWait() await finiteTasks.waitForAll() - await core.finishInputAndDrain() await entitlements.sealAndDrain() + await core.finishInputAndDrain() await failures.sealAndDrain() producerCancellation.removeAll() } @@ -241,39 +416,60 @@ package final class StoreTransactionRuntime: Sendable { private func outcome( receipt: ProcessingReceipt, - operation: StoreTransactionOperation, - snapshot: StoreTransactionSnapshot?, + observation: DirectOperationObservation, observerLease: FiniteOperationLease, transform: (Value) -> Output ) async throws -> Output { do { let value = try await receipt.value() + observation.deliver() observerLease.end() return transform(value) - } catch is CancellationError { - let failures = failures - let task = Task { - defer { observerLease.end() } - do { - _ = try await receipt.terminalValue() - } catch { - await failures.enqueue( - StoreTransactionBackgroundFailure( - source: .abandonedDirectOperation(operation), - transactionID: snapshot?.id, - productID: snapshot?.productID, - underlyingError: error - )) - } + } catch is ProcessingReceiptWaiterCancellation { + if let report = observation.abandon() { + await failures.enqueue(report) } - finiteTasks.insert(task) + observerLease.end() throw CancellationError() + } catch let failure as DirectOperationFailure { + observation.deliver() + observerLease.end() + throw failure.underlyingError } catch { + observation.deliver() observerLease.end() throw error } } + private func directFailure( + observation: DirectOperationObservation, + binding: DirectOperationObservation.Binding, + propagating error: any Error, + reportsWhenAbandoned: Bool, + operation: StoreTransactionOperation, + snapshot: StoreTransactionSnapshot? + ) async -> DirectOperationFailure { + let propagation = StoreTransactionFailurePropagation(error) + let report: StoreTransactionBackgroundFailure? + if reportsWhenAbandoned && !propagation.hasReportingOwner { + report = StoreTransactionBackgroundFailure( + source: .abandonedDirectOperation(operation), + transactionID: snapshot?.id, + productID: snapshot?.productID, + underlyingError: propagation.underlyingError + ) + } else { + report = nil + } + if let claimed = observation.fail(binding, report: report) { + await failures.enqueue(claimed) + } + return DirectOperationFailure( + underlyingError: propagation.underlyingError + ) + } + package static func historyOrder( _ lhs: StoreTransactionSnapshot, _ rhs: StoreTransactionSnapshot diff --git a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift index 312938b..8264cd8 100644 --- a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift +++ b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift @@ -2,13 +2,18 @@ import Foundation import Synchronization package final class TaskCompletionBag: Sendable { - private let tasks = Mutex<[UUID: Task]>([:]) + private struct State { + var tasks: [UUID: Task] = [:] + var emptyWaiters: [CheckedContinuation] = [] + } + + private let state = Mutex(State()) package init() {} package func insert(_ task: Task) { let id = UUID() - tasks.withLock { $0[id] = task } + state.withLock { $0.tasks[id] = task } Task { [weak self] in await task.value self?.remove(id) @@ -16,25 +21,39 @@ package final class TaskCompletionBag: Sendable { } package func waitForAll() async { - let snapshot = tasks.withLock { Array($0.values) } - for task in snapshot { - await task.value + await withCheckedContinuation { continuation in + let isEmpty = state.withLock { state in + guard !state.tasks.isEmpty else { return true } + state.emptyWaiters.append(continuation) + return false + } + if isEmpty { + continuation.resume() + } } - tasks.withLock { $0.removeAll(keepingCapacity: false) } } package func cancel() { - let snapshot = tasks.withLock { Array($0.values) } + let snapshot = state.withLock { Array($0.tasks.values) } for task in snapshot { task.cancel() } } package func retainedTaskCount() -> Int { - tasks.withLock { $0.count } + state.withLock { $0.tasks.count } } private func remove(_ id: UUID) { - _ = tasks.withLock { $0.removeValue(forKey: id) } + let waiters = state.withLock { state -> [CheckedContinuation] in + state.tasks.removeValue(forKey: id) + guard state.tasks.isEmpty else { return [] } + let waiters = state.emptyWaiters + state.emptyWaiters.removeAll(keepingCapacity: false) + return waiters + } + for waiter in waiters { + waiter.resume() + } } } diff --git a/Sources/StoreTransactionKit/StoreEntitlements.swift b/Sources/StoreTransactionKit/StoreEntitlements.swift index 39bf372..7ac4f64 100644 --- a/Sources/StoreTransactionKit/StoreEntitlements.swift +++ b/Sources/StoreTransactionKit/StoreEntitlements.swift @@ -1,6 +1,10 @@ /// A complete, ordered projection of the current StoreKit entitlements. public struct StoreEntitlements: Sendable, Equatable { - /// Verified current entitlements in StoreTransactionKit's documented order. + /// Verified current entitlements in a stable order. + /// + /// Transactions are ordered by product identifier UTF-8 bytes ascending, + /// then purchase date ascending, transaction identifier ascending, and + /// exact JWS UTF-8 bytes ascending. public let transactions: [StoreTransactionSnapshot] package init(transactions: [StoreTransactionSnapshot]) { @@ -11,8 +15,18 @@ public struct StoreEntitlements: Sendable, Equatable { /// The initial entitlement publication completed by `start()`. package struct StoreTransactionReadiness: Sendable, Equatable { let entitlements: StoreEntitlements + let refreshToken: UInt64 - package init(entitlements: StoreEntitlements) { + package init( + entitlements: StoreEntitlements, + refreshToken: UInt64 + ) { self.entitlements = entitlements + self.refreshToken = refreshToken } } + +package struct StoreTransactionReadinessFailure: Error { + let refreshToken: UInt64 + let underlyingError: any Error +} diff --git a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift index 8c32988..a7f82e6 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift @@ -7,17 +7,32 @@ package extension StoreTransactionSource { await consume(LiveTransactionAdapter.delivery(result)) } }, - runUnfinished: { consume in - for await result in Transaction.unfinished { - await consume(LiveTransactionAdapter.delivery(result)) + runSubscriptionStatusUpdates: { consume in + for await _ in Product.SubscriptionInfo.Status.updates { + await consume() } }, currentEntitlements: { var snapshots: [StoreTransactionSnapshot] = [] + var verificationFailures: [StoreTransactionVerificationError] = [] for await result in Transaction.currentEntitlements { - snapshots.append(try LiveTransactionAdapter.snapshot(result)) + do { + snapshots.append(try LiveTransactionAdapter.snapshot(result)) + } catch let error as StoreTransactionVerificationError { + verificationFailures.append(error) + } } - return snapshots + return CurrentEntitlementQueryResult( + snapshots: snapshots, + verificationFailures: verificationFailures + ) + }, + queryUnfinished: { + var deliveries: [StoreTransactionDelivery] = [] + for await result in Transaction.unfinished { + deliveries.append(LiveTransactionAdapter.delivery(result)) + } + return deliveries }, history: { productID in var snapshots: [StoreTransactionSnapshot] = [] diff --git a/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift b/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift index 8258661..babcf92 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift @@ -21,7 +21,8 @@ package enum LiveTransactionAdapter { )) case .unverified(_, let error): return .unverified( - StoreTransactionVerificationError( + revision: Data(result.jwsRepresentation.utf8), + error: StoreTransactionVerificationError( underlyingError: error )) } @@ -51,6 +52,12 @@ package enum LiveTransactionAdapter { productID: transaction.productID, subscriptionGroupID: transaction.subscriptionGroupID, productType: transaction.productType, + environment: transaction.environment, + offer: transaction.offer, + storefrontID: transaction.storefront.id, + storefrontCountryCode: transaction.storefront.countryCode, + price: transaction.price, + currency: transaction.currency, purchaseDate: transaction.purchaseDate, originalPurchaseDate: transaction.originalPurchaseDate, expirationDate: transaction.expirationDate, diff --git a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift index 1aba9be..c9e8f40 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift @@ -1,8 +1,22 @@ +import Foundation import StoreKit package enum StoreTransactionDelivery: Sendable { case verified(ProcessingEnvelope) - case unverified(any Error) + case unverified(revision: Data, error: any Error) +} + +package struct CurrentEntitlementQueryResult: Sendable { + package let snapshots: [StoreTransactionSnapshot] + package let verificationFailures: [StoreTransactionVerificationError] + + package init( + snapshots: [StoreTransactionSnapshot], + verificationFailures: [StoreTransactionVerificationError] + ) { + self.snapshots = snapshots + self.verificationFailures = verificationFailures + } } package struct StoreTransactionSource: Sendable { @@ -10,11 +24,12 @@ package struct StoreTransactionSource: Sendable { @Sendable ( @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void - package let runUnfinished: + package let runSubscriptionStatusUpdates: @Sendable ( - @Sendable (StoreTransactionDelivery) async -> Void + @Sendable () async -> Void ) async -> Void - package let currentEntitlements: @Sendable () async throws -> [StoreTransactionSnapshot] + package let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult + package let queryUnfinished: @Sendable () async -> [StoreTransactionDelivery] package let history: @Sendable (Product.ID) async throws -> [StoreTransactionSnapshot] package let synchronize: @Sendable () async throws -> Void package let purchaseDelivery: @Sendable (VerificationResult) -> StoreTransactionDelivery @@ -24,13 +39,15 @@ package struct StoreTransactionSource: Sendable { @escaping @Sendable ( @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void, - runUnfinished: + runSubscriptionStatusUpdates: @escaping @Sendable ( - @Sendable (StoreTransactionDelivery) async -> Void + @Sendable () async -> Void ) async -> Void, currentEntitlements: @escaping @Sendable () async throws - -> [StoreTransactionSnapshot], + -> CurrentEntitlementQueryResult, + queryUnfinished: + @escaping @Sendable () async -> [StoreTransactionDelivery], history: @escaping @Sendable (Product.ID) async throws -> [StoreTransactionSnapshot], @@ -41,8 +58,9 @@ package struct StoreTransactionSource: Sendable { ) -> StoreTransactionDelivery ) { self.runUpdates = runUpdates - self.runUnfinished = runUnfinished + self.runSubscriptionStatusUpdates = runSubscriptionStatusUpdates self.currentEntitlements = currentEntitlements + self.queryUnfinished = queryUnfinished self.history = history self.synchronize = synchronize self.purchaseDelivery = purchaseDelivery diff --git a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift index 003ef59..57bb24f 100644 --- a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift +++ b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift @@ -1,5 +1,5 @@ /// The semantic result of processing a direct StoreKit purchase result. -public enum StorePurchaseOutcome: Sendable { +public enum StorePurchaseOutcome: Sendable, Hashable { /// StoreTransactionKit verified, durably handled, finished, and refreshed the transaction. case completed(StoreTransactionSnapshot) diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 7767164..2fd2bf6 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -1,8 +1,8 @@ import Foundation /// A store operation that can appear in lifecycle and diagnostic errors. -public enum StoreTransactionOperation: Sendable { - /// Processing a direct ``StoreKit/Product/PurchaseResult``. +public enum StoreTransactionOperation: Sendable, Hashable { + /// Processing a direct `Product.PurchaseResult`. case processPurchase /// Refreshing the current entitlement projection. @@ -27,16 +27,19 @@ package enum StoreTransactionCallback: Sendable { /// A failure produced by work that has no attached public caller. public struct StoreTransactionBackgroundFailure: Error, Sendable { /// The background path that produced the failure. - public enum Source: Sendable { - /// A delivery from ``StoreKit/Transaction/updates``. + public enum Source: Sendable, Hashable { + /// A delivery from `Transaction.updates`. case updates - /// A delivery from ``StoreKit/Transaction/unfinished`` during startup. + /// A delivery from `Transaction.unfinished` during monitoring or reconciliation. case unfinished /// A refresh requested after background processing completed. case entitlementRefresh + /// An unverified element omitted from the current entitlement projection. + case currentEntitlementVerification + /// A direct operation whose caller cancelled after the session accepted it. case abandonedDirectOperation(StoreTransactionOperation) } @@ -66,8 +69,33 @@ public struct StoreTransactionBackgroundFailure: Error, Sendable { } } +package struct StoreTransactionFailureWithReportingOwner: Error, Sendable { + package let underlyingError: any Error + + package init(underlyingError: any Error) { + self.underlyingError = underlyingError + } +} + +package struct StoreTransactionFailurePropagation: Sendable { + package let underlyingError: any Error + package let hasReportingOwner: Bool + + package init(_ error: any Error) { + if let owned = + error as? StoreTransactionFailureWithReportingOwner + { + self.underlyingError = owned.underlyingError + self.hasReportingOwner = true + } else { + self.underlyingError = error + self.hasReportingOwner = false + } + } +} + /// An error caused by using a store outside its documented lifecycle. -public enum StoreTransactionError: Error, Sendable { +public enum StoreTransactionError: Error, Sendable, Hashable { /// The store has begun its shared close operation and accepts no new work. case closing @@ -77,10 +105,13 @@ public enum StoreTransactionError: Error, Sendable { /// StoreKit returned a purchase result unknown to this framework version. case unknownPurchaseResult - /// A consumer callback attempted to synchronously reenter its own session. + /// A consumer callback attempted to reenter its own session. /// - /// Reentrancy is rejected because a callback can otherwise wait behind - /// itself or attempt to close the dispatcher that is executing it. + /// Reentrancy with propagated callback context is rejected because a + /// callback can otherwise wait behind itself or attempt to close the + /// dispatcher that is executing it. Callbacks must also avoid awaiting a + /// detached task that calls the same store: detached tasks don't carry the + /// callback context but can still create the same dependency cycle. case reentrantOperation(operation: StoreTransactionOperation) } @@ -89,12 +120,19 @@ package enum StoreTransactionLifecycleError: Error, Sendable { case notStarted } -package struct StoreTransactionVerificationError: LocalizedError, Sendable { - package let underlyingError: any Error +/// An error StoreKit returns when transaction verification fails. +public struct StoreTransactionVerificationError: LocalizedError, Sendable { + /// The verification error supplied by StoreKit. + public let underlyingError: any Error - package var errorDescription: String? { + /// A localized description of the underlying StoreKit verification error. + public var errorDescription: String? { underlyingError.localizedDescription } + + package init(underlyingError: any Error) { + self.underlyingError = underlyingError + } } package enum StoreTransactionInternalError: Error, Sendable { diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index 136815f..c0cf6a1 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -5,35 +5,47 @@ verified transaction only after its durable business effect succeeds. ## Overview -StoreKit can deliver a purchase as a direct ``StoreKit/Product/PurchaseResult``, -through ``StoreKit/Transaction/updates``, or as unfinished work on a later -launch. ``Store`` normalizes these paths into one verified, FIFO transaction +StoreKit can deliver a purchase as a direct `Product.PurchaseResult`, through +`Transaction.updates`, or as unfinished work on a later launch. +``TransactionStore`` normalizes these paths into one verified, FIFO transaction processor and publishes observable current-entitlement state. +Startup and entitlement refreshes reconcile every verified transaction still +reported by `Transaction.unfinished`, including consumables, before publishing +entitlement state. A durable handler failure fails readiness or the refresh and +leaves that transaction available for a later retry. Unverified unfinished +deliveries are reported through ``StoreTransactionBackgroundFailure/Source/unfinished``. + Create one store in the application composition root. Supply an idempotent transaction handler that commits the app's business effect before returning. The app defines a string-backed entitlement identifier type; -``Store/activeEntitlements`` then exposes an optional typed set derived from -StoreKit's verified current entitlements. `nil` means the initial entitlement -query remains unresolved; an empty set means the query completed with no -matching entitlement. Pass direct results from custom purchase UI into -``Store/process(_:)``. +``TransactionStore/activeEntitlements`` then exposes an optional typed set +derived from StoreKit's verified current entitlements. `nil` means the initial +entitlement query remains unresolved; an empty set means the query completed +with no matching entitlement. Transactions superseded by a subscription upgrade +remain in the complete snapshot but don't appear in the typed active set. Pass +direct results from custom purchase UI into ``TransactionStore/process(_:)``. The framework owns StoreKit verification, process-local exact-revision coalescing, `finish()`, entitlement refresh, history ordering, restore synchronization, background failure delivery, and explicit shutdown. The app continues to own persistence, server communication, access presentation, and -the concrete purchase scene or window. +the concrete purchase scene or window. It also owns raw +`Product.SubscriptionInfo.Status` interpretation for renewal, grace-period, +and billing-retry UI, and `PurchaseIntent.intents` handling for purchases that +begin outside the app. -> Important: StoreKit delivery is at least once. Make the injected transaction -> handler durably idempotent using transaction identity and the business event -> it applies. Do not call StoreKit `finish()` from the handler. +> Important: StoreTransactionKit exposes an at-least-once handler-delivery +> contract. Make the injected transaction handler durably idempotent using +> transaction identity and the business event it applies. Purchase and +> revocation revisions are distinct business events. Do not call StoreKit +> `finish()` or call back into the same store from the handler. ## Topics ### Creating a store -- ``Store`` +- ``TransactionStore`` - ``StoreEntitlements`` ### Processing purchases @@ -44,5 +56,6 @@ the concrete purchase scene or window. ### Diagnosing lifecycle and background work - ``StoreTransactionError`` +- ``StoreTransactionVerificationError`` - ``StoreTransactionBackgroundFailure`` - ``StoreTransactionOperation`` diff --git a/Sources/StoreTransactionKit/StoreTransactionSession.swift b/Sources/StoreTransactionKit/StoreTransactionSession.swift index 74bfe23..e79df6e 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSession.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSession.swift @@ -15,6 +15,7 @@ package actor StoreTransactionSession { let source: StoreTransactionSource let handleTransaction: @Sendable (StoreTransactionSnapshot) async throws -> Void let entitlementsDidChange: @Sendable (StoreEntitlements) async -> Void + let entitlementRefreshDidSucceed: @Sendable (EntitlementRefreshSuccess) async -> Void let reportFailure: @Sendable (StoreTransactionBackgroundFailure) async -> Void } @@ -32,9 +33,10 @@ package actor StoreTransactionSession { /// /// - Parameters: /// - handleTransaction: Applies the durable business effect for a verified - /// transaction. The handler must be idempotent because StoreKit delivery - /// is at least once. StoreTransactionKit calls `finish()` only after this - /// closure returns successfully. + /// transaction. StoreTransactionKit exposes an at-least-once + /// handler-delivery contract, so the handler must be idempotent. + /// StoreTransactionKit calls `finish()` only after this closure returns + /// successfully. /// - entitlementsDidChange: Receives complete, ordered entitlement /// snapshots when the current entitlement content changes. /// - reportFailure: Receives failures from process-owned work that has no @@ -56,6 +58,7 @@ package actor StoreTransactionSession { source: .live, handleTransaction: handleTransaction, entitlementsDidChange: entitlementsDidChange, + entitlementRefreshDidSucceed: { _ in }, reportFailure: reportFailure )) } @@ -67,6 +70,8 @@ package actor StoreTransactionSession { @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, entitlementsDidChange: @escaping @Sendable (StoreEntitlements) async -> Void = { _ in }, + entitlementRefreshDidSucceed: + @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in }, reportFailure: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void ) { @@ -76,13 +81,14 @@ package actor StoreTransactionSession { source: source, handleTransaction: handleTransaction, entitlementsDidChange: entitlementsDidChange, + entitlementRefreshDidSucceed: entitlementRefreshDidSucceed, reportFailure: reportFailure )) } /// Starts transaction monitoring, reconciles unfinished work, and publishes initial entitlements. /// - /// Both StoreKit producer tasks are retained before this method first + /// All StoreKit producer tasks are retained before this method first /// suspends. The method returns only after the startup unfinished sequence, /// the initial entitlement query, and any initial entitlement callback have /// completed. @@ -94,6 +100,20 @@ package actor StoreTransactionSession { /// - Throws: A lifecycle or callback reentrancy error, an entitlement query /// error, or `CancellationError` when the attached waiter cancels. package func start() async throws -> StoreTransactionReadiness { + do { + return try await startPreservingReadinessFailure() + } catch let failure as StoreTransactionReadinessFailure { + throw failure.underlyingError + } + } + + package func startForTransactionStore() async throws -> StoreTransactionReadiness { + try await startPreservingReadinessFailure() + } + + private func startPreservingReadinessFailure() async throws + -> StoreTransactionReadiness + { guard case .initialized(let configuration) = state else { switch state { case .running: throw StoreTransactionLifecycleError.alreadyStarted @@ -108,6 +128,8 @@ package actor StoreTransactionSession { source: configuration.source, handleTransaction: configuration.handleTransaction, entitlementsDidChange: configuration.entitlementsDidChange, + entitlementRefreshDidSucceed: + configuration.entitlementRefreshDidSucceed, reportFailure: configuration.reportFailure ) state = .running(runtime) @@ -166,7 +188,8 @@ package actor StoreTransactionSession { /// /// - Parameter productID: The StoreKit product identifier to query. /// - Returns: Verified snapshots ordered by purchase date, signed date, and - /// transaction identifier descending, with exact JWS bytes as a final tie breaker. + /// transaction identifier descending, then exact JWS UTF-8 bytes + /// ascending. /// - Throws: A StoreKit verification or query error, a lifecycle or callback /// reentrancy error, or `CancellationError` for an abandoned waiter. package func history( @@ -187,7 +210,9 @@ package actor StoreTransactionSession { /// /// - Returns: The entitlement publication from a query reserved after sync succeeds. /// - Throws: A synchronization, verification, query, lifecycle, callback - /// reentrancy, or caller cancellation error. + /// reentrancy, or caller cancellation error. StoreKit may throw + /// ``StoreKit/StoreKitError/userCancelled`` when the user dismisses + /// authentication; callers should treat that as a normal user outcome. package func restorePurchases() async throws -> StoreEntitlements { let runtime = try runningRuntime(operation: .restorePurchases) guard let leases = runtime.beginOperation() else { diff --git a/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift b/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift index 338161f..fcfca65 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift @@ -4,7 +4,7 @@ import StoreKit /// An immutable projection of a transaction that StoreKit verified. /// /// StoreTransactionKit creates snapshots only after StoreKit verification -/// succeeds. A snapshot never owns the underlying ``StoreKit/Transaction`` and +/// succeeds. A snapshot never owns the underlying `Transaction` and /// exposes no authority to finish it. public struct StoreTransactionSnapshot: Sendable, Hashable { /// The identifier of this transaction revision's transaction. @@ -22,6 +22,26 @@ public struct StoreTransactionSnapshot: Sendable, Hashable { /// The StoreKit product type associated with the transaction. public let productType: Product.ProductType + /// The App Store server environment that generated and signed the transaction. + public let environment: AppStore.Environment + + /// The offer that applies to the transaction, when applicable. + public let offer: Transaction.Offer? + + /// The Apple-defined identifier of the storefront associated with the transaction. + public let storefrontID: String + + /// The ISO 3166-1 alpha-3 country code of the transaction's storefront. + public let storefrontCountryCode: String + + /// The total price StoreKit recorded for the transaction, in units of ``currency``. + /// + /// Use App Store Connect reporting tools for financial and accounting purposes. + public let price: Decimal? + + /// The currency of ``price``. + public let currency: Locale.Currency? + /// The date on which the customer purchased this transaction. public let purchaseDate: Date @@ -68,6 +88,12 @@ public struct StoreTransactionSnapshot: Sendable, Hashable { productID: String, subscriptionGroupID: String?, productType: Product.ProductType, + environment: AppStore.Environment, + offer: Transaction.Offer?, + storefrontID: String, + storefrontCountryCode: String, + price: Decimal?, + currency: Locale.Currency?, purchaseDate: Date, originalPurchaseDate: Date, expirationDate: Date?, @@ -86,6 +112,12 @@ public struct StoreTransactionSnapshot: Sendable, Hashable { self.productID = productID self.subscriptionGroupID = subscriptionGroupID self.productType = productType + self.environment = environment + self.offer = offer + self.storefrontID = storefrontID + self.storefrontCountryCode = storefrontCountryCode + self.price = price + self.currency = currency self.purchaseDate = purchaseDate self.originalPurchaseDate = originalPurchaseDate self.expirationDate = expirationDate diff --git a/Sources/StoreTransactionKit/Store.swift b/Sources/StoreTransactionKit/TransactionStore.swift similarity index 55% rename from Sources/StoreTransactionKit/Store.swift rename to Sources/StoreTransactionKit/TransactionStore.swift index 169d3b5..a7c9bb2 100644 --- a/Sources/StoreTransactionKit/Store.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -7,6 +7,8 @@ import StoreKit /// Create one store in the application's process composition root. The store /// starts transaction monitoring during initialization and publishes complete /// current-entitlement snapshots on the main actor. +/// Public operations other than ``close()`` wait for the startup attempt, +/// including durable handling of startup unfinished transactions, to complete. /// /// `EntitlementID` is an app-defined, string-backed identifier. Values whose /// raw values match a current StoreKit entitlement appear in @@ -15,53 +17,62 @@ import StoreKit /// never hidden. @MainActor @Observable -public final class Store +public final class TransactionStore where EntitlementID: RawRepresentable & Hashable & Sendable, EntitlementID.RawValue == String { - /// The latest complete current-entitlement projection. + /// The latest verified current-entitlement projection. /// /// The value is `nil` until the first entitlement query succeeds. An empty /// projection is non-`nil` and means StoreKit reported no current - /// entitlements. + /// entitlements. Elements that StoreKit can't verify are omitted and + /// reported through the failure callback. public private(set) var entitlements: StoreEntitlements? - /// App-defined identifiers represented by the latest current entitlements. + /// App-defined identifiers represented by the latest active entitlements. /// /// The value is `nil` until the first entitlement query succeeds. An empty /// set is non-`nil` and means none of the app-defined identifiers is - /// currently entitled. + /// currently entitled. Transactions superseded by a subscription upgrade + /// remain available through ``entitlements`` but don't appear in this set. public var activeEntitlements: Set? { entitlements.map { entitlements in Set( entitlements.transactions.compactMap { - EntitlementID(rawValue: $0.productID) + guard !$0.isUpgraded else { return nil } + return EntitlementID(rawValue: $0.productID) }) } } - /// The error from the initial entitlement query when startup did not reach - /// readiness. + /// The error from the initial readiness attempt. /// - /// Transaction monitoring remains active after a recoverable startup query - /// failure. A later successful entitlement refresh clears this value. + /// Startup includes durable handling of every verified transaction still + /// reported by `Transaction.unfinished`. Transaction monitoring remains + /// active after a recoverable startup failure. A later successful + /// entitlement refresh retries unfinished work and clears this value. public private(set) var startupError: (any Error)? @ObservationIgnored private let sessionID: UUID @ObservationIgnored private let transactionSession: StoreTransactionSession @ObservationIgnored private let startupCompletion: ProcessingReceipt @ObservationIgnored private var startupTask: Task? + @ObservationIgnored private var startupOrdering = TransactionStoreStartupOrdering() /// Creates and starts an observable StoreKit store. /// /// - Parameters: /// - handleTransaction: Applies the durable business effect for a verified - /// transaction. The handler must be idempotent because StoreKit delivery - /// is at least once. StoreTransactionKit calls `finish()` only after the - /// closure returns successfully. + /// transaction. StoreTransactionKit exposes an at-least-once + /// handler-delivery contract, so the handler must be idempotent. + /// StoreTransactionKit calls `finish()` only after the closure returns + /// successfully. The handler must not call back into the same store, + /// directly or through an awaited child task, because doing so creates a + /// dependency cycle with the operation being handled. /// - reportFailure: Receives failures from process-owned transaction work - /// that has no attached public caller. + /// that has no attached public caller. This callback must not call back + /// into the same store, directly or through an awaited child task. public convenience init( handleTransaction: @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, @@ -82,15 +93,16 @@ where reportFailure: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void ) { - let owner = StoreOwner() + let owner = TransactionStoreOwner() let sessionID = UUID() let startupCompletion = ProcessingReceipt() let transactionSession = StoreTransactionSession( sessionID: sessionID, source: source, handleTransaction: handleTransaction, - entitlementsDidChange: { entitlements in - await owner.apply(entitlements) + entitlementsDidChange: { _ in }, + entitlementRefreshDidSucceed: { success in + await owner.apply(success) }, reportFailure: reportFailure ) @@ -102,12 +114,18 @@ where startupTask = Task { [weak self, transactionSession] in defer { startupCompletion.succeed(()) } do { - _ = try await transactionSession.start() - } catch is CancellationError { + _ = try await transactionSession.startForTransactionStore() + } catch let failure as StoreTransactionReadinessFailure { + guard !Task.isCancelled else { return } + self?.applyStartupFailure( + token: failure.refreshToken, + error: failure.underlyingError + ) + } catch { // Explicit close cancels only this readiness waiter. The // session's close operation owns draining accepted work. - } catch { guard !Task.isCancelled else { return } + self?.startupOrdering.recordUnsequencedFailure() self?.startupError = error } } @@ -116,7 +134,7 @@ where /// Processes a direct result from custom purchase UI. /// /// StoreKit views deliver successful purchases through - /// ``StoreKit/Transaction/updates`` by default and don't need to call this + /// `Transaction.updates` by default and don't need to call this /// method. Use it for a result returned directly by a `Product` purchase /// API or by a custom StoreKit view completion action. public func process( @@ -128,6 +146,11 @@ where /// Refreshes current entitlements and updates observable store state. /// + /// Before publishing the result, the store durably handles every verified + /// transaction currently reported by `Transaction.unfinished`, including + /// consumables. A handler failure leaves the transaction unfinished, fails + /// this refresh, and allows a later refresh to retry it. + /// /// - Returns: The complete verified entitlement projection. @discardableResult public func refreshEntitlements() async throws -> StoreEntitlements { @@ -136,6 +159,12 @@ where } /// Returns verified transaction history for a product in newest-first order. + /// + /// StoreKit omits finished consumables unless the app enables + /// `SKIncludeConsumableInAppPurchaseHistory` in its information property + /// list. Revoked and refunded transactions remain in the returned history. + /// Results are ordered by purchase date, signed date, and transaction + /// identifier descending, then exact JWS UTF-8 bytes ascending. public func history( for productID: Product.ID ) async throws -> [StoreTransactionSnapshot] { @@ -146,7 +175,10 @@ where /// Synchronizes App Store purchases after an explicit user restore action. /// /// This method can present authentication UI. It refreshes observable - /// entitlement state before returning. + /// entitlement state before returning. StoreKit may throw + /// ``StoreKit/StoreKitError/userCancelled`` when the user dismisses + /// authentication; treat that as a normal user outcome rather than a + /// diagnostic failure. @discardableResult public func restorePurchases() async throws -> StoreEntitlements { try await waitForStartupAttempt(operation: .restorePurchases) @@ -157,6 +189,9 @@ where /// /// Production apps normally retain the store for process lifetime. Call /// this method from controlled shutdown and test lifecycles. + /// + /// - Throws: ``StoreTransactionError/reentrantOperation(operation:)`` when + /// an injected callback attempts to close the store that is executing it. public func close() async throws { try rejectReentrancy(operation: .close) startupTask?.cancel() @@ -165,9 +200,20 @@ where startupTask = nil } - fileprivate func apply(_ entitlements: StoreEntitlements) { - self.entitlements = entitlements - startupError = nil + fileprivate func apply(_ success: EntitlementRefreshSuccess) { + entitlements = success.entitlements + if startupOrdering.recordSuccess(token: success.token) { + startupError = nil + } + } + + private func applyStartupFailure( + token: UInt64, + error: any Error + ) { + if startupOrdering.recordFailure(token: token) { + startupError = error + } } private func waitForStartupAttempt( @@ -175,7 +221,11 @@ where ) async throws { try rejectReentrancy(operation: operation) try Task.checkCancellation() - try await startupCompletion.value() + do { + try await startupCompletion.value() + } catch is ProcessingReceiptWaiterCancellation { + throw CancellationError() + } } private func rejectReentrancy( @@ -197,15 +247,38 @@ where } } +package struct TransactionStoreStartupOrdering: Sendable { + private var latestSuccessfulToken: UInt64 = 0 + private var failureToken: UInt64? + + package mutating func recordSuccess(token: UInt64) -> Bool { + precondition(token > latestSuccessfulToken) + latestSuccessfulToken = token + guard let failureToken, token > failureToken else { return false } + self.failureToken = nil + return true + } + + package mutating func recordFailure(token: UInt64) -> Bool { + guard latestSuccessfulToken < token else { return false } + failureToken = token + return true + } + + package mutating func recordUnsequencedFailure() { + failureToken = latestSuccessfulToken + } +} + @MainActor -private final class StoreOwner: Sendable +private final class TransactionStoreOwner: Sendable where EntitlementID: RawRepresentable & Hashable & Sendable, EntitlementID.RawValue == String { - weak var store: Store? + weak var store: TransactionStore? - func apply(_ entitlements: StoreEntitlements) { - store?.apply(entitlements) + func apply(_ success: EntitlementRefreshSuccess) { + store?.apply(success) } } diff --git a/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift b/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift new file mode 100644 index 0000000..2a6eea2 --- /dev/null +++ b/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift @@ -0,0 +1,81 @@ +import Testing +@testable import StoreTransactionKit + +@Suite("Completed delivery entitlement refresh", .timeLimit(.minutes(1))) +struct CompletedDeliveryRefreshTests { + @Test("a completed redelivery retries a failed entitlement refresh") + func completedRedeliveryRetriesRefresh() async { + let snapshot = makeSnapshot( + id: 51, + productID: "lifetime.completed", + productType: .nonConsumable, + jws: "completed-redelivery" + ) + let query = FailingOnceEntitlementQuery(recovered: [snapshot]) + let handlerCalls = TestSignal() + let finishes = TestSignal() + let publications = UInt64Recorder() + let reports = StringRecorder() + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + } + let entitlements = EntitlementRefreshCoordinator( + query: { _ in try await query.next() }, + didChange: { value in + await publications.append(UInt64(value.transactions.count)) + } + ) + let failures = FailureReporterDispatcher { failure in + await reports.append( + "\(failure.source)-\(failure.transactionID ?? 0)-\(failure.productID ?? "")" + ) + } + let pipeline = StoreTransactionPipeline( + core: core, + entitlements: entitlements, + failures: failures + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + + await pipeline.processBackground(delivery, source: .updates) + await pipeline.processBackground(delivery, source: .unfinished) + + await core.finishInputAndDrain() + await entitlements.sealAndDrain() + await failures.sealAndDrain() + #expect(await query.count() == 2) + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 1) + #expect(await publications.snapshot() == [1]) + #expect( + await reports.snapshot() == [ + "entitlementRefresh-51-lifetime.completed" + ] + ) + } +} + +private actor FailingOnceEntitlementQuery { + private let recovered: [StoreTransactionSnapshot] + private var invocationCount = 0 + + init(recovered: [StoreTransactionSnapshot]) { + self.recovered = recovered + } + + func next() throws -> [StoreTransactionSnapshot] { + invocationCount += 1 + if invocationCount == 1 { + throw TestFailure() + } + return recovered + } + + func count() -> Int { + invocationCount + } +} diff --git a/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift b/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift new file mode 100644 index 0000000..7baf55c --- /dev/null +++ b/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift @@ -0,0 +1,73 @@ +import Testing +@testable import StoreTransactionKit + +@Suite("Direct operation reporting authority") +struct DirectOperationReportingTests { + @Test("an attached caller receives the failure without a background report") + func attachedCallerOwnsFailureDelivery() throws { + let authority = DirectOperationReportingAuthority() + let owner = DirectOperationObservation() + let observer = DirectOperationObservation() + let ownerBinding = owner.bind(to: authority) + let observerBinding = observer.bind(to: authority) + + #expect(owner.abandon() == nil) + #expect( + owner.fail(ownerBinding, report: makeReport(id: 1)) == nil + ) + #expect(observer.fail(observerBinding, report: nil) == nil) + observer.deliver() + + #expect(owner.abandon() == nil) + } + + @Test("the last abandoned caller claims the physical owner's report once") + func lastAbandonedCallerClaimsOwnerReport() throws { + let authority = DirectOperationReportingAuthority() + let owner = DirectOperationObservation() + let observer = DirectOperationObservation() + let ownerBinding = owner.bind(to: authority) + let observerBinding = observer.bind(to: authority) + + #expect( + owner.fail(ownerBinding, report: makeReport(id: 2)) == nil + ) + #expect(observer.fail(observerBinding, report: nil) == nil) + #expect(owner.abandon() == nil) + let claimed = observer.abandon() + + #expect( + claimed?.source + == .abandonedDirectOperation(.currentEntitlements) + ) + #expect(claimed?.transactionID == 2) + #expect(claimed?.underlyingError is TestFailure) + #expect(observer.abandon() == nil) + } + + @Test("a failure claims once when every caller abandoned before completion") + func failureAfterEveryCallerAbandons() throws { + let authority = DirectOperationReportingAuthority() + let owner = DirectOperationObservation() + let observer = DirectOperationObservation() + let ownerBinding = owner.bind(to: authority) + let observerBinding = observer.bind(to: authority) + + #expect(owner.abandon() == nil) + #expect(observer.abandon() == nil) + #expect(observer.fail(observerBinding, report: nil) == nil) + let claimed = owner.fail(ownerBinding, report: makeReport(id: 3)) + + #expect(claimed?.transactionID == 3) + #expect(owner.fail(ownerBinding, report: makeReport(id: 3)) == nil) + } + + private func makeReport(id: UInt64) -> StoreTransactionBackgroundFailure { + StoreTransactionBackgroundFailure( + source: .abandonedDirectOperation(.currentEntitlements), + transactionID: id, + productID: "product-\(id)", + underlyingError: TestFailure() + ) + } +} diff --git a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift index 37a8cf7..93d9c99 100644 --- a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift +++ b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift @@ -1,32 +1,32 @@ import Testing @testable import StoreTransactionKit -@Suite("EntitlementRefreshCoordinator") +@Suite("EntitlementRefreshCoordinator", .timeLimit(.minutes(1))) struct EntitlementRefreshCoordinatorTests { @Test("reservations that arrive during a query run in the next cycle") func cutoffReservations() async throws { let query = ControlledEntitlementQuery() let publicationSizes = UInt64Recorder() let coordinator = EntitlementRefreshCoordinator( - query: { try await query.next() }, + query: { _ in try await query.next() }, didChange: { value in await publicationSizes.append(UInt64(value.transactions.count)) } ) let first = await coordinator.reserve() - await query.waitForRequest(1) + try await query.waitForRequest(1) let second = await coordinator.reserve() await query.succeed([makeSnapshot(id: 1, productID: "b")]) - let firstValue = try await first.terminalValue() + let firstValue = try await first.receipt.terminalValue() #expect(firstValue.transactions.map(\.productID) == ["b"]) - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.succeed([ makeSnapshot(id: 2, productID: "a"), makeSnapshot(id: 1, productID: "b"), ]) - let secondValue = try await second.terminalValue() + let secondValue = try await second.receipt.terminalValue() #expect(secondValue.transactions.map(\.productID) == ["a", "b"]) #expect(await publicationSizes.snapshot() == [1, 2]) @@ -37,26 +37,31 @@ struct EntitlementRefreshCoordinatorTests { func equalContentDoesNotPublish() async throws { let query = ControlledEntitlementQuery() let publicationSizes = UInt64Recorder() + let successfulTokens = UInt64Recorder() let coordinator = EntitlementRefreshCoordinator( - query: { try await query.next() }, + query: { _ in try await query.next() }, didChange: { value in await publicationSizes.append(UInt64(value.transactions.count)) + }, + didSucceed: { success in + await successfulTokens.append(success.token) } ) let snapshot = makeSnapshot(id: 4) let first = await coordinator.reserve() - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.succeed([snapshot]) - _ = try await first.terminalValue() + _ = try await first.receipt.terminalValue() let second = await coordinator.reserve() - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.succeed([snapshot]) - let value = try await second.terminalValue() + let value = try await second.receipt.terminalValue() #expect(value.transactions == [snapshot]) #expect(await publicationSizes.snapshot() == [1]) + #expect(await successfulTokens.snapshot() == [1, 2]) await coordinator.sealAndDrain() } @@ -65,26 +70,116 @@ struct EntitlementRefreshCoordinatorTests { let query = ControlledEntitlementQuery() let publicationSizes = UInt64Recorder() let coordinator = EntitlementRefreshCoordinator( - query: { try await query.next() }, + query: { _ in try await query.next() }, didChange: { value in await publicationSizes.append(UInt64(value.transactions.count)) } ) let failed = await coordinator.reserve() - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.fail(TestFailure()) await #expect(throws: TestFailure.self) { - _ = try await failed.terminalValue() + _ = try await failed.receipt.terminalValue() } #expect(await publicationSizes.snapshot().isEmpty) let recovered = await coordinator.reserve() - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.succeed([]) - let value = try await recovered.terminalValue() + let value = try await recovered.receipt.terminalValue() #expect(value.transactions.isEmpty) #expect(await publicationSizes.snapshot() == [0]) await coordinator.sealAndDrain() } + + @Test("each pending query batch has one reporting owner") + func pendingBatchReportingAuthority() async throws { + let query = ControlledEntitlementQuery() + let coordinator = EntitlementRefreshCoordinator( + query: { _ in try await query.next() }, + didChange: { _ in } + ) + + let active = await coordinator.reserve() + try await query.waitForRequest(1) + let nextOwner = await coordinator.reserve() + let nextObserver = await coordinator.reserve() + + #expect(active.role == .owner) + #expect(nextOwner.role == .owner) + #expect(nextObserver.role == .observer) + #expect(active.reportingAuthority !== nextOwner.reportingAuthority) + #expect(nextOwner.reportingAuthority === nextObserver.reportingAuthority) + + await query.succeed([]) + _ = try await active.receipt.terminalValue() + try await query.waitForRequest(2) + await query.succeed([]) + _ = try await nextOwner.receipt.terminalValue() + _ = try await nextObserver.receipt.terminalValue() + await coordinator.sealAndDrain() + } + + @Test("mixed retry policies form contiguous query batches") + func mixedRetryPolicyBatches() async throws { + let query = ControlledEntitlementQuery() + let policies = StringRecorder() + let coordinator = EntitlementRefreshCoordinator( + query: { retryFailedTransactions in + await policies.append(String(retryFailedTransactions)) + return try await query.next() + }, + didChange: { _ in } + ) + + let active = await coordinator.reserve( + retryFailedTransactions: false + ) + try await query.waitForRequest(1) + let falseOwner = await coordinator.reserve( + retryFailedTransactions: false + ) + let falseObserver = await coordinator.reserve( + retryFailedTransactions: false + ) + let trueOwner = await coordinator.reserve( + retryFailedTransactions: true + ) + let trueObserver = await coordinator.reserve( + retryFailedTransactions: true + ) + let trailingFalseOwner = await coordinator.reserve( + retryFailedTransactions: false + ) + + #expect(falseOwner.role == .owner) + #expect(falseObserver.role == .observer) + #expect(trueOwner.role == .owner) + #expect(trueObserver.role == .observer) + #expect(trailingFalseOwner.role == .owner) + + await query.succeed([]) + _ = try await active.receipt.terminalValue() + + try await query.waitForRequest(2) + await query.succeed([]) + _ = try await falseOwner.receipt.terminalValue() + _ = try await falseObserver.receipt.terminalValue() + + try await query.waitForRequest(3) + await query.succeed([]) + _ = try await trueOwner.receipt.terminalValue() + _ = try await trueObserver.receipt.terminalValue() + + try await query.waitForRequest(4) + await query.succeed([]) + _ = try await trailingFalseOwner.receipt.terminalValue() + + #expect( + await policies.snapshot() == [ + "false", "false", "true", "false", + ]) + await coordinator.sealAndDrain() + } } diff --git a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift new file mode 100644 index 0000000..58769e2 --- /dev/null +++ b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift @@ -0,0 +1,232 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Failure reporter dispatcher", .timeLimit(.minutes(1))) +struct FailureReporterDispatcherTests { + @Test("capacity one delivers every admitted failure before enqueue returns") + func boundedDeliveryIsLossless() async throws { + let callbackEntered = TestSignal() + let callbackGate = TestGate() + let callbackValues = UInt64Recorder() + let enqueueCompleted = TestSignal() + let dispatcher = FailureReporterDispatcher(capacity: 1) { failure in + guard let transactionID = failure.transactionID else { + Issue.record("The test failure lost its transaction identifier.") + return + } + await callbackValues.append(transactionID) + await callbackEntered.send() + _ = try? await callbackGate.wait() + } + + let first = Task { + await dispatcher.enqueue(makeFailure(id: 1)) + await enqueueCompleted.send() + } + try await callbackEntered.wait(for: 1) + #expect(await enqueueCompleted.value() == 0) + + let second = Task { + await dispatcher.enqueue(makeFailure(id: 2)) + await enqueueCompleted.send() + } + let third = Task { + await dispatcher.enqueue(makeFailure(id: 3)) + await enqueueCompleted.send() + } + + await callbackGate.open() + await first.value + await second.value + await third.value + await dispatcher.sealAndDrain() + + let values = await callbackValues.snapshot() + #expect(values.first == 1) + #expect(Set(values) == [1, 2, 3]) + #expect(await enqueueCompleted.value() == 3) + } + + @Test("seal waits for an active failure callback and its enqueue receipt") + func sealDrainsActiveCallback() async throws { + let callbackEntered = TestSignal() + let callbackGate = TestGate() + let enqueueCompleted = TestSignal() + let sealStarted = TestSignal() + let sealCompleted = TestSignal() + let dispatcher = FailureReporterDispatcher(capacity: 1) { _ in + await callbackEntered.send() + _ = try? await callbackGate.wait() + } + + let enqueue = Task { + await dispatcher.enqueue(makeFailure(id: 1)) + await enqueueCompleted.send() + } + try await callbackEntered.wait(for: 1) + + let seal = Task { + await sealStarted.send() + await dispatcher.sealAndDrain() + await sealCompleted.send() + } + try await sealStarted.wait(for: 1) + #expect(await enqueueCompleted.value() == 0) + #expect(await sealCompleted.value() == 0) + + await callbackGate.open() + await enqueue.value + await seal.value + + #expect(await enqueueCompleted.value() == 1) + #expect(await sealCompleted.value() == 1) + } + + private func makeFailure(id: UInt64) -> StoreTransactionBackgroundFailure { + StoreTransactionBackgroundFailure( + source: .updates, + transactionID: id, + productID: "product-\(id)", + underlyingError: TestFailure() + ) + } +} + +@Suite("Restore coordinator failures", .timeLimit(.minutes(1))) +struct RestoreCoordinatorFailureTests { + @Test("coalesced waiters share a failure and the next reservation retries") + func coalescedFailureAllowsRetry() async throws { + let synchronization = ControlledRestoreSynchronization() + let entitlementQueryCount = TestSignal() + let entitlements = EntitlementRefreshCoordinator( + query: { _ in + await entitlementQueryCount.send() + return [] + }, + didChange: { _ in } + ) + let coordinator = RestoreCoordinator( + synchronize: { try await synchronization.run() }, + entitlements: entitlements + ) + + let first = await coordinator.reserve() + try await synchronization.waitForAttempt(1) + let second = await coordinator.reserve() + #expect(first.role == .owner) + #expect(second.role == .observer) + #expect(first.receipt === second.receipt) + #expect(first.reportingAuthority === second.reportingAuthority) + + await synchronization.releaseFirstAttempt() + await #expect(throws: RestoreCoordinatorFailure.self) { + _ = try await first.receipt.terminalValue() + } + await #expect(throws: RestoreCoordinatorFailure.self) { + _ = try await second.receipt.terminalValue() + } + + let retry = await coordinator.reserve() + #expect(retry.role == .owner) + #expect(retry.receipt !== first.receipt) + #expect(retry.reportingAuthority !== first.reportingAuthority) + try await synchronization.waitForAttempt(2) + let value = try await retry.receipt.terminalValue() + + #expect(value.transactions.isEmpty) + #expect(await synchronization.attemptCount() == 2) + #expect(await entitlementQueryCount.value() == 1) + await entitlements.sealAndDrain() + } +} + +@Suite("Session closing admission", .timeLimit(.minutes(1))) +struct SessionClosingAdmissionTests { + @Test("closing rejects every new session operation") + func closingRejectsNewOperations() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in }, + reportFailure: { _ in } + ) + + let startup = Task { try await session.start() } + try await query.waitForRequest(1) + await query.succeed([]) + _ = try await startup.value + + let acceptedRefresh = Task { + try await session.currentEntitlements() + } + try await query.waitForRequest(2) + + let close = Task { try await session.close() } + try await fixture.updateTermination.wait(for: 1) + + await expectClosing("start") { + _ = try await session.start() + } + await expectClosing("process") { + _ = try await session.process(.pending) + } + await expectClosing("currentEntitlements") { + _ = try await session.currentEntitlements() + } + await expectClosing("history") { + _ = try await session.history(for: "product") + } + await expectClosing("restorePurchases") { + _ = try await session.restorePurchases() + } + + await query.succeed([]) + _ = try await acceptedRefresh.value + try await close.value + } + + private func expectClosing( + _ operationName: String, + operation: () async throws -> Void + ) async { + do { + try await operation() + Issue.record("\(operationName) accepted new work while closing.") + } catch StoreTransactionError.closing { + } catch { + Issue.record("\(operationName) returned an unexpected error: \(error)") + } + } +} + +private actor ControlledRestoreSynchronization { + private let started = TestSignal() + private let firstAttemptGate = TestGate() + private var attempts = 0 + + func run() async throws { + attempts += 1 + let attempt = attempts + await started.send() + if attempt == 1 { + try await firstAttemptGate.wait() + throw TestFailure() + } + } + + func waitForAttempt(_ count: Int) async throws { + try await started.wait(for: count) + } + + func releaseFirstAttempt() async { + await firstAttemptGate.open() + } + + func attemptCount() -> Int { + attempts + } +} diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift new file mode 100644 index 0000000..e28ac42 --- /dev/null +++ b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift @@ -0,0 +1,568 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Current entitlement reconciliation fixed point", .timeLimit(.minutes(1))) +struct ReconciliationFixedPointTests { + @Test("reconciliation repeats until no new entitlement revision remains") + func repeatsUntilNoNewRevision() async throws { + let first = makeSnapshot( + id: 41, + productID: "lifetime.first", + productType: .nonConsumable, + jws: "fixed-point-first" + ) + let second = makeSnapshot( + id: 42, + productID: "lifetime.second", + productType: .nonConsumable, + jws: "fixed-point-second" + ) + let current = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let currentQueryCount = TestSignal() + let unfinishedQueryCount = TestSignal() + let handled = UInt64Recorder() + let finished = UInt64Recorder() + let reports = StringRecorder() + + let persistentFirst = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: first) + ) + let secondDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: second) { + await finished.append(second.id) + } + ) + let firstDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: first) { + await finished.append(first.id) + await current.replace(with: [first, second]) + await unfinished.replace( + with: [persistentFirst, secondDelivery] + ) + } + ) + await unfinished.replace(with: [firstDelivery]) + + let core = TransactionProcessingCore { snapshot in + await handled.append(snapshot.id) + } + let failures = FailureReporterDispatcher { failure in + await reports.append("\(failure.source)") + } + let reconciler = CurrentEntitlementReconciler( + query: { + await currentQueryCount.send() + return CurrentEntitlementQueryResult( + snapshots: await current.read(), + verificationFailures: [] + ) + }, + queryUnfinished: { + await unfinishedQueryCount.send() + return await unfinished.read() + }, + core: core, + failures: failures + ) + + let snapshots = try await reconciler.query( + retryFailedTransactions: false + ) + + await core.finishInputAndDrain() + await failures.sealAndDrain() + #expect(snapshots == [first, second]) + #expect(await handled.snapshot() == [first.id, second.id]) + #expect(await finished.snapshot() == [first.id, second.id]) + #expect(await currentQueryCount.value() == 1) + #expect(await unfinishedQueryCount.value() == 4) + #expect(await reports.snapshot().isEmpty) + } + + @Test("only the stable query reports current entitlement verification failures") + func reportsStableVerificationFailuresOnce() async throws { + let first = makeSnapshot( + id: 43, + productID: "lifetime.first", + productType: .nonConsumable, + jws: "fixed-point-verification-first" + ) + let second = makeSnapshot( + id: 47, + productID: "lifetime.second", + productType: .nonConsumable, + jws: "fixed-point-verification-second" + ) + let current = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let currentQueryCount = TestSignal() + let unfinishedQueryCount = TestSignal() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + + let persistentFirst = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: first) + ) + let persistentSecond = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: second) + ) + let secondDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: second) { + await current.replace(with: [first, second]) + await unfinished.replace( + with: [persistentFirst, persistentSecond] + ) + await finishes.send() + } + ) + let firstDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: first) { + await current.replace(with: [first]) + await unfinished.replace(with: [persistentFirst]) + await finishes.send() + } + ) + await unfinished.replace(with: [firstDelivery]) + + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + } + let failures = FailureReporterDispatcher { failure in + guard failure.source == .currentEntitlementVerification, + let verificationFailure = + failure.underlyingError as? StoreTransactionVerificationError + else { + await reports.append("unexpected") + return + } + switch verificationFailure.underlyingError { + case is DiscardedVerificationFailure: + await reports.append("discarded") + case is StableVerificationFailure: + await reports.append("stable") + default: + await reports.append("unexpected") + } + } + let reconciler = CurrentEntitlementReconciler( + query: { + await currentQueryCount.send() + let queryNumber = await currentQueryCount.value() + if queryNumber == 1 { + await unfinished.replace( + with: [persistentFirst, secondDelivery] + ) + } + let underlyingError: any Error = + if queryNumber == 1 { + DiscardedVerificationFailure() + } else { + StableVerificationFailure() + } + return CurrentEntitlementQueryResult( + snapshots: await current.read(), + verificationFailures: [ + StoreTransactionVerificationError( + underlyingError: underlyingError + ) + ] + ) + }, + queryUnfinished: { + await unfinishedQueryCount.send() + return await unfinished.read() + }, + core: core, + failures: failures + ) + + let snapshots = try await reconciler.query( + retryFailedTransactions: false + ) + + await core.finishInputAndDrain() + await failures.sealAndDrain() + #expect(snapshots == [first, second]) + #expect(await currentQueryCount.value() == 2) + #expect(await unfinishedQueryCount.value() == 5) + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 2) + #expect(await reports.snapshot() == ["stable"]) + } + + @Test("a failed unfinished consumable blocks readiness and remains retryable") + func failedUnfinishedConsumableIsRetryable() async throws { + let snapshot = makeSnapshot( + id: 44, + productID: "consumable.tokens", + productType: .consumable, + jws: "unfinished-consumable" + ) + let unfinished = UnfinishedValueSource() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await unfinished.replace(with: []) + } + ) + await unfinished.replace(with: [delivery]) + + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + } + let failures = FailureReporterDispatcher { failure in + guard failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.productID == snapshot.productID, + failure.underlyingError is TestFailure + else { + await reports.append("unexpected") + return + } + await reports.append("unfinished-\(snapshot.id)") + } + let reconciler = CurrentEntitlementReconciler( + query: { + CurrentEntitlementQueryResult( + snapshots: [], + verificationFailures: [] + ) + }, + queryUnfinished: { await unfinished.read() }, + core: core, + failures: failures + ) + + do { + _ = try await reconciler.query( + retryFailedTransactions: false + ) + Issue.record("A failed unfinished transaction unexpectedly reconciled.") + } catch let owned as StoreTransactionFailureWithReportingOwner { + #expect(owned.underlyingError is TestFailure) + } catch { + Issue.record("Unexpected reconciliation error: \(error)") + } + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await reports.snapshot() == ["unfinished-44"]) + + let snapshots = try await reconciler.query( + retryFailedTransactions: true + ) + + #expect(snapshots.isEmpty) + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["unfinished-44"]) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } + + @Test("unfinished work is durable before a current entitlement query failure") + func handlesUnfinishedBeforeCurrentQueryFailure() async throws { + let snapshot = makeSnapshot( + id: 48, + productID: "consumable.before-query-failure", + productType: .consumable, + jws: "unfinished-before-query-failure" + ) + let unfinished = UnfinishedValueSource() + let events = StringRecorder() + let reports = StringRecorder() + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await events.append("finish") + await unfinished.replace(with: []) + } + ) + await unfinished.replace(with: [delivery]) + + let core = TransactionProcessingCore { _ in + await events.append("handle") + } + let failures = FailureReporterDispatcher { failure in + await reports.append("\(failure.source)") + } + let reconciler = CurrentEntitlementReconciler( + query: { + await events.append("current-entitlements") + throw CurrentEntitlementQueryFailure() + }, + queryUnfinished: { + await events.append("unfinished") + return await unfinished.read() + }, + core: core, + failures: failures + ) + + await #expect(throws: CurrentEntitlementQueryFailure.self) { + _ = try await reconciler.query( + retryFailedTransactions: false + ) + } + + #expect( + await events.snapshot() == [ + "unfinished", + "handle", + "finish", + "unfinished", + "current-entitlements", + ]) + #expect(await reports.snapshot().isEmpty) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } + + @Test("a persistent unverified unfinished delivery is reported by the stable query once") + func persistentUnverifiedUnfinishedReportsOnce() async throws { + let snapshot = makeSnapshot( + id: 45, + productID: "lifetime.verified", + productType: .nonConsumable, + jws: "unfinished-verification-fixed-point" + ) + let current = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let currentQueryCount = TestSignal() + let unfinishedQueryCount = TestSignal() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let unverified = StoreTransactionDelivery.unverified( + revision: Data("persistent-unverified".utf8), + error: PersistentUnfinishedVerificationFailure() + ) + let verified = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await current.replace(with: [snapshot]) + await unfinished.replace(with: [unverified]) + } + ) + await unfinished.replace(with: [unverified, verified]) + + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + } + let failures = FailureReporterDispatcher { failure in + guard failure.source == .unfinished, + failure.transactionID == nil, + failure.productID == nil, + failure.underlyingError + is PersistentUnfinishedVerificationFailure + else { + await reports.append("unexpected") + return + } + await reports.append("unverified") + } + let reconciler = CurrentEntitlementReconciler( + query: { + await currentQueryCount.send() + return CurrentEntitlementQueryResult( + snapshots: await current.read(), + verificationFailures: [] + ) + }, + queryUnfinished: { + await unfinishedQueryCount.send() + return await unfinished.read() + }, + core: core, + failures: failures + ) + + let snapshots = try await reconciler.query( + retryFailedTransactions: false + ) + + #expect(snapshots == [snapshot]) + #expect(await currentQueryCount.value() == 1) + #expect(await unfinishedQueryCount.value() == 3) + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["unverified"]) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } + + @Test("an observed unverified unfinished delivery is reported after it disappears") + func disappearingUnverifiedUnfinishedIsReported() async throws { + let snapshot = makeSnapshot( + id: 49, + productID: "lifetime.disappearing-verification", + productType: .nonConsumable, + jws: "disappearing-unfinished-verification" + ) + let current = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let reports = StringRecorder() + let unverified = StoreTransactionDelivery.unverified( + revision: Data("disappearing-unverified".utf8), + error: DisappearingUnfinishedVerificationFailure() + ) + let verified = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await current.replace(with: [snapshot]) + await unfinished.replace(with: []) + } + ) + await unfinished.replace(with: [unverified, verified]) + + let core = TransactionProcessingCore { _ in } + let failures = FailureReporterDispatcher { failure in + guard failure.source == .unfinished, + failure.transactionID == nil, + failure.productID == nil, + failure.underlyingError + is DisappearingUnfinishedVerificationFailure + else { + await reports.append("unexpected") + return + } + await reports.append("unverified") + } + let reconciler = CurrentEntitlementReconciler( + query: { + CurrentEntitlementQueryResult( + snapshots: await current.read(), + verificationFailures: [] + ) + }, + queryUnfinished: { await unfinished.read() }, + core: core, + failures: failures + ) + + let snapshots = try await reconciler.query( + retryFailedTransactions: false + ) + + #expect(snapshots == [snapshot]) + #expect(await reports.snapshot() == ["unverified"]) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } + + @Test("a terminal handler failure preserves verification diagnostics") + func handlerFailurePreservesVerificationDiagnostics() async throws { + let snapshot = makeSnapshot( + id: 46, + productID: "consumable.diagnostics", + productType: .consumable + ) + let finishes = TestSignal() + let reports = StringRecorder() + let unfinishedQueryCount = TestSignal() + let currentFailure = StoreTransactionVerificationError( + underlyingError: TerminalCurrentVerificationFailure() + ) + let core = TransactionProcessingCore { _ in + throw TestFailure() + } + let failures = FailureReporterDispatcher { failure in + switch failure.source { + case .unfinished: + if failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("handler") + } else if failure.transactionID == nil, + failure.underlyingError + is TerminalUnfinishedVerificationFailure + { + await reports.append("unfinished-verification") + } else { + await reports.append("unexpected") + } + case .currentEntitlementVerification: + guard + let verificationFailure = + failure.underlyingError + as? StoreTransactionVerificationError, + verificationFailure.underlyingError + is TerminalCurrentVerificationFailure + else { + await reports.append("unexpected") + return + } + await reports.append("current-verification") + default: + await reports.append("unexpected") + } + } + let reconciler = CurrentEntitlementReconciler( + query: { + CurrentEntitlementQueryResult( + snapshots: [], + verificationFailures: [currentFailure] + ) + }, + queryUnfinished: { + await unfinishedQueryCount.send() + guard await unfinishedQueryCount.value() > 1 else { + return [] + } + return [ + .unverified( + revision: Data("terminal-unverified".utf8), + error: TerminalUnfinishedVerificationFailure() + ), + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + }), + ] + }, + core: core, + failures: failures + ) + + do { + _ = try await reconciler.query( + retryFailedTransactions: false + ) + Issue.record("A failed handler unexpectedly reconciled.") + } catch let owned as StoreTransactionFailureWithReportingOwner { + #expect(owned.underlyingError is TestFailure) + } catch { + Issue.record("Unexpected reconciliation error: \(error)") + } + + #expect(await finishes.value() == 0) + #expect( + await reports.snapshot() == [ + "handler", + "unfinished-verification", + "current-verification", + ]) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } +} + +private struct DiscardedVerificationFailure: Error, Sendable {} + +private struct StableVerificationFailure: Error, Sendable {} + +private struct PersistentUnfinishedVerificationFailure: Error, Sendable {} + +private struct DisappearingUnfinishedVerificationFailure: Error, Sendable {} + +private struct TerminalUnfinishedVerificationFailure: Error, Sendable {} + +private struct TerminalCurrentVerificationFailure: Error, Sendable {} + +private struct CurrentEntitlementQueryFailure: Error, Sendable {} diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift index 354f9b5..fc96e7e 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift @@ -3,12 +3,121 @@ import StoreKit import Testing @testable import StoreTransactionKit -@Suite("Runtime contracts") +@Suite("Runtime contracts", .timeLimit(.minutes(1))) struct RuntimeContractTests { + @Test("refresh success and readiness failure preserve their physical order") + func readinessFailureOrdering() async throws { + let query = ControlledEntitlementQuery() + let successfulTokens = UInt64Recorder() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + entitlementRefreshDidSucceed: { success in + await successfulTokens.append(success.token) + }, + reportFailure: { _ in } + ) + fixture.updates.yield( + .verified( + makeEnvelope( + snapshot: makeSnapshot( + id: 91, + productType: .nonConsumable + ) + ) + ) + ) + + try await query.waitForRequest(1) + let readiness = Task { try await runtime.readiness() } + await query.succeed([makeSnapshot(id: 91, productType: .nonConsumable)]) + try await query.waitForRequest(2) + await query.fail(TestFailure()) + + do { + _ = try await readiness.value + Issue.record("Readiness unexpectedly succeeded.") + } catch let failure as StoreTransactionReadinessFailure { + #expect(failure.refreshToken == 2) + #expect(failure.underlyingError is TestFailure) + } catch { + Issue.record("Unexpected readiness failure: \(error)") + } + #expect(await successfulTokens.snapshot() == [1]) + await runtime.close() + } + + @Test("receipt waiter cancellation is distinct from terminal cancellation failure") + func receiptCancellationIdentity() async throws { + let terminalFailure = ProcessingReceipt() + terminalFailure.fail(CancellationError()) + + do { + try await terminalFailure.value() + Issue.record("A terminal cancellation failure unexpectedly succeeded.") + } catch is ProcessingReceiptWaiterCancellation { + Issue.record("A terminal failure was mistaken for waiter cancellation.") + } catch is CancellationError { + // The dependency's terminal failure remains intact. + } catch { + Issue.record("Unexpected terminal receipt error: \(error)") + } + + let pending = ProcessingReceipt() + let gate = TestGate() + let waiter = Task { + _ = try? await gate.wait() + do { + try await pending.value() + return false + } catch is ProcessingReceiptWaiterCancellation { + return true + } catch { + Issue.record("Unexpected cancelled waiter error: \(error)") + return false + } + } + waiter.cancel() + await gate.open() + + #expect(await waiter.value) + } + + @Test("immediate purchase outcomes return their semantic values") + func immediatePurchaseOutcomes() async throws { + let fixture = TestSourceFixture() + let handlerCalls = TestSignal() + let reports = StringRecorder() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + }, + reportFailure: { failure in + await reports.append("\(failure.source)") + } + ) + _ = try await session.start() + + let pending = try await session.process(.pending) + let userCancelled = try await session.process(.userCancelled) + + #expect(pending == .pending) + #expect(userCancelled == .userCancelled) + #expect(await handlerCalls.value() == 0) + #expect(await fixture.entitlementQueryCount.value() == 1) + #expect(await reports.snapshot().isEmpty) + try await session.close() + } + @Test("immediate purchase outcomes honor caller cancellation") func immediatePurchaseOutcomeCancellation() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, @@ -19,7 +128,7 @@ struct RuntimeContractTests { for result: Product.PurchaseResult in [.pending, .userCancelled] { let gate = TestGate() let process = Task { - await gate.wait() + _ = try? await gate.wait() return try await session.process(result) } process.cancel() @@ -37,33 +146,143 @@ struct RuntimeContractTests { func restoreCoalescing() async throws { let synchronizationStarted = TestSignal() let synchronizationGate = TestGate() + let entitlementQueryCount = TestSignal() + let entitlements = EntitlementRefreshCoordinator( + query: { _ in + await entitlementQueryCount.send() + return [] + }, + didChange: { _ in } + ) + let coordinator = RestoreCoordinator( + synchronize: { + await synchronizationStarted.send() + try await synchronizationGate.wait() + }, + entitlements: entitlements + ) + + let first = await coordinator.reserve() + try await synchronizationStarted.wait(for: 1) + let second = await coordinator.reserve() + + #expect(first.role == .owner) + #expect(second.role == .observer) + #expect(first.receipt === second.receipt) + + await synchronizationGate.open() + let firstValue = try await first.receipt.terminalValue() + let secondValue = try await second.receipt.terminalValue() + + #expect(firstValue == secondValue) + #expect(await synchronizationStarted.value() == 1) + #expect(await entitlementQueryCount.value() == 1) + await entitlements.sealAndDrain() + } + + @Test("a cancelled restore observer does not report an attached failure") + func cancelledRestoreObserverDoesNotReportAttachedFailure() async throws { + let synchronizationStarted = TestSignal() + let synchronizationGate = TestGate() + let reports = StringRecorder() let fixture = TestSourceFixture( synchronize: { await synchronizationStarted.send() - await synchronizationGate.wait() + try await synchronizationGate.wait() + throw TestFailure() } ) - fixture.unfinished.finish() - let session = StoreTransactionSession( + let runtime = StoreTransactionRuntime( + sessionID: UUID(), source: fixture.source, handleTransaction: { _ in }, - reportFailure: { _ in } + entitlementsDidChange: { _ in }, + reportFailure: { failure in + switch failure.source { + case .abandonedDirectOperation(.restorePurchases): + await reports.append("restore") + default: + await reports.append("unexpected") + } + } ) - _ = try await session.start() + _ = try await runtime.readiness() - async let first = session.restorePurchases() - await synchronizationStarted.wait(for: 1) - async let second = session.restorePurchases() - await Task.yield() - #expect(await synchronizationStarted.value() == 1) + let ownerLeases = try #require(runtime.beginOperation()) + let owner = Task { + try await runtime.restorePurchases(leases: ownerLeases) + } + try await synchronizationStarted.wait(for: 1) + + let observerLeases = try #require(runtime.beginOperation()) + let observer = Task { + try await runtime.restorePurchases(leases: observerLeases) + } + observer.cancel() + await #expect(throws: CancellationError.self) { + _ = try await observer.value + } await synchronizationGate.open() - let (firstValue, secondValue) = try await (first, second) + await #expect(throws: TestFailure.self) { + _ = try await owner.value + } + await runtime.close() - #expect(firstValue == secondValue) - #expect(await synchronizationStarted.value() == 1) - #expect(await fixture.entitlementQueryCount.value() == 2) - try await session.close() + #expect(await reports.snapshot().isEmpty) + } + + @Test("cancelled coalesced restore callers report one physical failure") + func cancelledRestoreCallersReportOnce() async throws { + let synchronizationStarted = TestSignal() + let synchronizationGate = TestGate() + let reports = StringRecorder() + let fixture = TestSourceFixture( + synchronize: { + await synchronizationStarted.send() + try await synchronizationGate.wait() + throw TestFailure() + } + ) + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + switch failure.source { + case .abandonedDirectOperation(.restorePurchases): + await reports.append("restore") + default: + await reports.append("unexpected") + } + } + ) + _ = try await runtime.readiness() + + let ownerLeases = try #require(runtime.beginOperation()) + let owner = Task { + try await runtime.restorePurchases(leases: ownerLeases) + } + try await synchronizationStarted.wait(for: 1) + owner.cancel() + await #expect(throws: CancellationError.self) { + _ = try await owner.value + } + + let observerLeases = try #require(runtime.beginOperation()) + let observer = Task { + try await runtime.restorePurchases(leases: observerLeases) + } + observer.cancel() + await #expect(throws: CancellationError.self) { + _ = try await observer.value + } + + await synchronizationGate.open() + await runtime.close() + + #expect(await reports.snapshot() == ["restore"]) } @Test("an abandoned refresh reports its later failure exactly once") @@ -74,7 +293,6 @@ struct RuntimeContractTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, @@ -90,23 +308,349 @@ struct RuntimeContractTests { ) let startup = Task { try await session.start() } - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.succeed([]) _ = try await startup.value let refresh = Task { try await session.currentEntitlements() } - await query.waitForRequest(2) + try await query.waitForRequest(2) refresh.cancel() await #expect(throws: CancellationError.self) { _ = try await refresh.value } await query.fail(TestFailure()) - await reported.wait(for: 1) + try await reported.wait(for: 1) try await session.close() #expect(await reports.snapshot() == ["abandoned-refresh"]) } + @Test("an attached refresh receives the reported underlying failure and can retry") + func attachedRefreshUnwrapsReportedFailure() async throws { + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() } + ) + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 26, + productID: "consumable.refresh", + productType: .consumable + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-26") + } else { + await reports.append("unexpected") + } + } + ) + _ = try await session.start() + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await unfinished.replace(with: []) + }) + ] + ) + + await #expect(throws: TestFailure.self) { + _ = try await session.currentEntitlements() + } + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await reports.snapshot() == ["unfinished-26"]) + + let entitlements = try await session.currentEntitlements() + + #expect(entitlements.transactions.isEmpty) + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["unfinished-26"]) + try await session.close() + } + + @Test("a direct process unwraps a reported refresh failure and can retry") + func directProcessUnwrapsReportedRefreshFailure() async throws { + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() } + ) + let handled = UInt64Recorder() + let consumableAttempts = TestSignal() + let directFinishes = TestSignal() + let consumableFinishes = TestSignal() + let reports = StringRecorder() + let direct = makeSnapshot( + id: 27, + productID: "lifetime.direct", + productType: .nonConsumable + ) + let consumable = makeSnapshot( + id: 28, + productID: "consumable.process", + productType: .consumable + ) + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { snapshot in + await handled.append(snapshot.id) + if snapshot.id == consumable.id { + await consumableAttempts.send() + if await consumableAttempts.value() == 1 { + throw TestFailure() + } + } + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == consumable.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-28") + } else { + await reports.append("unexpected") + } + } + ) + _ = try await runtime.readiness() + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: consumable) { + await consumableFinishes.send() + await unfinished.replace(with: []) + }) + ] + ) + let directDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: direct) { + await directFinishes.send() + } + ) + + let firstLeases = try #require(runtime.beginOperation()) + await #expect(throws: TestFailure.self) { + _ = try await runtime.process( + directDelivery, + leases: firstLeases + ) + } + #expect(await handled.snapshot() == [direct.id, consumable.id]) + #expect(await directFinishes.value() == 1) + #expect(await consumableFinishes.value() == 0) + #expect(await reports.snapshot() == ["unfinished-28"]) + + let secondLeases = try #require(runtime.beginOperation()) + let outcome = try await runtime.process( + directDelivery, + leases: secondLeases + ) + + #expect(outcome == .completed(direct)) + #expect( + await handled.snapshot() == [ + direct.id, consumable.id, consumable.id, + ] + ) + #expect(await directFinishes.value() == 1) + #expect(await consumableFinishes.value() == 1) + #expect(await reports.snapshot() == ["unfinished-28"]) + await runtime.close() + } + + @Test("restore unwraps a reported refresh failure and can retry") + func restoreUnwrapsReportedRefreshFailure() async throws { + let unfinished = UnfinishedValueSource() + let synchronizations = TestSignal() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() }, + synchronize: { await synchronizations.send() } + ) + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 29, + productID: "consumable.restore", + productType: .consumable + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-29") + } else { + await reports.append("unexpected") + } + } + ) + _ = try await session.start() + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await unfinished.replace(with: []) + }) + ] + ) + + await #expect(throws: TestFailure.self) { + _ = try await session.restorePurchases() + } + #expect(await synchronizations.value() == 1) + #expect(await reports.snapshot() == ["unfinished-29"]) + + let entitlements = try await session.restorePurchases() + + #expect(entitlements.transactions.isEmpty) + #expect(await synchronizations.value() == 2) + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["unfinished-29"]) + try await session.close() + } + + @Test("an abandoned refresh does not report an owned reconciliation failure twice") + func abandonedRefreshDoesNotDuplicateReportedFailure() async throws { + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() } + ) + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let reported = TestSignal() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 30, + productID: "consumable.abandoned", + productType: .consumable + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-30") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + _ = try await session.start() + await unfinished.replace( + with: [.verified(makeEnvelope(snapshot: snapshot))] + ) + + let refresh = Task { + try await session.currentEntitlements() + } + try await handlerStarted.wait(for: 1) + refresh.cancel() + await #expect(throws: CancellationError.self) { + _ = try await refresh.value + } + + await handlerGate.open() + try await reported.wait(for: 1) + try await session.close() + + #expect(await reports.snapshot() == ["unfinished-30"]) + } + + @Test("an abandoned restore does not report an owned reconciliation failure twice") + func abandonedRestoreDoesNotDuplicateReportedFailure() async throws { + let unfinished = UnfinishedValueSource() + let synchronizations = TestSignal() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() }, + synchronize: { await synchronizations.send() } + ) + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let reported = TestSignal() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 32, + productID: "consumable.abandoned-restore", + productType: .consumable + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-32") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + _ = try await session.start() + await unfinished.replace( + with: [.verified(makeEnvelope(snapshot: snapshot))] + ) + + let restore = Task { + try await session.restorePurchases() + } + try await handlerStarted.wait(for: 1) + restore.cancel() + await #expect(throws: CancellationError.self) { + _ = try await restore.value + } + + await handlerGate.open() + try await reported.wait(for: 1) + try await session.close() + + #expect(await synchronizations.value() == 1) + #expect(await reports.snapshot() == ["unfinished-32"]) + } + @Test("history is newest first and retains revoked transactions") func historyOrderAndMembership() async throws { let sharedDate = Date(timeIntervalSince1970: 100) @@ -132,7 +676,6 @@ struct RuntimeContractTests { [older, lowerID, higherIDRevoked, newestSigned] } ) - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, @@ -155,7 +698,6 @@ struct RuntimeContractTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, @@ -168,21 +710,398 @@ struct RuntimeContractTests { ) let startup = Task { try await session.start() } - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.succeed([]) _ = try await startup.value fixture.updates.yield( .verified(makeEnvelope(snapshot: makeSnapshot(id: 19))) ) - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.fail(TestFailure()) - await reported.wait(for: 1) + try await reported.wait(for: 1) try await session.close() #expect(await reports.snapshot() == ["entitlementRefresh-19-product"]) } + @Test("an update owner prevents an observer refresh from reporting the same failure") + func observerRefreshUsesTransactionReportingOwner() async throws { + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 31, + productID: "consumable.observer", + productType: .consumable + ) + let envelope = makeEnvelope(snapshot: snapshot) + let core = TransactionProcessingCore { _ in + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + } + let failures = FailureReporterDispatcher { failure in + switch failure.source { + case .updates where failure.underlyingError is TestFailure: + await reports.append("updates") + case .unfinished, .entitlementRefresh, + .abandonedDirectOperation: + await reports.append("duplicate") + default: + await reports.append("unexpected") + } + } + let reconciler = CurrentEntitlementReconciler( + query: { + CurrentEntitlementQueryResult( + snapshots: [], + verificationFailures: [] + ) + }, + queryUnfinished: { [] }, + core: core, + failures: failures + ) + + let owner = await core.accept(envelope) + try await handlerStarted.wait(for: 1) + let observer = await core.accept(envelope) + #expect(owner.role == .owner) + #expect(observer.role == .inFlightObserver) + + let entitlements = EntitlementRefreshCoordinator( + query: { _ in + try await reconciler.drain([ + CurrentEntitlementReconciler.AcceptedTransaction( + snapshot: snapshot, + acceptance: observer + ) + ]) + return [] + }, + didChange: { _ in } + ) + let pipeline = StoreTransactionPipeline( + core: core, + entitlements: entitlements, + failures: failures + ) + let update = Task { + await pipeline.processAcceptedBackground( + snapshot: snapshot, + acceptance: owner, + source: .updates + ) + } + let subscriptionRefresh = Task { + await pipeline.refreshEntitlements() + } + + await handlerGate.open() + await update.value + await subscriptionRefresh.value + + #expect(await reports.snapshot() == ["updates"]) + await core.finishInputAndDrain() + await entitlements.sealAndDrain() + await failures.sealAndDrain() + } + + @Test("reconciliation handles a new unfinished revision before querying entitlements") + func reconciliationHandlesUnfinishedBeforeQuerying() async throws { + let entitlementQueryCount = TestSignal() + let unfinishedQueryStarted = TestSignal() + let unfinishedQueryGate = TestGate() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let currentEntitlements = EntitlementValueSource([]) + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 24, + productType: .nonConsumable + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "arrived-after-query") { + await currentEntitlements.replace(with: [snapshot]) + await finishes.send() + } + ) + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + } + let failures = FailureReporterDispatcher { failure in + await reports.append("\(failure.source)") + } + let reconciler = CurrentEntitlementReconciler( + query: { + await entitlementQueryCount.send() + return CurrentEntitlementQueryResult( + snapshots: await currentEntitlements.read(), + verificationFailures: [] + ) + }, + queryUnfinished: { + await unfinishedQueryStarted.send() + _ = try? await unfinishedQueryGate.wait() + return [delivery] + }, + core: core, + failures: failures + ) + + let query = Task { + try await reconciler.query(retryFailedTransactions: false) + } + try await unfinishedQueryStarted.wait(for: 1) + #expect(await entitlementQueryCount.value() == 0) + + await unfinishedQueryGate.open() + let snapshots = try await query.value + + #expect(snapshots == [snapshot]) + #expect(await entitlementQueryCount.value() == 1) + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 1) + await core.finishInputAndDrain() + await failures.sealAndDrain() + #expect(await reports.snapshot().isEmpty) + } + + @Test("duplicate background deliveries report one handler failure") + func duplicateBackgroundDeliveryFailure() async throws { + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + } + let entitlements = EntitlementRefreshCoordinator( + query: { _ in + Issue.record("A failed transaction unexpectedly refreshed entitlements.") + return [] + }, + didChange: { _ in } + ) + let failures = FailureReporterDispatcher { failure in + switch failure.source { + case .updates: + await reports.append("updates") + case .unfinished: + await reports.append("unfinished") + default: + await reports.append("unexpected") + } + } + let pipeline = StoreTransactionPipeline( + core: core, + entitlements: entitlements, + failures: failures + ) + let snapshot = makeSnapshot(id: 21) + let update = try await pipeline.accept( + .verified( + makeEnvelope(snapshot: snapshot, revision: "same") { + await finishes.send() + }) + ) + try await handlerStarted.wait(for: 1) + let unfinished = try await pipeline.accept( + .verified( + makeEnvelope(snapshot: snapshot, revision: "same") { + await finishes.send() + }) + ) + + #expect(update.acceptance.role == .owner) + #expect(unfinished.acceptance.role == .inFlightObserver) + + let updateTask = Task { + await pipeline.processAcceptedBackground( + snapshot: update.snapshot, + acceptance: update.acceptance, + source: .updates + ) + } + let unfinishedTask = Task { + await pipeline.processAcceptedBackground( + snapshot: unfinished.snapshot, + acceptance: unfinished.acceptance, + source: .unfinished + ) + } + await handlerGate.open() + await updateTask.value + await unfinishedTask.value + + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await reports.snapshot() == ["updates"]) + + await core.finishInputAndDrain() + await entitlements.sealAndDrain() + await failures.sealAndDrain() + } + + @Test("a later delivery retries after an earlier handler attempt fails") + func laterDeliveryRetriesFailedRevision() async { + let handlerCalls = TestSignal() + let reports = StringRecorder() + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + throw TestFailure() + } + let entitlements = EntitlementRefreshCoordinator( + query: { _ in + Issue.record("A failed transaction unexpectedly refreshed entitlements.") + return [] + }, + didChange: { _ in } + ) + let failures = FailureReporterDispatcher { failure in + await reports.append("\(failure.source)") + } + let pipeline = StoreTransactionPipeline( + core: core, + entitlements: entitlements, + failures: failures + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: makeSnapshot(id: 22), revision: "retry") + ) + + await pipeline.processBackground(delivery, source: .updates) + await core.completeInitialAttempt() + await pipeline.processBackground(delivery, source: .unfinished) + + #expect(await handlerCalls.value() == 2) + #expect(await reports.snapshot() == ["updates", "unfinished"]) + await core.finishInputAndDrain() + await entitlements.sealAndDrain() + await failures.sealAndDrain() + } + + @Test("a cancelled direct observer leaves failure reporting with the background owner") + func directObserverCancellationDoesNotDuplicateFailure() async throws { + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let handlerCalls = TestSignal() + let reported = TestSignal() + let reports = StringRecorder() + let fixture = TestSourceFixture() + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + await reports.append("\(failure.source)") + await reported.send() + } + ) + _ = try await runtime.readiness() + let snapshot = makeSnapshot(id: 23) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "shared") + ) + fixture.updates.yield(delivery) + try await handlerStarted.wait(for: 1) + + let leases = try #require(runtime.beginOperation()) + let directObserver = Task { + try await runtime.process(delivery, leases: leases) + } + directObserver.cancel() + await #expect(throws: CancellationError.self) { + _ = try await directObserver.value + } + + await handlerGate.open() + try await reported.wait(for: 1) + await runtime.close() + + #expect(await handlerCalls.value() == 1) + #expect(await reports.snapshot() == ["updates"]) + } + + @Test("a cancelled completed observer reports its own refresh failure") + func completedObserverCancellationReportsRefreshFailure() async throws { + let query = ControlledEntitlementQuery() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reported = TestSignal() + let reports = StringRecorder() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + switch failure.source { + case .abandonedDirectOperation(.processPurchase): + await reports.append("abandoned-process") + default: + await reports.append("unexpected") + } + await reported.send() + } + ) + + let readiness = Task { try await runtime.readiness() } + try await query.waitForRequest(1) + await query.succeed([]) + _ = try await readiness.value + + let snapshot = makeSnapshot( + id: 25, + productType: .nonConsumable + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "completed") { + await finishes.send() + } + ) + let firstLeases = try #require(runtime.beginOperation()) + let firstProcess = Task { + try await runtime.process(delivery, leases: firstLeases) + } + try await query.waitForRequest(2) + await query.succeed([snapshot]) + _ = try await firstProcess.value + + let secondLeases = try #require(runtime.beginOperation()) + let completedObserver = Task { + try await runtime.process(delivery, leases: secondLeases) + } + try await query.waitForRequest(3) + completedObserver.cancel() + await #expect(throws: CancellationError.self) { + _ = try await completedObserver.value + } + await query.fail(TestFailure()) + try await reported.wait(for: 1) + + await runtime.close() + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["abandoned-process"]) + } + @Test("close completes after accepted handling and finish") func closeDrainsAcceptedTransaction() async throws { let handlerStarted = TestSignal() @@ -191,13 +1110,12 @@ struct RuntimeContractTests { let closeCallersStarted = TestSignal() let closeCallersFinished = TestSignal() let fixture = TestSourceFixture() - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in await events.append("handle-start") await handlerStarted.send() - await handlerGate.wait() + try await handlerGate.wait() await events.append("handle-end") }, reportFailure: { _ in } @@ -208,7 +1126,7 @@ struct RuntimeContractTests { makeEnvelope(snapshot: makeSnapshot(id: 20)) { await events.append("finish") })) - await handlerStarted.wait(for: 1) + try await handlerStarted.wait(for: 1) let firstClose = Task { await closeCallersStarted.send() @@ -222,7 +1140,7 @@ struct RuntimeContractTests { await events.append("close-2") await closeCallersFinished.send() } - await closeCallersStarted.wait(for: 2) + try await closeCallersStarted.wait(for: 2) #expect(await closeCallersFinished.value() == 0) await handlerGate.open() @@ -254,10 +1172,10 @@ struct CompletedRevisionCacheTests { } } -@Suite("Task completion bag") +@Suite("Task completion bag", .timeLimit(.minutes(1))) struct TaskCompletionBagTests { - @Test("completed tasks are released before shutdown") - func completedTasksAreReleased() async { + @Test("completed tasks are released when the bag becomes empty") + func completedTasksAreReleased() async throws { let bag = TaskCompletionBag() let completed = TestSignal() @@ -267,11 +1185,8 @@ struct TaskCompletionBagTests { await completed.send() }) } - await completed.wait(for: 32) - - for _ in 0..<100 where bag.retainedTaskCount() != 0 { - await Task.yield() - } + try await completed.wait(for: 32) + await bag.waitForAll() #expect(bag.retainedTaskCount() == 0) } } diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift index d8a45e0..6c17d2a 100644 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTests.swift @@ -1,7 +1,7 @@ import Testing @testable import StoreTransactionKit -@Suite("Observable Store") +@Suite("Observable TransactionStore", .timeLimit(.minutes(1))) @MainActor struct StoreTests { private enum SubscriptionID: String, Hashable, Sendable { @@ -9,6 +9,41 @@ struct StoreTests { case yearly = "subscription.yearly" } + @Test("a later startup failure is preserved after an earlier success") + func startupFailureAfterSuccess() { + var ordering = TransactionStoreStartupOrdering() + + let clearedBySuccess = ordering.recordSuccess(token: 1) + let recordedFailure = ordering.recordFailure(token: 2) + + #expect(!clearedBySuccess) + #expect(recordedFailure) + } + + @Test("a later success clears an earlier startup failure") + func successAfterStartupFailure() { + var ordering = TransactionStoreStartupOrdering() + + let recordedFailure = ordering.recordFailure(token: 1) + let clearedBySuccess = ordering.recordSuccess(token: 2) + + #expect(recordedFailure) + #expect(clearedBySuccess) + } + + @Test("only a success later than the failed readiness boundary recovers startup") + func sequencedStartupRecovery() { + var ordering = TransactionStoreStartupOrdering() + + let clearedBeforeFailure = ordering.recordSuccess(token: 1) + let recordedFailure = ordering.recordFailure(token: 2) + let clearedAfterFailure = ordering.recordSuccess(token: 3) + + #expect(!clearedBeforeFailure) + #expect(recordedFailure) + #expect(clearedAfterFailure) + } + @Test("app-defined identifiers project current entitlements") func typedEntitlementProjection() async throws { let values = EntitlementValueSource([ @@ -18,8 +53,7 @@ struct StoreTests { let fixture = TestSourceFixture( currentEntitlements: { await values.read() } ) - fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, reportFailure: { _ in } @@ -45,21 +79,55 @@ struct StoreTests { try await store.close() } + @Test("an upgraded transaction remains in the snapshot without granting access") + func upgradedTransactionProjection() async throws { + let fixture = TestSourceFixture( + currentEntitlements: { + [ + makeSnapshot( + id: 1, + productID: SubscriptionID.monthly.rawValue, + isUpgraded: true + ), + makeSnapshot( + id: 2, + productID: SubscriptionID.yearly.rawValue + ), + ] + } + ) + let store = TransactionStore( + source: fixture.source, + handleTransaction: { _ in }, + reportFailure: { _ in } + ) + + await store.waitForStartup() + + #expect(store.activeEntitlements == [.yearly]) + #expect( + store.entitlements?.transactions.map(\.productID) == [ + SubscriptionID.monthly.rawValue, + SubscriptionID.yearly.rawValue, + ] + ) + try await store.close() + } + @Test("a later refresh recovers observable state after startup failure") func startupFailureRecovery() async throws { let query = ControlledEntitlementQuery() let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, reportFailure: { _ in } ) #expect(store.activeEntitlements == nil) - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.fail(TestFailure()) await store.waitForStartup() #expect(store.entitlements == nil) @@ -67,7 +135,7 @@ struct StoreTests { #expect(store.startupError != nil) let refresh = Task { try await store.refreshEntitlements() } - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.succeed([ makeSnapshot(id: 4, productID: SubscriptionID.yearly.rawValue) ]) @@ -78,11 +146,124 @@ struct StoreTests { try await store.close() } + @Test("startup exposes the underlying unfinished handler failure") + func startupUnwrapsReportedHandlerFailure() async throws { + let snapshot = makeSnapshot( + id: 8, + productID: "consumable.startup", + productType: .consumable + ) + let fixture = TestSourceFixture( + queryUnfinished: { + [.verified(makeEnvelope(snapshot: snapshot))] + } + ) + let handlerCalls = TestSignal() + let reports = StringRecorder() + let store = TransactionStore( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-8") + } else { + await reports.append("unexpected") + } + } + ) + + await store.waitForStartup() + + #expect(store.startupError is TestFailure) + #expect(store.entitlements == nil) + #expect(await handlerCalls.value() == 1) + #expect(await reports.snapshot() == ["unfinished-8"]) + + let entitlements = try await store.refreshEntitlements() + + #expect(entitlements.transactions.isEmpty) + #expect(store.startupError == nil) + #expect(await handlerCalls.value() == 2) + #expect(await reports.snapshot() == ["unfinished-8"]) + try await store.close() + } + + @Test("a background refresh failure preserves resolved entitlement state") + func backgroundFailurePreservesResolvedEntitlements() async throws { + let snapshot = makeSnapshot( + id: 7, + productID: SubscriptionID.monthly.rawValue + ) + let query = ControlledEntitlementQuery() + let reports = StringRecorder() + let reported = TestSignal() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + handleTransaction: { _ in }, + reportFailure: { failure in + if failure.source == .entitlementRefresh, + failure.transactionID == snapshot.id, + failure.productID == snapshot.productID, + failure.underlyingError is TestFailure + { + await reports.append("entitlement-refresh-7") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + + try await query.waitForRequest(1) + await query.succeed([snapshot]) + await store.waitForStartup() + + fixture.updates.yield( + .verified(makeEnvelope(snapshot: snapshot)) + ) + try await query.waitForRequest(2) + await query.fail(TestFailure()) + try await reported.wait(for: 1) + + #expect(store.activeEntitlements == [.monthly]) + #expect(store.startupError == nil) + #expect(await reports.snapshot() == ["entitlement-refresh-7"]) + try await store.close() + } + + @Test("a dependency cancellation failure remains a startup error") + func startupCancellationFailure() async throws { + let fixture = TestSourceFixture( + currentEntitlements: { throw CancellationError() } + ) + let store = TransactionStore( + source: fixture.source, + handleTransaction: { _ in }, + reportFailure: { _ in } + ) + + await store.waitForStartup() + + #expect(store.startupError is CancellationError) + #expect(store.activeEntitlements == nil) + try await store.close() + } + @Test("an empty set means entitlement resolution completed") func emptyTypedEntitlementProjection() async throws { let fixture = TestSourceFixture(currentEntitlements: { [] }) - fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, reportFailure: { _ in } @@ -94,31 +275,34 @@ struct StoreTests { try await store.close() } - @Test("the Store facade rejects handler reentry during startup") + @Test("the TransactionStore facade rejects handler reentry during startup") func startupHandlerReentrancy() async throws { - let fixture = TestSourceFixture() - let holder = StoreHolder() + let holder = TransactionStoreHolder() let rejected = TestSignal() let finished = TestSignal() - fixture.unfinished.yield( - .verified( - makeEnvelope(snapshot: makeSnapshot(id: 5)) { - await finished.send() - })) - fixture.unfinished.finish() - - let store = Store( + let fixture = TestSourceFixture( + queryUnfinished: { + [ + .verified( + makeEnvelope(snapshot: makeSnapshot(id: 5)) { + await finished.send() + }) + ] + } + ) + + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in do { _ = try await holder.get().refreshEntitlements() - Issue.record("Store unexpectedly allowed handler reentry.") + Issue.record("TransactionStore unexpectedly allowed handler reentry.") } catch StoreTransactionError.reentrantOperation( operation: .currentEntitlements ) { await rejected.send() } catch { - Issue.record("Unexpected Store reentrancy error: \(error)") + Issue.record("Unexpected TransactionStore reentrancy error: \(error)") } }, reportFailure: { _ in } @@ -138,13 +322,12 @@ struct StoreTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, reportFailure: { _ in } ) - await query.waitForRequest(1) + try await query.waitForRequest(1) let refresh = Task { try await store.refreshEntitlements() } refresh.cancel() @@ -162,28 +345,27 @@ struct StoreTests { func reentrantClosePreservesStartup() async throws { let query = ControlledEntitlementQuery() let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } + currentEntitlements: { try await query.next() }, + queryUnfinished: { + [.verified(makeEnvelope(snapshot: makeSnapshot(id: 6)))] + } ) - let holder = StoreHolder() + let holder = TransactionStoreHolder() let closeRejected = TestSignal() let startupCompleted = TestSignal() - fixture.unfinished.yield( - .verified(makeEnvelope(snapshot: makeSnapshot(id: 6))) - ) - fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in do { try await holder.get().close() - Issue.record("Store unexpectedly allowed a reentrant close.") + Issue.record("TransactionStore unexpectedly allowed a reentrant close.") } catch StoreTransactionError.reentrantOperation( operation: .close ) { await closeRejected.send() } catch { - Issue.record("Unexpected Store reentrancy error: \(error)") + Issue.record("Unexpected TransactionStore reentrancy error: \(error)") } }, reportFailure: { _ in } @@ -194,12 +376,10 @@ struct StoreTests { await startupCompleted.send() } - await closeRejected.wait(for: 1) - await query.waitForRequest(1) + try await query.waitForRequest(1) #expect(await startupCompleted.value() == 0) + try await closeRejected.wait(for: 1) - await query.succeed([]) - await query.waitForRequest(2) await query.succeed([]) await startupWaiter.value #expect(await startupCompleted.value() == 1) diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift index 9e10194..851ea13 100644 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift @@ -2,12 +2,11 @@ import Foundation import Testing @testable import StoreTransactionKit -@Suite("StoreTransactionSession") +@Suite("StoreTransactionSession", .timeLimit(.minutes(1))) struct StoreTransactionSessionTests { @Test("start publishes initial entitlements and close terminates producers") func startAndClose() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let publicationSizes = UInt64Recorder() let session = StoreTransactionSession( source: fixture.source, @@ -23,13 +22,595 @@ struct StoreTransactionSessionTests { #expect(await publicationSizes.snapshot() == [0]) try await session.close() - await fixture.updateTermination.wait(for: 1) + try await fixture.updateTermination.wait(for: 1) + try await fixture.subscriptionStatusTermination.wait(for: 1) + } + + @Test("initial entitlement publication joins unfinished processing") + func initialEntitlementsJoinUnfinishedProcessing() async throws { + let snapshot = makeSnapshot(id: 41, productID: "subscription.plus") + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let events = StringRecorder() + let publications = UInt64Recorder() + let fixture = TestSourceFixture( + currentEntitlements: { [snapshot] }, + queryUnfinished: { + [ + .verified( + makeEnvelope(snapshot: snapshot) { + await events.append("finish-41") + }) + ] + } + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { transaction in + await events.append("handle-\(transaction.id)") + await handlerStarted.send() + try await handlerGate.wait() + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + }, + reportFailure: { failure in + Issue.record("Unexpected startup failure: \(failure)") + } + ) + + let startup = Task { try await session.start() } + try await handlerStarted.wait(for: 1) + #expect(await publications.snapshot().isEmpty) + + await handlerGate.open() + let readiness = try await startup.value + + #expect(readiness.entitlements.transactions == [snapshot]) + #expect(await events.snapshot() == ["handle-41", "finish-41"]) + #expect(await publications.snapshot() == [1]) + try await session.close() + } + + @Test("startup does not retry an update failure through unfinished reconciliation") + func startupSharesFailedUpdateWithUnfinishedReconciliation() async throws { + let snapshot = makeSnapshot( + id: 42, + productID: "consumable.startup", + productType: .consumable + ) + let envelope = makeEnvelope(snapshot: snapshot) + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reported = TestSignal() + let reports = StringRecorder() + let unfinished = UnfinishedValueSource() + let unfinishedDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await unfinished.replace(with: []) + } + ) + let fixture = TestSourceFixture( + currentEntitlements: { + try await reported.wait(for: 1) + return [] + }, + queryUnfinished: { await unfinished.read() } + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + if failure.source == .updates, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("updates-42") + } else { + await reports.append("unexpected") + } + await unfinished.replace(with: [unfinishedDelivery]) + await reported.send() + } + ) + + fixture.updates.yield(.verified(envelope)) + + await #expect(throws: TestFailure.self) { + _ = try await session.start() + } + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await reports.snapshot() == ["updates-42"]) + + _ = try await session.currentEntitlements() + + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["updates-42"]) + try await session.close() + } + + @Test("cancelling the startup waiter does not open a retry boundary") + func cancelledStartupWaiterKeepsInitialAttempt() async throws { + let failedSnapshot = makeSnapshot( + id: 43, + productID: "consumable.cancelled-startup", + productType: .consumable + ) + let markerSnapshot = makeSnapshot(id: 44) + let query = ControlledEntitlementQuery() + let handled = UInt64Recorder() + let failedFinish = TestSignal() + let markerFinish = TestSignal() + let reported = TestSignal() + let reports = StringRecorder() + let unfinished = UnfinishedValueSource() + let failedDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: failedSnapshot) { + await failedFinish.send() + await unfinished.replace(with: []) + } + ) + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() }, + queryUnfinished: { await unfinished.read() } + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { snapshot in + await handled.append(snapshot.id) + if snapshot.id == failedSnapshot.id { + throw TestFailure() + } + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + if failure.source == .updates, + failure.transactionID == failedSnapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("updates-43") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + + let startup = Task { try await session.start() } + try await query.waitForRequest(1) + fixture.updates.yield( + .verified(makeEnvelope(snapshot: failedSnapshot)) + ) + try await reported.wait(for: 1) + + startup.cancel() + await #expect(throws: CancellationError.self) { + _ = try await startup.value + } + + fixture.updates.yield( + .verified(makeEnvelope(snapshot: failedSnapshot)) + ) + fixture.updates.yield( + .verified( + makeEnvelope(snapshot: markerSnapshot) { + await markerFinish.send() + }) + ) + try await markerFinish.wait(for: 1) + + #expect(await handled.snapshot() == [43, 44]) + #expect(await failedFinish.value() == 0) + #expect(await reports.snapshot() == ["updates-43"]) + + await unfinished.replace(with: [failedDelivery]) + await query.succeed([]) + try await session.close() + + #expect(await handled.snapshot() == [43, 44]) + #expect(await failedFinish.value() == 0) + #expect(await reports.snapshot() == ["updates-43"]) + } + + @Test("subscription status waits for initial entitlement readiness") + func subscriptionStatusWaitsForReadiness() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + Issue.record("Unexpected status failure: \(failure)") + } + ) + + let startup = Task { try await session.start() } + try await query.waitForRequest(1) + fixture.subscriptionStatusUpdates.yield() + try await fixture.subscriptionStatusDeliveryCount.wait(for: 1) + #expect(await fixture.entitlementQueryCount.value() == 1) + + await query.succeed([]) + _ = try await startup.value + try await query.waitForRequest(2) + await query.succeed([]) + + try await session.close() + } + + @Test("close cancels a status waiter but drains startup readiness") + func closeDuringSubscriptionStatusReadiness() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let closeCompleted = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + reportFailure: { _ in } + ) + + let startup = Task { try await session.start() } + try await query.waitForRequest(1) + fixture.subscriptionStatusUpdates.yield() + try await fixture.subscriptionStatusDeliveryCount.wait(for: 1) + + let close = Task { + try await session.close() + await closeCompleted.send() + } + try await fixture.subscriptionStatusTermination.wait(for: 1) + #expect(await closeCompleted.value() == 0) + + await query.succeed([]) + do { + _ = try await startup.value + Issue.record("Startup unexpectedly completed after close began.") + } catch StoreTransactionError.closing { + // Closing owns the runtime after readiness drains. + } + try await close.value + #expect(await closeCompleted.value() == 1) + } + + @Test("known subscription status changes refresh without replaying handling") + func knownSubscriptionStatusReconciliation() async throws { + let snapshot = makeSnapshot(id: 1, productID: "subscription.plus") + let values = EntitlementValueSource([snapshot]) + let fixture = TestSourceFixture( + currentEntitlements: { await values.read() } + ) + let handlerCalls = TestSignal() + let publications = UInt64Recorder() + let published = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + await published.send() + }, + reportFailure: { failure in + Issue.record("Unexpected background failure: \(failure)") + } + ) + + _ = try await session.start() + await values.replace(with: []) + + fixture.subscriptionStatusUpdates.yield() + try await published.wait(for: 2) + + #expect(await handlerCalls.value() == 0) + #expect(await fixture.entitlementQueryCount.value() == 2) + #expect(await publications.snapshot() == [1, 0]) + try await session.close() + } + + @Test("a new subscription status is handled and finished before publication") + func newSubscriptionStatusUsesTransactionPipeline() async throws { + let snapshot = makeSnapshot(id: 2, productID: "subscription.pro") + let values = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + currentEntitlements: { await values.read() }, + queryUnfinished: { await unfinished.read() } + ) + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let events = StringRecorder() + let publications = UInt64Recorder() + let published = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { snapshot in + await events.append("handle-\(snapshot.id)") + await handlerStarted.send() + try await handlerGate.wait() + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + await published.send() + }, + reportFailure: { failure in + Issue.record("Unexpected background failure: \(failure)") + } + ) + + _ = try await session.start() + await values.replace(with: [snapshot]) + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: snapshot) { + await events.append("finish-2") + }) + ] + ) + fixture.subscriptionStatusUpdates.yield() + + try await handlerStarted.wait(for: 1) + #expect(await publications.snapshot() == [0]) + #expect(await fixture.entitlementQueryCount.value() == 1) + + await handlerGate.open() + try await published.wait(for: 2) + #expect(await events.snapshot() == ["handle-2", "finish-2"]) + #expect(await fixture.entitlementQueryCount.value() == 2) + #expect(await publications.snapshot() == [0, 1]) + try await session.close() + } + + @Test("an unfinished consumable failure blocks publication and reports once") + func unfinishedConsumableFailureBlocksPublication() async throws { + let active = makeSnapshot( + id: 3, + productID: "subscription.plus", + productType: .autoRenewable + ) + let consumable = makeSnapshot( + id: 4, + productID: "consumable.tokens", + productType: .consumable + ) + let values = EntitlementValueSource([active]) + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + currentEntitlements: { await values.read() }, + queryUnfinished: { await unfinished.read() } + ) + let handlerCalls = TestSignal() + let finishes = TestSignal() + let publications = UInt64Recorder() + let published = TestSignal() + let reports = StringRecorder() + let reported = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { snapshot in + #expect(snapshot.id == consumable.id) + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + await published.send() + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == consumable.id, + failure.productID == consumable.productID, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-4") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + + _ = try await session.start() + await values.replace(with: []) + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: consumable) { + await finishes.send() + await unfinished.replace(with: []) + }) + ] + ) + + fixture.subscriptionStatusUpdates.yield() + try await reported.wait(for: 1) + + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await publications.snapshot() == [1]) + + fixture.subscriptionStatusUpdates.yield() + try await published.wait(for: 2) + + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await publications.snapshot() == [1, 0]) + #expect(await reports.snapshot() == ["unfinished-4"]) + try await session.close() + } + + @Test("revocation handling finishes before entitlement removal is published") + func revocationPrecedesRemovalPublication() async throws { + let active = makeSnapshot( + id: 31, + productID: "subscription.plus", + productType: .autoRenewable, + jws: "active-31" + ) + let revoked = makeSnapshot( + id: 31, + productID: "subscription.plus", + productType: .autoRenewable, + signedDate: Date(timeIntervalSince1970: 100), + jws: "revoked-31", + revocationDate: Date(timeIntervalSince1970: 99) + ) + let values = EntitlementValueSource([active]) + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + currentEntitlements: { await values.read() }, + queryUnfinished: { await unfinished.read() } + ) + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let events = StringRecorder() + let publications = UInt64Recorder() + let published = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { snapshot in + #expect(snapshot.revocationDate != nil) + await events.append("handle-\(snapshot.id)") + await handlerStarted.send() + try await handlerGate.wait() + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + await published.send() + }, + reportFailure: { failure in + Issue.record("Unexpected revocation failure: \(failure)") + } + ) + + _ = try await session.start() + await values.replace(with: []) + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: revoked) { + await events.append("finish-31") + }) + ] + ) + fixture.subscriptionStatusUpdates.yield() + + try await handlerStarted.wait(for: 1) + #expect(await publications.snapshot() == [1]) + + await handlerGate.open() + try await published.wait(for: 2) + #expect(await events.snapshot() == ["handle-31", "finish-31"]) + #expect(await publications.snapshot() == [1, 0]) + try await session.close() + } + + @Test("reconciliation drains and reports every accepted handler failure") + func reconciliationDrainsAllAcceptedFailures() async throws { + let unfinished = UnfinishedValueSource([ + .verified( + makeEnvelope( + snapshot: makeSnapshot( + id: 32, + productID: "subscription.plus", + productType: .autoRenewable + ) + ) + ), + .verified( + makeEnvelope( + snapshot: makeSnapshot( + id: 33, + productID: "lifetime", + productType: .nonConsumable + ) + ) + ), + ]) + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() } + ) + let handlerCalls = TestSignal() + let reports = UInt64Recorder() + let reported = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + throw TestFailure() + }, + reportFailure: { failure in + if failure.source == .unfinished, + let transactionID = failure.transactionID + { + await reports.append(transactionID) + await reported.send() + } + } + ) + + await #expect(throws: TestFailure.self) { + _ = try await session.start() + } + try await reported.wait(for: 2) + + #expect(await handlerCalls.value() == 2) + #expect(await reports.snapshot() == [32, 33]) + try await session.close() + } + + @Test("unverified current elements are reported without hiding verified entitlements") + func mixedCurrentEntitlementVerification() async throws { + let snapshot = makeSnapshot(id: 5, productID: "subscription.plus") + let verificationFailure = StoreTransactionVerificationError( + underlyingError: TestFailure() + ) + let fixture = TestSourceFixture( + currentEntitlements: { [snapshot] }, + currentEntitlementVerificationFailures: { + [verificationFailure] + } + ) + let reported = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + if case .currentEntitlementVerification = failure.source { + await reported.send() + } + } + ) + + let readiness = try await session.start() + + #expect(readiness.entitlements.transactions == [snapshot]) + try await reported.wait(for: 1) + try await session.close() } @Test("updates use the durable handler then finish and refresh") func updateProcessing() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let events = StringRecorder() let finished = TestSignal() let session = StoreTransactionSession( @@ -51,17 +632,16 @@ struct StoreTransactionSessionTests { await events.append("finish-10") await finished.send() })) - await finished.wait(for: 1) + try await finished.wait(for: 1) #expect(await events.snapshot() == ["handle-10", "finish-10"]) - await fixture.entitlementQueryCount.wait(for: 2) + try await fixture.entitlementQueryCount.wait(for: 2) try await session.close() } @Test("background handler failures are reported and never finished") func backgroundFailure() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let reported = TestSignal() let finished = TestSignal() let session = StoreTransactionSession( @@ -83,7 +663,7 @@ struct StoreTransactionSessionTests { await finished.send() })) - await reported.wait(for: 1) + try await reported.wait(for: 1) #expect(await finished.value() == 0) try await session.close() } @@ -107,7 +687,6 @@ struct StoreTransactionSessionTests { @Test("callbacks reject reentry into their own session") func callbackReentrancy() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let holder = SessionHolder() let observations = StringRecorder() let finished = TestSignal() @@ -160,9 +739,13 @@ struct StoreTransactionSessionTests { makeEnvelope(snapshot: makeSnapshot(id: 12)) { await finished.send() })) - await finished.wait(for: 1) - fixture.updates.yield(.unverified(TestFailure())) - await failureReported.wait(for: 1) + try await finished.wait(for: 1) + fixture.updates.yield( + .unverified( + revision: Data("update-verification-failure".utf8), + error: TestFailure() + )) + try await failureReported.wait(for: 1) #expect( await observations.snapshot() == [ @@ -182,7 +765,6 @@ struct StoreTransactionSessionTests { reportFailure: { _ in } ) let fixture = TestSourceFixture() - fixture.unfinished.finish() let callbackCompleted = TestSignal() let session = StoreTransactionSession( source: fixture.source, @@ -199,7 +781,7 @@ struct StoreTransactionSessionTests { ) _ = try await session.start() - await callbackCompleted.wait(for: 1) + try await callbackCompleted.wait(for: 1) try await session.close() } } diff --git a/Tests/StoreTransactionKitTests/TestSupport.swift b/Tests/StoreTransactionKitTests/TestSupport.swift index c853e18..db83e82 100644 --- a/Tests/StoreTransactionKitTests/TestSupport.swift +++ b/Tests/StoreTransactionKitTests/TestSupport.swift @@ -5,49 +5,90 @@ import Testing @testable import StoreTransactionKit actor TestSignal { + private struct Waiter { + let target: Int + let continuation: CheckedContinuation + } + private var count = 0 - private var waiters: [(target: Int, continuation: CheckedContinuation)] = [] + private var waiters: [UUID: Waiter] = [:] func send() { count += 1 - let ready = waiters.filter { $0.target <= count } - waiters.removeAll { $0.target <= count } - for waiter in ready { - waiter.continuation.resume() + let ready = waiters.compactMap { id, waiter in + waiter.target <= count ? id : nil + } + for id in ready { + waiters.removeValue(forKey: id)?.continuation.resume() } } - func wait(for target: Int) async { + func wait(for target: Int) async throws { guard count < target else { return } - await withCheckedContinuation { continuation in - waiters.append((target, continuation)) + try Task.checkCancellation() + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if count >= target { + continuation.resume() + } else { + waiters[id] = Waiter( + target: target, + continuation: continuation + ) + } + } + } onCancel: { + Task { await self.cancelWaiter(id) } } } func value() -> Int { count } + + private func cancelWaiter(_ id: UUID) { + waiters.removeValue(forKey: id)?.continuation.resume( + throwing: CancellationError() + ) + } } actor TestGate { private var isOpen = false - private var waiters: [CheckedContinuation] = [] + private var waiters: [UUID: CheckedContinuation] = [:] - func wait() async { + func wait() async throws { guard !isOpen else { return } - await withCheckedContinuation { continuation in - waiters.append(continuation) + try Task.checkCancellation() + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if isOpen { + continuation.resume() + } else { + waiters[id] = continuation + } + } + } onCancel: { + Task { await self.cancelWaiter(id) } } } func open() { isOpen = true - let pending = waiters + let pending = Array(waiters.values) waiters.removeAll(keepingCapacity: false) for waiter in pending { waiter.resume() } } + + private func cancelWaiter(_ id: UUID) { + waiters.removeValue(forKey: id)?.resume( + throwing: CancellationError() + ) + } } actor StringRecorder { @@ -94,24 +135,24 @@ final class SessionHolder: Sendable { } } -final class StoreHolder: Sendable +final class TransactionStoreHolder: Sendable where EntitlementID: RawRepresentable & Hashable & Sendable, EntitlementID.RawValue == String { - private let storage = Mutex?>(nil) + private let storage = Mutex?>(nil) - func set(_ store: Store) { + func set(_ store: TransactionStore) { storage.withLock { value in precondition(value == nil) value = store } } - func get() -> Store { + func get() -> TransactionStore { storage.withLock { value in guard let value else { - preconditionFailure("StoreHolder was read before initialization.") + preconditionFailure("TransactionStoreHolder was read before initialization.") } return value } @@ -119,28 +160,48 @@ where } actor ControlledEntitlementQuery { - private var requests: [CheckedContinuation<[StoreTransactionSnapshot], any Error>] = [] + private struct Request { + let id: UUID + let continuation: CheckedContinuation<[StoreTransactionSnapshot], any Error> + } + + private var requests: [Request] = [] private let started = TestSignal() func next() async throws -> [StoreTransactionSnapshot] { - await started.send() - return try await withCheckedThrowingContinuation { continuation in - requests.append(continuation) + try Task.checkCancellation() + let id = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + requests.append(Request(id: id, continuation: continuation)) + Task { await started.send() } + } + } onCancel: { + Task { await self.cancelRequest(id) } } } - func waitForRequest(_ count: Int) async { - await started.wait(for: count) + func waitForRequest(_ count: Int) async throws { + try await started.wait(for: count) } func succeed(_ snapshots: [StoreTransactionSnapshot]) { precondition(!requests.isEmpty) - requests.removeFirst().resume(returning: snapshots) + requests.removeFirst().continuation.resume(returning: snapshots) } func fail(_ error: any Error) { precondition(!requests.isEmpty) - requests.removeFirst().resume(throwing: error) + requests.removeFirst().continuation.resume(throwing: error) + } + + private func cancelRequest(_ id: UUID) { + guard let index = requests.firstIndex(where: { $0.id == id }) else { + return + } + requests.remove(at: index).continuation.resume( + throwing: CancellationError() + ) } } @@ -160,13 +221,31 @@ actor EntitlementValueSource { } } +actor UnfinishedValueSource { + private var value: [StoreTransactionDelivery] + + init(_ value: [StoreTransactionDelivery] = []) { + self.value = value + } + + func read() -> [StoreTransactionDelivery] { + value + } + + func replace(with value: [StoreTransactionDelivery]) { + self.value = value + } +} + func makeSnapshot( id: UInt64, productID: String = "product", + productType: Product.ProductType = .consumable, purchaseDate: Date? = nil, signedDate: Date? = nil, jws: String? = nil, - revocationDate: Date? = nil + revocationDate: Date? = nil, + isUpgraded: Bool = false ) -> StoreTransactionSnapshot { let purchaseDate = purchaseDate ?? Date(timeIntervalSince1970: TimeInterval(id)) return StoreTransactionSnapshot( @@ -174,14 +253,20 @@ func makeSnapshot( originalID: id, productID: productID, subscriptionGroupID: nil, - productType: .consumable, + productType: productType, + environment: .xcode, + offer: nil, + storefrontID: "143441", + storefrontCountryCode: "USA", + price: nil, + currency: nil, purchaseDate: purchaseDate, originalPurchaseDate: purchaseDate, expirationDate: nil, revocationDate: revocationDate, revocationReason: nil, purchasedQuantity: 1, - isUpgraded: false, + isUpgraded: isUpgraded, ownershipType: .purchased, reason: .purchase, appAccountToken: nil, @@ -207,29 +292,44 @@ struct TestFailure: Error, Sendable, Equatable {} struct TestSourceFixture: Sendable { let source: StoreTransactionSource let updates: AsyncStream.Continuation - let unfinished: AsyncStream.Continuation + let subscriptionStatusUpdates: AsyncStream.Continuation let updateTermination: TestSignal + let subscriptionStatusDeliveryCount: TestSignal + let subscriptionStatusTermination: TestSignal let entitlementQueryCount: TestSignal init( currentEntitlements: @escaping @Sendable () async throws -> [StoreTransactionSnapshot] = { [] }, + currentEntitlementVerificationFailures: + @escaping @Sendable () async + -> [StoreTransactionVerificationError] = { [] }, + queryUnfinished: + @escaping @Sendable () async + -> [StoreTransactionDelivery] = { [] }, history: @escaping @Sendable (Product.ID) async throws -> [StoreTransactionSnapshot] = { _ in [] }, synchronize: @escaping @Sendable () async throws -> Void = {} ) { let updatePair = AsyncStream.makeStream() - let unfinishedPair = AsyncStream.makeStream() + let subscriptionStatusPair = AsyncStream.makeStream() let updateTermination = TestSignal() + let subscriptionStatusDeliveryCount = TestSignal() + let subscriptionStatusTermination = TestSignal() let entitlementQueryCount = TestSignal() updatePair.continuation.onTermination = { _ in Task { await updateTermination.send() } } + subscriptionStatusPair.continuation.onTermination = { _ in + Task { await subscriptionStatusTermination.send() } + } self.updates = updatePair.continuation - self.unfinished = unfinishedPair.continuation + self.subscriptionStatusUpdates = subscriptionStatusPair.continuation self.updateTermination = updateTermination + self.subscriptionStatusDeliveryCount = subscriptionStatusDeliveryCount + self.subscriptionStatusTermination = subscriptionStatusTermination self.entitlementQueryCount = entitlementQueryCount self.source = StoreTransactionSource( runUpdates: { consume in @@ -237,15 +337,21 @@ struct TestSourceFixture: Sendable { await consume(delivery) } }, - runUnfinished: { consume in - for await delivery in unfinishedPair.stream { - await consume(delivery) + runSubscriptionStatusUpdates: { consume in + for await _ in subscriptionStatusPair.stream { + await subscriptionStatusDeliveryCount.send() + await consume() } }, currentEntitlements: { await entitlementQueryCount.send() - return try await currentEntitlements() + return CurrentEntitlementQueryResult( + snapshots: try await currentEntitlements(), + verificationFailures: + await currentEntitlementVerificationFailures() + ) }, + queryUnfinished: queryUnfinished, history: history, synchronize: synchronize, purchaseDelivery: { result in diff --git a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift index e25426a..e0cbeda 100644 --- a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift +++ b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import StoreTransactionKit -@Suite("TransactionProcessingCore") +@Suite("TransactionProcessingCore", .timeLimit(.minutes(1))) struct TransactionProcessingCoreTests { @Test("durable handling precedes finish") func handlerPrecedesFinish() async throws { @@ -12,16 +12,17 @@ struct TransactionProcessingCoreTests { let core = TransactionProcessingCore { _ in await events.append("handle-start") await handlerStarted.send() - await gate.wait() + try await gate.wait() await events.append("handle-end") } let snapshot = makeSnapshot(id: 1) let receipt = await core.accept( makeEnvelope(snapshot: snapshot) { await events.append("finish") - }) + } + ).receipt - await handlerStarted.wait(for: 1) + try await handlerStarted.wait(for: 1) #expect(await events.snapshot() == ["handle-start"]) await gate.open() _ = try await receipt.terminalValue() @@ -49,12 +50,16 @@ struct TransactionProcessingCoreTests { let first = await core.accept(envelope) await #expect(throws: TestFailure.self) { - _ = try await first.terminalValue() + _ = try await first.receipt.terminalValue() } #expect(await finishes.value() == 0) + await core.completeInitialAttempt() + #expect(await core.beginTransactionAttempt()) let second = await core.accept(envelope) - _ = try await second.terminalValue() + _ = try await second.receipt.terminalValue() + #expect(first.role == .owner) + #expect(second.role == .owner) #expect(await attempts.value() == 2) #expect(await finishes.value() == 1) await core.finishInputAndDrain() @@ -70,7 +75,7 @@ struct TransactionProcessingCoreTests { let core = TransactionProcessingCore { _ in await handles.send() await handlerStarted.send() - await handlerGate.wait() + try await handlerGate.wait() } let snapshot = makeSnapshot(id: 3) let first = await core.accept( @@ -80,7 +85,7 @@ struct TransactionProcessingCoreTests { ) { await firstFinish.send() }) - await handlerStarted.wait(for: 1) + try await handlerStarted.wait(for: 1) let duplicate = await core.accept( makeEnvelope( snapshot: snapshot, @@ -90,8 +95,8 @@ struct TransactionProcessingCoreTests { }) await handlerGate.open() - _ = try await first.terminalValue() - _ = try await duplicate.terminalValue() + _ = try await first.receipt.terminalValue() + _ = try await duplicate.receipt.terminalValue() let completed = await core.accept( makeEnvelope( snapshot: snapshot, @@ -99,8 +104,13 @@ struct TransactionProcessingCoreTests { ) { await duplicateFinish.send() }) - _ = try await completed.terminalValue() + _ = try await completed.receipt.terminalValue() + #expect(first.role == .owner) + #expect(duplicate.role == .inFlightObserver) + #expect(completed.role == .completedObserver) + #expect(first.reportingAuthority === duplicate.reportingAuthority) + #expect(first.reportingAuthority !== completed.reportingAuthority) #expect(await handles.value() == 1) #expect(await firstFinish.value() == 1) #expect(await duplicateFinish.value() == 0) @@ -116,22 +126,22 @@ struct TransactionProcessingCoreTests { await events.append("handle-\(snapshot.id)") if snapshot.id == 1 { await firstStarted.send() - await firstGate.wait() + try await firstGate.wait() } } let first = await core.accept( makeEnvelope(snapshot: makeSnapshot(id: 1)) { await events.append("finish-1") }) - await firstStarted.wait(for: 1) + try await firstStarted.wait(for: 1) let second = await core.accept( makeEnvelope(snapshot: makeSnapshot(id: 2)) { await events.append("finish-2") }) await firstGate.open() - _ = try await first.terminalValue() - _ = try await second.terminalValue() + _ = try await first.receipt.terminalValue() + _ = try await second.receipt.terminalValue() #expect( await events.snapshot() == [ "handle-1", "finish-1", "handle-2", "finish-2", diff --git a/Tools/TestApp/README.md b/Tools/TestApp/README.md new file mode 100644 index 0000000..5bd06be --- /dev/null +++ b/Tools/TestApp/README.md @@ -0,0 +1,50 @@ +# StoreKit Integration Tests + +The app host gives StoreKit Test a real application process while testing the +public `TransactionStore` API from an external target. `StoreKitTest.storekit` +defines Plus and Pro as two service levels in the same subscription group and +Lifetime as a non-consumable entitlement. + +The serialized suite covers: + +- direct purchases and external `Transaction.updates` handling through finish +- launch reconciliation for an existing purchase +- launch reconciliation for cancelled-active and already-expired subscriptions +- unfinished purchase replay until durable handling succeeds and finishes it +- interrupted purchases that resume through `Transaction.updates` +- verification failures that are reported and remain unfinished +- immediate Plus-to-Pro upgrades +- Pro-to-Plus downgrades at renewal +- cancellation remaining entitled until explicit expiration +- renewal transaction lineage and durable handling before status publication +- refunds +- empty, existing-entitlement, and failed-then-retried restores +- Ask to Buy approval and decline + +Run the suite on an installed iOS Simulator. StoreKit Test owns one environment +per process, so parallel test execution must remain disabled. + +```sh +xcodebuild test \ + -workspace Tools/TestApp/StoreTransactionKitTestApp.xcworkspace \ + -scheme StoreTransactionKitIntegrationTests \ + -destination 'platform=iOS Simulator,id=SIMULATOR_UDID' \ + -parallel-testing-enabled NO +``` + +The tests wait for observable state changes with cancellation-aware event +signals. They don't use fixed sleeps or accelerated wall-clock time. A suite +time limit exists only to surface a missing event as a failed test instead of +hanging the test process. + +CI runs this suite with Xcode 26.5 on the iOS 26.2 simulator available in the +GitHub macOS 26 image. The same suite is also validated locally with Xcode 26.5 +on iOS 18.6 to cover the supported iOS 18 line. + +Local StoreKit testing doesn't validate App Store Connect configuration, +App Store Server Notifications, cross-device propagation, Family Sharing or +Ask to Buy with real accounts, offer eligibility, or the production purchase +and restore sheets. Validate those boundaries separately in Sandbox before +release. Billing retry and grace-period transitions also remain Sandbox +scenarios because the StoreKit query doesn't complete reliably after forcing a +billing failure with Xcode 26.5 on the supported iOS 18.6 runtime. diff --git a/Tools/TestApp/StoreKitTest.storekit b/Tools/TestApp/StoreKitTest.storekit new file mode 100644 index 0000000..c7bfdcf --- /dev/null +++ b/Tools/TestApp/StoreKitTest.storekit @@ -0,0 +1,153 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "StoreTransactionKitTests", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "9.99", + "familyShareable" : false, + "internalID" : "StoreTransactionKitLifetime", + "localizations" : [ + { + "description" : "The lifetime entitlement used by integration tests.", + "displayName" : "Lifetime", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.lifetime", + "referenceName" : "Lifetime", + "type" : "NonConsumable" + } + ], + "settings" : { + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, + "_disableDialogs" : true, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "enabled" : false, + "name" : "Load Products" + }, + { + "enabled" : false, + "name" : "Purchase" + }, + { + "enabled" : false, + "name" : "Verification" + }, + { + "enabled" : false, + "name" : "App Store Sync" + }, + { + "enabled" : false, + "name" : "Subscription Status" + }, + { + "enabled" : false, + "name" : "App Transaction" + }, + { + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ], + "_timeRate" : 0 + }, + "subscriptionGroups" : [ + { + "id" : "StoreTransactionKitSubscriptionGroup", + "localizations" : [ + + ], + "name" : "Plans", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "1.99", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "StoreTransactionKitPlus", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "The Plus plan used by integration tests.", + "displayName" : "Plus", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.plus", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Plus", + "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "3.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "StoreTransactionKitPro", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "The Pro plan used by integration tests.", + "displayName" : "Pro", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.pro", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Pro", + "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift new file mode 100644 index 0000000..7dd73c5 --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift @@ -0,0 +1,902 @@ +import Foundation +import Observation +import StoreKit +import StoreKitTest +import StoreTransactionKit +import Testing + +private enum Entitlement: String, Hashable, Sendable { + case lifetime = "com.example.StoreTransactionKit.lifetime" + case plus = "com.example.StoreTransactionKit.plus" + case pro = "com.example.StoreTransactionKit.pro" +} + +@Suite(.serialized, .timeLimit(.minutes(1))) +@MainActor +struct StoreTransactionKitIntegrationTests { + @Test + func externalPurchaseIsHandledAndFinishedBeforePublication() async throws { + let session = try await makeTestSession() + defer { + session.resetToDefaultState() + session.clearTransactions() + } + let handlerStarted = TestSignal() + let handlerRelease = TestSignal() + let handlerCalls = TestSignal() + + let observedStore = ObservedStore( + handleTransaction: { _ in + await handlerCalls.send() + await handlerStarted.send() + try await handlerRelease.wait(for: 1) + }, + reportFailure: { failure in + Issue.record("Unexpected purchase failure: \(failure)") + } + ) + do { + try await observedStore.waitForEntitlements([]) + + _ = try await session.buyProduct( + identifier: Entitlement.lifetime.rawValue + ) + try await handlerStarted.wait(for: 1) + + #expect(observedStore.store.activeEntitlements == []) + await handlerRelease.send() + try await observedStore.waitForEntitlements([.lifetime]) + #expect(await handlerCalls.value() == 1) + } catch { + await handlerRelease.send() + do { + try await observedStore.close() + } catch { + Issue.record("Store cleanup failed: \(error)") + } + throw error + } + try await observedStore.close() + + let replayedHandlerCalls = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await replayedHandlerCalls.send() + }, + reportFailure: { failure in + Issue.record("Unexpected post-finish failure: \(failure)") + } + ) { observedStore in + try await observedStore.waitForEntitlements([.lifetime]) + #expect(await replayedHandlerCalls.value() == 0) + } + } + + @Test + func directPurchaseIsProcessedAndPublishesPlus() async throws { + try await withTestContext { context in + let product = try await context.product(.plus) + let appAccountToken = UUID() + let result = try await product.purchase( + options: [.appAccountToken(appAccountToken)] + ) + + let outcome = try await context.store.process(result) + + guard case .completed(let transaction) = outcome else { + Issue.record("Expected a completed direct purchase.") + return + } + #expect(transaction.productID == Entitlement.plus.rawValue) + #expect( + transaction.subscriptionGroupID + == "StoreTransactionKitSubscriptionGroup" + ) + #expect(transaction.productType == .autoRenewable) + #expect(transaction.environment == .xcode) + #expect(transaction.offer == nil) + #expect(!transaction.storefrontID.isEmpty) + #expect(transaction.storefrontCountryCode == "USA") + #expect(transaction.price == Decimal(string: "1.99")) + #expect(transaction.currency?.identifier == "USD") + #expect(transaction.originalID == transaction.id) + #expect(transaction.originalPurchaseDate == transaction.purchaseDate) + #expect(transaction.revocationDate == nil) + #expect(transaction.revocationReason == nil) + #expect(transaction.purchasedQuantity == 1) + #expect(!transaction.isUpgraded) + #expect(transaction.ownershipType == .purchased) + #expect(transaction.reason == .purchase) + #expect(transaction.appAccountToken == appAccountToken) + #expect(!transaction.jwsRepresentation.isEmpty) + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func launchReconcilesAnExistingPurchase() async throws { + try await withTestContext(preexistingSubscription: .plus) { context in + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func launchKeepsACancelledSubscriptionUntilItsExpiration() async throws { + try await withTestContext( + preexistingSubscription: .plus, + preexistingPurchaseOptions: [ + .purchaseDate(.now, renewalBehavior: .cancelImmediately) + ] + ) { context in + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func launchExcludesAnAlreadyExpiredSubscription() async throws { + let purchaseDate = try #require( + Calendar(identifier: .gregorian).date( + byAdding: .month, + value: -2, + to: .now + ) + ) + + try await withTestContext( + preexistingSubscription: .plus, + preexistingPurchaseOptions: [ + .purchaseDate( + purchaseDate, + renewalBehavior: .cancelImmediately + ) + ], + expectedEntitlements: [] + ) { context in + #expect(context.store.activeEntitlements == []) + } + } + + @Test + func unfinishedPurchaseReplaysUntilDurableHandlingSucceeds() async throws { + let session = try await makeTestSession() + defer { + session.resetToDefaultState() + session.clearTransactions() + } + _ = try await session.buyProduct(identifier: Entitlement.plus.rawValue) + + let failedHandlerCalls = TestSignal() + let launchDeliveryFailures = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await failedHandlerCalls.send() + throw DurableHandlingFailure() + }, + reportFailure: { failure in + switch failure.source { + case .updates, .unfinished: + await launchDeliveryFailures.send() + case .entitlementRefresh: + break + default: + Issue.record("Unexpected launch failure: \(failure)") + } + } + ) { observedStore in + try await failedHandlerCalls.wait(for: 1) + try await launchDeliveryFailures.wait(for: 1) + try await observedStore.waitForStartupFailure() + #expect(observedStore.store.activeEntitlements == nil) + } + + let successfulHandlerCalls = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await successfulHandlerCalls.send() + }, + reportFailure: { failure in + Issue.record("Unexpected retry failure: \(failure)") + } + ) { observedStore in + try await successfulHandlerCalls.wait(for: 1) + try await observedStore.waitForEntitlements([.plus]) + } + + let postFinishHandlerCalls = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await postFinishHandlerCalls.send() + }, + reportFailure: { failure in + Issue.record("Unexpected post-finish failure: \(failure)") + } + ) { observedStore in + try await observedStore.waitForEntitlements([.plus]) + #expect(await postFinishHandlerCalls.value() == 0) + } + } + + @Test + func interruptedPurchaseCompletesAfterTheIssueIsResolved() async throws { + try await withTestContext { context in + context.session.interruptedPurchasesEnabled = true + let product = try await context.product(.plus) + let result = try await product.purchase() + guard case .pending = result else { + Issue.record("Expected the interrupted purchase to remain pending.") + return + } + let interrupted = try #require( + context.session.allTransactions().first { + $0.productIdentifier == Entitlement.plus.rawValue + && $0.hasPurchaseIssue + } + ) + + try context.session.resolveIssueForTransaction( + identifier: interrupted.identifier + ) + + try await context.waitForEntitlements([.plus]) + } + } + + @Test + func unverifiedUpdateIsReportedAndRemainsUnfinished() async throws { + let session = try await makeTestSession() + defer { + session.resetToDefaultState() + session.clearTransactions() + } + let rejectedHandlerCalls = TestSignal() + let updateFailures = TestSignal() + + try await withObservedStore( + handleTransaction: { _ in + await rejectedHandlerCalls.send() + }, + reportFailure: { failure in + if case .updates = failure.source { + await updateFailures.send() + } + } + ) { observedStore in + try await observedStore.waitForEntitlements([]) + try await session.setSimulatedError( + .verification(.invalidSignature), + forAPI: .verification + ) + + _ = try await session.buyProduct(identifier: Entitlement.plus.rawValue) + try await updateFailures.wait(for: 1) + + #expect(await rejectedHandlerCalls.value() == 0) + #expect(observedStore.store.activeEntitlements == []) + } + + let unfinishedFailures = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await rejectedHandlerCalls.send() + }, + reportFailure: { failure in + if case .unfinished = failure.source { + await unfinishedFailures.send() + } + } + ) { _ in + try await unfinishedFailures.wait(for: 1) + #expect(await rejectedHandlerCalls.value() == 0) + } + } + + @Test + func upgradeFromPlusToProPublishesOnlyPro() async throws { + try await withTestContext { context in + _ = try await context.session.buyProduct(identifier: Entitlement.plus.rawValue) + try await context.waitForEntitlements([.plus]) + + _ = try await context.session.buyProduct(identifier: Entitlement.pro.rawValue) + + try await context.waitForEntitlements([.pro]) + #expect( + context.store.entitlements?.transactions.contains { + $0.productID == Entitlement.plus.rawValue && $0.isUpgraded + } == true + ) + } + } + + @Test + func downgradeFromProToPlusChangesAtRenewal() async throws { + try await withTestContext { context in + _ = try await context.session.buyProduct(identifier: Entitlement.pro.rawValue) + try await context.waitForEntitlements([.pro]) + + _ = try await context.session.buyProduct(identifier: Entitlement.plus.rawValue) + + let preRenewalEntitlements = try await context.store.refreshEntitlements() + #expect( + preRenewalEntitlements.transactions.map(\.productID) + == [Entitlement.pro.rawValue] + ) + #expect(context.store.activeEntitlements == [.pro]) + try context.session.forceRenewalOfSubscription( + productIdentifier: Entitlement.pro.rawValue + ) + try await context.waitForEntitlements([.plus]) + } + } + + @Test + func cancellationKeepsPlusUntilExpirationThenRemovesIt() async throws { + try await withTestContext { context in + let transaction = try await context.session.buyProduct( + identifier: Entitlement.plus.rawValue + ) + try await context.waitForEntitlements([.plus]) + + try context.session.disableAutoRenewForTransaction( + identifier: UInt(transaction.id) + ) + let cancelledTransaction = try #require( + context.session.allTransactions().first { + $0.identifier == UInt(transaction.id) + } + ) + #expect(!cancelledTransaction.autoRenewingEnabled) + + let cancelledEntitlements = try await context.store.refreshEntitlements() + #expect( + cancelledEntitlements.transactions.map(\.productID) + == [Entitlement.plus.rawValue] + ) + #expect(context.store.activeEntitlements == [.plus]) + + try context.session.expireSubscription( + productIdentifier: Entitlement.plus.rawValue + ) + + try await context.waitForEntitlements([]) + } + } + + @Test + func renewalPublishesTheNewTransactionLineage() async throws { + try await withTestContext { context in + let initial = try await context.session.buyProduct( + identifier: Entitlement.plus.rawValue + ) + try await context.waitForEntitlements([.plus]) + + try context.session.forceRenewalOfSubscription( + productIdentifier: Entitlement.plus.rawValue + ) + + let renewal = try await context.waitForTransaction( + productID: Entitlement.plus.rawValue, + excluding: initial.id + ) + #expect(renewal.originalID == initial.originalID) + #expect(renewal.reason == .renewal) + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func renewalWaitsForDurableHandlingBeforeStatusPublication() async throws { + let session = try await makeTestSession() + defer { + session.resetToDefaultState() + session.clearTransactions() + } + let renewalHandlerStarted = TestSignal() + let renewalHandlerRelease = TestSignal() + let observedStore = ObservedStore( + handleTransaction: { transaction in + guard transaction.reason == .renewal else { return } + await renewalHandlerStarted.send() + try await renewalHandlerRelease.wait(for: 1) + }, + reportFailure: { failure in + Issue.record("Unexpected renewal failure: \(failure)") + } + ) + + do { + try await observedStore.waitForEntitlements([]) + let initial = try await session.buyProduct( + identifier: Entitlement.plus.rawValue + ) + try await observedStore.waitForEntitlements([.plus]) + + try session.forceRenewalOfSubscription( + productIdentifier: Entitlement.plus.rawValue + ) + try await renewalHandlerStarted.wait(for: 1) + + #expect( + observedStore.store.entitlements?.transactions.first?.id + == initial.id + ) + await renewalHandlerRelease.send() + let renewal = try await observedStore.waitForTransaction( + productID: Entitlement.plus.rawValue, + excluding: initial.id + ) + #expect(renewal.reason == .renewal) + #expect(renewal.originalID == initial.originalID) + } catch { + await renewalHandlerRelease.send() + do { + try await observedStore.close() + } catch { + Issue.record("Store cleanup failed: \(error)") + } + throw error + } + try await observedStore.close() + + let replayedHandlerCalls = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await replayedHandlerCalls.send() + }, + reportFailure: { failure in + Issue.record("Unexpected post-renewal failure: \(failure)") + } + ) { observedStore in + try await observedStore.waitForEntitlements([.plus]) + #expect(await replayedHandlerCalls.value() == 0) + } + } + + @Test + func refundRemovesTheEntitlement() async throws { + try await withTestContext { context in + let transaction = try await context.session.buyProduct( + identifier: Entitlement.plus.rawValue + ) + try await context.waitForEntitlements([.plus]) + + try context.session.refundTransaction(identifier: UInt(transaction.id)) + + try await context.waitForEntitlements([]) + } + } + + @Test + func restoreWithNoPurchasesPublishesResolvedEmpty() async throws { + try await withTestContext { context in + let entitlements = try await context.store.restorePurchases() + + #expect(entitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + } + } + + @Test + func restoreReturnsAnExistingEntitlement() async throws { + try await withTestContext(preexistingSubscription: .plus) { context in + let entitlements = try await context.store.restorePurchases() + + #expect( + entitlements.transactions.map(\.productID) + == [Entitlement.plus.rawValue] + ) + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func restorePropagatesAppStoreSyncFailureAndCanRetry() async throws { + try await withTestContext { context in + let injectedFailure = SKTestFailures.AppStoreSync.generic( + .networkError(URLError(.notConnectedToInternet)) + ) + try await context.session.setSimulatedError( + injectedFailure, + forAPI: .appStoreSync + ) + #expect( + await context.session.simulatedError(forAPI: .appStoreSync) + == injectedFailure + ) + + do { + _ = try await context.store.restorePurchases() + Issue.record("Expected restorePurchases() to fail.") + } catch StoreKitError.networkError(let error) { + #expect(error.code == .notConnectedToInternet) + } catch { + Issue.record("Unexpected restore error: \(error)") + } + + try await context.session.setSimulatedError( + nil, + forAPI: .appStoreSync + ) + #expect( + await context.session.simulatedError(forAPI: .appStoreSync) + == nil + ) + let entitlements = try await context.store.restorePurchases() + #expect(entitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + } + } + + @Test + func askToBuyApprovalArrivesThroughUpdates() async throws { + try await withTestContext { context in + context.session.askToBuyEnabled = true + let product = try await context.product(.plus) + let result = try await product.purchase() + + let outcome = try await context.store.process(result) + + guard case .pending = outcome else { + Issue.record("Expected an Ask to Buy purchase to remain pending.") + return + } + let pending = try #require( + context.session.allTransactions().first { + $0.pendingAskToBuyConfirmation + } + ) + try context.session.approveAskToBuyTransaction( + identifier: pending.identifier + ) + try await context.waitForEntitlements([.plus]) + } + } + + @Test + func askToBuyDeclineDoesNotGrantAnEntitlement() async throws { + try await withTestContext { context in + context.session.askToBuyEnabled = true + let product = try await context.product(.plus) + let result = try await product.purchase() + + let outcome = try await context.store.process(result) + + guard case .pending = outcome else { + Issue.record("Expected an Ask to Buy purchase to remain pending.") + return + } + let pending = try #require( + context.session.allTransactions().first { + $0.pendingAskToBuyConfirmation + } + ) + try context.session.declineAskToBuyTransaction( + identifier: pending.identifier + ) + + let entitlements = try await context.store.restorePurchases() + #expect(entitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + #expect( + context.session.allTransactions().allSatisfy { + !$0.pendingAskToBuyConfirmation + } + ) + } + } + + private func withTestContext( + preexistingSubscription: Entitlement? = nil, + preexistingPurchaseOptions: Set = [], + expectedEntitlements: Set? = nil, + _ body: (TestContext) async throws -> Void + ) async throws { + let context = try await TestContext( + preexistingSubscription: preexistingSubscription, + preexistingPurchaseOptions: preexistingPurchaseOptions, + expectedEntitlements: expectedEntitlements + ) + do { + try await body(context) + } catch { + do { + try await context.close() + } catch { + Issue.record("Store cleanup failed: \(error)") + } + throw error + } + try await context.close() + } +} + +@MainActor +private final class TestContext { + let session: SKTestSession + private let observedStore: ObservedStore + + var store: TransactionStore { + observedStore.store + } + + init( + preexistingSubscription: Entitlement? = nil, + preexistingPurchaseOptions: Set = [], + expectedEntitlements: Set? = nil + ) async throws { + let session = try await makeTestSession() + self.session = session + if let preexistingSubscription { + _ = try await session.buyProduct( + identifier: preexistingSubscription.rawValue, + options: preexistingPurchaseOptions + ) + } + + let observedStore = ObservedStore( + handleTransaction: { _ in }, + reportFailure: { error in + Issue.record("Background transaction failure: \(error)") + } + ) + self.observedStore = observedStore + let expected = + expectedEntitlements + ?? preexistingSubscription.map { [$0] } + ?? [] + try await observedStore.waitForEntitlements(expected) + #expect(store.startupError == nil) + } + + func product(_ entitlement: Entitlement) async throws -> Product { + try #require( + try await Product.products(for: [entitlement.rawValue]).first + ) + } + + func waitForEntitlements( + _ expected: Set + ) async throws { + try await observedStore.waitForEntitlements(expected) + } + + func waitForTransaction( + productID: String, + excluding transactionID: UInt64 + ) async throws -> StoreTransactionSnapshot { + try await observedStore.waitForTransaction( + productID: productID, + excluding: transactionID + ) + } + + func close() async throws { + defer { + session.resetToDefaultState() + session.clearTransactions() + } + try await observedStore.close() + } +} + +@MainActor +private final class ObservedStore { + let store: TransactionStore + let observation: TransactionStoreObservation + + init( + handleTransaction: + @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, + reportFailure: + @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + ) { + let store = TransactionStore( + handleTransaction: handleTransaction, + reportFailure: reportFailure + ) + self.store = store + self.observation = TransactionStoreObservation(store: store) + } + + func waitForEntitlements(_ expected: Set) async throws { + while true { + let generation = observation.generation + guard store.activeEntitlements != expected else { return } + try await observation.waitForChange(after: generation) + } + } + + func waitForStartupFailure() async throws { + while true { + let generation = observation.generation + guard store.startupError == nil else { return } + try await observation.waitForChange(after: generation) + } + } + + func waitForTransaction( + productID: String, + excluding transactionID: UInt64 + ) async throws -> StoreTransactionSnapshot { + while true { + let generation = observation.generation + if let transaction = store.entitlements?.transactions.first(where: { + $0.productID == productID && $0.id != transactionID + }) { + return transaction + } + try await observation.waitForChange(after: generation) + } + } + + func close() async throws { + observation.finish() + try await store.close() + } +} + +@MainActor +private func withObservedStore( + handleTransaction: + @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, + reportFailure: + @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void, + _ body: (ObservedStore) async throws -> Void +) async throws { + let observedStore = ObservedStore( + handleTransaction: handleTransaction, + reportFailure: reportFailure + ) + do { + try await body(observedStore) + } catch { + do { + try await observedStore.close() + } catch { + Issue.record("Store cleanup failed: \(error)") + } + throw error + } + try await observedStore.close() +} + +@MainActor +private final class TransactionStoreObservation { + private weak var store: TransactionStore? + private let changes: AsyncStream + private let continuation: AsyncStream.Continuation + private(set) var generation: UInt64 = 0 + + init(store: TransactionStore) { + let pair = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + self.changes = pair.stream + self.continuation = pair.continuation + self.store = store + observeNextChange() + } + + func waitForChange(after observedGeneration: UInt64) async throws { + guard observedGeneration == generation else { return } + var iterator = changes.makeAsyncIterator() + while observedGeneration == generation { + try Task.checkCancellation() + guard await iterator.next() != nil else { + throw CancellationError() + } + } + } + + func finish() { + store = nil + continuation.finish() + } + + private func observeNextChange() { + guard let store else { return } + withObservationTracking { + _ = store.entitlements + _ = store.startupError + } onChange: { [weak self] in + Task { @MainActor in + self?.didChange() + } + } + } + + private func didChange() { + precondition(generation < .max) + generation += 1 + observeNextChange() + continuation.yield(generation) + } +} + +private actor TestSignal { + private struct Waiter { + let target: Int + let continuation: CheckedContinuation + } + + private var count = 0 + private var waiters: [UUID: Waiter] = [:] + + func send() { + count += 1 + let ready = waiters.filter { $0.value.target <= count } + for (id, waiter) in ready { + waiters.removeValue(forKey: id) + waiter.continuation.resume() + } + } + + func wait(for target: Int) async throws { + guard count < target else { return } + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else if count >= target { + continuation.resume() + } else { + waiters[id] = Waiter( + target: target, + continuation: continuation + ) + } + } + } onCancel: { + Task { await self.cancelWaiter(id) } + } + } + + func value() -> Int { + count + } + + private func cancelWaiter(_ id: UUID) { + waiters.removeValue(forKey: id)?.continuation.resume( + throwing: CancellationError() + ) + } +} + +private struct DurableHandlingFailure: Error {} + +private func makeTestSession() async throws -> SKTestSession { + let configuration = try #require( + Bundle(for: BundleToken.self).url( + forResource: "StoreKitTest", + withExtension: "storekit" + ) + ) + let session = try SKTestSession(contentsOf: configuration) + session.disableDialogs = true + session.timeRate = .realTime + session.resetToDefaultState() + session.clearTransactions() + try await StoreKitTestEnvironment.requireAvailable() + return session +} + +@MainActor +private enum StoreKitTestEnvironment { + private static var isAvailable = false + + static func requireAvailable() async throws { + guard !isAvailable else { + return + } + _ = try #require( + try await Product.products( + for: [Entitlement.lifetime.rawValue] + ).first, + "The StoreKit test configuration is not active." + ) + isAvailable = true + } +} + +private final class BundleToken {} diff --git a/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7221037 --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj @@ -0,0 +1,474 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 0A298711DEBD3B6EB230ABB9 /* StoreTransactionKitIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227115D1E76A48AC12FD5D6 /* StoreTransactionKitIntegrationTests.swift */; }; + 38FAC3AA36BA020043C15489 /* StoreTransactionKitTestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4854362AB2597C739C5FD6 /* StoreTransactionKitTestApp.swift */; }; + 8139D7CCFBB15D9CE28B97C0 /* StoreKitTest.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 708FCFDBC45AA604121AE7DC /* StoreKitTest.storekit */; }; + DC89CACC7D41724581221AC2 /* StoreTransactionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 979A820F5F67B3E4C1536AC7 /* StoreTransactionKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 1D294771080BE03EF6F71236 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3ACC78A3FE42CBF0145C6042 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EABBA04AB93715D8FB560B24; + remoteInfo = StoreTransactionKitTestApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 16F16063DB121EFC3B4AF293 /* StoreTransactionKitTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StoreTransactionKitTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1E4854362AB2597C739C5FD6 /* StoreTransactionKitTestApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StoreTransactionKitTestApp.swift; sourceTree = ""; }; + 708FCFDBC45AA604121AE7DC /* StoreKitTest.storekit */ = {isa = PBXFileReference; includeInIndex = 1; path = StoreKitTest.storekit; sourceTree = ""; }; + CA0A7E6C9BFF622EA9194CB9 /* StoreTransactionKitIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StoreTransactionKitIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + E227115D1E76A48AC12FD5D6 /* StoreTransactionKitIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StoreTransactionKitIntegrationTests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4CD8555B8E9244B36EDA7453 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC89CACC7D41724581221AC2 /* StoreTransactionKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CBE70F6D15630C643FB9301D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 168BF7BE37B1E4FB20E86CBF /* Products */ = { + isa = PBXGroup; + children = ( + 16F16063DB121EFC3B4AF293 /* StoreTransactionKitTestApp.app */, + CA0A7E6C9BFF622EA9194CB9 /* StoreTransactionKitIntegrationTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 69B93CA4D260A36EC529996F = { + isa = PBXGroup; + children = ( + 168BF7BE37B1E4FB20E86CBF /* Products */, + F12449B1ECF7C1E4374C8275 /* StoreTransactionKitTestApp */, + F2DC827B3C8063624D6F0CE3 /* StoreTransactionKitIntegrationTests */, + 708FCFDBC45AA604121AE7DC /* StoreKitTest.storekit */, + ); + sourceTree = ""; + }; + F12449B1ECF7C1E4374C8275 /* StoreTransactionKitTestApp */ = { + isa = PBXGroup; + children = ( + 1E4854362AB2597C739C5FD6 /* StoreTransactionKitTestApp.swift */, + ); + name = StoreTransactionKitTestApp; + path = StoreTransactionKitTestApp; + sourceTree = ""; + }; + F2DC827B3C8063624D6F0CE3 /* StoreTransactionKitIntegrationTests */ = { + isa = PBXGroup; + children = ( + E227115D1E76A48AC12FD5D6 /* StoreTransactionKitIntegrationTests.swift */, + ); + name = StoreTransactionKitIntegrationTests; + path = StoreTransactionKitIntegrationTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 578B204A3251F74CCAEF5433 /* StoreTransactionKitIntegrationTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D4273B97CE0C94E099541CBF /* Build configuration list for PBXNativeTarget "StoreTransactionKitIntegrationTests" */; + buildPhases = ( + A36C3485111ADFCB71E24F0F /* Sources */, + 4CD8555B8E9244B36EDA7453 /* Frameworks */, + A8079E6EBE2E408327BE20C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DB2A42933EB09967829A9DF7 /* PBXTargetDependency */, + ); + name = StoreTransactionKitIntegrationTests; + packageProductDependencies = ( + 979A820F5F67B3E4C1536AC7 /* StoreTransactionKit */, + ); + productName = StoreTransactionKitIntegrationTests; + productReference = CA0A7E6C9BFF622EA9194CB9 /* StoreTransactionKitIntegrationTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + EABBA04AB93715D8FB560B24 /* StoreTransactionKitTestApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1C4A40D06FA3AB1E1E71D3A /* Build configuration list for PBXNativeTarget "StoreTransactionKitTestApp" */; + buildPhases = ( + 9B6B194DE2758845BD528260 /* Sources */, + CBE70F6D15630C643FB9301D /* Frameworks */, + 8A4B766EB1FF6E94BF0B0FB6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StoreTransactionKitTestApp; + productName = StoreTransactionKitTestApp; + productReference = 16F16063DB121EFC3B4AF293 /* StoreTransactionKitTestApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 3ACC78A3FE42CBF0145C6042 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + }; + buildConfigurationList = 6C612D14E0C478CD4900FC4D /* Build configuration list for PBXProject "StoreTransactionKitTestApp" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 69B93CA4D260A36EC529996F; + minimizedProjectReferenceProxies = 0; + packageReferences = ( + 685C7318A90AED4AB632C854 /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 100; + productRefGroup = 168BF7BE37B1E4FB20E86CBF /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + EABBA04AB93715D8FB560B24 /* StoreTransactionKitTestApp */, + 578B204A3251F74CCAEF5433 /* StoreTransactionKitIntegrationTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8A4B766EB1FF6E94BF0B0FB6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A8079E6EBE2E408327BE20C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8139D7CCFBB15D9CE28B97C0 /* StoreKitTest.storekit in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 9B6B194DE2758845BD528260 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 38FAC3AA36BA020043C15489 /* StoreTransactionKitTestApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A36C3485111ADFCB71E24F0F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0A298711DEBD3B6EB230ABB9 /* StoreTransactionKitIntegrationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + DB2A42933EB09967829A9DF7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StoreTransactionKitTestApp; + target = EABBA04AB93715D8FB560B24 /* StoreTransactionKitTestApp */; + targetProxy = 1D294771080BE03EF6F71236 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 4DBB1CF4BF0F532FED9E0127 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + StoreKitTest, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.IntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/StoreTransactionKitTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/StoreTransactionKitTestApp"; + }; + name = Debug; + }; + 7351B6A5190CAAE26AB19EB0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + 9FDF3FAFCE0A76D4402541CA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + A81D7E05599894178C7EE383 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.TestApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A8DB151A87C5162DF2D5E9AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + StoreKitTest, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.IntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/StoreTransactionKitTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/StoreTransactionKitTestApp"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F789B675F7B22BE59E3CA5F8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.TestApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 6C612D14E0C478CD4900FC4D /* Build configuration list for PBXProject "StoreTransactionKitTestApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9FDF3FAFCE0A76D4402541CA /* Debug */, + 7351B6A5190CAAE26AB19EB0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A1C4A40D06FA3AB1E1E71D3A /* Build configuration list for PBXNativeTarget "StoreTransactionKitTestApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F789B675F7B22BE59E3CA5F8 /* Release */, + A81D7E05599894178C7EE383 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D4273B97CE0C94E099541CBF /* Build configuration list for PBXNativeTarget "StoreTransactionKitIntegrationTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8DB151A87C5162DF2D5E9AD /* Release */, + 4DBB1CF4BF0F532FED9E0127 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 685C7318A90AED4AB632C854 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 979A820F5F67B3E4C1536AC7 /* StoreTransactionKit */ = { + isa = XCSwiftPackageProductDependency; + package = 685C7318A90AED4AB632C854 /* XCLocalSwiftPackageReference ".." */; + productName = StoreTransactionKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 3ACC78A3FE42CBF0145C6042 /* Project object */; +} diff --git a/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme new file mode 100644 index 0000000..5b3299f --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tools/TestApp/StoreTransactionKitTestApp.xcworkspace/contents.xcworkspacedata b/Tools/TestApp/StoreTransactionKitTestApp.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1148920 --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitTestApp.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Tools/TestApp/StoreTransactionKitTestApp/StoreTransactionKitTestApp.swift b/Tools/TestApp/StoreTransactionKitTestApp/StoreTransactionKitTestApp.swift new file mode 100644 index 0000000..e7e58fc --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitTestApp/StoreTransactionKitTestApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct StoreTransactionKitTestApp: App { + var body: some Scene { + WindowGroup { + Text("StoreTransactionKit integration tests") + } + } +}