Files
FamilyMealPlanner/MealMoodTests/PremiumSyncServiceTests.swift
T
alexandrev-tibco 110807d239 1.0.5: Eating out slots, rule violations panel, Crashlytics, search bar fix
- Eating out: tap any empty slot → "Mark as eating out"; shows teal indicator,
  skipped by auto-assign, counts as complete, tap again to unmark
- Rule violations panel: access via wand long-press context menu when conflicts exist;
  shows all isRuleOverridden slots with Fix (clear) or Ignore (acknowledge) actions
- Firebase Crashlytics integrated: CrashlyticsService + dSYM upload build phase,
  isPremium property tracked per session
- PremiumSyncService: extracted premium state machine, StoreKit Transaction.updates
  listener, isPremium no longer synced via iCloud to avoid stale state
- StoreManager: analytics on purchase/restore, bundle ID fallback for product ID lookup
- Search bar contrast bug fixed: TextField now has explicit foreground color for dark mode
- WelcomeStepView: redesigned onboarding welcome screen with week preview
- Version bump: 1.0.5 build 22

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:48:20 +02:00

170 lines
7.5 KiB
Swift

import XCTest
import SwiftData
@testable import MealMood
@MainActor
final class PremiumSyncServiceTests: XCTestCase {
var service: PremiumSyncService!
override func setUp() {
super.setUp()
service = PremiumSyncService()
}
// MARK: - Active subscription
func testSync_activeSubscription_whenFree_upgradesTo_premium() async {
service.hasActiveSubscription = { true }
let result = await service.sync(currentIsPremium: false, isColdLaunch: true)
XCTAssertTrue(result, "Active subscription should upgrade free user to premium")
}
func testSync_activeSubscription_whenAlreadyPremium_staysPremium() async {
service.hasActiveSubscription = { true }
let result = await service.sync(currentIsPremium: true, isColdLaunch: true)
XCTAssertTrue(result, "Active subscription keeps premium status")
}
func testSync_activeSubscription_onForeground_upgradesCorrectly() async {
service.hasActiveSubscription = { true }
let result = await service.sync(currentIsPremium: false, isColdLaunch: false)
XCTAssertTrue(result, "Active subscription upgrades on foreground activation")
}
// MARK: - No subscription, was free
func testSync_noSubscription_whenFree_coldLaunch_staysFree() async {
service.hasActiveSubscription = { false }
let result = await service.sync(currentIsPremium: false, isColdLaunch: true)
XCTAssertFalse(result, "No subscription and was free → stays free")
}
func testSync_noSubscription_whenFree_foreground_staysFree() async {
service.hasActiveSubscription = { false }
let result = await service.sync(currentIsPremium: false, isColdLaunch: false)
XCTAssertFalse(result, "No subscription and was free → stays free on foreground")
}
// MARK: - No subscription, was premium (expired or timing issue)
func testSync_noSubscription_whenPremium_coldLaunch_downgrades() async {
// Subscription expired between sessions downgrade on cold launch
service.hasActiveSubscription = { false }
let result = await service.sync(currentIsPremium: true, isColdLaunch: true)
XCTAssertFalse(result, "Expired subscription should downgrade on cold launch")
}
func testSync_noSubscription_whenPremium_foreground_keepsPremium() async {
// CRITICAL: StoreKit's Transaction.currentEntitlements can return empty for a
// few seconds immediately after a purchase completes. We must NOT downgrade
// mid-session let the Transaction.updates listener handle real revocations.
service.hasActiveSubscription = { false }
let result = await service.sync(currentIsPremium: true, isColdLaunch: false)
XCTAssertTrue(result, "Should not downgrade premium on foreground activation (StoreKit timing protection)")
}
// MARK: - Purchase flow simulation
func testPurchaseFlow_storeKitReturnsFalseRightAfterPurchase_premiumPreserved() async {
// Simulate the exact Apple Review rejection scenario:
// 1. User purchases successfully (isPremium = true is set by PremiumView)
// 2. StoreKit payment sheet closes scene becomes .active
// 3. syncPremiumStatus(isColdLaunch: false) runs
// 4. StoreKit returns false momentarily (receipt not yet propagated)
// 5. Expected: premium status is PRESERVED
service.hasActiveSubscription = { false }
let result = await service.sync(currentIsPremium: true, isColdLaunch: false)
XCTAssertTrue(result, "Premium must be preserved immediately after purchase even if StoreKit returns false")
}
func testPurchaseFlow_storeKitReturnsTrueAfterPurchase_premiumConfirmed() async {
// Normal happy path: purchase completes, StoreKit confirms
service.hasActiveSubscription = { true }
let result = await service.sync(currentIsPremium: false, isColdLaunch: false)
XCTAssertTrue(result, "Purchase confirmed by StoreKit → premium activated")
}
// MARK: - Multiple sync cycles
func testMultipleForegroundSyncs_withNoSubscription_afterPurchase_keepsPremium() async {
// User purchases goes to background 5 times StoreKit never confirms
// (extreme timing issue). Premium should be preserved in all foreground cycles.
service.hasActiveSubscription = { false }
var currentPremium = true // Set by purchase
for _ in 1...5 {
currentPremium = await service.sync(currentIsPremium: currentPremium, isColdLaunch: false)
}
XCTAssertTrue(currentPremium, "Premium must survive multiple foreground cycles when StoreKit timing fails")
}
func testColdLaunchAfterExpiry_downgrades() async {
// User had premium, subscription expired, reopens app next day
service.hasActiveSubscription = { false }
let result = await service.sync(currentIsPremium: true, isColdLaunch: true)
XCTAssertFalse(result, "Expired subscription must be detected and downgraded on next cold launch")
}
}
// MARK: - iCloud sync isolation tests
@MainActor
final class ICloudSyncPremiumIsolationTests: XCTestCase {
func testICloudSync_doesNotOverwritePremiumStatus() async throws {
// Arrange: in-memory SwiftData container
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: AppSettings.self, Tag.self, Dish.self, WeekPlan.self, MealSlot.self,
configurations: config
)
let context = container.mainContext
let settings = AppSettings()
settings.isPremium = true
context.insert(settings)
try context.save()
// Verify initial state
let before = try context.fetch(FetchDescriptor<AppSettings>()).first
XCTAssertTrue(before?.isPremium == true, "Setup: isPremium should be true")
// Act: simulate what ICloudSyncService.apply does it should NOT touch isPremium
// The fix removes isPremium from SettingsPayload, so apply() can never set it.
// We verify by checking that isPremium is unchanged after any settings update.
let settingsAfter = try context.fetch(FetchDescriptor<AppSettings>()).first
// Simulate a settings-only update (language change etc.) without touching isPremium
settingsAfter?.language = "es"
try context.save()
// Assert: isPremium untouched
let final = try context.fetch(FetchDescriptor<AppSettings>()).first
XCTAssertTrue(final?.isPremium == true, "isPremium must not be overwritten by settings sync operations")
XCTAssertEqual(final?.language, "es", "Other settings should still be updated")
}
func testPremiumStatus_persistsAcrossSettingsChanges() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: AppSettings.self, Tag.self, Dish.self, WeekPlan.self, MealSlot.self,
configurations: config
)
let context = container.mainContext
let settings = AppSettings()
settings.isPremium = true
context.insert(settings)
try context.save()
// Simulate multiple setting updates
let s = try context.fetch(FetchDescriptor<AppSettings>()).first!
s.includeWeekends = true
s.mealWindows = "dinnerOnly"
try context.save()
let result = try context.fetch(FetchDescriptor<AppSettings>()).first
XCTAssertTrue(result?.isPremium == true, "isPremium must persist through unrelated settings changes")
}
}