diff --git a/CHANGELOG.md b/CHANGELOG.md index e1669d3f91..b47635af74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes - Fixes the SDK getting stuck in test mode when a sheet such as the Customer Center is already open as it finishes loading. +- Test mode no longer turns on in App Store builds because the app's bundle ID differs from the one on the Superwall dashboard. A wrong bundle ID on the dashboard used to put every customer in test mode, replacing their real subscription with simulated purchases. Development, simulator and TestFlight builds still enter test mode on a mismatch, and test users marked on the dashboard still get it in any build. +- Fixes the SDK waiting forever, with every `register` call held, when the test mode sheet was accepted for presentation but never reached the screen. +- Fixes subscribers staying in test mode on every launch after the dashboard setting that caused it had been corrected. The SDK no longer saves a config that turns test mode on, and no longer starts a launch from one saved by an earlier version. - Stops matching installs for attribution unless the Superwall MMP is turned on for your app. - Fixes paywalls on first launch missing acquisition attributes in their audience filters: they now wait up to 2 seconds for install attribution to finish. diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 8c71f0a7e6..c24ffcce2c 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -168,7 +168,7 @@ class ConfigManager { let startAt = Date() // Step 1: Determine fetch strategy based on subscription status and cached data - let cachedConfig = storage.get(LatestConfig.self) + let cachedConfig = cachedConfigForLaunch() let isSubscribed = hasActiveCachedSubscription() let shouldFetchAsync = cachedConfig != nil && isSubscribed @@ -447,11 +447,41 @@ class ConfigManager { ) } - /// Saves `config` and applies the parts that don't depend on purchases. - private func storeAndApply(_ config: Config) { + /// The config the previous launch saved, unless it would put this launch in + /// test mode. + /// + /// A subscriber launches from the saved config without waiting on the + /// network, and the refresh that replaces it is only scheduled once that + /// config has been processed. A saved config that turns test mode on would + /// therefore do so on every launch, even after the dashboard setting behind + /// it had been corrected. So test mode is only ever entered from a config + /// fetched during the current launch. Versions before 4.18.0 saved these + /// configs, which is why one can still be found here. + private func cachedConfigForLaunch() -> Config? { + guard let cachedConfig = storage.get(LatestConfig.self) else { + return nil + } + let testModeManager = factory.makeTestModeManager() + if testModeManager.isPutInTestMode(by: cachedConfig, options: options) { + storage.delete(LatestConfig.self) + return nil + } + return cachedConfig + } + + /// Applies the parts of `config` that don't depend on purchases, and saves + /// it for the next launch to start from when `keepingForNextLaunch` is true. + private func storeAndApply(_ config: Config, keepingForNextLaunch: Bool) { storage.save( config.featureFlags.disableVerbosePlacements, forType: DisableVerbosePlacements.self) - storage.save(config, forType: LatestConfig.self) + if keepingForNextLaunch { + storage.save(config, forType: LatestConfig.self) + } else { + // Also drops the config saved before this one. Starting the next launch + // from that would show paywalls with real products for a moment, before + // the refresh turned test mode back on. + storage.delete(LatestConfig.self) + } triggersByPlacementName = ConfigLogic.getTriggersByPlacementName(from: config.triggers) choosePaywallVariants(from: config.triggers) deviceHelper.startIPCollectionIfEnabled(for: config) @@ -467,12 +497,15 @@ class ConfigManager { isFirstTime: Bool, publishingEarlyFrom savedCustomerInfo: CustomerInfo? = nil ) async { - storeAndApply(config) + let testModeManager = factory.makeTestModeManager() // Evaluate test mode before loading products - let testModeManager = factory.makeTestModeManager() let wasTestMode = testModeManager.isTestMode - testModeManager.evaluateTestMode(config: config, options: options) + let testModeReason = testModeManager.evaluateTestMode(config: config, options: options) + storeAndApply( + config, + keepingForNextLaunch: testModeReason?.comesFromConfig != true + ) let testModeJustActivated = !wasTestMode && testModeManager.isTestMode let testModeJustDeactivated = wasTestMode && !testModeManager.isTestMode diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index aaf0277ef9..b31c967c66 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -85,7 +85,8 @@ public final class SuperwallOptions: NSObject, Encodable { @objc(SWKTestModeBehavior) public enum TestModeBehavior: Int, Encodable, CustomStringConvertible { /// Activates test mode when enabled for a user via the dashboard or when a bundle ID - /// mismatch is detected, but never during UI tests. + /// mismatch is detected, but never during UI tests. A bundle ID mismatch is ignored in + /// App Store builds, so a wrong bundle ID on the dashboard can't put customers in test mode. case automatic /// Activates test mode only when specifically enabled for a user via the dashboard. @@ -409,7 +410,8 @@ public final class SuperwallOptions: NSObject, Encodable { /// Controls when the SDK enters test mode. Defaults to `.automatic`. /// /// - `.automatic`: Activates test mode when enabled for a user via the dashboard or when - /// a bundle ID mismatch is detected, but never during UI tests. + /// a bundle ID mismatch is detected, but never during UI tests. A bundle ID mismatch is + /// ignored in App Store builds. /// - `.whenEnabledForUser`: Activates test mode only when specifically enabled for a /// user via the dashboard. /// - `.never`: Test mode is never activated, regardless of configuration. diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift index 316dbf9574..26d3ad5c5a 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift @@ -68,10 +68,46 @@ enum TestModeModal { message: "Couldn't show the test mode sheet, so using the saved test mode settings." ) modal.finish() + } else { + // That only catches a refusal UIKit makes on the spot. A sheet it + // accepted can still never reach the screen, and then nothing would + // ever close it, so stop waiting for that one too. + giveUpIfNeverShown(navController, modal: modal) } } } + /// How long an accepted sheet gets to start appearing. UIKit starts a + /// presentation on the next turn of the run loop, so this only passes for a + /// sheet that is never going to show. + static let noShowTimeout: TimeInterval = 3 + + /// Falls back to the saved settings if `navController` still hasn't started + /// appearing after `timeout`. Config waits on this sheet, so one that never + /// shows would otherwise leave every `register` call waiting for good. + static func giveUpIfNeverShown( + _ navController: TestModeNavigationController, + modal: TestModeModalViewController, + after timeout: TimeInterval = noShowTimeout + ) { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + if navController.hasStartedAppearing { + return + } + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "The test mode sheet never appeared, so using the saved test mode settings." + ) + // Take it back from UIKit so it can't turn up later with nothing listening. + if navController.presentingViewController != nil { + navController.dismiss(animated: false, completion: nil) + } + modal.finish() + } + } + /// Walks up from `viewController` to the screen on top, skipping any that /// are on their way out, so the modal can sit above sheets like the /// Customer Center. @@ -91,6 +127,14 @@ enum TestModeModal { final class TestModeNavigationController: UINavigationController { var onClose: (() -> Void)? + /// Set once UIKit starts putting the sheet on screen. + private(set) var hasStartedAppearing = false + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + hasStartedAppearing = true + } + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) // Still around if it's only covered by something presented on top. diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift index ab59ab08cd..be52c7222a 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManager.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift @@ -36,9 +36,23 @@ enum TestModeReason: Sendable { /// Test mode is always enabled via SuperwallOptions. case testModeOption - /// The app's bundle ID doesn't match the config's `bundleIds.ios`. + /// The app's bundle ID doesn't match the config's `bundleIds.ios`, in a + /// build that isn't from the App Store. case bundleIdMismatch(expected: String, actual: String) + /// Whether the config itself asked for test mode, as opposed to an option + /// the app set. Only a config can change between launches, so only these + /// reasons can come from a config saved by an earlier launch. + var comesFromConfig: Bool { + switch self { + case .configMatch, + .bundleIdMismatch: + return true + case .testModeOption: + return false + } + } + var description: String { switch self { case .configMatch: @@ -92,6 +106,10 @@ final class TestModeManager { return NSClassFromString("XCTestCase") != nil }() + /// Whether the app is running outside App Store production: simulator, + /// TestFlight or a development build. Replaced in tests. + var isSandboxEnvironment: () -> Bool = { DeviceHelper.isSandboxEnvironment } + unowned let identityManager: IdentityManager private unowned let deviceHelper: DeviceHelper private unowned let storage: Storage @@ -108,53 +126,68 @@ final class TestModeManager { /// Evaluates whether the current user should be in test mode based on the config /// and the `testModeBehavior` option. Called on every config refresh. - func evaluateTestMode(config: Config, options: SuperwallOptions) { - if DevMode.isActive(options) { - isTestMode = true - testModeReason = .testModeOption - return - } - switch options.testModeBehavior { - case .never: + /// + /// - Returns: The reason test mode is now on, or `nil` if it's off. + @discardableResult + func evaluateTestMode(config: Config, options: SuperwallOptions) -> TestModeReason? { + guard let reason = reasonForTestMode( + config: config, + options: options, + warnAboutIgnoredMismatch: true + ) else { isTestMode = false testModeReason = nil clearTestModeState() - return + return nil + } + isTestMode = true + testModeReason = reason + return reason + } - case .always: - isTestMode = true - testModeReason = .testModeOption - return + /// Why `config` and `options` would put the current user in test mode, or + /// `nil` if they wouldn't. Changes nothing, so a config can be checked before + /// it is used. + func reasonForTestMode(config: Config, options: SuperwallOptions) -> TestModeReason? { + return reasonForTestMode(config: config, options: options, warnAboutIgnoredMismatch: false) + } + private func reasonForTestMode( + config: Config, + options: SuperwallOptions, + warnAboutIgnoredMismatch: Bool + ) -> TestModeReason? { + if DevMode.isActive(options) { + return .testModeOption + } + switch options.testModeBehavior { + case .never: + return nil + case .always: + return .testModeOption case .whenEnabledForUser: // Only check user ID match, skip bundle ID check - if checkConfigMatch(config: config) { return } - isTestMode = false - testModeReason = nil - clearTestModeState() - return - + return configMatchReason(config: config) case .automatic: // Skip entirely if in UI tests if Self.isTestEnvironment { - isTestMode = false - testModeReason = nil - clearTestModeState() - return + return nil } // Check user match, then bundle ID mismatch - if checkConfigMatch(config: config) { return } - if checkBundleIdMismatch(config: config) { return } - isTestMode = false - testModeReason = nil - clearTestModeState() - return + return configMatchReason(config: config) + ?? bundleIdMismatchReason(config: config, warnIfIgnored: warnAboutIgnoredMismatch) } } - /// Checks if the current user's ID or alias matches any test store user in the config. - /// Returns `true` and activates test mode if a match is found. - private func checkConfigMatch(config: Config) -> Bool { + /// Whether `config` itself would put the current user in test mode. Such a + /// config must never be the one a launch starts from: see + /// `ConfigManager.cachedConfigForLaunch()`. + func isPutInTestMode(by config: Config, options: SuperwallOptions) -> Bool { + return reasonForTestMode(config: config, options: options)?.comesFromConfig == true + } + + /// The reason if the current user's ID or alias matches any test store user in the config. + private func configMatchReason(config: Config) -> TestModeReason? { let testModeUserIds = config.testModeUserIds ?? [] let aliasId = identityManager.aliasId let appUserId = identityManager.appUserId @@ -163,35 +196,52 @@ final class TestModeManager { switch testUser.type { case .userId: if let appUserId, appUserId == testUser.value { - isTestMode = true - testModeReason = .configMatch - return true + return .configMatch } case .aliasId: if aliasId == testUser.value { - isTestMode = true - testModeReason = .configMatch - return true + return .configMatch } } } - return false + return nil } - /// Checks if the app's bundle ID differs from the config's expected bundle ID. - /// Returns `true` and activates test mode if a mismatch is found. + /// The reason if the app's bundle ID differs from the config's expected bundle ID. /// App extensions are allowed because their bundle ID uses the main app's /// bundle ID as a prefix (e.g., `com.example.app.widget-extension`). - private func checkBundleIdMismatch(config: Config) -> Bool { - if let expectedBundleId = config.bundleIdConfig, + /// + /// - Parameter warnIfIgnored: Whether to log when an App Store build ignores + /// the mismatch. Only set when the config is being applied, so the warning + /// shows once per config rather than on every check. + private func bundleIdMismatchReason(config: Config, warnIfIgnored: Bool) -> TestModeReason? { + guard + let expectedBundleId = config.bundleIdConfig, let actualBundleId = Bundle.main.bundleIdentifier, expectedBundleId != actualBundleId, - !actualBundleId.hasPrefix(expectedBundleId + ".") { - isTestMode = true - testModeReason = .bundleIdMismatch(expected: expectedBundleId, actual: actualBundleId) - return true + !actualBundleId.hasPrefix(expectedBundleId + ".") + else { + return nil + } + // A mismatch is how a developer's own build gets into test mode without + // any setup. An App Store install is a customer's, and the mismatch there + // means the bundle ID on the dashboard was edited, which must not replace + // their real purchases with simulated ones. Test users marked on the + // dashboard still get test mode in an App Store build. + guard isSandboxEnvironment() else { + if !warnIfIgnored { + return nil + } + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "The app's bundle ID is \(actualBundleId) but the Superwall dashboard expects " + + "\(expectedBundleId). Ignoring the mismatch because this is an App Store build, " + + "so test mode stays off. Correct the bundle ID in the dashboard's app settings." + ) + return nil } - return false + return .bundleIdMismatch(expected: expectedBundleId, actual: actualBundleId) } /// Clears all test mode state including entitlements, products, diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift index 710b236052..72fea63efa 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift @@ -596,4 +596,141 @@ struct ConfigManagerTests { // Wait for background tasks to complete before test ends try? await Task.sleep(nanoseconds: UInt64(0.2 * 1_000_000_000)) } + + // MARK: - Configs that turn test mode on + + /// A config the dashboard would send while the app's bundle ID is set wrongly. + private func testModeConfig(buildId: String) -> Config { + return Config.stub() + .setting(\.buildId, to: buildId) + .setting(\.featureFlags, to: .stub()) + .setting(\.testModeUserIds, to: []) + .setting(\.bundleIdConfig, to: "com.some.other.bundle") + } + + private func makeConfigManager( + dependencyContainer: DependencyContainer, + storage: StorageMock, + network: NetworkMock + ) -> (ConfigManager, DeviceHelperMock) { + let deviceHelper = DeviceHelperMock( + api: dependencyContainer.api, + storage: storage, + network: network, + entitlementsInfo: dependencyContainer.entitlementsInfo, + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer + ) + let configManager = ConfigManager( + options: SuperwallOptions(), + storeKitManager: dependencyContainer.storeKitManager, + storage: storage, + network: network, + paywallManager: dependencyContainer.paywallManager, + deviceHelper: deviceHelper, + entitlementsInfo: dependencyContainer.entitlementsInfo, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + factory: dependencyContainer + ) + return (configManager, deviceHelper) + } + + @Test + func fetchConfiguration_subscriberDoesNotLaunchFromASavedConfigThatTurnsTestModeOn() async throws { + // Given: a subscriber whose previous launch saved a config that turns + // test mode on, and a dashboard that has since been corrected. + let storage = StorageMock() + let dependencyContainer = DependencyContainer() + let network = NetworkMock( + options: SuperwallOptions(), + factory: dependencyContainer + ) + storage.save(testModeConfig(buildId: "cached_123"), forType: LatestConfig.self) + storage.save(SubscriptionStatus.active([.stub()]), forType: SubscriptionStatusKey.self) + + let newConfig: Config = .stub() + .setting(\.buildId, to: "fresh_456") + network.configReturnValue = .success(newConfig) + + let (configManager, deviceHelper) = makeConfigManager( + dependencyContainer: dependencyContainer, + storage: storage, + network: network + ) + + // When + await configManager.fetchConfiguration() + + // Then: the saved config is thrown away and the launch waits for a fresh one. + #expect(network.getConfigCalled, "Should fetch a fresh config instead of using the saved one") + #expect(configManager.config?.buildId == "fresh_456") + #expect(storage.get(LatestConfig.self)?.buildId == "fresh_456") + #expect(dependencyContainer.testModeManager.isTestMode == false) + + _ = deviceHelper + try await Task.sleep(nanoseconds: UInt64(0.3 * 1_000_000_000)) + } + + @Test + func fetchConfiguration_doesNotSaveAConfigThatTurnsTestModeOn() async throws { + // Given: an ordinary saved config, and a dashboard that now sends one + // that turns test mode on. + let storage = StorageMock() + let dependencyContainer = DependencyContainer() + let network = NetworkMock( + options: SuperwallOptions(), + factory: dependencyContainer + ) + let cachedConfig: Config = .stub() + .setting(\.buildId, to: "cached_123") + .setting(\.featureFlags, to: .stub()) + storage.save(cachedConfig, forType: LatestConfig.self) + storage.save(SubscriptionStatus.inactive, forType: SubscriptionStatusKey.self) + network.configReturnValue = .success(testModeConfig(buildId: "testmode_789")) + + let (configManager, deviceHelper) = makeConfigManager( + dependencyContainer: dependencyContainer, + storage: storage, + network: network + ) + + // When + await configManager.fetchConfiguration() + + // Then: test mode is on for this launch, and the next launch has no saved + // config to start from, so it fetches one. + #expect(dependencyContainer.testModeManager.isTestMode == true) + #expect(configManager.config?.buildId == "testmode_789") + #expect(storage.get(LatestConfig.self) == nil) + + _ = deviceHelper + try await Task.sleep(nanoseconds: UInt64(0.3 * 1_000_000_000)) + } + + @Test + func fetchConfiguration_savesAnOrdinaryConfig() async throws { + let storage = StorageMock() + let dependencyContainer = DependencyContainer() + let network = NetworkMock( + options: SuperwallOptions(), + factory: dependencyContainer + ) + storage.save(SubscriptionStatus.inactive, forType: SubscriptionStatusKey.self) + let newConfig: Config = .stub() + .setting(\.buildId, to: "fresh_456") + network.configReturnValue = .success(newConfig) + + let (configManager, deviceHelper) = makeConfigManager( + dependencyContainer: dependencyContainer, + storage: storage, + network: network + ) + + await configManager.fetchConfiguration() + + #expect(storage.get(LatestConfig.self)?.buildId == "fresh_456") + + _ = deviceHelper + try await Task.sleep(nanoseconds: UInt64(0.3 * 1_000_000_000)) + } } diff --git a/Tests/SuperwallKitTests/Network/NetworkMock.swift b/Tests/SuperwallKitTests/Network/NetworkMock.swift index efb72aed35..0f3aaf3d7e 100644 --- a/Tests/SuperwallKitTests/Network/NetworkMock.swift +++ b/Tests/SuperwallKitTests/Network/NetworkMock.swift @@ -55,6 +55,10 @@ final class NetworkMock: Network { } } + override func getSuperwallProducts() async throws -> SuperwallProductsResponse { + return SuperwallProductsResponse(data: []) + } + override func confirmAssignment(_ assignment: Assignment) async -> Assignment { assignmentsConfirmed = true assignment.markAsSent() diff --git a/Tests/SuperwallKitTests/Storage/StorageMock.swift b/Tests/SuperwallKitTests/Storage/StorageMock.swift index 148fbed172..448ff1e50f 100644 --- a/Tests/SuperwallKitTests/Storage/StorageMock.swift +++ b/Tests/SuperwallKitTests/Storage/StorageMock.swift @@ -99,6 +99,20 @@ final class StorageMock: Storage { internalConfirmedAssignments?.update(with: newAssignment) } + override func delete(_ keyType: Key.Type) where Key : Storable { + if keyType == LatestConfig.self { + internalCachedConfig = nil + } + super.delete(keyType) + } + + override func delete(_ keyType: Key.Type) where Key : Storable, Key.Value : Encodable { + if keyType == LatestConfig.self { + internalCachedConfig = nil + } + super.delete(keyType) + } + override func save(_ value: Key.Value, forType keyType: Key.Type) where Key : Storable { if keyType == LatestConfig.self { internalCachedConfig = value as? Config diff --git a/Tests/SuperwallKitTests/TestMode/TestModeManagerTests.swift b/Tests/SuperwallKitTests/TestMode/TestModeManagerTests.swift index 496cded652..b40ad720de 100644 --- a/Tests/SuperwallKitTests/TestMode/TestModeManagerTests.swift +++ b/Tests/SuperwallKitTests/TestMode/TestModeManagerTests.swift @@ -320,4 +320,119 @@ struct TestModeManagerTests { #expect(Bool(false), "Expected .bundleIdMismatch reason, got \(String(describing: manager.testModeReason))") } } + + // MARK: - Checking a config without applying it + + @Test + func reasonForTestMode_changesNothing() { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.testModeManager! + + let config = Config.stub() + .setting(\.testModeUserIds, to: []) + .setting(\.bundleIdConfig, to: "com.some.other.bundle") + + let reason = manager.reasonForTestMode(config: config, options: SuperwallOptions()) + + if case .bundleIdMismatch = reason { + // Expected + } else { + #expect(Bool(false), "Expected .bundleIdMismatch reason, got \(String(describing: reason))") + } + #expect(manager.isTestMode == false) + #expect(manager.testModeReason == nil) + } + + @Test + func isPutInTestMode_byBundleIdMismatch() { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.testModeManager! + + let config = Config.stub() + .setting(\.testModeUserIds, to: []) + .setting(\.bundleIdConfig, to: "com.some.other.bundle") + + #expect(manager.isPutInTestMode(by: config, options: SuperwallOptions()) == true) + } + + @Test + func isPutInTestMode_byTestUser() { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.testModeManager! + + let aliasId = dependencyContainer.identityManager.aliasId + let config = Config.stub() + .setting(\.testModeUserIds, to: [ + TestStoreUser(type: .aliasId, value: aliasId) + ]) + + #expect(manager.isPutInTestMode(by: config, options: SuperwallOptions()) == true) + } + + @Test + func isPutInTestMode_isFalseWhenTheAppAskedForTestMode() { + // The option turns test mode on whatever the config says, so the config + // is safe to save and to start a launch from. + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.testModeManager! + + let options = SuperwallOptions() + options.testModeBehavior = .always + + #expect(manager.reasonForTestMode(config: .stub(), options: options) != nil) + #expect(manager.isPutInTestMode(by: .stub(), options: options) == false) + } + + @Test + func isPutInTestMode_isFalseForAnOrdinaryConfig() { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.testModeManager! + + let config = Config.stub() + .setting(\.testModeUserIds, to: []) + .setting(\.bundleIdConfig, to: Bundle.main.bundleIdentifier) + + #expect(manager.isPutInTestMode(by: config, options: SuperwallOptions()) == false) + } + + // MARK: - App Store builds + + @Test + func testModeBehavior_automatic_ignoresBundleIdMismatchInAnAppStoreBuild() { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.testModeManager! + manager.isSandboxEnvironment = { false } + + let config = Config.stub() + .setting(\.testModeUserIds, to: []) + .setting(\.bundleIdConfig, to: "com.some.other.bundle") + + manager.evaluateTestMode(config: config, options: SuperwallOptions()) + + #expect(manager.isTestMode == false) + #expect(manager.testModeReason == nil) + } + + @Test + func testModeBehavior_automatic_stillActivatesForATestUserInAnAppStoreBuild() { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.testModeManager! + manager.isSandboxEnvironment = { false } + + let aliasId = dependencyContainer.identityManager.aliasId + let config = Config.stub() + .setting(\.testModeUserIds, to: [ + TestStoreUser(type: .aliasId, value: aliasId) + ]) + .setting(\.bundleIdConfig, to: "com.some.other.bundle") + + manager.evaluateTestMode(config: config, options: SuperwallOptions()) + + #expect(manager.isTestMode == true) + if case .configMatch = manager.testModeReason { + // Expected + } else { + #expect(Bool(false), "Expected .configMatch reason, got \(String(describing: manager.testModeReason))") + } + } } diff --git a/Tests/SuperwallKitTests/TestMode/TestModeModalTests.swift b/Tests/SuperwallKitTests/TestMode/TestModeModalTests.swift index fc7004b170..4e8ea1a962 100644 --- a/Tests/SuperwallKitTests/TestMode/TestModeModalTests.swift +++ b/Tests/SuperwallKitTests/TestMode/TestModeModalTests.swift @@ -155,6 +155,37 @@ struct TestModeModalTests { #expect(callCount == 1) } + @Test + func giveUpIfNeverShown_reportsBackOnce_whenTheSheetNeverAppears() async throws { + let modal = makeModal() + let nav = TestModeNavigationController(rootViewController: modal) + var callCount = 0 + modal.onDismiss = { _, _ in + callCount += 1 + } + + TestModeModal.giveUpIfNeverShown(nav, modal: modal, after: 0) + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(callCount == 1) + } + + @Test + func giveUpIfNeverShown_keepsWaiting_whenTheSheetStartedAppearing() async throws { + let modal = makeModal() + let nav = TestModeNavigationController(rootViewController: modal) + var callCount = 0 + modal.onDismiss = { _, _ in + callCount += 1 + } + + nav.viewWillAppear(false) + TestModeModal.giveUpIfNeverShown(nav, modal: modal, after: 0) + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(callCount == 0) + } + @Test func modal_closeWithoutOK_savesAndReportsBack() { clearSavedSettings()