diff --git a/docs/ios_greenfield_test_plan.csv b/docs/ios_greenfield_test_plan.csv index f0abf99..1a34996 100644 --- a/docs/ios_greenfield_test_plan.csv +++ b/docs/ios_greenfield_test_plan.csv @@ -100,7 +100,7 @@ "SUB-005","Subscription","Restore purchases","Restore on clean install/device migration","Manual + Integration","P0","iOS, Android","Existing prior subscription","Tap restore","Entitlements restored and backend synced","No previous purchases","Restore should not duplicate grants","Manual","" "SUB-006","Subscription","Purchase cancellation","User-cancelled purchase does not show fatal error","Manual","P1","iOS, Android","Open paywall","Start then cancel purchase","No entitlement changes; UX remains stable","Repeated cancel attempts","User cancel considered non-error","Manual","" "SUB-007","Subscription","Backend verification failure","Store purchase succeeds but backend verify fails","Manual","P0","iOS, Android","Force backend verify failure","Complete purchase","User sees recoverable state and can retry/restore","Receipt parsing mismatch","App should avoid false pro unlock","Manual","" -"SUB-008","Subscription","Feature gating","Pro-only features hidden/disabled for limited users","Manual + E2E","P0","iOS, Android, Web, Desktop","Test free and pro accounts","Traverse gated features (actions, limits, upgrades)","Gating consistent across surfaces","Cache stale after plan change","Gating uses subscription status + limitationsEnabled","Automate","" +"SUB-008","Subscription","Feature gating","Pro-only features hidden/disabled for limited users","Manual + E2E","P0","iOS, Android, Web, Desktop","Test free and pro accounts","Traverse gated features (actions, limits, upgrades)","Gating consistent across surfaces","Cache stale after plan change","Gating uses subscription status + limitationsEnabled","Automate","🟢 noSubscriptionTierIsFree | subscriptionWithExpiresAtTierIsPro | subscriptionWithEmptyExpiresAtTierIsFree | subscriptionWithNilExpiresAtTierIsFree | nilSubscriptionNeverBlocks | limitationsDisabledNeverBlocks | proTierNeverBlocks | freeTierUnderLimitAllowed | freeTierAtLimitBlocked | freeTierOverLimitBlocked | tasksLimitEnforced | contractorsLimitEnforced | documentsLimitEnforced | nilLimitMeansUnlimited | unknownLimitKeyReturnsFalse | noLimitsForTierReturnsFalse | deprecatedPromptFreeWithLimitations | deprecatedPromptFreeWithoutLimitations | deprecatedPromptNilSubscription | canShareResidenceWhenNoSubscription | canShareResidenceWhenLimitationsDisabled | cannotShareResidenceWhenFreeWithLimitations | canShareResidenceWhenProWithLimitations | canShareContractorWhenNoSubscription | canShareContractorWhenLimitationsDisabled | cannotShareContractorWhenFreeWithLimitations | canShareContractorWhenProWithLimitations (SubscriptionGatingTests)" "WID-001","Widgets","Small widget rendering","Small widget shows counts and opens app","Manual","P1","Android","Widget added, logged in","Place small widget and tap","Correct counts; tap opens app","No data cached yet","Widget reads from shared preferences state","Manual","" "WID-002","Widgets","Medium widget list","Medium widget shows top tasks and overdue badge","Manual","P1","Android","Tasks exist","Place medium widget","Task rows and overdue badge correct","Malformed tasks_json","JSON parse fallback to empty list","Manual","" "WID-003","Widgets","Large widget interactions","Large widget actions execute for pro users","Manual + Integration","P0","Android","Pro account, widget configured","Tap row and action controls","Opens task or executes action as expected","Free user should not see/execute pro actions","Widget passes task_id in intent","Manual","🟢 encodeDecodeRoundTrip | decodedValuesMatch | taskIdReturnsCorrectValue | taskTitleReturnsCorrectValue (WidgetActionTests)" diff --git a/iosApp/CaseraTests/SubscriptionGatingTests.swift b/iosApp/CaseraTests/SubscriptionGatingTests.swift new file mode 100644 index 0000000..bd314dc --- /dev/null +++ b/iosApp/CaseraTests/SubscriptionGatingTests.swift @@ -0,0 +1,252 @@ +// +// SubscriptionGatingTests.swift +// CaseraTests +// +// Unit tests for SubscriptionCacheWrapper feature gating logic: +// currentTier, shouldShowUpgradePrompt, canShareResidence, canShareContractor. +// +// Uses the shared singleton with serialized tests to avoid race conditions. +// + +import Testing +import Foundation +@testable import Casera +import ComposeApp + +// MARK: - Helpers + +/// Build a SubscriptionStatus with sensible defaults for testing. +private func makeSubscription( + expiresAt: String? = nil, + limitationsEnabled: Bool = false, + limits: [String: TierLimits] = [:] +) -> SubscriptionStatus { + SubscriptionStatus( + subscribedAt: nil, + expiresAt: expiresAt, + autoRenew: true, + usage: UsageStats(propertiesCount: 0, tasksCount: 0, contractorsCount: 0, documentsCount: 0), + limits: limits, + limitationsEnabled: limitationsEnabled + ) +} + +private let freeLimits = TierLimits( + properties: KotlinInt(int: 1), + tasks: KotlinInt(int: 10), + contractors: KotlinInt(int: 5), + documents: KotlinInt(int: 5) +) + +private let proLimits = TierLimits( + properties: nil, + tasks: nil, + contractors: nil, + documents: nil +) + +// MARK: - Serialized Suite (SubscriptionCacheWrapper is a shared singleton) + +@Suite(.serialized) +struct SubscriptionGatingTests { + + private let cache = SubscriptionCacheWrapper.shared + + // MARK: - currentTier Tests + + @Test func noSubscriptionTierIsFree() { + cache.currentSubscription = nil + #expect(cache.currentTier == "free") + } + + @Test func subscriptionWithExpiresAtTierIsPro() { + cache.currentSubscription = makeSubscription(expiresAt: "2030-01-01T00:00:00Z") + #expect(cache.currentTier == "pro") + } + + @Test func subscriptionWithEmptyExpiresAtTierIsFree() { + cache.currentSubscription = makeSubscription(expiresAt: "") + #expect(cache.currentTier == "free") + } + + @Test func subscriptionWithNilExpiresAtTierIsFree() { + cache.currentSubscription = makeSubscription(expiresAt: nil) + #expect(cache.currentTier == "free") + } + + // MARK: - shouldShowUpgradePrompt Tests + + @Test func nilSubscriptionNeverBlocks() { + cache.currentSubscription = nil + #expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "properties") == false) + } + + @Test func limitationsDisabledNeverBlocks() { + cache.currentSubscription = makeSubscription( + limitationsEnabled: false, + limits: ["free": freeLimits] + ) + #expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "properties") == false) + } + + @Test func proTierNeverBlocks() { + cache.currentSubscription = makeSubscription( + expiresAt: "2030-01-01T00:00:00Z", + limitationsEnabled: true, + limits: ["pro": proLimits] + ) + #expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "properties") == false) + } + + @Test func freeTierUnderLimitAllowed() { + cache.currentSubscription = makeSubscription( + limitationsEnabled: true, + limits: ["free": freeLimits] + ) + #expect(cache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "properties") == false) + } + + @Test func freeTierAtLimitBlocked() { + cache.currentSubscription = makeSubscription( + limitationsEnabled: true, + limits: ["free": freeLimits] + ) + // freeLimits.properties = 1, so count of 1 should block + #expect(cache.shouldShowUpgradePrompt(currentCount: 1, limitKey: "properties") == true) + } + + @Test func freeTierOverLimitBlocked() { + cache.currentSubscription = makeSubscription( + limitationsEnabled: true, + limits: ["free": freeLimits] + ) + #expect(cache.shouldShowUpgradePrompt(currentCount: 5, limitKey: "properties") == true) + } + + @Test func tasksLimitEnforced() { + cache.currentSubscription = makeSubscription( + limitationsEnabled: true, + limits: ["free": freeLimits] + ) + // freeLimits.tasks = 10 + #expect(cache.shouldShowUpgradePrompt(currentCount: 9, limitKey: "tasks") == false) + #expect(cache.shouldShowUpgradePrompt(currentCount: 10, limitKey: "tasks") == true) + } + + @Test func contractorsLimitEnforced() { + cache.currentSubscription = makeSubscription( + limitationsEnabled: true, + limits: ["free": freeLimits] + ) + // freeLimits.contractors = 5 + #expect(cache.shouldShowUpgradePrompt(currentCount: 4, limitKey: "contractors") == false) + #expect(cache.shouldShowUpgradePrompt(currentCount: 5, limitKey: "contractors") == true) + } + + @Test func documentsLimitEnforced() { + cache.currentSubscription = makeSubscription( + limitationsEnabled: true, + limits: ["free": freeLimits] + ) + // freeLimits.documents = 5 + #expect(cache.shouldShowUpgradePrompt(currentCount: 4, limitKey: "documents") == false) + #expect(cache.shouldShowUpgradePrompt(currentCount: 5, limitKey: "documents") == true) + } + + @Test func nilLimitMeansUnlimited() { + let unlimitedTasks = TierLimits( + properties: KotlinInt(int: 1), + tasks: nil, + contractors: nil, + documents: nil + ) + cache.currentSubscription = makeSubscription( + limitationsEnabled: true, + limits: ["free": unlimitedTasks] + ) + #expect(cache.shouldShowUpgradePrompt(currentCount: 10000, limitKey: "tasks") == false) + } + + @Test func unknownLimitKeyReturnsFalse() { + cache.currentSubscription = makeSubscription( + limitationsEnabled: true, + limits: ["free": freeLimits] + ) + #expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "unknown") == false) + } + + @Test func noLimitsForTierReturnsFalse() { + cache.currentSubscription = makeSubscription( + limitationsEnabled: true, + limits: [:] // no "free" key + ) + #expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "properties") == false) + } + + // MARK: - Deprecated shouldShowUpgradePrompt (computed property) + + @Test func deprecatedPromptFreeWithLimitations() { + cache.currentSubscription = makeSubscription(limitationsEnabled: true) + #expect(cache.shouldShowUpgradePrompt == true) + } + + @Test func deprecatedPromptFreeWithoutLimitations() { + cache.currentSubscription = makeSubscription(limitationsEnabled: false) + #expect(cache.shouldShowUpgradePrompt == false) + } + + @Test func deprecatedPromptNilSubscription() { + cache.currentSubscription = nil + #expect(cache.shouldShowUpgradePrompt == false) + } + + // MARK: - canShareResidence Tests + + @Test func canShareResidenceWhenNoSubscription() { + cache.currentSubscription = nil + #expect(cache.canShareResidence() == true) + } + + @Test func canShareResidenceWhenLimitationsDisabled() { + cache.currentSubscription = makeSubscription(limitationsEnabled: false) + #expect(cache.canShareResidence() == true) + } + + @Test func cannotShareResidenceWhenFreeWithLimitations() { + cache.currentSubscription = makeSubscription(limitationsEnabled: true) + #expect(cache.canShareResidence() == false) + } + + @Test func canShareResidenceWhenProWithLimitations() { + cache.currentSubscription = makeSubscription( + expiresAt: "2030-01-01T00:00:00Z", + limitationsEnabled: true + ) + #expect(cache.canShareResidence() == true) + } + + // MARK: - canShareContractor Tests + + @Test func canShareContractorWhenNoSubscription() { + cache.currentSubscription = nil + #expect(cache.canShareContractor() == true) + } + + @Test func canShareContractorWhenLimitationsDisabled() { + cache.currentSubscription = makeSubscription(limitationsEnabled: false) + #expect(cache.canShareContractor() == true) + } + + @Test func cannotShareContractorWhenFreeWithLimitations() { + cache.currentSubscription = makeSubscription(limitationsEnabled: true) + #expect(cache.canShareContractor() == false) + } + + @Test func canShareContractorWhenProWithLimitations() { + cache.currentSubscription = makeSubscription( + expiresAt: "2030-01-01T00:00:00Z", + limitationsEnabled: true + ) + #expect(cache.canShareContractor() == true) + } +}