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
[](https://swift.org)
-[]()
+[]()
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)
+ }
+}