From 63d93bbb7af0fea6d971169be342180e9ef688d0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:28:23 +0900 Subject: [PATCH 1/2] fix(api): clarify subscription access and errors Document tier values as exact plan identities and keep feature inclusion in app policy. Explain complete history semantics and make StoreTransactionError provide useful localized diagnostics. --- README.md | 28 ++++++-- .../StoreTransactionFailure.swift | 72 ++++++++++++++++++- .../DefiningSubscriptionAccess.md | 24 +++++-- .../UnderstandingTransactionHandling.md | 12 ++++ .../PublicPolicyStateTests.swift | 66 +++++++++++++++++ 5 files changed, 192 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e1c91a6..529329d 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) 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. +grant the same app entitlement. App Store Connect level 1 is the highest +service level. Here, each tier identifies the exact active plan; the app decides +which features that plan includes. Create one store at the app's process-lifetime composition root: @@ -89,8 +91,8 @@ struct ExampleApp: App { } ``` -Read the store directly and gate only the paid feature. Entitlement -availability does not need to block the rest of the UI: +Read the store directly and derive feature access from the exact active plan. +Entitlement availability does not need to block the rest of the UI: ```swift import StoreKit @@ -101,6 +103,11 @@ struct ContentView: View { @Environment(TransactionStore.self) private var store @State private var isShowingPaywall = false + private var canUsePremiumFeatures: Bool { + store.isEntitled(to: .tier1) + || store.isEntitled(to: .tier2) + } + private var canExportPDF: Bool { store.isEntitled(to: .tier1) } @@ -114,6 +121,11 @@ struct ContentView: View { } Section { + NavigationLink("Premium templates") { + PremiumTemplatesView() + } + .disabled(!canUsePremiumFeatures) + Button("Export as PDF") { exportPDF() } @@ -268,13 +280,19 @@ func subscriptionUpdatesViewModel() async throws { ) #expect(viewModel.canExportPDF) + + try await harness.expireActiveSubscription() + + #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. +test needs no timing guess. `expireActiveSubscription()` returns after publishing +a ready empty entitlement set; it does not simulate renewal or billing time. +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. diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 6388b3d..0a508dd 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -96,7 +96,7 @@ package struct StoreTransactionFailurePropagation: Sendable { } /// An error produced while operating a transaction store. -public enum StoreTransactionError: Error, Sendable { +public enum StoreTransactionError: LocalizedError, Sendable { /// An irreversible StoreKit action that completed before a later operation failed. public enum CompletedOperation: Sendable, Hashable { /// The framework finished the exact transaction revision. @@ -144,6 +144,76 @@ public enum StoreTransactionError: Error, Sendable { after: CompletedOperation, underlyingError: any Error ) + + /// A localized description of the transaction-store failure. + public var errorDescription: String? { + switch self { + case .closing: + "The transaction store is closing and cannot accept new operations." + + case .closed: + "The transaction store is closed." + + case .unknownPurchaseResult: + "StoreKit returned an unknown purchase result." + + case .unhandledTransaction(let productID, let productType): + "Transaction handling is required for product \(productID) of type " + + "\(productType.rawValue)." + + case .reentrantOperation(let operation): + "The transaction store cannot perform \(operation.errorDescription) " + + "from a callback owned by the same store." + + case .operationUnavailableInOverride(let operation): + "The transaction store cannot perform \(operation.errorDescription) " + + "when entitlements are overridden." + + case .entitlementRefreshFailed(let operation, let underlyingError): + "Entitlement refresh failed after \(operation.errorDescription): " + + underlyingError.localizedDescription + } + } + + /// A localized suggestion for recovering from the failure, when available. + public var recoverySuggestion: String? { + switch self { + case .entitlementRefreshFailed: + "Call refreshEntitlements() instead of repeating the completed StoreKit action." + + case .closing, .closed, .unknownPurchaseResult, .unhandledTransaction, + .reentrantOperation, .operationUnavailableInOverride: + nil + } + } +} + +private extension StoreTransactionOperation { + var errorDescription: String { + switch self { + case .processPurchase: + "purchase processing" + case .refreshEntitlements: + "entitlement refresh" + case .history: + "a transaction history query" + case .restorePurchases: + "purchase restoration" + case .close: + "store closure" + } + } +} + +private extension StoreTransactionError.CompletedOperation { + var errorDescription: String { + switch self { + case .finishedTransaction(let transaction): + "finishing transaction \(transaction.id) for product \(transaction.productID)" + case .synchronizedPurchases: + "synchronizing purchases" + } + } } package enum StoreTransactionLifecycleError: Error, Sendable { diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md index 6ecf163..b644ec6 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md @@ -6,8 +6,10 @@ 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. +several durations at each level. App Store Connect numbers service levels from +highest to lowest, so level 1 is the highest. StoreKit owns that upgrade and +downgrade ordering, duration, renewal, and billing state. Your app owns the +access meaning. ```swift import StoreTransactionKit @@ -52,6 +54,10 @@ 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. +In this example, `.tier1` and `.tier2` are exact active plan identities. The +catalog doesn't interpret StoreKit's service-level order as app feature +inclusion. + ## Merchandise declared products Pass the same declared Product IDs to StoreKit's subscription view: @@ -74,15 +80,25 @@ See to choose another policy with ## 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: +`false` while access is unavailable. Derive feature access from the active plan +while keeping ordinary app content usable: ```swift +private var canUsePremiumFeatures: Bool { + store.isEntitled(to: .tier1) + || store.isEntitled(to: .tier2) +} + private var canExportPDF: Bool { store.isEntitled(to: .tier1) } ``` +Here, tier 1 grants the features shared by both plans and the tier-1-only PDF +export, while tier 2 grants only the shared features. If several screens use the +same inclusion rule, centralize it in the app's ViewModel or feature policy +instead of repeating the membership checks. + 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 diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md index 368ab95..890da1b 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -113,6 +113,18 @@ For billing retry, grace period, and renewal presentation, use `Product.SubscriptionInfo.Status`; do not infer subscription status only from snapshot dates. +## Complete history queries + +``TransactionStore/history(for:)`` is an all-or-nothing audit query. Its result +represents the complete verified history for the requested Product ID, so an +unverified revision causes the operation to throw instead of silently returning +an incomplete history as if it were complete. + +Current entitlements serve a different, continuity-oriented purpose: their +verified remainder can publish while omitted verification failures are reported +through the background path. History accepts a raw `Product.ID` intentionally so +an app can also inspect products outside the subscription catalog. + ## Failure ownership An admitted physical failure has one delivery owner. While a direct caller diff --git a/Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift b/Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift index c54fbf8..1d1c0a1 100644 --- a/Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift +++ b/Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift @@ -1,3 +1,4 @@ +import Foundation import StoreKit import Testing @testable import StoreTransactionKit @@ -82,12 +83,77 @@ struct PublicPolicyStateTests { #expect(productID == "consumable.product") #expect(productType == Product.ProductType.consumable) } + + @Test("store transaction errors provide localized descriptions") + func localizedStoreTransactionErrors() { + let transaction = makeSnapshot( + id: 42, + productID: "localized.product" + ) + let underlyingDescription = "The entitlement query was unavailable." + let unhandled = StoreTransactionError.unhandledTransaction( + productID: "unhandled.product", + productType: .consumable + ) + let reentrant = StoreTransactionError.reentrantOperation( + operation: .history + ) + let unavailable = StoreTransactionError.operationUnavailableInOverride( + operation: .restorePurchases + ) + let refreshFailed = StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(transaction), + underlyingError: LocalizedMarkerError( + description: underlyingDescription + ) + ) + let errors: [StoreTransactionError] = [ + .closing, + .closed, + .unknownPurchaseResult, + unhandled, + reentrant, + unavailable, + refreshFailed, + ] + + for error in errors { + let localizedError: any LocalizedError = error + #expect(localizedError.errorDescription?.isEmpty == false) + } + + #expect(unhandled.errorDescription?.contains("unhandled.product") == true) + #expect( + unhandled.errorDescription? + .contains(Product.ProductType.consumable.rawValue) == true + ) + #expect(reentrant.errorDescription?.contains("history") == true) + #expect( + unavailable.errorDescription?.contains("purchase restoration") == true + ) + #expect(refreshFailed.errorDescription?.contains("42") == true) + #expect( + refreshFailed.errorDescription?.contains("localized.product") == true + ) + #expect( + refreshFailed.errorDescription?.contains(underlyingDescription) == true + ) + #expect(refreshFailed.recoverySuggestion?.isEmpty == false) + } } private struct MarkerError: Error, Sendable, Equatable { let value: Int } +private struct LocalizedMarkerError: LocalizedError, Sendable { + let description: String + + var errorDescription: String? { + description + } +} + private final class DefaultDelegate: TransactionStoreDelegate {} private actor ActorDelegate: TransactionStoreDelegate { From 663cab9ae9597f9994b2a72fef5248d01ab591e0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:33:20 +0900 Subject: [PATCH 2/2] feat(testing): support access loss and arbitrary deliveries --- .../Sources/Consumer/Consumer.swift | 7 +- .../SubscriptionAccessTests.swift | 65 ++++- .../TestingSubscriptionAccess.md | 40 ++- .../TransactionStore.swift | 26 +- .../SyntheticTransactionLedger.swift | 91 +++++-- .../TransactionStoreTestHarness.swift | 56 +++- .../TransactionStoreTestHarnessError.swift | 6 + .../TransactionStoreTestHarnessTests.swift | 257 ++++++++++++++++++ 8 files changed, 520 insertions(+), 28 deletions(-) diff --git a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift index 2e2fc7a..ca1cdcc 100644 --- a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift +++ b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift @@ -34,6 +34,11 @@ public let legacySubscriptionProductID = public final class NotesViewModel { private let store: TransactionStore + public var hasPremiumAccess: Bool { + store.isEntitled(to: .tier1) + || store.isEntitled(to: .tier2) + } + public var canExportPDF: Bool { store.isEntitled(to: .tier1) } @@ -89,7 +94,7 @@ struct Consumer { unrecognizedSubscriptionDelegate ) let viewModel = NotesViewModel(store: store) - print("Can export PDF: \(viewModel.canExportPDF)") + print("Has premium access: \(viewModel.hasPremiumAccess)") try await store.close() } } diff --git a/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift index 0b2d421..a5f4248 100644 --- a/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift +++ b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift @@ -1,16 +1,18 @@ import Consumer import StoreTransactionKit import StoreTransactionKitTesting +import StoreKit import Testing @Test @MainActor -func subscriptionUpdatesViewModel() async throws { +func tier1PurchaseAndExpirationUpdateViewModel() async throws { try await withTransactionStoreTestHarness( subscriptionCatalog: subscriptionCatalog ) { harness in let viewModel = NotesViewModel(store: harness.store) + #expect(!viewModel.hasPremiumAccess) #expect(!viewModel.canExportPDF) let transaction = try await harness.purchase( @@ -19,7 +21,33 @@ func subscriptionUpdatesViewModel() async throws { ) #expect(transaction.productID == Plans.ProductID.tier1_Monthly.rawValue) + #expect(viewModel.hasPremiumAccess) #expect(viewModel.canExportPDF) + + #expect( + try await harness.expireActiveSubscription() + == transaction + ) + #expect(!viewModel.hasPremiumAccess) + #expect(!viewModel.canExportPDF) + } +} + +@Test +@MainActor +func tier2GrantsCommonPremiumWithoutTier1Export() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog + ) { harness in + let viewModel = NotesViewModel(store: harness.store) + + _ = try await harness.purchase( + .tier2_Monthly, + in: Plans.self + ) + + #expect(viewModel.hasPremiumAccess) + #expect(!viewModel.canExportPDF) } } @@ -47,3 +75,38 @@ func unrecognizedSubscriptionUpdatesViewModel() async throws { #expect(viewModel.canExportPDF) } } + +@Test +@MainActor +func unmanagedTransactionUsesProductionDelegateRoute() async throws { + let delegate = ExternalUnmanagedTransactionDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) { harness in + let transaction = harness.makeTransaction( + productID: "external-consumer.tokens", + productType: .consumable + ) + let outcome = try await harness.deliver(transaction) + + #expect(outcome == .completed(transaction)) + #expect(await delegate.transactions == [transaction]) + #expect(harness.store.entitlements?.transactions == []) + #expect(harness.store.activeEntitlements == []) + } +} + +private actor ExternalUnmanagedTransactionDelegate: + TransactionStoreDelegate +{ + private(set) var transactions: [StoreTransactionSnapshot] = [] + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + transactions.append(transaction) + return .finish + } +} diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md index a2d0a28..c7636e6 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md @@ -48,8 +48,25 @@ 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. +harness models immediate current access, not StoreKit's billing and +subscription-timing machinery. + +## Remove active subscription access + +`expireActiveSubscription()` removes the harness's active subscription and +returns its snapshot after the store publishes ready-empty raw and typed state: + +```swift +let expired = try await harness.expireActiveSubscription() + +#expect(expired.productID == Plans.ProductID.tier1_Monthly.rawValue) +#expect(harness.store.entitlements?.transactions == []) +#expect(harness.store.activeEntitlements == []) +``` + +This command models the deterministic loss of current access needed by app and +ViewModel tests. It doesn't simulate StoreKit renewal, billing, time passage, +revocation, or expiration metadata. ## Exercise an unrecognized subscription @@ -93,6 +110,25 @@ try await withTransactionStoreTestHarness( harness. Replaying an older revision does not replace a later synthetic subscription that is already current. +## Deliver an arbitrary transaction + +Use `makeTransaction(productID:productType:subscriptionGroupID:isUpgraded:)` +to exercise the production classification and general delegate path with a +delivery-only snapshot: + +```swift +let transaction = harness.makeTransaction( + productID: "com.example.tokens", + productType: .consumable +) + +let outcome = try await harness.deliver(transaction) +``` + +An arbitrary transaction never changes the synthetic current-entitlement +source. Its transaction policy, exact-revision replay, and completion still use +the production pipeline. + 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 diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index aa97cf6..b1a25c5 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -303,6 +303,29 @@ where Entitlement: Hashable & Sendable { ) } + package func refreshSyntheticEntitlements( + didAdmit: () throws -> AdmissionResult + ) async throws -> AdmissionResult { + try Task.checkCancellation() + try rejectReentrancy(operation: .refreshEntitlements) + guard case .syntheticRuntime(let runtime, let lifecycle, _) = backend else { + preconditionFailure( + "Synthetic entitlement mutation requires a synthetic TransactionStore." + ) + } + let leases = try lifecycle.beginOperation() + let admissionResult: AdmissionResult + do { + admissionResult = try didAdmit() + } catch { + leases.work.end() + leases.observer.end() + throw error + } + _ = try await runtime.currentEntitlements(leases: leases) + return admissionResult + } + /// Reconciles unfinished transactions and publishes current entitlements. /// /// Raw and typed entitlement values are validated and committed atomically. @@ -316,7 +339,8 @@ where Entitlement: Hashable & Sendable { /// Returns verified transaction history for one product. /// - /// Results are all-or-nothing and ordered newest first. + /// Results are all-or-nothing and ordered newest first. A verification + /// failure throws without returning partial results. public func history( for productID: Product.ID ) async throws -> [StoreTransactionSnapshot] { diff --git a/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift b/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift index f66c53d..194e27d 100644 --- a/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift +++ b/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift @@ -4,21 +4,50 @@ import StoreTransactionKit @MainActor final class SyntheticTransactionLedger: Sendable { + enum CurrentEntitlementEffect: Equatable, Sendable { + case none + case replaceActiveSubscription + } + + private struct Registration: Sendable { + let snapshot: StoreTransactionSnapshot + let currentEntitlementEffect: CurrentEntitlementEffect + } + + private enum CurrentSubscriptionState: Sendable { + case empty(latestTransactionID: UInt64?) + case active(StoreTransactionSnapshot) + + var latestTransactionID: UInt64? { + switch self { + case .empty(let latestTransactionID): + latestTransactionID + case .active(let snapshot): + snapshot.id + } + } + } + private var nextTransactionID: UInt64 = 1 - private var registeredSnapshots: [UInt64: StoreTransactionSnapshot] = [:] - private var activeSnapshot: StoreTransactionSnapshot? + private var registrations: [UInt64: Registration] = [:] + private var currentSubscription: CurrentSubscriptionState = + .empty(latestTransactionID: nil) func snapshots() -> [StoreTransactionSnapshot] { - if let activeSnapshot { - [activeSnapshot] - } else { + switch currentSubscription { + case .empty: [] + case .active(let snapshot): + [snapshot] } } func makeRegisteredSnapshot( productID: String, - subscriptionGroupID: SubscriptionGroupID + productType: Product.ProductType, + subscriptionGroupID: SubscriptionGroupID?, + isUpgraded: Bool = false, + currentEntitlementEffect: CurrentEntitlementEffect ) -> StoreTransactionSnapshot { precondition( nextTransactionID < .max, @@ -32,8 +61,8 @@ final class SyntheticTransactionLedger: Sendable { id: transactionID, originalID: transactionID, productID: productID, - subscriptionGroupID: subscriptionGroupID.rawValue, - productType: .autoRenewable, + subscriptionGroupID: subscriptionGroupID?.rawValue, + productType: productType, environment: .xcode, offer: nil, storefrontID: "143441", @@ -46,7 +75,7 @@ final class SyntheticTransactionLedger: Sendable { revocationDate: nil, revocationReason: nil, purchasedQuantity: 1, - isUpgraded: false, + isUpgraded: isUpgraded, ownershipType: .purchased, reason: .purchase, appAccountToken: nil, @@ -54,23 +83,47 @@ final class SyntheticTransactionLedger: Sendable { jwsRepresentation: "StoreTransactionKitTesting.synthetic.\(transactionID)" ) - precondition(registeredSnapshots[transactionID] == nil) - registeredSnapshots[transactionID] = snapshot + precondition(registrations[transactionID] == nil) + registrations[transactionID] = Registration( + snapshot: snapshot, + currentEntitlementEffect: currentEntitlementEffect + ) return snapshot } func contains(_ snapshot: StoreTransactionSnapshot) -> Bool { - registeredSnapshots[snapshot.id] == snapshot + registrations[snapshot.id]?.snapshot == snapshot } - func activate(_ snapshot: StoreTransactionSnapshot) { - precondition( - contains(snapshot), - "Only a transaction registered by this test harness can become current." - ) - guard activeSnapshot.map({ $0.id <= snapshot.id }) ?? true else { + func applyDeliveryEffect(for snapshot: StoreTransactionSnapshot) { + guard let registration = registrations[snapshot.id], + registration.snapshot == snapshot + else { + preconditionFailure( + "Only an exact transaction registered by this test harness can be delivered." + ) + } + guard + registration.currentEntitlementEffect + == .replaceActiveSubscription + else { + return + } + guard + currentSubscription.latestTransactionID.map({ + $0 < snapshot.id + }) ?? true + else { return } - activeSnapshot = snapshot + currentSubscription = .active(snapshot) + } + + func expireActiveSubscription() -> StoreTransactionSnapshot? { + guard case .active(let snapshot) = currentSubscription else { + return nil + } + currentSubscription = .empty(latestTransactionID: snapshot.id) + return snapshot } } diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift index 9274446..e468fe1 100644 --- a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift @@ -1,4 +1,5 @@ import StoreTransactionKit +import StoreKit /// A StoreKit-free driver for a production ``TransactionStore`` data flow. @MainActor @@ -44,11 +45,13 @@ where Entitlement: Hashable & Sendable { try Task.checkCancellation() let snapshot = ledger.makeRegisteredSnapshot( productID: rawProductID, - subscriptionGroupID: Group.id + productType: .autoRenewable, + subscriptionGroupID: Group.id, + currentEntitlementEffect: .replaceActiveSubscription ) let outcome = try await store.processSyntheticDelivery( .synthetic(snapshot: snapshot) { [ledger] in - await ledger.activate(snapshot) + await ledger.applyDeliveryEffect(for: snapshot) } ) guard case .completed(let completed) = outcome else { @@ -84,7 +87,34 @@ where Entitlement: Hashable & Sendable { return ledger.makeRegisteredSnapshot( productID: productID, - subscriptionGroupID: Group.id + productType: .autoRenewable, + subscriptionGroupID: Group.id, + currentEntitlementEffect: .replaceActiveSubscription + ) + } + + /// Creates and registers a delivery-only synthetic transaction. + /// + /// The transaction enters the production classification and policy pipeline + /// when passed to ``deliver(_:)`` but never changes the synthetic current + /// entitlement source. Other snapshot fields use deterministic harness + /// defaults. + public func makeTransaction( + productID: String, + productType: Product.ProductType, + subscriptionGroupID: SubscriptionGroupID? = nil, + isUpgraded: Bool = false + ) -> StoreTransactionSnapshot { + precondition( + !productID.isEmpty, + "A synthetic transaction product identifier must not be empty." + ) + return ledger.makeRegisteredSnapshot( + productID: productID, + productType: productType, + subscriptionGroupID: subscriptionGroupID, + isUpgraded: isUpgraded, + currentEntitlementEffect: .none ) } @@ -114,11 +144,29 @@ where Entitlement: Hashable & Sendable { acknowledge: {} ), didAdmit: { [ledger] in - await ledger.activate(transaction) + await ledger.applyDeliveryEffect(for: transaction) } ) } + /// Removes the active synthetic subscription and publishes ready-empty state. + /// + /// The command returns the removed snapshot after production entitlement + /// reconciliation and observable-state publication complete. It does not + /// simulate renewal, billing, time passage, revocation, or StoreKit + /// expiration metadata. + @discardableResult + public func expireActiveSubscription() async throws + -> StoreTransactionSnapshot + { + try await store.refreshSyntheticEntitlements { [ledger] in + guard let expired = ledger.expireActiveSubscription() else { + throw TransactionStoreTestHarnessError.noActiveSubscription + } + return expired + } + } + private func validate( _ groupType: Group.Type ) throws where Group: AutoRenewableSubscriptionGroup { diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift index 6ca370f..76f1caa 100644 --- a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift @@ -33,6 +33,9 @@ public enum TransactionStoreTestHarnessError: /// The supplied value doesn't exactly match a snapshot registered by this harness. case unregisteredTransaction(transactionID: UInt64) + /// The synthetic current-entitlement source has no active subscription. + case noActiveSubscription + /// The synthetic store doesn't provide the requested live StoreKit operation. case operationUnavailable(operation: StoreTransactionOperation) @@ -54,6 +57,9 @@ public enum TransactionStoreTestHarnessError: case .unregisteredTransaction(let transactionID): "Transaction \(transactionID) does not exactly match a snapshot registered by this test harness." + case .noActiveSubscription: + "The transaction-store test harness has no active subscription to expire." + case .operationUnavailable(let operation): "The synthetic transaction store does not provide \(operation.description)." } diff --git a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift index 3fb3c9d..1630acd 100644 --- a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift +++ b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift @@ -77,6 +77,243 @@ struct TransactionStoreTestHarnessTests { } } + @MainActor + @Test("expiration returns after publishing ready empty state") + func expirationCompletion() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + let purchased = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + + let expired = try await harness.expireActiveSubscription() + + #expect(expired == purchased) + guard case .ready = harness.store.entitlementStatus else { + Issue.record("Expiration did not publish ready state.") + return + } + #expect(harness.store.entitlements?.transactions == []) + #expect(harness.store.activeEntitlements == []) + #expect(!harness.store.isEntitled(to: .tier1)) + } + } + + @MainActor + @Test("expiration requires an active subscription") + func expirationRequiresActiveSubscription() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + await expectHarnessError(.noActiveSubscription) { + _ = try await harness.expireActiveSubscription() + } + + guard case .ready = harness.store.entitlementStatus else { + Issue.record("The synthetic store lost ready state.") + return + } + #expect(harness.store.entitlements?.transactions == []) + #expect(harness.store.activeEntitlements == []) + } + } + + @MainActor + @Test("closed admission takes precedence over missing active access") + func closedExpirationAdmission() async throws { + var retained: TransactionStoreTestHarness? + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + retained = harness + } + + do { + _ = try await #require(retained).expireActiveSubscription() + Issue.record("Expected closed synthetic expiration admission.") + } catch StoreTransactionError.closed { + return + } catch { + Issue.record("Unexpected expiration error: \(error)") + } + } + + @MainActor + @Test("reentrant admission takes precedence over missing active access") + func reentrantExpirationAdmission() async throws { + let delegate = ReentrantExpirationDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + await delegate.setExpiration { + _ = try await harness.expireActiveSubscription() + } + + do { + _ = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + Issue.record("Expected reentrant expiration admission.") + } catch StoreTransactionError.reentrantOperation( + operation: .refreshEntitlements + ) { + #expect(harness.store.entitlements?.transactions == []) + #expect(harness.store.activeEntitlements == []) + } catch { + Issue.record("Unexpected purchase error: \(error)") + } + } + } + + @MainActor + @Test("pre-admission expiration cancellation preserves active access") + func preAdmissionExpirationCancellation() async throws { + let clock = TransactionStoreTestClock() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + let purchased = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + let expiration = Task { @MainActor in + do { + try await clock.sleep(for: .seconds(1)) + } catch is CancellationError { + // Preserve cancellation before entering expiration admission. + } + return try await harness.expireActiveSubscription() + } + try await clock.waitUntilPendingSleepCount(reaches: 1) + + expiration.cancel() + await #expect(throws: CancellationError.self) { + _ = try await expiration.value + } + + #expect(harness.store.entitlements?.transactions == [purchased]) + #expect(harness.store.activeEntitlements == [.tier1]) + } + } + + @MainActor + @Test("an expired revision replay cannot resurrect access") + func expiredRevisionReplay() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + let expired = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + #expect( + try await harness.expireActiveSubscription() + == expired + ) + + #expect( + try await harness.deliver(expired) + == .completed(expired) + ) + #expect(harness.store.entitlements?.transactions == []) + #expect(harness.store.activeEntitlements == []) + + let refreshed = try await harness.store.refreshEntitlements() + #expect(refreshed.transactions == []) + #expect(harness.store.entitlements?.transactions == []) + #expect(harness.store.activeEntitlements == []) + + let newer = try await harness.purchase( + .tier2_Monthly, + in: HarnessPlans.self + ) + #expect(newer.id > expired.id) + #expect(harness.store.entitlements?.transactions == [newer]) + #expect(harness.store.activeEntitlements == [.tier2]) + } + } + + @MainActor + @Test("an arbitrary unmanaged transaction uses the production pipeline") + func arbitraryUnmanagedTransaction() async throws { + let delegate = ActorRecordingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + let transaction = harness.makeTransaction( + productID: "testing.consumable.tokens", + productType: .consumable, + subscriptionGroupID: DifferentIDPlans.id, + isUpgraded: true + ) + let outcome = try await harness.deliver(transaction) + + #expect( + transaction.subscriptionGroupID + == DifferentIDPlans.id.rawValue + ) + #expect(transaction.productType == .consumable) + #expect(transaction.isUpgraded) + #expect(outcome == .completed(transaction)) + #expect( + await delegate.snapshot() + == .init(decisions: 1, failures: 0) + ) + #expect(harness.store.entitlements?.transactions == []) + #expect(harness.store.activeEntitlements == []) + } + } + + @MainActor + @Test("same-group non-subscription metadata fails catalog validation") + func arbitraryTransactionCatalogContradiction() async throws { + let delegate = ActorRecordingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + let transaction = harness.makeTransaction( + productID: "testing.subscription.invalid-type", + productType: .nonConsumable, + subscriptionGroupID: HarnessPlans.id + ) + + do { + _ = try await harness.deliver(transaction) + Issue.record("Expected catalog product-type validation to fail.") + } catch let error as AutoRenewableSubscriptionCatalogError { + switch error { + case .productTypeMismatch(let productID, let actual): + #expect(productID == transaction.productID) + #expect(actual == .nonConsumable) + case .subscriptionGroupMismatch: + Issue.record("Received the wrong catalog validation error.") + } + } + + #expect( + await delegate.snapshot() + == .init(decisions: 0, failures: 0) + ) + guard case .failed = harness.store.entitlementStatus else { + Issue.record("Catalog contradiction did not fail entitlement state.") + return + } + #expect(harness.store.entitlements == nil) + #expect(harness.store.activeEntitlements == nil) + } + } + @MainActor @Test("default unrecognized policy publishes raw ready state without entitlement") func defaultUnrecognizedPolicy() async throws { @@ -782,6 +1019,26 @@ private actor ThrowingDelegate: TransactionStoreDelegate { } } +private actor ReentrantExpirationDelegate: TransactionStoreDelegate { + private var expiration: (@MainActor @Sendable () async throws -> Void)? + + func setExpiration( + _ expiration: @escaping @MainActor @Sendable () async throws -> Void + ) { + self.expiration = expiration + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + guard let expiration else { + preconditionFailure("The reentrant expiration was not configured.") + } + try await expiration() + return .finish + } +} + private typealias HarnessUnrecognizedPolicy = UnrecognizedSubscriptionPolicy