Phase 3 of the one-app pivot (DESIGN 12 ▸ The entitlement / Distribution, ruled 2026-07-30; card c3a3ddd5). New Kanban/Tier/: Tier (.free/.pro — deliberately no .lapsed case; unsubscribed and lapsed are one state) and the pure decision Tier.resolve(from:now:) over SubscriptionFacts (expiration + willAutoRenew), unit-tested through all five named states: free, active, lapsed, offline-grace, never-online. The facts are a persisted cache (standard defaults), not a live view: StoreKit ages an expired subscription out of currentEntitlements locally, so an offline device and a real lapse are indistinguishable from that property alone — the cache holds the last answer, empty entitlements read as silence, and holds end only on a definitive answer (revocation, or the subscription-group status read Settings performs). That is 12's offline-grace trade, resolved toward the paying user. ProEntitlement is the local adapter (currentEntitlements + Transaction.updates, started from launch, never from a test host); ProStorefront holds everything networked (product load, purchase, AppStore.sync) and only the Settings section ever constructs one — the split is the enforcement of "never network on the open path". beginSession reads the tier once at composition; BoardSession.tier is a let with no path back in, so a lapse never rebinds an open session. makeHistoryProvider now takes the tier; both tiers bind the native stack until pro-m1 builds the git provider — the seam's consumer is named, not invented early. Settings gains the Pro section (subscribe with localized price, manage, restore; a quiet unreachable line, no indefinite spinner) — the third of the exactly-three Pro mentions; the About line gains its "…in Settings" pointer now that there is a Settings to point at. A successful purchase or restore offers once to reopen open boards (close + reopen through the ordinary paths). Configuration.storekit wired into the scheme's run action for ASC-free exercise; RELEASE.md gains the pro-m1 store-side steps and the rule that the product must not be configured before then. 1901 tests in 319 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
224 lines
11 KiB
Swift
224 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|