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()).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()).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()).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()).first! s.includeWeekends = true s.mealWindows = "dinnerOnly" try context.save() let result = try context.fetch(FetchDescriptor()).first XCTAssertTrue(result?.isPremium == true, "isPremium must persist through unrelated settings changes") } }