diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d00e767..34fffcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: -destination 'generic/platform=macOS' \ -derivedDataPath .build/docc-ci - - name: Build external consumer fixture + - name: Build and test external consumer fixture working-directory: Fixtures/ExternalConsumer run: | swift package resolve @@ -101,7 +101,7 @@ jobs: echo "External consumer resolved an app-only dependency." >&2 exit 1 fi - swift build --build-system swiftbuild + swift test --build-system swiftbuild supported-platform-builds: name: Test Build (${{ matrix.name }}) @@ -154,7 +154,7 @@ jobs: CODE_SIGNING_REQUIRED=NO ios-tests: - name: Package Tests (iOS Simulator) + name: Package and StoreKit Tests (iOS Simulator) runs-on: macos-26 timeout-minutes: 10 env: @@ -195,8 +195,9 @@ jobs: - name: Run iOS Simulator tests run: | xcodebuild test \ - -scheme StoreTransactionKit \ + -scheme StoreTransactionKit-Package \ -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -parallel-testing-enabled NO \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO diff --git a/Fixtures/ExternalConsumer/Package.swift b/Fixtures/ExternalConsumer/Package.swift index 28fc8da..11fc452 100644 --- a/Fixtures/ExternalConsumer/Package.swift +++ b/Fixtures/ExternalConsumer/Package.swift @@ -29,6 +29,25 @@ let package = Package( .defaultIsolation(nil), .strictMemorySafety(), ] - ) + ), + .testTarget( + name: "ConsumerTests", + dependencies: [ + "Consumer", + .product( + name: "StoreTransactionKit", + package: "StoreTransactionKit" + ), + .product( + name: "StoreTransactionKitTesting", + package: "StoreTransactionKit" + ), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .defaultIsolation(nil), + .strictMemorySafety(), + ] + ), ] ) diff --git a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift index 25e993c..1ce4248 100644 --- a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift +++ b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift @@ -1,27 +1,72 @@ import StoreTransactionKit -private enum EntitlementID: String, Hashable, Sendable { - case premium = "com.example.premium" +public enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +public enum Plans: AutoRenewableSubscriptionGroup { + public static let id = SubscriptionGroupID( + rawValue: "external-consumer.subscription-group" + ) + + public enum ProductID: String, Hashable, Sendable { + case tier1_Monthly = "external-consumer.subscription.tier1.monthly" + case tier1_Yearly = "external-consumer.subscription.tier1.yearly" + case tier2_Monthly = "external-consumer.subscription.tier2.monthly" + case tier2_Yearly = "external-consumer.subscription.tier2.yearly" + } + + public static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) + } +} + +public let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) + +@MainActor +public final class NotesViewModel { + private let store: TransactionStore + + public var canExportPDF: Bool { + store.isEntitled(to: .tier1) + } + + public init(store: TransactionStore) { + self.store = store + } +} + +public actor AppTransactionDelegate: TransactionStoreDelegate { + public init() {} + + public func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + .automatic + } + + public func didFail( + with failure: StoreTransactionBackgroundFailure + ) async { + print("Background failure from \(failure.source)") + } } @main @MainActor struct Consumer { static func main() async throws { - let store = TransactionStore( - handleTransaction: { transaction in - print("Handle transaction \(transaction.id)") - }, - reportFailure: { failure in - print("Background failure from \(failure.source)") - } + let delegate = AppTransactionDelegate() + let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate ) - - if let activeEntitlements = store.activeEntitlements { - print("Active entitlements: \(activeEntitlements)") - } else { - print("Active entitlements are unresolved") - } + let viewModel = NotesViewModel(store: store) + print("Can export PDF: \(viewModel.canExportPDF)") try await store.close() } } diff --git a/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift new file mode 100644 index 0000000..b378285 --- /dev/null +++ b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift @@ -0,0 +1,24 @@ +import Consumer +import StoreTransactionKit +import StoreTransactionKitTesting +import Testing + +@Test +@MainActor +func subscriptionUpdatesViewModel() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog + ) { harness in + let viewModel = NotesViewModel(store: harness.store) + + #expect(!viewModel.canExportPDF) + + let transaction = try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + + #expect(transaction.productID == Plans.ProductID.tier1_Monthly.rawValue) + #expect(viewModel.canExportPDF) + } +} diff --git a/Package.swift b/Package.swift index 174d991..d6077ce 100644 --- a/Package.swift +++ b/Package.swift @@ -22,17 +22,31 @@ let package = Package( .library( name: "StoreTransactionKit", targets: ["StoreTransactionKit"] - ) + ), + .library( + name: "StoreTransactionKitTesting", + targets: ["StoreTransactionKitTesting"] + ), ], targets: [ .target( name: "StoreTransactionKit", swiftSettings: strictSwiftSettings ), + .target( + name: "StoreTransactionKitTesting", + dependencies: ["StoreTransactionKit"], + swiftSettings: strictSwiftSettings + ), .testTarget( name: "StoreTransactionKitTests", dependencies: ["StoreTransactionKit"], swiftSettings: strictSwiftSettings ), + .testTarget( + name: "StoreTransactionKitTestingTests", + dependencies: ["StoreTransactionKitTesting"], + swiftSettings: strictSwiftSettings + ), ] ) diff --git a/README.md b/README.md index 9727246..a2d1211 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # StoreTransactionKit -StoreTransactionKit moves StoreKit 2 transaction monitoring, verification, -durable processing, and `Transaction.finish()` into one process-owned, +StoreTransactionKit centralizes verified transaction handling, entitlement +reconciliation, and `Transaction.finish()` authority in one process-owned, observable store. ## Requirements @@ -14,236 +14,231 @@ observable store. - visionOS 2.4+ - Swift 6.3+ -## What it owns — and what your app owns +## Installation -The store owns the durable transaction path for the process lifetime: - -- Monitoring: `Transaction.updates`, `Transaction.unfinished` reconciliation, - and subscription status changes -- Verification: only verified transactions reach your code; unverified - deliveries surface as thrown errors or reported failures -- Ordering: durable handling first, then `finish()`, with at-least-once - delivery to an idempotent handler and exact-revision deduplication -- State: the observable current-entitlement projection, restore - synchronization, background failure delivery, and explicit shutdown - -Your app owns everything the user sees and everything it persists: - -- Paywall and purchase UI (StoreKit views or the platform-appropriate - StoreKit purchase action) -- The durable ledger that the transaction handler writes to -- Subscription status presentation (`Product.SubscriptionInfo.Status`) -- Purchases that begin outside the app on platforms that provide - `PurchaseIntent.intents` +In Xcode, choose **File > Add Package Dependencies**, enter +`https://github.com/lynnswap/StoreTransactionKit`, and add the +`StoreTransactionKit` library to your app target. ## Quick start -Define the entitlement identifiers in the app. A string-backed enum keeps -StoreKit product identifiers typed without requiring a framework protocol. +Define the app's entitlements and one App Store Connect auto-renewable +subscription group: ```swift import StoreTransactionKit -enum SubscriptionID: String, Hashable, Sendable { - case monthly = "com.example.subscription.monthly" - case yearly = "com.example.subscription.yearly" +enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 } -actor PurchaseLedger { - func apply(_ transaction: StoreTransactionSnapshot) async throws { - 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 - ) - } +enum Plans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" + ) + + enum ProductID: String, Hashable, Sendable { + case tier1_Monthly = "com.example.subscription.tier1.monthly" + case tier1_Yearly = "com.example.subscription.tier1.yearly" + case tier2_Monthly = "com.example.subscription.tier2.monthly" + case tier2_Yearly = "com.example.subscription.tier2.yearly" } -} -actor StoreDiagnostics { - func record(_ failure: StoreTransactionBackgroundFailure) { - logger.error("StoreKit background failure: \(failure.underlyingError)") + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) } } -@MainActor -func makeStore( - ledger: PurchaseLedger, - diagnostics: StoreDiagnostics -) -> TransactionStore { - TransactionStore( - handleTransaction: { transaction in - try await ledger.apply(transaction) - }, - reportFailure: { failure in - await diagnostics.record(failure) - } - ) -} +let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) ``` -`TransactionStore` is `@MainActor` and `@Observable`. It starts monitoring -during initialization. Create the app-owned dependencies once at the -process-lifetime composition root, retain one store with SwiftUI state, and -inject that same instance into the environment: +Use the subscription group ID and Product IDs exactly as configured in +[App Store Connect][subscription-setup]. Monthly and yearly subscriptions can +grant the same app entitlement. + +Create one store at the app's process-lifetime composition root: ```swift +import StoreTransactionKit import SwiftUI @main struct ExampleApp: App { - @State private var store: TransactionStore + @State private var store: TransactionStore init() { - let ledger = PurchaseLedger() - let diagnostics = StoreDiagnostics() _store = State( - initialValue: makeStore( - ledger: ledger, - diagnostics: diagnostics + initialValue: TransactionStore( + subscriptionCatalog: subscriptionCatalog ) ) } var body: some Scene { WindowGroup { - PremiumStoreView() - .environment(store) + NavigationStack { + ContentView() + } + .environment(store) } } } ``` -The view reads the store directly and renders the three entitlement states — -resolving, failed, and resolved: +Read the store directly and gate only the paid feature. Entitlement +availability does not need to block the rest of the UI: ```swift import StoreKit +import StoreTransactionKit import SwiftUI -struct PremiumStoreView: View { - @Environment(TransactionStore.self) private var store - @State private var refreshError: (any Error)? +struct ContentView: View { + @Environment(TransactionStore.self) private var store + @State private var isShowingPaywall = false + + private var canExportPDF: Bool { + store.isEntitled(to: .tier1) + } var body: some View { - VStack { - if let activeEntitlements = store.activeEntitlements { - if activeEntitlements.contains(.monthly) - || activeEntitlements.contains(.yearly) - { - Label("Premium active", systemImage: "checkmark.seal.fill") + List { + Section { + NavigationLink("All notes") { + NotesView() } - } 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() } - SubscriptionStoreView( - groupID: "YOUR_SUBSCRIPTION_GROUP_ID" - ) + Section { + Button("Export as PDF") { + exportPDF() + } + .disabled(!canExportPDF) + + Button("Plans and subscriptions") { + isShowingPaywall = true + } + } header: { + Text("Premium") + } } + .sheet(isPresented: $isShowingPaywall) { + SubscriptionStoreView(groupID: Plans.id.rawValue) + } + } +} +``` + +No `onInAppPurchaseCompletion` modifier is needed for the default StoreKit-view +flow. Successful purchases arrive through `Transaction.updates`, which the +store monitors. Use the same Product IDs in the active `.storekit` +configuration when running local StoreKit tests. + +## Entitlement availability + +- `activeEntitlements == nil` means no usable entitlement snapshot is + available. An empty set means the query succeeded and no app entitlement is + active. +- `entitlementStatus` explains whether the store is loading, failed, ready, or + using an app-supplied override. +- `isEntitled(to:)` performs exact membership and returns `false` while the + entitlement set is unavailable. + +Keep normal app content usable while the entitlement set is unavailable. Gate +only features that require an active purchase. + +## Override entitlements + +An app-defined debug, preview, or distribution environment can bypass StoreKit +with an exact entitlement set: + +```swift +let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + overridingEntitlements: [ + SubscriptionEntitlement.tier1, + .tier2, + ] +) +``` + +## Transaction delegate + +The delegate is optional. Supply one only when the app owns an additional +durable transaction effect, handles a product outside the subscription catalog, +or needs background-failure notifications. Without a delegate, automatic +handling finishes only catalog-validated auto-renewable subscriptions. + +Return `.finish` only after applying an app-owned effect durably. Throwing leaves +the transaction unfinished so a later StoreKit delivery can retry it. + +```swift +actor AppTransactionDelegate: TransactionStoreDelegate { + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + try await purchaseLedger.apply(transaction) + return .finish + } + + func didFail( + with failure: StoreTransactionBackgroundFailure + ) async { + diagnostics.record(failure) } } ``` -No `onInAppPurchaseCompletion` modifier is needed: successful StoreKit view -purchases arrive through `Transaction.updates`, which the store already -monitors. If you add a non-`nil` completion action, it replaces that default -*and* StoreKit's failure alert — pass each `.success` value to -`store.process(_:)` and own `.failure` presentation yourself. - -## The callback contracts - -`handleTransaction` owns the app's durable transaction correctness: - -- **Be idempotent.** Delivery is at least once; key the ledger on transaction - identity plus the business event it applies. -- **Treat purchase and revocation as distinct events.** A refund or - family-sharing revocation arrives as the same transaction with - `revocationDate` set; transaction ID alone is not a sufficient key. -- **Return only after the business effect is durable.** The store calls - `finish()` after the handler returns. Throwing keeps the transaction - unfinished, and a later refresh retries it. -- **Never call back into the same store** from `handleTransaction` or - `reportFailure`, even through an awaited detached task — doing so creates a - dependency cycle with the work being handled. - -`reportFailure` is also a liveness boundary. StoreTransactionKit delivers -admitted failures serially with backpressure and waits for each callback to -return; `close()` waits for those callbacks too. Record or enqueue the failure -promptly instead of performing work that can wait indefinitely. - -Both callback contracts are documented on `TransactionStore.init`. - -## How entitlement state behaves - -- `activeEntitlements` is `nil` until the first entitlement query resolves; an - empty set means the query resolved and no known identifier matched. -- Startup and every refresh reconcile `Transaction.unfinished` — including - consumables — before publishing state. A handler failure fails that refresh; - the next refresh retries the unfinished work. -- Transactions superseded by a subscription upgrade stay in `entitlements` but - leave `activeEntitlements`. -- Unverified current-entitlement elements are omitted and reported to - `reportFailure` with source `.currentEntitlementVerification`. -- Identifiers map 1:1 to product IDs. Gate access on the tier set, or use - `StoreTransactionSnapshot.subscriptionGroupID` to grant at - subscription-group granularity. - -For the full delivery, reconciliation, and failure-reporting model, see -[Understanding transaction handling][understanding]. - -## Beyond the basics - -- **Custom purchase UI** — load products and start the purchase with StoreKit - views, SwiftUI's `PurchaseAction`, or the platform-appropriate `Product` - purchase API. Pass the resulting `Product.PurchaseResult` to - `store.process(_:)`; `.pending` outcomes arrive later through the handler. -- **Restore** — call `store.restorePurchases()` only from an explicit user - action; `AppStore.sync()` presents authentication UI, and - `StoreKitError.userCancelled` is a normal outcome, not a diagnostic failure. -- **Promoted purchases and win-back offers** — on platforms that provide - `PurchaseIntent.intents`, the app completes each intent's purchase and - passes the result to `store.process(_:)`. -- **Renewal, grace-period, and billing-retry UI** — read - `Product.SubscriptionInfo.Status` directly; the store owns the durable - transaction path, not subscription status presentation. - -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). -Apple's -[purchase API guidance](https://developer.apple.com/documentation/storekit/product/purchase(options:)) -explains which purchase entry point to use for each UI framework and platform. +The store serializes policy decisions and background notifications separately. +Do not call back into the same store from either delegate method. ## Testing -The app-hosted StoreKit integration suite runs with `xcodebuild`. See -[Tools/TestApp/README.md](Tools/TestApp/README.md) for the scenarios and +App and ViewModel tests can use `StoreTransactionKitTesting` without a +`.storekit` configuration: + +```swift +import StoreTransactionKit +import StoreTransactionKitTesting +import Testing + +@Test +@MainActor +func subscriptionUpdatesViewModel() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog + ) { harness in + let viewModel = NotesViewModel(store: harness.store) + + #expect(!viewModel.canExportPDF) + + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + + #expect(viewModel.canExportPDF) + } +} +``` + +`purchase(_:,in:)` returns after the resulting entitlement publication, so the +test needs no timing guess. Inject `TransactionStoreTestClock` into the app +component that owns a delay or deadline. + +Add both `StoreTransactionKit` and `StoreTransactionKitTesting` to the test +target. Keep only `StoreTransactionKit` in the production target. + +The app-hosted StoreKit integration suite continues to test the live adapter. +See [Tools/TestApp/README.md](Tools/TestApp/README.md) for its scenarios and command. ## License StoreTransactionKit is available under the MIT License. -[understanding]: https://lynnswap.github.io/StoreTransactionKit/documentation/storetransactionkit/understandingtransactionhandling +[subscription-setup]: https://developer.apple.com/help/app-store-connect/manage-subscriptions/offer-auto-renewable-subscriptions diff --git a/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift b/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift index 1c5f7f7..16d1656 100644 --- a/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift +++ b/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog package actor FailureReporterDispatcher { private struct Item: Sendable { @@ -7,24 +8,25 @@ package actor FailureReporterDispatcher { } private let sessionID: UUID + private let lifetime: TransactionStoreLifecycle? private let capacity: Int - private let report: - @Sendable (StoreTransactionBackgroundFailure) async - -> Void + private let report: (@Sendable (StoreTransactionBackgroundFailure) async -> Void)? private var queue: [Item] = [] private var spaceWaiters: [ProcessingReceipt] = [] private var worker: Task? private var acceptsFailures = true + private nonisolated let workerCancellation = TaskCancellationBag() package init( sessionID: UUID = UUID(), capacity: Int = 32, + lifetime: TransactionStoreLifecycle? = nil, report: - @escaping @Sendable (StoreTransactionBackgroundFailure) async - -> Void + (@Sendable (StoreTransactionBackgroundFailure) async -> Void)? = nil ) { precondition(capacity > 0) self.sessionID = sessionID + self.lifetime = lifetime self.capacity = capacity self.report = report } @@ -51,12 +53,18 @@ package actor FailureReporterDispatcher { precondition(spaceWaiters.isEmpty) } + package nonisolated func cancelSynchronously() { + workerCancellation.cancel() + } + private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task.detached { [weak self] in + let task = Task.detached { [weak self] in guard let self else { return } await self.drainQueue() } + worker = task + workerCancellation.insert(task) } private func drainQueue() async { @@ -71,14 +79,28 @@ package actor FailureReporterDispatcher { callback: .failureReporter ) ) { - await report(item.failure) + let errorType = String( + reflecting: type(of: item.failure.underlyingError) + ) + Self.logger.error( + "Background StoreKit failure from \(String(describing: item.failure.source), privacy: .public) [\(errorType, privacy: .public)]" + ) + if let report { + await report(item.failure) + } } item.receipt.succeed(()) } worker = nil + workerCancellation.removeAll() } isolated deinit { worker?.cancel() } + + private nonisolated static let logger = Logger( + subsystem: "StoreTransactionKit", + category: "TransactionStore" + ) } diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift index 0501211..802215a 100644 --- a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -1,18 +1,36 @@ import Foundation import StoreKit +package struct CurrentEntitlementReconciliation: Sendable { + package let snapshots: [StoreTransactionSnapshot] + package let causalClaims: [TransactionCausalResolutionClaim] + package let diagnostics: [StoreTransactionBackgroundFailure] +} + +package struct CurrentEntitlementReconciliationFailure: Error, Sendable { + package let underlyingError: any Error + package let causalFailures: [CurrentEntitlementCausalFailure] + package let rootReportingAuthorities: [DirectOperationReportingAuthority] + package let exactFailures: [CurrentEntitlementExactFailure] + package let diagnostics: [StoreTransactionBackgroundFailure] +} + +package struct CurrentEntitlementCausalFailure: Sendable { + package let claim: TransactionCausalResolutionClaim + package let error: any Error +} + +package struct CurrentEntitlementExactFailure: Sendable { + package let snapshot: StoreTransactionSnapshot + package let reportingAuthority: DirectOperationReportingAuthority + package let underlyingError: any Error + package let isCausalOwner: Bool +} + package final class CurrentEntitlementReconciler: Sendable { - struct AcceptedTransaction: Sendable { + private struct AcceptedTransaction: Sendable { let snapshot: StoreTransactionSnapshot let acceptance: ProcessingAcceptance - - init( - snapshot: StoreTransactionSnapshot, - acceptance: ProcessingAcceptance - ) { - self.snapshot = snapshot - self.acceptance = acceptance - } } private struct UnfinishedBatch: Sendable { @@ -29,7 +47,6 @@ package final class CurrentEntitlementReconciler: Sendable { private let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult private let queryUnfinished: @Sendable () async -> [StoreTransactionDelivery] private let core: TransactionProcessingCore - private let failures: FailureReporterDispatcher package init( query: @@ -37,56 +54,69 @@ package final class CurrentEntitlementReconciler: Sendable { -> CurrentEntitlementQueryResult, queryUnfinished: @escaping @Sendable () async -> [StoreTransactionDelivery], - core: TransactionProcessingCore, - failures: FailureReporterDispatcher + core: TransactionProcessingCore ) { - self.currentEntitlements = query + currentEntitlements = query self.queryUnfinished = queryUnfinished self.core = core - self.failures = failures } package func query( retryFailedTransactions: Bool - ) async throws -> [StoreTransactionSnapshot] { + ) async throws -> CurrentEntitlementReconciliation { if retryFailedTransactions { await core.beginRetryAttempt() } var reconciledRevisions: Set = [] - var batch = await unfinishedBatch( - excluding: reconciledRevisions - ) - var observedUnfinishedVerificationRevisions: Set = [] - var observedUnfinishedVerificationFailures: [any Error] = [] + var batch = await unfinishedBatch(excluding: reconciledRevisions) + var observedVerificationRevisions: Set = [] + var diagnostics: [StoreTransactionBackgroundFailure] = [] + var causalClaims: [TransactionCausalResolutionClaim] = [] collectUnfinishedVerificationFailures( batch.verificationFailures, - observedRevisions: &observedUnfinishedVerificationRevisions, - observedFailures: &observedUnfinishedVerificationFailures + observedRevisions: &observedVerificationRevisions, + diagnostics: &diagnostics ) var precedingCurrentVerificationFailures: [StoreTransactionVerificationError] = [] while true { while !batch.acceptedTransactions.isEmpty { do { - try await drain(batch.acceptedTransactions) - } catch { - await reportVerificationFailures( - unfinished: observedUnfinishedVerificationFailures, - currentEntitlements: - precedingCurrentVerificationFailures + causalClaims.append( + contentsOf: try await drain( + batch.acceptedTransactions + ) + ) + } catch let failure as DrainFailure { + appendCurrentEntitlementVerificationFailures( + precedingCurrentVerificationFailures, + to: &diagnostics + ) + let rootCausalFailures = causalClaims.map { + CurrentEntitlementCausalFailure( + claim: $0, + error: failure.underlyingError + ) + } + throw CurrentEntitlementReconciliationFailure( + underlyingError: failure.underlyingError, + causalFailures: + rootCausalFailures + failure.causalFailures, + rootReportingAuthorities: + causalClaims.map(\.reportingAuthority) + + failure.rootReportingAuthorities, + exactFailures: failure.exactFailures, + diagnostics: diagnostics ) - throw error + } catch { + preconditionFailure("Unclassified reconciliation failure: \(error)") } reconciledRevisions.formUnion(batch.revisions) - batch = await unfinishedBatch( - excluding: reconciledRevisions - ) + batch = await unfinishedBatch(excluding: reconciledRevisions) collectUnfinishedVerificationFailures( batch.verificationFailures, - observedRevisions: - &observedUnfinishedVerificationRevisions, - observedFailures: - &observedUnfinishedVerificationFailures + observedRevisions: &observedVerificationRevisions, + diagnostics: &diagnostics ) } @@ -94,10 +124,19 @@ package final class CurrentEntitlementReconciler: Sendable { do { result = try await currentEntitlements() } catch { - await reportUnfinishedVerificationFailures( - observedUnfinishedVerificationFailures + throw CurrentEntitlementReconciliationFailure( + underlyingError: error, + causalFailures: causalClaims.map { + CurrentEntitlementCausalFailure( + claim: $0, + error: error + ) + }, + rootReportingAuthorities: + causalClaims.map(\.reportingAuthority), + exactFailures: [], + diagnostics: diagnostics ) - throw error } let postQueryBatch = await unfinishedBatch( @@ -105,21 +144,22 @@ package final class CurrentEntitlementReconciler: Sendable { ) collectUnfinishedVerificationFailures( postQueryBatch.verificationFailures, - observedRevisions: - &observedUnfinishedVerificationRevisions, - observedFailures: - &observedUnfinishedVerificationFailures + observedRevisions: &observedVerificationRevisions, + diagnostics: &diagnostics ) guard !postQueryBatch.acceptedTransactions.isEmpty else { - await reportVerificationFailures( - unfinished: observedUnfinishedVerificationFailures, - currentEntitlements: result.verificationFailures + appendCurrentEntitlementVerificationFailures( + result.verificationFailures, + to: &diagnostics + ) + return CurrentEntitlementReconciliation( + snapshots: result.snapshots, + causalClaims: causalClaims, + diagnostics: diagnostics ) - return result.snapshots } batch = postQueryBatch - precedingCurrentVerificationFailures = - result.verificationFailures + precedingCurrentVerificationFailures = result.verificationFailures } } @@ -143,13 +183,15 @@ package final class CurrentEntitlementReconciler: Sendable { AcceptedTransaction( snapshot: envelope.value, acceptance: await core.accept(envelope) - )) + ) + ) case .unverified(let revision, let error): verificationFailures.append( UnfinishedVerificationFailure( revision: revision, error: error - )) + ) + ) } } @@ -163,71 +205,101 @@ package final class CurrentEntitlementReconciler: Sendable { private func collectUnfinishedVerificationFailures( _ verificationFailures: [UnfinishedVerificationFailure], observedRevisions: inout Set, - observedFailures: inout [any Error] + diagnostics: inout [StoreTransactionBackgroundFailure] ) { 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( + diagnostics.append( StoreTransactionBackgroundFailure( source: .unfinished, transactionID: nil, productID: nil, - underlyingError: failure + underlyingError: failure.error ) ) } } - func drain(_ transactions: [AcceptedTransaction]) async throws { - var firstError: (any Error)? + private struct DrainFailure: Error, Sendable { + let underlyingError: any Error + let causalFailures: [CurrentEntitlementCausalFailure] + let rootReportingAuthorities: [DirectOperationReportingAuthority] + let exactFailures: [CurrentEntitlementExactFailure] + } + + private func drain( + _ transactions: [AcceptedTransaction] + ) async throws -> [TransactionCausalResolutionClaim] { + var claimedTransactions: + [( + transaction: AcceptedTransaction, + claim: TransactionCausalResolutionClaim? + )] = [] for transaction in transactions { + claimedTransactions.append( + ( + transaction, + await transaction.acceptance.claimCausalResolutionIfOwner() + ) + ) + } + + var exactFailures: [CurrentEntitlementExactFailure] = [] + for entry in claimedTransactions { do { - _ = try await transaction.acceptance.receipt.terminalValue() + _ = try await entry.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 + exactFailures.append( + CurrentEntitlementExactFailure( + snapshot: entry.transaction.snapshot, + reportingAuthority: + entry.transaction.acceptance.reportingAuthority, + underlyingError: error, + isCausalOwner: entry.claim != nil ) - await failures.enqueue(failure) - } - if firstError == nil { - firstError = StoreTransactionFailureWithReportingOwner( - underlyingError: error - ) - } + ) } } - if let firstError { - throw firstError + if let firstFailure = exactFailures.first { + let additionalOwnedAuthorities = exactFailures.dropFirst() + .filter(\.isCausalOwner) + .map(\.reportingAuthority) + let causalFailures: [CurrentEntitlementCausalFailure] = + claimedTransactions.compactMap { entry in + guard let claim = entry.claim else { return nil } + let exactError = exactFailures.first { failure in + failure.reportingAuthority + === entry.transaction.acceptance.reportingAuthority + }?.underlyingError + return CurrentEntitlementCausalFailure( + claim: claim, + error: exactError ?? firstFailure.underlyingError + ) + } + let rootReportingAuthorities = + causalFailures + .map { $0.claim.reportingAuthority } + .filter { authority in + !additionalOwnedAuthorities.contains { + $0 === authority + } + } + [firstFailure.reportingAuthority] + throw DrainFailure( + underlyingError: firstFailure.underlyingError, + causalFailures: causalFailures, + rootReportingAuthorities: rootReportingAuthorities, + exactFailures: exactFailures + ) } + return claimedTransactions.compactMap(\.claim) } - private func reportCurrentEntitlementVerificationFailures( - _ verificationFailures: [StoreTransactionVerificationError] - ) async { + private func appendCurrentEntitlementVerificationFailures( + _ verificationFailures: [StoreTransactionVerificationError], + to diagnostics: inout [StoreTransactionBackgroundFailure] + ) { for failure in verificationFailures { - await failures.enqueue( + diagnostics.append( StoreTransactionBackgroundFailure( source: .currentEntitlementVerification, transactionID: nil, diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index 1fac461..ce5edc3 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -1,99 +1,153 @@ import Foundation -package struct EntitlementRefreshReservation: Sendable { +package struct EntitlementPublication: Sendable +where Entitlement: Hashable & Sendable { + package let entitlements: StoreEntitlements + package let activeEntitlements: Set +} + +package enum EntitlementRefreshOutcome: Sendable +where Entitlement: Hashable & Sendable { + case success(EntitlementPublication) + case transientFailure(any Error) + case catalogFailure(AutoRenewableSubscriptionCatalogError) +} + +package struct EntitlementRefreshReservation: Sendable +where Entitlement: Hashable & Sendable { package enum Role: Equatable, Sendable { case owner case observer } - package let receipt: ProcessingReceipt + package let receipt: ProcessingReceipt> package let role: Role - package let token: UInt64 package let reportingAuthority: DirectOperationReportingAuthority + package let directBinding: DirectOperationObservation.Binding? } -package struct EntitlementRefreshSuccess: Sendable { - package let token: UInt64 - package let entitlements: StoreEntitlements -} - -package actor EntitlementRefreshCoordinator { +package actor EntitlementRefreshCoordinator +where Entitlement: Hashable & Sendable { private struct PendingReservation: Sendable { - let token: UInt64 let retryFailedTransactions: Bool - let receipt: ProcessingReceipt + let receipt: ProcessingReceipt> let reportingAuthority: DirectOperationReportingAuthority } - private let sessionID: UUID - 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 struct PendingFailureResolution: Sendable { + let claim: TransactionCausalResolutionClaim + let error: any Error + } + + private enum PendingWork: Sendable { + case refresh(PendingReservation) + case failure(PendingFailureResolution) + } + + private let query: @Sendable (Bool) async throws -> CurrentEntitlementReconciliation + private let project: + @Sendable (StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set + private let didComplete: @Sendable (EntitlementRefreshOutcome) async -> Void + private let failures: FailureReporterDispatcher + private let lifetime: TransactionStoreLifecycle? + private let reservationDidEnqueue: (@Sendable () -> Void)? private var current: StoreEntitlements? - private var pending: [PendingReservation] = [] + private var pending: [PendingWork] = [] private var worker: Task? private var acceptsReservations = true + private nonisolated let workerCancellation = TaskCancellationBag() package init( - sessionID: UUID = UUID(), query: @escaping @Sendable (Bool) async throws - -> [StoreTransactionSnapshot], - didChange: @escaping @Sendable (StoreEntitlements) async -> Void, - didSucceed: - @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in } + -> CurrentEntitlementReconciliation, + project: + @escaping @Sendable (StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set, + didComplete: + @escaping @Sendable (EntitlementRefreshOutcome) async + -> Void, + failures: FailureReporterDispatcher, + lifetime: TransactionStoreLifecycle? = nil, + reservationDidEnqueue: (@Sendable () -> Void)? = nil ) { - self.sessionID = sessionID self.query = query - self.didChange = didChange - self.didSucceed = didSucceed + self.project = project + self.didComplete = didComplete + self.failures = failures + self.lifetime = lifetime + self.reservationDidEnqueue = reservationDidEnqueue } package func reserve( - retryFailedTransactions: Bool = true - ) -> EntitlementRefreshReservation { + retryFailedTransactions: Bool = true, + directObservation: DirectOperationObservation? = nil, + reportingAuthority preferredReportingAuthority: + DirectOperationReportingAuthority? = nil + ) -> EntitlementRefreshReservation { guard acceptsReservations else { return EntitlementRefreshReservation( receipt: .failed( StoreTransactionInternalError.entitlementRefreshClosed ), role: .owner, - token: 0, - reportingAuthority: DirectOperationReportingAuthority() + reportingAuthority: DirectOperationReportingAuthority(), + directBinding: nil ) } - precondition(nextToken < .max) - nextToken += 1 - let role: EntitlementRefreshReservation.Role + + let role: EntitlementRefreshReservation.Role let reportingAuthority: DirectOperationReportingAuthority - if let preceding = pending.last, + if case .refresh(let preceding)? = pending.last, preceding.retryFailedTransactions == retryFailedTransactions { role = .observer reportingAuthority = preceding.reportingAuthority + preferredReportingAuthority?.merge(into: reportingAuthority) } else { role = .owner - reportingAuthority = DirectOperationReportingAuthority() + reportingAuthority = + preferredReportingAuthority + ?? DirectOperationReportingAuthority() } - let receipt = ProcessingReceipt() + let receipt = ProcessingReceipt>() + let directBinding = directObservation?.bind(to: reportingAuthority) pending.append( - PendingReservation( - token: nextToken, - retryFailedTransactions: retryFailedTransactions, - receipt: receipt, - reportingAuthority: reportingAuthority + .refresh( + PendingReservation( + retryFailedTransactions: retryFailedTransactions, + receipt: receipt, + reportingAuthority: reportingAuthority + ) ) ) + reservationDidEnqueue?() startWorkerIfNeeded() return EntitlementRefreshReservation( receipt: receipt, role: role, - token: nextToken, - reportingAuthority: reportingAuthority + reportingAuthority: reportingAuthority, + directBinding: directBinding ) } + package func resolve( + _ claim: TransactionCausalResolutionClaim, + failure error: any Error + ) { + precondition(acceptsReservations) + pending.append( + .failure( + PendingFailureResolution( + claim: claim, + error: error + ) + ) + ) + startWorkerIfNeeded() + } + package func sealAndDrain() async { acceptsReservations = false let activeWorker = worker @@ -101,57 +155,215 @@ package actor EntitlementRefreshCoordinator { precondition(pending.isEmpty) } + package nonisolated func cancelSynchronously() { + workerCancellation.cancel() + } + private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task.detached { [weak self] in + let task = Task.detached { [weak self] in guard let self else { return } - await self.runQueries() + await self.runWork() } + worker = task + workerCancellation.insert(task) } - private func runQueries() async { + private func runWork() async { while !pending.isEmpty { - let retryFailedTransactions = - pending[0].retryFailedTransactions - let end = - pending.firstIndex { - $0.retryFailedTransactions != retryFailedTransactions - } ?? pending.endIndex - let reservations = Array(pending[..>], + reservationHasReportingOwner: Bool = false, + exactFailures: [CurrentEntitlementExactFailure] = [] + ) async { + let exposed: any Error + if let catalogFailure = error as? StoreTransactionCatalogFailure { + exposed = catalogFailure.error + await didComplete(.catalogFailure(catalogFailure.error)) + } else { + exposed = error + await didComplete(.transientFailure(error)) + } + for failure in exactFailures where failure.isCausalOwner { + failure.reportingAuthority.record( + report: exactFailureReport(failure) + ) + } + for failure in causalFailures { + await failure.claim.fail(exposedError(failure.error)) + } + let reservationError: any Error = + if reservationHasReportingOwner { + StoreTransactionFailureWithReportingOwner( + underlyingError: exposed + ) + } else { + exposed + } + for receipt in reservationReceipts { + receipt.fail(reservationError) + } + } + + private func report( + _ exactFailures: [CurrentEntitlementExactFailure] + ) async { + for failure in exactFailures where failure.isCausalOwner { + let report = exactFailureReport(failure) + if let claimed = failure.reportingAuthority + .failWithoutParticipant(report: report) + { + await failures.enqueue(claimed) + } + } + } + + private func exactFailureReport( + _ failure: CurrentEntitlementExactFailure + ) -> StoreTransactionBackgroundFailure { + StoreTransactionBackgroundFailure( + source: .unfinished, + transactionID: failure.snapshot.id, + productID: failure.snapshot.productID, + underlyingError: exposedError(failure.underlyingError) + ) + } + + private func report(_ diagnostics: [StoreTransactionBackgroundFailure]) async { + for diagnostic in diagnostics { + await failures.enqueue(diagnostic) + } + } + + private func merge( + _ authorities: [DirectOperationReportingAuthority], + into reportingAuthority: DirectOperationReportingAuthority + ) { + for authority in authorities { + authority.merge(into: reportingAuthority) + } + } + + private func exposedError(_ error: any Error) -> any Error { + if let catalogFailure = error as? StoreTransactionCatalogFailure { + return catalogFailure.error + } + return error } package nonisolated static func entitlementOrder( diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift new file mode 100644 index 0000000..d12e4ac --- /dev/null +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift @@ -0,0 +1,19 @@ +/// The availability of the store's typed entitlement projection. +public enum EntitlementStatus: Sendable { + /// The initial entitlement reconciliation has not completed. + case loading + + /// No usable complete entitlement snapshot is available. + /// + /// The associated error explains why entitlement readiness failed. + case failed(any Error) + + /// A complete live entitlement snapshot is available. + /// + /// Both raw and typed entitlement collections are authoritative in this + /// state, including when they are empty. + case ready + + /// App-supplied entitlements are authoritative instead of StoreKit state. + case overridden +} diff --git a/Sources/StoreTransactionKit/Processing/CompletedRevisionCache.swift b/Sources/StoreTransactionKit/Processing/CompletedRevisionCache.swift index f7e53b3..87ea7b5 100644 --- a/Sources/StoreTransactionKit/Processing/CompletedRevisionCache.swift +++ b/Sources/StoreTransactionKit/Processing/CompletedRevisionCache.swift @@ -1,9 +1,14 @@ import Foundation package struct CompletedRevisionCache { + package enum State: Sendable { + case needsRefresh + case satisfied + } + private let capacity: Int private var insertionOrder: [Data] = [] - private var membership: Set = [] + private var states: [Data: State] = [:] package init(capacity: Int = 512) { precondition(capacity > 0) @@ -11,15 +16,26 @@ package struct CompletedRevisionCache { } package func contains(_ revision: Data) -> Bool { - membership.contains(revision) + states[revision] != nil } - package mutating func insert(_ revision: Data) { - guard membership.insert(revision).inserted else { return } + package func state(for revision: Data) -> State? { + states[revision] + } + + package mutating func insert( + _ revision: Data, + state: State = .needsRefresh + ) { + guard states[revision] == nil else { + states[revision] = state + return + } + states[revision] = state insertionOrder.append(revision) if insertionOrder.count > capacity { let evicted = insertionOrder.removeFirst() - membership.remove(evicted) + states.removeValue(forKey: evicted) } } } diff --git a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift index 00b9b35..d8a6ff8 100644 --- a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift +++ b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift @@ -1,5 +1,38 @@ import Foundation +package struct TransactionCausalResolutionClaim: Sendable { + package let value: Value + package let reportingAuthority: DirectOperationReportingAuthority + + private let receipt: ProcessingReceipt + private let finishSuccess: @Sendable () async -> Void + private let finishFailure: @Sendable () async -> Void + + fileprivate init( + value: Value, + reportingAuthority: DirectOperationReportingAuthority, + receipt: ProcessingReceipt, + finishSuccess: @escaping @Sendable () async -> Void, + finishFailure: @escaping @Sendable () async -> Void + ) { + self.value = value + self.reportingAuthority = reportingAuthority + self.receipt = receipt + self.finishSuccess = finishSuccess + self.finishFailure = finishFailure + } + + package func succeed() async { + await finishSuccess() + receipt.succeed(value) + } + + package func fail(_ error: any Error) async { + await finishFailure() + receipt.fail(error) + } +} + package struct ProcessingAcceptance: Sendable { package enum Role: Equatable, Sendable { case owner @@ -8,23 +41,66 @@ package struct ProcessingAcceptance: Sendable { case completedObserver } + /// Completes after the handling policy and `finish()` complete. package let receipt: ProcessingReceipt + + /// Completes after the exact revision's causal entitlement publication. + package let causalReceipt: ProcessingReceipt package let role: Role package let reportingAuthority: DirectOperationReportingAuthority + package let directBinding: DirectOperationObservation.Binding? + + private let claimCausalResolution: @Sendable () async -> TransactionCausalResolutionClaim? + + fileprivate init( + receipt: ProcessingReceipt, + causalReceipt: ProcessingReceipt, + role: Role, + reportingAuthority: DirectOperationReportingAuthority, + directBinding: DirectOperationObservation.Binding?, + claimCausalResolution: + @escaping @Sendable () async + -> TransactionCausalResolutionClaim? + ) { + self.receipt = receipt + self.causalReceipt = causalReceipt + self.role = role + self.reportingAuthority = reportingAuthority + self.directBinding = directBinding + self.claimCausalResolution = claimCausalResolution + } + + package func claimCausalResolutionIfOwner() async + -> TransactionCausalResolutionClaim? + { + await claimCausalResolution() + } } package actor TransactionProcessingCore { + private enum AttemptPhase: Sendable { + case deciding + case decisionFailed + case finished + } + private struct Attempt: Sendable { - let receipt: ProcessingReceipt + let id: UUID + let value: Value + let decisionReceipt: ProcessingReceipt + let causalReceipt: ProcessingReceipt let reportingAuthority: DirectOperationReportingAuthority + var causalResolutionClaimed: Bool + var phase: AttemptPhase } private struct QueuedOperation: Sendable { let envelope: ProcessingEnvelope - let attempt: Attempt + let attemptID: UUID } private let sessionID: UUID + private let lifetime: TransactionStoreLifecycle? private let handle: @Sendable (Value) async throws -> Void private var queue: [QueuedOperation] = [] private var inFlight: [Data: Attempt] = [:] @@ -33,59 +109,94 @@ package actor TransactionProcessingCore { private var worker: Task? private var acceptsInput = true private var initialAttemptCompleted = false + private nonisolated let workerCancellation = TaskCancellationBag() package init( sessionID: UUID = UUID(), + lifetime: TransactionStoreLifecycle? = nil, handle: @escaping @Sendable (Value) async throws -> Void ) { self.sessionID = sessionID + self.lifetime = lifetime self.handle = handle } package func accept( - _ envelope: ProcessingEnvelope + _ envelope: ProcessingEnvelope, + directObservation: DirectOperationObservation? = nil ) -> ProcessingAcceptance { guard acceptsInput else { + let error = StoreTransactionInternalError.inputClosed return ProcessingAcceptance( - receipt: .failed(StoreTransactionInternalError.inputClosed), + receipt: .failed(error), + causalReceipt: .failed(error), role: .owner, + reportingAuthority: DirectOperationReportingAuthority(), + directBinding: nil, + claimCausalResolution: { nil } + ) + } + if let attempt = inFlight[envelope.revision] { + return acceptance( + revision: envelope.revision, + attempt: attempt, + role: .inFlightObserver, + directObservation: directObservation + ) + } + if let attempt = failed[envelope.revision] { + return acceptance( + revision: envelope.revision, + attempt: attempt, + role: .failedObserver, + directObservation: directObservation, reportingAuthority: DirectOperationReportingAuthority() ) } - if completed.contains(envelope.revision) { + if completed.state(for: envelope.revision) == .satisfied { + let reportingAuthority = DirectOperationReportingAuthority() return ProcessingAcceptance( receipt: .succeeded(envelope.value), + causalReceipt: .succeeded(envelope.value), role: .completedObserver, - reportingAuthority: DirectOperationReportingAuthority() + reportingAuthority: reportingAuthority, + directBinding: directObservation?.bind( + to: reportingAuthority + ), + claimCausalResolution: { nil } ) } - if let attempt = inFlight[envelope.revision] { - return ProcessingAcceptance( - receipt: attempt.receipt, - role: .inFlightObserver, - reportingAuthority: attempt.reportingAuthority + if completed.state(for: envelope.revision) == .needsRefresh { + let attempt = makeAttempt( + value: envelope.value, + decisionReceipt: .succeeded(envelope.value), + alreadyFinished: true ) - } - if let attempt = failed[envelope.revision] { - return ProcessingAcceptance( - receipt: attempt.receipt, - role: .failedObserver, - reportingAuthority: attempt.reportingAuthority + inFlight[envelope.revision] = attempt + return acceptance( + revision: envelope.revision, + attempt: attempt, + role: .completedObserver, + directObservation: directObservation ) } - let attempt = Attempt( - receipt: ProcessingReceipt(), - reportingAuthority: DirectOperationReportingAuthority() - ) + let attempt = makeAttempt(value: envelope.value) inFlight[envelope.revision] = attempt - queue.append(QueuedOperation(envelope: envelope, attempt: attempt)) - startWorkerIfNeeded() - return ProcessingAcceptance( - receipt: attempt.receipt, + queue.append( + QueuedOperation( + envelope: envelope, + attemptID: attempt.id + ) + ) + let accepted = acceptance( + revision: envelope.revision, + attempt: attempt, role: .owner, - reportingAuthority: attempt.reportingAuthority + directObservation: directObservation ) + startWorkerIfNeeded() + return accepted } package func retryFailedTransactionsInNewAttempt() -> Bool { @@ -117,17 +228,126 @@ package actor TransactionProcessingCore { failed.removeAll(keepingCapacity: false) } + package nonisolated func cancelSynchronously() { + workerCancellation.cancel() + } + + private func makeAttempt( + value: Value, + decisionReceipt: ProcessingReceipt = ProcessingReceipt(), + alreadyFinished: Bool = false + ) -> Attempt { + Attempt( + id: UUID(), + value: value, + decisionReceipt: decisionReceipt, + causalReceipt: ProcessingReceipt(), + reportingAuthority: DirectOperationReportingAuthority(), + causalResolutionClaimed: false, + phase: alreadyFinished ? .finished : .deciding + ) + } + + private func acceptance( + revision: Data, + attempt: Attempt, + role: ProcessingAcceptance.Role, + directObservation: DirectOperationObservation?, + reportingAuthority: DirectOperationReportingAuthority? = nil + ) -> ProcessingAcceptance { + let reportingAuthority = reportingAuthority ?? attempt.reportingAuthority + return ProcessingAcceptance( + receipt: attempt.decisionReceipt, + causalReceipt: attempt.causalReceipt, + role: role, + reportingAuthority: reportingAuthority, + directBinding: directObservation?.bind(to: reportingAuthority), + claimCausalResolution: { [weak self] in + guard let self else { return nil } + return await self.claimCausalResolution( + revision: revision, + attemptID: attempt.id + ) + } + ) + } + + private func claimCausalResolution( + revision: Data, + attemptID: UUID + ) -> TransactionCausalResolutionClaim? { + let attempt: Attempt + if var active = inFlight[revision], active.id == attemptID { + guard !active.causalResolutionClaimed else { return nil } + active.causalResolutionClaimed = true + inFlight[revision] = active + attempt = active + } else { + return nil + } + + return TransactionCausalResolutionClaim( + value: attempt.value, + reportingAuthority: attempt.reportingAuthority, + receipt: attempt.causalReceipt, + finishSuccess: { [weak self] in + await self?.finishCausalResolution( + revision: revision, + attemptID: attemptID, + succeeded: true + ) + }, + finishFailure: { [weak self] in + await self?.finishCausalResolution( + revision: revision, + attemptID: attemptID, + succeeded: false + ) + } + ) + } + + private func finishCausalResolution( + revision: Data, + attemptID: UUID, + succeeded: Bool + ) { + if let attempt = inFlight[revision], attempt.id == attemptID { + precondition(attempt.causalResolutionClaimed) + inFlight.removeValue(forKey: revision) + if succeeded { + precondition(attempt.phase == .finished) + completed.insert(revision, state: .satisfied) + } else if attempt.phase == .decisionFailed { + failed[revision] = attempt + } + return + } + if let attempt = failed[revision], attempt.id == attemptID { + precondition(attempt.causalResolutionClaimed) + return + } + } + private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task.detached { [weak self] in + let task = Task.detached { [weak self] in guard let self else { return } await self.drainQueue() } + worker = task + workerCancellation.insert(task) } private func drainQueue() async { while !queue.isEmpty { let operation = queue.removeFirst() + guard + let attempt = inFlight[operation.envelope.revision], + attempt.id == operation.attemptID + else { + preconditionFailure("A queued transaction lost its processing attempt.") + } do { try await StoreTransactionCallbackContext.$current.withValue( StoreTransactionCallbackInvocation( @@ -139,15 +359,33 @@ package actor TransactionProcessingCore { } await operation.envelope.finish() completed.insert(operation.envelope.revision) - inFlight.removeValue(forKey: operation.envelope.revision) - operation.attempt.receipt.succeed(operation.envelope.value) + guard + var finishedAttempt = inFlight[operation.envelope.revision], + finishedAttempt.id == operation.attemptID + else { + preconditionFailure( + "A finished transaction lost its processing attempt." + ) + } + finishedAttempt.phase = .finished + inFlight[operation.envelope.revision] = finishedAttempt + attempt.decisionReceipt.succeed(operation.envelope.value) } catch { - inFlight.removeValue(forKey: operation.envelope.revision) - failed[operation.envelope.revision] = operation.attempt - operation.attempt.receipt.fail(error) + guard + var failedAttempt = inFlight[operation.envelope.revision], + failedAttempt.id == operation.attemptID + else { + preconditionFailure( + "A failed transaction lost its processing attempt." + ) + } + failedAttempt.phase = .decisionFailed + inFlight[operation.envelope.revision] = failedAttempt + attempt.decisionReceipt.fail(error) } } worker = nil + workerCancellation.removeAll() } isolated deinit { diff --git a/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift b/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift index 7914b75..937a538 100644 --- a/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift +++ b/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift @@ -14,12 +14,19 @@ package final class DirectOperationReportingAuthority: Sendable { var claimed = false } - private let state = Mutex(State()) + private final class Node: @unchecked Sendable { + var parent: Node? + let state = Mutex(State()) + } + + private static let graph = Mutex(()) + private let node = Node() package init() {} fileprivate func attach(abandoned: Bool) -> UUID { - state.withLock { state in + withState { state in + precondition(!state.claimed) let id = UUID() state.participants[id] = abandoned ? .abandoned : .attached return id @@ -27,7 +34,7 @@ package final class DirectOperationReportingAuthority: Sendable { } fileprivate func succeed(participant id: UUID) { - state.withLock { state in + withState { state in _ = state.participants.removeValue(forKey: id) } } @@ -36,7 +43,7 @@ package final class DirectOperationReportingAuthority: Sendable { participant id: UUID, report: StoreTransactionBackgroundFailure? ) -> StoreTransactionBackgroundFailure? { - state.withLock { state in + withState { state in guard state.participants[id] != nil else { return nil } if state.report == nil { state.report = report @@ -48,7 +55,7 @@ package final class DirectOperationReportingAuthority: Sendable { fileprivate func abandon( participant id: UUID ) -> StoreTransactionBackgroundFailure? { - state.withLock { state in + withState { state in guard state.participants[id] != nil else { return nil } state.participants[id] = .abandoned return claimReportIfAbandoned(state: &state) @@ -56,7 +63,7 @@ package final class DirectOperationReportingAuthority: Sendable { } fileprivate func deliver(participant id: UUID) { - state.withLock { state in + withState { state in guard state.participants.removeValue(forKey: id) != nil else { return } @@ -64,6 +71,74 @@ package final class DirectOperationReportingAuthority: Sendable { } } + package func failWithoutParticipant( + report: StoreTransactionBackgroundFailure + ) -> StoreTransactionBackgroundFailure? { + withState { state in + if state.report == nil { + state.report = report + } + return claimReportIfAbandoned(state: &state) + } + } + + package func record( + report: StoreTransactionBackgroundFailure + ) { + withState { state in + if state.report == nil { + state.report = report + } + } + } + + package func merge( + into authority: DirectOperationReportingAuthority + ) { + Self.graph.withLock { _ in + let source = root(of: node) + let target = root(of: authority.node) + guard source !== target else { return } + + let sourceState = source.state.withLock { $0 } + target.state.withLock { targetState in + precondition( + !sourceState.claimed + && !sourceState.delivered + && !targetState.claimed + && !targetState.delivered + ) + precondition( + sourceState.report == nil || targetState.report == nil + ) + for (id, participant) in sourceState.participants { + precondition(targetState.participants[id] == nil) + targetState.participants[id] = participant + } + if targetState.report == nil { + targetState.report = sourceState.report + } + } + source.parent = target + } + } + + private func withState( + _ body: (inout sending State) -> sending Result + ) -> Result { + Self.graph.withLock { _ in + root(of: node).state.withLock(body) + } + } + + private func root(of node: Node) -> Node { + var node = node + while let parent = node.parent { + node = parent + } + return node + } + private func claimReportIfAbandoned( state: inout State ) -> StoreTransactionBackgroundFailure? { diff --git a/Sources/StoreTransactionKit/Runtime/FiniteOperationRegistry.swift b/Sources/StoreTransactionKit/Runtime/FiniteOperationRegistry.swift index 8fd8c96..b8e1e52 100644 --- a/Sources/StoreTransactionKit/Runtime/FiniteOperationRegistry.swift +++ b/Sources/StoreTransactionKit/Runtime/FiniteOperationRegistry.swift @@ -30,9 +30,14 @@ package final class FiniteOperationRegistry: Sendable { } } - package func stopAdmissionAndWait() async { - let receipt = state.withLock { state -> ProcessingReceipt? in + package func seal() { + state.withLock { state in state.acceptsInput = false + } + } + + package func waitForDrain() async { + let receipt = state.withLock { state -> ProcessingReceipt? in guard state.count > 0 else { return nil } if let receipt = state.drainReceipt { return receipt } let receipt = ProcessingReceipt() @@ -44,6 +49,11 @@ package final class FiniteOperationRegistry: Sendable { } } + package func stopAdmissionAndWait() async { + seal() + await waitForDrain() + } + fileprivate func end() { let receipt = state.withLock { state -> ProcessingReceipt? in precondition(state.count > 0) diff --git a/Sources/StoreTransactionKit/Runtime/LiveTransactionStoreLease.swift b/Sources/StoreTransactionKit/Runtime/LiveTransactionStoreLease.swift new file mode 100644 index 0000000..4d4b6b7 --- /dev/null +++ b/Sources/StoreTransactionKit/Runtime/LiveTransactionStoreLease.swift @@ -0,0 +1,43 @@ +import Synchronization + +package final class LiveTransactionStoreLease: Sendable { + private static let isAcquired = Mutex(false) + + private let isReleased = Mutex(false) + private let releaseReceipt = ProcessingReceipt() + + private init() {} + + package static func acquire() -> LiveTransactionStoreLease { + isAcquired.withLock { isAcquired in + precondition( + !isAcquired, + "Only one live TransactionStore may exist in a process." + ) + isAcquired = true + } + return LiveTransactionStoreLease() + } + + package func release() { + let shouldRelease = isReleased.withLock { isReleased in + guard !isReleased else { return false } + isReleased = true + return true + } + guard shouldRelease else { return } + Self.isAcquired.withLock { isAcquired in + precondition(isAcquired) + isAcquired = false + } + releaseReceipt.succeed(()) + } + + package func waitUntilReleased() async { + _ = try? await releaseReceipt.terminalValue() + } + + deinit { + release() + } +} diff --git a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift index 90b9dc9..48471c2 100644 --- a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift +++ b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift @@ -1,73 +1,81 @@ -package struct RestoreReservation: Sendable { +package struct RestoreReservation: Sendable +where Entitlement: Hashable & Sendable { package enum Role: Equatable, Sendable { case owner case observer } - package let receipt: ProcessingReceipt + package let receipt: ProcessingReceipt> package let role: Role package let reportingAuthority: DirectOperationReportingAuthority + package let directBinding: DirectOperationObservation.Binding? } -package struct RestoreCoordinatorFailure: Error { +package struct RestoreCoordinatorFailure: Error, Sendable { package let underlyingError: any Error + package let synchronized: Bool package let reportsWhenAbandoned: Bool - package let reportingAuthority: DirectOperationReportingAuthority package init( propagating error: any Error, - reportsWhenAbandoned: Bool, - reportingAuthority: DirectOperationReportingAuthority + synchronized: Bool, + reportsWhenAbandoned: Bool ) { let propagation = StoreTransactionFailurePropagation(error) - self.underlyingError = propagation.underlyingError + underlyingError = propagation.underlyingError + self.synchronized = synchronized self.reportsWhenAbandoned = reportsWhenAbandoned && !propagation.hasReportingOwner - self.reportingAuthority = reportingAuthority } } -package actor RestoreCoordinator { +package actor RestoreCoordinator +where Entitlement: Hashable & Sendable { private struct InFlight: Sendable { let id: UInt64 - let receipt: ProcessingReceipt + let receipt: ProcessingReceipt> let reportingAuthority: DirectOperationReportingAuthority let task: Task } private let synchronize: @Sendable () async throws -> Void - private let entitlements: EntitlementRefreshCoordinator + private let entitlements: EntitlementRefreshCoordinator private var nextID: UInt64 = 0 private var inFlight: InFlight? + private nonisolated let taskCancellation = TaskCancellationBag() package init( synchronize: @escaping @Sendable () async throws -> Void, - entitlements: EntitlementRefreshCoordinator + entitlements: EntitlementRefreshCoordinator ) { self.synchronize = synchronize self.entitlements = entitlements } package func reserve( - retryFailedTransactions: Bool = true - ) -> RestoreReservation { + retryFailedTransactions: Bool = true, + directObservation: DirectOperationObservation? = nil + ) -> RestoreReservation { if let inFlight { return RestoreReservation( receipt: inFlight.receipt, role: .observer, - reportingAuthority: inFlight.reportingAuthority + reportingAuthority: inFlight.reportingAuthority, + directBinding: directObservation?.bind( + to: inFlight.reportingAuthority + ) ) } precondition(nextID < .max) nextID += 1 let id = nextID - let receipt = ProcessingReceipt() + let receipt = ProcessingReceipt>() let reportingAuthority = DirectOperationReportingAuthority() + let directBinding = directObservation?.bind(to: reportingAuthority) let synchronize = synchronize let entitlements = entitlements let task = Task.detached { [weak self] in - let result: Result do { try await synchronize() } catch { @@ -76,28 +84,39 @@ package actor RestoreCoordinator { result: .failure( RestoreCoordinatorFailure( propagating: error, - reportsWhenAbandoned: true, - reportingAuthority: reportingAuthority - )) + synchronized: false, + reportsWhenAbandoned: true + ) + ) ) return } let refresh = await entitlements.reserve( - retryFailedTransactions: retryFailedTransactions + retryFailedTransactions: retryFailedTransactions, + reportingAuthority: reportingAuthority ) do { - result = .success(try await refresh.receipt.terminalValue()) + await self?.complete( + id: id, + 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: .failure( + RestoreCoordinatorFailure( + propagating: error, + synchronized: true, + reportsWhenAbandoned: refresh.role == .owner + ) + ) + ) } - await self?.complete(id: id, result: result) } + taskCancellation.insert(task) inFlight = InFlight( id: id, receipt: receipt, @@ -107,13 +126,14 @@ package actor RestoreCoordinator { return RestoreReservation( receipt: receipt, role: .owner, - reportingAuthority: reportingAuthority + reportingAuthority: reportingAuthority, + directBinding: directBinding ) } private func complete( id: UInt64, - result: Result + result: Result, any Error> ) { guard let active = inFlight, active.id == id else { return } inFlight = nil @@ -123,6 +143,11 @@ package actor RestoreCoordinator { case .failure(let error): active.receipt.fail(error) } + taskCancellation.removeAll() + } + + package nonisolated func cancelSynchronously() { + taskCancellation.cancel() } isolated deinit { diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift index 0f89ef7..8df02e6 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift @@ -1,11 +1,12 @@ -package final class StoreTransactionPipeline: Sendable { +package final class StoreTransactionPipeline: Sendable +where Entitlement: Hashable & Sendable { private let core: TransactionProcessingCore - private let entitlements: EntitlementRefreshCoordinator + private let entitlements: EntitlementRefreshCoordinator private let failures: FailureReporterDispatcher package init( core: TransactionProcessingCore, - entitlements: EntitlementRefreshCoordinator, + entitlements: EntitlementRefreshCoordinator, failures: FailureReporterDispatcher ) { self.core = core @@ -14,7 +15,8 @@ package final class StoreTransactionPipeline: Sendable { } package func accept( - _ delivery: StoreTransactionDelivery + _ delivery: StoreTransactionDelivery, + directObservation: DirectOperationObservation? = nil ) async throws -> ( snapshot: StoreTransactionSnapshot, acceptance: ProcessingAcceptance, @@ -25,7 +27,10 @@ package final class StoreTransactionPipeline: Sendable { let retryFailedTransactions = await core.beginTransactionAttempt() return ( envelope.value, - await core.accept(envelope), + await core.accept( + envelope, + directObservation: directObservation + ), retryFailedTransactions ) case .unverified(_, let error): @@ -37,72 +42,66 @@ package final class StoreTransactionPipeline: Sendable { _ delivery: StoreTransactionDelivery, source: StoreTransactionBackgroundFailure.Source ) async { - let snapshot: StoreTransactionSnapshot? - let acceptance: ProcessingAcceptance - let retryFailedTransactions: Bool + let accepted: + ( + snapshot: StoreTransactionSnapshot, + acceptance: ProcessingAcceptance, + retryFailedTransactions: Bool + ) do { - let accepted = try await accept(delivery) - snapshot = accepted.snapshot - acceptance = accepted.acceptance - retryFailedTransactions = accepted.retryFailedTransactions + accepted = try await accept(delivery) } catch { await failures.enqueue( StoreTransactionBackgroundFailure( source: source, transactionID: nil, productID: nil, - underlyingError: error - )) + underlyingError: exposedError(error) + ) + ) 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 { + let claim = await accepted.acceptance.claimCausalResolutionIfOwner() do { - _ = try await acceptance.receipt.terminalValue() + _ = try await accepted.acceptance.receipt.terminalValue() } catch { - guard case .owner = acceptance.role else { return } - await failures.enqueue( - StoreTransactionBackgroundFailure( - source: source, - transactionID: snapshot?.id, - productID: snapshot?.productID, - underlyingError: error - )) + if let claim { + await entitlements.resolve(claim, failure: error) + } + _ = try? await accepted.acceptance.causalReceipt.terminalValue() + await reportIfBackgroundOwned( + authority: accepted.acceptance.reportingAuthority, + source: source, + snapshot: accepted.snapshot, + error: error + ) return } - if case .inFlightObserver = acceptance.role { - return + if let claim { + let refresh = await entitlements.reserve( + retryFailedTransactions: accepted.retryFailedTransactions, + reportingAuthority: + accepted.acceptance.reportingAuthority + ) + do { + _ = try await refresh.receipt.terminalValue() + await claim.succeed() + } catch { + await claim.fail(error) + } } - let refresh = await entitlements.reserve( - retryFailedTransactions: retryFailedTransactions - ) + do { - _ = try await refresh.receipt.terminalValue() + _ = try await accepted.acceptance.causalReceipt.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: propagation.underlyingError - )) + await reportIfBackgroundOwned( + authority: accepted.acceptance.reportingAuthority, + source: .entitlementRefresh, + snapshot: accepted.snapshot, + error: error + ) } } @@ -118,13 +117,44 @@ package final class StoreTransactionPipeline: Sendable { let propagation = StoreTransactionFailurePropagation(error) guard !propagation.hasReportingOwner else { return } guard refresh.role == .owner else { return } - await failures.enqueue( - StoreTransactionBackgroundFailure( - source: .entitlementRefresh, - transactionID: nil, - productID: nil, - underlyingError: propagation.underlyingError - )) + let report = StoreTransactionBackgroundFailure( + source: .entitlementRefresh, + transactionID: nil, + productID: nil, + underlyingError: exposedError(propagation.underlyingError) + ) + if let claimed = refresh.reportingAuthority.failWithoutParticipant( + report: report + ) { + await failures.enqueue(claimed) + } + } + } + + private func reportIfBackgroundOwned( + authority: DirectOperationReportingAuthority, + source: StoreTransactionBackgroundFailure.Source, + snapshot: StoreTransactionSnapshot, + error: any Error + ) async { + let report = StoreTransactionBackgroundFailure( + source: source, + transactionID: snapshot.id, + productID: snapshot.productID, + underlyingError: exposedError(error) + ) + if let claimed = authority.failWithoutParticipant(report: report) { + await failures.enqueue(claimed) + } + } + + private func exposedError(_ error: any Error) -> any Error { + let propagation = StoreTransactionFailurePropagation(error) + if let catalogFailure = + propagation.underlyingError as? StoreTransactionCatalogFailure + { + return catalogFailure.error } + return propagation.underlyingError } } diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index d7438c8..61ded61 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -1,149 +1,216 @@ import StoreKit +import Synchronization private struct DirectOperationFailure: Error { let underlyingError: any Error } -package final class StoreTransactionRuntime: Sendable { +package final class StoreTransactionRuntime: Sendable +where Entitlement: Hashable & Sendable { + private struct RuntimeTasks: Sendable { + let updates: Task + let subscriptionStatus: Task + let startup: Task + } + private let source: StoreTransactionSource + private let lifecycle: TransactionStoreLifecycle + private let delegate: TransactionStoreDelegateReference private let core: TransactionProcessingCore - private let entitlements: EntitlementRefreshCoordinator + private let entitlements: EntitlementRefreshCoordinator private let failures: FailureReporterDispatcher - private let pipeline: StoreTransactionPipeline - private let restoreCoordinator: RestoreCoordinator - private let operations = FiniteOperationRegistry() - private let readinessLease: FiniteOperationLease + private let pipeline: StoreTransactionPipeline + private let restoreCoordinator: RestoreCoordinator private let subscriptionStatusReadiness: ProcessingReceipt private let producerCancellation = TaskCancellationBag() private let finiteTasks = TaskCompletionBag() - private let updatesTask: Task - private let subscriptionStatusTask: Task + private let startupCompletion: ProcessingReceipt + private let tasks = Mutex(nil) package init( sessionID: UUID, source: StoreTransactionSource, - handleTransaction: - @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 + lifecycle: TransactionStoreLifecycle, + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)?, + entitlementOutcome: + @escaping @Sendable (EntitlementRefreshOutcome) async + -> Void, + entitlementReservationDidEnqueue: (@Sendable () -> Void)? = nil ) { self.source = source + self.lifecycle = lifecycle + let startupCompletion = ProcessingReceipt() + self.startupCompletion = startupCompletion - let core = TransactionProcessingCore( + let delegateReference = TransactionStoreDelegateReference(delegate) + self.delegate = delegateReference + let failures = FailureReporterDispatcher( sessionID: sessionID, - handle: handleTransaction + lifetime: lifecycle, + report: { failure in + await delegateReference.didFail(with: failure) + } ) - let failures = FailureReporterDispatcher( + self.failures = failures + + let core = TransactionProcessingCore( sessionID: sessionID, - report: reportFailure + lifetime: lifecycle, + handle: { transaction in + let classification: AutoRenewableSubscriptionClassification + do { + classification = try subscriptionCatalog.classification( + of: transaction + ) + } catch let error as AutoRenewableSubscriptionCatalogError { + throw StoreTransactionCatalogFailure(error: error) + } + + let policy = try await delegateReference.decidePolicy( + for: transaction + ) + switch (classification, policy) { + case (.managed, .automatic), + (.managed, .finish), + (.unmanaged, .finish): + return + case (.unmanaged, .automatic): + throw StoreTransactionError.unhandledTransaction( + productID: transaction.productID, + productType: transaction.productType + ) + } + } ) - let currentEntitlements = CurrentEntitlementReconciler( + self.core = core + + let reconciler = CurrentEntitlementReconciler( query: source.currentEntitlements, queryUnfinished: source.queryUnfinished, - core: core, - failures: failures + core: core ) let entitlements = EntitlementRefreshCoordinator( - sessionID: sessionID, query: { retryFailedTransactions in - try await currentEntitlements.query( + try await reconciler.query( retryFailedTransactions: retryFailedTransactions ) }, - didChange: entitlementsDidChange, - didSucceed: entitlementRefreshDidSucceed + project: { + (entitlements: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + try subscriptionCatalog.activeEntitlements(in: entitlements) + }, + didComplete: entitlementOutcome, + failures: failures, + lifetime: lifecycle, + reservationDidEnqueue: entitlementReservationDidEnqueue ) - let pipeline = StoreTransactionPipeline( + self.entitlements = entitlements + + let pipeline = StoreTransactionPipeline( core: core, entitlements: entitlements, failures: failures ) - self.core = core - self.entitlements = entitlements - self.failures = failures self.pipeline = pipeline self.restoreCoordinator = RestoreCoordinator( synchronize: source.synchronize, entitlements: entitlements ) - self.readinessLease = operations.begin()! + let subscriptionStatusReadiness = ProcessingReceipt() self.subscriptionStatusReadiness = subscriptionStatusReadiness - - self.updatesTask = Task.detached { - await source.runUpdates { delivery in - await pipeline.processBackground(delivery, source: .updates) - } - } - 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(subscriptionStatusTask) - } - - package func beginOperation() -> FiniteOperationLeases? { - operations.beginPair() } - package func readiness() async throws -> StoreTransactionReadiness { - let reservation = await entitlements.reserve( - retryFailedTransactions: false - ) - let completion = ProcessingReceipt() + package func start() { + let source = source + let lifecycle = lifecycle + let pipeline = pipeline + let entitlements = entitlements let core = core + let failures = failures let subscriptionStatusReadiness = subscriptionStatusReadiness - let readinessLease = readinessLease - let task = Task { - let result: Result + let startupCompletion = startupCompletion + let startupRegistration = finiteTasks.reserve() + + let updatesTask = Task.detached { + await source.runUpdates( + { lifecycle.beginProducerIteration() }, + { delivery in + await pipeline.processBackground( + delivery, + source: .updates + ) + } + ) + } + let subscriptionStatusTask = Task.detached { + await source.runSubscriptionStatusUpdates( + { lifecycle.beginProducerIteration() }, + { + _ = try? await subscriptionStatusReadiness.terminalValue() + await pipeline.refreshEntitlements() + } + ) + } + let startupTask = Task.detached { + defer { startupRegistration.complete() } + let reservation = await entitlements.reserve( + retryFailedTransactions: false + ) + 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) + _ = try await reservation.receipt.terminalValue() + result = .success(()) } catch { - result = .failure(error) + let propagation = StoreTransactionFailurePropagation(error) + let exposed: any Error + if let catalogFailure = + propagation.underlyingError + as? StoreTransactionCatalogFailure + { + exposed = catalogFailure.error + } else { + exposed = propagation.underlyingError + } + if !propagation.hasReportingOwner { + let report = StoreTransactionBackgroundFailure( + source: .entitlementRefresh, + transactionID: nil, + productID: nil, + underlyingError: exposed + ) + if let claimed = reservation.reportingAuthority + .failWithoutParticipant(report: report) + { + await failures.enqueue(claimed) + } + } + result = .failure(exposed) } await core.completeInitialAttempt() subscriptionStatusReadiness.succeed(()) - readinessLease.end() switch result { - case .success(let readiness): - completion.succeed(readiness) + case .success: + startupCompletion.succeed(()) case .failure(let error): - completion.fail( - StoreTransactionReadinessFailure( - refreshToken: reservation.token, - underlyingError: error - ) - ) + startupCompletion.fail(error) } } - finiteTasks.insert(task) - - do { - return try await completion.value() - } catch is ProcessingReceiptWaiterCancellation { - throw CancellationError() + startupRegistration.attach(startupTask) + let inserted = tasks.withLock { tasks in + guard tasks == nil else { return false } + tasks = RuntimeTasks( + updates: updatesTask, + subscriptionStatus: subscriptionStatusTask, + startup: startupTask + ) + return true } + precondition(inserted, "A transaction runtime can start only once.") + producerCancellation.insert(updatesTask) + producerCancellation.insert(subscriptionStatusTask) } package func process( @@ -152,32 +219,20 @@ package final class StoreTransactionRuntime: Sendable { ) async throws -> StorePurchaseOutcome { switch result { case .success(let verificationResult): - return try await process( + return try await processAccepted( source.purchaseDelivery(verificationResult), leases: leases ) case .pending: - do { - try Task.checkCancellation() - } catch { - leases.work.end() - leases.observer.end() - throw error - } - leases.work.end() - leases.observer.end() - return .pending + return try finishImmediate( + leases: leases, + outcome: .pending + ) case .userCancelled: - do { - try Task.checkCancellation() - } catch { - leases.work.end() - leases.observer.end() - throw error - } - leases.work.end() - leases.observer.end() - return .userCancelled + return try finishImmediate( + leases: leases, + outcome: .userCancelled + ) @unknown default: leases.work.end() leases.observer.end() @@ -187,74 +242,151 @@ package final class StoreTransactionRuntime: Sendable { package func process( _ delivery: StoreTransactionDelivery, - leases: FiniteOperationLeases + leases: FiniteOperationLeases, + didAdmit: @escaping @Sendable () async -> Void = {} ) async throws -> StorePurchaseOutcome { + return try await processAccepted( + delivery, + leases: leases, + didAdmit: didAdmit + ) + } + + private func processAccepted( + _ delivery: StoreTransactionDelivery, + leases: FiniteOperationLeases, + didAdmit: @escaping @Sendable () async -> Void = {} + ) async throws -> StorePurchaseOutcome { + if case .unverified(_, let error) = delivery { + return try await failAdmittedDelivery( + error, + leases: leases, + didAdmit: didAdmit + ) + } + let accepted: ( snapshot: StoreTransactionSnapshot, acceptance: ProcessingAcceptance, retryFailedTransactions: Bool ) + let observation = DirectOperationObservation() do { - accepted = try await pipeline.accept(delivery) + accepted = try await pipeline.accept( + delivery, + directObservation: observation + ) } catch { leases.work.end() leases.observer.end() - throw error + throw exposedError(error) } - let observation = DirectOperationObservation() - let transactionBinding = observation.bind( - to: accepted.acceptance.reportingAuthority - ) + + guard let binding = accepted.acceptance.directBinding else { + preconditionFailure("A direct transaction lost its reporting binding.") + } + let claim = await accepted.acceptance.claimCausalResolutionIfOwner() + await didAdmit() let operationReceipt = ProcessingReceipt() - let entitlements = entitlements + let registration = finiteTasks.reserve() let task = Task { - defer { leases.work.end() } - let snapshot: StoreTransactionSnapshot + defer { + leases.work.end() + registration.complete() + } do { - snapshot = try await accepted.acceptance.receipt - .terminalValue() + _ = try await accepted.acceptance.receipt.terminalValue() } catch { + if let claim { + await entitlements.resolve(claim, failure: error) + } + _ = try? await accepted.acceptance.causalReceipt.terminalValue() operationReceipt.fail( await directFailure( observation: observation, - binding: transactionBinding, + binding: binding, propagating: error, - reportsWhenAbandoned: - accepted.acceptance.role == .owner, + reportsWhenAbandoned: true, operation: .processPurchase, snapshot: accepted.snapshot ) ) return } - observation.succeed(transactionBinding) - let refresh = await entitlements.reserve( - retryFailedTransactions: - accepted.retryFailedTransactions - ) - let refreshBinding = observation.bind( - to: refresh.reportingAuthority - ) + if let claim { + let refresh = await entitlements.reserve( + retryFailedTransactions: accepted.retryFailedTransactions, + reportingAuthority: + accepted.acceptance.reportingAuthority + ) + do { + _ = try await refresh.receipt.terminalValue() + await claim.succeed() + } catch { + await claim.fail(error) + } + } + do { - _ = try await refresh.receipt.terminalValue() - observation.succeed(refreshBinding) - operationReceipt.succeed(snapshot) + _ = try await accepted.acceptance.causalReceipt.terminalValue() + observation.succeed(binding) + operationReceipt.succeed(accepted.snapshot) } catch { operationReceipt.fail( await directFailure( observation: observation, - binding: refreshBinding, - propagating: error, - reportsWhenAbandoned: refresh.role == .owner, + binding: binding, + propagating: completedTransactionFailure( + snapshot: accepted.snapshot, + error: error + ), + reportsWhenAbandoned: true, operation: .processPurchase, - snapshot: accepted.snapshot + snapshot: accepted.snapshot, + backgroundError: exposedError(error) ) ) } } - finiteTasks.insert(task) + registration.attach(task) + return try await outcome( + receipt: operationReceipt, + observation: observation, + observerLease: leases.observer + ) { .completed($0) } + } + + private func failAdmittedDelivery( + _ error: any Error, + leases: FiniteOperationLeases, + didAdmit: @escaping @Sendable () async -> Void + ) async throws -> StorePurchaseOutcome { + let observation = DirectOperationObservation() + let binding = observation.bind( + to: DirectOperationReportingAuthority() + ) + await didAdmit() + let operationReceipt = ProcessingReceipt() + let registration = finiteTasks.reserve() + let task = Task { + defer { + leases.work.end() + registration.complete() + } + operationReceipt.fail( + await directFailure( + observation: observation, + binding: binding, + propagating: error, + reportsWhenAbandoned: true, + operation: .processPurchase, + snapshot: nil + ) + ) + } + registration.attach(task) return try await outcome( receipt: operationReceipt, observation: observation, @@ -267,20 +399,25 @@ package final class StoreTransactionRuntime: Sendable { ) async throws -> StoreEntitlements { let retryFailedTransactions = await core.retryFailedTransactionsInNewAttempt() + let observation = DirectOperationObservation() let refresh = await entitlements.reserve( - retryFailedTransactions: retryFailedTransactions + retryFailedTransactions: retryFailedTransactions, + directObservation: observation ) - let observation = DirectOperationObservation() - let binding = observation.bind(to: refresh.reportingAuthority) - let operationReceipt = ProcessingReceipt() + guard let binding = refresh.directBinding else { + preconditionFailure("A direct refresh lost its reporting binding.") + } + let operationReceipt = ProcessingReceipt>() + let registration = finiteTasks.reserve() let task = Task { - defer { leases.work.end() } + defer { + leases.work.end() + registration.complete() + } do { - let value = try await refresh.receipt.terminalValue() + let publication = try await refresh.receipt.terminalValue() observation.succeed(binding) - operationReceipt.succeed( - value - ) + operationReceipt.succeed(publication) } catch { operationReceipt.fail( await directFailure( @@ -288,18 +425,18 @@ package final class StoreTransactionRuntime: Sendable { binding: binding, propagating: error, reportsWhenAbandoned: refresh.role == .owner, - operation: .currentEntitlements, + operation: .refreshEntitlements, snapshot: nil ) ) } } - finiteTasks.insert(task) + registration.attach(task) return try await outcome( receipt: operationReceipt, observation: observation, observerLease: leases.observer - ) { $0 } + ) { $0.entitlements } } package func history( @@ -311,9 +448,12 @@ package final class StoreTransactionRuntime: Sendable { to: DirectOperationReportingAuthority() ) let operationReceipt = ProcessingReceipt<[StoreTransactionSnapshot]>() - let source = source + let registration = finiteTasks.reserve() let task = Task { - defer { leases.work.end() } + defer { + leases.work.end() + registration.complete() + } do { let snapshots = try await source.history(productID) .sorted(by: Self.historyOrder) @@ -332,7 +472,7 @@ package final class StoreTransactionRuntime: Sendable { ) } } - finiteTasks.insert(task) + registration.attach(task) return try await outcome( receipt: operationReceipt, observation: observation, @@ -345,42 +485,47 @@ package final class StoreTransactionRuntime: Sendable { ) 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 restore = await restoreCoordinator.reserve( + retryFailedTransactions: retryFailedTransactions, + directObservation: observation ) - let operationReceipt = ProcessingReceipt() + guard let binding = restore.directBinding else { + preconditionFailure("A direct restore lost its reporting binding.") + } + let operationReceipt = ProcessingReceipt>() + let registration = finiteTasks.reserve() let task = Task { - defer { leases.work.end() } + defer { + leases.work.end() + registration.complete() + } do { - let value = try await restore.receipt.terminalValue() - observation.succeed(restoreBinding) - operationReceipt.succeed( - value - ) + let publication = try await restore.receipt.terminalValue() + observation.succeed(binding) + operationReceipt.succeed(publication) } 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 + let exposed = exposedError(failure.underlyingError) + let directError: any Error + if failure.synchronized { + directError = StoreTransactionError.entitlementRefreshFailed( + after: .synchronizedPurchases, + underlyingError: exposed ) + } else { + directError = exposed } operationReceipt.fail( await directFailure( observation: observation, - binding: failureBinding, - propagating: failure.underlyingError, + binding: binding, + propagating: directError, reportsWhenAbandoned: restore.role == .owner && failure.reportsWhenAbandoned, operation: .restorePurchases, - snapshot: nil + snapshot: nil, + backgroundError: exposed ) ) } catch { @@ -389,29 +534,67 @@ package final class StoreTransactionRuntime: Sendable { ) } } - finiteTasks.insert(task) + registration.attach(task) return try await outcome( receipt: operationReceipt, observation: observation, observerLease: leases.observer - ) { $0 } + ) { $0.entitlements } + } + + package func waitForInitialReadiness() async throws { + do { + try await startupCompletion.value() + } catch is ProcessingReceiptWaiterCancellation { + throw CancellationError() + } } - package func close() async { + package func shutdown() async { + let tasks = tasks.withLock { tasks -> RuntimeTasks in + guard let tasks else { + preconditionFailure("A transaction runtime was closed before start.") + } + return tasks + } producerCancellation.cancel() - await updatesTask.value - await subscriptionStatusTask.value - await operations.stopAdmissionAndWait() + tasks.startup.cancel() + await tasks.updates.value + await tasks.subscriptionStatus.value + await lifecycle.waitForProducerIterations() + await lifecycle.waitForOperations() await finiteTasks.waitForAll() await entitlements.sealAndDrain() await core.finishInputAndDrain() await failures.sealAndDrain() + delegate.release() producerCancellation.removeAll() } package func cancelSynchronously() { + lifecycle.sealSynchronously() producerCancellation.cancel() finiteTasks.cancel() + restoreCoordinator.cancelSynchronously() + core.cancelSynchronously() + entitlements.cancelSynchronously() + failures.cancelSynchronously() + } + + private func finishImmediate( + leases: FiniteOperationLeases, + outcome: StorePurchaseOutcome + ) throws -> StorePurchaseOutcome { + do { + try Task.checkCancellation() + } catch { + leases.work.end() + leases.observer.end() + throw error + } + leases.work.end() + leases.observer.end() + return outcome } private func outcome( @@ -448,16 +631,18 @@ package final class StoreTransactionRuntime: Sendable { propagating error: any Error, reportsWhenAbandoned: Bool, operation: StoreTransactionOperation, - snapshot: StoreTransactionSnapshot? + snapshot: StoreTransactionSnapshot?, + backgroundError: (any Error)? = nil ) async -> DirectOperationFailure { let propagation = StoreTransactionFailurePropagation(error) + let exposed = exposedError(propagation.underlyingError) let report: StoreTransactionBackgroundFailure? if reportsWhenAbandoned && !propagation.hasReportingOwner { report = StoreTransactionBackgroundFailure( source: .abandonedDirectOperation(operation), transactionID: snapshot?.id, productID: snapshot?.productID, - underlyingError: propagation.underlyingError + underlyingError: backgroundError.map(exposedError) ?? exposed ) } else { report = nil @@ -466,8 +651,35 @@ package final class StoreTransactionRuntime: Sendable { await failures.enqueue(claimed) } return DirectOperationFailure( - underlyingError: propagation.underlyingError + underlyingError: exposed + ) + } + + private func completedTransactionFailure( + snapshot: StoreTransactionSnapshot, + error: any Error + ) -> any Error { + let propagation = StoreTransactionFailurePropagation(error) + let publicError = StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(snapshot), + underlyingError: exposedError(propagation.underlyingError) ) + if propagation.hasReportingOwner { + return StoreTransactionFailureWithReportingOwner( + underlyingError: publicError + ) + } + return publicError + } + + private func exposedError(_ error: any Error) -> any Error { + let propagation = StoreTransactionFailurePropagation(error) + if let catalogFailure = + propagation.underlyingError as? StoreTransactionCatalogFailure + { + return catalogFailure.error + } + return propagation.underlyingError } package static func historyOrder( diff --git a/Sources/StoreTransactionKit/Runtime/TaskCancellationBag.swift b/Sources/StoreTransactionKit/Runtime/TaskCancellationBag.swift index 6066df3..4263113 100644 --- a/Sources/StoreTransactionKit/Runtime/TaskCancellationBag.swift +++ b/Sources/StoreTransactionKit/Runtime/TaskCancellationBag.swift @@ -1,22 +1,36 @@ import Synchronization package final class TaskCancellationBag: Sendable { - private let tasks = Mutex<[Task]>([]) + private struct State { + var tasks: [Task] = [] + var isCancelled = false + } + + private let state = Mutex(State()) package init() {} package func insert(_ task: Task) { - tasks.withLock { $0.append(task) } + let shouldCancel = state.withLock { state in + state.tasks.append(task) + return state.isCancelled + } + if shouldCancel { + task.cancel() + } } package func cancel() { - let snapshot = tasks.withLock { $0 } + let snapshot = state.withLock { state in + state.isCancelled = true + return state.tasks + } for task in snapshot { task.cancel() } } package func removeAll() { - tasks.withLock { $0.removeAll(keepingCapacity: false) } + state.withLock { $0.tasks.removeAll(keepingCapacity: false) } } } diff --git a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift index 8264cd8..0ea6af5 100644 --- a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift +++ b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift @@ -2,58 +2,107 @@ import Foundation import Synchronization package final class TaskCompletionBag: Sendable { - private struct State { - var tasks: [UUID: Task] = [:] - var emptyWaiters: [CheckedContinuation] = [] + package struct Registration: Sendable { + private let attachTask: @Sendable (Task) -> Void + private let completeTask: @Sendable () -> Void + + fileprivate init( + attachTask: @escaping @Sendable (Task) -> Void, + completeTask: @escaping @Sendable () -> Void + ) { + self.attachTask = attachTask + self.completeTask = completeTask + } + + package func attach(_ task: Task) { + attachTask(task) + } + + package func complete() { + completeTask() + } + } + + private struct Entry: Sendable { + var task: Task? + let completion: ProcessingReceipt + } + + private struct State: Sendable { + var entries: [UUID: Entry] = [:] + var isCancelled = false } private let state = Mutex(State()) package init() {} - package func insert(_ task: Task) { + package func reserve() -> Registration { let id = UUID() - state.withLock { $0.tasks[id] = task } - Task { [weak self] in - await task.value - self?.remove(id) + let completion = ProcessingReceipt() + state.withLock { state in + state.entries[id] = Entry( + task: nil, + completion: completion + ) } + return Registration( + attachTask: { [weak self] task in + self?.attach(task, to: id) + }, + completeTask: { [weak self] in + self?.complete(id) + } + ) } package func waitForAll() async { - await withCheckedContinuation { continuation in - let isEmpty = state.withLock { state in - guard !state.tasks.isEmpty else { return true } - state.emptyWaiters.append(continuation) - return false + while true { + let snapshot = state.withLock { + $0.entries.values.map(\.completion) } - if isEmpty { - continuation.resume() + guard !snapshot.isEmpty else { return } + + for completion in snapshot { + _ = try? await completion.terminalValue() } } } package func cancel() { - let snapshot = state.withLock { Array($0.tasks.values) } + let snapshot = state.withLock { state in + state.isCancelled = true + return state.entries.values.compactMap(\.task) + } for task in snapshot { task.cancel() } } package func retainedTaskCount() -> Int { - state.withLock { $0.tasks.count } + state.withLock(\.entries.count) } - private func remove(_ id: UUID) { - 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 + private func attach( + _ task: Task, + to id: UUID + ) { + let shouldCancel = state.withLock { state in + guard var entry = state.entries[id] else { return false } + precondition(entry.task == nil, "A task completion registration was attached twice.") + entry.task = task + state.entries[id] = entry + return state.isCancelled } - for waiter in waiters { - waiter.resume() + if shouldCancel { + task.cancel() + } + } + + private func complete(_ id: UUID) { + let completion = state.withLock { + $0.entries.removeValue(forKey: id)?.completion } + completion?.succeed(()) } } diff --git a/Sources/StoreTransactionKit/Runtime/TransactionStoreDelegateReference.swift b/Sources/StoreTransactionKit/Runtime/TransactionStoreDelegateReference.swift new file mode 100644 index 0000000..c376995 --- /dev/null +++ b/Sources/StoreTransactionKit/Runtime/TransactionStoreDelegateReference.swift @@ -0,0 +1,27 @@ +import Synchronization + +package final class TransactionStoreDelegateReference: Sendable { + private let delegate: Mutex<(any TransactionStoreDelegate)?> + + package init(_ delegate: (any TransactionStoreDelegate)?) { + self.delegate = Mutex(delegate) + } + + package func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + let delegate = delegate.withLock { $0 } + return try await delegate?.decidePolicy(for: transaction) ?? .automatic + } + + package func didFail( + with failure: StoreTransactionBackgroundFailure + ) async { + let delegate = delegate.withLock { $0 } + await delegate?.didFail(with: failure) + } + + package func release() { + delegate.withLock { $0 = nil } + } +} diff --git a/Sources/StoreTransactionKit/Runtime/TransactionStoreLifecycle.swift b/Sources/StoreTransactionKit/Runtime/TransactionStoreLifecycle.swift new file mode 100644 index 0000000..3bdeaab --- /dev/null +++ b/Sources/StoreTransactionKit/Runtime/TransactionStoreLifecycle.swift @@ -0,0 +1,152 @@ +import Synchronization + +package final class TransactionStoreLifecycle: Sendable { + private enum Phase: Sendable { + case running + case closing(ProcessingReceipt) + case closed + } + + private struct State: Sendable { + var phase: Phase = .running + var liveLease: LiveTransactionStoreLease? + } + + private enum CloseAction: Sendable { + case start(ProcessingReceipt) + case join(ProcessingReceipt) + case complete + } + + private let state: Mutex + private let operations = FiniteOperationRegistry() + private let producerIterations = FiniteOperationRegistry() + private let didSeal = ProcessingReceipt() + + package init(liveLease: LiveTransactionStoreLease? = nil) { + state = Mutex(State(liveLease: liveLease)) + } + + package func validateRunning() throws { + switch phase() { + case .running: + return + case .closing: + throw StoreTransactionError.closing + case .closed: + throw StoreTransactionError.closed + } + } + + package func beginOperation() throws -> FiniteOperationLeases { + try Task.checkCancellation() + switch phase() { + case .running: + guard let leases = operations.beginPair() else { + throw lifecycleError() + } + return leases + case .closing: + throw StoreTransactionError.closing + case .closed: + throw StoreTransactionError.closed + } + } + + package func beginProducerIteration() -> FiniteOperationLease? { + guard case .running = phase() else { return nil } + return producerIterations.begin() + } + + package func close( + shutdown: @escaping @Sendable () async -> Void + ) async { + let action = state.withLock { state -> CloseAction in + switch state.phase { + case .running: + let receipt = ProcessingReceipt() + state.phase = .closing(receipt) + operations.seal() + producerIterations.seal() + return .start(receipt) + case .closing(let receipt): + return .join(receipt) + case .closed: + return .complete + } + } + + switch action { + case .start(let receipt): + didSeal.succeed(()) + Task.detached { [self] in + await shutdown() + finishClose(receipt: receipt) + } + _ = try? await receipt.terminalValue() + case .join(let receipt): + _ = try? await receipt.terminalValue() + case .complete: + return + } + } + + package func waitForOperations() async { + await operations.waitForDrain() + } + + package func waitForProducerIterations() async { + await producerIterations.waitForDrain() + } + + package func waitUntilSealed() async { + _ = try? await didSeal.terminalValue() + } + + package func sealSynchronously() { + let shouldSeal = state.withLock { state in + guard case .running = state.phase else { return false } + state.phase = .closing(ProcessingReceipt()) + operations.seal() + producerIterations.seal() + return true + } + guard shouldSeal else { return } + didSeal.succeed(()) + } + + private func phase() -> Phase { + state.withLock(\.phase) + } + + private func lifecycleError() -> StoreTransactionError { + switch phase() { + case .running, .closing: + .closing + case .closed: + .closed + } + } + + private func finishClose(receipt: ProcessingReceipt) { + let lease = state.withLock { state -> LiveTransactionStoreLease? in + guard case .closing(let activeReceipt) = state.phase, + activeReceipt === receipt + else { + preconditionFailure("Close completed without owning lifecycle shutdown.") + } + state.phase = .closed + defer { state.liveLease = nil } + return state.liveLease + } + lease?.release() + receipt.succeed(()) + } + + deinit { + state.withLock { state in + state.liveLease?.release() + state.liveLease = nil + } + } +} diff --git a/Sources/StoreTransactionKit/StoreEntitlements.swift b/Sources/StoreTransactionKit/StoreEntitlements.swift index 7ac4f64..1043c4d 100644 --- a/Sources/StoreTransactionKit/StoreEntitlements.swift +++ b/Sources/StoreTransactionKit/StoreEntitlements.swift @@ -1,10 +1,10 @@ -/// A complete, ordered projection of the current StoreKit entitlements. +/// A complete, ordered projection of current transaction entitlements. public struct StoreEntitlements: Sendable, Equatable { - /// Verified current entitlements in a stable order. + /// Current transaction snapshots 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. + /// revision-representation UTF-8 bytes ascending. public let transactions: [StoreTransactionSnapshot] package init(transactions: [StoreTransactionSnapshot]) { diff --git a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift index a7f82e6..5774eb3 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift @@ -1,51 +1,60 @@ import StoreKit package extension StoreTransactionSource { - static let live = StoreTransactionSource( - runUpdates: { consume in - for await result in Transaction.updates { - 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 { - do { + static var live: StoreTransactionSource { + StoreTransactionSource( + runUpdates: { beginIteration, consume in + var iterator = Transaction.updates.makeAsyncIterator() + while let lease = beginIteration() { + defer { lease.end() } + guard let result = await iterator.next() else { return } + await consume(LiveTransactionAdapter.delivery(result)) + } + }, + runSubscriptionStatusUpdates: { beginIteration, consume in + var iterator = Product.SubscriptionInfo.Status.updates + .makeAsyncIterator() + while let lease = beginIteration() { + defer { lease.end() } + guard await iterator.next() != nil else { return } + await consume() + } + }, + currentEntitlements: { + var snapshots: [StoreTransactionSnapshot] = [] + var verificationFailures: [StoreTransactionVerificationError] = [] + for await result in Transaction.currentEntitlements { + do { + snapshots.append(try LiveTransactionAdapter.snapshot(result)) + } catch let error as StoreTransactionVerificationError { + verificationFailures.append(error) + } + } + 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] = [] + for await result in Transaction.all(for: productID) { snapshots.append(try LiveTransactionAdapter.snapshot(result)) - } catch let error as StoreTransactionVerificationError { - verificationFailures.append(error) } + return snapshots + }, + synchronize: { + try await AppStore.sync() + }, + purchaseDelivery: { result in + LiveTransactionAdapter.delivery(result) } - 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] = [] - for await result in Transaction.all(for: productID) { - snapshots.append(try LiveTransactionAdapter.snapshot(result)) - } - return snapshots - }, - synchronize: { - try await AppStore.sync() - }, - purchaseDelivery: { result in - LiveTransactionAdapter.delivery(result) - } - ) + ) + } } diff --git a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift index c9e8f40..3339d1c 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift @@ -22,10 +22,12 @@ package struct CurrentEntitlementQueryResult: Sendable { package struct StoreTransactionSource: Sendable { package let runUpdates: @Sendable ( + @Sendable () -> FiniteOperationLease?, @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void package let runSubscriptionStatusUpdates: @Sendable ( + @Sendable () -> FiniteOperationLease?, @Sendable () async -> Void ) async -> Void package let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult @@ -37,10 +39,12 @@ package struct StoreTransactionSource: Sendable { package init( runUpdates: @escaping @Sendable ( + @Sendable () -> FiniteOperationLease?, @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void, runSubscriptionStatusUpdates: @escaping @Sendable ( + @Sendable () -> FiniteOperationLease?, @Sendable () async -> Void ) async -> Void, currentEntitlements: diff --git a/Sources/StoreTransactionKit/StoreKitSource/SyntheticStoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/SyntheticStoreTransactionSource.swift new file mode 100644 index 0000000..6c74c7b --- /dev/null +++ b/Sources/StoreTransactionKit/StoreKitSource/SyntheticStoreTransactionSource.swift @@ -0,0 +1,41 @@ +import Foundation +import StoreKit + +package struct SyntheticStoreTransactionSource: Sendable { + package let source: StoreTransactionSource + + package init( + currentEntitlements: + @escaping @Sendable () async -> [StoreTransactionSnapshot] + ) { + source = StoreTransactionSource( + runUpdates: { _, _ in }, + runSubscriptionStatusUpdates: { _, _ in }, + currentEntitlements: { + CurrentEntitlementQueryResult( + snapshots: await currentEntitlements(), + verificationFailures: [] + ) + }, + queryUnfinished: { [] }, + history: { _ in preconditionFailure() }, + synchronize: { preconditionFailure() }, + purchaseDelivery: { _ in preconditionFailure() } + ) + } +} + +package extension StoreTransactionDelivery { + static func synthetic( + snapshot: StoreTransactionSnapshot, + acknowledge: @escaping @Sendable () async -> Void + ) -> StoreTransactionDelivery { + .verified( + ProcessingEnvelope( + revision: Data(snapshot.jwsRepresentation.utf8), + value: snapshot, + finish: acknowledge + ) + ) + } +} diff --git a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift index 57bb24f..2b93ee6 100644 --- a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift +++ b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift @@ -1,6 +1,6 @@ /// The semantic result of processing a direct StoreKit purchase result. public enum StorePurchaseOutcome: Sendable, Hashable { - /// StoreTransactionKit verified, durably handled, finished, and refreshed the transaction. + /// StoreTransactionKit verified, applied policy, finished, reconciled, and published the transaction. case completed(StoreTransactionSnapshot) /// The purchase is awaiting an external action and may arrive through transaction updates later. diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 2b01604..3ddd2fd 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -1,4 +1,5 @@ import Foundation +import StoreKit /// A store operation that can appear in lifecycle and diagnostic errors. public enum StoreTransactionOperation: Sendable, Hashable { @@ -6,7 +7,7 @@ public enum StoreTransactionOperation: Sendable, Hashable { case processPurchase /// Refreshing the current entitlement projection. - case currentEntitlements + case refreshEntitlements /// Querying transaction history for one product. case history @@ -94,8 +95,17 @@ package struct StoreTransactionFailurePropagation: Sendable { } } -/// An error caused by using a store outside its documented lifecycle. -public enum StoreTransactionError: Error, Sendable, Hashable { +/// An error produced while operating a transaction store. +public enum StoreTransactionError: Error, Sendable { + /// An irreversible StoreKit action that completed before a later operation failed. + public enum CompletedOperation: Sendable, Hashable { + /// The framework finished the exact transaction revision. + case finishedTransaction(StoreTransactionSnapshot) + + /// The framework successfully synchronized purchases with the App Store. + case synchronizedPurchases + } + /// The store has begun its shared close operation and accepts no new work. case closing @@ -105,6 +115,12 @@ public enum StoreTransactionError: Error, Sendable, Hashable { /// StoreKit returned a purchase result unknown to this framework version. case unknownPurchaseResult + /// Automatic handling was requested for a product outside the managed catalog. + case unhandledTransaction( + productID: Product.ID, + productType: Product.ProductType + ) + /// A consumer callback attempted to reenter its own session. /// /// Reentrancy with propagated callback context is rejected because a @@ -113,6 +129,20 @@ public enum StoreTransactionError: Error, Sendable, Hashable { /// 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) + + /// A StoreKit-backed operation was requested from a fixed override store. + case operationUnavailableInOverride( + operation: StoreTransactionOperation + ) + + /// Entitlement refresh failed after an irreversible StoreKit action completed. + /// + /// Retry ``TransactionStore/refreshEntitlements()`` instead of repeating + /// the completed action. + case entitlementRefreshFailed( + after: CompletedOperation, + underlyingError: any Error + ) } package enum StoreTransactionLifecycleError: Error, Sendable { diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md new file mode 100644 index 0000000..16541f5 --- /dev/null +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md @@ -0,0 +1,88 @@ +# Defining subscription access + +Map the Product IDs in one App Store Connect subscription group to values that +describe access in your app. + +## Declare the catalog + +An App Store Connect subscription group can contain several service levels and +several durations at each level. StoreKit owns group-level ordering, duration, +renewal, and billing state. Your app owns the access meaning. + +```swift +import StoreTransactionKit + +enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +enum Plans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" + ) + + enum ProductID: String, Hashable, Sendable { + case tier1_Monthly = "com.example.subscription.tier1.monthly" + case tier1_Yearly = "com.example.subscription.tier1.yearly" + case tier2_Monthly = "com.example.subscription.tier2.monthly" + case tier2_Yearly = "com.example.subscription.tier2.yearly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) + } +} + +let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) +``` + +Use the group ID and Product ID raw values exactly as configured in App Store +Connect. ``AutoRenewableSubscriptionGroup/subscriptions`` is the complete +entitlement-granting declaration; a case that is present only in `ProductID` +doesn't grant typed access. The catalog requires at least one product, nonempty +raw identifiers, and no duplicate raw Product IDs. Several products may grant +the same entitlement. + +The compiler keeps group-specific Product IDs and app entitlements typed. At +runtime, the catalog also validates each matching transaction's auto-renewable +product type and subscription group before publishing access. + +## Read access without blocking the UI + +``TransactionStore/isEntitled(to:)`` performs exact set membership and returns +`false` while access is unavailable. Keep ordinary app content usable and gate +only the paid feature: + +```swift +private var canExportPDF: Bool { + store.isEntitled(to: .tier1) +} +``` + +Use ``TransactionStore/entitlementStatus`` only when the interface needs to +explain why access is unavailable. In `.ready`, an empty +``TransactionStore/activeEntitlements`` set means the query succeeded and no +declared entitlement is active. In `.loading` or `.failed`, the set is `nil`. + +## Override access at composition time + +For a preview, debug build, UI test, or app-defined distribution environment, +create a StoreKit-free store with an exact entitlement set: + +```swift +let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + overridingEntitlements: [ + SubscriptionEntitlement.tier1, + .tier2, + ] +) +``` + +The app owns the condition selecting this initializer. An empty sequence is an +authoritative override with no access. Override mode starts no StoreKit work, +keeps raw entitlements `nil`, and rejects StoreKit-backed operations. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index 9cd3960..2488885 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -1,62 +1,68 @@ # ``StoreTransactionKit`` -Own StoreKit 2 transaction monitoring at application lifetime and finish each -verified transaction only after its durable business effect succeeds. +Centralize verified transaction handling, `Transaction.finish()` authority, +and observable subscription access in one process-owned store. ## Overview -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. It supports -iOS and Mac Catalyst 18.4 and later, macOS 15.4 and later, tvOS 18.4 and -later, watchOS 11.4 and later, and visionOS 2.4 and later. - -Create one store in the application composition root and retain it for the -process lifetime; call ``TransactionStore/close()`` only from controlled -shutdown and test lifecycles. Supply an idempotent transaction handler that -commits the app's business effect before returning. The app defines a -string-backed entitlement identifier type; -``TransactionStore/activeEntitlements`` then exposes an optional typed set -derived from StoreKit's verified current entitlements. Pass direct results -from custom purchase UI into ``TransactionStore/process(_:)``. - - describes the full model: delivery -paths and deduplication, unfinished-transaction reconciliation before each -entitlement publication, verification-failure reporting, and how the -projection behaves across upgrades, revocations, and grace periods. - -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, the -concrete purchase scene or window, raw `Product.SubscriptionInfo.Status` -interpretation for renewal UI, and, where the API is available, -`PurchaseIntent.intents` handling for purchases that begin outside the app. - -> 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. +StoreKit can deliver the same purchase through a direct +`Product.PurchaseResult`, `Transaction.updates`, and unfinished-transaction +reconciliation. ``TransactionStore`` joins those paths by exact transaction +revision, applies one handling policy, and publishes raw and app-defined +entitlement state together on the main actor. + +Define one ``AutoRenewableSubscriptionCatalog`` from the Product IDs in an App +Store Connect subscription group. Several products, such as monthly and yearly +durations, may grant the same app entitlement. Create one live store at the +application composition root and inject that instance into feature code. + +Use ``TransactionStore/isEntitled(to:)`` for feature gating. When UI needs to +explain unavailable access, inspect ``TransactionStore/entitlementStatus`` to +distinguish the initial load, failure, a ready empty set, and an app-supplied +override. + +The optional ``TransactionStoreDelegate`` owns only app-specific durable +effects and background-failure reactions. Without a delegate, the default +policy finishes catalog-managed auto-renewable subscriptions. StoreTransactionKit +still verifies deliveries, reconciles unfinished work, validates catalog +metadata, orders history, restores purchases, reports background failures, and +drains admitted work during explicit shutdown. + +Start with , then read + before adding an app-owned transaction +effect. shows StoreKit-free ViewModel tests +that use the production store data flow. ## Topics ### Essentials +- - +- -### Creating a store +### Declaring subscriptions + +- ``AutoRenewableSubscriptionCatalog`` +- ``AutoRenewableSubscriptionGroup`` +- ``SubscriptionGroupID`` +- ``StoreSubscription`` +- ``StoreSubscriptionsBuilder`` + +### Reading entitlement state - ``TransactionStore`` +- ``EntitlementStatus`` - ``StoreEntitlements`` -### Processing purchases +### Processing transactions +- ``TransactionStoreDelegate`` +- ``StoreTransactionHandlingPolicy`` - ``StorePurchaseOutcome`` - ``StoreTransactionSnapshot`` -### Diagnosing lifecycle and background work +### Failures and lifecycle - ``StoreTransactionError`` - ``StoreTransactionVerificationError`` diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md new file mode 100644 index 0000000..171e48c --- /dev/null +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md @@ -0,0 +1,111 @@ +# Testing subscription access + +Drive the production transaction and entitlement pipeline without a `.storekit` +configuration. + +## Add the testing product + +Keep the production target dependent only on `StoreTransactionKit`. Add both +`StoreTransactionKit` and `StoreTransactionKitTesting` to the test target, then +import both modules explicitly: + +```swift +import StoreTransactionKit +import StoreTransactionKitTesting +import Testing +``` + +The testing product depends on the production module internally, but does not +re-export it. Explicit imports keep production and test-only dependencies clear. + +## Drive a typed purchase + +```swift +@Test +@MainActor +func subscriptionUpdatesViewModel() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog + ) { harness in + let viewModel = NotesViewModel(store: harness.store) + + #expect(!viewModel.canExportPDF) + + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + + #expect(viewModel.canExportPDF) + } +} +``` + +The scoped harness starts with a ready empty entitlement set and closes its +store before returning or throwing. `purchase(_:,in:)` validates the supplied +group declaration and Product ID before admission, then returns after policy, +synthetic acknowledgement, reconciliation, and observable-state publication. +No fixed delay or global “idle” wait is needed. + +A later purchase in the same group replaces the active synthetic product. The +harness models immediate current access; it does not simulate renewal timing, +billing retry, upgrade scheduling, expiration, or revocation. + +The returned ``StoreTransactionSnapshot`` is synthetic. Its +``StoreTransactionSnapshot/jwsRepresentation`` is a deterministic sentinel, not +a signed JWS, and its transaction identifier is local to that harness. Use +app-hosted StoreKit tests for verification, live adapter behavior, StoreKit Test +sessions, renewals, restore UI, history, and revocation. + +## Control consumer-owned time + +The harness transaction pipeline has no delay or retry timer. Inject +`TransactionStoreTestClock` into the app component that owns time, such as a +ViewModel or delegate, and advance it explicitly. A harness purchase receipt +still marks entitlement publication; clock advancement alone does not imply +that transaction work is complete. + +```swift +final class DelayedTransactionDelegate: TransactionStoreDelegate { + private let clock: any Clock + + init(clock: any Clock) { + self.clock = clock + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + try await clock.sleep(for: .seconds(30)) + return .finish + } +} + +let clock = TransactionStoreTestClock() +let delegate = DelayedTransactionDelegate(clock: clock) + +try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate +) { harness in + let viewModel = NotesViewModel(store: harness.store) + let purchase = Task { @MainActor in + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + } + + try await clock.waitUntilPendingSleepCount(reaches: 1) + #expect(!viewModel.canExportPDF) + + clock.advance(by: .seconds(30)) + try await purchase.value + + #expect(viewModel.canExportPDF) +} +``` + +`waitUntilPendingSleepCount(reaches:)` is a continuation-backed registration +barrier, so the test needs neither a fixed delay nor guessed `Task.yield()` +counts. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md index 14cf719..2f9c153 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -1,131 +1,160 @@ # Understanding transaction handling -How StoreTransactionKit turns StoreKit 2 deliveries into durable, observable -entitlement state — and what that model asks of your handler and your UI. - -## Delivery paths and deduplication - -Every purchase converges on one FIFO transaction processor, whichever path it -arrives by: a direct `Product.PurchaseResult` passed to -``TransactionStore/process(_:)``, a delivery from `Transaction.updates`, or an -unfinished transaction reconciled at startup and on entitlement refreshes. - -Each delivery is identified by its exact JWS revision. When the same revision -arrives through several paths — StoreKit delivers launch-time unfinished -transactions through `Transaction.updates` as well — concurrent deliveries -join the in-flight attempt and completed revisions are suppressed. The -suppression cache is process-local and bounded, which is why the handler -contract is at-least-once rather than exactly-once: the handler must stay -idempotent across process launches and cache eviction. - -## Reconciliation before publication - -Startup and every entitlement refresh query `Transaction.unfinished` and -durably handle each verified delivery — including consumables — before the -entitlement projection is published, so published entitlement state never -runs ahead of the durable ledger for transactions this device still reports -as unfinished. A transaction that was already finished elsewhere — on another -device, or by a previous process — can appear in the projection without a -local handler invocation; while the app is running, purchases completed on -other devices reach the handler through `Transaction.updates`. - -When the handler throws, the transaction is not finished and that refresh (or -startup readiness) fails with the handler's error. The failed work is not -retried in a loop; the next refresh, or the next arriving transaction, opens a -new attempt and retries it. Because public operations other than -``TransactionStore/close()`` wait for the startup attempt, a handler that -hangs blocks the store — return or throw promptly and let a later refresh -retry. - -## Verification - -Snapshots exist only for transactions that StoreKit verified; the handler and -the projection never observe unverified data. An unverified purchase result -passed to ``TransactionStore/process(_:)`` throws a -``StoreTransactionVerificationError`` to that caller and is not reported to -the failure callback. Unverified elements accepted by background monitoring, -reconciliation, or projection-query paths are reported through the failure -callback instead: - -- From `Transaction.updates`, with source - ``StoreTransactionBackgroundFailure/Source/updates``. -- From `Transaction.unfinished` reconciliation, with source - ``StoreTransactionBackgroundFailure/Source/unfinished``. -- From a current-entitlement query, with source - ``StoreTransactionBackgroundFailure/Source/currentEntitlementVerification``; - the element is omitted and the verified remainder still publishes. - -## The entitlement projection - -``TransactionStore/entitlements`` is `nil` until the first query resolves and -non-`nil` empty when nothing is currently entitled. Its transactions follow -the stable order documented on ``StoreEntitlements/transactions``. StoreKit -itself excludes refunded and revoked transactions from current entitlements; -the store additionally excludes transactions superseded by a subscription -upgrade from ``TransactionStore/activeEntitlements`` while keeping them in the -complete snapshot. - -The projection refreshes at startup, after each processed transaction, on -subscription status changes, and on explicit -``TransactionStore/refreshEntitlements()`` or -``TransactionStore/restorePurchases()`` calls. - -Two subscription nuances live outside this projection. A subscription in a -billing grace period stays entitled while its snapshot's `expirationDate` is -already past, so render renewal and billing state from -`Product.SubscriptionInfo.Status` rather than from dates. And entitlement -identifiers map 1:1 to product identifiers, so gate access on the tier set or -on `StoreTransactionSnapshot.subscriptionGroupID` when any tier of a group -grants the same access. - -## Failure reporting and readiness - -Failure delivery and readiness state are related but separate contracts. -Public operations deliver terminal errors to their attached callers by -throwing. Background-owned physical work reports a failure once through the -failure callback as a ``StoreTransactionBackgroundFailure``. This includes -background deliveries, unfinished and current-entitlement verification, and -direct operations whose every waiting caller cancelled. - -Some public operations contain child work whose physical attempt may already -be owned by another producer. A failed `Transaction.unfinished` -reconciliation attempt both fails the enclosing startup or refresh and is -reported once by that attempt's reporting owner. Its source is -``StoreTransactionBackgroundFailure/Source/unfinished`` when reconciliation -owns the attempt, or the existing owner's source — such as -``StoreTransactionBackgroundFailure/Source/updates`` — when reconciliation -joins in-flight work. The enclosing operation propagates the underlying error -but doesn't report that physical failure a second time. - -The initial readiness attempt has no throwing public caller. -``TransactionStore/startupError`` reflects its error for observable UI state. -A failed startup unfinished reconciliation therefore appears both as one -owner-sourced report and as `startupError`; that report isn't necessarily -`.unfinished` when the attempt was already in flight. An entitlement query -failure with no separate reporting owner appears only as `startupError`. A -later successful entitlement refresh — which also retries failed unfinished -work — clears the property; call -``TransactionStore/refreshEntitlements()`` from a retry affordance in the UI. - -The failure callback receives admitted failures serially and losslessly with -backpressure. Every reporting path waits for the callback to return, and -``TransactionStore/close()`` drains admitted callbacks. Record or enqueue each -failure promptly instead of waiting indefinitely. - -## Lifecycle - -Create one store per process. A second store would run its own listeners and -hold independent `finish()` authority over the same transactions, so one -store's handler failure could be masked by the other store finishing first. - -``TransactionStore/close()`` stops the producers and drains every accepted -operation and callback; dropping the last reference is not an awaitable -shutdown. Production apps normally retain the store for the process lifetime -and never call it. - -The injected callbacks must not call back into the same store, directly or -through an awaited child or detached task: the callback runs on the same -worker the re-entrant operation would wait for, so the call becomes a -dependency cycle. Propagated-context reentrancy is rejected with -``StoreTransactionError/reentrantOperation(operation:)``; a detached task -escapes that guard but still forms the cycle. +Follow a transaction from admission through policy, finishing, entitlement +publication, failure ownership, and shutdown. + +## Exact-revision processing + +A direct `Product.PurchaseResult`, `Transaction.updates`, and +`Transaction.unfinished` can expose the same transaction. StoreTransactionKit +identifies a live delivery by the exact verified JWS revision. Concurrent +deliveries of that revision join one physical attempt instead of repeating +policy or `finish()`. + +After finishing, the revision enters a bounded process-local cache that +suppresses nearby redelivery. The cache is neither durable nor an app business +ledger: process launch or eviction can present the revision again. If a +``TransactionStoreDelegate`` applies an app-owned effect, make that effect +durably idempotent for the business event. Transaction ID alone is insufficient +because a later signed revision can describe a revocation or another change. + +## Policy and finishing + +The catalog validates and classifies every verified transaction before asking +the delegate for ``TransactionStoreDelegate/decidePolicy(for:)``: + +- A declared auto-renewable subscription with matching group metadata is + managed. ``StoreTransactionHandlingPolicy/automatic`` and + ``StoreTransactionHandlingPolicy/finish`` both allow finishing it. +- A product outside the catalog's group is unmanaged. `.automatic` throws + ``StoreTransactionError/unhandledTransaction(productID:productType:)``; + `.finish` allows finishing only after the app has durably handled it. +- Contradictory metadata or an undeclared current product inside the managed + group fails catalog validation before the delegate runs and is never + finished. + +If the delegate throws, the framework does not finish the transaction or start +its causal entitlement refresh. A later independent StoreKit delivery opens a +new attempt; the framework adds no timer or retry loop. + +For a successful purchase, +``TransactionStore/process(_:)`` returns +``StorePurchaseOutcome/completed(_:)`` only after policy, `finish()`, unfinished +reconciliation, catalog projection, and main-actor publication complete. +If the refresh fails after `finish()`, the method instead throws +``StoreTransactionError/entitlementRefreshFailed(after:underlyingError:)`` with +``StoreTransactionError/CompletedOperation/finishedTransaction(_:)``. Retry +``TransactionStore/refreshEntitlements()``; do not repeat the completed action. + +The same boundary applies to restore. A synchronization failure is returned +directly. If `AppStore.sync()` succeeds and its following refresh fails, +``TransactionStore/restorePurchases()`` throws the completed-operation error +with ``StoreTransactionError/CompletedOperation/synchronizedPurchases``. + +## Reconciliation and publication + +The initial load and every entitlement refresh handle all verified unfinished +transactions before publishing `Transaction.currentEntitlements`. This keeps +the observable projection from running ahead of unfinished durable work. +Current entitlements that were finished by another process or device may still +appear without a local policy invocation. + +``TransactionStore/entitlements``, +``TransactionStore/activeEntitlements``, and +``TransactionStore/entitlementStatus`` derive from one availability value: + +| Status | Raw entitlements | Typed entitlements | +| --- | --- | --- | +| ``EntitlementStatus/loading`` | `nil` | `nil` | +| ``EntitlementStatus/failed(_:)`` | `nil` | `nil` | +| ``EntitlementStatus/ready`` | authoritative collection | authoritative set | +| ``EntitlementStatus/overridden`` | `nil` | authoritative app-supplied set | + +An empty ready or overridden set means no entitlement; it is not unresolved. +A transient query or transaction-handling failure preserves an existing ready +snapshot. A catalog contradiction clears raw and typed projections and moves +to `.failed`, because stale typed access may be unsafe. A later successful +refresh publishes a new ready snapshot. + +The catalog maps declared Product IDs to app entitlement values. It does not +copy StoreKit group levels or subscription periods into the entitlement type, +and multiple Product IDs may map to the same value. A transaction that StoreKit +marks as upgraded grants no typed access. The raw projection otherwise mirrors +the verified current-entitlement items StoreKit returns, including products +outside the managed group. + +StoreKit excludes revoked or refunded transactions from current entitlements. +For billing retry, grace period, and renewal presentation, use +`Product.SubscriptionInfo.Status`; do not infer subscription status only from +snapshot dates. + +## Failure ownership + +An admitted physical failure has one delivery owner. While a direct caller +remains attached, the operation throws to that caller and does not also send the +same failure through the background callback. If every attached caller cancels +after admission, the physical work continues and the last abandonment transfers +failure ownership to the background path. + +Background-owned failures are logged once and, when supplied, delivered once to +``TransactionStoreDelegate/didFail(with:)``. This includes update processing, +unfinished reconciliation, current-entitlement verification failures, and +abandoned direct operations. The callback is a notification after the failure; +it cannot change policy or request retry. + +Failures that affect observable entitlement state commit that state before +notification begins. Notifications are serialized with backpressure and +``TransactionStore/close()`` drains every admitted notification. Policy +decisions are also serialized, but policy and notification execution may +overlap. + +An unverified direct purchase throws ``StoreTransactionVerificationError`` to +its caller. An unverified background element is reported instead. Unverified +current-entitlement elements are omitted while the verified remainder can still +publish. + +## Admission and cancellation + +`process(_:)`, `refreshEntitlements()`, `history(for:)`, and +`restorePurchases()` check cancellation before crossing their admission +boundary. Pre-admission cancellation starts no physical work. After admission, +caller cancellation abandons only that caller's wait: policy, finishing, +refresh, publication, and any required background reporting continue. + +When closing has sealed admission, a new operation throws +``StoreTransactionError/closing``. After shutdown completes, it throws +``StoreTransactionError/closed``. The override initializer has no StoreKit +backend and its StoreKit operations throw +``StoreTransactionError/operationUnavailableInOverride(operation:)``. + +Delegate methods must not call an admission-bearing operation on the same +store. Such a call can wait behind the callback that is making it. Reentry with +inherited callback context throws +``StoreTransactionError/reentrantOperation(operation:)``. A detached task does +not inherit that detection context, but awaiting one from the callback still +creates the same dependency cycle and must also be avoided. + +## Close and deinitialization + +Create one live ``TransactionStore`` per process. A second live initializer is +a programmer error while the first store owns transaction monitoring and +finishing authority. Override stores and StoreTransactionKitTesting harnesses +do not consume that live-store lease. + +The first ``TransactionStore/close()`` call atomically seals public and producer +admission and starts one terminal shutdown. Concurrent close calls join the same +completion, and caller cancellation does not abandon it. Shutdown stops StoreKit +producers, drains admitted producer elements and public operations, drains +transaction, refresh, restore, and failure workers, releases the delegate, and +then releases the live-store lease. The last entitlement state remains readable. + +Calling `close()` from a callback owned by the same store throws a reentrancy +error. Production apps normally retain the store for process lifetime; explicit +close is primarily for controlled shutdown and tests. + +An isolated deinitializer is only a synchronous containment backstop. It seals +new admission and signals cancellation to framework-owned work, but cannot await +callbacks or claim a successful drain. Admitted work retains lifecycle authority +until it terminates, so another live store may not be constructible immediately +after an unclosed store is released. `close()` is the only awaitable replacement +boundary. diff --git a/Sources/StoreTransactionKit/StoreTransactionSession.swift b/Sources/StoreTransactionKit/StoreTransactionSession.swift deleted file mode 100644 index 1f06574..0000000 --- a/Sources/StoreTransactionKit/StoreTransactionSession.swift +++ /dev/null @@ -1,294 +0,0 @@ -import Foundation -import StoreKit - -/// The package-owned StoreKit 2 transaction and entitlement session. -/// -/// Create exactly one session in the application's process composition root and -/// call ``start()`` as early as the app's durable dependencies are available. -/// The session monitors StoreKit updates and startup unfinished transactions; -/// presentation of purchase UI remains the application's responsibility. -/// -/// Explicitly call ``close()`` from controlled shutdown and test lifecycles. -/// Dropping the last reference is not an awaitable shutdown mechanism. -package actor StoreTransactionSession { - private struct Configuration: Sendable { - 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 - } - - private enum State: Sendable { - case initialized(Configuration) - case running(StoreTransactionRuntime) - case closing(Task) - case closed - } - - private let sessionID: UUID - private var state: State - - /// Creates a StoreKit transaction session. - /// - /// - Parameters: - /// - handleTransaction: Applies the durable business effect for a verified - /// 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 owned by process background work, - /// including background deliveries, reconciliation verification, and - /// direct operations abandoned by every caller. Admitted failures are - /// delivered serially and losslessly with backpressure. The callback - /// must return promptly; ``close()`` waits for every admitted callback - /// to finish. - /// - /// None of the callbacks may call back into the same session, directly or - /// through an awaited child or detached task. - package init( - sessionID: UUID = UUID(), - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - entitlementsDidChange: - @escaping @Sendable (StoreEntitlements) async -> Void = { _ in }, - reportFailure: - @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void - ) { - self.sessionID = sessionID - self.state = .initialized( - Configuration( - source: .live, - handleTransaction: handleTransaction, - entitlementsDidChange: entitlementsDidChange, - entitlementRefreshDidSucceed: { _ in }, - reportFailure: reportFailure - )) - } - - package init( - sessionID: UUID = UUID(), - source: StoreTransactionSource, - handleTransaction: - @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 - ) { - self.sessionID = sessionID - self.state = .initialized( - Configuration( - source: source, - handleTransaction: handleTransaction, - entitlementsDidChange: entitlementsDidChange, - entitlementRefreshDidSucceed: entitlementRefreshDidSucceed, - reportFailure: reportFailure - )) - } - - /// Starts transaction monitoring, reconciles unfinished work, and publishes initial entitlements. - /// - /// 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. - /// - /// If the waiting caller cancels after startup begins, the process-owned - /// session remains running. Its lifecycle owner must call ``close()``. - /// - /// - Returns: The initial entitlement readiness value. - /// - 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 - case .closing: throw StoreTransactionError.closing - case .closed: throw StoreTransactionError.closed - case .initialized: preconditionFailure() - } - } - - let runtime = StoreTransactionRuntime( - sessionID: sessionID, - source: configuration.source, - handleTransaction: configuration.handleTransaction, - entitlementsDidChange: configuration.entitlementsDidChange, - entitlementRefreshDidSucceed: - configuration.entitlementRefreshDidSucceed, - reportFailure: configuration.reportFailure - ) - state = .running(runtime) - - let readiness = try await runtime.readiness() - guard case .running(let activeRuntime) = state, - activeRuntime === runtime - else { - throw StoreTransactionError.closing - } - return readiness - } - - /// Processes the result of purchase UI presented by the application. - /// - /// A verified success enters the same durable FIFO used by updates and - /// unfinished transactions. Pending and user-cancelled results are returned - /// as values and never reported as failures. - /// - /// - Parameter result: The purchase result returned by StoreKit. - /// - Returns: The semantic outcome after any required durable handling, - /// finish, and entitlement refresh. - /// - Throws: Verification, durable handler, entitlement refresh, lifecycle, - /// callback reentrancy, or caller cancellation errors. - package func process( - _ result: Product.PurchaseResult - ) async throws -> StorePurchaseOutcome { - let runtime = try runningRuntime(operation: .processPurchase) - guard let leases = runtime.beginOperation() else { - throw StoreTransactionError.closing - } - return try await runtime.process(result, leases: leases) - } - - /// Refreshes and returns the complete current entitlement projection. - /// - /// Concurrent refresh reservations are coalesced only when they precede the - /// same physical query cutoff. A changed result is returned after its ordered - /// callback completes. - /// - /// - Returns: The current entitlement publication. - /// - Throws: A StoreKit verification or query error, a lifecycle or callback - /// reentrancy error, or `CancellationError` for an abandoned waiter. - package func currentEntitlements() async throws -> StoreEntitlements { - let runtime = try runningRuntime(operation: .currentEntitlements) - guard let leases = runtime.beginOperation() else { - throw StoreTransactionError.closing - } - return try await runtime.currentEntitlements(leases: leases) - } - - /// Returns verified transaction history for a product in newest-first order. - /// - /// The query is all-or-nothing: one unverified element fails the complete - /// result. Revoked and refunded transactions remain in this audit projection. - /// - /// - Parameter productID: The StoreKit product identifier to query. - /// - Returns: Verified snapshots ordered by purchase date, signed date, and - /// 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( - for productID: Product.ID - ) async throws -> [StoreTransactionSnapshot] { - let runtime = try runningRuntime(operation: .history) - guard let leases = runtime.beginOperation() else { - throw StoreTransactionError.closing - } - return try await runtime.history(for: productID, leases: leases) - } - - /// Explicitly synchronizes App Store purchases and refreshes entitlements. - /// - /// Call this method only from a user-initiated restore action because - /// ``StoreKit/AppStore/sync()`` can present authentication UI. Concurrent - /// callers share one synchronization operation. - /// - /// - Returns: The entitlement publication from a query reserved after sync succeeds. - /// - Throws: A synchronization, verification, query, lifecycle, callback - /// reentrancy, or caller cancellation error. StoreKit may throw - /// `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 { - throw StoreTransactionError.closing - } - return try await runtime.restorePurchases(leases: leases) - } - - /// Stops producers and drains every operation and callback accepted before closing. - /// - /// The first caller creates one shared noncancellable close completion; - /// concurrent callers join it. Calling this method before ``start()`` closes - /// the session without acquiring StoreKit resources. Calling it again after - /// closure succeeds without effect. - /// - /// - Throws: A callback reentrancy error when one of this session's injected - /// callbacks attempts to close the session that is executing it. - package func close() async throws { - try rejectReentrancy(operation: .close) - switch state { - case .initialized: - state = .closed - case .running(let runtime): - let closeTask = Task { - await runtime.close() - } - state = .closing(closeTask) - await closeTask.value - state = .closed - case .closing(let closeTask): - await closeTask.value - state = .closed - case .closed: - return - } - } - - private func runningRuntime( - operation: StoreTransactionOperation - ) throws -> StoreTransactionRuntime { - try rejectReentrancy(operation: operation) - switch state { - case .initialized: - throw StoreTransactionLifecycleError.notStarted - case .running(let runtime): - return runtime - case .closing: - throw StoreTransactionError.closing - case .closed: - throw StoreTransactionError.closed - } - } - - private func rejectReentrancy( - operation: StoreTransactionOperation - ) throws { - if let invocation = StoreTransactionCallbackContext.current, - invocation.sessionID == sessionID - { - throw StoreTransactionError.reentrantOperation(operation: operation) - } - } - - isolated deinit { - switch state { - case .running(let runtime): - runtime.cancelSynchronously() - case .closing(let closeTask): - closeTask.cancel() - case .initialized, .closed: - break - } - } -} diff --git a/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift b/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift index fcfca65..31d634f 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift @@ -1,11 +1,12 @@ import Foundation import StoreKit -/// An immutable projection of a transaction that StoreKit verified. +/// An immutable projection of a verified or test-harness transaction. /// -/// StoreTransactionKit creates snapshots only after StoreKit verification -/// succeeds. A snapshot never owns the underlying `Transaction` and -/// exposes no authority to finish it. +/// StoreTransactionKit creates live snapshots only after StoreKit verification +/// succeeds. StoreTransactionKitTesting creates deterministic synthetic +/// snapshots for its transaction-store harness. 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. public let id: UInt64 @@ -75,11 +76,13 @@ public struct StoreTransactionSnapshot: Sendable, Hashable { /// The date the App Store signed this transaction revision. public let signedDate: Date - /// The exact JWS Compact Serialization that StoreKit verified. + /// The exact verified JWS, or a deterministic testing revision sentinel. /// /// StoreTransactionKit uses the UTF-8 bytes of this value as its /// process-local delivery revision. Consumers must not treat it as a secret - /// or persist it as the sole business idempotency key. + /// or persist it as the sole business idempotency key. Synthetic snapshots + /// use `StoreTransactionKitTesting.synthetic.` instead of a + /// signed JWS; their transaction identifiers restart for each harness. public let jwsRepresentation: String package init( diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift new file mode 100644 index 0000000..b40438d --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift @@ -0,0 +1,142 @@ +import StoreKit + +/// A validated mapping from one auto-renewable subscription group to app entitlements. +/// +/// The catalog treats ``AutoRenewableSubscriptionGroup/subscriptions`` as the +/// complete declaration of product identifiers that can grant typed access and +/// validates StoreKit product type and group metadata before publication. +public struct AutoRenewableSubscriptionCatalog: Sendable +where Entitlement: Hashable & Sendable { + package let subscriptionGroupID: SubscriptionGroupID + + private let declaringGroupTypeID: ObjectIdentifier + private let entitlementsByProductID: [Product.ID: Entitlement] + + /// Creates and validates a catalog from one auto-renewable subscription group. + /// + /// An empty declaration, empty product identifier, or duplicate raw product + /// identifier is a programmer error. Multiple products may grant the same + /// entitlement. + public init(_ groupType: Group.Type) + where Group: AutoRenewableSubscriptionGroup { + let subscriptions = Group.subscriptions + + precondition( + !subscriptions.isEmpty, + "An auto-renewable subscription group must declare at least one product." + ) + + var entitlementsByProductID: [Product.ID: Entitlement] = [:] + entitlementsByProductID.reserveCapacity(subscriptions.count) + + for subscription in subscriptions { + let productID = subscription.id.rawValue + + precondition( + !productID.isEmpty, + "A subscription product identifier must not be empty." + ) + precondition( + entitlementsByProductID[productID] == nil, + "A subscription product identifier must not be declared more than once: \(productID)" + ) + + entitlementsByProductID[productID] = subscription.entitlement + } + + subscriptionGroupID = Group.id + declaringGroupTypeID = ObjectIdentifier(groupType) + self.entitlementsByProductID = entitlementsByProductID + } + + package func activeEntitlements( + in entitlements: StoreEntitlements + ) throws(AutoRenewableSubscriptionCatalogError) -> Set { + var activeEntitlements: Set = [] + + for transaction in entitlements.transactions { + switch try validatedTransaction(transaction) { + case let .declared(entitlement): + if !transaction.isUpgraded { + activeEntitlements.insert(entitlement) + } + + case .retiredUpgraded, .unmanaged: + continue + } + } + + return activeEntitlements + } + + package func classification( + of transaction: StoreTransactionSnapshot + ) throws(AutoRenewableSubscriptionCatalogError) + -> AutoRenewableSubscriptionClassification + { + switch try validatedTransaction(transaction) { + case .declared, .retiredUpgraded: + .managed + + case .unmanaged: + .unmanaged + } + } + + package func isDeclared(by groupType: Any.Type) -> Bool { + declaringGroupTypeID == ObjectIdentifier(groupType) + } + + package func contains(productID: Product.ID) -> Bool { + entitlementsByProductID[productID] != nil + } + + private func validatedTransaction( + _ transaction: StoreTransactionSnapshot + ) throws(AutoRenewableSubscriptionCatalogError) -> ValidatedTransaction { + if let entitlement = entitlementsByProductID[transaction.productID] { + guard transaction.productType == .autoRenewable else { + throw AutoRenewableSubscriptionCatalogError.productTypeMismatch( + productID: transaction.productID, + actual: transaction.productType + ) + } + + guard transaction.subscriptionGroupID == subscriptionGroupID.rawValue else { + throw AutoRenewableSubscriptionCatalogError.subscriptionGroupMismatch( + productID: transaction.productID, + expected: subscriptionGroupID, + actual: transaction.subscriptionGroupID + ) + } + + return .declared(entitlement) + } + + guard transaction.subscriptionGroupID == subscriptionGroupID.rawValue else { + return .unmanaged + } + + guard transaction.isUpgraded else { + throw AutoRenewableSubscriptionCatalogError.undeclaredProduct( + productID: transaction.productID, + subscriptionGroupID: subscriptionGroupID + ) + } + + guard transaction.productType == .autoRenewable else { + throw AutoRenewableSubscriptionCatalogError.productTypeMismatch( + productID: transaction.productID, + actual: transaction.productType + ) + } + + return .retiredUpgraded + } + + private enum ValidatedTransaction { + case declared(Entitlement) + case retiredUpgraded + case unmanaged + } +} diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift new file mode 100644 index 0000000..3ce1fd4 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift @@ -0,0 +1,46 @@ +import Foundation +import StoreKit + +/// An inconsistency between a transaction snapshot and a subscription catalog. +public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { + /// A current product in the managed group has no catalog declaration. + case undeclaredProduct( + productID: Product.ID, + subscriptionGroupID: SubscriptionGroupID + ) + + /// A catalog product isn't an auto-renewable subscription. + case productTypeMismatch( + productID: Product.ID, + actual: Product.ProductType + ) + + /// A catalog product belongs to a different subscription group. + case subscriptionGroupMismatch( + productID: Product.ID, + expected: SubscriptionGroupID, + actual: String? + ) + + /// A localized description of the catalog inconsistency. + public var errorDescription: String? { + switch self { + case let .undeclaredProduct(productID, subscriptionGroupID): + "Product \(productID) is not declared in subscription group " + + "\(subscriptionGroupID.rawValue)." + + case let .productTypeMismatch(productID, actual): + "Product \(productID) has type \(actual); expected an " + + "auto-renewable subscription." + + case let .subscriptionGroupMismatch(productID, expected, actual): + "Product \(productID) belongs to subscription group " + + (actual ?? "nil") + + "; expected \(expected.rawValue)." + } + } +} + +package struct StoreTransactionCatalogFailure: Error, Sendable { + package let error: AutoRenewableSubscriptionCatalogError +} diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift new file mode 100644 index 0000000..bd2b1f3 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift @@ -0,0 +1,7 @@ +package enum AutoRenewableSubscriptionClassification: + Equatable, + Sendable +{ + case managed + case unmanaged +} diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift new file mode 100644 index 0000000..4b1a63a --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift @@ -0,0 +1,30 @@ +/// A typed declaration of one App Store auto-renewable subscription group. +/// +/// The group binds typed product identifiers to app-domain entitlement values. +/// StoreKit continues to own subscription duration and upgrade or downgrade +/// ordering; the entitlement type describes access in the app. +public protocol AutoRenewableSubscriptionGroup { + /// The app-defined access value granted by the group's products. + associatedtype Entitlement: Hashable & Sendable + + /// A typed product identifier declared by this group. + associatedtype + ProductID: + RawRepresentable & Hashable & Sendable + + /// The group's identifier in App Store Connect. + static var id: SubscriptionGroupID { get } + + /// The complete set of declared products and the entitlement each one grants. + @StoreSubscriptionsBuilder< + Self.ProductID, + Self.Entitlement + > + static var subscriptions: Self.StoreSubscriptions { get } +} + +public extension AutoRenewableSubscriptionGroup { + /// The concrete collection produced by the subscription builder. + typealias StoreSubscriptions = + [StoreSubscription] +} diff --git a/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift b/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift new file mode 100644 index 0000000..9763654 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift @@ -0,0 +1,23 @@ +/// A product declaration and the app entitlement it grants. +/// +/// Monthly and yearly products may intentionally grant the same entitlement. +public struct StoreSubscription: Sendable +where + ProductID: RawRepresentable & Hashable & Sendable, + Entitlement: Hashable & Sendable +{ + /// The product identifier configured in App Store Connect. + public let id: ProductID + + /// The app-defined entitlement granted by the product. + public let entitlement: Entitlement + + /// Declares the entitlement granted by a subscription product. + public init( + _ id: ProductID, + entitlement: Entitlement + ) { + self.id = id + self.entitlement = entitlement + } +} diff --git a/Sources/StoreTransactionKit/Subscriptions/StoreSubscriptionsBuilder.swift b/Sources/StoreTransactionKit/Subscriptions/StoreSubscriptionsBuilder.swift new file mode 100644 index 0000000..87a7f12 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/StoreSubscriptionsBuilder.swift @@ -0,0 +1,25 @@ +/// Builds the subscription declarations for an auto-renewable subscription group. +@resultBuilder +public struct StoreSubscriptionsBuilder +where + ProductID: RawRepresentable & Hashable & Sendable, + Entitlement: Hashable & Sendable +{ + public typealias Element = + StoreSubscription + + /// Adds one subscription declaration to the group. + public static func buildExpression( + _ expression: Element + ) -> Element { + expression + } + + /// Builds a nonempty subscription declaration. + public static func buildBlock( + _ first: Element, + _ rest: Element... + ) -> [Element] { + [first] + rest + } +} diff --git a/Sources/StoreTransactionKit/Subscriptions/SubscriptionGroupID.swift b/Sources/StoreTransactionKit/Subscriptions/SubscriptionGroupID.swift new file mode 100644 index 0000000..6428a93 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/SubscriptionGroupID.swift @@ -0,0 +1,19 @@ +/// The identifier of an auto-renewable subscription group in App Store Connect. +public struct SubscriptionGroupID: + RawRepresentable, + Hashable, + Sendable +{ + /// The identifier configured in App Store Connect. + public let rawValue: String + + /// Creates a subscription group identifier from its App Store Connect value. + public init(rawValue: String) { + precondition( + !rawValue.isEmpty, + "A subscription group identifier must not be empty." + ) + + self.rawValue = rawValue + } +} diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index 3cd518d..14d1d56 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -1,238 +1,404 @@ -import Foundation import Observation import StoreKit -/// An observable, process-owned StoreKit store. +/// An observable, process-owned StoreKit transaction and entitlement store. /// -/// 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 -/// ``activeEntitlements``. The complete verified projection remains available -/// through ``entitlements`` so identifiers outside that app-defined type are -/// never hidden. -/// -/// describes the delivery, -/// reconciliation, and failure-reporting model behind this type. +/// A live store monitors StoreKit deliveries, owns `Transaction.finish()` +/// authority, and publishes raw and app-defined entitlement state together on +/// the main actor. Create one live instance at the application composition root +/// and retain it for the process lifetime. @MainActor @Observable -public final class TransactionStore -where - EntitlementID: RawRepresentable & Hashable & Sendable, - EntitlementID.RawValue == String -{ - /// The latest verified current-entitlement projection. +public final class TransactionStore +where Entitlement: Hashable & Sendable { + private enum EntitlementAvailability { + case loading + case failed(any Error) + case ready( + entitlements: StoreEntitlements, + activeEntitlements: Set + ) + case overridden(activeEntitlements: Set) + } + + private enum Backend: Sendable { + case liveRuntime( + StoreTransactionRuntime, + TransactionStoreLifecycle + ) + case syntheticRuntime( + StoreTransactionRuntime, + TransactionStoreLifecycle, + @Sendable (StoreTransactionOperation) -> any Error + ) + case override + } + + private struct RuntimeAdmission: Sendable { + let runtime: StoreTransactionRuntime + let leases: FiniteOperationLeases + } + + /// The availability of the typed 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. Elements that StoreKit can't verify are omitted and - /// reported through the failure callback. - public private(set) var entitlements: StoreEntitlements? + /// Inspect this value when the UI needs to distinguish loading or failure + /// from a ready empty entitlement set. + public var entitlementStatus: EntitlementStatus { + switch availability { + case .loading: + .loading + case .failed(let error): + .failed(error) + case .ready: + .ready + case .overridden: + .overridden + } + } - /// App-defined identifiers represented by the latest active entitlements. + /// The latest complete raw StoreKit entitlement projection. /// - /// 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. 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 { - guard !$0.isUpgraded else { return nil } - return EntitlementID(rawValue: $0.productID) - }) + /// This value is non-`nil` only in ``EntitlementStatus/ready``. Override + /// mode has no synthetic raw StoreKit projection. + public var entitlements: StoreEntitlements? { + guard case .ready(let entitlements, _) = availability else { + return nil } + return entitlements } - /// The error from the initial readiness attempt. + /// The app-defined entitlements granted by the current catalog projection. /// - /// 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)? + /// A non-`nil` empty set authoritatively means that no declared entitlement + /// is active. The value is `nil` while loading or when no usable projection + /// is available after failure. + public var activeEntitlements: Set? { + switch availability { + case .ready(_, let activeEntitlements), + .overridden(let activeEntitlements): + activeEntitlements + case .loading, .failed: + nil + } + } @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() + @ObservationIgnored private let backend: Backend + private var availability: EntitlementAvailability - /// Creates and starts an observable StoreKit store. + /// Creates the process's live StoreKit store for an auto-renewable subscription catalog. /// - /// - Parameters: - /// - handleTransaction: Applies the durable business effect for a verified - /// 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 or detached task, because doing - /// so creates a dependency cycle with the operation being handled. - /// - reportFailure: Receives failures owned by process background work, - /// including background deliveries, unfinished and current-entitlement - /// verification, and direct operations abandoned by every caller. A - /// background-owned failure can also fail an enclosing startup or - /// refresh. Admitted failures are delivered serially with backpressure, - /// and ``close()`` waits for every callback to return. Record or enqueue - /// each failure promptly. This callback must not call back into the same - /// store, directly or through an awaited child or detached task. + /// Initialization starts transaction monitoring and the first entitlement + /// reconciliation. The store strongly retains `delegate` until terminal + /// shutdown. Creating a second live store in the same process before the + /// first store finishes ``close()`` is a programmer error. public convenience init( - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - reportFailure: - @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil ) { + let liveLease = LiveTransactionStoreLease.acquire() + let lifecycle = TransactionStoreLifecycle(liveLease: liveLease) self.init( source: .live, - handleTransaction: handleTransaction, - reportFailure: reportFailure + lifecycle: lifecycle, + backendKind: .live, + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + } + + /// Creates a StoreKit-free store with one authoritative entitlement set. + /// + /// The sequence is normalized to a set and published immediately with + /// ``EntitlementStatus/overridden``. This store performs no StoreKit work, + /// has no raw ``entitlements``, and rejects StoreKit-backed operations. + public convenience init( + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + overridingEntitlements: some Sequence + ) { + _ = subscriptionCatalog + self.init( + sessionID: UUID(), + availability: .overridden( + activeEntitlements: Set(overridingEntitlements) + ), + backend: .override ) } - package init( + convenience init( source: StoreTransactionSource, - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - reportFailure: - @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil + ) { + self.init( + source: source, + lifecycle: TransactionStoreLifecycle(), + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + } + + convenience init( + source: StoreTransactionSource, + lifecycle: TransactionStoreLifecycle, + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil + ) { + self.init( + source: source, + lifecycle: lifecycle, + backendKind: .live, + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + } + + package convenience init( + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + syntheticSource: SyntheticStoreTransactionSource, + delegate: (any TransactionStoreDelegate)? = nil, + unavailableOperationError: + @escaping @Sendable (StoreTransactionOperation) -> any Error + ) { + self.init( + source: syntheticSource.source, + lifecycle: TransactionStoreLifecycle(), + backendKind: .synthetic(unavailableOperationError), + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + } + + private enum BackendKind: Sendable { + case live + case synthetic(@Sendable (StoreTransactionOperation) -> any Error) + } + + private convenience init( + source: StoreTransactionSource, + lifecycle: TransactionStoreLifecycle, + backendKind: BackendKind, + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? ) { - let owner = TransactionStoreOwner() let sessionID = UUID() - let startupCompletion = ProcessingReceipt() - let transactionSession = StoreTransactionSession( + let owner = TransactionStoreAvailabilityOwner() + let runtime = StoreTransactionRuntime( sessionID: sessionID, source: source, - handleTransaction: handleTransaction, - entitlementsDidChange: { _ in }, - entitlementRefreshDidSucceed: { success in - await owner.apply(success) - }, - reportFailure: reportFailure - ) - self.sessionID = sessionID - self.transactionSession = transactionSession - self.startupCompletion = startupCompletion - owner.store = self - - startupTask = Task { [weak self, transactionSession] in - defer { startupCompletion.succeed(()) } - do { - _ = 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. - guard !Task.isCancelled else { return } - self?.startupOrdering.recordUnsequencedFailure() - self?.startupError = error + lifecycle: lifecycle, + subscriptionCatalog: subscriptionCatalog, + delegate: delegate, + entitlementOutcome: { outcome in + await owner.apply(outcome) } + ) + let backend: Backend + switch backendKind { + case .live: + backend = .liveRuntime(runtime, lifecycle) + case .synthetic(let unavailableOperationError): + backend = .syntheticRuntime( + runtime, + lifecycle, + unavailableOperationError + ) } + self.init( + sessionID: sessionID, + availability: .loading, + backend: backend + ) + owner.attach(self) + runtime.start() + } + + private init( + sessionID: UUID, + availability: EntitlementAvailability, + backend: Backend + ) { + self.sessionID = sessionID + self.availability = availability + self.backend = backend + } + + /// Returns whether the exact app-defined entitlement is active. + /// + /// This method returns `false` while entitlement state is unavailable. + public func isEntitled(to entitlement: Entitlement) -> Bool { + activeEntitlements?.contains(entitlement) == true } /// Processes a direct result from custom purchase UI. /// - /// StoreKit views deliver successful purchases through - /// `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. + /// A successful verified purchase completes only after policy selection, + /// finishing, causal entitlement reconciliation, and main-actor publication. + /// Pending and user-cancelled results return their corresponding semantic + /// outcome without transaction processing. public func process( _ result: Product.PurchaseResult ) async throws -> StorePurchaseOutcome { - try await waitForStartupAttempt(operation: .processPurchase) - return try await transactionSession.process(result) + let admission = try admit(operation: .processPurchase) + return try await admission.runtime.process( + result, + leases: admission.leases + ) } - /// 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. + func process( + _ delivery: StoreTransactionDelivery, + didAdmit: @escaping @Sendable () async -> Void = {} + ) async throws -> StorePurchaseOutcome { + let admission = try admit(operation: .processPurchase) + return try await admission.runtime.process( + delivery, + leases: admission.leases, + didAdmit: didAdmit + ) + } + + @discardableResult + package func processSyntheticDelivery( + _ delivery: StoreTransactionDelivery + ) async throws -> StoreTransactionSnapshot { + try rejectReentrancy(operation: .processPurchase) + guard case .syntheticRuntime(let runtime, let lifecycle, _) = backend else { + preconditionFailure( + "Synthetic deliveries require a synthetic TransactionStore." + ) + } + let leases = try lifecycle.beginOperation() + let outcome = try await runtime.process(delivery, leases: leases) + guard case .completed(let snapshot) = outcome else { + preconditionFailure("A synthetic delivery must complete a transaction.") + } + return snapshot + } + + /// Reconciles unfinished transactions and publishes current entitlements. /// - /// - Returns: The complete verified entitlement projection. + /// Raw and typed entitlement values are validated and committed atomically. @discardableResult public func refreshEntitlements() async throws -> StoreEntitlements { - try await waitForStartupAttempt(operation: .currentEntitlements) - return try await transactionSession.currentEntitlements() + let admission = try admit(operation: .refreshEntitlements) + return try await admission.runtime.currentEntitlements( + leases: admission.leases + ) } - /// Returns verified transaction history for a product in newest-first order. + /// Returns verified transaction history for one product. /// - /// 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. + /// Results are all-or-nothing and ordered newest first. public func history( for productID: Product.ID ) async throws -> [StoreTransactionSnapshot] { - try await waitForStartupAttempt(operation: .history) - return try await transactionSession.history(for: productID) + let admission = try admit(operation: .history) + return try await admission.runtime.history( + for: productID, + leases: admission.leases + ) } - /// Synchronizes App Store purchases after an explicit user restore action. + /// Synchronizes App Store purchases and refreshes entitlements. /// - /// This method can present authentication UI. It refreshes observable - /// entitlement state before returning. StoreKit may throw - /// `StoreKitError.userCancelled` when the user dismisses - /// authentication; treat that as a normal user outcome rather than a - /// diagnostic failure. + /// If synchronization succeeds but refresh fails, this method throws + /// ``StoreTransactionError/entitlementRefreshFailed(after:underlyingError:)`` + /// with ``StoreTransactionError/CompletedOperation/synchronizedPurchases``. @discardableResult public func restorePurchases() async throws -> StoreEntitlements { - try await waitForStartupAttempt(operation: .restorePurchases) - return try await transactionSession.restorePurchases() + let admission = try admit(operation: .restorePurchases) + return try await admission.runtime.restorePurchases( + leases: admission.leases + ) } - /// Stops transaction producers and drains every accepted operation. - /// - /// Production apps normally retain the store for process lifetime. Call - /// this method from controlled shutdown and test lifecycles. + /// Stops producers and drains every operation accepted before closing. /// - /// - Throws: ``StoreTransactionError/reentrantOperation(operation:)`` when - /// an injected callback attempts to close the store that is executing it. + /// The first call seals admission and starts one shared terminal shutdown. + /// Concurrent calls join it, and later calls return successfully. Caller + /// cancellation does not abandon shutdown. Calling this method from a + /// callback owned by this store throws + /// ``StoreTransactionError/reentrantOperation(operation:)``. public func close() async throws { try rejectReentrancy(operation: .close) - startupTask?.cancel() - try await transactionSession.close() - await startupTask?.value - startupTask = nil + switch backend { + case .liveRuntime(let runtime, let lifecycle), + .syntheticRuntime(let runtime, let lifecycle, _): + await lifecycle.close { + await runtime.shutdown() + } + case .override: + return + } } - fileprivate func apply(_ success: EntitlementRefreshSuccess) { - entitlements = success.entitlements - if startupOrdering.recordSuccess(token: success.token) { - startupError = nil + package func waitForInitialReadiness() async throws { + switch backend { + case .liveRuntime(let runtime, _), + .syntheticRuntime(let runtime, _, _): + try await runtime.waitForInitialReadiness() + case .override: + return } } - private func applyStartupFailure( - token: UInt64, - error: any Error + package func waitUntilClosing() async { + switch backend { + case .liveRuntime(_, let lifecycle), + .syntheticRuntime(_, let lifecycle, _): + await lifecycle.waitUntilSealed() + case .override: + return + } + } + + fileprivate func apply( + _ outcome: EntitlementRefreshOutcome ) { - if startupOrdering.recordFailure(token: token) { - startupError = error + switch outcome { + case .success(let publication): + availability = .ready( + entitlements: publication.entitlements, + activeEntitlements: publication.activeEntitlements + ) + case .transientFailure(let error): + guard case .ready = availability else { + availability = .failed(error) + return + } + case .catalogFailure(let error): + availability = .failed(error) } } - private func waitForStartupAttempt( + private func admit( operation: StoreTransactionOperation - ) async throws { + ) throws -> RuntimeAdmission { try rejectReentrancy(operation: operation) - try Task.checkCancellation() - do { - try await startupCompletion.value() - } catch is ProcessingReceiptWaiterCancellation { - throw CancellationError() + switch backend { + case .liveRuntime(let runtime, let lifecycle): + return RuntimeAdmission( + runtime: runtime, + leases: try lifecycle.beginOperation() + ) + case .syntheticRuntime( + let runtime, + let lifecycle, + let unavailableOperationError + ): + let leases = try lifecycle.beginOperation() + guard operation == .refreshEntitlements else { + leases.work.end() + leases.observer.end() + throw unavailableOperationError(operation) + } + return RuntimeAdmission(runtime: runtime, leases: leases) + case .override: + throw StoreTransactionError.operationUnavailableInOverride( + operation: operation + ) } } @@ -246,47 +412,30 @@ where } } - package func waitForStartup() async { - _ = try? await startupCompletion.terminalValue() - } - isolated deinit { - startupTask?.cancel() + switch backend { + case .liveRuntime(let runtime, let lifecycle), + .syntheticRuntime(let runtime, let lifecycle, _): + lifecycle.sealSynchronously() + runtime.cancelSynchronously() + case .override: + return + } } } -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 - } +@MainActor +private final class TransactionStoreAvailabilityOwner: Sendable +where Entitlement: Hashable & Sendable { + private weak var store: TransactionStore? - package mutating func recordFailure(token: UInt64) -> Bool { - guard latestSuccessfulToken < token else { return false } - failureToken = token - return true + func attach(_ store: TransactionStore) { + precondition(self.store == nil) + self.store = store } - package mutating func recordUnsequencedFailure() { - failureToken = latestSuccessfulToken - } -} - -@MainActor -private final class TransactionStoreOwner: Sendable -where - EntitlementID: RawRepresentable & Hashable & Sendable, - EntitlementID.RawValue == String -{ - weak var store: TransactionStore? - - func apply(_ success: EntitlementRefreshSuccess) { - store?.apply(success) + func apply(_ outcome: EntitlementRefreshOutcome) { + guard let store else { return } + store.apply(outcome) } } diff --git a/Sources/StoreTransactionKit/TransactionStoreDelegate.swift b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift new file mode 100644 index 0000000..d63a05a --- /dev/null +++ b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift @@ -0,0 +1,53 @@ +/// The action StoreTransactionKit takes after classifying a verified transaction. +public enum StoreTransactionHandlingPolicy: Sendable, Hashable { + /// Finishes a catalog-managed transaction without an app-owned business effect. + /// + /// StoreTransactionKit rejects this policy for a transaction outside the + /// managed subscription catalog. + case automatic + + /// Finishes a transaction after the app has durably applied its business effect. + /// + /// Use this policy for an unmanaged product only after the app has committed + /// its idempotent business effect. + case finish +} + +/// Receives transaction decisions and background failure notifications. +/// +/// The delegate is optional because both requirements have default +/// implementations. Policy decisions and failure notifications are each +/// serialized, but the two streams may overlap. +public protocol TransactionStoreDelegate: AnyObject, Sendable { + /// Chooses how to handle a verified transaction. + /// + /// StoreTransactionKit invokes decisions serially after catalog + /// classification. Throwing prevents the transaction from being finished + /// and prevents its causal entitlement refresh. A later independent + /// StoreKit delivery may retry the exact revision. + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy + + /// Notifies the delegate of a failure owned by background work. + /// + /// This notification cannot alter the completed operation or request a + /// retry. Delivery is serialized and applies backpressure. When a failure + /// changes observable entitlement state, that state is committed before + /// this method begins. + func didFail( + with failure: StoreTransactionBackgroundFailure + ) async +} + +public extension TransactionStoreDelegate { + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + .automatic + } + + func didFail( + with failure: StoreTransactionBackgroundFailure + ) async {} +} diff --git a/Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift b/Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift new file mode 100644 index 0000000..44301ee --- /dev/null +++ b/Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift @@ -0,0 +1,61 @@ +import Foundation +import StoreKit +import StoreTransactionKit + +@MainActor +final class SyntheticCurrentEntitlements: Sendable { + private var nextTransactionID: UInt64 = 1 + private var activeSnapshot: StoreTransactionSnapshot? + + func snapshots() -> [StoreTransactionSnapshot] { + if let activeSnapshot { + [activeSnapshot] + } else { + [] + } + } + + func makeSnapshot( + productID: String, + subscriptionGroupID: SubscriptionGroupID + ) -> StoreTransactionSnapshot { + precondition( + nextTransactionID < .max, + "The transaction-store test harness exhausted its transaction identifiers." + ) + let transactionID = nextTransactionID + nextTransactionID += 1 + + let date = Date(timeIntervalSince1970: TimeInterval(transactionID)) + return StoreTransactionSnapshot( + id: transactionID, + originalID: transactionID, + productID: productID, + subscriptionGroupID: subscriptionGroupID.rawValue, + productType: .autoRenewable, + environment: .xcode, + offer: nil, + storefrontID: "143441", + storefrontCountryCode: "USA", + price: nil, + currency: nil, + purchaseDate: date, + originalPurchaseDate: date, + expirationDate: nil, + revocationDate: nil, + revocationReason: nil, + purchasedQuantity: 1, + isUpgraded: false, + ownershipType: .purchased, + reason: .purchase, + appAccountToken: nil, + signedDate: date, + jwsRepresentation: + "StoreTransactionKitTesting.synthetic.\(transactionID)" + ) + } + + func replace(with snapshot: StoreTransactionSnapshot) { + activeSnapshot = snapshot + } +} diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestClock.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestClock.swift new file mode 100644 index 0000000..a5a5e6c --- /dev/null +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestClock.swift @@ -0,0 +1,208 @@ +import Synchronization + +/// A manually advanced clock for deterministic tests. +public final class TransactionStoreTestClock: Clock, Sendable { + public typealias Duration = Swift.Duration + + /// An instant in a ``TransactionStoreTestClock`` timeline. + public struct Instant: InstantProtocol, Sendable { + public typealias Duration = Swift.Duration + + /// The origin of a test-clock timeline. + public static let zero: Instant = Instant(offset: .zero) + + private let offset: Duration + + private init(offset: Duration) { + self.offset = offset + } + + public func advanced(by duration: Duration) -> Instant { + Instant(offset: offset + duration) + } + + public func duration(to other: Instant) -> Duration { + other.offset - offset + } + + public static func < (lhs: Instant, rhs: Instant) -> Bool { + lhs.offset < rhs.offset + } + } + + private struct Sleeper { + let id: UInt64 + let deadline: Instant + let continuation: CheckedContinuation + } + + private struct SleepCountWaiter { + let target: Int + let continuation: CheckedContinuation + } + + private struct State { + var now: Instant + var nextID: UInt64 = 0 + var sleepers: [UInt64: Sleeper] = [:] + var sleepCountWaiters: [UInt64: SleepCountWaiter] = [:] + + mutating func takeID() -> UInt64 { + precondition(nextID < .max, "The test clock exhausted its waiter identifiers.") + defer { nextID += 1 } + return nextID + } + + mutating func takeSatisfiedSleepCountWaiters() -> [CheckedContinuation] { + let readyIDs = sleepCountWaiters.compactMap { id, waiter in + waiter.target <= sleepers.count ? id : nil + } + return readyIDs.compactMap { id in + sleepCountWaiters.removeValue(forKey: id)?.continuation + } + } + } + + private enum SleepRegistration { + case pending([CheckedContinuation]) + case elapsed + case cancelled + } + + private enum SleepCountWaiterRegistration { + case pending + case reached + case cancelled + } + + private let state: Mutex + + /// The clock's current virtual instant. + public var now: Instant { + state.withLock { $0.now } + } + + /// The clock's virtual-time resolution. + public var minimumResolution: Duration { + .zero + } + + /// Creates a clock at the supplied virtual instant. + public init(now: Instant = .zero) { + state = Mutex(State(now: now)) + } + + /// Suspends until the deadline is reached by a call to ``advance(by:)``. + /// + /// The clock resumes at the exact deadline boundary, so `tolerance` does + /// not alter virtual-time scheduling. + public func sleep( + until deadline: Instant, + tolerance: Duration? + ) async throws { + try Task.checkCancellation() + let id = state.withLock { $0.takeID() } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let registration = state.withLock { state -> SleepRegistration in + guard !Task.isCancelled else { return .cancelled } + guard deadline > state.now else { return .elapsed } + + state.sleepers[id] = Sleeper( + id: id, + deadline: deadline, + continuation: continuation + ) + return .pending(state.takeSatisfiedSleepCountWaiters()) + } + + switch registration { + case .pending(let waiters): + for waiter in waiters { + waiter.resume() + } + case .elapsed: + continuation.resume() + case .cancelled: + continuation.resume(throwing: CancellationError()) + } + } + } onCancel: { + self.cancelSleeper(id) + } + } + + /// Advances virtual time and resumes every sleeper whose deadline is due. + public func advance(by duration: Duration) { + precondition(duration >= .zero, "A test clock cannot advance by a negative duration.") + + let continuations = state.withLock { state -> [CheckedContinuation] in + state.now = state.now.advanced(by: duration) + let dueIDs = state.sleepers.values + .filter { $0.deadline <= state.now } + .sorted { + if $0.deadline != $1.deadline { + return $0.deadline < $1.deadline + } + return $0.id < $1.id + } + .map(\.id) + return dueIDs.compactMap { id in + state.sleepers.removeValue(forKey: id)?.continuation + } + } + + for continuation in continuations { + continuation.resume() + } + } + + /// Suspends until at least `count` sleeps are registered with the clock. + public func waitUntilPendingSleepCount( + reaches count: Int + ) async throws { + precondition(count >= 0, "A pending sleep count cannot be negative.") + try Task.checkCancellation() + let id = state.withLock { $0.takeID() } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let registration = state.withLock { state -> SleepCountWaiterRegistration in + guard !Task.isCancelled else { return .cancelled } + guard state.sleepers.count < count else { return .reached } + state.sleepCountWaiters[id] = SleepCountWaiter( + target: count, + continuation: continuation + ) + return .pending + } + + switch registration { + case .pending: + break + case .reached: + continuation.resume() + case .cancelled: + continuation.resume(throwing: CancellationError()) + } + } + } onCancel: { + self.cancelSleepCountWaiter(id) + } + } + + private func cancelSleeper(_ id: UInt64) { + let continuation = state.withLock { state in + state.sleepers.removeValue(forKey: id)?.continuation + } + continuation?.resume(throwing: CancellationError()) + } + + private func cancelSleepCountWaiter(_ id: UInt64) { + let continuation = state.withLock { state in + state.sleepCountWaiters.removeValue(forKey: id)?.continuation + } + continuation?.resume(throwing: CancellationError()) + } +} diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift new file mode 100644 index 0000000..ce8294e --- /dev/null +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift @@ -0,0 +1,100 @@ +import StoreTransactionKit + +/// A StoreKit-free driver for a production ``TransactionStore`` data flow. +@MainActor +public final class TransactionStoreTestHarness +where Entitlement: Hashable & Sendable { + /// The production transaction store supplied to the app component under test. + public let store: TransactionStore + + private let subscriptionCatalog: AutoRenewableSubscriptionCatalog + private let currentEntitlements: SyntheticCurrentEntitlements + + private init( + store: TransactionStore, + subscriptionCatalog: + AutoRenewableSubscriptionCatalog, + currentEntitlements: SyntheticCurrentEntitlements + ) { + self.store = store + self.subscriptionCatalog = subscriptionCatalog + self.currentEntitlements = currentEntitlements + } + + /// Simulates an immediately active purchase of a declared subscription product. + /// + /// The command completes after transaction policy, acknowledgement, + /// entitlement reconciliation, and observable-state publication complete. + @discardableResult + public func purchase( + _ productID: Group.ProductID, + in groupType: Group.Type + ) async throws -> StoreTransactionSnapshot + where Group: AutoRenewableSubscriptionGroup { + guard subscriptionCatalog.subscriptionGroupID == Group.id else { + throw TransactionStoreTestHarnessError.subscriptionGroupMismatch( + expected: subscriptionCatalog.subscriptionGroupID, + actual: Group.id + ) + } + guard subscriptionCatalog.isDeclared(by: groupType) else { + throw TransactionStoreTestHarnessError.subscriptionGroupTypeMismatch( + subscriptionGroupID: Group.id + ) + } + + let rawProductID = productID.rawValue + guard subscriptionCatalog.contains(productID: rawProductID) else { + throw TransactionStoreTestHarnessError.undeclaredProduct( + productID: rawProductID, + subscriptionGroupID: Group.id + ) + } + + try Task.checkCancellation() + let snapshot = currentEntitlements.makeSnapshot( + productID: rawProductID, + subscriptionGroupID: Group.id + ) + return try await store.processSyntheticDelivery( + .synthetic(snapshot: snapshot) { [currentEntitlements] in + await currentEntitlements.replace(with: snapshot) + } + ) + } + + static func make( + subscriptionCatalog: + AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? + ) async throws -> TransactionStoreTestHarness { + let currentEntitlements = SyntheticCurrentEntitlements() + let syntheticSource = SyntheticStoreTransactionSource { + await currentEntitlements.snapshots() + } + let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + syntheticSource: syntheticSource, + delegate: delegate, + unavailableOperationError: { + TransactionStoreTestHarnessError.operationUnavailable( + operation: $0 + ) + } + ) + let harness = TransactionStoreTestHarness( + store: store, + subscriptionCatalog: subscriptionCatalog, + currentEntitlements: currentEntitlements + ) + + do { + try await store.waitForInitialReadiness() + return harness + } catch { + let initializationError = error + try await store.close() + throw initializationError + } + } +} diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift new file mode 100644 index 0000000..ab742bf --- /dev/null +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift @@ -0,0 +1,63 @@ +import Foundation +import StoreTransactionKit + +/// An error produced while configuring or operating a transaction-store test harness. +public enum TransactionStoreTestHarnessError: + LocalizedError, + Sendable, + Hashable +{ + /// The supplied group identifier differs from the catalog's group identifier. + case subscriptionGroupMismatch( + expected: SubscriptionGroupID, + actual: SubscriptionGroupID + ) + + /// The supplied group declaration isn't the declaration that created the catalog. + case subscriptionGroupTypeMismatch( + subscriptionGroupID: SubscriptionGroupID + ) + + /// The supplied product isn't declared by the catalog's subscription group. + case undeclaredProduct( + productID: String, + subscriptionGroupID: SubscriptionGroupID + ) + + /// The synthetic store doesn't provide the requested live StoreKit operation. + case operationUnavailable(operation: StoreTransactionOperation) + + /// A localized description of the harness configuration or operation error. + public var errorDescription: String? { + switch self { + case .subscriptionGroupMismatch(let expected, let actual): + "Expected subscription group \(expected.rawValue), but received \(actual.rawValue)." + + case .subscriptionGroupTypeMismatch(let subscriptionGroupID): + "The subscription group declaration for \(subscriptionGroupID.rawValue) did not create this catalog." + + case .undeclaredProduct(let productID, let subscriptionGroupID): + "Product \(productID) is not declared by subscription group \(subscriptionGroupID.rawValue)." + + case .operationUnavailable(let operation): + "The synthetic transaction store does not provide \(operation.description)." + } + } +} + +private extension StoreTransactionOperation { + var description: String { + switch self { + case .processPurchase: + "purchase-result processing" + case .refreshEntitlements: + "entitlement refresh" + case .history: + "transaction history" + case .restorePurchases: + "purchase restoration" + case .close: + "closing" + } + } +} diff --git a/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift b/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift new file mode 100644 index 0000000..a0454e5 --- /dev/null +++ b/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift @@ -0,0 +1,42 @@ +import StoreTransactionKit + +/// Runs a scoped test with a ready-empty synthetic transaction store. +/// +/// The store is closed and drained before this function returns or throws. +@MainActor +public func withTransactionStoreTestHarness( + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil, + _ operation: + @MainActor ( + TransactionStoreTestHarness + ) async throws -> Result +) async throws -> Result +where Entitlement: Hashable & Sendable { + let harness = try await TransactionStoreTestHarness.make( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + + let operationResult: Swift.Result + do { + operationResult = .success(try await operation(harness)) + } catch { + operationResult = .failure(error) + } + + let closeResult: Swift.Result + do { + closeResult = .success(try await harness.store.close()) + } catch { + closeResult = .failure(error) + } + + switch operationResult { + case .success(let result): + try closeResult.get() + return result + case .failure(let error): + throw error + } +} diff --git a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift new file mode 100644 index 0000000..599776d --- /dev/null +++ b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift @@ -0,0 +1,180 @@ +import StoreTransactionKitTesting +import Testing + +@Suite("TransactionStoreTestClock", .timeLimit(.minutes(1))) +struct TransactionStoreTestClockTests { + @Test("Instant arithmetic defines the virtual timeline") + func instantArithmetic() { + let origin = TransactionStoreTestClock.Instant.zero + let later = origin.advanced(by: .seconds(3)) + + #expect(origin < later) + #expect(origin.duration(to: later) == .seconds(3)) + #expect(later.duration(to: origin) == .seconds(-3)) + } + + @Test("Initialization selects now and zero resolution") + func initialization() { + let initial = TransactionStoreTestClock.Instant.zero + .advanced(by: .seconds(12)) + let clock = TransactionStoreTestClock(now: initial) + + #expect(clock.now == initial) + #expect(clock.minimumResolution == .zero) + } + + @Test("The clock satisfies a Clock Duration dependency") + func clockDependency() async throws { + let clock = TransactionStoreTestClock() + let dependency: any Clock = clock + let sleeper = Task { + try await dependency.sleep(for: .seconds(2)) + } + + try await clock.waitUntilPendingSleepCount(reaches: 1) + clock.advance(by: .seconds(2)) + + try await sleeper.value + #expect(clock.now == .zero.advanced(by: .seconds(2))) + } + + @Test("Advancing releases only due sleepers") + func releasesOnlyDueSleepers() async throws { + let clock = TransactionStoreTestClock() + let first = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(1)), + tolerance: nil + ) + } + let second = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(2)), + tolerance: nil + ) + } + let third = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(3)), + tolerance: nil + ) + } + + try await clock.waitUntilPendingSleepCount(reaches: 3) + clock.advance(by: .seconds(1)) + try await first.value + + third.cancel() + await #expect(throws: CancellationError.self) { + try await third.value + } + + clock.advance(by: .seconds(1)) + try await second.value + + clock.advance(by: .seconds(10)) + } + + @Test("Deadlines at or before now return immediately") + func elapsedDeadline() async throws { + let clock = TransactionStoreTestClock() + clock.advance(by: .seconds(5)) + + try await clock.sleep( + until: .zero.advanced(by: .seconds(5)), + tolerance: .seconds(1) + ) + try await clock.sleep( + until: .zero.advanced(by: .seconds(4)), + tolerance: nil + ) + } + + @Test("The registration barrier supports multiple waiters") + func multipleRegistrationWaiters() async throws { + let clock = TransactionStoreTestClock() + let firstWaiter = Task { + try await clock.waitUntilPendingSleepCount(reaches: 1) + } + let secondWaiter = Task { + try await clock.waitUntilPendingSleepCount(reaches: 2) + } + let firstSleeper = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(1)), + tolerance: nil + ) + } + + try await firstWaiter.value + + let secondSleeper = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(2)), + tolerance: nil + ) + } + + try await secondWaiter.value + clock.advance(by: .seconds(2)) + try await firstSleeper.value + try await secondSleeper.value + } + + @Test("Cancelling a sleep removes it and throws CancellationError") + func cancelledSleep() async throws { + let clock = TransactionStoreTestClock() + let sleeper = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(1)), + tolerance: nil + ) + } + + try await clock.waitUntilPendingSleepCount(reaches: 1) + sleeper.cancel() + + await #expect(throws: CancellationError.self) { + try await sleeper.value + } + + clock.advance(by: .seconds(1)) + } + + @Test("Cancelling a registration waiter throws CancellationError") + func cancelledRegistrationWaiter() async { + let clock = TransactionStoreTestClock() + let waiter = Task { + try await clock.waitUntilPendingSleepCount(reaches: 1) + } + + waiter.cancel() + + await #expect(throws: CancellationError.self) { + try await waiter.value + } + } + + @Test("Zero pending sleeps is already reached") + func zeroPendingSleeps() async throws { + let clock = TransactionStoreTestClock() + try await clock.waitUntilPendingSleepCount(reaches: 0) + } + + #if os(macOS) + @Test("A negative advance is a programmer error") + func negativeAdvance() async { + await #expect(processExitsWith: .failure) { + TransactionStoreTestClock().advance(by: .seconds(-1)) + } + } + + @Test("A negative pending-sleep target is a programmer error") + func negativePendingSleepCount() async { + await #expect(processExitsWith: .failure) { + try await TransactionStoreTestClock() + .waitUntilPendingSleepCount(reaches: -1) + } + } + #endif +} diff --git a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift new file mode 100644 index 0000000..f503567 --- /dev/null +++ b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift @@ -0,0 +1,501 @@ +import StoreTransactionKit +import StoreTransactionKitTesting +import StoreKit +import Synchronization +import Testing + +@Suite("TransactionStoreTestHarness", .timeLimit(.minutes(1))) +struct TransactionStoreTestHarnessTests { + @MainActor + @Test("construction publishes a ready empty entitlement set") + func readyEmptyConstruction() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + guard case .ready = harness.store.entitlementStatus else { + Issue.record("The synthetic store did not become ready.") + return + } + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(!harness.store.isEntitled(to: .tier1)) + } + } + + @MainActor + @Test("purchase returns after policy, acknowledgement, and publication") + func purchaseCompletion() async throws { + let delegate = ActorRecordingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + let snapshot = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + + #expect(snapshot.id == 1) + #expect(snapshot.originalID == 1) + #expect(snapshot.productID == HarnessPlans.ProductID.tier1_Monthly.rawValue) + #expect(snapshot.subscriptionGroupID == HarnessPlans.id.rawValue) + #expect(snapshot.productType == .autoRenewable) + #expect(snapshot.jwsRepresentation == "StoreTransactionKitTesting.synthetic.1") + #expect(harness.store.entitlements?.transactions == [snapshot]) + #expect(harness.store.activeEntitlements == [.tier1]) + #expect(harness.store.isEntitled(to: .tier1)) + #expect(await delegate.snapshot() == .init(decisions: 1, failures: 0)) + } + } + + @MainActor + @Test("a later product replaces the active synthetic subscription") + func purchaseReplacement() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + let first = try await harness.purchase( + .tier1_Yearly, + in: HarnessPlans.self + ) + let second = try await harness.purchase( + .tier2_Monthly, + in: HarnessPlans.self + ) + + #expect(first.id == 1) + #expect(second.id == 2) + #expect(harness.store.entitlements?.transactions == [second]) + #expect(harness.store.activeEntitlements == [.tier2]) + #expect(!harness.store.isEntitled(to: .tier1)) + #expect(harness.store.isEntitled(to: .tier2)) + + let refreshed = try await harness.store.refreshEntitlements() + #expect(refreshed.transactions == [second]) + } + } + + @MainActor + @Test("group and product validation completes before production admission") + func validationPrecedesAdmission() async throws { + let delegate = ActorRecordingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + await expectHarnessError( + .subscriptionGroupMismatch( + expected: HarnessPlans.id, + actual: DifferentIDPlans.id + ) + ) { + try await harness.purchase( + .monthly, + in: DifferentIDPlans.self + ) + } + await expectHarnessError( + .subscriptionGroupTypeMismatch( + subscriptionGroupID: HarnessPlans.id + ) + ) { + try await harness.purchase( + .monthly, + in: SubstitutedPlans.self + ) + } + await expectHarnessError( + .undeclaredProduct( + productID: HarnessPlans.ProductID.undeclared.rawValue, + subscriptionGroupID: HarnessPlans.id + ) + ) { + try await harness.purchase( + .undeclared, + in: HarnessPlans.self + ) + } + + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(await delegate.snapshot() == .init(decisions: 0, failures: 0)) + + let firstAdmitted = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + #expect(firstAdmitted.id == 1) + } + } + + @MainActor + @Test("cancellation already set before admission starts no transaction work") + func preAdmissionCancellation() async throws { + let delegate = ActorRecordingDelegate() + let clock = TransactionStoreTestClock() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + let purchase = Task { @MainActor in + do { + try await clock.sleep(for: .seconds(1)) + } catch is CancellationError { + // Preserve the task's cancellation flag before entering purchase. + } + return try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + } + try await clock.waitUntilPendingSleepCount(reaches: 1) + + purchase.cancel() + await #expect(throws: CancellationError.self) { + _ = try await purchase.value + } + + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(await delegate.snapshot() == .init(decisions: 0, failures: 0)) + + let firstAdmitted = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + #expect(firstAdmitted.id == 1) + } + } + + @MainActor + @Test("decision failure is returned directly without acknowledging the purchase") + func decisionFailure() async throws { + let delegate = ThrowingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + await #expect(throws: HarnessTestError.decision) { + try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + } + + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(await delegate.snapshot() == .init(decisions: 1, failures: 0)) + } + } + + @MainActor + @Test("unsupported live operations fail before source or delegate work") + func unsupportedOperations() async throws { + let delegate = ActorRecordingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + await expectHarnessError( + .operationUnavailable(operation: .processPurchase) + ) { + _ = try await harness.store.process(.pending) + } + await expectHarnessError(.operationUnavailable(operation: .history)) { + _ = try await harness.store.history(for: "test.product") + } + await expectHarnessError( + .operationUnavailable(operation: .restorePurchases) + ) { + _ = try await harness.store.restorePurchases() + } + + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(await delegate.snapshot() == .init(decisions: 0, failures: 0)) + } + } + + @MainActor + @Test("multiple synthetic stores coexist without live StoreKit authority") + func multipleSyntheticStores() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { first in + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { second in + _ = try await first.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + _ = try await second.purchase( + .tier2_Monthly, + in: HarnessPlans.self + ) + + #expect(first.store.activeEntitlements == [.tier1]) + #expect(second.store.activeEntitlements == [.tier2]) + } + } + } + + @MainActor + @Test("a class delegate can delay policy with the deterministic clock") + func classDelegateClockIntegration() async throws { + let clock = TransactionStoreTestClock() + let delegate = DelayedClassDelegate(clock: clock) + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + let purchase = Task { @MainActor in + try await harness.purchase( + .tier2_Monthly, + in: HarnessPlans.self + ) + } + + try await clock.waitUntilPendingSleepCount(reaches: 1) + #expect(harness.store.activeEntitlements == []) + #expect(delegate.decisionCount == 1) + + clock.advance(by: .seconds(30)) + let snapshot = try await purchase.value + + #expect(harness.store.entitlements?.transactions == [snapshot]) + #expect(harness.store.activeEntitlements == [.tier2]) + } + } + + @MainActor + @Test("scoped cleanup closes a retained harness after success") + func scopedSuccessCleanup() async throws { + var retained: TransactionStoreTestHarness? + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + retained = harness + } + + try await expectClosed(try #require(retained)) + } + + @MainActor + @Test("scoped cleanup closes a retained harness after operation failure") + func scopedFailureCleanup() async throws { + var retained: TransactionStoreTestHarness? + + await #expect(throws: HarnessTestError.operation) { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness -> Void in + retained = harness + throw HarnessTestError.operation + } + } + + try await expectClosed(try #require(retained)) + } + + @MainActor + @Test("scoped cleanup drains after operation cancellation") + func scopedCancellationCleanup() async throws { + let clock = TransactionStoreTestClock() + var retained: TransactionStoreTestHarness? + let scoped = Task { @MainActor in + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + retained = harness + try await clock.sleep(for: .seconds(1)) + } + } + try await clock.waitUntilPendingSleepCount(reaches: 1) + + scoped.cancel() + await #expect(throws: CancellationError.self) { + try await scoped.value + } + + try await expectClosed(try #require(retained)) + } + + @MainActor + @Test("scoped cleanup is idempotent when the operation closes the store") + func operationClosesStore() async throws { + var retained: TransactionStoreTestHarness? + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + retained = harness + try await harness.store.close() + } + + try await expectClosed(try #require(retained)) + } +} + +private enum HarnessEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +private enum HarnessPlans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "testing.subscription.group" + ) + + enum ProductID: String, Hashable, Sendable { + case tier1_Monthly = "testing.subscription.tier1.monthly" + case tier1_Yearly = "testing.subscription.tier1.yearly" + case tier2_Monthly = "testing.subscription.tier2.monthly" + case undeclared = "testing.subscription.undeclared" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + } +} + +private enum DifferentIDPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID( + rawValue: "testing.subscription.other-group" + ) + + enum ProductID: String, Hashable, Sendable { + case monthly = "testing.subscription.tier1.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.monthly, entitlement: .tier1) + } +} + +private enum SubstitutedPlans: + AutoRenewableSubscriptionGroup +{ + static let id = HarnessPlans.id + + enum ProductID: String, Hashable, Sendable { + case monthly = "testing.subscription.tier1.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.monthly, entitlement: .tier1) + } +} + +private let harnessSubscriptionCatalog = + AutoRenewableSubscriptionCatalog(HarnessPlans.self) + +private enum HarnessTestError: Error, Sendable { + case decision + case operation +} + +private struct DelegateSnapshot: Equatable, Sendable { + let decisions: Int + let failures: Int +} + +private actor ActorRecordingDelegate: TransactionStoreDelegate { + private var decisions = 0 + private var failures = 0 + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + return .finish + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + failures += 1 + } + + func snapshot() -> DelegateSnapshot { + DelegateSnapshot(decisions: decisions, failures: failures) + } +} + +private actor ThrowingDelegate: TransactionStoreDelegate { + private var decisions = 0 + private var failures = 0 + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + throw HarnessTestError.decision + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + failures += 1 + } + + func snapshot() -> DelegateSnapshot { + DelegateSnapshot(decisions: decisions, failures: failures) + } +} + +private final class DelayedClassDelegate: TransactionStoreDelegate { + private let clock: any Clock + private let decisions = Mutex(0) + + var decisionCount: Int { + decisions.withLock { $0 } + } + + init(clock: any Clock) { + self.clock = clock + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions.withLock { $0 += 1 } + try await clock.sleep(for: .seconds(30)) + return .finish + } +} + +@MainActor +private func expectHarnessError( + _ expected: TransactionStoreTestHarnessError, + performing operation: @MainActor () async throws -> Void +) async { + do { + try await operation() + Issue.record("Expected test harness error: \(expected)") + } catch let error as TransactionStoreTestHarnessError { + #expect(error == expected) + #expect(error.errorDescription != nil) + } catch { + Issue.record("Unexpected error: \(error)") + } +} + +@MainActor +private func expectClosed( + _ harness: TransactionStoreTestHarness +) async throws { + do { + _ = try await harness.store.history(for: "test.product") + Issue.record("The retained synthetic store was not closed.") + } catch StoreTransactionError.closed { + return + } +} diff --git a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift new file mode 100644 index 0000000..9785270 --- /dev/null +++ b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift @@ -0,0 +1,439 @@ +import Foundation +import StoreKit +import Synchronization +import Testing +@testable import StoreTransactionKit + +@Suite("Auto-renewable subscription catalog") +struct AutoRenewableSubscriptionCatalogTests { + @Test("the declaration is evaluated once and normalized into one lookup") + func evaluatesDeclarationOnce() throws { + countingSubscriptionsAccessCount.withLock { count in + count = 0 + } + + let catalog = AutoRenewableSubscriptionCatalog(CountingPlans.self) + + #expect(countingSubscriptionsAccessCount.withLock { $0 } == 1) + #expect(catalog.subscriptionGroupID == CountingPlans.id) + #expect(catalog.isDeclared(by: CountingPlans.self)) + #expect(!catalog.isDeclared(by: OtherPlans.self)) + #expect(catalog.contains(productID: CountingPlans.ProductID.monthly.rawValue)) + #expect(!catalog.contains(productID: "com.example.subscription.undeclared")) + + let transaction = subscriptionSnapshot( + id: 0, + productID: CountingPlans.ProductID.monthly.rawValue, + subscriptionGroupID: CountingPlans.id.rawValue + ) + #expect(try catalog.classification(of: transaction) == .managed) + #expect( + try catalog.activeEntitlements( + in: StoreEntitlements(transactions: [transaction]) + ) == [.tier1] + ) + #expect(countingSubscriptionsAccessCount.withLock { $0 } == 1) + } + + @Test("monthly and yearly products project to their declared entitlements") + func projectsDeclaredEntitlements() throws { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let entitlements = StoreEntitlements( + transactions: [ + subscriptionSnapshot( + id: 1, + productID: Plans.ProductID.tier1_Monthly.rawValue + ), + subscriptionSnapshot( + id: 2, + productID: Plans.ProductID.tier1_Yearly.rawValue + ), + subscriptionSnapshot( + id: 3, + productID: Plans.ProductID.tier2_Monthly.rawValue + ), + subscriptionSnapshot( + id: 4, + productID: Plans.ProductID.tier2_Yearly.rawValue + ), + ] + ) + + #expect( + try catalog.activeEntitlements(in: entitlements) + == [.tier1, .tier2] + ) + } + + @Test("an upgraded declared product remains managed without granting access") + func upgradedDeclaredProductDoesNotGrantAccess() throws { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let transaction = subscriptionSnapshot( + id: 5, + productID: Plans.ProductID.tier1_Monthly.rawValue, + isUpgraded: true + ) + + #expect(try catalog.classification(of: transaction) == .managed) + #expect( + try catalog.activeEntitlements( + in: StoreEntitlements(transactions: [transaction]) + ).isEmpty + ) + } + + @Test("a retired upgraded product remains managed without granting access") + func retiredUpgradedProductDoesNotGrantAccess() throws { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let transaction = subscriptionSnapshot( + id: 6, + productID: "com.example.subscription.retired", + isUpgraded: true + ) + + #expect(try catalog.classification(of: transaction) == .managed) + #expect( + try catalog.activeEntitlements( + in: StoreEntitlements(transactions: [transaction]) + ).isEmpty + ) + } + + @Test("a product outside the catalog group remains unmanaged and unprojected") + func externalProductIsUnmanaged() throws { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let transaction = subscriptionSnapshot( + id: 7, + productID: "com.example.other.product", + subscriptionGroupID: "other-group" + ) + + #expect(try catalog.classification(of: transaction) == .unmanaged) + #expect( + try catalog.activeEntitlements( + in: StoreEntitlements(transactions: [transaction]) + ).isEmpty + ) + } + + @Test("a declared product with a wrong StoreKit type fails validation") + func declaredProductTypeMismatch() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let productID = Plans.ProductID.tier1_Monthly.rawValue + + do { + _ = try catalog.activeEntitlements( + in: StoreEntitlements( + transactions: [ + subscriptionSnapshot( + id: 8, + productID: productID, + productType: .nonConsumable + ) + ] + ) + ) + Issue.record("Projection unexpectedly accepted a non-consumable product.") + } catch let error { + guard case let .productTypeMismatch(actualProductID, actual) = error else { + Issue.record("Unexpected catalog error: \(error)") + return + } + + #expect(actualProductID == productID) + #expect(actual == .nonConsumable) + } + } + + @Test("a declared product with a wrong group fails validation") + func declaredProductGroupMismatch() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let productID = Plans.ProductID.tier1_Monthly.rawValue + + do { + _ = try catalog.classification( + of: subscriptionSnapshot( + id: 9, + productID: productID, + subscriptionGroupID: "other-group" + ) + ) + Issue.record("Classification unexpectedly accepted the wrong group.") + } catch let error { + guard + case let .subscriptionGroupMismatch( + actualProductID, + expected, + actual + ) = error + else { + Issue.record("Unexpected catalog error: \(error)") + return + } + + #expect(actualProductID == productID) + #expect(expected == Plans.id) + #expect(actual == "other-group") + } + } + + @Test("an undeclared current product in the managed group fails validation") + func undeclaredCurrentProduct() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let productID = "com.example.subscription.undeclared" + + do { + _ = try catalog.activeEntitlements( + in: StoreEntitlements( + transactions: [ + subscriptionSnapshot( + id: 10, + productID: productID + ) + ] + ) + ) + Issue.record("Projection unexpectedly accepted an undeclared product.") + } catch let error { + guard + case let .undeclaredProduct( + actualProductID, + subscriptionGroupID + ) = error + else { + Issue.record("Unexpected catalog error: \(error)") + return + } + + #expect(actualProductID == productID) + #expect(subscriptionGroupID == Plans.id) + } + } + + @Test("an undeclared upgraded product still requires an auto-renewable type") + func retiredProductTypeMismatch() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let productID = "com.example.subscription.retired" + + do { + _ = try catalog.classification( + of: subscriptionSnapshot( + id: 11, + productID: productID, + productType: .nonRenewable, + isUpgraded: true + ) + ) + Issue.record("Classification unexpectedly accepted the wrong type.") + } catch let error { + guard case let .productTypeMismatch(actualProductID, actual) = error else { + Issue.record("Unexpected catalog error: \(error)") + return + } + + #expect(actualProductID == productID) + #expect(actual == .nonRenewable) + } + } + + @Test("projection validates the complete candidate before returning a set") + func projectionIsAllOrNothing() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + + do { + _ = try catalog.activeEntitlements( + in: StoreEntitlements( + transactions: [ + subscriptionSnapshot( + id: 12, + productID: Plans.ProductID.tier1_Monthly.rawValue + ), + subscriptionSnapshot( + id: 13, + productID: "com.example.subscription.undeclared" + ), + ] + ) + ) + Issue.record("Projection unexpectedly returned a partial set.") + } catch let error { + guard case .undeclaredProduct = error else { + Issue.record("Unexpected catalog error: \(error)") + return + } + } + } + + #if os(macOS) + @Test("an empty group identifier is a construction error") + func emptyGroupIDFails() async { + await #expect(processExitsWith: .failure) { + _ = SubscriptionGroupID(rawValue: "") + } + } + + @Test("an empty subscription declaration is a construction error") + func emptyDeclarationFails() async { + await #expect(processExitsWith: .failure) { + _ = AutoRenewableSubscriptionCatalog(EmptyPlans.self) + } + } + + @Test("an empty product identifier is a construction error") + func emptyProductIDFails() async { + await #expect(processExitsWith: .failure) { + _ = AutoRenewableSubscriptionCatalog(EmptyProductIDPlans.self) + } + } + + @Test("duplicate raw product identifiers are a construction error") + func duplicateProductIDFails() async { + await #expect(processExitsWith: .failure) { + _ = AutoRenewableSubscriptionCatalog(DuplicateProductIDPlans.self) + } + } + #endif +} + +private enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +private enum Plans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "example-group") + + enum ProductID: String, Hashable, Sendable { + case tier1_Monthly = "com.example.subscription.tier1.monthly" + case tier1_Yearly = "com.example.subscription.tier1.yearly" + case tier2_Monthly = "com.example.subscription.tier2.monthly" + case tier2_Yearly = "com.example.subscription.tier2.yearly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) + } +} + +private enum OtherPlans: + AutoRenewableSubscriptionGroup +{ + static let id = Plans.id + + enum ProductID: String, Hashable, Sendable { + case monthly = "com.example.subscription.tier1.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.monthly, entitlement: .tier2) + } +} + +private let countingSubscriptionsAccessCount = Mutex(0) + +private enum CountingPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "counting-group") + + enum ProductID: String, Hashable, Sendable { + case monthly = "com.example.counting.monthly" + } + + static var subscriptions: StoreSubscriptions { + countingSubscriptionsAccessCount.withLock { count in + count += 1 + } + + return [StoreSubscription(.monthly, entitlement: .tier1)] + } +} + +private enum EmptyPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "empty-group") + + enum ProductID: String, Hashable, Sendable { + case monthly = "com.example.empty.monthly" + } + + static var subscriptions: StoreSubscriptions { + return [] + } +} + +private enum EmptyProductIDPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "empty-product-group") + + enum ProductID: String, Hashable, Sendable { + case empty = "" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.empty, entitlement: .tier1) + } +} + +private struct DuplicateProductID: RawRepresentable, Hashable, Sendable { + let rawValue: String + + static let first = Self(rawValue: "com.example.duplicate") + static let second = Self(rawValue: "com.example.duplicate") +} + +private enum DuplicateProductIDPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "duplicate-product-group") + + typealias ProductID = DuplicateProductID + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.first, entitlement: .tier1) + StoreSubscription(.second, entitlement: .tier2) + } +} + +private func subscriptionSnapshot( + id: UInt64, + productID: String, + subscriptionGroupID: String? = Plans.id.rawValue, + productType: Product.ProductType = .autoRenewable, + isUpgraded: Bool = false +) -> StoreTransactionSnapshot { + let date = Date(timeIntervalSince1970: TimeInterval(id)) + + return StoreTransactionSnapshot( + id: id, + originalID: id, + productID: productID, + subscriptionGroupID: subscriptionGroupID, + productType: productType, + environment: .xcode, + offer: nil, + storefrontID: "143441", + storefrontCountryCode: "USA", + price: nil, + currency: nil, + purchaseDate: date, + originalPurchaseDate: date, + expirationDate: nil, + revocationDate: nil, + revocationReason: nil, + purchasedQuantity: 1, + isUpgraded: isUpgraded, + ownershipType: .purchased, + reason: .purchase, + appAccountToken: nil, + signedDate: date, + jwsRepresentation: "catalog-jws-\(id)" + ) +} diff --git a/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift b/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift deleted file mode 100644 index 2a6eea2..0000000 --- a/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -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 index 7baf55c..ccc71c2 100644 --- a/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift +++ b/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift @@ -38,7 +38,7 @@ struct DirectOperationReportingTests { #expect( claimed?.source - == .abandonedDirectOperation(.currentEntitlements) + == .abandonedDirectOperation(.refreshEntitlements) ) #expect(claimed?.transactionID == 2) #expect(claimed?.underlyingError is TestFailure) @@ -62,9 +62,34 @@ struct DirectOperationReportingTests { #expect(owner.fail(ownerBinding, report: makeReport(id: 3)) == nil) } + @Test("merged physical owners select one background report") + func mergedAuthoritiesReportOnce() throws { + let processAuthority = DirectOperationReportingAuthority() + let refreshAuthority = DirectOperationReportingAuthority() + let process = DirectOperationObservation() + let refresh = DirectOperationObservation() + let processBinding = process.bind(to: processAuthority) + let refreshBinding = refresh.bind(to: refreshAuthority) + processAuthority.merge(into: refreshAuthority) + + #expect(process.abandon() == nil) + #expect(refresh.abandon() == nil) + let claimed = process.fail( + processBinding, + report: makeReport(id: 4) + ) + let duplicate = refresh.fail( + refreshBinding, + report: makeReport(id: 5) + ) + + #expect(claimed?.transactionID == 4) + #expect(duplicate == nil) + } + private func makeReport(id: UInt64) -> StoreTransactionBackgroundFailure { StoreTransactionBackgroundFailure( - source: .abandonedDirectOperation(.currentEntitlements), + source: .abandonedDirectOperation(.refreshEntitlements), transactionID: id, productID: "product-\(id)", underlyingError: TestFailure() diff --git a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift index 93d9c99..cf6aecd 100644 --- a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift +++ b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift @@ -1,185 +1,188 @@ +import Foundation import Testing @testable import StoreTransactionKit @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: { _ in try await query.next() }, - didChange: { value in - await publicationSizes.append(UInt64(value.transactions.count)) - } - ) + @Test("a reservation after the physical query cutoff starts a later query") + func physicalQueryCutoff() async throws { + let query = ControlledReconciliationQuery() + let failures = FailureReporterDispatcher() + let coordinator = makeCoordinator(query: query, failures: failures) let first = await coordinator.reserve() try await query.waitForRequest(1) let second = await coordinator.reserve() - await query.succeed([makeSnapshot(id: 1, productID: "b")]) - let firstValue = try await first.receipt.terminalValue() - #expect(firstValue.transactions.map(\.productID) == ["b"]) + #expect(first.role == .owner) + #expect(second.role == .owner) + await query.succeed([]) + _ = try await first.receipt.terminalValue() + try await query.waitForRequest(2) - await query.succeed([ - makeSnapshot(id: 2, productID: "a"), - makeSnapshot(id: 1, productID: "b"), - ]) - let secondValue = try await second.receipt.terminalValue() - - #expect(secondValue.transactions.map(\.productID) == ["a", "b"]) - #expect(await publicationSizes.snapshot() == [1, 2]) + await query.succeed([]) + _ = try await second.receipt.terminalValue() + await coordinator.sealAndDrain() + await failures.sealAndDrain() } - @Test("equal content completes reservations without a new publication") - func equalContentDoesNotPublish() async throws { - let query = ControlledEntitlementQuery() - let publicationSizes = UInt64Recorder() - let successfulTokens = UInt64Recorder() - let coordinator = EntitlementRefreshCoordinator( + @Test("publication callback completes before the reservation receipt") + func publicationPrecedesReceipt() async throws { + let query = ControlledReconciliationQuery() + let failures = FailureReporterDispatcher() + let callbackStarted = TestSignal() + let callbackGate = TestGate() + let receiptCompleted = TestSignal() + let coordinator = EntitlementRefreshCoordinator( query: { _ in try await query.next() }, - didChange: { value in - await publicationSizes.append(UInt64(value.transactions.count)) + project: { + (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + [.tier1] }, - didSucceed: { success in - await successfulTokens.append(success.token) - } + didComplete: { _ in + await callbackStarted.send() + try? await callbackGate.wait() + }, + failures: failures ) - let snapshot = makeSnapshot(id: 4) - let first = await coordinator.reserve() + let reservation = await coordinator.reserve() try await query.waitForRequest(1) - await query.succeed([snapshot]) - _ = try await first.receipt.terminalValue() + await query.succeed([]) + let waiter = Task { + _ = try await reservation.receipt.terminalValue() + await receiptCompleted.send() + } + try await callbackStarted.wait(for: 1) + #expect(await receiptCompleted.value() == 0) - let second = await coordinator.reserve() - try await query.waitForRequest(2) - await query.succeed([snapshot]) - let value = try await second.receipt.terminalValue() + await callbackGate.open() + try await waiter.value + #expect(await receiptCompleted.value() == 1) - #expect(value.transactions == [snapshot]) - #expect(await publicationSizes.snapshot() == [1]) - #expect(await successfulTokens.snapshot() == [1, 2]) await coordinator.sealAndDrain() + await failures.sealAndDrain() } - @Test("an unverified or failed query never publishes a partial replacement") - func failedQueryDoesNotPublish() async throws { - let query = ControlledEntitlementQuery() - let publicationSizes = UInt64Recorder() - let coordinator = EntitlementRefreshCoordinator( + @Test("catalog projection failure is classified at the projection boundary") + func catalogFailureClassification() async throws { + let query = ControlledReconciliationQuery() + let failures = FailureReporterDispatcher() + let outcomes = RefreshOutcomeRecorder() + let catalogError = AutoRenewableSubscriptionCatalogError.undeclaredProduct( + productID: "undeclared", + subscriptionGroupID: TestPlans.id + ) + let coordinator = EntitlementRefreshCoordinator( query: { _ in try await query.next() }, - didChange: { value in - await publicationSizes.append(UInt64(value.transactions.count)) - } + project: { + (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + throw catalogError + }, + didComplete: { outcome in await outcomes.append(outcome) }, + failures: failures ) - let failed = await coordinator.reserve() + let reservation = await coordinator.reserve() try await query.waitForRequest(1) - await query.fail(TestFailure()) - await #expect(throws: TestFailure.self) { - _ = try await failed.receipt.terminalValue() + await query.succeed([]) + await #expect(throws: AutoRenewableSubscriptionCatalogError.self) { + _ = try await reservation.receipt.terminalValue() } - #expect(await publicationSizes.snapshot().isEmpty) - let recovered = await coordinator.reserve() - try await query.waitForRequest(2) - await query.succeed([]) - let value = try await recovered.receipt.terminalValue() - #expect(value.transactions.isEmpty) - #expect(await publicationSizes.snapshot() == [0]) + #expect(await outcomes.kinds() == [.catalogFailure]) await coordinator.sealAndDrain() + await failures.sealAndDrain() } - @Test("each pending query batch has one reporting owner") - func pendingBatchReportingAuthority() async throws { - let query = ControlledEntitlementQuery() - let coordinator = EntitlementRefreshCoordinator( + private func makeCoordinator( + query: ControlledReconciliationQuery, + failures: FailureReporterDispatcher + ) -> EntitlementRefreshCoordinator { + EntitlementRefreshCoordinator( query: { _ in try await query.next() }, - didChange: { _ in } + project: { + (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + [] + }, + didComplete: { _ in }, + failures: failures ) + } +} - let active = await coordinator.reserve() - try await query.waitForRequest(1) - let nextOwner = await coordinator.reserve() - let nextObserver = await coordinator.reserve() +private actor ControlledReconciliationQuery { + private struct Request { + let id: UUID + let continuation: CheckedContinuation + } - #expect(active.role == .owner) - #expect(nextOwner.role == .owner) - #expect(nextObserver.role == .observer) - #expect(active.reportingAuthority !== nextOwner.reportingAuthority) - #expect(nextOwner.reportingAuthority === nextObserver.reportingAuthority) + private var requests: [Request] = [] + private let started = TestSignal() - 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() + func next() async throws -> CurrentEntitlementReconciliation { + 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.cancel(id) } + } } - @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 } - ) + func waitForRequest(_ count: Int) async throws { + try await started.wait(for: count) + } - let active = await coordinator.reserve( - retryFailedTransactions: false + func succeed(_ snapshots: [StoreTransactionSnapshot]) { + precondition(!requests.isEmpty) + requests.removeFirst().continuation.resume( + returning: CurrentEntitlementReconciliation( + snapshots: snapshots, + causalClaims: [], + diagnostics: [] + ) ) - 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() + private func cancel(_ id: UUID) { + guard let index = requests.firstIndex(where: { $0.id == id }) else { + return + } + requests.remove(at: index).continuation.resume( + throwing: CancellationError() + ) + } +} - try await query.waitForRequest(2) - await query.succeed([]) - _ = try await falseOwner.receipt.terminalValue() - _ = try await falseObserver.receipt.terminalValue() +private actor RefreshOutcomeRecorder { + enum Kind: Equatable, Sendable { + case success + case transientFailure + case catalogFailure + } - try await query.waitForRequest(3) - await query.succeed([]) - _ = try await trueOwner.receipt.terminalValue() - _ = try await trueObserver.receipt.terminalValue() + private var values: [Kind] = [] - try await query.waitForRequest(4) - await query.succeed([]) - _ = try await trailingFalseOwner.receipt.terminalValue() + func append(_ outcome: EntitlementRefreshOutcome) { + switch outcome { + case .success: + values.append(.success) + case .transientFailure: + values.append(.transientFailure) + case .catalogFailure: + values.append(.catalogFailure) + } + } - #expect( - await policies.snapshot() == [ - "false", "false", "true", "false", - ]) - await coordinator.sealAndDrain() + func kinds() -> [Kind] { + values } } diff --git a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift deleted file mode 100644 index 58769e2..0000000 --- a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift +++ /dev/null @@ -1,232 +0,0 @@ -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/PublicPolicyStateTests.swift b/Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift new file mode 100644 index 0000000..c54fbf8 --- /dev/null +++ b/Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift @@ -0,0 +1,99 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Public policy and state primitives") +struct PublicPolicyStateTests { + @Test("a failed entitlement status preserves its underlying error") + func failedEntitlementStatusPreservesError() { + let status = EntitlementStatus.failed(MarkerError(value: 7)) + + guard case .failed(let error) = status else { + Issue.record("Expected a failed entitlement status.") + return + } + #expect(error as? MarkerError == MarkerError(value: 7)) + } + + @Test("the default delegate selects automatic handling") + func defaultDelegatePolicy() async throws { + let delegate: any TransactionStoreDelegate = DefaultDelegate() + let transaction = makeSnapshot(id: 1) + + let policy = try await delegate.decidePolicy(for: transaction) + #expect(policy == .automatic) + + await delegate.didFail( + with: StoreTransactionBackgroundFailure( + source: .updates, + transactionID: transaction.id, + productID: transaction.productID, + underlyingError: MarkerError(value: 1) + )) + } + + @Test("an actor delegate can provide an app-owned finish decision") + func actorDelegatePolicy() async throws { + let delegate: any TransactionStoreDelegate = ActorDelegate() + + let policy = try await delegate.decidePolicy( + for: makeSnapshot(id: 2) + ) + + #expect(policy == .finish) + } + + @Test("completed action errors preserve recovery context") + func completedActionErrorContext() { + let transaction = makeSnapshot(id: 3) + let error = StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(transaction), + underlyingError: MarkerError(value: 3) + ) + + guard + case .entitlementRefreshFailed(let operation, let underlying) = + error + else { + Issue.record("Expected a completed-action refresh failure.") + return + } + #expect( + operation + == .finishedTransaction(transaction) + ) + #expect(underlying as? MarkerError == MarkerError(value: 3)) + } + + @Test("an unhandled transaction records its StoreKit product metadata") + func unhandledTransactionContext() { + let error = StoreTransactionError.unhandledTransaction( + productID: "consumable.product", + productType: .consumable + ) + + guard + case .unhandledTransaction(let productID, let productType) = + error + else { + Issue.record("Expected an unhandled transaction failure.") + return + } + #expect(productID == "consumable.product") + #expect(productType == Product.ProductType.consumable) + } +} + +private struct MarkerError: Error, Sendable, Equatable { + let value: Int +} + +private final class DefaultDelegate: TransactionStoreDelegate {} + +private actor ActorDelegate: TransactionStoreDelegate { + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + .finish + } +} diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift deleted file mode 100644 index e28ac42..0000000 --- a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift +++ /dev/null @@ -1,568 +0,0 @@ -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/RuntimeContractCoverageTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift new file mode 100644 index 0000000..3d00d27 --- /dev/null +++ b/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift @@ -0,0 +1,1023 @@ +import Foundation +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Runtime contract coverage", .timeLimit(.minutes(1))) +struct RuntimeContractCoverageTests { + @MainActor + @Test("subscription status waits for readiness and close drains its publication") + func subscriptionStatusReadinessAndCloseDrain() async throws { + let query = ControlledEntitlementQuery() + let snapshot = makeSubscriptionSnapshot( + id: 301, + productID: .tier1Monthly + ) + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + 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 store.waitForInitialReadiness() + try await query.waitForRequest(2) + + let closeCompleted = TestSignal() + let close = Task { @MainActor in + try await store.close() + await closeCompleted.send() + } + await store.waitUntilClosing() + #expect(await closeCompleted.value() == 0) + + await query.succeed([snapshot]) + try await close.value + + #expect(await closeCompleted.value() == 1) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + } + + @MainActor + @Test("subscription status handles revocation before publishing removal") + func subscriptionStatusRevocationOrdering() async throws { + let active = makeSnapshot( + id: 302, + productID: TestPlans.ProductID.tier1Monthly.rawValue, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + jws: "active-302" + ) + let revoked = makeSnapshot( + id: active.id, + productID: active.productID, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + signedDate: Date(timeIntervalSince1970: 400), + jws: "revoked-302", + revocationDate: Date(timeIntervalSince1970: 399) + ) + let current = EntitlementValueSource([active]) + let unfinished = UnfinishedValueSource() + let decisionStarted = TestSignal() + let decisionFinished = TestSignal() + let decisionGate = NonCancellableGate() + let delegate = GatedPolicyDelegate( + started: decisionStarted, + finished: decisionFinished, + gate: decisionGate + ) + let finishes = TestSignal() + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() }, + queryUnfinished: { await unfinished.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + await current.replace(with: []) + await unfinished.replace(with: [ + .verified( + makeEnvelope(snapshot: revoked, revision: "revoked-302") { + await finishes.send() + await unfinished.replace(with: []) + } + ) + ]) + fixture.subscriptionStatusUpdates.yield() + try await fixture.subscriptionStatusDeliveryCount.wait(for: 1) + try await decisionStarted.wait(for: 1) + + #expect(store.activeEntitlements == [.tier1]) + #expect(await finishes.value() == 0) + + let close = Task { @MainActor in try await store.close() } + await store.waitUntilClosing() + decisionGate.open() + try await close.value + + #expect(await decisionFinished.value() == 1) + #expect(await finishes.value() == 1) + #expect(await delegate.decisionCount() == 1) + #expect(store.entitlements?.transactions.isEmpty == true) + #expect(store.activeEntitlements == []) + } + + @Test("restore reservations coalesce, expose one failure, and retry independently") + func restoreCoordinatorCoalescingAndRetry() async throws { + let synchronization = ControlledSynchronization() + let failures = FailureReporterDispatcher() + let entitlements = EntitlementRefreshCoordinator( + query: { _ in + CurrentEntitlementReconciliation( + snapshots: [], + causalClaims: [], + diagnostics: [] + ) + }, + project: { + (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + [] + }, + didComplete: { _ in }, + failures: failures + ) + 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.failNext(TestFailure()) + for receipt in [first.receipt, second.receipt] { + do { + _ = try await receipt.terminalValue() + Issue.record("A failed restore reservation unexpectedly succeeded.") + } catch let failure as RestoreCoordinatorFailure { + #expect(failure.underlyingError is TestFailure) + #expect(!failure.synchronized) + } + } + + let retry = await coordinator.reserve() + try await synchronization.waitForAttempt(2) + await synchronization.succeedNext() + let publication = try await retry.receipt.terminalValue() + + #expect(retry.role == .owner) + #expect(retry.receipt !== first.receipt) + #expect(publication.entitlements.transactions.isEmpty) + #expect(await synchronization.attemptCount() == 2) + await entitlements.sealAndDrain() + await failures.sealAndDrain() + } + + @MainActor + @Test("an attached restore synchronization failure returns directly and can retry") + func attachedRestoreSynchronizationFailureCanRetry() async throws { + let synchronization = ControlledSynchronization() + let delegate = RecordingFailureDelegate() + let fixture = TestSourceFixture( + synchronize: { try await synchronization.run() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + let first = Task { @MainActor in try await store.restorePurchases() } + try await synchronization.waitForAttempt(1) + await synchronization.failNext(TestFailure()) + await #expect(throws: TestFailure.self) { + _ = try await first.value + } + + let retry = Task { @MainActor in try await store.restorePurchases() } + try await synchronization.waitForAttempt(2) + await synchronization.succeedNext() + let restored = try await retry.value + + #expect(restored.transactions.isEmpty) + #expect(await synchronization.attemptCount() == 2) + #expect((await delegate.failures()).isEmpty) + try await store.close() + } + + @MainActor + @Test("an abandoned restore reports once and a later restore retries synchronization") + func abandonedRestoreReportsOnceAndRetries() async throws { + let synchronization = ControlledSynchronization() + let delegate = RecordingFailureDelegate() + let fixture = TestSourceFixture( + synchronize: { try await synchronization.run() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + let first = Task { @MainActor in try await store.restorePurchases() } + try await synchronization.waitForAttempt(1) + first.cancel() + await #expect(throws: CancellationError.self) { + _ = try await first.value + } + + await synchronization.failNext(TestFailure()) + try await delegate.waitForFailures(1) + + let retry = Task { @MainActor in try await store.restorePurchases() } + try await synchronization.waitForAttempt(2) + await synchronization.succeedNext() + let restored = try await retry.value + + let recorded = await delegate.failures() + #expect(recorded.count == 1) + #expect( + recorded[0].source + == .abandonedDirectOperation(.restorePurchases) + ) + #expect(recorded[0].underlyingError is TestFailure) + #expect(restored.transactions.isEmpty) + #expect(await synchronization.attemptCount() == 2) + try await store.close() + } + + @Test( + "coalesced direct operations preserve caller errors and one abandoned report" + ) + func coalescedDirectOperationFailureOwnership() async throws { + let query = ControlledEntitlementQuery() + let reservations = TestCounterSignal() + let synchronizations = TestCounterSignal() + let finishes = TestCounterSignal() + let delegate = RecordingFailureDelegate() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() }, + synchronize: { synchronizations.send() } + ) + let lifecycle = TransactionStoreLifecycle() + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + lifecycle: lifecycle, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate, + entitlementOutcome: { _ in }, + entitlementReservationDidEnqueue: { + reservations.send() + } + ) + runtime.start() + + try await query.waitForRequest(1) + #expect(reservations.value() == 1) + + let firstSnapshot = makeSubscriptionSnapshot( + id: 306, + productID: .tier1Monthly, + revision: "coalesced-first" + ) + let firstProcess = Task { + try await runtime.process( + .verified( + makeEnvelope( + snapshot: firstSnapshot, + revision: "coalesced-first" + ) { + finishes.send() + } + ), + leases: try lifecycle.beginOperation() + ) + } + let firstRestore = Task { + try await runtime.restorePurchases( + leases: try lifecycle.beginOperation() + ) + } + let firstRefresh = Task { + try await runtime.currentEntitlements( + leases: try lifecycle.beginOperation() + ) + } + + try await reservations.wait(for: 4) + #expect(finishes.value() == 1) + #expect(synchronizations.value() == 1) + + await query.succeed([]) + try await runtime.waitForInitialReadiness() + try await query.waitForRequest(2) + #expect(await fixture.entitlementQueryCount.value() == 2) + await query.fail(CoalescedRefreshFailure(batch: 1)) + + do { + _ = try await firstProcess.value + Issue.record("The process caller unexpectedly succeeded.") + } catch StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(let transaction), + underlyingError: let error + ) { + #expect(transaction == firstSnapshot) + #expect((error as? CoalescedRefreshFailure)?.batch == 1) + } catch { + Issue.record("The process caller received an unexpected error: \(error)") + } + + do { + _ = try await firstRestore.value + Issue.record("The restore caller unexpectedly succeeded.") + } catch StoreTransactionError.entitlementRefreshFailed( + after: .synchronizedPurchases, + underlyingError: let error + ) { + #expect((error as? CoalescedRefreshFailure)?.batch == 1) + } catch { + Issue.record("The restore caller received an unexpected error: \(error)") + } + + do { + _ = try await firstRefresh.value + Issue.record("The refresh caller unexpectedly succeeded.") + } catch let error as CoalescedRefreshFailure { + #expect(error.batch == 1) + } catch { + Issue.record("The refresh caller received an unexpected error: \(error)") + } + #expect((await delegate.failures()).isEmpty) + + let separatingRefresh = Task { + try await runtime.currentEntitlements( + leases: try lifecycle.beginOperation() + ) + } + try await reservations.wait(for: 5) + try await query.waitForRequest(3) + + let secondSnapshot = makeSubscriptionSnapshot( + id: 307, + productID: .tier2Monthly, + revision: "coalesced-abandoned" + ) + let secondProcess = Task { + try await runtime.process( + .verified( + makeEnvelope( + snapshot: secondSnapshot, + revision: "coalesced-abandoned" + ) { + finishes.send() + } + ), + leases: try lifecycle.beginOperation() + ) + } + let secondRestore = Task { + try await runtime.restorePurchases( + leases: try lifecycle.beginOperation() + ) + } + let secondRefresh = Task { + try await runtime.currentEntitlements( + leases: try lifecycle.beginOperation() + ) + } + + try await reservations.wait(for: 8) + #expect(finishes.value() == 2) + #expect(synchronizations.value() == 2) + + secondProcess.cancel() + secondRestore.cancel() + secondRefresh.cancel() + await #expect(throws: CancellationError.self) { + _ = try await secondProcess.value + } + await #expect(throws: CancellationError.self) { + _ = try await secondRestore.value + } + await #expect(throws: CancellationError.self) { + _ = try await secondRefresh.value + } + + await query.succeed([]) + _ = try await separatingRefresh.value + try await query.waitForRequest(4) + #expect(await fixture.entitlementQueryCount.value() == 4) + await query.fail(CoalescedRefreshFailure(batch: 2)) + try await delegate.waitForFailures(1) + + await lifecycle.close { + await runtime.shutdown() + } + + let failures = await delegate.failures() + #expect(failures.count == 1) + #expect( + (failures.first?.underlyingError as? CoalescedRefreshFailure)? + .batch == 2 + ) + if let source = failures.first?.source { + guard case .abandonedDirectOperation(let operation) = source else { + Issue.record("The failed batch had no abandoned direct owner.") + return + } + #expect( + [ + StoreTransactionOperation.processPurchase, + .refreshEntitlements, + .restorePurchases, + ].contains(operation) + ) + } + #expect(reservations.value() == 8) + #expect(await fixture.entitlementQueryCount.value() == 4) + } + + @MainActor + @Test("immediate purchase outcomes perform no transaction work") + func immediatePurchaseOutcomes() async throws { + let delegate = RecordingPolicyDelegate() + let fixture = TestSourceFixture() + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + #expect(try await store.process(.pending) == .pending) + #expect(try await store.process(.userCancelled) == .userCancelled) + #expect(await delegate.decisionCount() == 0) + #expect(await fixture.entitlementQueryCount.value() == 1) + try await store.close() + } + + @MainActor + @Test("cancellation before admission starts no operation-specific work") + func cancellationBeforeAdmission() async throws { + let delegate = RecordingPolicyDelegate() + let historyCalls = TestSignal() + let synchronizationCalls = TestSignal() + let fixture = TestSourceFixture( + history: { _ in + await historyCalls.send() + return [] + }, + synchronize: { await synchronizationCalls.send() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + try await expectCancellationBeforeInvocation { + try await store.process(.pending) + } + try await expectCancellationBeforeInvocation { + try await store.refreshEntitlements() + } + try await expectCancellationBeforeInvocation { + try await store.history(for: "cancelled.history") + } + try await expectCancellationBeforeInvocation { + try await store.restorePurchases() + } + + #expect(await delegate.decisionCount() == 0) + #expect(await fixture.entitlementQueryCount.value() == 1) + #expect(await historyCalls.value() == 0) + #expect(await synchronizationCalls.value() == 0) + try await store.close() + } + + @MainActor + @Test("distinct revisions of one transaction are processed independently") + func distinctRevisionsProcessRevocation() async throws { + let active = makeSnapshot( + id: 303, + productID: TestPlans.ProductID.tier2Monthly.rawValue, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + jws: "active-303" + ) + let revoked = makeSnapshot( + id: active.id, + productID: active.productID, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + signedDate: Date(timeIntervalSince1970: 500), + jws: "revoked-303", + revocationDate: Date(timeIntervalSince1970: 499) + ) + let current = EntitlementValueSource([]) + let delegate = RecordingPolicyDelegate() + let finishes = StringRecorder() + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + await current.replace(with: [active]) + _ = try await store.process( + .verified( + makeEnvelope(snapshot: active, revision: "active-303") { + await finishes.append("active") + } + ) + ) + await current.replace(with: []) + _ = try await store.process( + .verified( + makeEnvelope(snapshot: revoked, revision: "revoked-303") { + await finishes.append("revoked") + } + ) + ) + + #expect(await delegate.decisionCount() == 2) + #expect(await finishes.snapshot() == ["active", "revoked"]) + #expect(store.entitlements?.transactions.isEmpty == true) + #expect(store.activeEntitlements == []) + try await store.close() + } + + @Test("completed revision cache evicts its oldest bounded entry") + func completedRevisionCacheEviction() { + let first = Data("first".utf8) + let second = Data("second".utf8) + let third = Data("third".utf8) + var cache = CompletedRevisionCache(capacity: 2) + + cache.insert(first, state: .satisfied) + cache.insert(second, state: .needsRefresh) + cache.insert(first, state: .needsRefresh) + cache.insert(third, state: .satisfied) + + #expect(cache.state(for: first) == nil) + guard case .needsRefresh = cache.state(for: second) else { + Issue.record("The second completed revision was unexpectedly evicted.") + return + } + guard case .satisfied = cache.state(for: third) else { + Issue.record("The newest completed revision was not retained.") + return + } + } + + @MainActor + @Test("a successful refresh recovers failed startup state") + func failedStartupRecoversToReady() async throws { + let query = ControlledEntitlementQuery() + let snapshot = makeSubscriptionSnapshot( + id: 304, + productID: .tier1Yearly + ) + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + try await query.waitForRequest(1) + await query.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + try await store.waitForInitialReadiness() + } + guard case .failed = store.entitlementStatus else { + Issue.record("The failed startup was not observable.") + try await store.close() + return + } + #expect(store.entitlements == nil) + #expect(store.activeEntitlements == nil) + + let refresh = Task { @MainActor in try await store.refreshEntitlements() } + try await query.waitForRequest(2) + await query.succeed([snapshot]) + _ = try await refresh.value + + guard case .ready = store.entitlementStatus else { + Issue.record("A successful refresh did not recover readiness.") + try await store.close() + return + } + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("close drains failure notification, releases delegate, and admits no later callback") + func closeDrainsFailureNotificationAndDelegate() async throws { + let callbackStarted = TestSignal() + let callbackFinished = TestSignal() + let callbackGate = NonCancellableGate() + let callbackCount = TestSignal() + let delegateDeinitialized = TestSignal() + var token: LifetimeToken? = LifetimeToken(signal: delegateDeinitialized) + weak let weakToken = token + var delegate: GatedFailureDelegate? = GatedFailureDelegate( + token: token!, + started: callbackStarted, + finished: callbackFinished, + calls: callbackCount, + gate: callbackGate + ) + let fixture = TestSourceFixture() + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + fixture.updates.yield( + .unverified( + revision: Data("close-failure".utf8), + error: TestFailure() + ) + ) + try await callbackStarted.wait(for: 1) + delegate = nil + token = nil + + let closeCompleted = TestSignal() + let close = Task { @MainActor in + try await store.close() + await closeCompleted.send() + } + await store.waitUntilClosing() + #expect(await closeCompleted.value() == 0) + #expect(weakToken != nil) + + callbackGate.open() + try await close.value + try await delegateDeinitialized.wait(for: 1) + + #expect(await callbackFinished.value() == 1) + #expect(await closeCompleted.value() == 1) + #expect(await callbackCount.value() == 1) + #expect(weakToken == nil) + + fixture.updates.yield( + .unverified( + revision: Data("late-failure".utf8), + error: TestFailure() + ) + ) + #expect(await callbackCount.value() == 1) + } + + @MainActor + @Test("deinit drains a suspended decision before releasing its delegate") + func deinitDrainsDecision() async throws { + let decisionStarted = TestSignal() + let decisionFinished = TestSignal() + let decisionGate = NonCancellableGate() + let delegateDeinitialized = TestSignal() + var token: LifetimeToken? = LifetimeToken(signal: delegateDeinitialized) + weak let weakToken = token + var delegate: GatedPolicyDelegate? = GatedPolicyDelegate( + token: token!, + started: decisionStarted, + finished: decisionFinished, + gate: decisionGate + ) + let finishes = TestSignal() + let fixture = TestSourceFixture() + var store: TransactionStore? = TransactionStore( + source: fixture.source, + lifecycle: TransactionStoreLifecycle(), + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store!.waitForInitialReadiness() + fixture.updates.yield( + .verified( + makeEnvelope( + snapshot: makeSubscriptionSnapshot( + id: 305, + productID: .tier1Monthly + ) + ) { + await finishes.send() + } + ) + ) + try await decisionStarted.wait(for: 1) + + weak let weakStore = store + delegate = nil + token = nil + store = nil + + #expect(weakStore == nil) + #expect(weakToken != nil) + #expect(await finishes.value() == 0) + + decisionGate.open() + try await decisionFinished.wait(for: 1) + try await delegateDeinitialized.wait(for: 1) + + #expect(await finishes.value() == 1) + #expect(weakToken == nil) + } + + @MainActor + @Test("deinit retains runtime while admitted restore synchronization is suspended") + func deinitDrainsRestoreSynchronization() async throws { + let synchronization = ControlledSynchronization() + let delegateDeinitialized = TestSignal() + var token: LifetimeToken? = LifetimeToken(signal: delegateDeinitialized) + weak let weakToken = token + var delegate: TokenHoldingDelegate? = TokenHoldingDelegate(token: token!) + let fixture = TestSourceFixture( + synchronize: { try await synchronization.run() } + ) + var store: TransactionStore? = TransactionStore( + source: fixture.source, + lifecycle: TransactionStoreLifecycle(), + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store!.waitForInitialReadiness() + + var restore: Task? = { + let admittedStore = store! + return Task { @MainActor in + try await admittedStore.restorePurchases() + } + }() + try await synchronization.waitForAttempt(1) + restore!.cancel() + await #expect(throws: CancellationError.self) { + _ = try await restore!.value + } + restore = nil + + weak let weakStore = store + delegate = nil + token = nil + store = nil + + #expect(weakStore == nil) + #expect(weakToken != nil) + + await synchronization.succeedNext() + try await delegateDeinitialized.wait(for: 1) + #expect(weakToken == nil) + } + + @MainActor + @Test("deinit drains a suspended failure notification") + func deinitDrainsFailureNotification() async throws { + let callbackStarted = TestSignal() + let callbackFinished = TestSignal() + let callbackGate = NonCancellableGate() + let callbackCount = TestSignal() + let delegateDeinitialized = TestSignal() + var token: LifetimeToken? = LifetimeToken(signal: delegateDeinitialized) + weak let weakToken = token + var delegate: GatedFailureDelegate? = GatedFailureDelegate( + token: token!, + started: callbackStarted, + finished: callbackFinished, + calls: callbackCount, + gate: callbackGate + ) + let fixture = TestSourceFixture() + var store: TransactionStore? = TransactionStore( + source: fixture.source, + lifecycle: TransactionStoreLifecycle(), + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store!.waitForInitialReadiness() + fixture.updates.yield( + .unverified( + revision: Data("deinit-failure".utf8), + error: TestFailure() + ) + ) + try await callbackStarted.wait(for: 1) + + weak let weakStore = store + delegate = nil + token = nil + store = nil + + #expect(weakStore == nil) + #expect(weakToken != nil) + + callbackGate.open() + try await callbackFinished.wait(for: 1) + try await delegateDeinitialized.wait(for: 1) + + #expect(await callbackCount.value() == 1) + #expect(weakToken == nil) + } +} + +@MainActor +private func expectCancellationBeforeInvocation( + _ operation: @escaping @MainActor @Sendable () async throws -> Output +) async throws { + let waiting = TestSignal() + let release = ProcessingReceipt() + let task = Task { @MainActor in + await waiting.send() + _ = try? await release.terminalValue() + return try await operation() + } + try await waiting.wait(for: 1) + task.cancel() + release.succeed(()) + do { + _ = try await task.value + Issue.record("A pre-cancelled operation unexpectedly succeeded.") + } catch is CancellationError { + } catch { + Issue.record("A pre-cancelled operation threw an unexpected error: \(error)") + } +} + +private final class NonCancellableGate: Sendable { + private let receipt = ProcessingReceipt() + + func wait() async { + _ = try? await receipt.terminalValue() + } + + func open() { + receipt.succeed(()) + } +} + +private struct CoalescedRefreshFailure: Error, Sendable { + let batch: Int +} + +private actor ControlledSynchronization { + private var attempts: [ProcessingReceipt] = [] + private let started = TestSignal() + + func run() async throws { + let receipt = ProcessingReceipt() + attempts.append(receipt) + await started.send() + try await receipt.terminalValue() + } + + func waitForAttempt(_ count: Int) async throws { + try await started.wait(for: count) + } + + func succeedNext() { + precondition(!attempts.isEmpty) + attempts.removeFirst().succeed(()) + } + + func failNext(_ error: any Error) { + precondition(!attempts.isEmpty) + attempts.removeFirst().fail(error) + } + + func attemptCount() async -> Int { + await started.value() + } +} + +private actor RecordingPolicyDelegate: TransactionStoreDelegate { + private var decisions = 0 + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + return .automatic + } + + func decisionCount() -> Int { + decisions + } +} + +private actor GatedPolicyDelegate: TransactionStoreDelegate { + private let token: LifetimeToken? + private let started: TestSignal + private let finished: TestSignal + private let gate: NonCancellableGate + private var decisions = 0 + + init( + token: LifetimeToken? = nil, + started: TestSignal, + finished: TestSignal, + gate: NonCancellableGate + ) { + self.token = token + self.started = started + self.finished = finished + self.gate = gate + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + _ = token + decisions += 1 + await started.send() + await gate.wait() + await finished.send() + return .automatic + } + + func decisionCount() -> Int { + decisions + } +} + +private actor RecordingFailureDelegate: TransactionStoreDelegate { + private var recorded: [StoreTransactionBackgroundFailure] = [] + private let signal = TestSignal() + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + recorded.append(failure) + await signal.send() + } + + func waitForFailures(_ count: Int) async throws { + try await signal.wait(for: count) + } + + func failures() -> [StoreTransactionBackgroundFailure] { + recorded + } +} + +private actor GatedFailureDelegate: TransactionStoreDelegate { + private let token: LifetimeToken + private let started: TestSignal + private let finished: TestSignal + private let calls: TestSignal + private let gate: NonCancellableGate + + init( + token: LifetimeToken, + started: TestSignal, + finished: TestSignal, + calls: TestSignal, + gate: NonCancellableGate + ) { + self.token = token + self.started = started + self.finished = finished + self.calls = calls + self.gate = gate + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + _ = token + await calls.send() + await started.send() + await gate.wait() + await finished.send() + } +} + +private actor TokenHoldingDelegate: TransactionStoreDelegate { + private let token: LifetimeToken + + init(token: LifetimeToken) { + self.token = token + } +} + +private final class LifetimeToken: Sendable { + private let signal: TestSignal + + init(signal: TestSignal) { + self.signal = signal + } + + deinit { + let signal = signal + Task { await signal.send() } + } +} diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift deleted file mode 100644 index fc96e7e..0000000 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ /dev/null @@ -1,1192 +0,0 @@ -import Foundation -import StoreKit -import Testing -@testable import StoreTransactionKit - -@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() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - _ = try await session.start() - - for result: Product.PurchaseResult in [.pending, .userCancelled] { - let gate = TestGate() - let process = Task { - _ = try? await gate.wait() - return try await session.process(result) - } - process.cancel() - await gate.open() - - await #expect(throws: CancellationError.self) { - _ = try await process.value - } - } - - try await session.close() - } - - @Test("concurrent restore callers share one synchronization") - 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() - 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) - - 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 #expect(throws: TestFailure.self) { - _ = try await owner.value - } - await runtime.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") - func abandonedRefreshFailure() async throws { - let query = ControlledEntitlementQuery() - let reported = TestSignal() - let reports = StringRecorder() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { failure in - switch failure.source { - case .abandonedDirectOperation(.currentEntitlements): - await reports.append("abandoned-refresh") - default: - await reports.append("unexpected") - } - await reported.send() - } - ) - - let startup = Task { try await session.start() } - try await query.waitForRequest(1) - await query.succeed([]) - _ = try await startup.value - - let refresh = Task { try await session.currentEntitlements() } - try await query.waitForRequest(2) - refresh.cancel() - await #expect(throws: CancellationError.self) { - _ = try await refresh.value - } - await query.fail(TestFailure()) - 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) - let older = makeSnapshot(id: 1, purchaseDate: Date(timeIntervalSince1970: 10)) - let lowerID = makeSnapshot( - id: 2, - purchaseDate: sharedDate, - signedDate: Date(timeIntervalSince1970: 200) - ) - let higherIDRevoked = makeSnapshot( - id: 3, - purchaseDate: sharedDate, - signedDate: Date(timeIntervalSince1970: 200), - revocationDate: Date(timeIntervalSince1970: 300) - ) - let newestSigned = makeSnapshot( - id: 4, - purchaseDate: sharedDate, - signedDate: Date(timeIntervalSince1970: 201) - ) - let fixture = TestSourceFixture( - history: { _ in - [older, lowerID, higherIDRevoked, newestSigned] - } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - _ = try await session.start() - - let history = try await session.history(for: "product") - - #expect(history.map(\.id) == [4, 3, 2, 1]) - #expect(history[1].revocationDate != nil) - try await session.close() - } - - @Test("background entitlement refresh failures have their own source") - func backgroundEntitlementRefreshFailure() async throws { - let query = ControlledEntitlementQuery() - let reported = TestSignal() - let reports = StringRecorder() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { failure in - await reports.append( - "\(failure.source)-\(failure.transactionID ?? 0)-\(failure.productID ?? "")" - ) - await reported.send() - } - ) - - let startup = Task { try await session.start() } - try await query.waitForRequest(1) - await query.succeed([]) - _ = try await startup.value - - fixture.updates.yield( - .verified(makeEnvelope(snapshot: makeSnapshot(id: 19))) - ) - try await query.waitForRequest(2) - await query.fail(TestFailure()) - 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() - let handlerGate = TestGate() - let events = StringRecorder() - let closeCallersStarted = TestSignal() - let closeCallersFinished = TestSignal() - let fixture = TestSourceFixture() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await events.append("handle-start") - await handlerStarted.send() - try await handlerGate.wait() - await events.append("handle-end") - }, - reportFailure: { _ in } - ) - _ = try await session.start() - fixture.updates.yield( - .verified( - makeEnvelope(snapshot: makeSnapshot(id: 20)) { - await events.append("finish") - })) - try await handlerStarted.wait(for: 1) - - let firstClose = Task { - await closeCallersStarted.send() - try await session.close() - await events.append("close-1") - await closeCallersFinished.send() - } - let secondClose = Task { - await closeCallersStarted.send() - try await session.close() - await events.append("close-2") - await closeCallersFinished.send() - } - try await closeCallersStarted.wait(for: 2) - #expect(await closeCallersFinished.value() == 0) - - await handlerGate.open() - try await firstClose.value - try await secondClose.value - - let recorded = await events.snapshot() - #expect(recorded.prefix(3) == ["handle-start", "handle-end", "finish"]) - #expect(Set(recorded.suffix(2)) == ["close-1", "close-2"]) - } -} - -@Suite("Completed revision cache") -struct CompletedRevisionCacheTests { - @Test("eviction removes the oldest completed revision") - func eviction() { - var cache = CompletedRevisionCache(capacity: 2) - let first = Data("first".utf8) - let second = Data("second".utf8) - let third = Data("third".utf8) - - cache.insert(first) - cache.insert(second) - cache.insert(third) - - #expect(!cache.contains(first)) - #expect(cache.contains(second)) - #expect(cache.contains(third)) - } -} - -@Suite("Task completion bag", .timeLimit(.minutes(1))) -struct TaskCompletionBagTests { - @Test("completed tasks are released when the bag becomes empty") - func completedTasksAreReleased() async throws { - let bag = TaskCompletionBag() - let completed = TestSignal() - - for _ in 0..<32 { - bag.insert( - Task { - await completed.send() - }) - } - try await completed.wait(for: 32) - await bag.waitForAll() - #expect(bag.retainedTaskCount() == 0) - } -} diff --git a/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift new file mode 100644 index 0000000..dbbd719 --- /dev/null +++ b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift @@ -0,0 +1,702 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Runtime owners", .timeLimit(.minutes(1))) +struct RuntimeOwnerTests { + @MainActor + @Test("unfinished reconciliation reaches a fixed point before publication") + func fixedPointReconciliation() async throws { + let first = makeSubscriptionSnapshot( + id: 20, + productID: .tier1Monthly + ) + let second = makeSubscriptionSnapshot( + id: 21, + productID: .tier2Monthly + ) + let unfinished = UnfinishedValueSource() + let finishes = UInt64Recorder() + await unfinished.replace(with: [ + .verified( + makeEnvelope(snapshot: first) { + await finishes.append(first.id) + await unfinished.replace(with: [ + .verified( + makeEnvelope(snapshot: second) { + await finishes.append(second.id) + await unfinished.replace(with: []) + } + ) + ]) + } + ) + ]) + let fixture = TestSourceFixture( + currentEntitlements: { [first, second] }, + queryUnfinished: { await unfinished.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + try await store.waitForInitialReadiness() + + #expect(await finishes.snapshot() == [first.id, second.id]) + #expect(store.entitlements?.transactions == [first, second]) + #expect(store.activeEntitlements == [.tier1, .tier2]) + try await store.close() + } + + @MainActor + @Test("reconciliation reports every exact decision failure once") + func reconciliationReportsEveryDecisionFailure() async throws { + let first = makeSubscriptionSnapshot( + id: 201, + productID: .tier1Monthly, + revision: "failure-201" + ) + let second = makeSubscriptionSnapshot( + id: 202, + productID: .tier2Monthly, + revision: "failure-202" + ) + let delegate = FailingDecisionDelegate() + let fixture = TestSourceFixture( + queryUnfinished: { + [ + .verified(makeEnvelope(snapshot: first)), + .verified(makeEnvelope(snapshot: second)), + ] + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + + await #expect(throws: TransactionDecisionFailure.self) { + try await store.waitForInitialReadiness() + } + try await delegate.waitForFailures(2) + try await store.close() + + let failures = await delegate.failures() + #expect(failures.map(\.transactionID) == [first.id, second.id]) + #expect(failures.allSatisfy { $0.source == .unfinished }) + #expect( + failures.compactMap { + ($0.underlyingError as? TransactionDecisionFailure)?.id + } == [first.id, second.id] + ) + } + + @MainActor + @Test("verified remainder publishes before verification diagnostics") + func verificationFailurePublishesRemainder() async throws { + let snapshot = makeSubscriptionSnapshot( + id: 22, + productID: .tier1Monthly + ) + let holder = TransactionStoreHolder() + let delegate = StateReadingFailureDelegate(holder: holder) + let fixture = TestSourceFixture( + currentEntitlements: { [snapshot] }, + currentEntitlementVerificationFailures: { + [ + StoreTransactionVerificationError( + underlyingError: TestFailure() + ) + ] + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + holder.set(store) + + try await store.waitForInitialReadiness() + try await delegate.waitForFailure() + + #expect(await delegate.observedReadyState()) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("an abandoned refresh transfers one terminal failure to the background") + func cancellationTransfersFailureOwnership() async throws { + let query = ControlledEntitlementQuery() + let delegate = FailureRecordingDelegate() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let refresh = Task { @MainActor in + try await store.refreshEntitlements() + } + try await query.waitForRequest(2) + refresh.cancel() + await #expect(throws: CancellationError.self) { + _ = try await refresh.value + } + await query.fail(TestFailure()) + try await delegate.waitForFailures(1) + + let failures = await delegate.failures() + #expect(failures.count == 1) + #expect( + failures.first?.source + == .abandonedDirectOperation(.refreshEntitlements) + ) + #expect(failures.first?.underlyingError is TestFailure) + try await store.close() + } + + @MainActor + @Test("cancellation after unverified delivery admission transfers failure ownership") + func unverifiedCancellationTransfersFailureOwnership() async throws { + let delegate = FailureRecordingDelegate() + let fixture = TestSourceFixture() + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + let admitted = TestSignal() + let release = ProcessingReceipt() + let operation = Task { @MainActor in + try await store.process( + .unverified( + revision: Data("unverified".utf8), + error: TestFailure() + ) + ) { + await admitted.send() + _ = try? await release.terminalValue() + } + } + try await admitted.wait(for: 1) + + operation.cancel() + release.succeed(()) + + await #expect(throws: CancellationError.self) { + _ = try await operation.value + } + try await delegate.waitForFailures(1) + try await store.close() + let failures = await delegate.failures() + #expect(failures.count == 1) + #expect( + failures[0].source + == .abandonedDirectOperation(.processPurchase) + ) + #expect(failures[0].underlyingError is TestFailure) + } + + @MainActor + @Test("close drains a producer element returned before iteration admission sealed") + func producerCloseRaceDrainsPublication() async throws { + let snapshot = makeSubscriptionSnapshot( + id: 23, + productID: .tier2Monthly + ) + let current = EntitlementValueSource([]) + let delegate = GatedDecisionDelegate() + let finishes = TestSignal() + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + fixture.updates.yield( + .verified( + makeEnvelope(snapshot: snapshot, revision: "producer-close") { + await finishes.send() + } + ) + ) + try await delegate.waitUntilDecisionStarts() + + let close = Task { @MainActor in try await store.close() } + await store.waitUntilClosing() + let cancelledClose = Task { @MainActor in try await store.close() } + cancelledClose.cancel() + await delegate.allowDecision() + try await close.value + try await cancelledClose.value + + #expect(await finishes.value() == 1) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier2]) + } + + @MainActor + @Test("history is all-or-nothing and has deterministic newest-first order") + func historyOrdering() async throws { + let oldest = makeSnapshot( + id: 1, + purchaseDate: Date(timeIntervalSince1970: 1), + signedDate: Date(timeIntervalSince1970: 2) + ) + let newestLowerID = makeSnapshot( + id: 2, + purchaseDate: Date(timeIntervalSince1970: 3), + signedDate: Date(timeIntervalSince1970: 4), + jws: "b" + ) + let newestHigherID = makeSnapshot( + id: 3, + purchaseDate: Date(timeIntervalSince1970: 3), + signedDate: Date(timeIntervalSince1970: 4), + jws: "a" + ) + let fixture = TestSourceFixture( + history: { _ in [oldest, newestLowerID, newestHigherID] } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await store.waitForInitialReadiness() + + let history = try await store.history(for: "history.product") + + #expect(history == [newestHigherID, newestLowerID, oldest]) + try await store.close() + } + + @MainActor + @Test("restore wraps only a refresh failure that follows successful sync") + func restoreFailureContext() async throws { + let query = ControlledEntitlementQuery() + let syncs = TestSignal() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() }, + synchronize: { await syncs.send() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let restore = Task { @MainActor in + try await store.restorePurchases() + } + try await query.waitForRequest(2) + await query.fail(TestFailure()) + + do { + _ = try await restore.value + Issue.record("Expected a post-sync refresh failure.") + } catch StoreTransactionError.entitlementRefreshFailed( + after: .synchronizedPurchases, + underlyingError: let error + ) { + #expect(error is TestFailure) + } + #expect(await syncs.value() == 1) + try await store.close() + } + + @MainActor + @Test("synthetic capability errors precede work but closed state takes priority") + func syntheticOperationGateOrdering() async throws { + let source = SyntheticStoreTransactionSource( + currentEntitlements: { [] } + ) + let store = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog, + syntheticSource: source, + unavailableOperationError: { operation in + StoreTransactionError.operationUnavailableInOverride( + operation: operation + ) + } + ) + try await store.waitForInitialReadiness() + + do { + _ = try await store.history(for: "product") + Issue.record("Synthetic source unexpectedly queried history.") + } catch StoreTransactionError.operationUnavailableInOverride( + operation: .history + ) {} + + try await store.close() + do { + _ = try await store.history(for: "product") + Issue.record("Closed synthetic store exposed capability error.") + } catch StoreTransactionError.closed {} + } + + @MainActor + @Test("synthetic delivery uses production policy, acknowledgement, and publication") + func syntheticDeliveryProductionPath() async throws { + let current = EntitlementValueSource([]) + let syntheticSource = SyntheticStoreTransactionSource( + currentEntitlements: { await current.read() } + ) + let snapshot = makeSubscriptionSnapshot( + id: 24, + productID: .tier1Yearly, + revision: "synthetic-24" + ) + let store = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog, + syntheticSource: syntheticSource, + unavailableOperationError: { + SyntheticOperationError(operation: $0) + } + ) + try await store.waitForInitialReadiness() + + do { + _ = try await store.history(for: snapshot.productID) + Issue.record("Synthetic store unexpectedly queried history.") + } catch let error as SyntheticOperationError { + #expect(error.operation == .history) + } + + let completed = try await store.processSyntheticDelivery( + .synthetic(snapshot: snapshot) { + await current.replace(with: [snapshot]) + } + ) + + #expect(completed == snapshot) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test( + "delegate decisions reject direct and inherited-child reentrancy", + arguments: [ReentryMode.direct, .child] + ) + func decisionReentrancy(mode: ReentryMode) async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let holder = TransactionStoreHolder() + let delegate = ReentrantDecisionDelegate(holder: holder, mode: mode) + let snapshot = makeSubscriptionSnapshot( + id: mode == .direct ? 30 : 31, + productID: .tier1Monthly + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + holder.set(store) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + _ = try await store.process(.verified(makeEnvelope(snapshot: snapshot))) + + #expect(await delegate.sawReentrantError()) + try await store.close() + } + + @MainActor + @Test("failure callbacks reject same-store reentrancy") + func failureCallbackReentrancy() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let holder = TransactionStoreHolder() + let delegate = ReentrantFailureDelegate(holder: holder) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + holder.set(store) + + try await query.waitForRequest(1) + await query.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + try await store.waitForInitialReadiness() + } + try await delegate.waitUntilCalled() + + #expect(await delegate.sawReentrantError()) + try await store.close() + } + + @MainActor + @Test("dropping an unclosed store cancels physical work before releasing its live lease") + func unclosedStoreCancellation() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let liveLease = LiveTransactionStoreLease.acquire() + var store: TransactionStore? = TransactionStore( + source: fixture.source, + lifecycle: TransactionStoreLifecycle(liveLease: liveLease), + subscriptionCatalog: testSubscriptionCatalog + ) + try await query.waitForRequest(1) + weak let weakStore = store + + store = nil + + #expect(weakStore == nil) + try await query.waitForCancellation() + await liveLease.waitUntilReleased() + let replacement = LiveTransactionStoreLease.acquire() + replacement.release() + } + + #if os(macOS) + @Test("the live lease is process-wide and releases explicitly") + func liveLeaseAuthority() async { + await #expect(processExitsWith: .failure) { + let first = LiveTransactionStoreLease.acquire() + let second = LiveTransactionStoreLease.acquire() + _ = (first, second) + } + + let first = LiveTransactionStoreLease.acquire() + first.release() + let second = LiveTransactionStoreLease.acquire() + second.release() + } + + @Test("live-store exclusion crosses TransactionStore generic specializations") + func crossGenericLiveLease() async { + await #expect(processExitsWith: .failure) { + await MainActor.run { + let first = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog + ) + let second = TransactionStore( + subscriptionCatalog: otherSubscriptionCatalog + ) + _ = (first, second) + } + } + } + #endif +} + +private enum OtherEntitlement: Hashable, Sendable { + case paid +} + +private enum OtherPlans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID(rawValue: "other.subscription.group") + + enum ProductID: String, Hashable, Sendable { + case monthly = "other.subscription.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.monthly, entitlement: .paid) + } +} + +private let otherSubscriptionCatalog = + AutoRenewableSubscriptionCatalog(OtherPlans.self) + +enum ReentryMode: Sendable { + case direct + case child +} + +private struct SyntheticOperationError: Error, Sendable { + let operation: StoreTransactionOperation +} + +private struct TransactionDecisionFailure: Error, Sendable { + let id: UInt64 +} + +private actor FailingDecisionDelegate: TransactionStoreDelegate { + private var recorded: [StoreTransactionBackgroundFailure] = [] + private let signal = TestSignal() + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + throw TransactionDecisionFailure(id: transaction.id) + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + recorded.append(failure) + await signal.send() + } + + func waitForFailures(_ count: Int) async throws { + try await signal.wait(for: count) + } + + func failures() -> [StoreTransactionBackgroundFailure] { + recorded + } +} + +private actor FailureRecordingDelegate: TransactionStoreDelegate { + private var recorded: [StoreTransactionBackgroundFailure] = [] + private let signal = TestSignal() + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + recorded.append(failure) + await signal.send() + } + + func waitForFailures(_ count: Int) async throws { + try await signal.wait(for: count) + } + + func failures() -> [StoreTransactionBackgroundFailure] { + recorded + } +} + +private actor StateReadingFailureDelegate: TransactionStoreDelegate { + private let holder: TransactionStoreHolder + private let signal = TestSignal() + private var sawReady = false + + init(holder: TransactionStoreHolder) { + self.holder = holder + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + sawReady = await MainActor.run { + guard case .ready = holder.get().entitlementStatus else { + return false + } + return true + } + await signal.send() + } + + func waitForFailure() async throws { + try await signal.wait(for: 1) + } + + func observedReadyState() -> Bool { + sawReady + } +} + +private actor GatedDecisionDelegate: TransactionStoreDelegate { + private let started = TestSignal() + private let gate = TestGate() + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + await started.send() + try await gate.wait() + return .automatic + } + + func waitUntilDecisionStarts() async throws { + try await started.wait(for: 1) + } + + func allowDecision() async { + await gate.open() + } +} + +private actor ReentrantDecisionDelegate: TransactionStoreDelegate { + private let holder: TransactionStoreHolder + private let mode: ReentryMode + private var rejected = false + + init( + holder: TransactionStoreHolder, + mode: ReentryMode + ) { + self.holder = holder + self.mode = mode + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + do { + switch mode { + case .direct: + _ = try await holder.get().refreshEntitlements() + case .child: + _ = try await Task { @MainActor in + try await holder.get().refreshEntitlements() + }.value + } + } catch StoreTransactionError.reentrantOperation( + operation: .refreshEntitlements + ) { + rejected = true + } + return .automatic + } + + func sawReentrantError() -> Bool { + rejected + } +} + +private actor ReentrantFailureDelegate: TransactionStoreDelegate { + private let holder: TransactionStoreHolder + private let called = TestSignal() + private var rejected = false + + init(holder: TransactionStoreHolder) { + self.holder = holder + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + do { + _ = try await holder.get().refreshEntitlements() + } catch StoreTransactionError.reentrantOperation( + operation: .refreshEntitlements + ) { + rejected = true + } catch { + Issue.record("Unexpected reentrant failure: \(error)") + } + await called.send() + } + + func waitUntilCalled() async throws { + try await called.wait(for: 1) + } + + func sawReentrantError() -> Bool { + rejected + } +} diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift deleted file mode 100644 index 6c17d2a..0000000 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ /dev/null @@ -1,388 +0,0 @@ -import Testing -@testable import StoreTransactionKit - -@Suite("Observable TransactionStore", .timeLimit(.minutes(1))) -@MainActor -struct StoreTests { - private enum SubscriptionID: String, Hashable, Sendable { - case monthly = "subscription.monthly" - 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([ - makeSnapshot(id: 1, productID: SubscriptionID.monthly.rawValue), - makeSnapshot(id: 2, productID: "nonconsumable.outside-enum"), - ]) - let fixture = TestSourceFixture( - currentEntitlements: { await values.read() } - ) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - #expect(store.activeEntitlements == nil) - await store.waitForStartup() - - #expect(store.activeEntitlements == [.monthly]) - #expect( - store.entitlements?.transactions.map(\.productID) == [ - "nonconsumable.outside-enum", - SubscriptionID.monthly.rawValue, - ]) - - await values.replace(with: [ - makeSnapshot(id: 3, productID: SubscriptionID.yearly.rawValue) - ]) - _ = try await store.refreshEntitlements() - - #expect(store.activeEntitlements == [.yearly]) - #expect(store.startupError == nil) - 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() } - ) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - #expect(store.activeEntitlements == nil) - try await query.waitForRequest(1) - await query.fail(TestFailure()) - await store.waitForStartup() - #expect(store.entitlements == nil) - #expect(store.activeEntitlements == nil) - #expect(store.startupError != nil) - - let refresh = Task { try await store.refreshEntitlements() } - try await query.waitForRequest(2) - await query.succeed([ - makeSnapshot(id: 4, productID: SubscriptionID.yearly.rawValue) - ]) - _ = try await refresh.value - - #expect(store.activeEntitlements == [.yearly]) - #expect(store.startupError == nil) - 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: { [] }) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - #expect(store.activeEntitlements == nil) - await store.waitForStartup() - #expect(store.activeEntitlements == Set()) - try await store.close() - } - - @Test("the TransactionStore facade rejects handler reentry during startup") - func startupHandlerReentrancy() async throws { - let holder = TransactionStoreHolder() - let rejected = TestSignal() - let finished = TestSignal() - 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("TransactionStore unexpectedly allowed handler reentry.") - } catch StoreTransactionError.reentrantOperation( - operation: .currentEntitlements - ) { - await rejected.send() - } catch { - Issue.record("Unexpected TransactionStore reentrancy error: \(error)") - } - }, - reportFailure: { _ in } - ) - holder.set(store) - - await store.waitForStartup() - - #expect(await rejected.value() == 1) - #expect(await finished.value() == 1) - try await store.close() - } - - @Test("a cancelled caller stops waiting without cancelling startup") - func startupWaitCancellation() async throws { - let query = ControlledEntitlementQuery() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - try await query.waitForRequest(1) - - let refresh = Task { try await store.refreshEntitlements() } - refresh.cancel() - await #expect(throws: CancellationError.self) { - _ = try await refresh.value - } - #expect(await fixture.entitlementQueryCount.value() == 1) - - await query.succeed([]) - await store.waitForStartup() - try await store.close() - } - - @Test("a rejected reentrant close does not cancel startup") - func reentrantClosePreservesStartup() async throws { - let query = ControlledEntitlementQuery() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() }, - queryUnfinished: { - [.verified(makeEnvelope(snapshot: makeSnapshot(id: 6)))] - } - ) - let holder = TransactionStoreHolder() - let closeRejected = TestSignal() - let startupCompleted = TestSignal() - - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in - do { - try await holder.get().close() - Issue.record("TransactionStore unexpectedly allowed a reentrant close.") - } catch StoreTransactionError.reentrantOperation( - operation: .close - ) { - await closeRejected.send() - } catch { - Issue.record("Unexpected TransactionStore reentrancy error: \(error)") - } - }, - reportFailure: { _ in } - ) - holder.set(store) - let startupWaiter = Task { - await store.waitForStartup() - await startupCompleted.send() - } - - try await query.waitForRequest(1) - #expect(await startupCompleted.value() == 0) - try await closeRejected.wait(for: 1) - - await query.succeed([]) - await startupWaiter.value - #expect(await startupCompleted.value() == 1) - try await store.close() - } -} diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift deleted file mode 100644 index 851ea13..0000000 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ /dev/null @@ -1,787 +0,0 @@ -import Foundation -import Testing -@testable import StoreTransactionKit - -@Suite("StoreTransactionSession", .timeLimit(.minutes(1))) -struct StoreTransactionSessionTests { - @Test("start publishes initial entitlements and close terminates producers") - func startAndClose() async throws { - let fixture = TestSourceFixture() - let publicationSizes = UInt64Recorder() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { value in - await publicationSizes.append(UInt64(value.transactions.count)) - }, - reportFailure: { _ in } - ) - - let readiness = try await session.start() - #expect(readiness.entitlements.transactions.isEmpty) - #expect(await publicationSizes.snapshot() == [0]) - - try await session.close() - 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() - let events = StringRecorder() - let finished = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { snapshot in - await events.append("handle-\(snapshot.id)") - }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - Issue.record("Unexpected background failure: \(failure)") - } - ) - _ = try await session.start() - - let snapshot = makeSnapshot(id: 10) - fixture.updates.yield( - .verified( - makeEnvelope(snapshot: snapshot) { - await events.append("finish-10") - await finished.send() - })) - try await finished.wait(for: 1) - - #expect(await events.snapshot() == ["handle-10", "finish-10"]) - 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() - let reported = TestSignal() - let finished = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in throw TestFailure() }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - if failure.transactionID == 11 { - await reported.send() - } - } - ) - _ = try await session.start() - fixture.updates.yield( - .verified( - makeEnvelope( - snapshot: makeSnapshot(id: 11) - ) { - await finished.send() - })) - - try await reported.wait(for: 1) - #expect(await finished.value() == 0) - try await session.close() - } - - @Test("close before start is idempotent and later operations are rejected") - func closeBeforeStart() async throws { - let fixture = TestSourceFixture() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - try await session.close() - try await session.close() - await #expect(throws: StoreTransactionError.self) { - _ = try await session.start() - } - } - - @Test("callbacks reject reentry into their own session") - func callbackReentrancy() async throws { - let fixture = TestSourceFixture() - let holder = SessionHolder() - let observations = StringRecorder() - let finished = TestSignal() - let failureReported = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - do { - try await holder.get().close() - await observations.append("handler-unexpected-success") - } catch StoreTransactionError.reentrantOperation( - operation: .close - ) { - await observations.append("handler-rejected") - } catch { - Issue.record("Unexpected handler reentrancy error: \(error)") - } - }, - entitlementsDidChange: { _ in - do { - _ = try await holder.get().currentEntitlements() - await observations.append("entitlements-unexpected-success") - } catch StoreTransactionError.reentrantOperation( - operation: .currentEntitlements - ) { - await observations.append("entitlements-rejected") - } catch { - Issue.record("Unexpected entitlement reentrancy error: \(error)") - } - }, - reportFailure: { _ in - do { - _ = try await holder.get().history(for: "product") - await observations.append("reporter-unexpected-success") - } catch StoreTransactionError.reentrantOperation( - operation: .history - ) { - await observations.append("reporter-rejected") - } catch { - Issue.record("Unexpected reporter reentrancy error: \(error)") - } - await failureReported.send() - } - ) - holder.set(session) - - _ = try await session.start() - fixture.updates.yield( - .verified( - makeEnvelope(snapshot: makeSnapshot(id: 12)) { - await finished.send() - })) - 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() == [ - "entitlements-rejected", - "handler-rejected", - "reporter-rejected", - ]) - try await session.close() - } - - @Test("a callback may operate on a different session") - func callbackMayUseAnotherSession() async throws { - let otherFixture = TestSourceFixture() - let otherSession = StoreTransactionSession( - source: otherFixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - let fixture = TestSourceFixture() - let callbackCompleted = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { _ in - do { - try await otherSession.close() - await callbackCompleted.send() - } catch { - Issue.record("A different session rejected the callback: \(error)") - } - }, - reportFailure: { _ in } - ) - - _ = try await session.start() - try await callbackCompleted.wait(for: 1) - try await session.close() - } -} diff --git a/Tests/StoreTransactionKitTests/TaskCompletionBagTests.swift b/Tests/StoreTransactionKitTests/TaskCompletionBagTests.swift new file mode 100644 index 0000000..e4c5294 --- /dev/null +++ b/Tests/StoreTransactionKitTests/TaskCompletionBagTests.swift @@ -0,0 +1,68 @@ +import Testing +@testable import StoreTransactionKit + +@Suite("Task completion bag", .timeLimit(.minutes(1))) +struct TaskCompletionBagTests { + @Test("completed tasks are released without waiting for shutdown") + func completedTasksAreReleased() async throws { + let bag = TaskCompletionBag() + let gate = TestGate() + let completed = TestSignal() + + for _ in 0..<32 { + let registration = bag.reserve() + let task = Task { + try? await gate.wait() + registration.complete() + await completed.send() + } + registration.attach(task) + } + + #expect(bag.retainedTaskCount() == 32) + await gate.open() + try await completed.wait(for: 32) + #expect(bag.retainedTaskCount() == 0) + } + + @Test("completion before attachment does not retain the task") + func completionBeforeAttachment() async throws { + let bag = TaskCompletionBag() + let completed = TestSignal() + let registration = bag.reserve() + let task = Task { + registration.complete() + await completed.send() + } + + try await completed.wait(for: 1) + registration.attach(task) + + #expect(bag.retainedTaskCount() == 0) + } + + @Test("cancellation before attachment reaches the task") + func cancellationBeforeAttachment() async throws { + let bag = TaskCompletionBag() + let gate = TestGate() + let cancelled = TestSignal() + let registration = bag.reserve() + + bag.cancel() + let task = Task { + defer { registration.complete() } + do { + try await gate.wait() + } catch is CancellationError { + await cancelled.send() + } catch { + Issue.record("Unexpected task failure: \(error)") + } + } + registration.attach(task) + + try await cancelled.wait(for: 1) + await bag.waitForAll() + #expect(bag.retainedTaskCount() == 0) + } +} diff --git a/Tests/StoreTransactionKitTests/TestSupport.swift b/Tests/StoreTransactionKitTests/TestSupport.swift index db83e82..90238e6 100644 --- a/Tests/StoreTransactionKitTests/TestSupport.swift +++ b/Tests/StoreTransactionKitTests/TestSupport.swift @@ -54,6 +54,57 @@ actor TestSignal { } } +final class TestCounterSignal: Sendable { + private struct Waiter: Sendable { + let target: Int + let receipt: ProcessingReceipt + } + + private struct State: Sendable { + var count = 0 + var waiters: [Waiter] = [] + } + + private let state = Mutex(State()) + + func send() { + let ready = state.withLock { state -> [ProcessingReceipt] in + state.count += 1 + var ready: [ProcessingReceipt] = [] + state.waiters.removeAll { waiter in + guard waiter.target <= state.count else { return false } + ready.append(waiter.receipt) + return true + } + return ready + } + for receipt in ready { + receipt.succeed(()) + } + } + + func wait(for target: Int) async throws { + precondition(target > 0) + let receipt = state.withLock { + state -> ProcessingReceipt? in + guard state.count < target else { return nil } + let receipt = ProcessingReceipt() + state.waiters.append(Waiter(target: target, receipt: receipt)) + return receipt + } + guard let receipt else { return } + do { + try await receipt.value() + } catch is ProcessingReceiptWaiterCancellation { + throw CancellationError() + } + } + + func value() -> Int { + state.withLock(\.count) + } +} + actor TestGate { private var isOpen = false private var waiters: [UUID: CheckedContinuation] = [:] @@ -115,41 +166,18 @@ actor UInt64Recorder { } } -final class SessionHolder: Sendable { - private let storage = Mutex(nil) - - func set(_ session: StoreTransactionSession) { - storage.withLock { value in - precondition(value == nil) - value = session - } - } - - func get() -> StoreTransactionSession { - storage.withLock { value in - guard let value else { - preconditionFailure("SessionHolder was read before initialization.") - } - return value - } - } -} - -final class TransactionStoreHolder: Sendable -where - EntitlementID: RawRepresentable & Hashable & Sendable, - EntitlementID.RawValue == String -{ - private let storage = Mutex?>(nil) +final class TransactionStoreHolder: Sendable +where Entitlement: Hashable & Sendable { + private let storage = Mutex?>(nil) - func set(_ store: TransactionStore) { + func set(_ store: TransactionStore) { storage.withLock { value in precondition(value == nil) value = store } } - func get() -> TransactionStore { + func get() -> TransactionStore { storage.withLock { value in guard let value else { preconditionFailure("TransactionStoreHolder was read before initialization.") @@ -167,6 +195,7 @@ actor ControlledEntitlementQuery { private var requests: [Request] = [] private let started = TestSignal() + private let cancelled = TestSignal() func next() async throws -> [StoreTransactionSnapshot] { try Task.checkCancellation() @@ -185,6 +214,10 @@ actor ControlledEntitlementQuery { try await started.wait(for: count) } + func waitForCancellation(_ count: Int = 1) async throws { + try await cancelled.wait(for: count) + } + func succeed(_ snapshots: [StoreTransactionSnapshot]) { precondition(!requests.isEmpty) requests.removeFirst().continuation.resume(returning: snapshots) @@ -202,6 +235,7 @@ actor ControlledEntitlementQuery { requests.remove(at: index).continuation.resume( throwing: CancellationError() ) + Task { await cancelled.send() } } } @@ -241,6 +275,7 @@ func makeSnapshot( id: UInt64, productID: String = "product", productType: Product.ProductType = .consumable, + subscriptionGroupID: String? = nil, purchaseDate: Date? = nil, signedDate: Date? = nil, jws: String? = nil, @@ -252,7 +287,7 @@ func makeSnapshot( id: id, originalID: id, productID: productID, - subscriptionGroupID: nil, + subscriptionGroupID: subscriptionGroupID, productType: productType, environment: .xcode, offer: nil, @@ -289,6 +324,46 @@ func makeEnvelope( struct TestFailure: Error, Sendable, Equatable {} +enum TestEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +enum TestPlans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID(rawValue: "test.subscription.group") + + enum ProductID: String, Hashable, Sendable { + case tier1Monthly = "test.subscription.tier1.monthly" + case tier1Yearly = "test.subscription.tier1.yearly" + case tier2Monthly = "test.subscription.tier2.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1Monthly, entitlement: .tier1) + StoreSubscription(.tier1Yearly, entitlement: .tier1) + StoreSubscription(.tier2Monthly, entitlement: .tier2) + } +} + +let testSubscriptionCatalog: AutoRenewableSubscriptionCatalog = + AutoRenewableSubscriptionCatalog(TestPlans.self) + +func makeSubscriptionSnapshot( + id: UInt64, + productID: TestPlans.ProductID, + isUpgraded: Bool = false, + revision: String? = nil +) -> StoreTransactionSnapshot { + makeSnapshot( + id: id, + productID: productID.rawValue, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + jws: revision, + isUpgraded: isUpgraded + ) +} + struct TestSourceFixture: Sendable { let source: StoreTransactionSource let updates: AsyncStream.Continuation @@ -332,13 +407,19 @@ struct TestSourceFixture: Sendable { self.subscriptionStatusTermination = subscriptionStatusTermination self.entitlementQueryCount = entitlementQueryCount self.source = StoreTransactionSource( - runUpdates: { consume in - for await delivery in updatePair.stream { + runUpdates: { beginIteration, consume in + var iterator = updatePair.stream.makeAsyncIterator() + while let lease = beginIteration() { + defer { lease.end() } + guard let delivery = await iterator.next() else { return } await consume(delivery) } }, - runSubscriptionStatusUpdates: { consume in - for await _ in subscriptionStatusPair.stream { + runSubscriptionStatusUpdates: { beginIteration, consume in + var iterator = subscriptionStatusPair.stream.makeAsyncIterator() + while let lease = beginIteration() { + defer { lease.end() } + guard await iterator.next() != nil else { return } await subscriptionStatusDeliveryCount.send() await consume() } diff --git a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift index e0cbeda..6ea0e86 100644 --- a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift +++ b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift @@ -16,16 +16,20 @@ struct TransactionProcessingCoreTests { await events.append("handle-end") } let snapshot = makeSnapshot(id: 1) - let receipt = await core.accept( + let acceptance = await core.accept( makeEnvelope(snapshot: snapshot) { await events.append("finish") } - ).receipt + ) + let claim = try #require( + await acceptance.claimCausalResolutionIfOwner() + ) try await handlerStarted.wait(for: 1) #expect(await events.snapshot() == ["handle-start"]) await gate.open() - _ = try await receipt.terminalValue() + _ = try await acceptance.receipt.terminalValue() + await claim.succeed() #expect( await events.snapshot() == [ "handle-start", "handle-end", "finish", @@ -33,6 +37,29 @@ struct TransactionProcessingCoreTests { await core.finishInputAndDrain() } + @Test("claiming causal resolution during a suspended handler preserves the claim") + func claimDuringSuspendedHandler() async throws { + let gate = TestGate() + let started = TestSignal() + let core = TransactionProcessingCore { _ in + await started.send() + try await gate.wait() + } + let acceptance = await core.accept( + makeEnvelope(snapshot: makeSnapshot(id: 101)) + ) + try await started.wait(for: 1) + + let claim = try #require( + await acceptance.claimCausalResolutionIfOwner() + ) + await gate.open() + _ = try await acceptance.receipt.terminalValue() + await claim.succeed() + _ = try await acceptance.causalReceipt.terminalValue() + await core.finishInputAndDrain() + } + @Test("handler failure leaves the revision retryable and unfinished") func failedHandlerIsRetryable() async throws { let attempts = TestSignal() @@ -49,15 +76,23 @@ struct TransactionProcessingCoreTests { } let first = await core.accept(envelope) + let firstClaim = try #require( + await first.claimCausalResolutionIfOwner() + ) await #expect(throws: TestFailure.self) { _ = try await first.receipt.terminalValue() } + await firstClaim.fail(TestFailure()) #expect(await finishes.value() == 0) await core.completeInitialAttempt() #expect(await core.beginTransactionAttempt()) let second = await core.accept(envelope) + let secondClaim = try #require( + await second.claimCausalResolutionIfOwner() + ) _ = try await second.receipt.terminalValue() + await secondClaim.succeed() #expect(first.role == .owner) #expect(second.role == .owner) #expect(await attempts.value() == 2) @@ -65,6 +100,36 @@ struct TransactionProcessingCoreTests { await core.finishInputAndDrain() } + @Test("a duplicate joins while decision failure awaits causal state commit") + func duplicateDuringDecisionFailureCommitJoins() async throws { + let attempts = TestSignal() + let core = TransactionProcessingCore { _ in + await attempts.send() + throw TestFailure() + } + let snapshot = makeSnapshot(id: 22) + let envelope = makeEnvelope(snapshot: snapshot, revision: "failed-active") + + let first = await core.accept(envelope) + let claim = try #require( + await first.claimCausalResolutionIfOwner() + ) + await #expect(throws: TestFailure.self) { + _ = try await first.receipt.terminalValue() + } + + let duplicate = await core.accept(envelope) + #expect(duplicate.role == .inFlightObserver) + #expect(first.causalReceipt === duplicate.causalReceipt) + #expect(await attempts.value() == 1) + + await claim.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + _ = try await duplicate.causalReceipt.terminalValue() + } + await core.finishInputAndDrain() + } + @Test("equal revisions join in flight and completed revisions are suppressed") func equalRevisionCoalescing() async throws { let handlerGate = TestGate() @@ -85,6 +150,9 @@ struct TransactionProcessingCoreTests { ) { await firstFinish.send() }) + let firstClaim = try #require( + await first.claimCausalResolutionIfOwner() + ) try await handlerStarted.wait(for: 1) let duplicate = await core.accept( makeEnvelope( @@ -97,6 +165,8 @@ struct TransactionProcessingCoreTests { await handlerGate.open() _ = try await first.receipt.terminalValue() _ = try await duplicate.receipt.terminalValue() + await firstClaim.succeed() + _ = try await duplicate.causalReceipt.terminalValue() let completed = await core.accept( makeEnvelope( snapshot: snapshot, @@ -133,15 +203,23 @@ struct TransactionProcessingCoreTests { makeEnvelope(snapshot: makeSnapshot(id: 1)) { await events.append("finish-1") }) + let firstClaim = try #require( + await first.claimCausalResolutionIfOwner() + ) try await firstStarted.wait(for: 1) let second = await core.accept( makeEnvelope(snapshot: makeSnapshot(id: 2)) { await events.append("finish-2") }) + let secondClaim = try #require( + await second.claimCausalResolutionIfOwner() + ) await firstGate.open() _ = try await first.receipt.terminalValue() _ = try await second.receipt.terminalValue() + await firstClaim.succeed() + await secondClaim.succeed() #expect( await events.snapshot() == [ "handle-1", "finish-1", "handle-2", "finish-2", diff --git a/Tests/StoreTransactionKitTests/TransactionStoreTests.swift b/Tests/StoreTransactionKitTests/TransactionStoreTests.swift new file mode 100644 index 0000000..bc61336 --- /dev/null +++ b/Tests/StoreTransactionKitTests/TransactionStoreTests.swift @@ -0,0 +1,554 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("TransactionStore", .timeLimit(.minutes(1))) +struct TransactionStoreTests { + @MainActor + @Test("override mode publishes an authoritative typed set without raw state") + func overrideState() async throws { + let store = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog, + overridingEntitlements: [ + TestEntitlement.tier1, + .tier1, + ] + ) + + guard case .overridden = store.entitlementStatus else { + Issue.record("Expected override availability.") + return + } + #expect(store.entitlements == nil) + #expect(store.activeEntitlements == [.tier1]) + #expect(store.isEntitled(to: .tier1)) + #expect(!store.isEntitled(to: .tier2)) + + do { + _ = try await store.refreshEntitlements() + Issue.record("Override mode unexpectedly refreshed StoreKit.") + } catch StoreTransactionError.operationUnavailableInOverride( + operation: .refreshEntitlements + ) {} + + try await store.close() + try await store.close() + } + + @MainActor + @Test("initial publication commits raw and typed entitlement state together") + func initialPublication() async throws { + let snapshot = makeSubscriptionSnapshot( + id: 1, + productID: .tier1Monthly + ) + let fixture = TestSourceFixture(currentEntitlements: { [snapshot] }) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + try await store.waitForInitialReadiness() + + guard case .ready = store.entitlementStatus else { + Issue.record("Expected ready availability.") + try await store.close() + return + } + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("a successful direct delivery decides, finishes, refreshes, and publishes") + func directDelivery() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let finishes = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 2, + productID: .tier1Yearly + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + let outcome = try await store.process( + .verified( + makeEnvelope(snapshot: snapshot, revision: "direct-2") { + await finishes.send() + } + ) + ) + + #expect(outcome == .completed(snapshot)) + #expect(await finishes.value() == 1) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("transient refresh failure preserves a prior ready snapshot") + func transientFailurePreservesReadyState() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let snapshot = makeSubscriptionSnapshot( + id: 3, + productID: .tier2Monthly + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + try await query.waitForRequest(1) + await query.succeed([snapshot]) + try await store.waitForInitialReadiness() + + let refresh = Task { @MainActor in + try await store.refreshEntitlements() + } + try await query.waitForRequest(2) + await query.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + _ = try await refresh.value + } + + guard case .ready = store.entitlementStatus else { + Issue.record("A transient failure discarded ready state.") + try await store.close() + return + } + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier2]) + try await store.close() + } + + @MainActor + @Test("catalog contradiction fails closed and clears both projections") + func catalogFailureClearsState() async throws { + let current = EntitlementValueSource([ + makeSubscriptionSnapshot(id: 4, productID: .tier1Monthly) + ]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await store.waitForInitialReadiness() + + let undeclared = makeSnapshot( + id: 5, + productID: "test.subscription.retired.current", + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue + ) + await current.replace(with: [undeclared]) + + await #expect(throws: AutoRenewableSubscriptionCatalogError.self) { + _ = try await store.refreshEntitlements() + } + guard case .failed(let error) = store.entitlementStatus else { + Issue.record("Expected catalog failure availability.") + try await store.close() + return + } + #expect(error is AutoRenewableSubscriptionCatalogError) + #expect(store.entitlements == nil) + #expect(store.activeEntitlements == nil) + try await store.close() + } + + @MainActor + @Test("post-finish failure retries only the causal refresh on redelivery") + func postFinishRedeliveryRetriesRefreshOnly() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let delegate = CountingDelegate(policy: .automatic) + let finishes = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 6, + productID: .tier1Monthly, + revision: "post-finish" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "post-finish") { + await finishes.send() + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let first = Task { @MainActor in + try await store.process(delivery) + } + try await query.waitForRequest(2) + await query.fail(TestFailure()) + do { + _ = try await first.value + Issue.record("Expected post-finish refresh failure.") + } catch StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(let completed), + underlyingError: let error + ) { + #expect(completed == snapshot) + #expect(error is TestFailure) + } + + let second = Task { @MainActor in + try await store.process(delivery) + } + try await query.waitForRequest(3) + await query.succeed([snapshot]) + #expect(try await second.value == .completed(snapshot)) + + #expect(await delegate.decisionCount() == 1) + #expect(await finishes.value() == 1) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("a satisfied exact revision suppresses policy, finish, and refresh") + func satisfiedDuplicateIsFullySuppressed() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let delegate = CountingDelegate(policy: .automatic) + let finishes = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 7, + productID: .tier1Monthly, + revision: "satisfied" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "satisfied") { + await finishes.send() + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + _ = try await store.process(delivery) + let queryCount = await fixture.entitlementQueryCount.value() + _ = try await store.process(delivery) + + #expect(await delegate.decisionCount() == 1) + #expect(await finishes.value() == 1) + #expect(await fixture.entitlementQueryCount.value() == queryCount) + try await store.close() + } + + @MainActor + @Test("a duplicate admitted during causal refresh joins the exact revision") + func duplicateDuringRefreshJoins() async throws { + let query = ControlledEntitlementQuery() + let delegate = CountingDelegate(policy: .automatic) + let finishes = TestSignal() + let secondAdmitted = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 70, + productID: .tier2Monthly, + revision: "joined" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "joined") { + await finishes.send() + } + ) + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let first = Task { @MainActor in try await store.process(delivery) } + try await query.waitForRequest(2) + let second = Task { @MainActor in + try await store.process(delivery) { + await secondAdmitted.send() + } + } + try await secondAdmitted.wait(for: 1) + await query.succeed([snapshot]) + + #expect(try await first.value == .completed(snapshot)) + #expect(try await second.value == .completed(snapshot)) + #expect(await delegate.decisionCount() == 1) + #expect(await finishes.value() == 1) + #expect(await fixture.entitlementQueryCount.value() == 2) + try await store.close() + } + + @MainActor + @Test("a thrown decision is retryable and never finishes its failed attempt") + func decisionFailureRetriesPolicy() async throws { + let current = EntitlementValueSource([]) + let delegate = RetryDecisionDelegate() + let finishes = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 71, + productID: .tier1Monthly, + revision: "decision-retry" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "decision-retry") { + await finishes.send() + } + ) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + await #expect(throws: TestFailure.self) { + _ = try await store.process(delivery) + } + #expect(await finishes.value() == 0) + #expect(await fixture.entitlementQueryCount.value() == 1) + + await current.replace(with: [snapshot]) + #expect(try await store.process(delivery) == .completed(snapshot)) + #expect(await delegate.decisionCount() == 2) + #expect(await finishes.value() == 1) + try await store.close() + } + + @MainActor + @Test("finish policy permits an app-owned unmanaged transaction") + func finishPolicyHandlesUnmanagedProduct() async throws { + let delegate = CountingDelegate(policy: .finish) + let finishes = TestSignal() + let snapshot = makeSnapshot( + id: 72, + productID: "test.consumable.owned", + productType: .consumable + ) + let fixture = TestSourceFixture() + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + let outcome = try await store.process( + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + ) + + #expect(outcome == .completed(snapshot)) + #expect(await delegate.decisionCount() == 1) + #expect(await finishes.value() == 1) + #expect(store.activeEntitlements == []) + try await store.close() + } + + @MainActor + @Test("automatic policy rejects unmanaged products without finishing") + func automaticRejectsUnmanagedProduct() async throws { + let fixture = TestSourceFixture() + let finishes = TestSignal() + let snapshot = makeSnapshot( + id: 8, + productID: "test.consumable", + productType: .consumable + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await store.waitForInitialReadiness() + + do { + _ = try await store.process( + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + ) + Issue.record("An unmanaged product was finished automatically.") + } catch StoreTransactionError.unhandledTransaction( + productID: let productID, + productType: let productType + ) { + #expect(productID == snapshot.productID) + #expect(productType == .consumable) + } + #expect(await finishes.value() == 0) + try await store.close() + } + + @MainActor + @Test("startup failure commits state before notifying the delegate") + func failureNotificationFollowsStateCommit() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let holder = TransactionStoreHolder() + let delegate = StateReadingDelegate(holder: holder) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + holder.set(store) + + try await query.waitForRequest(1) + await query.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + try await store.waitForInitialReadiness() + } + try await delegate.waitForFailure() + + #expect(await delegate.observedFailedState()) + try await store.close() + } + + @MainActor + @Test("close seals new admission, drains accepted work, and is shared") + func closeDrainsAcceptedWork() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let refresh = Task { @MainActor in + try await store.refreshEntitlements() + } + try await query.waitForRequest(2) + let firstClose = Task { @MainActor in try await store.close() } + let secondClose = Task { @MainActor in try await store.close() } + + await store.waitUntilClosing() + do { + _ = try await store.refreshEntitlements() + Issue.record("Closing store accepted a new refresh.") + } catch StoreTransactionError.closing {} + + await query.succeed([]) + _ = try await refresh.value + try await firstClose.value + try await secondClose.value + + do { + _ = try await store.refreshEntitlements() + Issue.record("Closed store accepted a new refresh.") + } catch StoreTransactionError.closed {} + } +} + +private actor CountingDelegate: TransactionStoreDelegate { + private let policy: StoreTransactionHandlingPolicy + private var decisions = 0 + + init(policy: StoreTransactionHandlingPolicy) { + self.policy = policy + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + return policy + } + + func decisionCount() -> Int { + decisions + } +} + +private actor RetryDecisionDelegate: TransactionStoreDelegate { + private var decisions = 0 + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + if decisions == 1 { + throw TestFailure() + } + return .automatic + } + + func decisionCount() -> Int { + decisions + } +} + +private actor StateReadingDelegate: TransactionStoreDelegate { + private let holder: TransactionStoreHolder + private let failure = TestSignal() + private var sawFailedState = false + + init(holder: TransactionStoreHolder) { + self.holder = holder + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + sawFailedState = await MainActor.run { + guard case .failed = holder.get().entitlementStatus else { + return false + } + return true + } + await self.failure.send() + } + + func waitForFailure() async throws { + try await failure.wait(for: 1) + } + + func observedFailedState() -> Bool { + sawFailedState + } +} diff --git a/Tools/TestApp/README.md b/Tools/TestApp/README.md index 35e5ae6..902cae5 100644 --- a/Tools/TestApp/README.md +++ b/Tools/TestApp/README.md @@ -2,24 +2,25 @@ 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. +defines Tier 1 and Tier 2 as two service levels in the same subscription group, +with monthly and yearly products for each level. A test-only subscription in a +different group exercises delegate handling outside that catalog. The serialized suite covers: - direct purchases and external `Transaction.updates` handling through finish -- launch reconciliation for an existing purchase +- launch reconciliation for every monthly and yearly catalog product - 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 +- immediate Tier 1-to-Tier 2 upgrades +- Tier 2-to-Tier 1 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 +- empty, existing-entitlement, and failed restores +- Ask to Buy decline on pre-27 runtimes and approval on OS 27 and later The host app and test bundle support iOS, tvOS, watchOS, and visionOS. Run the StoreKit runtime suite on an installed iOS Simulator. StoreKit Test owns one @@ -55,6 +56,25 @@ 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. +The external-update test covers an unmanaged delegate `.finish` on every +runtime with the test-only subscription group. The raw projection retains that +transaction while the typed catalog projection remains empty. On iOS 18.6, +StoreKit Test's automatic causal and startup queries can resolve before its +current-entitlement view updates. That lane verifies initial handling before an +explicit refresh exposes the raw transaction, then uses a new store to prove +there is no unfinished replay before refreshing its raw projection. + +Ask to Buy decline isn't automated on iOS 27 because StoreKit Test produces a +purchased transaction after `declineAskToBuyTransaction(identifier:)`. The +suite validates decline on pre-27 runtimes and pending-to-approved delivery on +OS 27 and later. Validate the iOS 27 decline path separately in Sandbox. + +Two other iOS 27 StoreKit Test differences have explicit test boundaries. +Expiring a subscription doesn't publish a subscription-status update, so that +lane performs a restore before asserting removal. An injected `AppStore.sync` +network failure arrives as `StoreKitError.systemError` with an unknown internal +error instead of the typed network error delivered on pre-27 runtimes. + CI runs this suite with Xcode 26.5 on the iOS 26.2 simulator available in the GitHub macOS 26 image. CI also cross-builds the app host and test bundle for tvOS, watchOS, and visionOS simulators. The same runtime suite is validated diff --git a/Tools/TestApp/StoreKitTest.storekit b/Tools/TestApp/StoreKitTest.storekit index c7bfdcf..d93099f 100644 --- a/Tools/TestApp/StoreKitTest.storekit +++ b/Tools/TestApp/StoreKitTest.storekit @@ -14,21 +14,7 @@ ], "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, @@ -97,18 +83,46 @@ "displayPrice" : "1.99", "familyShareable" : false, "groupNumber" : 2, - "internalID" : "StoreTransactionKitPlus", + "internalID" : "StoreTransactionKitTier1Monthly", "introductoryOffer" : null, "localizations" : [ { - "description" : "The Plus plan used by integration tests.", - "displayName" : "Plus", + "description" : "The monthly Tier 1 plan used by integration tests.", + "displayName" : "Tier 1 Monthly", "locale" : "en_US" } ], - "productID" : "com.example.StoreTransactionKit.plus", + "productID" : "com.example.StoreTransactionKit.tier1.monthly", "recurringSubscriptionPeriod" : "P1M", - "referenceName" : "Plus", + "referenceName" : "Tier 1 Monthly", + "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "19.99", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "StoreTransactionKitTier1Yearly", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "The yearly Tier 1 plan used by integration tests.", + "displayName" : "Tier 1 Yearly", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.tier1.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Tier 1 Yearly", "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", "type" : "RecurringSubscription", "winbackOffers" : [ @@ -125,22 +139,87 @@ "displayPrice" : "3.99", "familyShareable" : false, "groupNumber" : 1, - "internalID" : "StoreTransactionKitPro", + "internalID" : "StoreTransactionKitTier2Monthly", "introductoryOffer" : null, "localizations" : [ { - "description" : "The Pro plan used by integration tests.", - "displayName" : "Pro", + "description" : "The monthly Tier 2 plan used by integration tests.", + "displayName" : "Tier 2 Monthly", "locale" : "en_US" } ], - "productID" : "com.example.StoreTransactionKit.pro", + "productID" : "com.example.StoreTransactionKit.tier2.monthly", "recurringSubscriptionPeriod" : "P1M", - "referenceName" : "Pro", + "referenceName" : "Tier 2 Monthly", + "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "39.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "StoreTransactionKitTier2Yearly", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "The yearly Tier 2 plan used by integration tests.", + "displayName" : "Tier 2 Yearly", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.tier2.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Tier 2 Yearly", "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", "type" : "RecurringSubscription", "winbackOffers" : [ + ] + } + ] + }, + { + "id" : "StoreTransactionKitExternalSubscriptionGroup", + "localizations" : [ + + ], + "name" : "External", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "StoreTransactionKitExternalMonthly", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "A catalog-external subscription used by integration tests.", + "displayName" : "External Monthly", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.external.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "External Monthly", + "subscriptionGroupID" : "StoreTransactionKitExternalSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + ] } ] diff --git a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift index 38ec2be..71ce59a 100644 --- a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift +++ b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift @@ -8,12 +8,35 @@ import Testing import UIKit #endif -private enum Entitlement: String, Hashable, Sendable { - case lifetime = "com.example.StoreTransactionKit.lifetime" - case plus = "com.example.StoreTransactionKit.plus" - case pro = "com.example.StoreTransactionKit.pro" +private enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 } +private enum Plans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "StoreTransactionKitSubscriptionGroup" + ) + + enum ProductID: String, CaseIterable, Sendable { + case tier1_Monthly = "com.example.StoreTransactionKit.tier1.monthly" + case tier1_Yearly = "com.example.StoreTransactionKit.tier1.yearly" + case tier2_Monthly = "com.example.StoreTransactionKit.tier2.monthly" + case tier2_Yearly = "com.example.StoreTransactionKit.tier2.yearly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) + } +} + +private let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) +private let externalSubscriptionProductID = + "com.example.StoreTransactionKit.external.monthly" + @Suite(.serialized, .timeLimit(.minutes(1))) @MainActor struct StoreTransactionKitIntegrationTests { @@ -29,28 +52,51 @@ struct StoreTransactionKitIntegrationTests { let handlerCalls = TestSignal() let observedStore = ObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await handlerCalls.send() await handlerStarted.send() try await handlerRelease.wait(for: 1) + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected purchase failure: \(failure)") } ) + var purchaseTask: Task? do { try await observedStore.waitForEntitlements([]) - _ = try await session.buyProduct( - identifier: Entitlement.lifetime.rawValue - ) + let task = Task { @MainActor in + try await session.buyProduct( + identifier: externalSubscriptionProductID + ) + } + purchaseTask = task try await handlerStarted.wait(for: 1) #expect(observedStore.store.activeEntitlements == []) await handlerRelease.send() - try await observedStore.waitForEntitlements([.lifetime]) + _ = try await task.value #expect(await handlerCalls.value() == 1) + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + _ = try await observedStore.waitForTransaction( + productID: externalSubscriptionProductID, + excluding: 0 + ) + } else { + _ = try await observedStore.store.refreshEntitlements() + } + #expect( + observedStore.store.entitlements?.transactions.map(\.productID) + == [externalSubscriptionProductID] + ) + #expect(observedStore.store.activeEntitlements == []) } catch { + purchaseTask?.cancel() await handlerRelease.send() do { try await observedStore.close() @@ -63,22 +109,45 @@ struct StoreTransactionKitIntegrationTests { let replayedHandlerCalls = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await replayedHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected post-finish failure: \(failure)") } ) { observedStore in - try await observedStore.waitForEntitlements([.lifetime]) + try await observedStore.waitForEntitlements([]) + guard case .ready = observedStore.store.entitlementStatus else { + Issue.record("The replay store did not reach ready state.") + return + } + #expect(await replayedHandlerCalls.value() == 0) + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + _ = try await observedStore.waitForTransaction( + productID: externalSubscriptionProductID, + excluding: 0 + ) + } else { + _ = try await observedStore.store.refreshEntitlements() + } + #expect( + observedStore.store.entitlements?.transactions.map(\.productID) + == [externalSubscriptionProductID] + ) + #expect(observedStore.store.activeEntitlements == []) #expect(await replayedHandlerCalls.value() == 0) } } @Test - func directPurchaseIsProcessedAndPublishesPlus() async throws { + func directPurchaseIsProcessedAndPublishesTier1() async throws { try await withTestContext { context in - let product = try await context.product(.plus) + let product = try await context.product(.tier1_Monthly) let appAccountToken = UUID() let result = try await purchase( product, @@ -91,7 +160,7 @@ struct StoreTransactionKitIntegrationTests { Issue.record("Expected a completed direct purchase.") return } - #expect(transaction.productID == Entitlement.plus.rawValue) + #expect(transaction.productID == Plans.ProductID.tier1_Monthly.rawValue) #expect( transaction.subscriptionGroupID == "StoreTransactionKitSubscriptionGroup" @@ -113,26 +182,33 @@ struct StoreTransactionKitIntegrationTests { #expect(transaction.reason == .purchase) #expect(transaction.appAccountToken == appAccountToken) #expect(!transaction.jwsRepresentation.isEmpty) - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) } } @Test - func launchReconcilesAnExistingPurchase() async throws { - try await withTestContext(preexistingSubscription: .plus) { context in - #expect(context.store.activeEntitlements == [.plus]) + func launchMapsEverySubscriptionProduct() async throws { + for productID in Plans.ProductID.allCases { + try await withTestContext(preexistingSubscription: productID) { context in + switch productID { + case .tier1_Monthly, .tier1_Yearly: + #expect(context.store.activeEntitlements == [.tier1]) + case .tier2_Monthly, .tier2_Yearly: + #expect(context.store.activeEntitlements == [.tier2]) + } + } } } @Test func launchKeepsACancelledSubscriptionUntilItsExpiration() async throws { try await withTestContext( - preexistingSubscription: .plus, + preexistingSubscription: .tier1_Monthly, preexistingPurchaseOptions: [ .purchaseDate(.now, renewalBehavior: .cancelImmediately) ] ) { context in - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) } } @@ -147,7 +223,7 @@ struct StoreTransactionKitIntegrationTests { ) try await withTestContext( - preexistingSubscription: .plus, + preexistingSubscription: .tier1_Monthly, preexistingPurchaseOptions: [ .purchaseDate( purchaseDate, @@ -167,16 +243,18 @@ struct StoreTransactionKitIntegrationTests { session.resetToDefaultState() session.clearTransactions() } - _ = try await session.buyProduct(identifier: Entitlement.plus.rawValue) + _ = try await session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) let failedHandlerCalls = TestSignal() let launchDeliveryFailures = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await failedHandlerCalls.send() throw DurableHandlingFailure() }, - reportFailure: { failure in + didFail: { failure in switch failure.source { case .updates, .unfinished: await launchDeliveryFailures.send() @@ -195,27 +273,29 @@ struct StoreTransactionKitIntegrationTests { let successfulHandlerCalls = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await successfulHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected retry failure: \(failure)") } ) { observedStore in try await successfulHandlerCalls.wait(for: 1) - try await observedStore.waitForEntitlements([.plus]) + try await observedStore.waitForEntitlements([.tier1]) } let postFinishHandlerCalls = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await postFinishHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected post-finish failure: \(failure)") } ) { observedStore in - try await observedStore.waitForEntitlements([.plus]) + try await observedStore.waitForEntitlements([.tier1]) #expect(await postFinishHandlerCalls.value() == 0) } } @@ -224,24 +304,45 @@ struct StoreTransactionKitIntegrationTests { func interruptedPurchaseCompletesAfterTheIssueIsResolved() async throws { try await withTestContext { context in context.session.interruptedPurchasesEnabled = true - let product = try await context.product(.plus) - let result = try await purchase(product) - 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 + let interrupted: SKTestTransaction + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + let transaction = try await context.session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) + interrupted = try #require( + context.session.allTransactions().first { + $0.identifier == Int(transaction.id) + } + ) + } else { + let product = try await context.product(.tier1_Monthly) + let result = try await purchase(product) + let outcome = try await context.store.process(result) + guard case .pending = outcome else { + Issue.record( + "Expected the interrupted purchase to remain pending." + ) + return } - ) + interrupted = try #require( + context.session.allTransactions().first { + $0.productIdentifier + == Plans.ProductID.tier1_Monthly.rawValue + && $0.hasPurchaseIssue + } + ) + } + #expect(interrupted.hasPurchaseIssue) try context.session.resolveIssueForTransaction( identifier: interrupted.identifier ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) } } @@ -256,10 +357,11 @@ struct StoreTransactionKitIntegrationTests { let updateFailures = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await rejectedHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in if case .updates = failure.source { await updateFailures.send() } @@ -271,7 +373,9 @@ struct StoreTransactionKitIntegrationTests { forAPI: .verification ) - _ = try await session.buyProduct(identifier: Entitlement.plus.rawValue) + _ = try await session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) try await updateFailures.wait(for: 1) #expect(await rejectedHandlerCalls.value() == 0) @@ -280,10 +384,11 @@ struct StoreTransactionKitIntegrationTests { let unfinishedFailures = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await rejectedHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in if case .unfinished = failure.source { await unfinishedFailures.send() } @@ -292,53 +397,58 @@ struct StoreTransactionKitIntegrationTests { try await unfinishedFailures.wait(for: 1) #expect(await rejectedHandlerCalls.value() == 0) } + + try await session.setSimulatedError(nil, forAPI: .verification) } @Test - func upgradeFromPlusToProPublishesOnlyPro() async throws { + func upgradeFromTier1ToTier2PublishesOnlyTier2() 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.session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) + try await context.waitForEntitlements([.tier1]) - try await context.waitForEntitlements([.pro]) - #expect( - context.store.entitlements?.transactions.contains { - $0.productID == Entitlement.plus.rawValue && $0.isUpgraded - } == true + _ = try await context.session.buyProduct( + identifier: Plans.ProductID.tier2_Monthly.rawValue ) + + try await context.waitForEntitlements([.tier2]) } } @Test - func downgradeFromProToPlusChangesAtRenewal() async throws { + func downgradeFromTier2ToTier1ChangesAtRenewal() 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: Plans.ProductID.tier2_Monthly.rawValue + ) + try await context.waitForEntitlements([.tier2]) - _ = try await context.session.buyProduct(identifier: Entitlement.plus.rawValue) + _ = try await context.session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) let preRenewalEntitlements = try await context.store.refreshEntitlements() #expect( preRenewalEntitlements.transactions.map(\.productID) - == [Entitlement.pro.rawValue] + == [Plans.ProductID.tier2_Monthly.rawValue] ) - #expect(context.store.activeEntitlements == [.pro]) + #expect(context.store.activeEntitlements == [.tier2]) try context.session.forceRenewalOfSubscription( - productIdentifier: Entitlement.pro.rawValue + productIdentifier: Plans.ProductID.tier2_Monthly.rawValue ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) } } @Test - func cancellationKeepsPlusUntilExpirationThenRemovesIt() async throws { + func cancellationKeepsTier1UntilExpirationThenRemovesIt() async throws { try await withTestContext { context in let transaction = try await context.session.buyProduct( - identifier: Entitlement.plus.rawValue + identifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) try context.session.disableAutoRenewForTransaction( identifier: UInt(transaction.id) @@ -353,15 +463,26 @@ struct StoreTransactionKitIntegrationTests { let cancelledEntitlements = try await context.store.refreshEntitlements() #expect( cancelledEntitlements.transactions.map(\.productID) - == [Entitlement.plus.rawValue] + == [Plans.ProductID.tier1_Monthly.rawValue] ) - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) try context.session.expireSubscription( - productIdentifier: Entitlement.plus.rawValue + productIdentifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await context.waitForEntitlements([]) + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + let expiredEntitlements = + try await context.store.restorePurchases() + #expect(expiredEntitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + } else { + try await context.waitForEntitlements([]) + } } } @@ -369,21 +490,21 @@ struct StoreTransactionKitIntegrationTests { func renewalPublishesTheNewTransactionLineage() async throws { try await withTestContext { context in let initial = try await context.session.buyProduct( - identifier: Entitlement.plus.rawValue + identifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) try context.session.forceRenewalOfSubscription( - productIdentifier: Entitlement.plus.rawValue + productIdentifier: Plans.ProductID.tier1_Monthly.rawValue ) let renewal = try await context.waitForTransaction( - productID: Entitlement.plus.rawValue, + productID: Plans.ProductID.tier1_Monthly.rawValue, excluding: initial.id ) #expect(renewal.originalID == initial.originalID) #expect(renewal.reason == .renewal) - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) } } @@ -397,12 +518,14 @@ struct StoreTransactionKitIntegrationTests { 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) + decidePolicy: { transaction in + if transaction.reason == .renewal { + await renewalHandlerStarted.send() + try await renewalHandlerRelease.wait(for: 1) + } + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected renewal failure: \(failure)") } ) @@ -410,12 +533,12 @@ struct StoreTransactionKitIntegrationTests { do { try await observedStore.waitForEntitlements([]) let initial = try await session.buyProduct( - identifier: Entitlement.plus.rawValue + identifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await observedStore.waitForEntitlements([.plus]) + try await observedStore.waitForEntitlements([.tier1]) try session.forceRenewalOfSubscription( - productIdentifier: Entitlement.plus.rawValue + productIdentifier: Plans.ProductID.tier1_Monthly.rawValue ) try await renewalHandlerStarted.wait(for: 1) @@ -425,7 +548,7 @@ struct StoreTransactionKitIntegrationTests { ) await renewalHandlerRelease.send() let renewal = try await observedStore.waitForTransaction( - productID: Entitlement.plus.rawValue, + productID: Plans.ProductID.tier1_Monthly.rawValue, excluding: initial.id ) #expect(renewal.reason == .renewal) @@ -443,14 +566,15 @@ struct StoreTransactionKitIntegrationTests { let replayedHandlerCalls = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await replayedHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected post-renewal failure: \(failure)") } ) { observedStore in - try await observedStore.waitForEntitlements([.plus]) + try await observedStore.waitForEntitlements([.tier1]) #expect(await replayedHandlerCalls.value() == 0) } } @@ -459,9 +583,9 @@ struct StoreTransactionKitIntegrationTests { func refundRemovesTheEntitlement() async throws { try await withTestContext { context in let transaction = try await context.session.buyProduct( - identifier: Entitlement.plus.rawValue + identifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) try context.session.refundTransaction(identifier: UInt(transaction.id)) @@ -481,19 +605,19 @@ struct StoreTransactionKitIntegrationTests { @Test func restoreReturnsAnExistingEntitlement() async throws { - try await withTestContext(preexistingSubscription: .plus) { context in + try await withTestContext(preexistingSubscription: .tier1_Yearly) { context in let entitlements = try await context.store.restorePurchases() #expect( entitlements.transactions.map(\.productID) - == [Entitlement.plus.rawValue] + == [Plans.ProductID.tier1_Yearly.rawValue] ) - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) } } @Test - func restorePropagatesAppStoreSyncFailureAndCanRetry() async throws { + func restorePropagatesAppStoreSyncFailure() async throws { try await withTestContext { context in let injectedFailure = SKTestFailures.AppStoreSync.generic( .networkError(URLError(.notConnectedToInternet)) @@ -507,34 +631,40 @@ struct StoreTransactionKitIntegrationTests { == injectedFailure ) + var capturedRestoreError: (any Error)? 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)") + capturedRestoreError = error } - - try await context.session.setSimulatedError( - nil, - forAPI: .appStoreSync - ) - #expect( - await context.session.simulatedError(forAPI: .appStoreSync) - == nil + let restoreError = try #require( + capturedRestoreError, + "Expected restorePurchases() to fail." ) - let entitlements = try await context.store.restorePurchases() - #expect(entitlements.transactions.isEmpty) + if #unavailable(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0) { + guard case StoreKitError.networkError(let error) = restoreError else { + Issue.record("Unexpected restore error: \(restoreError)") + return + } + #expect(error.code == .notConnectedToInternet) + } else { + guard case StoreKitError.systemError = restoreError else { + Issue.record("Unexpected restore error: \(restoreError)") + return + } + } #expect(context.store.activeEntitlements == []) } } @Test - func askToBuyApprovalArrivesThroughUpdates() async throws { + func askToBuyResolutionMatchesTheStoreKitTestRuntime() async throws { try await withTestContext { context in context.session.askToBuyEnabled = true - let product = try await context.product(.plus) + let product = try await context.product(.tier1_Monthly) let result = try await purchase(product) let outcome = try await context.store.process(result) @@ -548,43 +678,30 @@ struct StoreTransactionKitIntegrationTests { $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 purchase(product) - - let outcome = try await context.store.process(result) + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + try context.session.approveAskToBuyTransaction( + identifier: pending.identifier + ) + try await context.waitForEntitlements([.tier1]) + } else { + try context.session.declineAskToBuyTransaction( + identifier: pending.identifier + ) - guard case .pending = outcome else { - Issue.record("Expected an Ask to Buy purchase to remain pending.") - return + let entitlements = + try await context.store.restorePurchases() + #expect(entitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + #expect( + context.session.allTransactions().allSatisfy { + !$0.pendingAskToBuyConfirmation + } + ) } - 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 - } - ) } } @@ -606,9 +723,9 @@ struct StoreTransactionKitIntegrationTests { } private func withTestContext( - preexistingSubscription: Entitlement? = nil, + preexistingSubscription: Plans.ProductID? = nil, preexistingPurchaseOptions: Set = [], - expectedEntitlements: Set? = nil, + expectedEntitlements: Set? = nil, _ body: (TestContext) async throws -> Void ) async throws { let context = try await TestContext( @@ -635,14 +752,14 @@ private final class TestContext { let session: SKTestSession private let observedStore: ObservedStore - var store: TransactionStore { + var store: TransactionStore { observedStore.store } init( - preexistingSubscription: Entitlement? = nil, + preexistingSubscription: Plans.ProductID? = nil, preexistingPurchaseOptions: Set = [], - expectedEntitlements: Set? = nil + expectedEntitlements: Set? = nil ) async throws { let session = try await makeTestSession() self.session = session @@ -654,28 +771,38 @@ private final class TestContext { } let observedStore = ObservedStore( - handleTransaction: { _ in }, - reportFailure: { error in + decidePolicy: { _ in .automatic }, + didFail: { error in Issue.record("Background transaction failure: \(error)") } ) self.observedStore = observedStore let expected = expectedEntitlements - ?? preexistingSubscription.map { [$0] } + ?? preexistingSubscription.map { + switch $0 { + case .tier1_Monthly, .tier1_Yearly: + [.tier1] + case .tier2_Monthly, .tier2_Yearly: + [.tier2] + } + } ?? [] try await observedStore.waitForEntitlements(expected) - #expect(store.startupError == nil) + guard case .ready = store.entitlementStatus else { + Issue.record("The store did not reach ready entitlement state.") + return + } } - func product(_ entitlement: Entitlement) async throws -> Product { + func product(_ productID: Plans.ProductID) async throws -> Product { try #require( - try await Product.products(for: [entitlement.rawValue]).first + try await Product.products(for: [productID.rawValue]).first ) } func waitForEntitlements( - _ expected: Set + _ expected: Set ) async throws { try await observedStore.waitForEntitlements(expected) } @@ -701,24 +828,31 @@ private final class TestContext { @MainActor private final class ObservedStore { - let store: TransactionStore + let store: TransactionStore let observation: TransactionStoreObservation init( - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - reportFailure: + decidePolicy: + @escaping @Sendable (StoreTransactionSnapshot) async throws + -> StoreTransactionHandlingPolicy, + didFail: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void ) { - let store = TransactionStore( - handleTransaction: handleTransaction, - reportFailure: reportFailure + let delegate = ClosureTransactionStoreDelegate( + decidePolicy: decidePolicy, + didFail: didFail + ) + let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate ) self.store = store self.observation = TransactionStoreObservation(store: store) } - func waitForEntitlements(_ expected: Set) async throws { + func waitForEntitlements( + _ expected: Set + ) async throws { while true { let generation = observation.generation guard store.activeEntitlements != expected else { return } @@ -729,8 +863,11 @@ private final class ObservedStore { func waitForStartupFailure() async throws { while true { let generation = observation.generation - guard store.startupError == nil else { return } - try await observation.waitForChange(after: generation) + guard case .failed = store.entitlementStatus else { + try await observation.waitForChange(after: generation) + continue + } + return } } @@ -755,17 +892,48 @@ private final class ObservedStore { } } +private final class ClosureTransactionStoreDelegate: TransactionStoreDelegate { + private let policy: + @Sendable (StoreTransactionSnapshot) async throws + -> StoreTransactionHandlingPolicy + private let failure: @Sendable (StoreTransactionBackgroundFailure) async -> Void + + init( + decidePolicy: + @escaping @Sendable (StoreTransactionSnapshot) async throws + -> StoreTransactionHandlingPolicy, + didFail: + @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + ) { + self.policy = decidePolicy + self.failure = didFail + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + try await policy(transaction) + } + + func didFail( + with failure: StoreTransactionBackgroundFailure + ) async { + await self.failure(failure) + } +} + @MainActor private func withObservedStore( - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - reportFailure: + decidePolicy: + @escaping @Sendable (StoreTransactionSnapshot) async throws + -> StoreTransactionHandlingPolicy, + didFail: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void, _ body: (ObservedStore) async throws -> Void ) async throws { let observedStore = ObservedStore( - handleTransaction: handleTransaction, - reportFailure: reportFailure + decidePolicy: decidePolicy, + didFail: didFail ) do { try await body(observedStore) @@ -782,12 +950,12 @@ private func withObservedStore( @MainActor private final class TransactionStoreObservation { - private weak var store: TransactionStore? + private weak var store: TransactionStore? private let changes: AsyncStream private let continuation: AsyncStream.Continuation private(set) var generation: UInt64 = 0 - init(store: TransactionStore) { + init(store: TransactionStore) { let pair = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) ) @@ -817,7 +985,8 @@ private final class TransactionStoreObservation { guard let store else { return } withObservationTracking { _ = store.entitlements - _ = store.startupError + _ = store.activeEntitlements + _ = store.entitlementStatus } onChange: { [weak self] in Task { @MainActor in self?.didChange() @@ -912,7 +1081,7 @@ private enum StoreKitTestEnvironment { } _ = try #require( try await Product.products( - for: [Entitlement.lifetime.rawValue] + for: [Plans.ProductID.tier1_Monthly.rawValue] ).first, "The StoreKit test configuration is not active." )