diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 28b0182..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 332ff1b..ec5b2b5 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -8,23 +8,39 @@ on: jobs: build-and-docs: - runs-on: macos-14 + # The package deploys to the OS 26 platforms, so the job needs an Xcode 26 SDK. + # Xcode 16.x cannot build it. + runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@v3 - - name: Select Xcode 16.2 + - name: Select Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.2' + xcode-version: latest-stable - - name: Print Swift version - run: swift --version + - name: Check the toolchain is new enough + run: | + # The package deploys to the OS 26 platforms and needs that SDK to build at all. + # Without this check a too-old image fails later, inside compilation, with errors + # that do not name the real cause. + required=26 + major=$(xcodebuild -version | head -1 | sed -E 's/Xcode ([0-9]+).*/\1/') + if [ "$major" -lt "$required" ]; then + echo "::error::Xcode $major is too old. This package needs Xcode $required or newer for the OS 26 SDK. Pin a runner image that provides it, or lower the platforms in Package.swift." + exit 1 + fi + xcodebuild -version + swift --version - name: Build Package run: swift build -v + - name: Run Tests + run: swift test -v + - name: Generate DocC run: | mkdir -p docs diff --git a/.gitignore b/.gitignore index a950dc8..678db45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store -Sources/.DS_Store +.build/ +xcuserdata/ diff --git a/.swiftpm/.DS_Store b/.swiftpm/.DS_Store deleted file mode 100644 index 296d3f7..0000000 Binary files a/.swiftpm/.DS_Store and /dev/null differ diff --git a/.swiftpm/xcode/.DS_Store b/.swiftpm/xcode/.DS_Store deleted file mode 100644 index ba0cff3..0000000 Binary files a/.swiftpm/xcode/.DS_Store and /dev/null differ diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 86e6b2e..0000000 Binary files a/.swiftpm/xcode/package.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/.swiftpm/xcode/xcuserdata/ramizkichibekov.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/ramizkichibekov.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 198dc6d..0000000 --- a/.swiftpm/xcode/xcuserdata/ramizkichibekov.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,37 +0,0 @@ - - - - - SchemeUserState - - PurchaseKit.xcscheme_^#shared#^_ - - orderHint - 0 - - PurchaseKitTests.xcscheme_^#shared#^_ - - orderHint - 1 - - RKPurchaseKit.xcscheme_^#shared#^_ - - orderHint - 1 - - - SuppressBuildableAutocreation - - PurchaseKitTests - - primary - - - RKPurchaseKit - - primary - - - - - diff --git a/Package.swift b/Package.swift index 86ce803..1109c5d 100644 --- a/Package.swift +++ b/Package.swift @@ -6,12 +6,12 @@ import PackageDescription let package = Package( name: "PurchaseKit", platforms: [ - .iOS(.v15), - .watchOS(.v8), - .tvOS(.v15), - .macOS(.v12), - .macCatalyst(.v15), - .visionOS(.v1) + .iOS(.v26), + .watchOS(.v26), + .tvOS(.v26), + .macOS(.v26), + .macCatalyst(.v26), + .visionOS(.v26) ], products: [ .library( @@ -27,6 +27,11 @@ let package = Package( swiftSettings: [ .define("SWIFT_PACKAGE") ] + ), + .testTarget( + name: "RKPurchaseKitTests", + dependencies: ["RKPurchaseKit"], + path: "Tests/RKPurchaseKitTests" ) ] ) diff --git a/README.md b/README.md index 97febd2..3f7bee7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # RKPurchaseKit [![Swift](https://img.shields.io/badge/swift-6.2-orange.svg)](https://swift.org) -[![Platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-blue)]() +[![Platforms](https://img.shields.io/badge/platforms-iOS%2026%20%7C%20macOS%2026%20%7C%20watchOS%2026%20%7C%20tvOS%2026%20%7C%20visionOS%2026-blue)]() A lightweight Swift framework for in-app purchases using StoreKit 2 with full support for Swift Concurrency, async/await, and SPM. @@ -17,7 +17,7 @@ https://github.com/Ramiz69/PurchaseKit.git ``` In Package.swift: ```text -.package(url: "https://github.com/Ramiz69/PurchaseKit.git", from: "1.0.5") +.package(url: "https://github.com/Ramiz69/PurchaseKit.git", from: "2.0.0") ``` ### ✅ Features @@ -25,7 +25,18 @@ In Package.swift: - actor-based PurchasesManager - DocC documentation - Static linking support (type: .static) -- Support for iOS, macOS, tvOS, watchOS, visionOS +- Support for iOS, macOS, tvOS, watchOS, visionOS (26 and later) + +### ⚠️ Migrating to 2.0 + +2.0 raises the deployment target and changes several public types. If you are on 1.x: + +- **Platforms** now start at iOS/macOS/tvOS/watchOS/visionOS **26**. Earlier releases are no longer supported; stay on 1.0.6 if you need them. +- **`StoreProduct.product` is now optional.** It is `nil` on values you build yourself, because `StoreKit.Product` cannot be constructed outside StoreKit. There is now a public initializer taking the fields directly, so `PurchasesProtocol` can finally be mocked in tests and previews. +- **`ProductType` gained `unknown`** and `PurchasesError` gained `unhandledPurchaseResult`. Exhaustive `switch` statements over either need a new branch. A StoreKit product type this SDK does not recognise now reports `unknown` instead of silently passing as `nonConsumable`. +- **`purchasedProducts` no longer replays past events.** Every access returns a fresh stream, so several observers can watch at once — previously two `for await` loops split the events between them and each missed the rest. Read current state with `hasEntitlement(for:)` or `requestProducts(includingCache:)` when a subscriber starts. +- **`requestProducts()` with no arguments** is now a distinct overload on `PurchasesProtocol`. Calls are unchanged; only a conformer that relied on the default implementation is affected, and that case no longer compiles rather than recursing forever at runtime. +- `PurchasesError` now conforms to `Equatable` and `LocalizedError`, and `PurchasesManager.resolved()` returns the singleton by throwing `notConfigured` instead of trapping like `shared`. ### 📚 Documentation diff --git a/Sources/.DS_Store b/Sources/.DS_Store deleted file mode 100644 index 29b1e17..0000000 Binary files a/Sources/.DS_Store and /dev/null differ diff --git a/Sources/PurchaseKit/.DS_Store b/Sources/PurchaseKit/.DS_Store deleted file mode 100644 index 80b2544..0000000 Binary files a/Sources/PurchaseKit/.DS_Store and /dev/null differ diff --git a/Sources/PurchaseKit/Manager/RKEventBroadcaster.swift b/Sources/PurchaseKit/Manager/RKEventBroadcaster.swift new file mode 100644 index 0000000..5cdb490 --- /dev/null +++ b/Sources/PurchaseKit/Manager/RKEventBroadcaster.swift @@ -0,0 +1,112 @@ +// +// RKEventBroadcaster.swift +// RKPurchaseKit +// +// Created by Ramiz Kichibekov on 11.05.2025. +// + +import Foundation +import Synchronization + +/// Delivers every event to every active subscriber. +/// +/// `AsyncStream` has exactly one consumer. Two `for await` loops over the *same* stream do +/// not each receive the full sequence — they split it, so one screen sees an event and the +/// other silently misses it. Handing out one shared stream therefore loses purchase events +/// as soon as a second observer appears. Instead each subscriber gets its own stream, and +/// this type keeps the live continuations so a single yield reaches all of them. +/// +/// State lives behind a `Mutex`, which makes the type checked-`Sendable`: the compiler +/// verifies the isolation rather than taking an `@unchecked` assertion on trust. +final class EventBroadcaster: Sendable { + + // MARK: Nested types + + private struct State { + var continuations: [UUID: AsyncStream.Continuation] = [:] + var isFinished = false + } + + // MARK: Properties + + /// Per-subscriber buffer depth. + /// + /// Bounded on purpose: the stream is optional to consume, and an unbounded buffer grows + /// for the whole lifetime of the process when nobody drains it. Events describe + /// entitlement changes, so when a slow subscriber overflows, the newest ones are the + /// ones worth keeping. + static var bufferSize: Int { 32 } + + private let state = Mutex(State()) + + // MARK: Internal methods + + /// Creates a stream that receives every event yielded from now on. + /// + /// Events yielded before this call are not replayed; query the current state directly + /// if you need it. Returns an already-finished stream once ``finish()`` has been called. + func makeStream() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: Event.self, + bufferingPolicy: .bufferingNewest(Self.bufferSize) + ) + + let hasFinished = state.withLock { state -> Bool in + guard !state.isFinished else { return true } + + state.continuations[id] = continuation + + return false + } + + guard !hasFinished else { + continuation.finish() + + return stream + } + + // Drop the continuation when the subscriber stops iterating or is cancelled, + // otherwise the registry grows for every screen that ever observed the stream. + // Assigning to an already-terminated continuation runs the handler immediately, + // which covers a subscriber that went away during registration. + continuation.onTermination = { [weak self] _ in + self?.remove(id) + } + + return stream + } + /// Sends one event to every active subscriber. + func yield(_ event: Event) { + // Read the targets under the lock, then deliver outside it: delivery can terminate a + // stream, and the resulting `onTermination` calls back into `remove(_:)`, which takes + // the same lock. + let targets = state.withLock { Array($0.continuations.values) } + for continuation in targets { + continuation.yield(event) + } + } + /// Ends every active stream and refuses new ones. + func finish() { + let targets = state.withLock { state -> [AsyncStream.Continuation] in + state.isFinished = true + let continuations = Array(state.continuations.values) + state.continuations.removeAll() + + return continuations + } + for continuation in targets { + continuation.finish() + } + } + /// Number of registered subscribers. Exists so tests can observe cleanup. + var subscriberCount: Int { + state.withLock { $0.continuations.count } + } + + // MARK: Private methods + + private func remove(_ id: UUID) { + state.withLock { _ = $0.continuations.removeValue(forKey: id) } + } +} diff --git a/Sources/PurchaseKit/Manager/RKPurchasesManager.swift b/Sources/PurchaseKit/Manager/RKPurchasesManager.swift index dae43b9..e7533f6 100644 --- a/Sources/PurchaseKit/Manager/RKPurchasesManager.swift +++ b/Sources/PurchaseKit/Manager/RKPurchasesManager.swift @@ -7,6 +7,7 @@ import Foundation import StoreKit +import Synchronization /// ``PurchasesManager`` – entry point to `RKPurchaseKit`. /// @@ -20,8 +21,22 @@ public actor PurchasesManager: PurchasesProtocol { /// Global singleton configured via ``configure(identifiers:)``. public nonisolated static var shared: PurchasesManager { - guard let instance else { - fatalError("❗️ PurchasesActor.configure(identifiers:) must be called before first use.") + guard let instance = storage.withLock({ $0 }) else { + fatalError("❗️ PurchasesManager.configure(identifiers:) must be called before first use.") + } + + return instance + } + /// The configured singleton, without trapping when there is none. + /// + /// ``shared`` is the convenient form and treats a missing configuration as a programmer + /// error. Use this where the caller can react instead — a plug-in surface, or a path + /// that may run before ``configure(identifiers:)``. + /// + /// - Throws: ``PurchasesError/notConfigured``. + public nonisolated static func resolved() throws -> PurchasesManager { + guard let instance = storage.withLock({ $0 }) else { + throw PurchasesError.notConfigured } return instance @@ -29,25 +44,40 @@ public actor PurchasesManager: PurchasesProtocol { /// Async stream of purchase events emitted when a product becomes entitled. /// /// You can `for await` this stream to reactively update UI or unlock features. - public nonisolated let purchasedProducts: AsyncStream + /// + /// Every access returns a fresh stream that receives every event, so several observers + /// can watch at once. Reading this once and iterating the result twice does not: a + /// single `AsyncStream` splits its events between iterators. Events are not replayed, + /// so read the current state with ``hasEntitlement(for:)`` or ``requestProducts(includingCache:)`` + /// when a subscriber starts. + public nonisolated var purchasedProducts: AsyncStream { + broadcaster.makeStream() + } private let identifiers: [String] private var productsCache: [String: StoreProduct] = [:] - private let continuation: AsyncStream.Continuation + private let broadcaster = EventBroadcaster() private var updateListenerTask: Task? - nonisolated(unsafe) private static var instance: PurchasesManager? + /// Backing store for ``shared``. + /// + /// `nonisolated(unsafe)` opted the singleton out of the compiler's checking without + /// putting anything in its place: `configure(identifiers:)` wrote this while other + /// threads read it through ``shared``, which ThreadSanitizer reports as a data race, and + /// racing reference-count traffic on an unsynchronised reference can leave a dangling + /// one. A mutex makes the access checked and lets configure's test-and-set be atomic. + private static let storage = Mutex(nil) // MARK: Initial methods private init(identifiers: [String]) { - self.identifiers = identifiers - let pair = AsyncStream.makeStream(of: PurchasedProductEvent.self) - self.purchasedProducts = pair.stream - self.continuation = pair.continuation + // Keep the caller's order but drop repeats: the order is what `requestProducts` + // returns, and a duplicated identifier would surface the same product twice. + var seen: Set = [] + self.identifiers = identifiers.filter { seen.insert($0).inserted } } deinit { updateListenerTask?.cancel() - continuation.finish() + broadcaster.finish() } // MARK: Public methods @@ -58,9 +88,19 @@ public actor PurchasesManager: PurchasesProtocol { /// - Returns: The configured singleton instance. @discardableResult public nonisolated static func configure(identifiers: [String]) -> PurchasesManager { - precondition(instance == nil, "PurchasesActor.configure(_:) has already been called. Double configuration is not allowed.") - let instance = PurchasesManager(identifiers: identifiers) - self.instance = instance + // Test and set under one lock. Checking `instance == nil` and assigning separately is + // a check-then-act on unsynchronised memory: two concurrent calls could both pass the + // check, and the precondition that is supposed to forbid that would not fire. + let instance = storage.withLock { stored -> PurchasesManager in + precondition( + stored == nil, + "PurchasesManager.configure(_:) has already been called. Double configuration is not allowed." + ) + let instance = PurchasesManager(identifiers: identifiers) + stored = instance + + return instance + } Task.detached { await instance.startListener() } @@ -73,7 +113,7 @@ public actor PurchasesManager: PurchasesProtocol { /// `isPurchased` flag remains accurate. public func requestProducts(includingCache: Bool = true) async throws -> [StoreProduct] { if includingCache { - let cachedProducts = productsCache.values.map { $0 } + let cachedProducts = configuredProducts if !cachedProducts.isEmpty { Task { await self.refreshEntitlements() } return cachedProducts @@ -85,28 +125,36 @@ public actor PurchasesManager: PurchasesProtocol { } await updateCustomerProductStatus() - return productsCache.values.map { $0 } + return configuredProducts } /// Performs a purchase flow for the given product identifier. public func purchase(productID: String) async throws -> (product: StoreProduct, transaction: Transaction) { - guard let product = try await Product.products(for: [productID]).first else { - throw PurchasesError.invalidProductID(productID) - } + let product = try await storeKitProduct(for: productID) switch try await product.purchase() { case .success(let result): let transaction = try checkVerified(result) await transaction.finish() - cache(product) - try await markPurchased(productID: product.id) + // Read the flag *after* the suspension above. The transaction listener may have + // rebuilt entitlements while this was suspended and already emitted for this + // product; re-reading here keeps a purchase to one event. Caching straight to + // `true` also avoids the old reset-to-`false`-then-set-`true` pass, which made + // the product look briefly unentitled. + let wasEntitled = productsCache[product.id]?.isPurchased ?? false + let purchased = cache(product, purchased: true) + if !wasEntitled { + broadcaster.yield(PurchasedProductEvent(product: purchased)) + } - return (product: productsCache[product.id]!, transaction: transaction) + return (product: purchased, transaction: transaction) case .userCancelled: throw PurchasesError.purchaseCancelled case .pending: throw PurchasesError.purchasePending default: - throw PurchasesError.unknown(PurchasesError.unknown(NSError(domain: "unknown", code: -1))) + // `Product.PurchaseResult` is non-frozen, so a StoreKit release can add a case + // this SDK predates. + throw PurchasesError.unhandledPurchaseResult } } /// Syncs with the App Store and refreshes current entitlements. @@ -116,27 +164,39 @@ public actor PurchasesManager: PurchasesProtocol { } /// Returns `true` if the user currently has an active entitlement for `productID`. /// - /// Uses `Transaction.currentEntitlements` under the hood and falls back to the cache - /// if available for fast checks. + /// A cached `true` is taken at face value as a fast path. Anything else is resolved + /// against `Transaction.currentEntitlements`, because a cached `false` only means the + /// entitlement has not been observed yet. public func hasEntitlement(for productID: String) async -> Bool { - if let cached = productsCache[productID] { - return cached.isPurchased + // The cache is authoritative only when it says yes. A cached `false` may simply mean + // the entitlement has not been read yet — a purchase made on another device, or a cold + // start before the first refresh — so that case has to reach StoreKit. Returning the + // cached `false` directly made those users look unentitled. + if productsCache[productID]?.isPurchased == true { + return true } + for await result in Transaction.currentEntitlements { - if let transaction = try? checkVerified(result), transaction.productID == productID { + guard let transaction = try? checkVerified(result) else { continue } + + if transaction.productID == productID, transaction.revocationDate == nil { return true } } + return false } /// Returns the set of product identifiers for which the user has an active entitlement. public func entitlementProductIDs() async -> Set { var ids: Set = [] for await result in Transaction.currentEntitlements { - if let transaction = try? checkVerified(result) { - ids.insert(transaction.productID) - } + guard let transaction = try? checkVerified(result), + transaction.revocationDate == nil + else { continue } + + ids.insert(transaction.productID) } + return ids } /// Returns all active **auto-renewable** subscriptions mapped to ``StoreProduct``. @@ -148,7 +208,9 @@ public actor PurchasesManager: PurchasesProtocol { } var result: [StoreProduct] = [] for await resultTransaction in Transaction.currentEntitlements { - guard let transaction = try? checkVerified(resultTransaction) else { continue } + guard let transaction = try? checkVerified(resultTransaction), + transaction.revocationDate == nil + else { continue } if let product = productsCache[transaction.productID], product.type == .autoRenewable { result.append(product) @@ -174,13 +236,16 @@ public actor PurchasesManager: PurchasesProtocol { _ = try? await requestProducts(includingCache: true) } for await resultTransaction in Transaction.currentEntitlements { - guard let transaction = try? checkVerified(resultTransaction) else { continue } - // Проверяем, что это подписка в нужной группе + guard let transaction = try? checkVerified(resultTransaction), + transaction.revocationDate == nil + else { continue } + + // Keep only auto-renewable subscriptions belonging to the requested group. if let product = try? await storeProduct(for: transaction.productID), product.type == .autoRenewable, product.subscriptionGroupID == groupID { - // У auto-renewable подписок у транзакции обычно есть expirationDate + // Auto-renewable transactions normally carry an expiration date. let expiration = transaction.expirationDate if best == nil || compare(expiration, isLaterThan: best?.expires) { best = (product, expiration) @@ -192,14 +257,38 @@ public actor PurchasesManager: PurchasesProtocol { // MARK: Private methods - private func startListener() async { + /// Cached products for the configured identifiers, in the order passed to + /// ``configure(identifiers:)``. + /// + /// `productsCache` is keyed by identifier, so iterating it yields a different order on + /// every run and shuffles the paywall. It also holds products cached opportunistically + /// from entitlements, which were never part of `identifiers` and must not be returned. + private var configuredProducts: [StoreProduct] { + identifiers.compactMap { productsCache[$0] } + } + + private func startListener() { guard updateListenerTask == nil else { return } - updateListenerTask = Task { await listenForTransactions() } + // `self` is captured weakly and re-acquired per update. Wrapping an isolated call + // in a plain `Task { ... }` captures it strongly instead, and because + // `Transaction.updates` never ends, that kept the actor alive forever: `deinit` was + // unreachable, so the cancel and finish it performs could never run. + updateListenerTask = Task { [weak self] in + for await result in Transaction.updates { + guard let self else { return } + + await self.handle(result) + } + } } - private func cache(_ product: Product, purchased: Bool = false) { - productsCache[product.id] = map(product, purchased) + @discardableResult + private func cache(_ product: Product, purchased: Bool = false) -> StoreProduct { + let stored = map(product, purchased) + productsCache[product.id] = stored + + return stored } private func map(_ product: Product, _ isPurchased: Bool) -> StoreProduct { @@ -212,48 +301,55 @@ public actor PurchasesManager: PurchasesProtocol { case .unverified: throw PurchasesError.verificationFailed } } - /// Marks the given product as purchased and emits a ``PurchasedProductEvent``. - private func markPurchased(productID: String) async throws { - guard let cached = productsCache[productID] else { return } - - let updated = cached.setPurchasingFlag(true) - productsCache[productID] = updated - continuation.yield(PurchasedProductEvent(product: updated)) - } - private func updateCustomerProductStatus() async { await refreshEntitlements() } - /// Listens to live transaction updates and finishes them. - private func listenForTransactions() async { - for await result in Transaction.updates { - guard let transaction = try? checkVerified(result) else { continue } + /// Finishes one live transaction update and rebuilds entitlement state. + private func handle(_ result: VerificationResult) async { + guard let transaction = try? checkVerified(result) else { return } - try? await markPurchased(productID: transaction.productID) - await transaction.finish() - } + await transaction.finish() + // `Transaction.updates` also delivers revocations: refunds, a family-sharing grant + // being withdrawn, an entitlement expiring. Marking the product purchased could only + // ever set the flag to `true`, so a refund arrived here as a purchase — and emitted a + // `PurchasedProductEvent` for it. Rebuilding from `currentEntitlements` moves the + // flag in both directions. + await refreshEntitlements() } - /// Rebuilds the entitlement state: - /// 1) Clears `isPurchased` on all cached products. - /// 2) Sets it to `true` for anything present in `Transaction.currentEntitlements`. + /// Rebuilds the entitlement state from `Transaction.currentEntitlements`. + /// + /// Collects the entitled identifiers, caching any product not seen before, then applies + /// the difference against the cache. Flags move in both directions, and + /// ``purchasedProducts`` only emits for a product that has actually become entitled. private func refreshEntitlements() async { - // 1) Clear all flags - if !productsCache.isEmpty { - for (index, storeProduct) in productsCache where storeProduct.isPurchased { - productsCache[index] = storeProduct.setPurchasingFlag(false) + var entitled: Set = [] + for await result in Transaction.currentEntitlements { + guard let transaction = try? checkVerified(result), + transaction.revocationDate == nil + else { continue } + + entitled.insert(transaction.productID) + if productsCache[transaction.productID] == nil, + let fetched = try? await Product.products(for: [transaction.productID]).first { + cache(fetched) } } - // 2) Apply current entitlements - for await result in Transaction.currentEntitlements { - guard let transaction = try? checkVerified(result) else { continue } - if productsCache[transaction.productID] == nil { - if let fetched = try? await Product.products(for: [transaction.productID]).first { - cache(fetched, purchased: true) - continue - } + // Apply the difference rather than clearing every flag and setting it again. The + // clear-then-reapply pass left every entitled product looking newly purchased on each + // refresh, so the event stream repeated itself for products the subscriber already + // knew about — and it never cleared a flag that had genuinely gone away. + for productID in productsCache.keys.sorted() { + guard let storeProduct = productsCache[productID] else { continue } + + let isEntitled = entitled.contains(productID) + guard storeProduct.isPurchased != isEntitled else { continue } + + let updated = storeProduct.setPurchasingFlag(isEntitled) + productsCache[productID] = updated + if isEntitled { + broadcaster.yield(PurchasedProductEvent(product: updated)) } - try? await markPurchased(productID: transaction.productID) } } /// Ensures a ``StoreProduct`` for `productID`, fetching it if needed. @@ -261,13 +357,27 @@ public actor PurchasesManager: PurchasesProtocol { if let cached = productsCache[productID] { return cached } + + return cache(try await fetchProduct(for: productID)) + } + /// Returns the StoreKit product for `productID`, reusing the cached one when there is one. + /// + /// Purchasing used to re-fetch unconditionally, spending a network round trip on a + /// product the cache was already holding. + private func storeKitProduct(for productID: String) async throws -> Product { + if let cached = productsCache[productID], let product = cached.product { + return product + } + + return try await fetchProduct(for: productID) + } + + private func fetchProduct(for productID: String) async throws -> Product { guard let fetched = try await Product.products(for: [productID]).first else { throw PurchasesError.invalidProductID(productID) } - cache(fetched) - - return productsCache[productID]! + return fetched } private func compare(_ lhs: Date?, isLaterThan rhs: Date?) -> Bool { diff --git a/Sources/PurchaseKit/Models/RKProductType.swift b/Sources/PurchaseKit/Models/RKProductType.swift index 8483079..1133261 100644 --- a/Sources/PurchaseKit/Models/RKProductType.swift +++ b/Sources/PurchaseKit/Models/RKProductType.swift @@ -15,6 +15,13 @@ public enum ProductType: Sendable { case consumable case nonRenewable case autoRenewable + /// A `StoreKit.Product.ProductType` this SDK does not know. + /// + /// `Product.ProductType` is a non-frozen raw-value type, so a StoreKit release can add a + /// kind that predates the SDK build. Such a product used to arrive as `nonConsumable`, + /// which reads as a permanent one-off purchase — the wrong answer to act on, and silent + /// about being a guess. Treat this case as "do not assume anything about entitlement". + case unknown init(_ productType: Product.ProductType) { switch productType { @@ -27,7 +34,7 @@ public enum ProductType: Sendable { case .autoRenewable: self = .autoRenewable default: - self = .nonConsumable + self = .unknown } } } diff --git a/Sources/PurchaseKit/Models/RKPurchasedProductEvent.swift b/Sources/PurchaseKit/Models/RKPurchasedProductEvent.swift index a42f5b7..e1db6c6 100644 --- a/Sources/PurchaseKit/Models/RKPurchasedProductEvent.swift +++ b/Sources/PurchaseKit/Models/RKPurchasedProductEvent.swift @@ -11,4 +11,13 @@ import Foundation /// - See public struct PurchasedProductEvent: Sendable { public let product: StoreProduct + + /// Creates an event. + /// + /// The memberwise initializer a struct gets for free is internal, so mock + /// implementations of ``PurchasesProtocol`` could not build one of these to feed a + /// stand-in event stream. This one is public for that purpose. + public init(product: StoreProduct) { + self.product = product + } } diff --git a/Sources/PurchaseKit/Models/RKPurchasesError.swift b/Sources/PurchaseKit/Models/RKPurchasesError.swift index 05984aa..e9d1f50 100644 --- a/Sources/PurchaseKit/Models/RKPurchasesError.swift +++ b/Sources/PurchaseKit/Models/RKPurchasesError.swift @@ -15,5 +15,55 @@ public enum PurchasesError: Error, Sendable { case purchaseCancelled case purchasePending case verificationFailed + /// StoreKit returned a purchase result this SDK does not handle. + /// + /// `Product.PurchaseResult` is non-frozen, so a newer StoreKit can report an outcome + /// that predates this build. + case unhandledPurchaseResult case unknown(Error) } + +// `Error` is not `Equatable`, so the conformance cannot be synthesised. Written out so +// callers and tests can compare an error against an expected case. +extension PurchasesError: Equatable { + public static func == (lhs: PurchasesError, rhs: PurchasesError) -> Bool { + switch (lhs, rhs) { + case (.notConfigured, .notConfigured), + (.purchaseCancelled, .purchaseCancelled), + (.purchasePending, .purchasePending), + (.verificationFailed, .verificationFailed), + (.unhandledPurchaseResult, .unhandledPurchaseResult): + true + case let (.invalidProductID(lhsID), .invalidProductID(rhsID)): + lhsID == rhsID + case let (.unknown(lhsError), .unknown(rhsError)): + lhsError as NSError == rhsError as NSError + default: + false + } + } +} + +// Without this, `error.localizedDescription` reports the enum's generic fallback rather than +// anything a caller can show or log. The strings are not localised: the package ships no +// resource bundle, and adding one is a larger decision than the conformance. +extension PurchasesError: LocalizedError { + public var errorDescription: String? { + switch self { + case .notConfigured: + "PurchasesManager.configure(identifiers:) has not been called." + case .invalidProductID(let productID): + "No App Store product matches the identifier “\(productID)”." + case .purchaseCancelled: + "The purchase was cancelled." + case .purchasePending: + "The purchase is pending approval and will complete later." + case .verificationFailed: + "The App Store transaction failed signature verification." + case .unhandledPurchaseResult: + "StoreKit reported a purchase result this version does not handle." + case .unknown(let error): + error.localizedDescription + } + } +} diff --git a/Sources/PurchaseKit/Models/RKStoreProduct.swift b/Sources/PurchaseKit/Models/RKStoreProduct.swift index 550f665..c5ccbe8 100644 --- a/Sources/PurchaseKit/Models/RKStoreProduct.swift +++ b/Sources/PurchaseKit/Models/RKStoreProduct.swift @@ -13,8 +13,14 @@ import StoreKit public struct StoreProduct: Sendable { // MARK: Properties - /// The raw StoreKit product object. - public let product: Product + /// The raw StoreKit product this value was read from. + /// + /// `nil` for a value you construct yourself — a test double, a SwiftUI preview fixture, + /// or a mock implementation of ``PurchasesProtocol``. `StoreKit.Product` has no public + /// initializer and cannot be created outside StoreKit, so a `StoreProduct` that always + /// carried one was impossible to build in a test, which left ``PurchasesProtocol`` + /// unmockable despite existing to be mocked. + public let product: Product? /// The product identifier from App Store Connect. public let productID: String /// High-level product type (mapped from `Product.ProductType`). @@ -31,15 +37,15 @@ public struct StoreProduct: Sendable { public let isFamilyShareable: Bool /// Convenience flag reflecting whether the user currently holds an entitlement /// for this product (derived from `Transaction.currentEntitlements`). - public let isPurchased: Bool + public private(set) var isPurchased: Bool /// Subscription group identifier for auto-renewable subscriptions. /// /// `nil` for non-subscription products and non-grouped items. public let subscriptionGroupID: String? - // MARK: Initial method + // MARK: Initial methods - /// Designated initializer. You don't create `StoreProduct` manually in apps — + /// Designated initializer. You don't create `StoreProduct` this way in apps — /// it is produced by the kit from `StoreKit.Product`. init(product: Product, isPurchased: Bool) { self.product = product @@ -53,14 +59,46 @@ public struct StoreProduct: Sendable { self.isPurchased = isPurchased subscriptionGroupID = product.subscription?.subscriptionGroupID } + /// Creates a value that is not backed by StoreKit. + /// + /// Use this in tests, previews and mock implementations of ``PurchasesProtocol``. + /// ``product`` is `nil` on the result, so code that reaches through to StoreKit should + /// treat it as absent rather than force-unwrap it. + public init( + productID: String, + type: ProductType, + displayName: String, + description: String, + price: Decimal, + displayPrice: String, + isFamilyShareable: Bool = false, + isPurchased: Bool = false, + subscriptionGroupID: String? = nil + ) { + product = nil + self.productID = productID + self.type = type + self.displayName = displayName + self.description = description + self.price = price + self.displayPrice = displayPrice + self.isFamilyShareable = isFamilyShareable + self.isPurchased = isPurchased + self.subscriptionGroupID = subscriptionGroupID + } // MARK: Internal methods - + /// Returns a copy with an updated `isPurchased` flag. /// /// - Parameter isPurchased: New entitlement state. /// - Returns: A new ``StoreProduct`` instance. func setPurchasingFlag(_ isPurchased: Bool) -> StoreProduct { - StoreProduct(product: product, isPurchased: isPurchased) + // Copy and adjust rather than rebuild from `product`, which is absent on values that + // did not come from StoreKit. + var updated = self + updated.isPurchased = isPurchased + + return updated } } diff --git a/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift b/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift index 7cc314e..946cf33 100644 --- a/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift +++ b/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift @@ -57,7 +57,12 @@ public protocol PurchasesProtocol: Sendable { /// Default wrapper that keeps source compatibility. /// - SeeAlso: ``PurchasesProtocol/requestProducts(includingCache:)`` public extension PurchasesProtocol { - func requestProducts(includingCache: Bool = true) async throws -> [StoreProduct] { - try await requestProducts(includingCache: includingCache) + /// Calls ``PurchasesProtocol/requestProducts(includingCache:)`` with caching enabled. + /// + /// Takes no parameters on purpose. An overload that repeats the requirement's signature + /// and only adds a default value becomes the witness for any conformer that does not + /// implement the requirement itself, and then calls itself forever. + func requestProducts() async throws -> [StoreProduct] { + try await requestProducts(includingCache: true) } } diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/.DS_Store b/Sources/PurchaseKit/RKPurchaseKit.docc/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/Sources/PurchaseKit/RKPurchaseKit.docc/.DS_Store and /dev/null differ diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/ProductType.md b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/ProductType.md index 06b6f0b..bb1ae8a 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/ProductType.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/ProductType.md @@ -6,3 +6,4 @@ Thin enum mapping `StoreKit.Product.ProductType` to an SDK-friendly representati * ``ProductType/consumable`` * ``ProductType/nonRenewable`` * ``ProductType/autoRenewable`` +* ``ProductType/unknown`` – a StoreKit kind newer than this SDK build; do not infer entitlement from it diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesError.md b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesError.md index 14f38ba..9b1cc01 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesError.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesError.md @@ -13,9 +13,12 @@ ### Purchase Flow * ``PurchasesError/purchaseCancelled`` – the user explicitly cancelled the transaction. * ``PurchasesError/purchasePending`` – the transaction is pending external action (e.g. Ask-to-Buy). +* ``PurchasesError/unhandledPurchaseResult`` – StoreKit reported a purchase result newer than this SDK build. ### Verification * ``PurchasesError/verificationFailed`` – StoreKit 2 signature could not be verified. ### Other -* ``PurchasesError/unknown(_:)`` – wrapper for any unexpected `Error`. +* ``PurchasesError/unknown(_:)`` – wrapper for any unexpected `Error`. Its description is the wrapped error's. + +The type conforms to `Equatable`, so a caller can compare a thrown error against an expected case, and to `LocalizedError`, so `localizedDescription` carries usable text. diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md index 42c17b6..cca8c81 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md @@ -1,9 +1,12 @@ # ``StoreProduct`` -Value-type wrapper around `StoreKit.Product`, enriched with a few convenience fields for UI and entitlement state. +Value type describing a product, enriched with a few convenience fields for UI and entitlement state. + +Normally the kit builds these for you from `StoreKit.Product`, and ``StoreProduct/product`` carries the product it was read from. You can also build one directly with the public initializer, for tests, SwiftUI previews and mock implementations of ``PurchasesProtocol``; ``StoreProduct/product`` is `nil` on those, because `StoreKit.Product` has no public initializer and cannot be created outside StoreKit. ## Properties +- ``StoreProduct/product`` – the backing `StoreKit.Product`, `nil` when the value was not read from StoreKit - ``StoreProduct/productID`` - ``StoreProduct/type`` - ``StoreProduct/displayName`` @@ -12,6 +15,4 @@ Value-type wrapper around `StoreKit.Product`, enriched with a few convenience fi - ``StoreProduct/isPurchased`` - ``StoreProduct/subscriptionGroupID`` -Use ``StoreProduct/setPurchasingFlag(_:)`` to create a copy with an updated purchase state. - > Tip: For auto-renewable subscriptions, `subscriptionGroupID` helps you select the “best” active subscription within a group (see ``PurchasesManager/activeSubscription(inGroup:)``). diff --git a/Tests/RKPurchaseKitTests/EventBroadcasterTests.swift b/Tests/RKPurchaseKitTests/EventBroadcasterTests.swift new file mode 100644 index 0000000..4c72914 --- /dev/null +++ b/Tests/RKPurchaseKitTests/EventBroadcasterTests.swift @@ -0,0 +1,159 @@ +// +// EventBroadcasterTests.swift +// RKPurchaseKitTests +// +// Created by Ramiz Kichibekov on 11.05.2025. +// + +import Testing +@testable import RKPurchaseKit + +/// Covers the guarantees ``EventBroadcaster`` exists to provide: every subscriber sees +/// every event, and no subscriber buffers without limit. +@Suite("EventBroadcaster", .timeLimit(.minutes(1))) +struct EventBroadcasterTests { + + private struct Event: Sendable, Equatable { + let number: Int + } + + /// Regression test for the defect that motivated this type. + /// + /// A single shared `AsyncStream` has exactly one consumer, so two `for await` loops over + /// it split the sequence — the first observer saw the odd events, the second the even + /// ones, and each silently missed the rest. + @Test("every subscriber receives every event") + func broadcastsToAllSubscribers() async { + let broadcaster = EventBroadcaster() + let first = broadcaster.makeStream() + let second = broadcaster.makeStream() + + let expected = Array(1...10) + let firstReceived = Task { await collect(expected.count, from: first) } + let secondReceived = Task { await collect(expected.count, from: second) } + + for number in expected { + broadcaster.yield(Event(number: number)) + } + + #expect(await firstReceived.value == expected) + #expect(await secondReceived.value == expected) + } + + @Test("a subscriber that never drains keeps only the newest events") + func boundsTheBuffer() async { + let broadcaster = EventBroadcaster() + let stream = broadcaster.makeStream() + + let overflow = EventBroadcaster.bufferSize * 100 + for number in 1...overflow { + broadcaster.yield(Event(number: number)) + } + broadcaster.finish() + + var received: [Int] = [] + for await event in stream { + received.append(event.number) + } + + #expect(received.count == EventBroadcaster.bufferSize) + // `.bufferingNewest`: the most recent entitlement state is the one worth keeping. + #expect(received.last == overflow) + } + + @Test("a subscriber that goes away is unregistered") + func releasesTerminatedSubscribers() async { + let broadcaster = EventBroadcaster() + #expect(broadcaster.subscriberCount == 0) + + do { + let stream = broadcaster.makeStream() + #expect(broadcaster.subscriberCount == 1) + // Yield before iterating: `for await` on an empty, unfinished stream blocks on + // the first element, so the loop body would never run. + broadcaster.yield(Event(number: 1)) + for await _ in stream { break } + } + + // Leaving the scope deinitialises the iterator, which terminates the continuation. + // `onTermination` runs asynchronously, so give it a bounded window to land. + await settle(untilTrue: { broadcaster.subscriberCount == 0 }) + + #expect(broadcaster.subscriberCount == 0) + } + + @Test("an abandoned subscriber does not stall the others") + func abandonedSubscriberDoesNotBlockDelivery() async { + let broadcaster = EventBroadcaster() + _ = broadcaster.makeStream() + let live = broadcaster.makeStream() + + broadcaster.yield(Event(number: 42)) + + var received: Int? + for await event in live { + received = event.number + break + } + + #expect(received == 42) + } + + @Test("finish ends live streams and refuses new ones") + func finishClosesEverything() async { + let broadcaster = EventBroadcaster() + let live = broadcaster.makeStream() + + broadcaster.finish() + + var receivedAfterFinish: [Int] = [] + for await event in live { + receivedAfterFinish.append(event.number) + } + #expect(receivedAfterFinish.isEmpty) + + var receivedOnLateStream: [Int] = [] + for await event in broadcaster.makeStream() { + receivedOnLateStream.append(event.number) + } + #expect(receivedOnLateStream.isEmpty) + #expect(broadcaster.subscriberCount == 0) + } + + @Test("concurrent yields reach a subscriber intact") + func survivesConcurrentYields() async { + let broadcaster = EventBroadcaster() + let stream = broadcaster.makeStream() + let total = EventBroadcaster.bufferSize + + let received = Task { await collect(total, from: stream) } + await withTaskGroup(of: Void.self) { group in + for number in 1...total { + group.addTask { broadcaster.yield(Event(number: number)) } + } + } + + #expect(await received.value.sorted() == Array(1...total)) + } + + // MARK: Helpers + + /// Polls `condition` briefly so a test does not depend on exact callback timing. + private func settle(untilTrue condition: @Sendable () -> Bool) async { + for _ in 0..<100 { + if condition() { return } + + try? await Task.sleep(for: .milliseconds(10)) + } + } + + private func collect(_ count: Int, from stream: AsyncStream) async -> [Int] { + var received: [Int] = [] + for await event in stream { + received.append(event.number) + if received.count == count { break } + } + + return received + } +} diff --git a/Tests/RKPurchaseKitTests/ProductTypeTests.swift b/Tests/RKPurchaseKitTests/ProductTypeTests.swift new file mode 100644 index 0000000..df16c49 --- /dev/null +++ b/Tests/RKPurchaseKitTests/ProductTypeTests.swift @@ -0,0 +1,39 @@ +// +// ProductTypeTests.swift +// RKPurchaseKitTests +// +// Created by Ramiz Kichibekov on 11.05.2025. +// + +import Testing +import StoreKit +@testable import RKPurchaseKit + +/// Covers the mapping from `StoreKit.Product.ProductType` onto the SDK-level enum. +@Suite("ProductType", .timeLimit(.minutes(1))) +struct ProductTypeTests { + + @Test( + "maps each StoreKit product type", + arguments: [ + (Product.ProductType.consumable, ProductType.consumable), + (Product.ProductType.nonConsumable, ProductType.nonConsumable), + (Product.ProductType.nonRenewable, ProductType.nonRenewable), + (Product.ProductType.autoRenewable, ProductType.autoRenewable) + ] + ) + func mapsKnownTypes(storeKitType: Product.ProductType, expected: ProductType) { + #expect(ProductType(storeKitType) == expected) + } + + /// `Product.ProductType` is a non-frozen raw-value type, so a future StoreKit release can + /// introduce a kind this SDK has never seen. It used to land on `.nonConsumable`, which + /// reads as a permanent one-off purchase and hid the fact that the SDK was guessing. + @Test("an unknown StoreKit type maps to unknown, not to a real kind") + func mapsUnknownTypeToUnknown() { + let future = Product.ProductType(rawValue: "future-store-kit-type") + + #expect(ProductType(future) == .unknown) + #expect(ProductType(future) != .nonConsumable) + } +} diff --git a/Tests/RKPurchaseKitTests/PurchasesErrorTests.swift b/Tests/RKPurchaseKitTests/PurchasesErrorTests.swift new file mode 100644 index 0000000..3309f2b --- /dev/null +++ b/Tests/RKPurchaseKitTests/PurchasesErrorTests.swift @@ -0,0 +1,96 @@ +// +// PurchasesErrorTests.swift +// RKPurchaseKitTests +// +// Created by Ramiz Kichibekov on 11.05.2025. +// + +import Foundation +import Testing +@testable import RKPurchaseKit + +/// Covers the conformances that make ``PurchasesError`` usable by callers. +@Suite("PurchasesError", .timeLimit(.minutes(1))) +struct PurchasesErrorTests { + + @Test("matching cases compare equal", arguments: [ + PurchasesError.notConfigured, + .purchaseCancelled, + .purchasePending, + .verificationFailed, + .unhandledPurchaseResult, + .invalidProductID("pro.monthly") + ]) + func equatesMatchingCases(error: PurchasesError) { + #expect(error == error) + } + + @Test("different cases do not compare equal") + func separatesDifferentCases() { + #expect(PurchasesError.purchaseCancelled != PurchasesError.purchasePending) + #expect(PurchasesError.notConfigured != PurchasesError.verificationFailed) + } + + @Test("the associated identifier participates in equality") + func comparesAssociatedIdentifier() { + #expect(PurchasesError.invalidProductID("a") == PurchasesError.invalidProductID("a")) + #expect(PurchasesError.invalidProductID("a") != PurchasesError.invalidProductID("b")) + } + + @Test("a wrapped error participates in equality") + func comparesWrappedError() { + let underlying = NSError(domain: "test", code: 7) + let other = NSError(domain: "test", code: 8) + + #expect(PurchasesError.unknown(underlying) == PurchasesError.unknown(underlying)) + #expect(PurchasesError.unknown(underlying) != PurchasesError.unknown(other)) + } + + @Test("every case describes itself", arguments: [ + PurchasesError.notConfigured, + .purchaseCancelled, + .purchasePending, + .verificationFailed, + .unhandledPurchaseResult, + .invalidProductID("pro.monthly"), + .unknown(NSError(domain: "test", code: 1)) + ]) + func describesEveryCase(error: PurchasesError) { + #expect(error.errorDescription?.isEmpty == false) + } + + /// Without `LocalizedError`, `localizedDescription` reports Foundation's generic fallback + /// rather than anything worth showing or logging. + /// + /// `unknown` is excluded on purpose: its description belongs to the error it wraps, and + /// when that error carries none, the fallback is the best available answer. + @Test("the SDK's own cases use their own wording", arguments: [ + PurchasesError.notConfigured, + .purchaseCancelled, + .purchasePending, + .verificationFailed, + .unhandledPurchaseResult, + .invalidProductID("pro.monthly") + ]) + func avoidsTheGenericFallback(error: PurchasesError) { + #expect(!error.localizedDescription.contains("The operation couldn’t be completed")) + } + + @Test("the invalid identifier appears in its description") + func namesTheInvalidIdentifier() { + let error = PurchasesError.invalidProductID("pro.monthly") + + #expect(error.localizedDescription.contains("pro.monthly")) + } + + @Test("a wrapped error describes itself through the wrapper") + func forwardsWrappedDescription() { + let underlying = NSError( + domain: "test", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Network unreachable"] + ) + + #expect(PurchasesError.unknown(underlying).localizedDescription == "Network unreachable") + } +} diff --git a/Tests/RKPurchaseKitTests/PurchasesProtocolTests.swift b/Tests/RKPurchaseKitTests/PurchasesProtocolTests.swift new file mode 100644 index 0000000..4eda1a9 --- /dev/null +++ b/Tests/RKPurchaseKitTests/PurchasesProtocolTests.swift @@ -0,0 +1,144 @@ +// +// PurchasesProtocolTests.swift +// RKPurchaseKitTests +// +// Created by Ramiz Kichibekov on 11.05.2025. +// + +import Synchronization +import Testing +import StoreKit +@testable import RKPurchaseKit + +/// Covers the convenience overload on ``PurchasesProtocol``. +@Suite("PurchasesProtocol", .timeLimit(.minutes(1))) +struct PurchasesProtocolTests { + + /// A stand-in implementation, which is what this protocol exists to make possible. + private final class SpyPurchases: PurchasesProtocol { + let receivedIncludingCache = Mutex<[Bool]>([]) + let catalogue: [StoreProduct] + + init(catalogue: [StoreProduct] = []) { + self.catalogue = catalogue + } + + func requestProducts(includingCache: Bool) async throws -> [StoreProduct] { + receivedIncludingCache.withLock { $0.append(includingCache) } + + return catalogue + } + + func purchase(productID: String) async throws -> (product: StoreProduct, transaction: Transaction) { + throw PurchasesError.invalidProductID(productID) + } + + func restore() async throws {} + + func hasEntitlement(for productID: String) async -> Bool { + catalogue.contains { $0.productID == productID && $0.isPurchased } + } + + func entitlementProductIDs() async -> Set { + Set(catalogue.filter(\.isPurchased).map(\.productID)) + } + + func activeSubscriptions() async -> [StoreProduct] { + catalogue.filter { $0.isPurchased && $0.type == .autoRenewable } + } + + func activeSubscription(inGroup groupID: String) async -> StoreProduct? { + catalogue.first { $0.subscriptionGroupID == groupID && $0.isPurchased } + } + } + + /// Regression test for an infinite recursion. + /// + /// The default implementation used to repeat the requirement's own signature and add + /// only a default argument value. For a conformer that did not implement the + /// requirement, that overload became the witness and called itself forever, so any mock + /// written against this protocol hung. The overload now takes no parameters, which also + /// makes the same omission a compile-time conformance error rather than a runtime hang. + /// + /// Guarded by a timeout so a regression fails the suite instead of hanging it. + @Test("the no-argument overload forwards instead of recursing") + func convenienceOverloadForwards() async throws { + let spy = SpyPurchases() + + let finished = await completes(within: .seconds(5)) { + _ = try? await spy.requestProducts() + } + + #expect(finished, "requestProducts() did not return — the default implementation is recursing") + #expect(spy.receivedIncludingCache.withLock { $0 } == [true]) + } + + @Test("the explicit argument is passed through unchanged") + func explicitArgumentIsForwarded() async throws { + let spy = SpyPurchases() + + _ = try await spy.requestProducts(includingCache: false) + _ = try await spy.requestProducts(includingCache: true) + + #expect(spy.receivedIncludingCache.withLock { $0 } == [false, true]) + } + + /// The protocol is documented as existing so it can be mocked. Until `StoreProduct` had + /// a public initializer that was not actually possible: a conformer outside the module + /// could not produce a single return value. + @Test("a stand-in can serve a catalogue through the protocol") + func mockCanReturnProducts() async throws { + let subscription = StoreProduct( + productID: "pro.monthly", + type: .autoRenewable, + displayName: "Pro Monthly", + description: "Everything, billed monthly", + price: 4.99, + displayPrice: "$4.99", + isPurchased: true, + subscriptionGroupID: "group.pro" + ) + let lifetime = StoreProduct( + productID: "pro.lifetime", + type: .nonConsumable, + displayName: "Lifetime", + description: "Pay once", + price: 49.99, + displayPrice: "$49.99" + ) + let spy = SpyPurchases(catalogue: [subscription, lifetime]) + + #expect(try await spy.requestProducts().map(\.productID) == ["pro.monthly", "pro.lifetime"]) + #expect(await spy.hasEntitlement(for: "pro.monthly")) + #expect(!(await spy.hasEntitlement(for: "pro.lifetime"))) + #expect(await spy.entitlementProductIDs() == ["pro.monthly"]) + #expect(await spy.activeSubscriptions().map(\.productID) == ["pro.monthly"]) + #expect(await spy.activeSubscription(inGroup: "group.pro")?.productID == "pro.monthly") + } + + // MARK: Helpers + + /// Runs `operation`, returning `false` if it has not finished within `limit`. + private func completes( + within limit: Duration, + _ operation: @escaping @Sendable () async -> Void + ) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { + await operation() + + return true + } + group.addTask { + try? await Task.sleep(for: limit) + + return false + } + + let finished = await group.next() ?? false + group.cancelAll() + + return finished + } + } +} diff --git a/Tests/RKPurchaseKitTests/StoreProductTests.swift b/Tests/RKPurchaseKitTests/StoreProductTests.swift new file mode 100644 index 0000000..bbf2894 --- /dev/null +++ b/Tests/RKPurchaseKitTests/StoreProductTests.swift @@ -0,0 +1,100 @@ +// +// StoreProductTests.swift +// RKPurchaseKitTests +// +// Created by Ramiz Kichibekov on 11.05.2025. +// + +import Testing +@testable import RKPurchaseKit + +/// Covers the initializer that lets a ``StoreProduct`` exist without StoreKit behind it. +@Suite("StoreProduct", .timeLimit(.minutes(1))) +struct StoreProductTests { + + private func makeSubscription(isPurchased: Bool = false) -> StoreProduct { + StoreProduct( + productID: "pro.monthly", + type: .autoRenewable, + displayName: "Pro Monthly", + description: "Everything, billed monthly", + price: 4.99, + displayPrice: "$4.99", + isFamilyShareable: true, + isPurchased: isPurchased, + subscriptionGroupID: "group.pro" + ) + } + + /// `StoreKit.Product` has no public initializer, so before this initializer existed a + /// `StoreProduct` could not be built in a test or a preview at all. + @Test("can be constructed without StoreKit") + func buildsWithoutStoreKit() { + let product = makeSubscription(isPurchased: true) + + #expect(product.product == nil) + #expect(product.productID == "pro.monthly") + #expect(product.type == .autoRenewable) + #expect(product.displayName == "Pro Monthly") + #expect(product.description == "Everything, billed monthly") + #expect(product.price == 4.99) + #expect(product.displayPrice == "$4.99") + #expect(product.isFamilyShareable) + #expect(product.isPurchased) + #expect(product.subscriptionGroupID == "group.pro") + } + + @Test("optional details default to absent") + func appliesDefaults() { + let product = StoreProduct( + productID: "tip.small", + type: .consumable, + displayName: "Small Tip", + description: "Thanks", + price: 0.99, + displayPrice: "$0.99" + ) + + #expect(!product.isFamilyShareable) + #expect(!product.isPurchased) + #expect(product.subscriptionGroupID == nil) + } + + /// `setPurchasingFlag` used to rebuild the value from its backing `StoreKit.Product`, + /// which a synthesized product does not have. It now copies and adjusts instead. + @Test("the entitlement flag moves on a product with no StoreKit backing", arguments: [true, false]) + func updatesFlagWithoutStoreKit(startingPurchased: Bool) { + let product = makeSubscription(isPurchased: startingPurchased) + + let updated = product.setPurchasingFlag(!startingPurchased) + + #expect(updated.isPurchased == !startingPurchased) + #expect(product.isPurchased == startingPurchased, "the original value must not change") + } + + @Test("only the entitlement flag changes") + func preservesEveryOtherField() { + let product = makeSubscription() + + let updated = product.setPurchasingFlag(true) + + #expect(updated.productID == product.productID) + #expect(updated.type == product.type) + #expect(updated.displayName == product.displayName) + #expect(updated.description == product.description) + #expect(updated.price == product.price) + #expect(updated.displayPrice == product.displayPrice) + #expect(updated.isFamilyShareable == product.isFamilyShareable) + #expect(updated.subscriptionGroupID == product.subscriptionGroupID) + } + + @Test("an event can be built around a product") + func wrapsIntoEvent() { + let product = makeSubscription(isPurchased: true) + + let event = PurchasedProductEvent(product: product) + + #expect(event.product.productID == product.productID) + #expect(event.product.isPurchased) + } +}