import Foundation import Testing @testable import Kanban /// **The tier decision** (12-editions.md ▸ The entitlement). /// /// Every test here goes through `Tier.resolve(from:now:)` with facts and a date the test wrote down /// — no StoreKit, no App Store account, no clock. That is the point of the seam: the semantics 12 /// rules are semantics about *cached facts and a date*, and everything else in the Pro stack /// (`ProEntitlement`, `ProStorefront`) exists to produce those facts honestly. Pinning the decision /// here is what makes the adapter thin enough to read. /// /// The five states 12 names are the five suites' worth of cases below, in its own order: /// active, lapsed, offline-grace, never-online, and free — with free and never-online deliberately /// arriving at the same input, because 12 says nothing may distinguish them. @Suite("Tier ▸ the entitlement decision") struct TierDecisionTests { /// A fixed instant, so nothing here depends on when the suite runs. static let now = Date(timeIntervalSinceReferenceDate: 800_000_000) private static func resolve(_ facts: SubscriptionFacts) -> Tier { Tier.resolve(from: facts, now: now) } // MARK: - Free @Test("No cached transaction reads as the free tier") func freeTier() { #expect(Self.resolve(.none) == .free) } @Test("An auto-renew flag with no expiry behind it is still the free tier") func autoRenewWithoutAnExpiryIsNotAnEntitlement() { // The offline-grace hold is about an expiry that *passed*, never about the flag on its own. // A fact set that carries a renew flag and no period is not a subscription in any state. #expect(Self.resolve(SubscriptionFacts(expiration: nil, willAutoRenew: true)) == .free) } // MARK: - Active @Test("An expiry in the future is Pro") func activeSubscription() { let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 20), willAutoRenew: true) #expect(Self.resolve(facts) == .pro) } @Test("An active subscription the user has already cancelled is Pro until its expiry") func cancelledButStillInsideThePaidPeriod() { // "A cancellation (auto-renew off) lapses **at expiry**" — not when it is made. The paid // period is paid for. let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 3), willAutoRenew: false) #expect(Self.resolve(facts) == .pro) } @Test("Offline changes nothing about an active subscription") func offlineWithAnActiveSubscription() { // 12: "Offline with an active subscription is indistinguishable from online." Pinned by the // absence of a reachability parameter as much as by this assertion — there is nothing to // pass in that could make this answer differently. let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 400), willAutoRenew: true) #expect(Self.resolve(facts) == .pro) } // MARK: - Lapsed @Test("A cancellation whose expiry has passed is the free tier") func lapsedSubscription() { // 12: "A cancellation (auto-renew off) lapses at expiry, offline or not." let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60), willAutoRenew: false) #expect(Self.resolve(facts) == .free) } @Test("A long-lapsed cancellation is the same free tier as never having subscribed") func lapsedAndUnsubscribedAreOneState() { // "Unsubscribed and lapsed are **one state** — the inert posture, nothing lost, histories // frozen not forfeited." Nothing downstream may tell these two apart, and the decision is // where that starts: one answer, from two histories. let lapsed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 365), willAutoRenew: false) #expect(Self.resolve(lapsed) == Self.resolve(.none)) } // MARK: - Offline grace @Test("An expiry that passed with auto-renew still on holds the entitlement") func offlineGraceHoldsForThePayingUser() { // 12: "an on-disk expiry passing while offline, with the last known state *active and // auto-renew on*, holds the entitlement until StoreKit actually refreshes and answers." let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60), willAutoRenew: true) #expect(Self.resolve(facts) == .pro) } @Test("The offline-grace hold does not decay with time") func theHoldHasNoTimeout() { // Deliberately no expiry-of-the-expiry: the hold ends when StoreKit *answers* // (`ProEntitlement.adopt`), not when a timer this function knows nothing about runs out. // 12 weighs the cost and takes it: "a wrong hold gives away days of local commits — Apple's // own billing grace makes the same trade." let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 90), willAutoRenew: true) #expect(Self.resolve(facts) == .pro) } @Test("Auto-renew is the only thing separating a hold from a lapse") func theFlagIsTheWholeDifference() { let expired = Self.now.addingTimeInterval(-60 * 60 * 24) #expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: true)) == .pro) #expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: false)) == .free) } // MARK: - Never online @Test("A fresh install that has never been online reads as the free tier") func neverOnline() { // 12: "A fresh install that has never been online has no cached transactions and reads as // the free tier until the first refresh — honest and self-correcting." The input is // identical to `freeTier` above and that is the finding, not a duplicate: a device that has // not asked yet and an account that never subscribed are the same state, and the app has no // vocabulary for telling them apart. #expect(Self.resolve(.none) == .free) } @Test("The first refresh is what corrects a never-online install") func theFirstRefreshCorrectsIt() { // The self-correction, as the decision sees it: nothing about the free answer is sticky — // the same function over the facts a refresh produces answers Pro immediately. #expect(Self.resolve(.none) == .free) let refreshed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true) #expect(Self.resolve(refreshed) == .pro) } // MARK: - The boundary @Test("An expiry falling exactly on now is past") func theExpiryBoundaryIsExclusive() { // A StoreKit expiration date is the instant the period ends, not the last instant it covers. // With auto-renew off that means the lapse takes effect at the boundary rather than a tick // after it; with auto-renew on the grace hold catches it, which is the paying user's side. #expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: false)) == .free) #expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: true)) == .pro) } @Test("The decision is a pure function of its two arguments") func theSameInputsAlwaysAnswerTheSame() { let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-1), willAutoRenew: true) let answers = (0..<5).map { _ in Tier.resolve(from: facts, now: Self.now) } #expect(Set(answers).count == 1) // And a *different* date genuinely moves it — the clock is an argument, not decoration. let before = Tier.resolve(from: facts, now: Self.now.addingTimeInterval(-60)) #expect(before == .pro, "still inside the paid period") } } // MARK: - The cached facts /// The entitlement's cache, exercised through its injected `UserDefaults` — the same injection every /// other app-side store in this project takes (`AppStateHome`), and here it also keeps a suite from /// editing the developer's own subscription state. /// /// StoreKit itself is deliberately absent: `ProEntitlement.start()` and /// `refreshFromLocalTransactions()` are the only members that touch it, neither is called here, and /// what is left is exactly the seam worth pinning — that the facts round-trip, that the tier follows /// them, and that adopting `.none` ends a hold. @MainActor @Suite("Tier ▸ the entitlement's cache") struct ProEntitlementCacheTests { /// A defaults domain of this suite's own. Removed at the end of each test so the cases cannot /// see each other's writes. private static func makeDefaults() -> (UserDefaults, () -> Void) { let name = "dev.rzen.indie.Kanban.tier-tests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! return (defaults, { UserDefaults.standard.removePersistentDomain(forName: name) }) } @Test("A fresh domain has no facts, and the tier is free") func emptyCache() { let (defaults, tearDown) = Self.makeDefaults() defer { tearDown() } let entitlement = ProEntitlement(defaults: defaults) #expect(entitlement.facts == .none) #expect(entitlement.tier == .free) } @Test("Adopted facts survive into the next launch") func factsRoundTrip() { let (defaults, tearDown) = Self.makeDefaults() defer { tearDown() } let facts = SubscriptionFacts(expiration: Date().addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true) ProEntitlement(defaults: defaults).adopt(facts) // A second object over the same domain is what a relaunch is. let relaunched = ProEntitlement(defaults: defaults) #expect(relaunched.facts == facts) #expect(relaunched.tier == .pro) } @Test("Adopting nothing ends the entitlement — StoreKit's definitive answer") func adoptingNoneEndsAHold() { let (defaults, tearDown) = Self.makeDefaults() defer { tearDown() } let entitlement = ProEntitlement(defaults: defaults) // An offline-grace hold: the expiry passed, the last we heard it was renewing. entitlement.adopt(SubscriptionFacts(expiration: Date().addingTimeInterval(-60), willAutoRenew: true)) #expect(entitlement.tier == .pro) // …and then StoreKit answers (a revocation, or a group status reading expired). entitlement.adopt(.none) #expect(entitlement.tier == .free) #expect(ProEntitlement(defaults: defaults).tier == .free, "and the answer is cached, not just held") } @Test("A cache that will not decode is treated as no cache") func aCorruptCacheIsEmpty() { let (defaults, tearDown) = Self.makeDefaults() defer { tearDown() } defaults.set(Data("not json".utf8), forKey: AppPreferences.subscriptionFactsKey) #expect(ProEntitlement(defaults: defaults).tier == .free) } }