288 lines
13 KiB
Swift
288 lines
13 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import RevenueCat
|
|
import TermindDomain
|
|
import Testing
|
|
|
|
@testable import TermindInfrastructure
|
|
|
|
@Suite(.timeLimit(.minutes(1))) @MainActor
|
|
struct RevenueCatCommerceTests {
|
|
private let now = Date(timeIntervalSince1970: 1_800_000_000)
|
|
|
|
@Test
|
|
func testStoreKeyCannotEnableProductionAndWhitespaceIsTrimmed() throws {
|
|
#expect(
|
|
RevenueCatConfiguration(apiKey: "test_example", entitlementID: "termind_pro", allowsTestStore: false) == nil
|
|
)
|
|
let test = try #require(
|
|
RevenueCatConfiguration(apiKey: " test_example \n", entitlementID: "termind_pro", allowsTestStore: true))
|
|
#expect(test.isTestStore)
|
|
#expect(test.apiKey == "test_example")
|
|
#expect(
|
|
RevenueCatConfiguration(
|
|
apiKey: "$(REVENUECAT_API_KEY)", entitlementID: "termind_pro", allowsTestStore: true) == nil)
|
|
#expect(
|
|
RevenueCatConfiguration(apiKey: "sk_private", entitlementID: "termind_pro", allowsTestStore: true) == nil)
|
|
#expect(RevenueCatConfiguration(apiKey: "appl_example", entitlementID: "", allowsTestStore: false) == nil)
|
|
#expect(
|
|
RevenueCatConfiguration(apiKey: "appl_example", entitlementID: "termind_pro", allowsTestStore: false) != nil
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func onlyTheConfiguredEntitlementUnlocksPro() throws {
|
|
let info = try customerInfo(entitlementID: "another_entitlement", expiration: now.addingTimeInterval(60))
|
|
#expect(resolve(info) == .free)
|
|
}
|
|
|
|
@Test
|
|
func lifetimeAndTestStoreProductIDsAreResolvedThroughTheEntitlement() throws {
|
|
let info = try customerInfo(expiration: nil)
|
|
#expect(resolve(info) == .purchased(.purchased))
|
|
}
|
|
|
|
@Test
|
|
func cancelledSubscriptionRetainsAccessUntilExpiryAndFamilyOwnership() throws {
|
|
let info = try customerInfo(
|
|
expiration: now.addingTimeInterval(60), period: "trial", ownership: "FAMILY_SHARED", cancelled: true)
|
|
guard case .subscription(let status) = resolve(info) else {
|
|
Issue.record("Missing subscription")
|
|
return
|
|
}
|
|
#expect(status.phase == .introductoryTrial)
|
|
#expect(status.ownership == .familyShared)
|
|
#expect(status.willAutoRenew == false)
|
|
#expect(status.expiresAt == now.addingTimeInterval(60))
|
|
}
|
|
|
|
@Test
|
|
func cachedActiveFlagCannotExtendAnExpiredSubscription() throws {
|
|
let info = try customerInfo(expiration: now.addingTimeInterval(60))
|
|
#expect(info.entitlements.all["termind_pro"]?.isActive == true)
|
|
#expect(
|
|
!RevenueCatEntitlementResolver.resolve(info, entitlementID: "termind_pro", now: now.addingTimeInterval(61))
|
|
.hasProAccess)
|
|
}
|
|
|
|
@Test
|
|
func graceUsesItsOwnDeadlineAndBillingRetryDoesNotUnlock() throws {
|
|
let graceEnd = now.addingTimeInterval(120)
|
|
let info = try customerInfo(expiration: now.addingTimeInterval(-60), billingIssue: true, grace: graceEnd)
|
|
guard case .subscription(let status) = resolve(info) else {
|
|
Issue.record("Missing grace")
|
|
return
|
|
}
|
|
#expect(status.phase == .gracePeriod)
|
|
#expect(status.expiresAt == graceEnd)
|
|
let retry = try customerInfo(expiration: now.addingTimeInterval(-60), billingIssue: true)
|
|
#expect(!resolve(retry).hasProAccess)
|
|
#expect(!RevenueCatEntitlementResolver.resolve(info, entitlementID: "termind_pro", now: graceEnd).hasProAccess)
|
|
}
|
|
|
|
@Test
|
|
func refundsAndFailedVerificationCannotUnlock() throws {
|
|
#expect(!resolve(try customerInfo(expiration: now.addingTimeInterval(60), refunded: true)).hasProAccess)
|
|
#expect(resolve(try customerInfo(expiration: nil, verification: 2)) == .verificationFailed)
|
|
}
|
|
|
|
@Test(arguments: [EntitlementStatus.purchased(.purchased), .free])
|
|
func remotePaywallCompletionUpdatesAccessWithoutClosingItsAccountVariant(status: EntitlementStatus) async {
|
|
let service = FakeService()
|
|
let controller = ProAccessController(purchaseService: service, observeUpdates: false)
|
|
controller.isPaywallPresented = true
|
|
service.status = status
|
|
await controller.completePaywallOperation()
|
|
#expect(controller.status == status)
|
|
#expect(controller.isPaywallPresented)
|
|
|
|
service.status = .free
|
|
await controller.refresh()
|
|
#expect(!controller.hasProAccess)
|
|
#expect(controller.isPaywallPresented)
|
|
}
|
|
|
|
@Test
|
|
func customerInfoUpdatesGrantAndRevokeAccessWithoutAStoreKitFallback() async {
|
|
let service = FakeService()
|
|
let controller = ProAccessController(purchaseService: service)
|
|
await controller.refresh()
|
|
service.continuation.yield(.purchased(.purchased))
|
|
for _ in 0..<100 where !controller.hasProAccess { await Task.yield() }
|
|
#expect(controller.hasProAccess)
|
|
service.continuation.yield(.free)
|
|
for _ in 0..<100 where controller.hasProAccess { await Task.yield() }
|
|
#expect(!controller.hasProAccess)
|
|
}
|
|
|
|
@Test func accessChangesNotifyRuntimeWithoutAVisibleViewOrPaywall() async {
|
|
let service = FakeService()
|
|
let controller = ProAccessController(purchaseService: service, observeUpdates: false)
|
|
var observed: [EntitlementStatus] = []
|
|
controller.accessDidChange = { observed.append(controller.status) }
|
|
defer { controller.accessDidChange = nil }
|
|
await controller.refresh()
|
|
service.status = .purchased(.purchased)
|
|
await controller.refresh()
|
|
service.status = .purchased(.familyShared)
|
|
await controller.refresh()
|
|
service.status = .free
|
|
await controller.refresh()
|
|
#expect(observed == [.free, .purchased(.purchased), .purchased(.familyShared), .free])
|
|
#expect(!controller.isPaywallPresented)
|
|
}
|
|
|
|
@Test
|
|
func newerCustomerInfoWinsOverAnOlderRefreshResponse() async {
|
|
let service = FakeService()
|
|
let controller = ProAccessController(purchaseService: service)
|
|
await controller.refresh()
|
|
service.suspendsRefresh = true
|
|
let refresh = Task { await controller.refresh() }
|
|
for _ in 0..<100 where service.refreshContinuation == nil { await Task.yield() }
|
|
#expect(service.refreshContinuation != nil)
|
|
service.continuation.yield(.purchased(.purchased))
|
|
for _ in 0..<100 where !controller.hasProAccess { await Task.yield() }
|
|
#expect(controller.hasProAccess)
|
|
service.refreshContinuation?.resume(returning: .free)
|
|
service.refreshContinuation = nil
|
|
await refresh.value
|
|
#expect(controller.hasProAccess)
|
|
}
|
|
|
|
@Test
|
|
func anObsoleteRefreshCannotOverwriteOrDiscardANewerRefresh() async throws {
|
|
let service = FakeService()
|
|
let controller = ProAccessController(purchaseService: service)
|
|
await controller.refresh()
|
|
service.suspendsRefresh = true
|
|
let olderRefresh = Task { await controller.refresh() }
|
|
for _ in 0..<100 where service.refreshContinuation == nil { await Task.yield() }
|
|
let olderResponse = try #require(service.refreshContinuation)
|
|
service.refreshContinuation = nil
|
|
|
|
service.continuation.yield(.purchased(.purchased))
|
|
for _ in 0..<100 where !controller.hasProAccess { await Task.yield() }
|
|
#expect(controller.hasProAccess)
|
|
let newerRefresh = Task { await controller.refresh() }
|
|
for _ in 0..<100 where service.refreshContinuation == nil { await Task.yield() }
|
|
let newerResponse = try #require(service.refreshContinuation)
|
|
service.refreshContinuation = nil
|
|
|
|
olderResponse.resume(returning: .free)
|
|
await olderRefresh.value
|
|
#expect(controller.hasProAccess)
|
|
newerResponse.resume(returning: .purchased(.familyShared))
|
|
await newerRefresh.value
|
|
#expect(controller.status == .purchased(.familyShared))
|
|
}
|
|
|
|
@Test
|
|
func concurrentRefreshesShareOneRevenueCatRequest() async throws {
|
|
let service = FakeService()
|
|
service.suspendsRefresh = true
|
|
let controller = ProAccessController(purchaseService: service, observeUpdates: false)
|
|
let first = Task { await controller.refresh() }
|
|
for _ in 0..<100 where service.refreshContinuation == nil { await Task.yield() }
|
|
let response = try #require(service.refreshContinuation)
|
|
service.refreshContinuation = nil
|
|
var secondStarted = false
|
|
let second = Task {
|
|
secondStarted = true
|
|
await controller.refresh()
|
|
}
|
|
for _ in 0..<100 where !secondStarted { await Task.yield() }
|
|
#expect(secondStarted)
|
|
response.resume(returning: .purchased(.purchased))
|
|
await first.value
|
|
await second.value
|
|
#expect(service.refreshes == 1)
|
|
#expect(controller.hasProAccess)
|
|
}
|
|
|
|
@Test
|
|
func anUnavailableServiceDoesNotStartAStoreKitFallback() async {
|
|
let controller = ProAccessController(purchaseService: nil)
|
|
await controller.refresh()
|
|
#expect(!controller.hasProAccess)
|
|
#expect(!controller.usesRevenueCat)
|
|
#expect(controller.status == .verificationFailed)
|
|
}
|
|
|
|
@Test
|
|
func aFailedInitialFetchDoesNotLeaveThePaywallLoading() async {
|
|
let service = FakeService()
|
|
service.refreshFails = true
|
|
let controller = ProAccessController(purchaseService: service, observeUpdates: false)
|
|
await controller.refresh()
|
|
#expect(controller.status == .verificationFailed)
|
|
service.refreshFails = false
|
|
service.status = .purchased(.purchased)
|
|
await controller.refresh()
|
|
#expect(controller.hasProAccess)
|
|
service.refreshFails = true
|
|
await controller.refresh()
|
|
#expect(controller.hasProAccess)
|
|
}
|
|
|
|
private func resolve(_ info: CustomerInfo) -> EntitlementStatus {
|
|
RevenueCatEntitlementResolver.resolve(info, entitlementID: "termind_pro", now: now)
|
|
}
|
|
|
|
private func customerInfo(
|
|
entitlementID: String = "termind_pro", expiration: Date?, period: String = "normal",
|
|
ownership: String = "PURCHASED", cancelled: Bool = false, billingIssue: Bool = false,
|
|
grace: Date? = nil, refunded: Bool = false, verification: Int = 1
|
|
) throws -> CustomerInfo {
|
|
let formatter = ISO8601DateFormatter()
|
|
let purchase = formatter.string(from: now.addingTimeInterval(-3600))
|
|
let productID = expiration == nil ? "test_lifetime" : "test_annual"
|
|
var product: [String: Any] = [
|
|
"purchase_date": purchase, "original_purchase_date": purchase, "store": "app_store",
|
|
"is_sandbox": true, "period_type": period, "ownership_type": ownership,
|
|
]
|
|
product["expires_date"] = expiration.map { formatter.string(from: $0) }
|
|
product["unsubscribe_detected_at"] = cancelled ? purchase : nil
|
|
product["billing_issues_detected_at"] = billingIssue ? purchase : nil
|
|
product["grace_period_expires_date"] = grace.map { formatter.string(from: $0) }
|
|
product["refunded_at"] = refunded ? purchase : nil
|
|
var entitlement: [String: Any] = ["product_identifier": productID, "purchase_date": purchase]
|
|
entitlement["expires_date"] = (grace ?? expiration).map { formatter.string(from: $0) }
|
|
let data: [String: Any] = [
|
|
"request_date": formatter.string(from: now), "entitlementVerification": verification,
|
|
"subscriber": [
|
|
"original_app_user_id": "$RCAnonymousID:test", "first_seen": purchase,
|
|
"subscriptions": expiration == nil ? [:] : [productID: product],
|
|
"non_subscriptions": expiration == nil ? [productID: [product]] : [:],
|
|
"entitlements": [entitlementID: entitlement],
|
|
],
|
|
]
|
|
let decoder = JSONDecoder()
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
return try decoder.decode(CustomerInfo.self, from: JSONSerialization.data(withJSONObject: data))
|
|
}
|
|
|
|
private final class FakeService: ProPurchaseService {
|
|
let updates: AsyncStream<EntitlementStatus>
|
|
let continuation: AsyncStream<EntitlementStatus>.Continuation
|
|
var status: EntitlementStatus = .free
|
|
var refreshes = 0
|
|
var refreshFails = false
|
|
var suspendsRefresh = false
|
|
var refreshContinuation: CheckedContinuation<EntitlementStatus, Never>?
|
|
|
|
init() { (updates, continuation) = AsyncStream.makeStream() }
|
|
func currentStatus() async throws -> EntitlementStatus {
|
|
refreshes += 1
|
|
if refreshFails { throw URLError(.notConnectedToInternet) }
|
|
if suspendsRefresh {
|
|
return await withCheckedContinuation { refreshContinuation = $0 }
|
|
}
|
|
return status
|
|
}
|
|
}
|
|
}
|