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>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user